diff --git a/apps/manadeck/.github/workflows/deploy-backend.yml b/apps/cards/.github/workflows/deploy-backend.yml similarity index 100% rename from apps/manadeck/.github/workflows/deploy-backend.yml rename to apps/cards/.github/workflows/deploy-backend.yml diff --git a/apps/manadeck/.gitignore b/apps/cards/.gitignore similarity index 100% rename from apps/manadeck/.gitignore rename to apps/cards/.gitignore diff --git a/apps/manadeck/CI_CD_SETUP_GUIDE.md b/apps/cards/CI_CD_SETUP_GUIDE.md similarity index 100% rename from apps/manadeck/CI_CD_SETUP_GUIDE.md rename to apps/cards/CI_CD_SETUP_GUIDE.md diff --git a/apps/manadeck/CREDIT_SYSTEM.md b/apps/cards/CREDIT_SYSTEM.md similarity index 100% rename from apps/manadeck/CREDIT_SYSTEM.md rename to apps/cards/CREDIT_SYSTEM.md diff --git a/apps/manadeck/DEPLOYMENT_CHECKLIST.md b/apps/cards/DEPLOYMENT_CHECKLIST.md similarity index 100% rename from apps/manadeck/DEPLOYMENT_CHECKLIST.md rename to apps/cards/DEPLOYMENT_CHECKLIST.md diff --git a/apps/manadeck/EDGE_FUNCTION_FIX.md b/apps/cards/EDGE_FUNCTION_FIX.md similarity index 100% rename from apps/manadeck/EDGE_FUNCTION_FIX.md rename to apps/cards/EDGE_FUNCTION_FIX.md diff --git a/apps/manadeck/MANA_CORE_ARCHITECTURE.md b/apps/cards/MANA_CORE_ARCHITECTURE.md similarity index 100% rename from apps/manadeck/MANA_CORE_ARCHITECTURE.md rename to apps/cards/MANA_CORE_ARCHITECTURE.md diff --git a/apps/manadeck/MANA_CORE_INTEGRATION_CHECKLIST.md b/apps/cards/MANA_CORE_INTEGRATION_CHECKLIST.md similarity index 74% rename from apps/manadeck/MANA_CORE_INTEGRATION_CHECKLIST.md rename to apps/cards/MANA_CORE_INTEGRATION_CHECKLIST.md index 2e03a49b7..00780cb20 100644 --- a/apps/manadeck/MANA_CORE_INTEGRATION_CHECKLIST.md +++ b/apps/cards/MANA_CORE_INTEGRATION_CHECKLIST.md @@ -19,6 +19,7 @@ Use this checklist when integrating `@mana-core/nestjs-integration` into a new N ### 1. Installation - [ ] Install the package: + ```bash npm install git+https://github.com/Memo-2023/mana-core-nestjs-package.git ``` @@ -31,6 +32,7 @@ Use this checklist when integrating `@mana-core/nestjs-integration` into a new N ### 2. Environment Configuration - [ ] Create/update `.env` file: + ```env MANA_SERVICE_URL=https://your-mana-instance.com APP_ID=your-app-id @@ -47,17 +49,18 @@ Use this checklist when integrating `@mana-core/nestjs-integration` into a new N - [ ] Import `ManaCoreModule` in `app.module.ts` - [ ] Configure with `forRootAsync()`: + ```typescript ManaCoreModule.forRootAsync({ - imports: [ConfigModule], - useFactory: (configService: ConfigService) => ({ - manaServiceUrl: 'your-mana-url', - appId: 'your-app-id', - serviceKey: configService.get('MANA_SUPABASE_SECRET_KEY'), - debug: configService.get('NODE_ENV') === 'development', - }), - inject: [ConfigService], - }) + imports: [ConfigModule], + useFactory: (configService: ConfigService) => ({ + manaServiceUrl: 'your-mana-url', + appId: 'your-app-id', + serviceKey: configService.get('MANA_SUPABASE_SECRET_KEY'), + debug: configService.get('NODE_ENV') === 'development', + }), + inject: [ConfigService], + }); ``` - [ ] Test backend starts without errors @@ -65,11 +68,13 @@ Use this checklist when integrating `@mana-core/nestjs-integration` into a new N ### 4. Protect Routes with AuthGuard - [ ] Import `AuthGuard` in controller: + ```typescript import { AuthGuard } from '@mana-core/nestjs-integration'; ``` - [ ] Apply to controller or route: + ```typescript @Controller('protected') @UseGuards(AuthGuard) @@ -81,11 +86,13 @@ Use this checklist when integrating `@mana-core/nestjs-integration` into a new N ### 5. Extract User Information - [ ] Import `@CurrentUser()` decorator: + ```typescript import { CurrentUser } from '@mana-core/nestjs-integration'; ``` - [ ] Use in route handlers: + ```typescript @Get('profile') async getProfile(@CurrentUser() user: JwtPayload) { @@ -98,31 +105,31 @@ Use this checklist when integrating `@mana-core/nestjs-integration` into a new N ### 6. Integrate Credit System - [ ] Inject `CreditClientService`: + ```typescript constructor(private creditClient: CreditClientService) {} ``` - [ ] Add pre-flight credit validation: + ```typescript - const validation = await this.creditClient.validateCredits( - userId, - 'operation_type', - creditCost, - ); + const validation = await this.creditClient.validateCredits(userId, 'operation_type', creditCost); ``` - [ ] Add credit consumption after success: + ```typescript await this.creditClient.consumeCredits( - userId, - 'operation_type', - creditCost, - 'Description', - metadata, + userId, + 'operation_type', + creditCost, + 'Description', + metadata ); ``` - [ ] Handle `InsufficientCreditsException`: + ```typescript import { InsufficientCreditsException } from '@mana-core/nestjs-integration'; ``` @@ -132,18 +139,17 @@ Use this checklist when integrating `@mana-core/nestjs-integration` into a new N ### 7. (Optional) Custom Token Decorator - [ ] Create `@UserToken()` decorator for RLS: + ```typescript // decorators/user.decorator.ts - export const UserToken = createParamDecorator( - (_data: unknown, ctx: ExecutionContext): string => { - const request = ctx.switchToHttp().getRequest(); - const authHeader = request.headers.authorization; - if (authHeader?.startsWith('Bearer ')) { - return authHeader.substring(7); - } - return request.token; - }, - ); + export const UserToken = createParamDecorator((_data: unknown, ctx: ExecutionContext): string => { + const request = ctx.switchToHttp().getRequest(); + const authHeader = request.headers.authorization; + if (authHeader?.startsWith('Bearer ')) { + return authHeader.substring(7); + } + return request.token; + }); ``` - [ ] Use for database RLS: @@ -164,6 +170,7 @@ Use this checklist when integrating `@mana-core/nestjs-integration` into a new N ### 1. Configure API Base URL - [ ] Create `.env` file: + ```env EXPO_PUBLIC_STORYTELLER_BACKEND_URL=http://localhost:3002 ``` @@ -178,17 +185,18 @@ Use this checklist when integrating `@mana-core/nestjs-integration` into a new N - [ ] Create `services/authService.ts` - [ ] Implement sign-in: + ```typescript signIn: async (email: string, password: string) => { - const deviceInfo = await getDeviceInfo(); - const response = await fetch(`${BACKEND_URL}/auth/signin`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ email, password, deviceInfo }), - }); - const data = await response.json(); - await storeTokens(data.appToken, data.refreshToken); - } + const deviceInfo = await getDeviceInfo(); + const response = await fetch(`${BACKEND_URL}/auth/signin`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, password, deviceInfo }), + }); + const data = await response.json(); + await storeTokens(data.appToken, data.refreshToken); + }; ``` - [ ] Implement sign-up @@ -202,29 +210,31 @@ Use this checklist when integrating `@mana-core/nestjs-integration` into a new N - [ ] Create `services/tokenManager.ts` - [ ] Implement `getValidToken()`: + ```typescript getValidToken: async () => { - let token = await storage.getItem('appToken'); - if (isExpiringSoon(token)) { - await this.refreshToken(); - token = await storage.getItem('appToken'); - } - return token; - } + let token = await storage.getItem('appToken'); + if (isExpiringSoon(token)) { + await this.refreshToken(); + token = await storage.getItem('appToken'); + } + return token; + }; ``` - [ ] Implement `refreshToken()`: + ```typescript refreshToken: async () => { - const refreshToken = await storage.getItem('refreshToken'); - const deviceInfo = await getDeviceInfo(); - const response = await fetch(`${BACKEND_URL}/auth/refresh`, { - method: 'POST', - body: JSON.stringify({ refreshToken, deviceInfo }), - }); - const data = await response.json(); - await storeTokens(data.appToken, data.refreshToken); - } + const refreshToken = await storage.getItem('refreshToken'); + const deviceInfo = await getDeviceInfo(); + const response = await fetch(`${BACKEND_URL}/auth/refresh`, { + method: 'POST', + body: JSON.stringify({ refreshToken, deviceInfo }), + }); + const data = await response.json(); + await storeTokens(data.appToken, data.refreshToken); + }; ``` - [ ] Test: Verify automatic refresh works @@ -232,23 +242,24 @@ Use this checklist when integrating `@mana-core/nestjs-integration` into a new N ### 4. Create Authenticated API Client - [ ] Create `fetchWithAuth()` function: + ```typescript export async function fetchWithAuth(endpoint: string, options = {}) { - const token = await tokenManager.getValidToken(); - const response = await fetch(`${API_BASE_URL}${endpoint}`, { - ...options, - headers: { - ...options.headers, - 'Authorization': `Bearer ${token}`, - }, - }); + const token = await tokenManager.getValidToken(); + const response = await fetch(`${API_BASE_URL}${endpoint}`, { + ...options, + headers: { + ...options.headers, + Authorization: `Bearer ${token}`, + }, + }); - if (response.status === 401) { - await tokenManager.refreshToken(); - // Retry request - } + if (response.status === 401) { + await tokenManager.refreshToken(); + // Retry request + } - return response; + return response; } ``` @@ -257,19 +268,21 @@ Use this checklist when integrating `@mana-core/nestjs-integration` into a new N ### 5. Handle Credit Errors - [ ] Create error handling utility: + ```typescript export function isInsufficientCreditsError(error: any) { - return error?.error === 'insufficient_credits'; + return error?.error === 'insufficient_credits'; } ``` - [ ] Handle in UI: + ```typescript if (data.error === 'insufficient_credits') { - showPurchaseCreditsModal({ - required: data.requiredCredits, - available: data.availableCredits, - }); + showPurchaseCreditsModal({ + required: data.requiredCredits, + available: data.availableCredits, + }); } ``` @@ -278,16 +291,17 @@ Use this checklist when integrating `@mana-core/nestjs-integration` into a new N ### 6. Device Management - [ ] Create `utils/deviceManager.ts`: + ```typescript export class DeviceManager { - static async getDeviceInfo() { - return { - deviceId: await getOrCreateDeviceId(), - deviceName: Platform.OS, - deviceType: Platform.OS as 'ios' | 'android' | 'web', - userAgent: getUserAgent(), - }; - } + static async getDeviceInfo() { + return { + deviceId: await getOrCreateDeviceId(), + deviceName: Platform.OS, + deviceType: Platform.OS as 'ios' | 'android' | 'web', + userAgent: getUserAgent(), + }; + } } ``` @@ -302,6 +316,7 @@ Use this checklist when integrating `@mana-core/nestjs-integration` into a new N ### Backend Tests - [ ] Create unit tests with mocked services: + ```typescript { provide: CreditClientService, @@ -341,6 +356,7 @@ Use this checklist when integrating `@mana-core/nestjs-integration` into a new N ### Backend - [ ] Set production environment variables: + ```env MANA_SERVICE_URL=https://production-mana.com APP_ID=production-app-id @@ -357,6 +373,7 @@ Use this checklist when integrating `@mana-core/nestjs-integration` into a new N ### Frontend - [ ] Update `.env` for production: + ```env EXPO_PUBLIC_BACKEND_URL=https://your-api.com ``` @@ -439,6 +456,7 @@ Once all items are checked, your application is fully integrated with Mana Core. **Estimated Time**: 2-4 hours for basic integration, 1-2 days for complete implementation with testing. **Next Steps**: + 1. Define your operation types and credit costs 2. Implement purchase flow for credits 3. Add analytics and monitoring diff --git a/apps/manadeck/MANA_CORE_INTEGRATION_GUIDE.md b/apps/cards/MANA_CORE_INTEGRATION_GUIDE.md similarity index 100% rename from apps/manadeck/MANA_CORE_INTEGRATION_GUIDE.md rename to apps/cards/MANA_CORE_INTEGRATION_GUIDE.md diff --git a/apps/manadeck/MANA_CORE_README.md b/apps/cards/MANA_CORE_README.md similarity index 100% rename from apps/manadeck/MANA_CORE_README.md rename to apps/cards/MANA_CORE_README.md diff --git a/apps/manadeck/README.md b/apps/cards/README.md similarity index 100% rename from apps/manadeck/README.md rename to apps/cards/README.md diff --git a/apps/manadeck/SETUP_GUIDE.md b/apps/cards/SETUP_GUIDE.md similarity index 100% rename from apps/manadeck/SETUP_GUIDE.md rename to apps/cards/SETUP_GUIDE.md diff --git a/apps/manadeck/apps/landing/astro.config.mjs b/apps/cards/apps/landing/astro.config.mjs similarity index 53% rename from apps/manadeck/apps/landing/astro.config.mjs rename to apps/cards/apps/landing/astro.config.mjs index c490f1deb..3c991463e 100644 --- a/apps/manadeck/apps/landing/astro.config.mjs +++ b/apps/cards/apps/landing/astro.config.mjs @@ -4,14 +4,11 @@ import sitemap from '@astrojs/sitemap'; // https://astro.build/config export default defineConfig({ - site: 'https://manadeck.app', - integrations: [ - tailwind(), - sitemap() - ], - vite: { - ssr: { - noExternal: ['@manacore/shared-landing-ui'] - } - } + site: 'https://manadeck.app', + integrations: [tailwind(), sitemap()], + vite: { + ssr: { + noExternal: ['@manacore/shared-landing-ui'], + }, + }, }); diff --git a/apps/manadeck/apps/landing/package.json b/apps/cards/apps/landing/package.json similarity index 100% rename from apps/manadeck/apps/landing/package.json rename to apps/cards/apps/landing/package.json diff --git a/apps/manadeck/apps/landing/public/favicon.svg b/apps/cards/apps/landing/public/favicon.svg similarity index 100% rename from apps/manadeck/apps/landing/public/favicon.svg rename to apps/cards/apps/landing/public/favicon.svg diff --git a/apps/manadeck/apps/landing/public/robots.txt b/apps/cards/apps/landing/public/robots.txt similarity index 100% rename from apps/manadeck/apps/landing/public/robots.txt rename to apps/cards/apps/landing/public/robots.txt diff --git a/apps/manadeck/apps/landing/src/components/Footer.astro b/apps/cards/apps/landing/src/components/Footer.astro similarity index 100% rename from apps/manadeck/apps/landing/src/components/Footer.astro rename to apps/cards/apps/landing/src/components/Footer.astro diff --git a/apps/manadeck/apps/landing/src/components/Navigation.astro b/apps/cards/apps/landing/src/components/Navigation.astro similarity index 100% rename from apps/manadeck/apps/landing/src/components/Navigation.astro rename to apps/cards/apps/landing/src/components/Navigation.astro diff --git a/apps/manadeck/apps/landing/src/env.d.ts b/apps/cards/apps/landing/src/env.d.ts similarity index 100% rename from apps/manadeck/apps/landing/src/env.d.ts rename to apps/cards/apps/landing/src/env.d.ts diff --git a/apps/manadeck/apps/landing/src/layouts/Layout.astro b/apps/cards/apps/landing/src/layouts/Layout.astro similarity index 100% rename from apps/manadeck/apps/landing/src/layouts/Layout.astro rename to apps/cards/apps/landing/src/layouts/Layout.astro diff --git a/apps/manadeck/apps/landing/src/pages/cookies.astro b/apps/cards/apps/landing/src/pages/cookies.astro similarity index 100% rename from apps/manadeck/apps/landing/src/pages/cookies.astro rename to apps/cards/apps/landing/src/pages/cookies.astro diff --git a/apps/manadeck/apps/landing/src/pages/imprint.astro b/apps/cards/apps/landing/src/pages/imprint.astro similarity index 100% rename from apps/manadeck/apps/landing/src/pages/imprint.astro rename to apps/cards/apps/landing/src/pages/imprint.astro diff --git a/apps/manadeck/apps/landing/src/pages/index.astro b/apps/cards/apps/landing/src/pages/index.astro similarity index 100% rename from apps/manadeck/apps/landing/src/pages/index.astro rename to apps/cards/apps/landing/src/pages/index.astro diff --git a/apps/manadeck/apps/landing/src/pages/pricing.astro b/apps/cards/apps/landing/src/pages/pricing.astro similarity index 100% rename from apps/manadeck/apps/landing/src/pages/pricing.astro rename to apps/cards/apps/landing/src/pages/pricing.astro diff --git a/apps/manadeck/apps/landing/src/pages/privacy.astro b/apps/cards/apps/landing/src/pages/privacy.astro similarity index 100% rename from apps/manadeck/apps/landing/src/pages/privacy.astro rename to apps/cards/apps/landing/src/pages/privacy.astro diff --git a/apps/manadeck/apps/landing/src/pages/terms.astro b/apps/cards/apps/landing/src/pages/terms.astro similarity index 100% rename from apps/manadeck/apps/landing/src/pages/terms.astro rename to apps/cards/apps/landing/src/pages/terms.astro diff --git a/apps/manadeck/apps/landing/src/styles/global.css b/apps/cards/apps/landing/src/styles/global.css similarity index 100% rename from apps/manadeck/apps/landing/src/styles/global.css rename to apps/cards/apps/landing/src/styles/global.css diff --git a/apps/cards/apps/landing/tailwind.config.mjs b/apps/cards/apps/landing/tailwind.config.mjs new file mode 100644 index 000000000..55afeb724 --- /dev/null +++ b/apps/cards/apps/landing/tailwind.config.mjs @@ -0,0 +1,37 @@ +/** @type {import('tailwindcss').Config} */ +export default { + content: [ + './src/**/*.{astro,html,js,jsx,md,mdx,svelte,ts,tsx,vue}', + '../../packages/shared-landing-ui/src/**/*.{astro,html,js,jsx,ts,tsx}', + ], + theme: { + extend: { + colors: { + // ManaDeck Purple Theme + primary: { + DEFAULT: '#7C3AED', + hover: '#8B5CF6', + glow: 'rgba(124, 58, 237, 0.3)', + }, + background: { + page: '#0f0a1a', + card: '#1a1625', + 'card-hover': '#2d2640', + }, + text: { + primary: '#f9fafb', + secondary: '#d1d5db', + muted: '#6b7280', + }, + border: { + DEFAULT: '#3d3555', + hover: '#4d4570', + }, + }, + fontFamily: { + sans: ['Inter', 'system-ui', 'sans-serif'], + }, + }, + }, + plugins: [require('@tailwindcss/typography')], +}; diff --git a/apps/manadeck/apps/landing/tsconfig.json b/apps/cards/apps/landing/tsconfig.json similarity index 100% rename from apps/manadeck/apps/landing/tsconfig.json rename to apps/cards/apps/landing/tsconfig.json diff --git a/apps/manadeck/apps/landing/wrangler.toml b/apps/cards/apps/landing/wrangler.toml similarity index 100% rename from apps/manadeck/apps/landing/wrangler.toml rename to apps/cards/apps/landing/wrangler.toml diff --git a/apps/manadeck/apps/mobile/.env.example b/apps/cards/apps/mobile/.env.example similarity index 100% rename from apps/manadeck/apps/mobile/.env.example rename to apps/cards/apps/mobile/.env.example diff --git a/apps/manadeck/apps/mobile/.env.production b/apps/cards/apps/mobile/.env.production similarity index 100% rename from apps/manadeck/apps/mobile/.env.production rename to apps/cards/apps/mobile/.env.production diff --git a/apps/manadeck/apps/mobile/.gitignore b/apps/cards/apps/mobile/.gitignore similarity index 100% rename from apps/manadeck/apps/mobile/.gitignore rename to apps/cards/apps/mobile/.gitignore diff --git a/apps/manadeck/apps/mobile/.mcp.json b/apps/cards/apps/mobile/.mcp.json similarity index 100% rename from apps/manadeck/apps/mobile/.mcp.json rename to apps/cards/apps/mobile/.mcp.json diff --git a/apps/manadeck/apps/mobile/CLAUDE.md b/apps/cards/apps/mobile/CLAUDE.md similarity index 100% rename from apps/manadeck/apps/mobile/CLAUDE.md rename to apps/cards/apps/mobile/CLAUDE.md diff --git a/apps/manadeck/apps/mobile/Docs/AI-Implementation-Plan.md b/apps/cards/apps/mobile/Docs/AI-Implementation-Plan.md similarity index 100% rename from apps/manadeck/apps/mobile/Docs/AI-Implementation-Plan.md rename to apps/cards/apps/mobile/Docs/AI-Implementation-Plan.md diff --git a/apps/manadeck/apps/mobile/Docs/EXPO_NATIVE_TABS.md b/apps/cards/apps/mobile/Docs/EXPO_NATIVE_TABS.md similarity index 100% rename from apps/manadeck/apps/mobile/Docs/EXPO_NATIVE_TABS.md rename to apps/cards/apps/mobile/Docs/EXPO_NATIVE_TABS.md diff --git a/apps/manadeck/apps/mobile/Docs/EXPO_UI_DOCS.md b/apps/cards/apps/mobile/Docs/EXPO_UI_DOCS.md similarity index 100% rename from apps/manadeck/apps/mobile/Docs/EXPO_UI_DOCS.md rename to apps/cards/apps/mobile/Docs/EXPO_UI_DOCS.md diff --git a/apps/manadeck/apps/mobile/Docs/EXPO_UI_OFFFICIAL_DOCS.md b/apps/cards/apps/mobile/Docs/EXPO_UI_OFFFICIAL_DOCS.md similarity index 100% rename from apps/manadeck/apps/mobile/Docs/EXPO_UI_OFFFICIAL_DOCS.md rename to apps/cards/apps/mobile/Docs/EXPO_UI_OFFFICIAL_DOCS.md diff --git a/apps/manadeck/apps/mobile/Docs/GLASS_HEADER_GUIDE.md b/apps/cards/apps/mobile/Docs/GLASS_HEADER_GUIDE.md similarity index 100% rename from apps/manadeck/apps/mobile/Docs/GLASS_HEADER_GUIDE.md rename to apps/cards/apps/mobile/Docs/GLASS_HEADER_GUIDE.md diff --git a/apps/manadeck/apps/mobile/Docs/HEADER_STYLING_GUIDE.md b/apps/cards/apps/mobile/Docs/HEADER_STYLING_GUIDE.md similarity index 100% rename from apps/manadeck/apps/mobile/Docs/HEADER_STYLING_GUIDE.md rename to apps/cards/apps/mobile/Docs/HEADER_STYLING_GUIDE.md diff --git a/apps/manadeck/apps/mobile/Docs/NATIVE_TAB_BEHAVIOR.md b/apps/cards/apps/mobile/Docs/NATIVE_TAB_BEHAVIOR.md similarity index 100% rename from apps/manadeck/apps/mobile/Docs/NATIVE_TAB_BEHAVIOR.md rename to apps/cards/apps/mobile/Docs/NATIVE_TAB_BEHAVIOR.md diff --git a/apps/manadeck/apps/mobile/Docs/database.md b/apps/cards/apps/mobile/Docs/database.md similarity index 100% rename from apps/manadeck/apps/mobile/Docs/database.md rename to apps/cards/apps/mobile/Docs/database.md diff --git a/apps/manadeck/apps/mobile/Docs/expo-sdk-54-upgrade-guide.md b/apps/cards/apps/mobile/Docs/expo-sdk-54-upgrade-guide.md similarity index 100% rename from apps/manadeck/apps/mobile/Docs/expo-sdk-54-upgrade-guide.md rename to apps/cards/apps/mobile/Docs/expo-sdk-54-upgrade-guide.md diff --git a/apps/manadeck/apps/mobile/Docs/expo-sdk-54-upgrade.md b/apps/cards/apps/mobile/Docs/expo-sdk-54-upgrade.md similarity index 100% rename from apps/manadeck/apps/mobile/Docs/expo-sdk-54-upgrade.md rename to apps/cards/apps/mobile/Docs/expo-sdk-54-upgrade.md diff --git a/apps/manadeck/apps/mobile/Docs/frontend-components-plan.md b/apps/cards/apps/mobile/Docs/frontend-components-plan.md similarity index 100% rename from apps/manadeck/apps/mobile/Docs/frontend-components-plan.md rename to apps/cards/apps/mobile/Docs/frontend-components-plan.md diff --git a/apps/manadeck/apps/mobile/Docs/next-steps-implementation-plan.md b/apps/cards/apps/mobile/Docs/next-steps-implementation-plan.md similarity index 100% rename from apps/manadeck/apps/mobile/Docs/next-steps-implementation-plan.md rename to apps/cards/apps/mobile/Docs/next-steps-implementation-plan.md diff --git a/apps/manadeck/apps/mobile/Docs/phase-3-card-system-plan.md b/apps/cards/apps/mobile/Docs/phase-3-card-system-plan.md similarity index 100% rename from apps/manadeck/apps/mobile/Docs/phase-3-card-system-plan.md rename to apps/cards/apps/mobile/Docs/phase-3-card-system-plan.md diff --git a/apps/manadeck/apps/mobile/Docs/samples/create-system-user.sql b/apps/cards/apps/mobile/Docs/samples/create-system-user.sql similarity index 100% rename from apps/manadeck/apps/mobile/Docs/samples/create-system-user.sql rename to apps/cards/apps/mobile/Docs/samples/create-system-user.sql diff --git a/apps/manadeck/apps/mobile/Docs/samples/sample-deck-english-basics.sql b/apps/cards/apps/mobile/Docs/samples/sample-deck-english-basics.sql similarity index 100% rename from apps/manadeck/apps/mobile/Docs/samples/sample-deck-english-basics.sql rename to apps/cards/apps/mobile/Docs/samples/sample-deck-english-basics.sql diff --git a/apps/manadeck/apps/mobile/Docs/samples/sample-deck-german-basics-simple.sql b/apps/cards/apps/mobile/Docs/samples/sample-deck-german-basics-simple.sql similarity index 100% rename from apps/manadeck/apps/mobile/Docs/samples/sample-deck-german-basics-simple.sql rename to apps/cards/apps/mobile/Docs/samples/sample-deck-german-basics-simple.sql diff --git a/apps/manadeck/apps/mobile/Docs/samples/sample-deck-german-basics.sql b/apps/cards/apps/mobile/Docs/samples/sample-deck-german-basics.sql similarity index 100% rename from apps/manadeck/apps/mobile/Docs/samples/sample-deck-german-basics.sql rename to apps/cards/apps/mobile/Docs/samples/sample-deck-german-basics.sql diff --git a/apps/manadeck/apps/mobile/Docs/samples/sample-deck-history-world.sql b/apps/cards/apps/mobile/Docs/samples/sample-deck-history-world.sql similarity index 100% rename from apps/manadeck/apps/mobile/Docs/samples/sample-deck-history-world.sql rename to apps/cards/apps/mobile/Docs/samples/sample-deck-history-world.sql diff --git a/apps/manadeck/apps/mobile/Docs/samples/sample-deck-math-basics.sql b/apps/cards/apps/mobile/Docs/samples/sample-deck-math-basics.sql similarity index 100% rename from apps/manadeck/apps/mobile/Docs/samples/sample-deck-math-basics.sql rename to apps/cards/apps/mobile/Docs/samples/sample-deck-math-basics.sql diff --git a/apps/manadeck/apps/mobile/Docs/spaced-repetition.md b/apps/cards/apps/mobile/Docs/spaced-repetition.md similarity index 100% rename from apps/manadeck/apps/mobile/Docs/spaced-repetition.md rename to apps/cards/apps/mobile/Docs/spaced-repetition.md diff --git a/apps/manadeck/apps/mobile/Docs/user-guides/deck-creation.md b/apps/cards/apps/mobile/Docs/user-guides/deck-creation.md similarity index 100% rename from apps/manadeck/apps/mobile/Docs/user-guides/deck-creation.md rename to apps/cards/apps/mobile/Docs/user-guides/deck-creation.md diff --git a/apps/manadeck/apps/mobile/Docs/user-guides/public-deck-suggestions.md b/apps/cards/apps/mobile/Docs/user-guides/public-deck-suggestions.md similarity index 100% rename from apps/manadeck/apps/mobile/Docs/user-guides/public-deck-suggestions.md rename to apps/cards/apps/mobile/Docs/user-guides/public-deck-suggestions.md diff --git a/apps/manadeck/apps/mobile/SPACING_MIGRATION.md b/apps/cards/apps/mobile/SPACING_MIGRATION.md similarity index 100% rename from apps/manadeck/apps/mobile/SPACING_MIGRATION.md rename to apps/cards/apps/mobile/SPACING_MIGRATION.md diff --git a/apps/manadeck/apps/mobile/Setup_Docs/SupabaseMCPClaudeCodeSetup.md b/apps/cards/apps/mobile/Setup_Docs/SupabaseMCPClaudeCodeSetup.md similarity index 100% rename from apps/manadeck/apps/mobile/Setup_Docs/SupabaseMCPClaudeCodeSetup.md rename to apps/cards/apps/mobile/Setup_Docs/SupabaseMCPClaudeCodeSetup.md diff --git a/apps/manadeck/apps/mobile/app-env.d.ts b/apps/cards/apps/mobile/app-env.d.ts similarity index 100% rename from apps/manadeck/apps/mobile/app-env.d.ts rename to apps/cards/apps/mobile/app-env.d.ts diff --git a/apps/manadeck/apps/mobile/app.config.ts b/apps/cards/apps/mobile/app.config.ts similarity index 100% rename from apps/manadeck/apps/mobile/app.config.ts rename to apps/cards/apps/mobile/app.config.ts diff --git a/apps/manadeck/apps/mobile/app.json b/apps/cards/apps/mobile/app.json similarity index 100% rename from apps/manadeck/apps/mobile/app.json rename to apps/cards/apps/mobile/app.json diff --git a/apps/manadeck/apps/mobile/app/(auth)/_layout.tsx b/apps/cards/apps/mobile/app/(auth)/_layout.tsx similarity index 100% rename from apps/manadeck/apps/mobile/app/(auth)/_layout.tsx rename to apps/cards/apps/mobile/app/(auth)/_layout.tsx diff --git a/apps/manadeck/apps/mobile/app/(auth)/forgot-password.tsx b/apps/cards/apps/mobile/app/(auth)/forgot-password.tsx similarity index 100% rename from apps/manadeck/apps/mobile/app/(auth)/forgot-password.tsx rename to apps/cards/apps/mobile/app/(auth)/forgot-password.tsx diff --git a/apps/manadeck/apps/mobile/app/(auth)/login.tsx b/apps/cards/apps/mobile/app/(auth)/login.tsx similarity index 100% rename from apps/manadeck/apps/mobile/app/(auth)/login.tsx rename to apps/cards/apps/mobile/app/(auth)/login.tsx diff --git a/apps/manadeck/apps/mobile/app/(auth)/register.tsx b/apps/cards/apps/mobile/app/(auth)/register.tsx similarity index 100% rename from apps/manadeck/apps/mobile/app/(auth)/register.tsx rename to apps/cards/apps/mobile/app/(auth)/register.tsx diff --git a/apps/manadeck/apps/mobile/app/(tabs)/_layout.tsx b/apps/cards/apps/mobile/app/(tabs)/_layout.tsx similarity index 100% rename from apps/manadeck/apps/mobile/app/(tabs)/_layout.tsx rename to apps/cards/apps/mobile/app/(tabs)/_layout.tsx diff --git a/apps/manadeck/apps/mobile/app/(tabs)/decks/_layout.tsx b/apps/cards/apps/mobile/app/(tabs)/decks/_layout.tsx similarity index 100% rename from apps/manadeck/apps/mobile/app/(tabs)/decks/_layout.tsx rename to apps/cards/apps/mobile/app/(tabs)/decks/_layout.tsx diff --git a/apps/manadeck/apps/mobile/app/(tabs)/decks/index.tsx b/apps/cards/apps/mobile/app/(tabs)/decks/index.tsx similarity index 100% rename from apps/manadeck/apps/mobile/app/(tabs)/decks/index.tsx rename to apps/cards/apps/mobile/app/(tabs)/decks/index.tsx diff --git a/apps/manadeck/apps/mobile/app/(tabs)/explore/_layout.tsx b/apps/cards/apps/mobile/app/(tabs)/explore/_layout.tsx similarity index 100% rename from apps/manadeck/apps/mobile/app/(tabs)/explore/_layout.tsx rename to apps/cards/apps/mobile/app/(tabs)/explore/_layout.tsx diff --git a/apps/manadeck/apps/mobile/app/(tabs)/explore/index.tsx b/apps/cards/apps/mobile/app/(tabs)/explore/index.tsx similarity index 100% rename from apps/manadeck/apps/mobile/app/(tabs)/explore/index.tsx rename to apps/cards/apps/mobile/app/(tabs)/explore/index.tsx diff --git a/apps/manadeck/apps/mobile/app/(tabs)/index.tsx b/apps/cards/apps/mobile/app/(tabs)/index.tsx similarity index 100% rename from apps/manadeck/apps/mobile/app/(tabs)/index.tsx rename to apps/cards/apps/mobile/app/(tabs)/index.tsx diff --git a/apps/manadeck/apps/mobile/app/(tabs)/profile/_layout.tsx b/apps/cards/apps/mobile/app/(tabs)/profile/_layout.tsx similarity index 100% rename from apps/manadeck/apps/mobile/app/(tabs)/profile/_layout.tsx rename to apps/cards/apps/mobile/app/(tabs)/profile/_layout.tsx diff --git a/apps/manadeck/apps/mobile/app/(tabs)/profile/index.tsx b/apps/cards/apps/mobile/app/(tabs)/profile/index.tsx similarity index 100% rename from apps/manadeck/apps/mobile/app/(tabs)/profile/index.tsx rename to apps/cards/apps/mobile/app/(tabs)/profile/index.tsx diff --git a/apps/manadeck/apps/mobile/app/(tabs)/progress/_layout.tsx b/apps/cards/apps/mobile/app/(tabs)/progress/_layout.tsx similarity index 100% rename from apps/manadeck/apps/mobile/app/(tabs)/progress/_layout.tsx rename to apps/cards/apps/mobile/app/(tabs)/progress/_layout.tsx diff --git a/apps/manadeck/apps/mobile/app/(tabs)/progress/index.tsx b/apps/cards/apps/mobile/app/(tabs)/progress/index.tsx similarity index 100% rename from apps/manadeck/apps/mobile/app/(tabs)/progress/index.tsx rename to apps/cards/apps/mobile/app/(tabs)/progress/index.tsx diff --git a/apps/manadeck/apps/mobile/app/+html.tsx b/apps/cards/apps/mobile/app/+html.tsx similarity index 100% rename from apps/manadeck/apps/mobile/app/+html.tsx rename to apps/cards/apps/mobile/app/+html.tsx diff --git a/apps/manadeck/apps/mobile/app/+not-found.tsx b/apps/cards/apps/mobile/app/+not-found.tsx similarity index 100% rename from apps/manadeck/apps/mobile/app/+not-found.tsx rename to apps/cards/apps/mobile/app/+not-found.tsx diff --git a/apps/manadeck/apps/mobile/app/_layout.tsx b/apps/cards/apps/mobile/app/_layout.tsx similarity index 100% rename from apps/manadeck/apps/mobile/app/_layout.tsx rename to apps/cards/apps/mobile/app/_layout.tsx diff --git a/apps/manadeck/apps/mobile/app/card/[id].tsx b/apps/cards/apps/mobile/app/card/[id].tsx similarity index 100% rename from apps/manadeck/apps/mobile/app/card/[id].tsx rename to apps/cards/apps/mobile/app/card/[id].tsx diff --git a/apps/manadeck/apps/mobile/app/card/create.tsx b/apps/cards/apps/mobile/app/card/create.tsx similarity index 100% rename from apps/manadeck/apps/mobile/app/card/create.tsx rename to apps/cards/apps/mobile/app/card/create.tsx diff --git a/apps/manadeck/apps/mobile/app/card/edit/[id].tsx b/apps/cards/apps/mobile/app/card/edit/[id].tsx similarity index 100% rename from apps/manadeck/apps/mobile/app/card/edit/[id].tsx rename to apps/cards/apps/mobile/app/card/edit/[id].tsx diff --git a/apps/manadeck/apps/mobile/app/deck/[id].tsx b/apps/cards/apps/mobile/app/deck/[id].tsx similarity index 100% rename from apps/manadeck/apps/mobile/app/deck/[id].tsx rename to apps/cards/apps/mobile/app/deck/[id].tsx diff --git a/apps/manadeck/apps/mobile/app/deck/[id]/cards.tsx b/apps/cards/apps/mobile/app/deck/[id]/cards.tsx similarity index 100% rename from apps/manadeck/apps/mobile/app/deck/[id]/cards.tsx rename to apps/cards/apps/mobile/app/deck/[id]/cards.tsx diff --git a/apps/manadeck/apps/mobile/app/deck/[id]/edit.tsx b/apps/cards/apps/mobile/app/deck/[id]/edit.tsx similarity index 100% rename from apps/manadeck/apps/mobile/app/deck/[id]/edit.tsx rename to apps/cards/apps/mobile/app/deck/[id]/edit.tsx diff --git a/apps/manadeck/apps/mobile/app/deck/create.tsx b/apps/cards/apps/mobile/app/deck/create.tsx similarity index 100% rename from apps/manadeck/apps/mobile/app/deck/create.tsx rename to apps/cards/apps/mobile/app/deck/create.tsx diff --git a/apps/manadeck/apps/mobile/app/index.tsx b/apps/cards/apps/mobile/app/index.tsx similarity index 100% rename from apps/manadeck/apps/mobile/app/index.tsx rename to apps/cards/apps/mobile/app/index.tsx diff --git a/apps/manadeck/apps/mobile/app/modal.tsx b/apps/cards/apps/mobile/app/modal.tsx similarity index 100% rename from apps/manadeck/apps/mobile/app/modal.tsx rename to apps/cards/apps/mobile/app/modal.tsx diff --git a/apps/manadeck/apps/mobile/app/study/session/[id].tsx b/apps/cards/apps/mobile/app/study/session/[id].tsx similarity index 100% rename from apps/manadeck/apps/mobile/app/study/session/[id].tsx rename to apps/cards/apps/mobile/app/study/session/[id].tsx diff --git a/apps/manadeck/apps/mobile/app/study/summary/[id].tsx b/apps/cards/apps/mobile/app/study/summary/[id].tsx similarity index 100% rename from apps/manadeck/apps/mobile/app/study/summary/[id].tsx rename to apps/cards/apps/mobile/app/study/summary/[id].tsx diff --git a/apps/manadeck/apps/mobile/assets/adaptive-icon.png b/apps/cards/apps/mobile/assets/adaptive-icon.png similarity index 100% rename from apps/manadeck/apps/mobile/assets/adaptive-icon.png rename to apps/cards/apps/mobile/assets/adaptive-icon.png diff --git a/apps/manadeck/apps/mobile/assets/favicon.png b/apps/cards/apps/mobile/assets/favicon.png similarity index 100% rename from apps/manadeck/apps/mobile/assets/favicon.png rename to apps/cards/apps/mobile/assets/favicon.png diff --git a/apps/manadeck/apps/mobile/assets/icon.png b/apps/cards/apps/mobile/assets/icon.png similarity index 100% rename from apps/manadeck/apps/mobile/assets/icon.png rename to apps/cards/apps/mobile/assets/icon.png diff --git a/apps/manadeck/apps/mobile/assets/splash.png b/apps/cards/apps/mobile/assets/splash.png similarity index 100% rename from apps/manadeck/apps/mobile/assets/splash.png rename to apps/cards/apps/mobile/assets/splash.png diff --git a/apps/manadeck/apps/mobile/babel.config.js b/apps/cards/apps/mobile/babel.config.js similarity index 100% rename from apps/manadeck/apps/mobile/babel.config.js rename to apps/cards/apps/mobile/babel.config.js diff --git a/apps/manadeck/apps/mobile/cesconfig.jsonc b/apps/cards/apps/mobile/cesconfig.jsonc similarity index 100% rename from apps/manadeck/apps/mobile/cesconfig.jsonc rename to apps/cards/apps/mobile/cesconfig.jsonc diff --git a/apps/manadeck/apps/mobile/components/EditScreenInfo.tsx b/apps/cards/apps/mobile/components/EditScreenInfo.tsx similarity index 100% rename from apps/manadeck/apps/mobile/components/EditScreenInfo.tsx rename to apps/cards/apps/mobile/components/EditScreenInfo.tsx diff --git a/apps/manadeck/apps/mobile/components/ErrorBoundary.tsx b/apps/cards/apps/mobile/components/ErrorBoundary.tsx similarity index 100% rename from apps/manadeck/apps/mobile/components/ErrorBoundary.tsx rename to apps/cards/apps/mobile/components/ErrorBoundary.tsx diff --git a/apps/manadeck/apps/mobile/components/HeaderButton.tsx b/apps/cards/apps/mobile/components/HeaderButton.tsx similarity index 100% rename from apps/manadeck/apps/mobile/components/HeaderButton.tsx rename to apps/cards/apps/mobile/components/HeaderButton.tsx diff --git a/apps/manadeck/apps/mobile/components/InsufficientCreditsModal.tsx b/apps/cards/apps/mobile/components/InsufficientCreditsModal.tsx similarity index 100% rename from apps/manadeck/apps/mobile/components/InsufficientCreditsModal.tsx rename to apps/cards/apps/mobile/components/InsufficientCreditsModal.tsx diff --git a/apps/manadeck/apps/mobile/components/ScreenContent.tsx b/apps/cards/apps/mobile/components/ScreenContent.tsx similarity index 100% rename from apps/manadeck/apps/mobile/components/ScreenContent.tsx rename to apps/cards/apps/mobile/components/ScreenContent.tsx diff --git a/apps/manadeck/apps/mobile/components/TabBarIcon.tsx b/apps/cards/apps/mobile/components/TabBarIcon.tsx similarity index 100% rename from apps/manadeck/apps/mobile/components/TabBarIcon.tsx rename to apps/cards/apps/mobile/components/TabBarIcon.tsx diff --git a/apps/manadeck/apps/mobile/components/ThemeProvider.tsx b/apps/cards/apps/mobile/components/ThemeProvider.tsx similarity index 100% rename from apps/manadeck/apps/mobile/components/ThemeProvider.tsx rename to apps/cards/apps/mobile/components/ThemeProvider.tsx diff --git a/apps/manadeck/apps/mobile/components/ThemeWrapper.tsx b/apps/cards/apps/mobile/components/ThemeWrapper.tsx similarity index 100% rename from apps/manadeck/apps/mobile/components/ThemeWrapper.tsx rename to apps/cards/apps/mobile/components/ThemeWrapper.tsx diff --git a/apps/manadeck/apps/mobile/components/ai/ImageCardCreator.tsx b/apps/cards/apps/mobile/components/ai/ImageCardCreator.tsx similarity index 100% rename from apps/manadeck/apps/mobile/components/ai/ImageCardCreator.tsx rename to apps/cards/apps/mobile/components/ai/ImageCardCreator.tsx diff --git a/apps/manadeck/apps/mobile/components/ai/SmartCardCreator.tsx b/apps/cards/apps/mobile/components/ai/SmartCardCreator.tsx similarity index 100% rename from apps/manadeck/apps/mobile/components/ai/SmartCardCreator.tsx rename to apps/cards/apps/mobile/components/ai/SmartCardCreator.tsx diff --git a/apps/manadeck/apps/mobile/components/card/CardList.tsx b/apps/cards/apps/mobile/components/card/CardList.tsx similarity index 100% rename from apps/manadeck/apps/mobile/components/card/CardList.tsx rename to apps/cards/apps/mobile/components/card/CardList.tsx diff --git a/apps/manadeck/apps/mobile/components/card/CardTypeSelector.tsx b/apps/cards/apps/mobile/components/card/CardTypeSelector.tsx similarity index 100% rename from apps/manadeck/apps/mobile/components/card/CardTypeSelector.tsx rename to apps/cards/apps/mobile/components/card/CardTypeSelector.tsx diff --git a/apps/manadeck/apps/mobile/components/card/CardView.tsx b/apps/cards/apps/mobile/components/card/CardView.tsx similarity index 100% rename from apps/manadeck/apps/mobile/components/card/CardView.tsx rename to apps/cards/apps/mobile/components/card/CardView.tsx diff --git a/apps/manadeck/apps/mobile/components/deck/DeckCard.tsx b/apps/cards/apps/mobile/components/deck/DeckCard.tsx similarity index 100% rename from apps/manadeck/apps/mobile/components/deck/DeckCard.tsx rename to apps/cards/apps/mobile/components/deck/DeckCard.tsx diff --git a/apps/manadeck/apps/mobile/components/progress/DeckProgressCard.tsx b/apps/cards/apps/mobile/components/progress/DeckProgressCard.tsx similarity index 100% rename from apps/manadeck/apps/mobile/components/progress/DeckProgressCard.tsx rename to apps/cards/apps/mobile/components/progress/DeckProgressCard.tsx diff --git a/apps/manadeck/apps/mobile/components/progress/HeatmapCalendar.tsx b/apps/cards/apps/mobile/components/progress/HeatmapCalendar.tsx similarity index 100% rename from apps/manadeck/apps/mobile/components/progress/HeatmapCalendar.tsx rename to apps/cards/apps/mobile/components/progress/HeatmapCalendar.tsx diff --git a/apps/manadeck/apps/mobile/components/progress/ProgressChart.tsx b/apps/cards/apps/mobile/components/progress/ProgressChart.tsx similarity index 100% rename from apps/manadeck/apps/mobile/components/progress/ProgressChart.tsx rename to apps/cards/apps/mobile/components/progress/ProgressChart.tsx diff --git a/apps/manadeck/apps/mobile/components/progress/StreakCard.tsx b/apps/cards/apps/mobile/components/progress/StreakCard.tsx similarity index 100% rename from apps/manadeck/apps/mobile/components/progress/StreakCard.tsx rename to apps/cards/apps/mobile/components/progress/StreakCard.tsx diff --git a/apps/manadeck/apps/mobile/components/study/StudyModeSelector.tsx b/apps/cards/apps/mobile/components/study/StudyModeSelector.tsx similarity index 100% rename from apps/manadeck/apps/mobile/components/study/StudyModeSelector.tsx rename to apps/cards/apps/mobile/components/study/StudyModeSelector.tsx diff --git a/apps/manadeck/apps/mobile/components/ui/Button.tsx b/apps/cards/apps/mobile/components/ui/Button.tsx similarity index 100% rename from apps/manadeck/apps/mobile/components/ui/Button.tsx rename to apps/cards/apps/mobile/components/ui/Button.tsx diff --git a/apps/manadeck/apps/mobile/components/ui/Card.tsx b/apps/cards/apps/mobile/components/ui/Card.tsx similarity index 100% rename from apps/manadeck/apps/mobile/components/ui/Card.tsx rename to apps/cards/apps/mobile/components/ui/Card.tsx diff --git a/apps/manadeck/apps/mobile/components/ui/FilterBar.tsx b/apps/cards/apps/mobile/components/ui/FilterBar.tsx similarity index 100% rename from apps/manadeck/apps/mobile/components/ui/FilterBar.tsx rename to apps/cards/apps/mobile/components/ui/FilterBar.tsx diff --git a/apps/manadeck/apps/mobile/components/ui/FloatingActionButton.tsx b/apps/cards/apps/mobile/components/ui/FloatingActionButton.tsx similarity index 100% rename from apps/manadeck/apps/mobile/components/ui/FloatingActionButton.tsx rename to apps/cards/apps/mobile/components/ui/FloatingActionButton.tsx diff --git a/apps/manadeck/apps/mobile/components/ui/Icon.tsx b/apps/cards/apps/mobile/components/ui/Icon.tsx similarity index 100% rename from apps/manadeck/apps/mobile/components/ui/Icon.tsx rename to apps/cards/apps/mobile/components/ui/Icon.tsx diff --git a/apps/manadeck/apps/mobile/components/ui/Input.tsx b/apps/cards/apps/mobile/components/ui/Input.tsx similarity index 100% rename from apps/manadeck/apps/mobile/components/ui/Input.tsx rename to apps/cards/apps/mobile/components/ui/Input.tsx diff --git a/apps/manadeck/apps/mobile/components/ui/PageHeader.tsx b/apps/cards/apps/mobile/components/ui/PageHeader.tsx similarity index 100% rename from apps/manadeck/apps/mobile/components/ui/PageHeader.tsx rename to apps/cards/apps/mobile/components/ui/PageHeader.tsx diff --git a/apps/manadeck/apps/mobile/components/ui/SettingsItem.tsx b/apps/cards/apps/mobile/components/ui/SettingsItem.tsx similarity index 100% rename from apps/manadeck/apps/mobile/components/ui/SettingsItem.tsx rename to apps/cards/apps/mobile/components/ui/SettingsItem.tsx diff --git a/apps/manadeck/apps/mobile/components/ui/SettingsSection.tsx b/apps/cards/apps/mobile/components/ui/SettingsSection.tsx similarity index 100% rename from apps/manadeck/apps/mobile/components/ui/SettingsSection.tsx rename to apps/cards/apps/mobile/components/ui/SettingsSection.tsx diff --git a/apps/manadeck/apps/mobile/components/ui/Switch.tsx b/apps/cards/apps/mobile/components/ui/Switch.tsx similarity index 100% rename from apps/manadeck/apps/mobile/components/ui/Switch.tsx rename to apps/cards/apps/mobile/components/ui/Switch.tsx diff --git a/apps/manadeck/apps/mobile/components/ui/Text.tsx b/apps/cards/apps/mobile/components/ui/Text.tsx similarity index 100% rename from apps/manadeck/apps/mobile/components/ui/Text.tsx rename to apps/cards/apps/mobile/components/ui/Text.tsx diff --git a/apps/manadeck/apps/mobile/components/ui/ThemeDebug.tsx b/apps/cards/apps/mobile/components/ui/ThemeDebug.tsx similarity index 100% rename from apps/manadeck/apps/mobile/components/ui/ThemeDebug.tsx rename to apps/cards/apps/mobile/components/ui/ThemeDebug.tsx diff --git a/apps/manadeck/apps/mobile/components/ui/ThemeSwitcher.tsx b/apps/cards/apps/mobile/components/ui/ThemeSwitcher.tsx similarity index 100% rename from apps/manadeck/apps/mobile/components/ui/ThemeSwitcher.tsx rename to apps/cards/apps/mobile/components/ui/ThemeSwitcher.tsx diff --git a/apps/manadeck/apps/mobile/eas.json b/apps/cards/apps/mobile/eas.json similarity index 100% rename from apps/manadeck/apps/mobile/eas.json rename to apps/cards/apps/mobile/eas.json diff --git a/apps/manadeck/apps/mobile/eslint.config.mjs b/apps/cards/apps/mobile/eslint.config.mjs similarity index 100% rename from apps/manadeck/apps/mobile/eslint.config.mjs rename to apps/cards/apps/mobile/eslint.config.mjs diff --git a/apps/manadeck/apps/mobile/examples/DeckCreationExample.tsx b/apps/cards/apps/mobile/examples/DeckCreationExample.tsx similarity index 100% rename from apps/manadeck/apps/mobile/examples/DeckCreationExample.tsx rename to apps/cards/apps/mobile/examples/DeckCreationExample.tsx diff --git a/apps/manadeck/apps/mobile/global.css b/apps/cards/apps/mobile/global.css similarity index 100% rename from apps/manadeck/apps/mobile/global.css rename to apps/cards/apps/mobile/global.css diff --git a/apps/manadeck/apps/mobile/hooks/useCredits.ts b/apps/cards/apps/mobile/hooks/useCredits.ts similarity index 100% rename from apps/manadeck/apps/mobile/hooks/useCredits.ts rename to apps/cards/apps/mobile/hooks/useCredits.ts diff --git a/apps/manadeck/apps/mobile/hooks/useInsufficientCredits.ts b/apps/cards/apps/mobile/hooks/useInsufficientCredits.ts similarity index 100% rename from apps/manadeck/apps/mobile/hooks/useInsufficientCredits.ts rename to apps/cards/apps/mobile/hooks/useInsufficientCredits.ts diff --git a/apps/manadeck/apps/mobile/metro.config.js b/apps/cards/apps/mobile/metro.config.js similarity index 100% rename from apps/manadeck/apps/mobile/metro.config.js rename to apps/cards/apps/mobile/metro.config.js diff --git a/apps/manadeck/apps/mobile/nativewind-env.d.ts b/apps/cards/apps/mobile/nativewind-env.d.ts similarity index 100% rename from apps/manadeck/apps/mobile/nativewind-env.d.ts rename to apps/cards/apps/mobile/nativewind-env.d.ts diff --git a/apps/manadeck/apps/mobile/package.json b/apps/cards/apps/mobile/package.json similarity index 100% rename from apps/manadeck/apps/mobile/package.json rename to apps/cards/apps/mobile/package.json diff --git a/apps/manadeck/apps/mobile/prettier.config.js b/apps/cards/apps/mobile/prettier.config.js similarity index 100% rename from apps/manadeck/apps/mobile/prettier.config.js rename to apps/cards/apps/mobile/prettier.config.js diff --git a/apps/manadeck/apps/mobile/services/apiClient.ts b/apps/cards/apps/mobile/services/apiClient.ts similarity index 100% rename from apps/manadeck/apps/mobile/services/apiClient.ts rename to apps/cards/apps/mobile/services/apiClient.ts diff --git a/apps/manadeck/apps/mobile/services/authService.ts b/apps/cards/apps/mobile/services/authService.ts similarity index 100% rename from apps/manadeck/apps/mobile/services/authService.ts rename to apps/cards/apps/mobile/services/authService.ts diff --git a/apps/manadeck/apps/mobile/services/creditService.ts b/apps/cards/apps/mobile/services/creditService.ts similarity index 100% rename from apps/manadeck/apps/mobile/services/creditService.ts rename to apps/cards/apps/mobile/services/creditService.ts diff --git a/apps/manadeck/apps/mobile/services/tokenManager.ts b/apps/cards/apps/mobile/services/tokenManager.ts similarity index 100% rename from apps/manadeck/apps/mobile/services/tokenManager.ts rename to apps/cards/apps/mobile/services/tokenManager.ts diff --git a/apps/manadeck/apps/mobile/store/aiStore.ts b/apps/cards/apps/mobile/store/aiStore.ts similarity index 100% rename from apps/manadeck/apps/mobile/store/aiStore.ts rename to apps/cards/apps/mobile/store/aiStore.ts diff --git a/apps/manadeck/apps/mobile/store/authStore.ts b/apps/cards/apps/mobile/store/authStore.ts similarity index 100% rename from apps/manadeck/apps/mobile/store/authStore.ts rename to apps/cards/apps/mobile/store/authStore.ts diff --git a/apps/manadeck/apps/mobile/store/cardStore.ts b/apps/cards/apps/mobile/store/cardStore.ts similarity index 100% rename from apps/manadeck/apps/mobile/store/cardStore.ts rename to apps/cards/apps/mobile/store/cardStore.ts diff --git a/apps/manadeck/apps/mobile/store/deckStore.ts b/apps/cards/apps/mobile/store/deckStore.ts similarity index 100% rename from apps/manadeck/apps/mobile/store/deckStore.ts rename to apps/cards/apps/mobile/store/deckStore.ts diff --git a/apps/manadeck/apps/mobile/store/progressStore.ts b/apps/cards/apps/mobile/store/progressStore.ts similarity index 100% rename from apps/manadeck/apps/mobile/store/progressStore.ts rename to apps/cards/apps/mobile/store/progressStore.ts diff --git a/apps/manadeck/apps/mobile/store/store.ts b/apps/cards/apps/mobile/store/store.ts similarity index 100% rename from apps/manadeck/apps/mobile/store/store.ts rename to apps/cards/apps/mobile/store/store.ts diff --git a/apps/manadeck/apps/mobile/store/studyStore.ts b/apps/cards/apps/mobile/store/studyStore.ts similarity index 100% rename from apps/manadeck/apps/mobile/store/studyStore.ts rename to apps/cards/apps/mobile/store/studyStore.ts diff --git a/apps/manadeck/apps/mobile/store/themeStore.tsx b/apps/cards/apps/mobile/store/themeStore.tsx similarity index 100% rename from apps/manadeck/apps/mobile/store/themeStore.tsx rename to apps/cards/apps/mobile/store/themeStore.tsx diff --git a/apps/manadeck/apps/mobile/tailwind.config.js b/apps/cards/apps/mobile/tailwind.config.js similarity index 100% rename from apps/manadeck/apps/mobile/tailwind.config.js rename to apps/cards/apps/mobile/tailwind.config.js diff --git a/apps/manadeck/apps/mobile/themes/default.ts b/apps/cards/apps/mobile/themes/default.ts similarity index 100% rename from apps/manadeck/apps/mobile/themes/default.ts rename to apps/cards/apps/mobile/themes/default.ts diff --git a/apps/manadeck/apps/mobile/themes/forest.ts b/apps/cards/apps/mobile/themes/forest.ts similarity index 100% rename from apps/manadeck/apps/mobile/themes/forest.ts rename to apps/cards/apps/mobile/themes/forest.ts diff --git a/apps/manadeck/apps/mobile/themes/index.ts b/apps/cards/apps/mobile/themes/index.ts similarity index 100% rename from apps/manadeck/apps/mobile/themes/index.ts rename to apps/cards/apps/mobile/themes/index.ts diff --git a/apps/manadeck/apps/mobile/themes/sunset.ts b/apps/cards/apps/mobile/themes/sunset.ts similarity index 100% rename from apps/manadeck/apps/mobile/themes/sunset.ts rename to apps/cards/apps/mobile/themes/sunset.ts diff --git a/apps/manadeck/apps/mobile/tsconfig.json b/apps/cards/apps/mobile/tsconfig.json similarity index 100% rename from apps/manadeck/apps/mobile/tsconfig.json rename to apps/cards/apps/mobile/tsconfig.json diff --git a/apps/manadeck/apps/mobile/types/auth.ts b/apps/cards/apps/mobile/types/auth.ts similarity index 100% rename from apps/manadeck/apps/mobile/types/auth.ts rename to apps/cards/apps/mobile/types/auth.ts diff --git a/apps/manadeck/apps/mobile/types/credits.ts b/apps/cards/apps/mobile/types/credits.ts similarity index 100% rename from apps/manadeck/apps/mobile/types/credits.ts rename to apps/cards/apps/mobile/types/credits.ts diff --git a/apps/manadeck/apps/mobile/types/theme.ts b/apps/cards/apps/mobile/types/theme.ts similarity index 100% rename from apps/manadeck/apps/mobile/types/theme.ts rename to apps/cards/apps/mobile/types/theme.ts diff --git a/apps/manadeck/apps/mobile/utils/apiClient.ts b/apps/cards/apps/mobile/utils/apiClient.ts similarity index 100% rename from apps/manadeck/apps/mobile/utils/apiClient.ts rename to apps/cards/apps/mobile/utils/apiClient.ts diff --git a/apps/manadeck/apps/mobile/utils/logger.ts b/apps/cards/apps/mobile/utils/logger.ts similarity index 100% rename from apps/manadeck/apps/mobile/utils/logger.ts rename to apps/cards/apps/mobile/utils/logger.ts diff --git a/apps/manadeck/apps/mobile/utils/networkErrorUtils.ts b/apps/cards/apps/mobile/utils/networkErrorUtils.ts similarity index 100% rename from apps/manadeck/apps/mobile/utils/networkErrorUtils.ts rename to apps/cards/apps/mobile/utils/networkErrorUtils.ts diff --git a/apps/manadeck/apps/mobile/utils/spacedRepetition.ts b/apps/cards/apps/mobile/utils/spacedRepetition.ts similarity index 100% rename from apps/manadeck/apps/mobile/utils/spacedRepetition.ts rename to apps/cards/apps/mobile/utils/spacedRepetition.ts diff --git a/apps/manadeck/apps/mobile/utils/spacing.ts b/apps/cards/apps/mobile/utils/spacing.ts similarity index 100% rename from apps/manadeck/apps/mobile/utils/spacing.ts rename to apps/cards/apps/mobile/utils/spacing.ts diff --git a/apps/manadeck/apps/mobile/utils/supabaseAIService.ts b/apps/cards/apps/mobile/utils/supabaseAIService.ts similarity index 100% rename from apps/manadeck/apps/mobile/utils/supabaseAIService.ts rename to apps/cards/apps/mobile/utils/supabaseAIService.ts diff --git a/apps/manadeck/apps/mobile/utils/themeUtils.ts b/apps/cards/apps/mobile/utils/themeUtils.ts similarity index 100% rename from apps/manadeck/apps/mobile/utils/themeUtils.ts rename to apps/cards/apps/mobile/utils/themeUtils.ts diff --git a/apps/manadeck/apps/server/package.json b/apps/cards/apps/server/package.json similarity index 100% rename from apps/manadeck/apps/server/package.json rename to apps/cards/apps/server/package.json diff --git a/apps/manadeck/apps/server/src/index.ts b/apps/cards/apps/server/src/index.ts similarity index 100% rename from apps/manadeck/apps/server/src/index.ts rename to apps/cards/apps/server/src/index.ts diff --git a/apps/manadeck/apps/server/tsconfig.json b/apps/cards/apps/server/tsconfig.json similarity index 100% rename from apps/manadeck/apps/server/tsconfig.json rename to apps/cards/apps/server/tsconfig.json diff --git a/apps/manadeck/apps/web/.env.example b/apps/cards/apps/web/.env.example similarity index 100% rename from apps/manadeck/apps/web/.env.example rename to apps/cards/apps/web/.env.example diff --git a/apps/manadeck/apps/web/.gitignore b/apps/cards/apps/web/.gitignore similarity index 100% rename from apps/manadeck/apps/web/.gitignore rename to apps/cards/apps/web/.gitignore diff --git a/apps/manadeck/apps/web/.npmrc b/apps/cards/apps/web/.npmrc similarity index 100% rename from apps/manadeck/apps/web/.npmrc rename to apps/cards/apps/web/.npmrc diff --git a/apps/manadeck/apps/web/Dockerfile b/apps/cards/apps/web/Dockerfile similarity index 100% rename from apps/manadeck/apps/web/Dockerfile rename to apps/cards/apps/web/Dockerfile diff --git a/apps/manadeck/apps/web/README.md b/apps/cards/apps/web/README.md similarity index 100% rename from apps/manadeck/apps/web/README.md rename to apps/cards/apps/web/README.md diff --git a/apps/manadeck/apps/web/eslint.config.js b/apps/cards/apps/web/eslint.config.js similarity index 100% rename from apps/manadeck/apps/web/eslint.config.js rename to apps/cards/apps/web/eslint.config.js diff --git a/apps/manadeck/apps/web/package.json b/apps/cards/apps/web/package.json similarity index 100% rename from apps/manadeck/apps/web/package.json rename to apps/cards/apps/web/package.json diff --git a/apps/manadeck/apps/web/src/app.css b/apps/cards/apps/web/src/app.css similarity index 100% rename from apps/manadeck/apps/web/src/app.css rename to apps/cards/apps/web/src/app.css diff --git a/apps/manadeck/apps/web/src/app.d.ts b/apps/cards/apps/web/src/app.d.ts similarity index 100% rename from apps/manadeck/apps/web/src/app.d.ts rename to apps/cards/apps/web/src/app.d.ts diff --git a/apps/manadeck/apps/web/src/app.html b/apps/cards/apps/web/src/app.html similarity index 100% rename from apps/manadeck/apps/web/src/app.html rename to apps/cards/apps/web/src/app.html diff --git a/apps/manadeck/apps/web/src/hooks.client.ts b/apps/cards/apps/web/src/hooks.client.ts similarity index 100% rename from apps/manadeck/apps/web/src/hooks.client.ts rename to apps/cards/apps/web/src/hooks.client.ts diff --git a/apps/manadeck/apps/web/src/hooks.server.ts b/apps/cards/apps/web/src/hooks.server.ts similarity index 100% rename from apps/manadeck/apps/web/src/hooks.server.ts rename to apps/cards/apps/web/src/hooks.server.ts diff --git a/apps/manadeck/apps/web/src/lib/api/feedback.ts b/apps/cards/apps/web/src/lib/api/feedback.ts similarity index 100% rename from apps/manadeck/apps/web/src/lib/api/feedback.ts rename to apps/cards/apps/web/src/lib/api/feedback.ts diff --git a/apps/manadeck/apps/web/src/lib/assets/favicon.svg b/apps/cards/apps/web/src/lib/assets/favicon.svg similarity index 100% rename from apps/manadeck/apps/web/src/lib/assets/favicon.svg rename to apps/cards/apps/web/src/lib/assets/favicon.svg diff --git a/apps/manadeck/apps/web/src/lib/auth.ts b/apps/cards/apps/web/src/lib/auth.ts similarity index 100% rename from apps/manadeck/apps/web/src/lib/auth.ts rename to apps/cards/apps/web/src/lib/auth.ts diff --git a/apps/manadeck/apps/web/src/lib/components/AppSlider.svelte b/apps/cards/apps/web/src/lib/components/AppSlider.svelte similarity index 100% rename from apps/manadeck/apps/web/src/lib/components/AppSlider.svelte rename to apps/cards/apps/web/src/lib/components/AppSlider.svelte diff --git a/apps/manadeck/apps/web/src/lib/components/Icon.svelte b/apps/cards/apps/web/src/lib/components/Icon.svelte similarity index 100% rename from apps/manadeck/apps/web/src/lib/components/Icon.svelte rename to apps/cards/apps/web/src/lib/components/Icon.svelte diff --git a/apps/manadeck/apps/web/src/lib/components/LanguageSelector.svelte b/apps/cards/apps/web/src/lib/components/LanguageSelector.svelte similarity index 100% rename from apps/manadeck/apps/web/src/lib/components/LanguageSelector.svelte rename to apps/cards/apps/web/src/lib/components/LanguageSelector.svelte diff --git a/apps/manadeck/apps/web/src/lib/components/deck/CreateDeckModal.svelte b/apps/cards/apps/web/src/lib/components/deck/CreateDeckModal.svelte similarity index 100% rename from apps/manadeck/apps/web/src/lib/components/deck/CreateDeckModal.svelte rename to apps/cards/apps/web/src/lib/components/deck/CreateDeckModal.svelte diff --git a/apps/manadeck/apps/web/src/lib/components/deck/DeckCard.svelte b/apps/cards/apps/web/src/lib/components/deck/DeckCard.svelte similarity index 100% rename from apps/manadeck/apps/web/src/lib/components/deck/DeckCard.svelte rename to apps/cards/apps/web/src/lib/components/deck/DeckCard.svelte diff --git a/apps/manadeck/apps/web/src/lib/content/help/index.test.ts b/apps/cards/apps/web/src/lib/content/help/index.test.ts similarity index 100% rename from apps/manadeck/apps/web/src/lib/content/help/index.test.ts rename to apps/cards/apps/web/src/lib/content/help/index.test.ts diff --git a/apps/manadeck/apps/web/src/lib/content/help/index.ts b/apps/cards/apps/web/src/lib/content/help/index.ts similarity index 100% rename from apps/manadeck/apps/web/src/lib/content/help/index.ts rename to apps/cards/apps/web/src/lib/content/help/index.ts diff --git a/apps/manadeck/apps/web/src/lib/data/guest-seed.ts b/apps/cards/apps/web/src/lib/data/guest-seed.ts similarity index 100% rename from apps/manadeck/apps/web/src/lib/data/guest-seed.ts rename to apps/cards/apps/web/src/lib/data/guest-seed.ts diff --git a/apps/manadeck/apps/web/src/lib/data/local-store.ts b/apps/cards/apps/web/src/lib/data/local-store.ts similarity index 100% rename from apps/manadeck/apps/web/src/lib/data/local-store.ts rename to apps/cards/apps/web/src/lib/data/local-store.ts diff --git a/apps/manadeck/apps/web/src/lib/data/queries.ts b/apps/cards/apps/web/src/lib/data/queries.ts similarity index 100% rename from apps/manadeck/apps/web/src/lib/data/queries.ts rename to apps/cards/apps/web/src/lib/data/queries.ts diff --git a/apps/manadeck/apps/web/src/lib/i18n/index.ts b/apps/cards/apps/web/src/lib/i18n/index.ts similarity index 100% rename from apps/manadeck/apps/web/src/lib/i18n/index.ts rename to apps/cards/apps/web/src/lib/i18n/index.ts diff --git a/apps/manadeck/apps/web/src/lib/i18n/locales/de.json b/apps/cards/apps/web/src/lib/i18n/locales/de.json similarity index 100% rename from apps/manadeck/apps/web/src/lib/i18n/locales/de.json rename to apps/cards/apps/web/src/lib/i18n/locales/de.json diff --git a/apps/manadeck/apps/web/src/lib/i18n/locales/en.json b/apps/cards/apps/web/src/lib/i18n/locales/en.json similarity index 100% rename from apps/manadeck/apps/web/src/lib/i18n/locales/en.json rename to apps/cards/apps/web/src/lib/i18n/locales/en.json diff --git a/apps/manadeck/apps/web/src/lib/i18n/locales/es.json b/apps/cards/apps/web/src/lib/i18n/locales/es.json similarity index 100% rename from apps/manadeck/apps/web/src/lib/i18n/locales/es.json rename to apps/cards/apps/web/src/lib/i18n/locales/es.json diff --git a/apps/manadeck/apps/web/src/lib/i18n/locales/fr.json b/apps/cards/apps/web/src/lib/i18n/locales/fr.json similarity index 100% rename from apps/manadeck/apps/web/src/lib/i18n/locales/fr.json rename to apps/cards/apps/web/src/lib/i18n/locales/fr.json diff --git a/apps/manadeck/apps/web/src/lib/i18n/locales/it.json b/apps/cards/apps/web/src/lib/i18n/locales/it.json similarity index 100% rename from apps/manadeck/apps/web/src/lib/i18n/locales/it.json rename to apps/cards/apps/web/src/lib/i18n/locales/it.json diff --git a/apps/manadeck/apps/web/src/lib/index.ts b/apps/cards/apps/web/src/lib/index.ts similarity index 100% rename from apps/manadeck/apps/web/src/lib/index.ts rename to apps/cards/apps/web/src/lib/index.ts diff --git a/apps/manadeck/apps/web/src/lib/stores/app-onboarding.svelte.ts b/apps/cards/apps/web/src/lib/stores/app-onboarding.svelte.ts similarity index 100% rename from apps/manadeck/apps/web/src/lib/stores/app-onboarding.svelte.ts rename to apps/cards/apps/web/src/lib/stores/app-onboarding.svelte.ts diff --git a/apps/manadeck/apps/web/src/lib/stores/auth.svelte.ts b/apps/cards/apps/web/src/lib/stores/auth.svelte.ts similarity index 100% rename from apps/manadeck/apps/web/src/lib/stores/auth.svelte.ts rename to apps/cards/apps/web/src/lib/stores/auth.svelte.ts diff --git a/apps/manadeck/apps/web/src/lib/stores/cardStore.svelte.ts b/apps/cards/apps/web/src/lib/stores/cardStore.svelte.ts similarity index 100% rename from apps/manadeck/apps/web/src/lib/stores/cardStore.svelte.ts rename to apps/cards/apps/web/src/lib/stores/cardStore.svelte.ts diff --git a/apps/manadeck/apps/web/src/lib/stores/deckStore.svelte.ts b/apps/cards/apps/web/src/lib/stores/deckStore.svelte.ts similarity index 100% rename from apps/manadeck/apps/web/src/lib/stores/deckStore.svelte.ts rename to apps/cards/apps/web/src/lib/stores/deckStore.svelte.ts diff --git a/apps/manadeck/apps/web/src/lib/stores/navigation.ts b/apps/cards/apps/web/src/lib/stores/navigation.ts similarity index 100% rename from apps/manadeck/apps/web/src/lib/stores/navigation.ts rename to apps/cards/apps/web/src/lib/stores/navigation.ts diff --git a/apps/manadeck/apps/web/src/lib/stores/progressStore.svelte.ts b/apps/cards/apps/web/src/lib/stores/progressStore.svelte.ts similarity index 100% rename from apps/manadeck/apps/web/src/lib/stores/progressStore.svelte.ts rename to apps/cards/apps/web/src/lib/stores/progressStore.svelte.ts diff --git a/apps/manadeck/apps/web/src/lib/stores/tags.svelte.ts b/apps/cards/apps/web/src/lib/stores/tags.svelte.ts similarity index 100% rename from apps/manadeck/apps/web/src/lib/stores/tags.svelte.ts rename to apps/cards/apps/web/src/lib/stores/tags.svelte.ts diff --git a/apps/manadeck/apps/web/src/lib/stores/theme.ts b/apps/cards/apps/web/src/lib/stores/theme.ts similarity index 100% rename from apps/manadeck/apps/web/src/lib/stores/theme.ts rename to apps/cards/apps/web/src/lib/stores/theme.ts diff --git a/apps/manadeck/apps/web/src/lib/stores/user-settings.svelte.ts b/apps/cards/apps/web/src/lib/stores/user-settings.svelte.ts similarity index 100% rename from apps/manadeck/apps/web/src/lib/stores/user-settings.svelte.ts rename to apps/cards/apps/web/src/lib/stores/user-settings.svelte.ts diff --git a/apps/manadeck/apps/web/src/lib/types/auth.ts b/apps/cards/apps/web/src/lib/types/auth.ts similarity index 100% rename from apps/manadeck/apps/web/src/lib/types/auth.ts rename to apps/cards/apps/web/src/lib/types/auth.ts diff --git a/apps/manadeck/apps/web/src/lib/types/card.ts b/apps/cards/apps/web/src/lib/types/card.ts similarity index 100% rename from apps/manadeck/apps/web/src/lib/types/card.ts rename to apps/cards/apps/web/src/lib/types/card.ts diff --git a/apps/manadeck/apps/web/src/lib/types/credits.ts b/apps/cards/apps/web/src/lib/types/credits.ts similarity index 100% rename from apps/manadeck/apps/web/src/lib/types/credits.ts rename to apps/cards/apps/web/src/lib/types/credits.ts diff --git a/apps/manadeck/apps/web/src/lib/types/deck.ts b/apps/cards/apps/web/src/lib/types/deck.ts similarity index 100% rename from apps/manadeck/apps/web/src/lib/types/deck.ts rename to apps/cards/apps/web/src/lib/types/deck.ts diff --git a/apps/manadeck/apps/web/src/lib/types/study.ts b/apps/cards/apps/web/src/lib/types/study.ts similarity index 100% rename from apps/manadeck/apps/web/src/lib/types/study.ts rename to apps/cards/apps/web/src/lib/types/study.ts diff --git a/apps/manadeck/apps/web/src/lib/version.ts b/apps/cards/apps/web/src/lib/version.ts similarity index 100% rename from apps/manadeck/apps/web/src/lib/version.ts rename to apps/cards/apps/web/src/lib/version.ts diff --git a/apps/manadeck/apps/web/src/routes/(app)/+layout.svelte b/apps/cards/apps/web/src/routes/(app)/+layout.svelte similarity index 100% rename from apps/manadeck/apps/web/src/routes/(app)/+layout.svelte rename to apps/cards/apps/web/src/routes/(app)/+layout.svelte diff --git a/apps/manadeck/apps/web/src/routes/(app)/apps/+page.svelte b/apps/cards/apps/web/src/routes/(app)/apps/+page.svelte similarity index 100% rename from apps/manadeck/apps/web/src/routes/(app)/apps/+page.svelte rename to apps/cards/apps/web/src/routes/(app)/apps/+page.svelte diff --git a/apps/manadeck/apps/web/src/routes/(app)/decks/+page.svelte b/apps/cards/apps/web/src/routes/(app)/decks/+page.svelte similarity index 100% rename from apps/manadeck/apps/web/src/routes/(app)/decks/+page.svelte rename to apps/cards/apps/web/src/routes/(app)/decks/+page.svelte diff --git a/apps/manadeck/apps/web/src/routes/(app)/decks/[id]/+page.svelte b/apps/cards/apps/web/src/routes/(app)/decks/[id]/+page.svelte similarity index 100% rename from apps/manadeck/apps/web/src/routes/(app)/decks/[id]/+page.svelte rename to apps/cards/apps/web/src/routes/(app)/decks/[id]/+page.svelte diff --git a/apps/manadeck/apps/web/src/routes/(app)/explore/+page.svelte b/apps/cards/apps/web/src/routes/(app)/explore/+page.svelte similarity index 100% rename from apps/manadeck/apps/web/src/routes/(app)/explore/+page.svelte rename to apps/cards/apps/web/src/routes/(app)/explore/+page.svelte diff --git a/apps/manadeck/apps/web/src/routes/(app)/feedback/+page.svelte b/apps/cards/apps/web/src/routes/(app)/feedback/+page.svelte similarity index 100% rename from apps/manadeck/apps/web/src/routes/(app)/feedback/+page.svelte rename to apps/cards/apps/web/src/routes/(app)/feedback/+page.svelte diff --git a/apps/manadeck/apps/web/src/routes/(app)/help/+page.svelte b/apps/cards/apps/web/src/routes/(app)/help/+page.svelte similarity index 100% rename from apps/manadeck/apps/web/src/routes/(app)/help/+page.svelte rename to apps/cards/apps/web/src/routes/(app)/help/+page.svelte diff --git a/apps/manadeck/apps/web/src/routes/(app)/mana/+page.svelte b/apps/cards/apps/web/src/routes/(app)/mana/+page.svelte similarity index 100% rename from apps/manadeck/apps/web/src/routes/(app)/mana/+page.svelte rename to apps/cards/apps/web/src/routes/(app)/mana/+page.svelte diff --git a/apps/manadeck/apps/web/src/routes/(app)/profile/+page.svelte b/apps/cards/apps/web/src/routes/(app)/profile/+page.svelte similarity index 100% rename from apps/manadeck/apps/web/src/routes/(app)/profile/+page.svelte rename to apps/cards/apps/web/src/routes/(app)/profile/+page.svelte diff --git a/apps/manadeck/apps/web/src/routes/(app)/progress/+page.svelte b/apps/cards/apps/web/src/routes/(app)/progress/+page.svelte similarity index 100% rename from apps/manadeck/apps/web/src/routes/(app)/progress/+page.svelte rename to apps/cards/apps/web/src/routes/(app)/progress/+page.svelte diff --git a/apps/manadeck/apps/web/src/routes/(app)/settings/+page.svelte b/apps/cards/apps/web/src/routes/(app)/settings/+page.svelte similarity index 100% rename from apps/manadeck/apps/web/src/routes/(app)/settings/+page.svelte rename to apps/cards/apps/web/src/routes/(app)/settings/+page.svelte diff --git a/apps/manadeck/apps/web/src/routes/(app)/tags/+page.svelte b/apps/cards/apps/web/src/routes/(app)/tags/+page.svelte similarity index 100% rename from apps/manadeck/apps/web/src/routes/(app)/tags/+page.svelte rename to apps/cards/apps/web/src/routes/(app)/tags/+page.svelte diff --git a/apps/manadeck/apps/web/src/routes/(app)/themes/+page.svelte b/apps/cards/apps/web/src/routes/(app)/themes/+page.svelte similarity index 100% rename from apps/manadeck/apps/web/src/routes/(app)/themes/+page.svelte rename to apps/cards/apps/web/src/routes/(app)/themes/+page.svelte diff --git a/apps/manadeck/apps/web/src/routes/(auth)/forgot-password/+page.svelte b/apps/cards/apps/web/src/routes/(auth)/forgot-password/+page.svelte similarity index 100% rename from apps/manadeck/apps/web/src/routes/(auth)/forgot-password/+page.svelte rename to apps/cards/apps/web/src/routes/(auth)/forgot-password/+page.svelte diff --git a/apps/manadeck/apps/web/src/routes/(auth)/login/+page.svelte b/apps/cards/apps/web/src/routes/(auth)/login/+page.svelte similarity index 100% rename from apps/manadeck/apps/web/src/routes/(auth)/login/+page.svelte rename to apps/cards/apps/web/src/routes/(auth)/login/+page.svelte diff --git a/apps/manadeck/apps/web/src/routes/(auth)/register/+page.svelte b/apps/cards/apps/web/src/routes/(auth)/register/+page.svelte similarity index 100% rename from apps/manadeck/apps/web/src/routes/(auth)/register/+page.svelte rename to apps/cards/apps/web/src/routes/(auth)/register/+page.svelte diff --git a/apps/manadeck/apps/web/src/routes/(auth)/reset-password/+page.svelte b/apps/cards/apps/web/src/routes/(auth)/reset-password/+page.svelte similarity index 100% rename from apps/manadeck/apps/web/src/routes/(auth)/reset-password/+page.svelte rename to apps/cards/apps/web/src/routes/(auth)/reset-password/+page.svelte diff --git a/apps/manadeck/apps/web/src/routes/+layout.svelte b/apps/cards/apps/web/src/routes/+layout.svelte similarity index 100% rename from apps/manadeck/apps/web/src/routes/+layout.svelte rename to apps/cards/apps/web/src/routes/+layout.svelte diff --git a/apps/manadeck/apps/web/src/routes/+layout.ts b/apps/cards/apps/web/src/routes/+layout.ts similarity index 100% rename from apps/manadeck/apps/web/src/routes/+layout.ts rename to apps/cards/apps/web/src/routes/+layout.ts diff --git a/apps/manadeck/apps/web/src/routes/+page.svelte b/apps/cards/apps/web/src/routes/+page.svelte similarity index 100% rename from apps/manadeck/apps/web/src/routes/+page.svelte rename to apps/cards/apps/web/src/routes/+page.svelte diff --git a/apps/manadeck/apps/web/src/routes/health/+server.ts b/apps/cards/apps/web/src/routes/health/+server.ts similarity index 100% rename from apps/manadeck/apps/web/src/routes/health/+server.ts rename to apps/cards/apps/web/src/routes/health/+server.ts diff --git a/apps/manadeck/apps/web/src/routes/offline/+page.svelte b/apps/cards/apps/web/src/routes/offline/+page.svelte similarity index 100% rename from apps/manadeck/apps/web/src/routes/offline/+page.svelte rename to apps/cards/apps/web/src/routes/offline/+page.svelte diff --git a/apps/manadeck/apps/web/src/routes/offline/+page.ts b/apps/cards/apps/web/src/routes/offline/+page.ts similarity index 100% rename from apps/manadeck/apps/web/src/routes/offline/+page.ts rename to apps/cards/apps/web/src/routes/offline/+page.ts diff --git a/apps/manadeck/apps/web/static/images/app-icons/maerchenzauber-logo-gradient.png b/apps/cards/apps/web/static/images/app-icons/maerchenzauber-logo-gradient.png similarity index 100% rename from apps/manadeck/apps/web/static/images/app-icons/maerchenzauber-logo-gradient.png rename to apps/cards/apps/web/static/images/app-icons/maerchenzauber-logo-gradient.png diff --git a/apps/manadeck/apps/web/static/images/app-icons/manacore-logo-gradient.png b/apps/cards/apps/web/static/images/app-icons/manacore-logo-gradient.png similarity index 100% rename from apps/manadeck/apps/web/static/images/app-icons/manacore-logo-gradient.png rename to apps/cards/apps/web/static/images/app-icons/manacore-logo-gradient.png diff --git a/apps/manadeck/apps/web/static/images/app-icons/manadeck-logo-gradient.png b/apps/cards/apps/web/static/images/app-icons/manadeck-logo-gradient.png similarity index 100% rename from apps/manadeck/apps/web/static/images/app-icons/manadeck-logo-gradient.png rename to apps/cards/apps/web/static/images/app-icons/manadeck-logo-gradient.png diff --git a/apps/manadeck/apps/web/static/images/app-icons/memoro-logo-gradient.png b/apps/cards/apps/web/static/images/app-icons/memoro-logo-gradient.png similarity index 100% rename from apps/manadeck/apps/web/static/images/app-icons/memoro-logo-gradient.png rename to apps/cards/apps/web/static/images/app-icons/memoro-logo-gradient.png diff --git a/apps/manadeck/apps/web/static/images/app-icons/moodlit-logo-gradient.png b/apps/cards/apps/web/static/images/app-icons/moodlit-logo-gradient.png similarity index 100% rename from apps/manadeck/apps/web/static/images/app-icons/moodlit-logo-gradient.png rename to apps/cards/apps/web/static/images/app-icons/moodlit-logo-gradient.png diff --git a/apps/manadeck/apps/web/static/robots.txt b/apps/cards/apps/web/static/robots.txt similarity index 100% rename from apps/manadeck/apps/web/static/robots.txt rename to apps/cards/apps/web/static/robots.txt diff --git a/apps/manadeck/apps/web/svelte.config.js b/apps/cards/apps/web/svelte.config.js similarity index 100% rename from apps/manadeck/apps/web/svelte.config.js rename to apps/cards/apps/web/svelte.config.js diff --git a/apps/manadeck/apps/web/tailwind.config.js.bak b/apps/cards/apps/web/tailwind.config.js.bak similarity index 100% rename from apps/manadeck/apps/web/tailwind.config.js.bak rename to apps/cards/apps/web/tailwind.config.js.bak diff --git a/apps/manadeck/apps/web/tsconfig.json b/apps/cards/apps/web/tsconfig.json similarity index 100% rename from apps/manadeck/apps/web/tsconfig.json rename to apps/cards/apps/web/tsconfig.json diff --git a/apps/manadeck/apps/web/vite.config.ts b/apps/cards/apps/web/vite.config.ts similarity index 100% rename from apps/manadeck/apps/web/vite.config.ts rename to apps/cards/apps/web/vite.config.ts diff --git a/apps/manadeck/supabase/migrations/remove_study_sessions_user_fkey.sql b/apps/cards/supabase/migrations/remove_study_sessions_user_fkey.sql similarity index 100% rename from apps/manadeck/supabase/migrations/remove_study_sessions_user_fkey.sql rename to apps/cards/supabase/migrations/remove_study_sessions_user_fkey.sql diff --git a/apps/manadeck/supabase/migrations/remove_user_fkey.sql b/apps/cards/supabase/migrations/remove_user_fkey.sql similarity index 100% rename from apps/manadeck/supabase/migrations/remove_user_fkey.sql rename to apps/cards/supabase/migrations/remove_user_fkey.sql diff --git a/apps/manacore/apps/landing/src/content/apps/manadeck-de.md b/apps/manacore/apps/landing/src/content/apps/cards-de.md similarity index 100% rename from apps/manacore/apps/landing/src/content/apps/manadeck-de.md rename to apps/manacore/apps/landing/src/content/apps/cards-de.md diff --git a/apps/manacore/apps/landing/src/content/manascore/2026-03-19-manadeck.md b/apps/manacore/apps/landing/src/content/manascore/2026-03-19-cards.md similarity index 100% rename from apps/manacore/apps/landing/src/content/manascore/2026-03-19-manadeck.md rename to apps/manacore/apps/landing/src/content/manascore/2026-03-19-cards.md diff --git a/apps/manacore/apps/web/src/lib/api/services/manadeck.ts b/apps/manacore/apps/web/src/lib/api/services/cards.ts similarity index 100% rename from apps/manacore/apps/web/src/lib/api/services/manadeck.ts rename to apps/manacore/apps/web/src/lib/api/services/cards.ts diff --git a/apps/manacore/apps/web/src/lib/components/dashboard/widgets/ManadeckProgressWidget.svelte b/apps/manacore/apps/web/src/lib/components/dashboard/widgets/CardsProgressWidget.svelte similarity index 100% rename from apps/manacore/apps/web/src/lib/components/dashboard/widgets/ManadeckProgressWidget.svelte rename to apps/manacore/apps/web/src/lib/components/dashboard/widgets/CardsProgressWidget.svelte diff --git a/apps/manadeck/apps/landing/tailwind.config.mjs b/apps/manadeck/apps/landing/tailwind.config.mjs deleted file mode 100644 index 745c62190..000000000 --- a/apps/manadeck/apps/landing/tailwind.config.mjs +++ /dev/null @@ -1,39 +0,0 @@ -/** @type {import('tailwindcss').Config} */ -export default { - content: [ - './src/**/*.{astro,html,js,jsx,md,mdx,svelte,ts,tsx,vue}', - '../../packages/shared-landing-ui/src/**/*.{astro,html,js,jsx,ts,tsx}' - ], - theme: { - extend: { - colors: { - // ManaDeck Purple Theme - primary: { - DEFAULT: '#7C3AED', - hover: '#8B5CF6', - glow: 'rgba(124, 58, 237, 0.3)' - }, - background: { - page: '#0f0a1a', - card: '#1a1625', - 'card-hover': '#2d2640' - }, - text: { - primary: '#f9fafb', - secondary: '#d1d5db', - muted: '#6b7280' - }, - border: { - DEFAULT: '#3d3555', - hover: '#4d4570' - } - }, - fontFamily: { - sans: ['Inter', 'system-ui', 'sans-serif'] - } - } - }, - plugins: [ - require('@tailwindcss/typography') - ] -}; diff --git a/apps/memoro/apps/audio-backend/.env.example b/apps/memoro/apps/audio-backend/.env.example deleted file mode 100644 index a7cfbb5cc..000000000 --- a/apps/memoro/apps/audio-backend/.env.example +++ /dev/null @@ -1,15 +0,0 @@ -# Server Configuration -PORT=1337 - -# Azure Speech Service -AZURE_SPEECH_KEY=your-azure-speech-key -AZURE_SPEECH_REGION=swedencentral - -# Azure Storage Account -AZURE_STORAGE_ACCOUNT_NAME=your-storage-account -AZURE_STORAGE_ACCOUNT_KEY=your-storage-key - -# Supabase Configuration -SUPABASE_URL=https://npgifbrwhftlbrbaglmi.supabase.co -SUPABASE_SERVICE_KEY=your-service-key -SUPABASE_ANON_KEY=your-anon-key diff --git a/apps/memoro/apps/audio-backend/.gcloudignore b/apps/memoro/apps/audio-backend/.gcloudignore deleted file mode 100644 index 2264e4c18..000000000 --- a/apps/memoro/apps/audio-backend/.gcloudignore +++ /dev/null @@ -1,13 +0,0 @@ -.gcloudignore -.git -.gitignore -node_modules/ -npm-debug.log -.env -.env.local -.env.*.local -uploads/ -dist/ -*.log -README.md -.dockerignore \ No newline at end of file diff --git a/apps/memoro/apps/audio-backend/.gitignore b/apps/memoro/apps/audio-backend/.gitignore deleted file mode 100644 index 4d92c3db4..000000000 --- a/apps/memoro/apps/audio-backend/.gitignore +++ /dev/null @@ -1,6 +0,0 @@ -/node_modules -/dist -pubsub-service-account-key.json -# Deployment secrets -.env.deploy -DEPLOY.md diff --git a/apps/memoro/apps/audio-backend/CHANGELOG.md b/apps/memoro/apps/audio-backend/CHANGELOG.md deleted file mode 100644 index 8328e19c4..000000000 --- a/apps/memoro/apps/audio-backend/CHANGELOG.md +++ /dev/null @@ -1,26 +0,0 @@ -# Audio Microservice Changelog - -## [Unreleased] - -### Added -- Service-to-service authentication using Supabase service role keys -- Support for `MEMORO_SUPABASE_SERVICE_KEY` environment variable -- UserId parameter in batch metadata updates for ownership validation - -### Changed -- All memoro service callbacks now use dedicated `/service/` endpoints -- Authentication uses service role key instead of user JWT tokens -- Updated callback methods: - - `notifyTranscriptionComplete`: Now calls `/memoro/service/transcription-completed` - - `notifyAppendTranscriptionComplete`: Now calls `/memoro/service/append-transcription-completed` - - `storeBatchJobMetadata`: Now calls `/memoro/service/update-batch-metadata` - -### Fixed -- 401 authentication errors when calling memoro service -- Callbacks no longer fail due to expired user tokens -- Service-to-service communication is now independent of user sessions - -### Security -- Service role keys are never exposed to clients -- All service-to-service communication uses HTTPS -- Environment variables store sensitive credentials \ No newline at end of file diff --git a/apps/memoro/apps/audio-backend/Dockerfile b/apps/memoro/apps/audio-backend/Dockerfile deleted file mode 100644 index 0af302a93..000000000 --- a/apps/memoro/apps/audio-backend/Dockerfile +++ /dev/null @@ -1,42 +0,0 @@ -FROM node:20-alpine - -# Install FFmpeg 8.x from Alpine edge repository -# - Native support for iOS spatial audio 'chnl' v1 metadata box -# - Fixes: "Unsupported 'chnl' box with version 1" error -# - Install mpg123-libs from edge to avoid symbol conflicts -RUN apk add --no-cache \ - --repository=https://dl-cdn.alpinelinux.org/alpine/edge/main \ - --repository=https://dl-cdn.alpinelinux.org/alpine/edge/community \ - ffmpeg \ - mpg123-libs - -WORKDIR /app - -# Copy package files -COPY package*.json ./ - -# Install all dependencies (including dev dependencies for build) -RUN npm ci - -# Copy source code -COPY . . - -# Build the application -RUN npm run build - -# Remove dev dependencies to reduce image size -RUN npm prune --production - -# Create uploads directory -RUN mkdir -p uploads - -# Cloud Run uses PORT environment variable -EXPOSE ${PORT:-1337} - -# Use non-root user for security -RUN addgroup -g 1001 -S nodejs -RUN adduser -S nestjs -u 1001 -USER nestjs - -# Start the application -CMD ["npm", "run", "start:prod"] \ No newline at end of file diff --git a/apps/memoro/apps/audio-backend/README.md b/apps/memoro/apps/audio-backend/README.md deleted file mode 100644 index 3d88ea145..000000000 --- a/apps/memoro/apps/audio-backend/README.md +++ /dev/null @@ -1,265 +0,0 @@ -# Enhanced Audio & Video Transcription Microservice - -NestJS microservice for advanced audio and video processing with transcription. Features dual routing: fast real-time processing and enhanced Azure Batch transcription for long files. - -## 🎯 What It Does - -### Audio Processing -- **Receives audio file** uploads (MP3, WAV, M4A, AAC, OGG, WebM, FLAC) -- **Validates format** and file size (50MB max) -- **Converts to Azure-compatible WAV format** using FFmpeg -- **Enhanced diarization** with up to 10 speaker detection -- **Multi-language support** with automatic language identification and smart fallback -- **Uploads to Azure Blob Storage** with SAS tokens -- **Starts Azure Batch transcription** with advanced speaker processing -- **Recovery tracking** via memo metadata storage -- **Returns job ID** for tracking and recovery - -### Video Processing (NEW) -- **Extracts audio from video files** (MP4, MOV, AVI, MKV, WEBM, FLV, WMV) -- **Automatic video-to-audio conversion** using FFmpeg -- **High-quality audio extraction** optimized for speech recognition -- **Supports all video formats** with audio tracks -- **Smart routing** (fast <115min, batch ≥115min) based on extracted audio duration -- **Full transcription pipeline** with speaker diarization -- **Progress tracking** and error handling - -## 🚀 Quick Start - -```bash -# Install dependencies -npm install - -# Configure environment -cp .env.example .env -# Edit .env with your Azure credentials - -# Start development server -npm run start:dev -# Service runs on port 1337 -``` - -## 📡 API Endpoints - -### Process Video File (NEW) -```bash -POST /audio/process-video -Content-Type: application/json -Authorization: Bearer - -curl -X POST http://localhost:1337/audio/process-video \ - -H "Authorization: Bearer your-jwt-token" \ - -H "Content-Type: application/json" \ - -d '{ - "videoPath": "user123/memo456/video.mp4", - "memoId": "memo456", - "userId": "user123", - "spaceId": "space789", - "recordingLanguages": ["en-US", "de-DE"], - "enableDiarization": true - }' -``` - -**Supported formats:** MP4, MOV, AVI, MKV, WEBM, FLV, WMV, MPEG -**Required Authentication:** Bearer JWT token -**Fields:** -- `videoPath` (required) - Supabase storage path to video file -- `memoId` (required) - Memo identifier -- `userId` (required) - User identifier -- `spaceId` (optional) - Space identifier -- `recordingLanguages` (optional) - Array of language codes -- `enableDiarization` (optional) - Enable speaker detection (default: true) - -**Response:** -```json -{ - "success": true, - "route": "fast", - "source": "video", - "memoId": "memo456", - "message": "Video processed and transcribed successfully via fast route" -} -``` - -### Upload Audio for Batch Transcription -```bash -POST /audio/transcribe -Content-Type: multipart/form-data - -curl -X POST http://localhost:1337/audio/transcribe \ - -F "audio=@your-audio-file.m4a" \ - -F "userId=user123" \ - -F "spaceId=space456" -``` - -**Supported formats:** MP3, WAV, M4A, AAC, OGG, WebM, FLAC -**Max file size:** 50MB -**Fields:** -- `audio` (required) - Audio file -- `userId` (optional) - User identifier -- `spaceId` (optional) - Space identifier - -### Convert and Transcribe (with Supabase Integration) -```bash -POST /audio/convert-and-transcribe -Content-Type: multipart/form-data -Authorization: Bearer - -curl -X POST http://localhost:1337/audio/convert-and-transcribe \ - -H "Authorization: Bearer your-jwt-token" \ - -F "audio=@your-audio-file.m4a" \ - -F "audioPath=user123/memo456/audio.m4a" \ - -F "memoId=memo456" \ - -F "recordingLanguages=en-US,es-ES" -``` - -**Required Authentication:** Bearer JWT token -**Fields:** -- `audio` (required) - Audio file -- `audioPath` (required) - Supabase storage path -- `memoId` (required) - Memo identifier -- `recordingLanguages` (optional) - Comma-separated language codes (if not provided, auto-detects from 10 common languages) - -## 📊 Response Examples - -### Success Response -```json -{ - "status": "processing", - "type": "batch", - "jobId": "azure-batch-job-123", - "userId": "user123", - "spaceId": "space456", - "duration": 3600.5, - "message": "Batch transcription started. Webhook will notify when complete." -} -``` - -### Error Response -```json -{ - "status": "failed", - "message": "Azure Storage credentials not configured", - "type": "batch", - "jobId": null, - "userId": "user123", - "spaceId": "space456" -} -``` - -## ⚙️ Configuration - -Required environment variables: - -```env -# Azure Configuration -AZURE_SPEECH_KEY=your-azure-speech-key -AZURE_SPEECH_REGION=swedencentral -AZURE_STORAGE_ACCOUNT_NAME=your-storage-account -AZURE_STORAGE_ACCOUNT_KEY=your-storage-key - -# Supabase Configuration -SUPABASE_URL=https://npgifbrwhftlbrbaglmi.supabase.co -SUPABASE_SERVICE_KEY=your-service-key -SUPABASE_ANON_KEY=your-anon-key - -# Memoro Service Integration -MEMORO_SERVICE_URL=https://memoro-service-111768794939.europe-west3.run.app - -# Server Configuration -PORT=1337 -``` - -## 🐳 Docker - -```bash -# Build image -docker build -t audio-microservice . - -# Run container -docker run -p 1337:1337 --env-file .env audio-microservice -``` - -## 🔄 How It Works - -### Enhanced Batch Transcription Route (`/audio/transcribe-from-storage`) -1. **Storage Download** → Download audio file from Supabase Storage -2. **Duration Analysis** → Calculate audio length using FFmpeg -3. **Convert** → FFmpeg converts to Azure-compatible WAV (PCM 16-bit LE, 16kHz mono) -4. **Upload** → Store in Azure Blob Storage with 6-hour SAS token -5. **Enhanced Batch Job** → Create Azure Speech batch transcription job with: - - **Advanced diarization** (up to 10 speakers) - - **Smart language identification** with fallback to 10 common languages when auto mode is used - - **Word-level timestamps** - - **Webhook callback configuration** -6. **Metadata Storage** → Store jobId in memo metadata for recovery tracking -7. **Response** → Return job ID and processing status - -### Fast Transcription Route (`/audio/convert-and-transcribe-from-storage`) -1. **Authentication** → Validate Bearer JWT token -2. **Storage Download** → Download audio from Supabase Storage -3. **Duration Analysis** → Calculate audio length using FFmpeg -4. **Convert** → Convert to WAV format if needed -5. **Supabase Upload** → Store converted audio in Supabase Storage (overwrite original) -6. **Edge Function** → Call Supabase transcribe function for real-time processing -7. **Response** → Return transcription results or processing status - -### Recovery System -- **Metadata Tracking** → Each batch job stores jobId in memo metadata using direct memo ID lookup (improved 2025-06-08) -- **Authentication Fixed** → Proper JWT token handling for metadata storage (fixed 2025-06-08) -- **Webhook Failure Recovery** → Planned cron job system for stuck transcriptions -- **Status Monitoring** → Integration with memoro-service for batch job tracking - -## 🌍 Language Detection - -The service supports intelligent language detection with two modes: - -### Specific Language Mode -When `recordingLanguages` is provided, Azure will attempt to identify the language from the specified list: -```bash -# Example: Detect Spanish or English --F "recordingLanguages=es-ES,en-US" -``` - -### Auto Mode (Smart Fallback) -When no `recordingLanguages` are provided, the service automatically uses a curated list of 10 common languages: -- `de-DE` (German) -- `en-GB` (English - UK) -- `fr-FR` (French) -- `it-IT` (Italian) -- `es-ES` (Spanish) -- `sv-SE` (Swedish) -- `ru-RU` (Russian) -- `nl-NL` (Dutch) -- `tr-TR` (Turkish) -- `pt-PT` (Portuguese) - -This ensures reliable language detection even when the frontend is in auto mode, improving transcription accuracy across different languages. - -## 🔧 Integration Example - -```javascript -// Call from another microservice -const formData = new FormData(); -formData.append('audio', audioFileBuffer); -formData.append('userId', 'user123'); -formData.append('spaceId', 'space456'); - -const response = await fetch('http://localhost:1337/audio/transcribe', { - method: 'POST', - body: formData -}); - -const result = await response.json(); -console.log('Job ID:', result.jobId); -``` - -Optimized for long audio files with Azure Batch transcription! 🎵 - -example response: {"status":"processing","type":"batch","jobId":"287e93a0-3065-487d-9a22-36c3cfb5e1dc","userId":"test-user","duration":2407.119819,"message":"Batch transcription started. Webhook will notify when complete."} - -Service URL: https://audio-microservice-111768794939.europe-west3.run.app# audio-middleware -# Deployment test Sat Jul 26 19:26:53 CEST 2025 - - -test \ No newline at end of file diff --git a/apps/memoro/apps/audio-backend/deploy.sh b/apps/memoro/apps/audio-backend/deploy.sh deleted file mode 100755 index e65ceca09..000000000 --- a/apps/memoro/apps/audio-backend/deploy.sh +++ /dev/null @@ -1,46 +0,0 @@ -#!/bin/bash - -# Load environment variables from .env.deploy and deploy to Google Cloud Run - -# Extract environment variables from .env.deploy (ignoring quotes and comments) -ENV_VARS="" -while IFS= read -r line || [[ -n "$line" ]]; do - # Skip empty lines and comments - if [[ -z "$line" || "$line" =~ ^[[:space:]]*# ]]; then - continue - fi - - # Extract key:value pairs, removing quotes and extra spaces - if [[ "$line" =~ ^[[:space:]]*([^:]+):[[:space:]]*\"?([^\"]*)\"?[[:space:]]*$ ]]; then - key="${BASH_REMATCH[1]// /}" - value="${BASH_REMATCH[2]}" - - # Add to ENV_VARS string - if [[ -n "$ENV_VARS" ]]; then - ENV_VARS="$ENV_VARS,$key=$value" - else - ENV_VARS="$key=$value" - fi - fi -done < .env.deploy - -# Add PORT if not present -if [[ ! "$ENV_VARS" =~ PORT= ]]; then - ENV_VARS="$ENV_VARS" -fi - -echo "Deploying with environment variables..." -echo "ENV_VARS: $ENV_VARS" - -# Deploy to Google Cloud Run -gcloud run deploy audio-microservice \ - --source . \ - --platform managed \ - --region europe-west3 \ - --allow-unauthenticated \ - --port 1337 \ - --memory 2Gi \ - --cpu 2 \ - --timeout 900 \ - --max-instances 10 \ - --set-env-vars "$ENV_VARS" \ No newline at end of file diff --git a/apps/memoro/apps/audio-backend/docs/to dos/api-exposition-roadmap.md b/apps/memoro/apps/audio-backend/docs/to dos/api-exposition-roadmap.md deleted file mode 100644 index e4b3493c1..000000000 --- a/apps/memoro/apps/audio-backend/docs/to dos/api-exposition-roadmap.md +++ /dev/null @@ -1,459 +0,0 @@ -# API-Exposition Roadmap - -## Übersicht - -Dieser Plan beschreibt alle notwendigen Schritte, um den Audio-Middleware-Service als professionelle, öffentliche API anzubieten. - -**Status**: Der Service IST bereits eine REST-API, benötigt aber noch Production-Ready Features für öffentliche Nutzung. - ---- - -## Was fehlt noch für eine professionelle API-Exposition? - -### 🔐 1. Authentifizierung & Autorisierung - -**Aktuell**: Nur simple Bearer-Token-Prüfung ohne Validierung -```typescript -// Aktuelle Implementierung in audio.controller.ts:44-46 -if (!authHeader || !authHeader.startsWith('Bearer ')) { - throw new BadRequestException('Authorization token is required'); -} -``` - -**Was fehlt:** -- API-Key-Management-System (API-Keys generieren, rotieren, widerrufen) -- JWT-Token-Validierung (derzeit wird Token nur weitergegeben, nicht validiert) -- OAuth 2.0 / OpenID Connect Integration -- Unterschiedliche Permission-Levels (Read/Write/Admin) -- Service-to-Service Authentifizierung - -**Technologien:** -- `@nestjs/passport` -- `@nestjs/jwt` -- `passport-jwt` - ---- - -### 📚 2. API-Dokumentation (OpenAPI/Swagger) - -**Was fehlt:** -- `@nestjs/swagger` Integration -- Automatische API-Docs auf `/api-docs` -- DTOs mit Decorators für automatische Validierung -- Request/Response-Beispiele -- Interaktive API-Playground - -**Beispiel-Implementation:** -```typescript -// Aktuell fehlt: -@ApiTags('audio') -@ApiBearerAuth() -export class AudioController { - - @ApiOperation({ summary: 'Process video file and transcribe' }) - @ApiResponse({ status: 200, description: 'Success', type: ProcessVideoResponse }) - @Post('process-video') - async processVideo(@Body() body: ProcessVideoDto) { ... } -} -``` - -**Technologien:** -- `@nestjs/swagger` -- `swagger-ui-express` - ---- - -### 🛡️ 3. Rate Limiting & Throttling - -**Was fehlt:** -- Request-Limits pro API-Key (z.B. 100 Requests/Minute) -- Throttling für ressourcenintensive Endpunkte -- `@nestjs/throttler` Package -- Unterschiedliche Limits für verschiedene Tier-Levels (Free/Pro/Enterprise) - -**Beispiel:** -```typescript -@Throttle(10, 60) // 10 requests per 60 seconds -@Post('process-video') -async processVideo() { ... } -``` - -**Technologien:** -- `@nestjs/throttler` -- Redis für distributed rate limiting - ---- - -### ✅ 4. Input-Validierung mit DTOs - -**Aktuell**: Manuelle Validierung -```typescript -if (!body.audioPath) { - throw new BadRequestException('Audio path is required'); -} -``` - -**Besser: Class-Validator DTOs:** -```typescript -class ProcessVideoDto { - @IsString() - @IsNotEmpty() - videoPath: string; - - @IsString() - @IsNotEmpty() - memoId: string; - - @IsArray() - @IsOptional() - recordingLanguages?: string[]; - - @IsString() - @IsOptional() - callbackUrl?: string; -} -``` - -**Technologien:** -- `class-validator` -- `class-transformer` - ---- - -### 📊 5. Monitoring, Logging & Analytics - -**Was fehlt:** -- Request/Response Logging (strukturiert) -- API-Nutzungsstatistiken pro API-Key -- Performance-Metriken (Latency, Success Rate) -- Error-Tracking (z.B. Sentry Integration) -- Dashboard für API-Health-Monitoring - -**Features:** -- Strukturiertes JSON-Logging -- Request-ID-Tracking über alle Services -- Performance-Metriken (P50, P95, P99 Latency) -- Error-Rate-Monitoring -- API-Usage-Analytics - -**Technologien:** -- `winston` oder `pino` für Logging -- Sentry für Error-Tracking -- Prometheus + Grafana für Metrics -- Google Cloud Monitoring - ---- - -### 🔢 6. API-Versionierung - -**Was fehlt:** -```typescript -// Beispiel: -@Controller('v1/audio') // Version 1 -@Controller('v2/audio') // Version 2 mit breaking changes -``` - -**Best Practices:** -- URL-basierte Versionierung (`/v1/audio`, `/v2/audio`) -- Sunset-Header für deprecated Endpoints -- Migrations-Guide zwischen Versionen - ---- - -### 💰 7. Quotas & Billing - -**Was fehlt:** -- Nutzungslimits (Minuten Transkription pro Monat) -- Kostenberechnung basierend auf Nutzung -- Billing-Integration (Stripe, etc.) -- Quota-Überwachung und Warnungen -- Usage-basierte Preismodelle - -**Features:** -- Free Tier: 100 Minuten/Monat -- Pro Tier: 1000 Minuten/Monat -- Enterprise: Custom Limits -- Echtzeitüberwachung der Nutzung - -**Technologien:** -- Stripe für Billing -- Redis/PostgreSQL für Quota-Tracking - ---- - -### 🔄 8. Webhook-Management - -**Aktuell**: Webhooks werden gesendet, aber: -- Kein Interface zum Registrieren/Verwalten von Webhooks -- Keine Webhook-Retry-Logik mit Exponential Backoff -- Kein Webhook-Event-Log -- Keine Webhook-Signatur-Validierung - -**Was fehlt:** -- Webhook-Registrierung-API -- Retry-Mechanismus (3 Retries mit Backoff) -- Webhook-Event-History -- HMAC-Signatur für Sicherheit -- Webhook-Testing-Tools - ---- - -### 📦 9. SDKs & Client Libraries - -**Was fehlt:** -- JavaScript/TypeScript SDK -- Python SDK -- Java SDK -- Go SDK -- Code-Beispiele für verschiedene Sprachen - -**Beispiel TypeScript SDK:** -```typescript -import { AudioAPI } from '@memo/audio-api'; - -const client = new AudioAPI({ apiKey: 'your-api-key' }); - -const result = await client.processVideo({ - videoPath: 'gs://bucket/video.mp4', - memoId: 'memo-123', - recordingLanguages: ['de-DE'] -}); -``` - ---- - -### 🌐 10. Developer Portal - -**Was fehlt:** -- Self-Service API-Key-Generierung -- Interaktive API-Dokumentation -- Code-Beispiele und Tutorials -- Nutzungsstatistiken-Dashboard -- Support/Ticketing-System -- Changelog und Release Notes - -**Features:** -- User-Registrierung und Login -- API-Key-Management (Erstellen, Rotieren, Löschen) -- Live-API-Testing-Playground -- Nutzungs-Dashboard mit Grafiken -- Billing-Übersicht - ---- - -### 🔒 11. Security Headers & CORS - -**Aktuell**: `app.enableCors()` (zu permissiv) - -**Besser:** -```typescript -app.enableCors({ - origin: process.env.ALLOWED_ORIGINS?.split(','), - methods: ['POST', 'GET'], - credentials: true, - maxAge: 3600 -}); - -// Helmet.js für Security Headers -app.use(helmet({ - contentSecurityPolicy: true, - hsts: true, - noSniff: true -})); -``` - -**Zusätzliche Security:** -- HTTPS-Only -- API-Key-Verschlüsselung im Storage -- Request-Signing für sensitive Operationen -- IP-Whitelisting (optional) - -**Technologien:** -- `helmet` -- `@nestjs/cors` - ---- - -## 📋 Priorisierte Umsetzungs-Roadmap - -### Phase 1: Basis-Absicherung - -**Ziel**: Minimale Production-Ready API - -**Tasks:** -1. ✅ DTOs mit class-validator implementieren - - ProcessVideoDto - - TranscribeDto - - ConvertAndTranscribeDto - - Response-DTOs - -2. ✅ API-Key-Authentifizierung - - API-Key-Generation - - API-Key-Validierung - - Datenbank-Schema für Keys - -3. ✅ Rate Limiting - - @nestjs/throttler Setup - - Pro-Endpoint-Limits - - Redis-Integration für distributed limiting - -4. ✅ Swagger-Dokumentation - - @nestjs/swagger Setup - - Controller-Decorators - - DTO-Documentation - - API-Docs auf /api-docs - -**Geschätzter Aufwand**: 1-2 Wochen - ---- - -### Phase 2: Professional Features - -**Ziel**: Production-Grade Monitoring & Security - -**Tasks:** -5. ✅ API-Versionierung - - v1/audio Endpoints - - Versionierungs-Strategie dokumentieren - -6. ✅ Strukturiertes Logging - - Winston/Pino Integration - - Request-ID-Tracking - - Strukturierte Log-Formate - -7. ✅ Error-Tracking - - Sentry Integration - - Error-Kategorisierung - - Alert-Konfiguration - -8. ✅ CORS-Konfiguration - - Environment-basierte Origin-Liste - - Helmet.js Integration - -9. ✅ Webhook-Retry-Logik - - Exponential Backoff - - Retry-Limits - - Event-Logging - -**Geschätzter Aufwand**: 2-3 Wochen - ---- - -### Phase 3: Enterprise-Features - -**Ziel**: Vollständiges API-Produkt - -**Tasks:** -10. ✅ Developer Portal - - Frontend-Entwicklung - - User-Management - - API-Key-Management-UI - - Usage-Dashboard - -11. ✅ SDKs - - TypeScript SDK - - Python SDK - - Code-Generatoren - -12. ✅ Quotas & Billing - - Quota-System - - Stripe-Integration - - Usage-Metering - -13. ✅ Webhook-Management-API - - Registrierung - - Testing-Tools - - Event-History - -14. ✅ Performance-Monitoring - - Prometheus-Metrics - - Grafana-Dashboards - - Alerting - -**Geschätzter Aufwand**: 4+ Wochen - ---- - -## 🎯 Quick Wins (Sofort umsetzbar) - -Diese Features können schnell implementiert werden und bringen sofortigen Mehrwert: - -1. **Swagger-Dokumentation** (1-2 Tage) - - Schnelle Übersicht für Entwickler - - Interaktives Testing - -2. **DTOs mit Validation** (2-3 Tage) - - Bessere Fehler-Messages - - Automatische Validierung - -3. **Rate Limiting** (1 Tag) - - Schutz vor Missbrauch - - Einfache Implementation - -4. **Strukturiertes Logging** (1-2 Tage) - - Besseres Debugging - - Production-Monitoring - ---- - -## 📚 Zusätzliche Empfehlungen - -### Performance-Optimierungen -- Response-Caching für häufige Requests -- Database-Connection-Pooling -- Background-Job-Queue für lange Prozesse - -### Testing -- Unit-Tests für alle Services -- Integration-Tests für API-Endpoints -- Load-Testing für Performance-Validierung - -### Documentation -- API-Reference-Dokumentation -- Getting-Started-Guide -- Code-Beispiele für alle Endpoints -- Troubleshooting-Guide - -### Compliance -- DSGVO-Compliance (Audio-Daten) -- Daten-Löschungs-Policies -- Audit-Logs für Compliance - ---- - -## 🔧 Benötigte Dependencies (Phase 1) - -```json -{ - "dependencies": { - "@nestjs/swagger": "^7.1.0", - "@nestjs/throttler": "^5.0.0", - "@nestjs/passport": "^10.0.0", - "@nestjs/jwt": "^10.1.0", - "class-validator": "^0.14.0", - "class-transformer": "^0.5.1", - "helmet": "^7.0.0", - "passport-jwt": "^4.0.1", - "bcrypt": "^5.1.1" - }, - "devDependencies": { - "@types/passport-jwt": "^3.0.9", - "@types/bcrypt": "^5.0.0" - } -} -``` - ---- - -## 💡 Nächste Schritte - -Welcher Aspekt soll zuerst implementiert werden? - -**Empfehlung**: Start mit Phase 1, Task 1-4 (Basis-Absicherung) - -1. DTOs & Validation -2. Swagger-Dokumentation -3. Rate Limiting -4. API-Key-Authentifizierung - -Dies schafft eine solide Basis für alle weiteren Features. diff --git a/apps/memoro/apps/audio-backend/nest-cli.json b/apps/memoro/apps/audio-backend/nest-cli.json deleted file mode 100644 index 68d1974c4..000000000 --- a/apps/memoro/apps/audio-backend/nest-cli.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/nest-cli", - "collection": "@nestjs/schematics", - "sourceRoot": "src" -} diff --git a/apps/memoro/apps/audio-backend/package.json b/apps/memoro/apps/audio-backend/package.json deleted file mode 100644 index 508f89eee..000000000 --- a/apps/memoro/apps/audio-backend/package.json +++ /dev/null @@ -1,37 +0,0 @@ -{ - "name": "@memoro/audio-backend", - "version": "1.0.0", - "description": "Simple microservice for audio transcription with batch routing", - "main": "dist/main.js", - "scripts": { - "build": "nest build", - "start": "nest start", - "start:dev": "nest start --watch", - "start:prod": "node dist/main" - }, - "dependencies": { - "@azure/storage-blob": "^12.17.0", - "@nestjs/common": "^10.0.0", - "@nestjs/config": "^3.0.0", - "@nestjs/core": "^10.0.0", - "@nestjs/platform-express": "^10.0.0", - "@nestjs/swagger": "^7.4.2", - "@nestjs/throttler": "^5.2.0", - "@supabase/supabase-js": "^2.41.0", - "class-transformer": "^0.5.1", - "class-validator": "^0.14.3", - "fluent-ffmpeg": "^2.1.2", - "helmet": "^8.1.0", - "multer": "^1.4.5-lts.1", - "reflect-metadata": "^0.1.13", - "rxjs": "^7.8.1", - "swagger-ui-express": "^5.0.1" - }, - "devDependencies": { - "@nestjs/cli": "^10.0.0", - "@types/fluent-ffmpeg": "^2.1.21", - "@types/multer": "^1.4.7", - "@types/node": "^20.3.1", - "typescript": "^5.1.3" - } -} diff --git a/apps/memoro/apps/audio-backend/src/app.module.ts b/apps/memoro/apps/audio-backend/src/app.module.ts deleted file mode 100644 index ee48734d4..000000000 --- a/apps/memoro/apps/audio-backend/src/app.module.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { Module } from '@nestjs/common'; -import { ConfigModule } from '@nestjs/config'; -import { MulterModule } from '@nestjs/platform-express'; -import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler'; -import { APP_GUARD } from '@nestjs/core'; -import { AudioController } from './audio.controller'; -import { AudioService } from './audio.service'; - -@Module({ - imports: [ - ConfigModule.forRoot({ isGlobal: true }), - MulterModule.register({ - dest: './uploads', - limits: { fileSize: 500 * 1024 * 1024 }, // 500MB - }), - ThrottlerModule.forRoot([ - { - name: 'short', - ttl: 1000, // 1 second - limit: 3, // 3 requests per second - }, - { - name: 'medium', - ttl: 60000, // 1 minute - limit: 20, // 20 requests per minute - }, - { - name: 'long', - ttl: 3600000, // 1 hour - limit: 100, // 100 requests per hour - }, - ]), - ], - controllers: [AudioController], - providers: [ - AudioService, - { - provide: APP_GUARD, - useClass: ThrottlerGuard, - }, - ], -}) -export class AppModule {} diff --git a/apps/memoro/apps/audio-backend/src/audio.controller.ts b/apps/memoro/apps/audio-backend/src/audio.controller.ts deleted file mode 100644 index 72afcaa44..000000000 --- a/apps/memoro/apps/audio-backend/src/audio.controller.ts +++ /dev/null @@ -1,205 +0,0 @@ -import { - Controller, - Post, - Get, - Body, - Param, - BadRequestException, - Logger, - Headers, -} from '@nestjs/common'; -import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiParam } from '@nestjs/swagger'; -import { AudioService } from './audio.service'; -import { - TranscribeRealtimeDto, - TranscribeFromStorageDto, - ProcessVideoDto, - TranscriptionResponseDto, - BatchStatusResponseDto, -} from './dto'; - -@ApiTags('Audio Transcription') -@ApiBearerAuth() -@Controller('audio') -export class AudioController { - private readonly logger = new Logger(AudioController.name); - constructor(private readonly audioService: AudioService) {} - - @Post('transcribe-realtime') - @ApiOperation({ - summary: 'Transcribe audio file in real-time', - description: - 'Process and transcribe audio files using real-time transcription with automatic fallback to batch processing for longer files (>115 minutes). Supports speaker diarization and multi-language detection.', - }) - @ApiResponse({ - status: 200, - description: 'Transcription completed successfully', - type: TranscriptionResponseDto, - }) - @ApiResponse({ - status: 400, - description: 'Bad request - invalid input parameters', - }) - async transcribeRealtime( - @Body() body: TranscribeRealtimeDto, - @Headers('authorization') authHeader?: string - ) { - if (!authHeader || !authHeader.startsWith('Bearer ')) { - throw new BadRequestException('Authorization token is required'); - } - - const token = authHeader.replace('Bearer ', ''); - - this.logger.log(`Starting fast transcription: ${body.audioPath} for memo ${body.memoId}`); - - try { - const result = await this.audioService.transcribeRealtimeWithFallback( - body.audioPath, - body.memoId, - body.userId, - body.spaceId, - body.recordingLanguages || [], - token, - body.enableDiarization, - body.isAppend, - body.recordingIndex - ); - - return result; - } catch (error) { - this.logger.error('Error in transcribe-realtime with fallback:', error); - throw new BadRequestException( - `Transcription failed after all fallback attempts: ${error.message}` - ); - } - } - - @Post('transcribe-from-storage') - @ApiOperation({ - summary: 'Transcribe audio from cloud storage', - description: - 'Process audio files directly from cloud storage paths. Supports both Google Cloud Storage (gs://) and Supabase storage paths.', - }) - @ApiResponse({ - status: 200, - description: 'Transcription completed successfully', - type: TranscriptionResponseDto, - }) - @ApiResponse({ - status: 400, - description: 'Bad request - invalid input parameters', - }) - async transcribeFromStorage( - @Body() body: TranscribeFromStorageDto, - @Headers('authorization') authHeader?: string - ) { - if (!authHeader || !authHeader.startsWith('Bearer ')) { - throw new BadRequestException('Authorization token is required'); - } - - const token = authHeader.replace('Bearer ', ''); - - this.logger.log(`Processing audio from storage: ${body.audioPath}`); - - try { - // Process audio using storage path - const result = await this.audioService.processAudioFromStorage( - body.audioPath, - body.userId, - body.spaceId, - body.recordingLanguages, - token, - body.memoId, - body.enableDiarization - ); - - return result; - } catch (error) { - this.logger.error('Error in transcribe-from-storage:', error); - throw new BadRequestException(`Transcription failed: ${error.message}`); - } - } - - @Get('batch-status/:jobId') - @ApiOperation({ - summary: 'Check batch transcription job status', - description: - 'Check the status and retrieve results of a batch transcription job. Used for long audio files that are processed asynchronously.', - }) - @ApiParam({ - name: 'jobId', - description: 'Batch transcription job ID', - example: 'batch-job-12345', - }) - @ApiResponse({ - status: 200, - description: 'Job status retrieved successfully', - type: BatchStatusResponseDto, - }) - @ApiResponse({ - status: 400, - description: 'Bad request - invalid job ID', - }) - async checkBatchStatus( - @Param('jobId') jobId: string, - @Headers('authorization') authHeader?: string - ) { - if (!jobId) { - throw new BadRequestException('Job ID is required'); - } - - this.logger.log(`Checking batch transcription status for job: ${jobId}`); - - try { - const result = await this.audioService.checkBatchTranscriptionStatus(jobId); - return result; - } catch (error) { - this.logger.error('Error checking batch status:', error); - throw new BadRequestException(`Status check failed: ${error.message}`); - } - } - - @Post('process-video') - @ApiOperation({ - summary: 'Process video file and transcribe audio', - description: - 'Extract audio from video files and transcribe automatically. Supports multiple video formats (MP4, MOV, AVI, MKV, WEBM, FLV, WMV) with automatic format detection and conversion.', - }) - @ApiResponse({ - status: 200, - description: 'Video processing and transcription completed successfully', - type: TranscriptionResponseDto, - }) - @ApiResponse({ - status: 400, - description: 'Bad request - invalid input parameters', - }) - async processVideo(@Body() body: ProcessVideoDto, @Headers('authorization') authHeader?: string) { - if (!authHeader || !authHeader.startsWith('Bearer ')) { - throw new BadRequestException('Authorization token is required'); - } - - const token = authHeader.replace('Bearer ', ''); - - this.logger.log(`Processing video file: ${body.videoPath} for memo ${body.memoId}`); - - try { - const result = await this.audioService.processVideoFile( - body.videoPath, - body.memoId, - body.userId, - body.spaceId, - body.recordingLanguages || [], - token, - body.enableDiarization, - body.isAppend, - body.recordingIndex - ); - - return result; - } catch (error) { - this.logger.error('Error processing video:', error); - throw new BadRequestException(`Video processing failed: ${error.message}`); - } - } -} diff --git a/apps/memoro/apps/audio-backend/src/audio.service.ts b/apps/memoro/apps/audio-backend/src/audio.service.ts deleted file mode 100644 index 6e4c8d06e..000000000 --- a/apps/memoro/apps/audio-backend/src/audio.service.ts +++ /dev/null @@ -1,2491 +0,0 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import * as ffmpeg from 'fluent-ffmpeg'; -import * as fs from 'fs'; -import * as path from 'path'; -import * as os from 'os'; - -@Injectable() -export class AudioService { - private readonly logger = new Logger(AudioService.name); - private readonly batchThresholdMinutes = 115; // 1h55m - - constructor(private readonly configService: ConfigService) {} - - /** - * Fast transcription with automatic fallback handling and timeout management - */ - async transcribeRealtimeWithFallback( - audioPath: string, - memoId: string, - userId: string, - spaceId?: string, - recordingLanguages?: string[], - token?: string, - enableDiarization?: boolean, - isAppend?: boolean, - recordingIndex?: number - ) { - // Configurable timeouts based on environment - const TOTAL_TIMEOUT = parseInt('1200000'); // 20 minutes default - const FAST_TIMEOUT = parseInt('1200000'); // 20 minutes default - const startTime = Date.now(); - - const checkTimeout = (stage: string) => { - const elapsed = Date.now() - startTime; - if (elapsed > TOTAL_TIMEOUT) { - throw new Error(`Fallback chain timeout exceeded after ${elapsed}ms in stage: ${stage}`); - } - return TOTAL_TIMEOUT - elapsed; // Return remaining time - }; - - try { - this.logger.log( - `[transcribeRealtimeWithFallback] Starting transcription with fallback for ${audioPath}` - ); - - // Attempt 1: Try fast transcription with timeout - try { - checkTimeout('initial-fast'); - return await Promise.race([ - this.transcribeRealtime( - audioPath, - memoId, - userId, - spaceId, - recordingLanguages, - token, - enableDiarization, - isAppend, - recordingIndex - ), - new Promise((_, reject) => - setTimeout(() => reject(new Error('Fast route timeout')), FAST_TIMEOUT) - ), - ]); - } catch (fastError) { - this.logger.warn( - `[transcribeRealtimeWithFallback] Fast route failed: ${fastError.message}` - ); - - // Check if this is a rate limit error (429) that should retry with different service - if (this.shouldRetryWithDifferentService(fastError)) { - const remainingTime = checkTimeout('service-retry'); - this.logger.log( - `[transcribeRealtimeWithFallback] Attempting service retry for rate limit error (${remainingTime}ms remaining)` - ); - - // Attempt 2: Try with different Azure service - try { - const serviceRetryTimeout = Math.min(FAST_TIMEOUT, remainingTime - 5000); // Leave 5s buffer - return await Promise.race([ - this.transcribeRealtimeWithServiceRetry( - audioPath, - memoId, - userId, - spaceId, - recordingLanguages, - token, - enableDiarization, - isAppend, - recordingIndex - ), - new Promise((_, reject) => - setTimeout(() => reject(new Error('Service retry timeout')), serviceRetryTimeout) - ), - ]); - } catch (serviceRetryError) { - this.logger.warn( - `[transcribeRealtimeWithFallback] Service retry failed: ${serviceRetryError.message}` - ); - // Continue to next fallback option - } - } - - // Check if this is a 422 error or format-related issue that could be resolved by conversion - if (this.shouldRetryWithConversion(fastError)) { - const remainingTime = checkTimeout('conversion-retry'); - this.logger.log( - `[transcribeRealtimeWithFallback] Attempting conversion retry for format-related error (${remainingTime}ms remaining)` - ); - - // Attempt 3: Try with additional audio conversion/preprocessing - try { - const conversionTimeout = Math.min(FAST_TIMEOUT, remainingTime - 5000); // Leave 5s buffer - return await Promise.race([ - this.transcribeRealtimeWithConversion( - audioPath, - memoId, - userId, - spaceId, - recordingLanguages, - token, - enableDiarization, - isAppend, - recordingIndex - ), - new Promise((_, reject) => - setTimeout(() => reject(new Error('Conversion retry timeout')), conversionTimeout) - ), - ]); - } catch (conversionError) { - this.logger.warn( - `[transcribeRealtimeWithFallback] Conversion retry failed: ${conversionError.message}` - ); - - // Attempt 4: Fallback to batch processing - checkTimeout('batch-fallback'); - this.logger.log(`[transcribeRealtimeWithFallback] Falling back to batch processing`); - return await this.fallbackToBatchProcessing( - audioPath, - memoId, - userId, - spaceId, - recordingLanguages, - token, - enableDiarization, - isAppend, - recordingIndex - ); - } - } else { - // For non-format, non-rate-limit errors, go directly to batch fallback - checkTimeout('direct-batch-fallback'); - this.logger.log( - `[transcribeRealtimeWithFallback] Non-format error, falling back to batch processing` - ); - return await this.fallbackToBatchProcessing( - audioPath, - memoId, - userId, - spaceId, - recordingLanguages, - token, - enableDiarization, - isAppend, - recordingIndex - ); - } - } - } catch (error) { - this.logger.error( - `[transcribeRealtimeWithFallback] All fallback attempts failed after ${Date.now() - startTime}ms:`, - error - ); - - // Determine which stage failed for better error reporting - let fallbackStage = 'unknown'; - if (error.message?.includes('timeout')) { - fallbackStage = 'timeout'; - } else if (error.message?.includes('service-retry')) { - fallbackStage = 'service-retry'; - } else if (error.message?.includes('conversion')) { - fallbackStage = 'conversion-retry'; - } else if (error.message?.includes('batch')) { - fallbackStage = 'batch-fallback'; - } else { - fallbackStage = 'initial-fast'; - } - - // Notify memoro service of final failure with enhanced context - try { - await this.notifyTranscriptionErrorWithContext( - memoId, - userId, - error.message, - 'fast', - fallbackStage, - token - ); - } catch (notifyError) { - this.logger.error(`Failed to notify transcription error:`, notifyError); - } - - throw error; - } - } - - /** - * Fast transcription using Azure Speech API for files <115 minutes and <300MB - */ - async transcribeRealtime( - audioPath: string, - memoId: string, - userId: string, - spaceId?: string, - recordingLanguages?: string[], - token?: string, - enableDiarization?: boolean, - isAppend?: boolean, - recordingIndex?: number - ) { - try { - this.logger.log(`[transcribeRealtime] Starting fast transcription for ${audioPath}`); - - // Download audio from storage - const audioBuffer = await this.downloadFromStorage(audioPath, token); - this.logger.log(`Downloaded audio: ${audioBuffer.length} bytes`); - - // Convert to Azure-compatible format - const convertedAudio = await this.convertAudioForAzure(audioBuffer, audioPath); - this.logger.log(`Converted audio: ${convertedAudio.length} bytes`); - - // Perform real-time transcription using Azure Speech API - const transcriptionResult = await this.performRealtimeTranscription( - convertedAudio, - recordingLanguages, - enableDiarization - ); - - // Send appropriate callback based on operation type - if (isAppend) { - // Send append-specific callback - await this.notifyAppendTranscriptionComplete( - memoId, - userId, - transcriptionResult, - 'fast', - token, - recordingIndex - ); - this.logger.log(`[transcribeRealtime] Sent append callback for memo ${memoId}`); - } else { - // Send regular transcription callback - await this.notifyTranscriptionComplete(memoId, userId, transcriptionResult, 'fast', token); - } - - return { - success: true, - route: 'fast', - memoId, - message: 'Fast transcription completed successfully', - }; - } catch (error) { - this.logger.error(`[transcribeRealtime] Error:`, error); - throw error; // Don't notify here - let the fallback handler manage notifications - } - } - - /** - * Get random Azure Speech Service configuration for load balancing - */ - private getRandomSpeechService() { - const speechServices = [ - { - key: this.configService.get('PROD_MEMORO_TRANSCRIBE_SWE'), - endpoint: - 'https://swedencentral.api.cognitive.microsoft.com/speechtotext/transcriptions:transcribe', - region: 'swedencentral', - name: 'prod-memoro-transcribe-swe', - }, - { - key: this.configService.get('PROD_MEMORO_TRANSCRIBE_SWE2'), - endpoint: - 'https://swedencentral.api.cognitive.microsoft.com/speechtotext/transcriptions:transcribe', - region: 'swedencentral', - name: 'prod-memoro-transcribe-swe2', - }, - { - key: this.configService.get('PROD_MEMORO_TRANSCRIBE_SWE3'), - endpoint: - 'https://swedencentral.api.cognitive.microsoft.com/speechtotext/transcriptions:transcribe', - region: 'swedencentral', - name: 'prod-memoro-transcribe-swe3', - }, - { - key: this.configService.get('PROD_MEMORO_TRANSCRIBE_SWE4'), - endpoint: - 'https://swedencentral.api.cognitive.microsoft.com/speechtotext/transcriptions:transcribe', - region: 'swedencentral', - name: 'prod-memoro-transcribe-swe4', - }, - ]; - - // Filter out services without keys and fallback to original config - const validServices = speechServices.filter((service) => service.key); - - if (validServices.length === 0) { - // Fallback to original single service configuration - const azureKey = this.configService.get('AZURE_SPEECH_KEY'); - const azureRegion = this.configService.get('AZURE_SPEECH_REGION'); - - if (!azureKey || !azureRegion) { - throw new Error('No Azure Speech credentials configured'); - } - - return { - key: azureKey, - endpoint: `https://${azureRegion}.api.cognitive.microsoft.com/speechtotext/transcriptions:transcribe`, - region: azureRegion, - name: 'fallback-service', - }; - } - - // Random selection for load balancing - const randomIndex = Math.floor(Math.random() * validServices.length); - const selectedService = validServices[randomIndex]; - - this.logger.log( - `[getRandomSpeechService] Selected service: ${selectedService.name} (${randomIndex + 1}/${validServices.length})` - ); - - return selectedService; - } - - /** - * Performs real-time transcription using Azure Speech API with load balancing - */ - private async performRealtimeTranscription( - audioBuffer: Buffer, - recordingLanguages?: string[], - enableDiarization?: boolean - ) { - const speechService = this.getRandomSpeechService(); - - // FIXED: Correct Azure Fast Transcription API diarization configuration - const definition: any = { - wordLevelTimestampsEnabled: true, - punctuationMode: 'Automatic', - profanityFilterMode: 'None', - }; - - // Conditionally add diarization based on user preference (default: enabled) - if (enableDiarization !== false) { - definition.diarization = { - enabled: true, - maxSpeakers: 10, // Correct format: maxSpeakers instead of speakers.maxCount - }; - } - - // Language identification setup - const CANDIDATE_LOCALES = [ - 'de-DE', - 'en-GB', - 'fr-FR', - 'it-IT', - 'es-ES', - 'sv-SE', - 'ru-RU', - 'nl-NL', - 'tr-TR', - 'pt-PT', - ]; - - if (recordingLanguages && recordingLanguages.length > 0) { - this.logger.log(`Using provided languages: ${recordingLanguages.join(', ')}`); - definition['languageIdentification'] = { - candidateLocales: recordingLanguages, - }; - } else { - this.logger.log(`Using default candidate locales: ${CANDIDATE_LOCALES.join(', ')}`); - definition['languageIdentification'] = { - candidateLocales: CANDIDATE_LOCALES, - }; - } - - // Prepare form data - const formData = new FormData(); - - // DEBUG: Log the exact definition being sent to Azure - this.logger.log( - `[Azure Request] DEBUG - Definition being sent: ${JSON.stringify(definition, null, 2)}` - ); - - formData.append('definition', JSON.stringify(definition)); - - // Create blob from buffer - const audioBlob = new Blob([audioBuffer], { type: 'audio/wav' }); - formData.append('audio', audioBlob, 'audio.wav'); - - this.logger.log(`Sending to Azure Speech API (${speechService.name})...`); - - const response = await fetch(`${speechService.endpoint}?api-version=2024-11-15`, { - method: 'POST', - headers: { - 'Ocp-Apim-Subscription-Key': speechService.key, - Accept: 'application/json', - }, - body: formData, - }); - - if (!response.ok) { - const errorText = await response.text(); - - // Log comprehensive error details for 429 analysis with special tags - if (response.status === 429) { - // Special tagged log for easy filtering: [AZURE_429_ERROR] - this.logger.error( - `[AZURE_429_ERROR] Azure Speech API Rate Limited - Service: ${speechService.name}` - ); - this.logger.error(`[AZURE_429_ERROR] Status: ${response.status}`); - this.logger.error(`[AZURE_429_ERROR] Response body: ${errorText}`); - this.logger.error( - `[AZURE_429_ERROR] Retry-After: ${response.headers.get('retry-after') || 'not provided'}` - ); - this.logger.error( - `[AZURE_429_ERROR] x-ms-service-quota-reason: ${response.headers.get('x-ms-service-quota-reason') || 'not provided'}` - ); - this.logger.error( - `[AZURE_429_ERROR] x-ms-request-id: ${response.headers.get('x-ms-request-id') || 'not provided'}` - ); - this.logger.error( - `[AZURE_429_ERROR] x-ms-retry-after-ms: ${response.headers.get('x-ms-retry-after-ms') || 'not provided'}` - ); - this.logger.error( - `[AZURE_429_ERROR] x-ms-error-code: ${response.headers.get('x-ms-error-code') || 'not provided'}` - ); - - // Log all headers for comprehensive analysis - const allHeaders = {}; - response.headers.forEach((value, key) => { - allHeaders[key] = value; - }); - this.logger.error( - `[AZURE_429_ERROR] All response headers: ${JSON.stringify(allHeaders, null, 2)}` - ); - } else if (response.status === 422) { - // Special tagged log for format errors: [AZURE_422_ERROR] - this.logger.error( - `[AZURE_422_ERROR] Azure Speech API Format Error - Service: ${speechService.name}` - ); - this.logger.error(`[AZURE_422_ERROR] Status: ${response.status}`); - this.logger.error(`[AZURE_422_ERROR] Response body: ${errorText}`); - this.logger.error( - `[AZURE_422_ERROR] x-ms-request-id: ${response.headers.get('x-ms-request-id') || 'not provided'}` - ); - } else { - this.logger.error(`Azure API error: ${response.status} - ${errorText}`); - } - - throw new Error(`Azure Speech API error: ${response.status} - ${errorText}`); - } - - const result = await response.json(); - this.logger.log(`Azure transcription result received`); - - // DEBUG: Log what Azure is actually returning for diarization analysis - this.logger.log(`[Azure Response] DEBUG - Full result keys: ${Object.keys(result).join(', ')}`); - this.logger.log( - `[Azure Response] DEBUG - Has phrases: ${!!result.phrases} (count: ${result.phrases?.length || 0})` - ); - - if (result.phrases && result.phrases.length > 0) { - const firstPhrase = result.phrases[0]; - this.logger.log( - `[Azure Response] DEBUG - First phrase keys: ${Object.keys(firstPhrase).join(', ')}` - ); - this.logger.log( - `[Azure Response] DEBUG - First phrase has speaker: ${firstPhrase.speaker !== undefined} (value: ${firstPhrase.speaker})` - ); - this.logger.log( - `[Azure Response] DEBUG - First phrase sample: ${JSON.stringify(firstPhrase, null, 2)}` - ); - - // Count how many phrases have speaker info - const phrasesWithSpeakers = result.phrases.filter((p) => p.speaker !== undefined); - this.logger.log( - `[Azure Response] DEBUG - Phrases with speaker data: ${phrasesWithSpeakers.length}/${result.phrases.length}` - ); - } else { - this.logger.warn(`[Azure Response] DEBUG - No phrases found in result!`); - } - - // Process the result to match existing data structure - return this.processTranscriptionResult(result); - } - - /** - * Performs real-time transcription with retry logic (selects different service) - */ - private async performRealtimeTranscriptionWithRetry( - audioBuffer: Buffer, - recordingLanguages?: string[], - enableDiarization?: boolean, - excludeServices: string[] = [] - ) { - // Get all available services and exclude the ones that already failed - const allServices = [ - { - key: this.configService.get('PROD_MEMORO_TRANSCRIBE_SWE'), - endpoint: - 'https://swedencentral.api.cognitive.microsoft.com/speechtotext/transcriptions:transcribe', - region: 'swedencentral', - name: 'prod-memoro-transcribe-swe', - }, - { - key: this.configService.get('PROD_MEMORO_TRANSCRIBE_SWE2'), - endpoint: - 'https://swedencentral.api.cognitive.microsoft.com/speechtotext/transcriptions:transcribe', - region: 'swedencentral', - name: 'prod-memoro-transcribe-swe2', - }, - { - key: this.configService.get('PROD_MEMORO_TRANSCRIBE_SWE3'), - endpoint: - 'https://swedencentral.api.cognitive.microsoft.com/speechtotext/transcriptions:transcribe', - region: 'swedencentral', - name: 'prod-memoro-transcribe-swe3', - }, - { - key: this.configService.get('PROD_MEMORO_TRANSCRIBE_SWE4'), - endpoint: - 'https://swedencentral.api.cognitive.microsoft.com/speechtotext/transcriptions:transcribe', - region: 'swedencentral', - name: 'prod-memoro-transcribe-swe4', - }, - ]; - - // Filter out excluded services and services without keys - const availableServices = allServices.filter( - (service) => service.key && !excludeServices.includes(service.name) - ); - - if (availableServices.length === 0) { - throw new Error('No available Azure Speech services for retry'); - } - - // Pick a random service from available ones - const randomIndex = Math.floor(Math.random() * availableServices.length); - const speechService = availableServices[randomIndex]; - - this.logger.log( - `[performRealtimeTranscriptionWithRetry] Selected service: ${speechService.name} (${randomIndex + 1}/${availableServices.length} available)` - ); - - // Enhanced configuration with speaker diarization (up to 10 speakers) - const definition: any = { - wordLevelTimestampsEnabled: true, - punctuationMode: 'Automatic', - profanityFilterMode: 'None', - }; - - // Conditionally add diarization based on user preference (default: enabled) - if (enableDiarization !== false) { - definition.diarization = { - enabled: true, - maxSpeakers: 10, - }; - } - - // Language identification setup - const CANDIDATE_LOCALES = [ - 'de-DE', - 'en-GB', - 'fr-FR', - 'it-IT', - 'es-ES', - 'sv-SE', - 'ru-RU', - 'nl-NL', - 'tr-TR', - 'pt-PT', - ]; - - if (recordingLanguages && recordingLanguages.length > 0) { - this.logger.log(`Using provided languages: ${recordingLanguages.join(', ')}`); - definition['languageIdentification'] = { - candidateLocales: recordingLanguages, - }; - } else { - this.logger.log(`Using default candidate locales: ${CANDIDATE_LOCALES.join(', ')}`); - definition['languageIdentification'] = { - candidateLocales: CANDIDATE_LOCALES, - }; - } - - // Prepare form data - const formData = new FormData(); - formData.append('definition', JSON.stringify(definition)); - - // Create blob from buffer - const audioBlob = new Blob([audioBuffer], { type: 'audio/wav' }); - formData.append('audio', audioBlob, 'audio.wav'); - - this.logger.log(`Sending to Azure Speech API retry (${speechService.name})...`); - - const response = await fetch(`${speechService.endpoint}?api-version=2024-11-15`, { - method: 'POST', - headers: { - 'Ocp-Apim-Subscription-Key': speechService.key, - Accept: 'application/json', - }, - body: formData, - }); - - if (!response.ok) { - const errorText = await response.text(); - - // Log comprehensive error details for 429 analysis with special tags - if (response.status === 429) { - // Special tagged log for easy filtering: [AZURE_429_RETRY_ERROR] - this.logger.error( - `[AZURE_429_RETRY_ERROR] Azure Speech API Rate Limited on Retry - Service: ${speechService.name}` - ); - this.logger.error(`[AZURE_429_RETRY_ERROR] Status: ${response.status}`); - this.logger.error(`[AZURE_429_RETRY_ERROR] Response body: ${errorText}`); - this.logger.error( - `[AZURE_429_RETRY_ERROR] Retry-After: ${response.headers.get('retry-after') || 'not provided'}` - ); - this.logger.error( - `[AZURE_429_RETRY_ERROR] x-ms-service-quota-reason: ${response.headers.get('x-ms-service-quota-reason') || 'not provided'}` - ); - this.logger.error( - `[AZURE_429_RETRY_ERROR] x-ms-request-id: ${response.headers.get('x-ms-request-id') || 'not provided'}` - ); - this.logger.error( - `[AZURE_429_RETRY_ERROR] x-ms-retry-after-ms: ${response.headers.get('x-ms-retry-after-ms') || 'not provided'}` - ); - this.logger.error( - `[AZURE_429_RETRY_ERROR] x-ms-error-code: ${response.headers.get('x-ms-error-code') || 'not provided'}` - ); - - // Log all headers for comprehensive analysis - const allHeaders = {}; - response.headers.forEach((value, key) => { - allHeaders[key] = value; - }); - this.logger.error( - `[AZURE_429_RETRY_ERROR] All response headers: ${JSON.stringify(allHeaders, null, 2)}` - ); - } else if (response.status === 422) { - // Special tagged log for format errors: [AZURE_422_RETRY_ERROR] - this.logger.error( - `[AZURE_422_RETRY_ERROR] Azure Speech API Format Error on Retry - Service: ${speechService.name}` - ); - this.logger.error(`[AZURE_422_RETRY_ERROR] Status: ${response.status}`); - this.logger.error(`[AZURE_422_RETRY_ERROR] Response body: ${errorText}`); - this.logger.error( - `[AZURE_422_RETRY_ERROR] x-ms-request-id: ${response.headers.get('x-ms-request-id') || 'not provided'}` - ); - } else { - this.logger.error(`Azure API retry error: ${response.status} - ${errorText}`); - } - - throw new Error(`Azure Speech API retry error: ${response.status} - ${errorText}`); - } - - const result = await response.json(); - this.logger.log(`Azure transcription retry result received`); - - // Process the result to match existing data structure - return this.processTranscriptionResult(result); - } - - /** - * Determines if error should trigger conversion retry - */ - private shouldRetryWithConversion(error: any): boolean { - // Direct status code check - const statusCode = - error.status || error.response?.status || this.extractStatusFromMessage(error.message); - const is422Error = statusCode === 422; - - // More specific format error patterns - const formatErrorPatterns = [ - /unsupported.*format/i, - /invalid.*audio/i, - /codec.*not.*supported/i, - /content.*type.*unsupported/i, - /bitrate.*not.*supported/i, - /sample.*rate.*invalid/i, - /audio.*format.*error/i, - /media.*type.*not.*supported/i, - ]; - - const errorText = error.message || error.toString() || ''; - const isFormatError = formatErrorPatterns.some((pattern) => pattern.test(errorText)); - - const shouldRetry = is422Error || isFormatError; - this.logger.log( - `[shouldRetryWithConversion] Error analysis: status=${statusCode}, 422=${is422Error}, format=${isFormatError}, shouldRetry=${shouldRetry}` - ); - - return shouldRetry; - } - - /** - * Determines if error should trigger service retry (429, 503, etc.) - */ - private shouldRetryWithDifferentService(error: any): boolean { - const statusCode = - error.status || error.response?.status || this.extractStatusFromMessage(error.message); - const retryableStatuses = [429, 503, 502, 500]; // Rate limit, service unavailable, bad gateway, internal error - - const shouldRetry = retryableStatuses.includes(statusCode); - this.logger.log( - `[shouldRetryWithDifferentService] Error analysis: status=${statusCode}, shouldRetry=${shouldRetry}` - ); - - return shouldRetry; - } - - /** - * Extract status code from error message like "Azure Speech API error: 429 - ..." - */ - private extractStatusFromMessage(message: string): number | undefined { - if (!message) return undefined; - - const statusMatch = message.match(/error:\s*(\d{3})/i); - return statusMatch ? parseInt(statusMatch[1], 10) : undefined; - } - - /** - * Attempts transcription with service retry (different Azure endpoint) - */ - private async transcribeRealtimeWithServiceRetry( - audioPath: string, - memoId: string, - userId: string, - spaceId?: string, - recordingLanguages?: string[], - token?: string, - enableDiarization?: boolean, - isAppend?: boolean, - recordingIndex?: number - ) { - this.logger.log( - `[transcribeRealtimeWithServiceRetry] Attempting service retry for ${audioPath}` - ); - - // Download audio from storage - const audioBuffer = await this.downloadFromStorage(audioPath, token); - this.logger.log(`Downloaded audio for service retry: ${audioBuffer.length} bytes`); - - // Convert to Azure-compatible format - const convertedAudio = await this.convertAudioForAzure(audioBuffer, audioPath); - this.logger.log(`Converted audio for service retry: ${convertedAudio.length} bytes`); - - // Perform real-time transcription using a different Azure Speech API service - const transcriptionResult = await this.performRealtimeTranscriptionWithRetry( - convertedAudio, - recordingLanguages, - enableDiarization - ); - - // Send appropriate callback based on operation type - if (isAppend) { - await this.notifyAppendTranscriptionComplete( - memoId, - userId, - transcriptionResult, - 'fast', - token, - recordingIndex - ); - } else { - await this.notifyTranscriptionComplete(memoId, userId, transcriptionResult, 'fast', token); - } - - return { - success: true, - route: 'fast-service-retry', - memoId, - message: 'Fast transcription completed after service retry', - }; - } - - /** - * Attempts transcription with enhanced conversion preprocessing - */ - private async transcribeRealtimeWithConversion( - audioPath: string, - memoId: string, - userId: string, - spaceId?: string, - recordingLanguages?: string[], - token?: string, - enableDiarization?: boolean, - isAppend?: boolean, - recordingIndex?: number - ) { - this.logger.log( - `[transcribeRealtimeWithConversion] Attempting enhanced conversion for ${audioPath}` - ); - - // Download audio from storage - const audioBuffer = await this.downloadFromStorage(audioPath, token); - this.logger.log(`Downloaded audio for conversion retry: ${audioBuffer.length} bytes`); - - // Apply enhanced conversion with multiple format attempts - const convertedAudio = await this.enhancedAudioConversion(audioBuffer, audioPath); - this.logger.log(`Enhanced conversion completed: ${convertedAudio.length} bytes`); - - // Perform real-time transcription using Azure Speech API - const transcriptionResult = await this.performRealtimeTranscription( - convertedAudio, - recordingLanguages, - enableDiarization - ); - - // Send appropriate callback based on operation type - if (isAppend) { - await this.notifyAppendTranscriptionComplete( - memoId, - userId, - transcriptionResult, - 'fast', - token, - recordingIndex - ); - } else { - await this.notifyTranscriptionComplete(memoId, userId, transcriptionResult, 'fast', token); - } - - return { - success: true, - route: 'fast-conversion-retry', - memoId, - message: 'Fast transcription completed after conversion retry', - }; - } - - /** - * Enhanced audio conversion with proper resource management and timeout - */ - private async enhancedAudioConversion(audioBuffer: Buffer, audioPath?: string): Promise { - this.logger.log('[enhancedAudioConversion] Attempting enhanced conversion'); - - const tempDir = os.tmpdir(); - - // Extract the actual file extension from audioPath - const fileExt = audioPath ? path.extname(audioPath) : '.m4a'; // fallback to .m4a - const inputFile = path.join(tempDir, `input_enhanced_${Date.now()}${fileExt}`); - const outputFile = path.join(tempDir, `output_enhanced_${Date.now()}.wav`); - - // Map common extensions to ffmpeg format names - const formatMap: Record = { - '.m4a': 'mp4', - '.mp4': 'mp4', - '.mp3': 'mp3', - '.wav': 'wav', - '.aac': 'aac', - '.ogg': 'ogg', - '.webm': 'webm', - '.flac': 'flac', - }; - - let inputFormat = formatMap[fileExt.toLowerCase()]; - - const cleanup = async () => { - try { - await Promise.all([ - fs.promises.unlink(inputFile).catch(() => {}), - fs.promises.unlink(outputFile).catch(() => {}), - ]); - } catch (error) { - this.logger.warn('Cleanup warning:', error); - } - }; - - try { - // Use async file operations - await fs.promises.writeFile(inputFile, audioBuffer); - - // Probe the file to detect actual format (fixes extension/content mismatch issues) - const probeResult = await this.probeAudioFile(inputFile); - if (probeResult.valid && probeResult.format) { - const probedFormat = probeResult.format.split(',')[0].trim(); - const probeFormatMap: Record = { - mp3: 'mp3', - mov: 'mp4', - mp4: 'mp4', - m4a: 'mp4', - wav: 'wav', - aac: 'aac', - ogg: 'ogg', - webm: 'webm', - flac: 'flac', - matroska: 'matroska', - }; - - if (probeFormatMap[probedFormat]) { - const detectedFormat = probeFormatMap[probedFormat]; - if (detectedFormat !== inputFormat) { - this.logger.warn( - `[enhancedAudioConversion] Format mismatch: extension suggests ${inputFormat}, content is ${detectedFormat}. Using detected format.` - ); - inputFormat = detectedFormat; - } - } - this.logger.log( - `[enhancedAudioConversion] Probed format: ${probeResult.format}, codec: ${probeResult.codec}` - ); - } - - return new Promise((resolve, reject) => { - const command = ffmpeg(inputFile) - .audioCodec('pcm_s16le') // PCM 16-bit little-endian - .audioFrequency(16000) // 16kHz sample rate (Azure's preferred) - .audioChannels(1) // Mono - .format('wav') // WAV format - .inputOptions([ - '-err_detect', - 'ignore_err', // Ignore unsupported metadata boxes (e.g., chnl v1) - '-fflags', - '+genpts', // Generate presentation timestamps - ]) - .audioFilters([ - 'highpass=f=80', // Remove very low frequencies - 'lowpass=f=8000', // Remove frequencies above 8kHz - 'volume=1.5', // Slight volume boost - ]) - .outputOptions(['-y']); // Force overwrite existing files - - // Use the actual detected format instead of file extension - if (inputFormat) { - command.inputFormat(inputFormat); - this.logger.log( - `[enhancedAudioConversion] Using input format: ${inputFormat} for file: ${fileExt}` - ); - } else { - this.logger.warn( - `[enhancedAudioConversion] Unknown format ${fileExt}, letting ffmpeg auto-detect` - ); - } - - command - .on('end', async () => { - try { - const converted = await fs.promises.readFile(outputFile); - await cleanup(); - this.logger.log(`✅ Enhanced audio conversion completed from ${fileExt} to WAV`); - resolve(converted); - } catch (error) { - await cleanup(); - reject(error); - } - }) - .on('error', async (err) => { - await cleanup(); - this.logger.error(`❌ Enhanced conversion error for ${fileExt}:`, err); - reject(err); - }) - .save(outputFile); - }); - } catch (error) { - await cleanup(); - throw error; - } - } - - /** - * Fallback to batch processing when fast routes fail - */ - private async fallbackToBatchProcessing( - audioPath: string, - memoId: string, - userId: string, - spaceId?: string, - recordingLanguages?: string[], - token?: string, - enableDiarization?: boolean, - isAppend?: boolean, - recordingIndex?: number - ) { - this.logger.log(`[fallbackToBatchProcessing] Starting batch fallback for ${audioPath}`); - - try { - // Use existing batch processing logic - const result = await this.processAudioFromStorage( - audioPath, - userId, - spaceId, - recordingLanguages, - token, - memoId, - enableDiarization - ); - - // Notify memoro service that we've fallen back to batch - // Note: The batch webhook will handle the final callback when processing completes - this.logger.log( - `[fallbackToBatchProcessing] Successfully initiated batch processing: ${result.jobId}` - ); - - return { - success: true, - route: 'batch-fallback', - memoId, - jobId: result.jobId, - message: 'Fell back to batch processing after fast route failures', - }; - } catch (batchError) { - this.logger.error(`[fallbackToBatchProcessing] Batch fallback also failed:`, batchError); - throw new Error(`All transcription methods failed. Last error: ${batchError.message}`); - } - } - - /** - * Process Azure transcription result to match existing data structure - */ - private processTranscriptionResult(azureResult: any) { - let text = ''; - let primaryAudioLanguage = null; - let allDetectedPhraseLanguages = ['de-DE']; // Fallback - - // Extract language information - ALWAYS use phrase-level analysis for accuracy - // Azure's top-level locale can be incorrect, so we count phrases by language - if (azureResult.phrases && Array.isArray(azureResult.phrases)) { - const languageCounts = {}; - const languageTextCounts = {}; // Count characters per language for more accuracy - - for (const phrase of azureResult.phrases) { - if (phrase.locale && typeof phrase.locale === 'string') { - // Count phrases - languageCounts[phrase.locale] = (languageCounts[phrase.locale] || 0) + 1; - - // Count characters for weighted analysis - const textLength = phrase.text ? phrase.text.length : 0; - languageTextCounts[phrase.locale] = (languageTextCounts[phrase.locale] || 0) + textLength; - } - } - - const uniqueLanguages = Object.keys(languageCounts); - if (uniqueLanguages.length > 0) { - // Find most frequent language by character count (more accurate than phrase count) - let mostFrequent = uniqueLanguages[0]; - let maxCharCount = languageTextCounts[mostFrequent] || 0; - - for (const locale of uniqueLanguages) { - const charCount = languageTextCounts[locale] || 0; - if (charCount > maxCharCount) { - mostFrequent = locale; - maxCharCount = charCount; - } - } - - primaryAudioLanguage = mostFrequent; - allDetectedPhraseLanguages = uniqueLanguages; - - // Debug logging for language detection - this.logger.log(`[Language Detection] Phrase counts: ${JSON.stringify(languageCounts)}`); - this.logger.log( - `[Language Detection] Character counts: ${JSON.stringify(languageTextCounts)}` - ); - this.logger.log( - `[Language Detection] Primary language: ${primaryAudioLanguage} (${maxCharCount} chars)` - ); - } - } else if (azureResult.locale && typeof azureResult.locale === 'string') { - // Fallback to top-level locale only if no phrases available - primaryAudioLanguage = azureResult.locale; - allDetectedPhraseLanguages = [azureResult.locale]; - this.logger.log( - `[Language Detection] Using top-level locale fallback: ${primaryAudioLanguage}` - ); - } - - // Extract transcript text - if (azureResult.combinedPhrases && Array.isArray(azureResult.combinedPhrases)) { - text = azureResult.combinedPhrases[0]?.text || ''; - } else if (azureResult.phrases && Array.isArray(azureResult.phrases)) { - text = azureResult.phrases.map((phrase: { text?: string }) => phrase.text || '').join(' '); - } - - // Process speaker information (enhanced diarization) - const utterances = []; - const speakerMap = {}; - const speakers = {}; - - if (azureResult.phrases) { - azureResult.phrases.forEach( - (segment: { - speaker?: number; - text?: string; - offsetMilliseconds?: number; - durationMilliseconds?: number; - }) => { - if (segment.speaker !== undefined && segment.text) { - const speakerId = `speaker${segment.speaker}`; - - utterances.push({ - speakerId, - text: segment.text, - offset: segment.offsetMilliseconds, - duration: segment.durationMilliseconds, - }); - - if (!speakerMap[speakerId]) speakerMap[speakerId] = []; - speakerMap[speakerId].push({ - text: segment.text, - offset: segment.offsetMilliseconds, - duration: segment.durationMilliseconds, - }); - } - } - ); - } - - // Sort utterances by time - utterances.sort((a, b) => a.offset - b.offset); - - // Create speaker labels - new Set(utterances.map((u) => u.speakerId)).forEach((id) => { - speakers[id] = `Speaker ${id.replace('speaker', '')}`; - }); - - const speakerCount = Object.keys(speakers).length; - - // Enhanced diarization logging for debugging - this.logger.log( - `[processTranscriptionResult] Transcription processed: ${text.length} chars, ${speakerCount} speakers, language: ${primaryAudioLanguage}` - ); - this.logger.log(`[processTranscriptionResult] Utterances count: ${utterances.length}`); - this.logger.log( - `[processTranscriptionResult] Has speaker data: ${Object.keys(speakers).length > 0}` - ); - this.logger.log( - `[processTranscriptionResult] Has speakerMap data: ${Object.keys(speakerMap).length > 0}` - ); - - if (utterances.length > 0) { - this.logger.log( - `[processTranscriptionResult] First utterance sample: ${JSON.stringify(utterances[0])}` - ); - } - - if (Object.keys(speakers).length > 0) { - this.logger.log(`[processTranscriptionResult] Speaker labels: ${JSON.stringify(speakers)}`); - } - - return { - text, - primary_language: primaryAudioLanguage, - languages: allDetectedPhraseLanguages, - utterances: utterances.length > 0 ? utterances : null, - speakers: Object.keys(speakers).length > 0 ? speakers : null, - speakerMap: Object.keys(speakerMap).length > 0 ? speakerMap : null, - }; - } - - /** - * Notify memoro service of successful append transcription - */ - private async notifyAppendTranscriptionComplete( - memoId: string, - userId: string, - transcriptionResult: any, - route: 'fast' | 'batch', - token?: string, - recordingIndex?: number - ) { - const memoroServiceUrl = this.configService.get('MEMORO_SERVICE_URL'); - - if (!memoroServiceUrl) { - this.logger.error('CRITICAL: MEMORO_SERVICE_URL is not configured'); - throw new Error('Missing required configuration: MEMORO_SERVICE_URL'); - } - - try { - this.logger.log( - `[notifyAppendTranscriptionComplete] Sending append callback for memo ${memoId}, recordingIndex: ${recordingIndex}` - ); - - // Use service role key for service-to-service authentication - const serviceKey = this.configService.get('MEMORO_SUPABASE_SERVICE_KEY'); - - if (!serviceKey) { - this.logger.error( - 'CRITICAL: MEMORO_SUPABASE_SERVICE_KEY is not configured for service-to-service communication' - ); - throw new Error('Missing required configuration: MEMORO_SUPABASE_SERVICE_KEY'); - } - - const response = await fetch( - `${memoroServiceUrl}/memoro/service/append-transcription-completed`, - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${serviceKey}`, - }, - body: JSON.stringify({ - memoId, - userId, - transcriptionResult, - route, - success: true, - recordingIndex, - }), - } - ); - - if (!response.ok) { - const errorText = await response.text(); - throw new Error(`Memoro service error: ${response.status} - ${errorText}`); - } - - this.logger.log( - `Successfully notified memoro service of append completion for memo ${memoId}` - ); - } catch (error) { - this.logger.error('Error notifying memoro service of append transcription:', error); - throw error; - } - } - - /** - * Notify memoro service of successful transcription - */ - private async notifyTranscriptionComplete( - memoId: string, - userId: string, - transcriptionResult: any, - route: 'fast' | 'batch', - token?: string - ) { - const memoroServiceUrl = this.configService.get('MEMORO_SERVICE_URL'); - - if (!memoroServiceUrl) { - this.logger.error('CRITICAL: MEMORO_SERVICE_URL is not configured'); - throw new Error('Missing required configuration: MEMORO_SERVICE_URL'); - } - - try { - // DEBUG: Log what we're sending to memoro service - this.logger.log(`[notifyTranscriptionComplete] Sending callback for memo ${memoId}`); - this.logger.log( - `[notifyTranscriptionComplete] transcriptionResult keys: ${Object.keys(transcriptionResult || {}).join(', ')}` - ); - this.logger.log( - `[notifyTranscriptionComplete] Has text: ${!!transcriptionResult?.text} (length: ${transcriptionResult?.text?.length || 0})` - ); - this.logger.log( - `[notifyTranscriptionComplete] Has utterances: ${!!transcriptionResult?.utterances} (count: ${transcriptionResult?.utterances?.length || 0})` - ); - this.logger.log( - `[notifyTranscriptionComplete] Has speakers: ${!!transcriptionResult?.speakers}` - ); - this.logger.log( - `[notifyTranscriptionComplete] Has speakerMap: ${!!transcriptionResult?.speakerMap}` - ); - - // Use service role key for service-to-service authentication - const serviceKey = this.configService.get('MEMORO_SUPABASE_SERVICE_KEY'); - - if (!serviceKey) { - this.logger.error( - 'CRITICAL: MEMORO_SUPABASE_SERVICE_KEY is not configured for service-to-service communication' - ); - throw new Error('Missing required configuration: MEMORO_SUPABASE_SERVICE_KEY'); - } - - const response = await fetch(`${memoroServiceUrl}/memoro/service/transcription-completed`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${serviceKey}`, - }, - body: JSON.stringify({ - memoId, - userId, - transcriptionResult, - route, - success: true, - }), - }); - - if (!response.ok) { - const errorText = await response.text(); - throw new Error(`Memoro service error: ${response.status} - ${errorText}`); - } - - this.logger.log(`Successfully notified memoro service of completion for memo ${memoId}`); - } catch (error) { - this.logger.error('Error notifying memoro service:', error); - throw error; - } - } - - /** - * Notify memoro service of transcription error with enhanced context - */ - private async notifyTranscriptionErrorWithContext( - memoId: string, - userId: string, - errorMessage: string, - route: 'fast' | 'batch', - fallbackStage: string, - token?: string - ) { - const memoroServiceUrl = this.configService.get('MEMORO_SERVICE_URL'); - - if (!memoroServiceUrl) { - this.logger.error('CRITICAL: MEMORO_SERVICE_URL is not configured'); - throw new Error('Missing required configuration: MEMORO_SERVICE_URL'); - } - - try { - const errorContext = { - memoId, - userId, - route, - fallbackStage, - error: errorMessage, - timestamp: new Date().toISOString(), - success: false, - }; - - // Use service role key for service-to-service authentication - const serviceKey = this.configService.get('MEMORO_SUPABASE_SERVICE_KEY'); - - if (!serviceKey) { - this.logger.error( - 'CRITICAL: MEMORO_SUPABASE_SERVICE_KEY is not configured for service-to-service communication' - ); - throw new Error('Missing required configuration: MEMORO_SUPABASE_SERVICE_KEY'); - } - - const response = await fetch(`${memoroServiceUrl}/memoro/service/transcription-completed`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${serviceKey}`, - }, - body: JSON.stringify(errorContext), - }); - - if (!response.ok) { - const errorText = await response.text(); - this.logger.error( - `Failed to notify error to memoro service: ${response.status} - ${errorText}` - ); - } - } catch (error) { - this.logger.error('Error notifying memoro service of error:', error); - } - } - - /** - * Notify memoro service of transcription error (legacy method for compatibility) - */ - // Method removed: notifyTranscriptionError - was unused (TSLint 6133) - - private async processAudio( - audioBuffer: Buffer, - userId: string, - spaceId?: string, - recordingLanguages?: string[], - enableDiarization?: boolean, - audioPath?: string - ) { - try { - // 1. Get audio duration - const duration = await this.getAudioDuration(audioBuffer); - const durationMinutes = duration / 60; - const shouldUseBatch = durationMinutes > this.batchThresholdMinutes; - - this.logger.log(`Audio: ${durationMinutes.toFixed(2)} minutes, batch: ${shouldUseBatch}`); - - const processedAudio = await this.convertAudioForAzure(audioBuffer, audioPath); - this.logger.log(`Converted audio: ${processedAudio.length} bytes`); - - // Upload to Azure Blob Storage - const blobUrl = await this.uploadToAzureBlob(processedAudio, userId); - this.logger.log(`Uploaded to Azure Blob: ${blobUrl}`); - - // Create Azure Batch Job - const jobId = await this.createBatchJob( - blobUrl, - userId, - recordingLanguages, - enableDiarization - ); - this.logger.log(`Created batch job: ${jobId}`); - - // Return immediate response - return { - status: 'processing', - type: 'batch', - jobId, - userId, - spaceId, - duration, - message: 'Batch transcription started. Webhook will notify when complete.', - }; - } catch (error) { - return { - status: 'failed', - message: error.message, - type: 'batch', - jobId: null, - userId, - spaceId, - }; - } - } - - private async getAudioDuration(audioBuffer: Buffer): Promise { - return new Promise((resolve, reject) => { - if (!audioBuffer || !(audioBuffer instanceof Buffer)) { - this.logger.error('Invalid audio buffer provided'); - return reject(new Error('Invalid audio buffer provided')); - } - - const tempFile = path.join(os.tmpdir(), `audio_${Date.now()}.tmp`); - - try { - fs.writeFileSync(tempFile, audioBuffer); - - ffmpeg.ffprobe(tempFile, (err, metadata) => { - // Cleanup - try { - fs.unlinkSync(tempFile); - } catch {} - - if (err) { - reject(err); - return; - } - - const duration = metadata?.format?.duration; - if (typeof duration === 'number') { - resolve(duration); - } else { - reject(new Error('Could not determine duration')); - } - }); - } catch (error) { - reject(error); - } - }); - } - - private async uploadToAzureBlob(audioBuffer: Buffer, userId: string): Promise { - const { - BlobServiceClient, - StorageSharedKeyCredential, - generateBlobSASQueryParameters, - BlobSASPermissions, - } = await import('@azure/storage-blob'); - - const accountName = this.configService.get('AZURE_STORAGE_ACCOUNT_NAME'); - const accountKey = this.configService.get('AZURE_STORAGE_ACCOUNT_KEY'); - - if (!accountName || !accountKey) { - throw new Error('Azure Storage credentials not configured'); - } - - const sharedKeyCredential = new StorageSharedKeyCredential(accountName, accountKey); - const blobServiceClient = new BlobServiceClient( - `https://${accountName}.blob.core.windows.net`, - sharedKeyCredential - ); - - const containerName = 'batch-transcription'; - const blobName = `${userId}/${Date.now()}_audio.wav`; - - try { - const containerClient = blobServiceClient.getContainerClient(containerName); - - // Ensure container exists - await containerClient.createIfNotExists(); - - const blockBlobClient = containerClient.getBlockBlobClient(blobName); - - await blockBlobClient.upload(audioBuffer, audioBuffer.length, { - blobHTTPHeaders: { blobContentType: 'audio/wav' }, - }); - - // Generate SAS token that expires in 2 hours - const sasOptions = { - containerName, - blobName, - permissions: BlobSASPermissions.parse('r'), // Read-only permission - startsOn: new Date(new Date().valueOf() - 5 * 60 * 1000), // Start 5 minutes ago to avoid clock skew issues - expiresOn: new Date(new Date().valueOf() + 6 * 60 * 60 * 1000), // Expires in 6 hours - }; - - // Generate the SAS token - const sasToken = generateBlobSASQueryParameters(sasOptions, sharedKeyCredential).toString(); - - // Construct the full URL with SAS token - const blobUrlWithSas = `${blockBlobClient.url}?${sasToken}`; - - this.logger.log( - `✅ Uploaded to Azure Blob with SAS token: ${blobUrlWithSas.substring(0, 100)}...` - ); - - return blobUrlWithSas; - } catch (error) { - this.logger.error('Azure Blob upload failed:', error); - throw error; - } - } - - private async createBatchJob( - blobUrl: string, - userId: string, - recordingLanguages?: string[], - enableDiarization?: boolean - ): Promise { - const speechService = this.getRandomSpeechService(); - const accountName = this.configService.get('AZURE_STORAGE_ACCOUNT_NAME'); - const accountKey = this.configService.get('AZURE_STORAGE_ACCOUNT_KEY'); - - if (!accountName || !accountKey) { - throw new Error('Azure Storage credentials not configured'); - } - - // Create a SAS token for the results container - const { - StorageSharedKeyCredential, - generateBlobSASQueryParameters, - ContainerSASPermissions, - BlobServiceClient, - } = await import('@azure/storage-blob'); - const sharedKeyCredential = new StorageSharedKeyCredential(accountName, accountKey); - const resultsContainerName = 'results'; - - // Ensure the results container exists - const blobServiceClient = new BlobServiceClient( - `https://${accountName}.blob.core.windows.net`, - sharedKeyCredential - ); - const containerClient = blobServiceClient.getContainerClient(resultsContainerName); - await containerClient.createIfNotExists(); - - // Generate SAS token for the results container - const sasToken = generateBlobSASQueryParameters( - { - containerName: resultsContainerName, - permissions: ContainerSASPermissions.parse('rcw'), // Read + Create + Write - startsOn: new Date(Date.now() - 5 * 60 * 1000), // Start 5 minutes ago to avoid clock skew issues - expiresOn: new Date(Date.now() + 24 * 60 * 60 * 1000), // Valid for 24 hours - }, - sharedKeyCredential - ).toString(); - - // Create the destination URL with SAS token - const destinationUrl = `https://${accountName}.blob.core.windows.net/${resultsContainerName}?${sasToken}`; - - this.logger.log(`Created destination container URL for results with SAS token`); - - // Define constants for speaker detection - const MAX_SPEAKERS = 10; - const DEFAULT_CANDIDATE_LOCALES = [ - 'en-US', - 'de-DE', - 'en-GB', - 'fr-FR', - 'it-IT', - 'es-ES', - 'sv-SE', - 'ru-RU', - 'nl-NL', - 'tr-TR', - 'pt-PT', - ]; - - // Build candidate locales list - ensure main locale is included and no duplicates - const mainLocale = recordingLanguages?.[0] || 'en-US'; - let candidateLocales = - recordingLanguages && recordingLanguages.length > 0 - ? Array.from(new Set([mainLocale, ...recordingLanguages, ...DEFAULT_CANDIDATE_LOCALES])) - : DEFAULT_CANDIDATE_LOCALES; - - // Azure requires: minimum 2, maximum 10 candidate locales - candidateLocales = candidateLocales.slice(0, 10); - if (candidateLocales.length < 2) { - // Ensure we have at least 2 locales by adding en-US as fallback - candidateLocales = Array.from(new Set([...candidateLocales, 'en-US', 'de-DE'])).slice(0, 10); - } - - // Build the transcription config with optional diarization - const properties: Record = { - wordLevelTimestampsEnabled: true, - punctuationMode: 'DictatedAndAutomatic', - profanityFilterMode: 'Masked', - destinationContainerUrl: destinationUrl, // This is REQUIRED for Azure to store results - // Add language identification - dynamically built candidate list - languageIdentification: { - candidateLocales: candidateLocales, - }, - }; - - // Conditionally add diarization based on user preference (default: enabled) - if (enableDiarization !== false) { - properties.diarizationEnabled = true; - properties.diarization = { - speakers: { - minCount: 1, - maxCount: MAX_SPEAKERS, - }, - }; - } - - const config: Record = { - contentUrls: [blobUrl], - properties, - locale: mainLocale, - displayName: `Batch transcription for ${userId}`, - }; - - this.logger.log( - `Enhanced batch transcription config (${speechService.name}): languages=${recordingLanguages?.join(', ') || 'default'}, maxSpeakers=${MAX_SPEAKERS}` - ); - console.log('Starting batch transcription with config: ' + JSON.stringify(config)); - try { - const batchEndpoint = speechService.endpoint.replace( - '/transcriptions:transcribe', - '/v3.1/transcriptions' - ); - const response = await fetch(batchEndpoint, { - method: 'POST', - headers: { - 'Ocp-Apim-Subscription-Key': speechService.key, - 'Content-Type': 'application/json', - }, - body: JSON.stringify(config), - }); - - if (!response.ok) { - const errorText = await response.text(); - throw new Error(`Azure Batch API error: ${response.status} - ${errorText}`); - } - - const result = await response.json(); - const jobId = result.self.split('/').pop(); - - this.logger.log(`✅ Created batch job: ${jobId}`); - return jobId; - } catch (error) { - this.logger.error('Batch job creation failed:', error); - throw error; - } - } - - private async convertAudioForAzure(audioBuffer: Buffer, audioPath?: string): Promise { - const tempDir = os.tmpdir(); - - // Extract the actual file extension from audioPath - const fileExt = audioPath ? path.extname(audioPath) : '.m4a'; // fallback to .m4a - const inputFile = path.join(tempDir, `input_${Date.now()}${fileExt}`); - const outputFile = path.join(tempDir, `output_${Date.now()}.wav`); - - // Map common extensions to ffmpeg format names - const formatMap: Record = { - '.m4a': 'mp4', - '.mp4': 'mp4', - '.mp3': 'mp3', - '.wav': 'wav', - '.aac': 'aac', - '.ogg': 'ogg', - '.webm': 'webm', - '.flac': 'flac', - }; - - const inputFormat = formatMap[fileExt.toLowerCase()]; - - try { - // Write buffer to file - fs.writeFileSync(inputFile, audioBuffer); - - // Verify the written file size matches the buffer - const stats = fs.statSync(inputFile); - if (stats.size !== audioBuffer.length) { - this.logger.error(`Buffer size: ${audioBuffer.length}, Written file size: ${stats.size}`); - throw new Error( - `File write verification failed: expected ${audioBuffer.length} bytes, got ${stats.size} bytes` - ); - } - this.logger.log(`File written and verified: ${stats.size} bytes at ${inputFile}`); - - // Use ffprobe to validate the file before conversion - const probeResult = await this.probeAudioFile(inputFile); - if (!probeResult.valid) { - // Check if it's the known 'chnl' box issue - const isChnlIssue = - probeResult.error?.includes('Unsupported') && probeResult.error?.includes('chnl'); - - if (isChnlIssue) { - this.logger.warn(`FFprobe warning: ${probeResult.error}`); - this.logger.warn( - 'Detected unsupported chnl box (iOS spatial audio metadata) - will attempt conversion with error tolerance' - ); - // Don't throw - ffmpeg with -err_detect ignore_err can handle this - } else { - this.logger.error(`FFprobe error details: ${probeResult.error}`); - this.logger.error(`File path: ${inputFile}`); - this.logger.error(`File exists: ${fs.existsSync(inputFile)}`); - this.logger.error(`File size: ${fs.statSync(inputFile).size}`); - - // Log first 100 bytes as hex for debugging - const fileBuffer = fs.readFileSync(inputFile); - this.logger.error(`First 100 bytes (hex): ${fileBuffer.slice(0, 100).toString('hex')}`); - - throw new Error(`Audio file validation failed: ${probeResult.error}`); - } - } else { - this.logger.log( - `Audio file validated: format=${probeResult.format}, duration=${probeResult.duration}s, codec=${probeResult.codec}` - ); - } - - // IMPORTANT: Use the actual detected format from ffprobe, not the file extension - // This fixes issues where file extension doesn't match actual content (e.g., MP3 saved as .m4a) - let actualInputFormat = inputFormat; - if (probeResult.valid && probeResult.format) { - // ffprobe returns format names like "mp3", "mov,mp4,m4a,3gp,3g2,mj2", etc. - // Extract the primary format and map it to ffmpeg input format - const probedFormat = probeResult.format.split(',')[0].trim(); - const probeFormatMap: Record = { - mp3: 'mp3', - mov: 'mp4', - mp4: 'mp4', - m4a: 'mp4', - wav: 'wav', - aac: 'aac', - ogg: 'ogg', - webm: 'webm', - flac: 'flac', - matroska: 'matroska', - }; - - if (probeFormatMap[probedFormat]) { - actualInputFormat = probeFormatMap[probedFormat]; - if (actualInputFormat !== inputFormat) { - this.logger.warn( - `Format mismatch detected: extension suggests ${inputFormat}, but content is ${actualInputFormat}. Using detected format.` - ); - } - } - } - - // Wrap ffmpeg conversion in a Promise - return new Promise((resolve, reject) => { - const command = ffmpeg(inputFile) - .audioCodec('pcm_s16le') // PCM 16-bit little-endian - .audioFrequency(16000) // 16kHz sample rate - .audioChannels(1) // Mono - .format('wav') // WAV format - .inputOptions([ - '-err_detect', - 'ignore_err', // Ignore unsupported metadata boxes (e.g., chnl v1) - '-fflags', - '+genpts', // Generate presentation timestamps - ]) - .outputOptions(['-y']); // Force overwrite existing files - - // Use the actual detected format (from ffprobe) instead of file extension - if (actualInputFormat) { - command.inputFormat(actualInputFormat); - this.logger.log( - `Using input format: ${actualInputFormat} for file: ${fileExt} (detected: ${probeResult.format})` - ); - } else { - this.logger.warn(`Unknown format ${fileExt}, letting ffmpeg auto-detect`); - } - - command - .on('end', () => { - try { - const converted = fs.readFileSync(outputFile); - fs.unlinkSync(inputFile); - fs.unlinkSync(outputFile); - this.logger.log(`✅ Audio converted from ${fileExt} to WAV for Azure compatibility`); - resolve(converted); - } catch (error) { - reject(error); - } - }) - .on('error', (err) => { - this.logger.error(`❌ FFmpeg conversion error for ${fileExt}: ${err.message}`); - try { - if (fs.existsSync(inputFile)) fs.unlinkSync(inputFile); - if (fs.existsSync(outputFile)) fs.unlinkSync(outputFile); - } catch {} - reject(err); - }) - .save(outputFile); - }); - } catch (error) { - // Clean up on any error before ffmpeg - try { - if (fs.existsSync(inputFile)) fs.unlinkSync(inputFile); - if (fs.existsSync(outputFile)) fs.unlinkSync(outputFile); - } catch {} - throw error; - } - } - - async checkBatchTranscriptionStatus(jobId: string) { - const speechService = this.getRandomSpeechService(); - - try { - // Get transcription status - const batchEndpoint = speechService.endpoint.replace( - '/transcriptions:transcribe', - '/v3.1/transcriptions' - ); - const response = await fetch(`${batchEndpoint}/${jobId}`, { - method: 'GET', - headers: { - 'Ocp-Apim-Subscription-Key': speechService.key, - 'Content-Type': 'application/json', - }, - }); - - if (!response.ok) { - const errorText = await response.text(); - throw new Error(`Azure Batch API error: ${response.status} - ${errorText}`); - } - - const result = await response.json(); - this.logger.log('Batch transcription full details:', JSON.stringify(result, null, 2)); - - // Get detailed error information if the job failed - let errorDetails = null; - if (result.status === 'Failed') { - errorDetails = await this.getTranscriptionError(jobId, speechService); - } - - // Format the response - return { - jobId, - status: result.status, - createdDateTime: result.createdDateTime, - lastActionDateTime: result.lastActionDateTime, - statusMessage: result.statusMessage || 'No status message provided', - percentCompleted: result.percentCompleted, - properties: result.properties, - errorDetails, - results: null, - rawResponse: result, - }; - } catch (error) { - this.logger.error('Error checking batch status:', error); - throw error; - } - } - - private async getTranscriptionError(jobId: string, speechService: any) { - try { - // Get transcription details - const batchEndpoint = speechService.endpoint.replace( - '/transcriptions:transcribe', - '/v3.1/transcriptions' - ); - const response = await fetch(`${batchEndpoint}/${jobId}`, { - method: 'GET', - headers: { - 'Ocp-Apim-Subscription-Key': speechService.key, - 'Content-Type': 'application/json', - }, - }); - - if (!response.ok) { - return 'Could not retrieve error details'; - } - - const result = await response.json(); - - // Check for error information in different places - if (result.properties?.error) { - return result.properties.error; - } - - if (result.properties?.message) { - return result.properties.message; - } - - if (result.statusMessage) { - return result.statusMessage; - } - - // Try to get error from the transcription files - if (result.links?.files) { - const filesResponse = await fetch(result.links.files, { - method: 'GET', - headers: { - 'Ocp-Apim-Subscription-Key': speechService.key, - }, - }); - - if (filesResponse.ok) { - const filesResult = await filesResponse.json(); - - // Look for error files - const errorFile = filesResult.values.find( - (file: any) => file.kind === 'TranscriptionError' - ); - - if (errorFile && errorFile.links?.contentUrl) { - const errorContentResponse = await fetch(errorFile.links.contentUrl, { - method: 'GET', - headers: { - 'Ocp-Apim-Subscription-Key': speechService.key, - }, - }); - - if (errorContentResponse.ok) { - return await errorContentResponse.json(); - } - } - } - } - - return 'No specific error details available'; - } catch (error) { - this.logger.error('Error getting transcription error details:', error); - return 'Error retrieving error details'; - } - } - - async processAudioFromStorage( - audioPath: string, - userId: string, - spaceId?: string, - recordingLanguages?: string[], - token?: string, - memoId?: string, - enableDiarization?: boolean - ) { - try { - this.logger.log(`Downloading audio from storage for batch processing: ${audioPath}`); - - // Download file from Supabase Storage (using service key for batch operations) - const audioBuffer = await this.downloadFromStorage(audioPath); - - this.logger.log(`Downloaded audio: ${audioBuffer.length} bytes`); - - // Use existing processAudio method - const result = await this.processAudio( - audioBuffer, - userId, - spaceId, - recordingLanguages, - enableDiarization, - audioPath - ); - - // Store jobId in memo metadata for recovery tracking - if (result.jobId && result.status === 'processing' && token && memoId) { - try { - await this.storeBatchJobMetadata(memoId, result.jobId, token, userId); - this.logger.log(`Stored batch job metadata for memo ${memoId}, jobId: ${result.jobId}`); - } catch (metadataError) { - this.logger.warn('Failed to store batch job metadata (non-critical):', metadataError); - // Don't fail the entire process if metadata storage fails - } - } - - // Enhanced: Return jobId and other metadata for tracking - if (result.jobId) { - (result as any).memoId = memoId; - } - - return result; - } catch (error) { - this.logger.error('Error in processAudioFromStorage:', error); - throw new Error(`Storage processing failed: ${error.message}`); - } - } - - /** - * Store batch job metadata in memo for recovery tracking - */ - private async storeBatchJobMetadata( - memoId: string, - jobId: string, - token: string, - userId?: string - ): Promise { - const memoroServiceUrl = this.configService.get('MEMORO_SERVICE_URL'); - - if (!memoroServiceUrl) { - this.logger.error('CRITICAL: MEMORO_SERVICE_URL is not configured'); - throw new Error('Missing required configuration: MEMORO_SERVICE_URL'); - } - - try { - // Use service role key for service-to-service authentication - const serviceKey = this.configService.get('MEMORO_SUPABASE_SERVICE_KEY'); - - if (!serviceKey) { - this.logger.error( - 'CRITICAL: MEMORO_SUPABASE_SERVICE_KEY is not configured for service-to-service communication' - ); - throw new Error('Missing required configuration: MEMORO_SUPABASE_SERVICE_KEY'); - } - - const response = await fetch(`${memoroServiceUrl}/memoro/service/update-batch-metadata`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${serviceKey}`, - }, - body: JSON.stringify({ - memoId, - jobId, - batchTranscription: true, - userId, // Pass userId for ownership validation - }), - }); - - if (!response.ok) { - const errorText = await response.text(); - throw new Error(`Memoro service error: ${response.status} - ${errorText}`); - } - - const result = await response.json(); - this.logger.log(`Successfully stored batch metadata: ${JSON.stringify(result)}`); - } catch (error) { - this.logger.error('Error storing batch job metadata:', error); - throw error; - } - } - - private async downloadFromStorage(audioPath: string, token?: string): Promise { - try { - const { createClient } = await import('@supabase/supabase-js'); - const supabaseUrl = this.configService.get('MEMORO_SUPABASE_URL'); - const supabaseServiceKey = this.configService.get('MEMORO_SUPABASE_SERVICE_KEY'); - const supabaseAnonKey = this.configService.get('MEMORO_SUPABASE_ANON_KEY'); - - if (!supabaseUrl) { - this.logger.error('CRITICAL: MEMORO_SUPABASE_URL is not configured'); - throw new Error('Missing required configuration: MEMORO_SUPABASE_URL'); - } - - this.logger.log(`Supabase URL: ${supabaseUrl}`); - this.logger.log(`Has service key: ${!!supabaseServiceKey}`); - this.logger.log(`Has anon key: ${!!supabaseAnonKey}`); - this.logger.log(`Has user token: ${!!token}`); - - if (!supabaseAnonKey && !supabaseServiceKey) { - this.logger.error( - 'CRITICAL: Neither MEMORO_SUPABASE_ANON_KEY nor MEMORO_SUPABASE_SERVICE_KEY is configured' - ); - throw new Error( - 'Missing required configuration: MEMORO_SUPABASE_ANON_KEY or MEMORO_SUPABASE_SERVICE_KEY' - ); - } - - // Try to use service key first, otherwise use user token - const supabase = supabaseServiceKey - ? createClient(supabaseUrl, supabaseServiceKey) - : createClient(supabaseUrl, supabaseAnonKey, { - global: { headers: { Authorization: `Bearer ${token}` } }, - }); - - this.logger.log( - `Using ${supabaseServiceKey ? 'service key' : 'user token'} for storage download` - ); - - // First, try to list the bucket to see if it exists and has files - try { - this.logger.log('Testing bucket access...'); - const { data: bucketList, error: bucketListError } = await supabase.storage.listBuckets(); - this.logger.log( - 'Available buckets:', - JSON.stringify(bucketList?.map((b) => b.name) || [], null, 2) - ); - - if (bucketListError) { - this.logger.error('Bucket list error:', JSON.stringify(bucketListError, null, 2)); - } - - // Try to list files in the user directory - const userDir = audioPath.split('/')[0]; - this.logger.log(`Attempting to list files in user directory: ${userDir}`); - const { data: fileList, error: fileListError } = await supabase.storage - .from('user-uploads') - .list(userDir, { limit: 10 }); - - if (fileListError) { - this.logger.error('File list error:', JSON.stringify(fileListError, null, 2)); - } else { - this.logger.log( - `Files in ${userDir}:`, - JSON.stringify(fileList?.map((f) => f.name) || [], null, 2) - ); - } - } catch (debugError) { - this.logger.error('Debug error:', debugError); - } - - const { data: fileData, error: downloadError } = await supabase.storage - .from('user-uploads') - .download(audioPath); - - if (downloadError) { - this.logger.error( - 'Supabase storage download error:', - JSON.stringify(downloadError, null, 2) - ); - this.logger.error('Attempting to download audioPath:', audioPath); - throw new Error( - `Failed to download file from storage: ${downloadError.message || JSON.stringify(downloadError)}` - ); - } - - if (!fileData) { - throw new Error('No file data returned from Supabase storage'); - } - - // Convert blob to buffer - const arrayBuffer = await fileData.arrayBuffer(); - const buffer = Buffer.from(arrayBuffer); - - // Validate buffer size - if (buffer.length < 1000) { - throw new Error(`Downloaded file is too small: ${buffer.length} bytes`); - } - - // Validate audio file header - const isValidAudio = this.validateAudioHeader(buffer); - if (!isValidAudio) { - this.logger.error('Invalid audio file header detected'); - this.logger.error(`First 20 bytes: ${buffer.slice(0, 20).toString('hex')}`); - throw new Error('Downloaded file does not appear to be a valid audio file'); - } - - this.logger.log(`Successfully downloaded and validated file: ${buffer.length} bytes`); - return buffer; - } catch (error) { - this.logger.error('Error downloading from storage:', error); - throw error; - } - } - - /** - * Probe audio file using ffprobe to get detailed metadata and validate - */ - private async probeAudioFile(filePath: string): Promise<{ - valid: boolean; - format?: string; - duration?: number; - codec?: string; - error?: string; - }> { - return new Promise((resolve) => { - ffmpeg.ffprobe(filePath, (err, metadata) => { - if (err) { - this.logger.error(`FFprobe validation failed for ${filePath}: ${err.message}`); - resolve({ - valid: false, - error: err.message, - }); - } else { - const format = metadata.format?.format_name || 'unknown'; - const duration = metadata.format?.duration || 0; - const codec = metadata.streams?.[0]?.codec_name || 'unknown'; - - resolve({ - valid: true, - format, - duration, - codec, - }); - } - }); - }); - } - - /** - * Validate audio file header to ensure it's a valid audio file - * Supports: M4A, MP4, MP3, WAV, OGG, WEBM, FLAC, AAC - */ - private validateAudioHeader(buffer: Buffer): boolean { - if (buffer.length < 12) return false; - - // M4A/MP4: Check for 'ftyp' box at offset 4 - const ftypCheck = buffer.slice(4, 8).toString('utf-8'); - if (ftypCheck === 'ftyp') return true; - - // M4A/MP4: Sometimes 'mdat' appears first - const mdatCheck = buffer.slice(0, 4).toString('utf-8'); - if (mdatCheck === 'mdat') return true; - - // M4A/MP4: Check for 'wide' atom - if (ftypCheck === 'wide') return true; - - // MP3: Check for ID3 tag or MPEG frame sync - const id3Check = buffer.slice(0, 3).toString('utf-8'); - if (id3Check === 'ID3') return true; - - // MP3: Check for MPEG frame sync (0xFFE or 0xFFF at start) - if (buffer[0] === 0xff && (buffer[1] & 0xe0) === 0xe0) return true; - - // WAV: Check for RIFF header - const riffCheck = buffer.slice(0, 4).toString('utf-8'); - const waveCheck = buffer.slice(8, 12).toString('utf-8'); - if (riffCheck === 'RIFF' && waveCheck === 'WAVE') return true; - - // OGG: Check for OggS header - const oggCheck = buffer.slice(0, 4).toString('utf-8'); - if (oggCheck === 'OggS') return true; - - // WEBM: Check for EBML header (0x1A 0x45 0xDF 0xA3) - if (buffer[0] === 0x1a && buffer[1] === 0x45 && buffer[2] === 0xdf && buffer[3] === 0xa3) - return true; - - // FLAC: Check for fLaC header - const flacCheck = buffer.slice(0, 4).toString('utf-8'); - if (flacCheck === 'fLaC') return true; - - // AAC: Check for ADTS header (0xFF 0xF1 or 0xFF 0xF9) - if (buffer[0] === 0xff && (buffer[1] === 0xf1 || buffer[1] === 0xf9)) return true; - - this.logger.warn('Unknown audio format detected'); - return false; - } - - /** - * Detect if a file is a video based on its header - * Supports: MP4, MOV, AVI, MKV, WEBM, FLV, WMV - */ - private isVideoFile(buffer: Buffer, filePath?: string): boolean { - if (buffer.length < 12) return false; - - // Check file extension first - if (filePath) { - const ext = path.extname(filePath).toLowerCase(); - const videoExtensions = [ - '.mp4', - '.mov', - '.m4v', - '.avi', - '.mkv', - '.webm', - '.flv', - '.wmv', - '.mpeg', - '.mpg', - ]; - if (videoExtensions.includes(ext)) { - this.logger.log(`Detected video file by extension: ${ext}`); - return true; - } - } - - // MP4/MOV: Check for 'ftyp' box - const ftypCheck = buffer.slice(4, 8).toString('utf-8'); - if (ftypCheck === 'ftyp') { - // Check for video-specific brand types - const brandCheck = buffer.slice(8, 12).toString('utf-8'); - const videoBrands = ['mp41', 'mp42', 'isom', 'qt ', 'm4v ']; - if (videoBrands.some((brand) => brandCheck.startsWith(brand))) { - return true; - } - } - - // AVI: Check for RIFF + AVI header - const riffCheck = buffer.slice(0, 4).toString('utf-8'); - const aviCheck = buffer.slice(8, 12).toString('utf-8'); - if (riffCheck === 'RIFF' && aviCheck === 'AVI ') return true; - - // MKV/WEBM: Check for EBML header - if (buffer[0] === 0x1a && buffer[1] === 0x45 && buffer[2] === 0xdf && buffer[3] === 0xa3) { - // Further check for Matroska signature - if (buffer.length >= 20) { - const docType = buffer.slice(16, 20).toString('utf-8'); - if (docType === 'webm' || docType.startsWith('matroska')) return true; - } - return true; // Likely video EBML file - } - - // FLV: Check for FLV header (0x46 0x4C 0x56) - if (buffer[0] === 0x46 && buffer[1] === 0x4c && buffer[2] === 0x56) return true; - - return false; - } - - /** - * Extract audio from video file using FFmpeg - * Converts video to high-quality audio suitable for transcription - * @param videoBuffer - The video file buffer - * @param videoPath - Optional path hint for format detection - * @returns Extracted audio as Buffer in WAV format - */ - async extractAudioFromVideo(videoBuffer: Buffer, videoPath?: string): Promise { - this.logger.log('[extractAudioFromVideo] Starting video-to-audio extraction'); - - const tempDir = os.tmpdir(); - const fileExt = videoPath ? path.extname(videoPath) : '.mp4'; - const inputFile = path.join(tempDir, `video_input_${Date.now()}${fileExt}`); - const outputFile = path.join(tempDir, `audio_output_${Date.now()}.wav`); - - const cleanup = async () => { - try { - await Promise.all([ - fs.promises.unlink(inputFile).catch(() => {}), - fs.promises.unlink(outputFile).catch(() => {}), - ]); - } catch (error) { - this.logger.warn('Cleanup warning:', error); - } - }; - - try { - // Write video buffer to temporary file - await fs.promises.writeFile(inputFile, videoBuffer); - this.logger.log(`[extractAudioFromVideo] Video file written: ${videoBuffer.length} bytes`); - - // Extract audio using FFmpeg - return new Promise((resolve, reject) => { - ffmpeg(inputFile) - .noVideo() // Remove video stream - .audioCodec('pcm_s16le') // PCM 16-bit for best quality - .audioFrequency(16000) // 16kHz sample rate (Azure optimal) - .audioChannels(1) // Mono for speech recognition - .format('wav') // WAV format - .audioFilters([ - 'highpass=f=80', // Remove very low frequencies - 'lowpass=f=8000', // Remove frequencies above 8kHz - 'volume=1.5', // Slight volume boost for better transcription - 'afftdn=nf=-20', // Noise reduction - ]) - .outputOptions([ - '-y', // Overwrite output file - '-loglevel', - 'warning', // Reduce FFmpeg output verbosity - ]) - .on('start', (commandLine) => { - this.logger.log(`[extractAudioFromVideo] FFmpeg command: ${commandLine}`); - }) - .on('progress', (progress) => { - if (progress.percent) { - this.logger.log(`[extractAudioFromVideo] Progress: ${Math.round(progress.percent)}%`); - } - }) - .on('end', async () => { - try { - const audioBuffer = await fs.promises.readFile(outputFile); - await cleanup(); - this.logger.log( - `[extractAudioFromVideo] Successfully extracted audio: ${audioBuffer.length} bytes` - ); - resolve(audioBuffer); - } catch (error) { - await cleanup(); - reject(new Error(`Failed to read extracted audio: ${error.message}`)); - } - }) - .on('error', async (err) => { - await cleanup(); - this.logger.error(`[extractAudioFromVideo] FFmpeg error: ${err.message}`); - reject(new Error(`Video-to-audio extraction failed: ${err.message}`)); - }) - .save(outputFile); - }); - } catch (error) { - await cleanup(); - this.logger.error('[extractAudioFromVideo] Extraction error:', error); - throw error; - } - } - - /** - * Process video file: extract audio then transcribe - * This is the main entry point for video file processing - */ - async processVideoFile( - videoPath: string, - memoId: string, - userId: string, - spaceId?: string, - recordingLanguages?: string[], - token?: string, - enableDiarization?: boolean, - isAppend?: boolean, - recordingIndex?: number - ) { - try { - this.logger.log(`[processVideoFile] Processing video file: ${videoPath}`); - - // Download video from storage - const videoBuffer = await this.downloadFromStorage(videoPath, token); - this.logger.log(`[processVideoFile] Downloaded video: ${videoBuffer.length} bytes`); - - // Verify it's actually a video file - if (!this.isVideoFile(videoBuffer, videoPath)) { - throw new Error('File does not appear to be a valid video file'); - } - - // Extract audio from video - const audioBuffer = await this.extractAudioFromVideo(videoBuffer, videoPath); - this.logger.log(`[processVideoFile] Audio extracted: ${audioBuffer.length} bytes`); - - // Get audio duration for routing decision - const duration = await this.getAudioDuration(audioBuffer); - const durationMinutes = duration / 60; - this.logger.log(`[processVideoFile] Audio duration: ${durationMinutes.toFixed(2)} minutes`); - - // Route to fast or batch transcription based on duration - if (durationMinutes < this.batchThresholdMinutes) { - this.logger.log('[processVideoFile] Using fast transcription route'); - - // Convert to Azure-compatible format - const convertedAudio = await this.convertAudioForAzure(audioBuffer, 'extracted_audio.wav'); - - // Perform real-time transcription - const transcriptionResult = await this.performRealtimeTranscription( - convertedAudio, - recordingLanguages, - enableDiarization - ); - - // Send appropriate callback - if (isAppend) { - await this.notifyAppendTranscriptionComplete( - memoId, - userId, - transcriptionResult, - 'fast', - token, - recordingIndex - ); - } else { - await this.notifyTranscriptionComplete( - memoId, - userId, - transcriptionResult, - 'fast', - token - ); - } - - return { - success: true, - route: 'fast', - source: 'video', - memoId, - message: 'Video processed and transcribed successfully via fast route', - }; - } else { - this.logger.log('[processVideoFile] Using batch transcription route'); - - // Process through batch pipeline - const processedAudio = await this.convertAudioForAzure(audioBuffer, 'extracted_audio.wav'); - const blobUrl = await this.uploadToAzureBlob(processedAudio, userId); - const jobId = await this.createBatchJob( - blobUrl, - userId, - recordingLanguages, - enableDiarization - ); - - // Store batch metadata - if (token && memoId) { - await this.storeBatchJobMetadata(memoId, jobId, token, userId); - } - - return { - success: true, - route: 'batch', - source: 'video', - jobId, - memoId, - userId, - duration, - message: 'Video processed - batch transcription started', - }; - } - } catch (error) { - this.logger.error('[processVideoFile] Error processing video:', error); - throw new Error(`Video processing failed: ${error.message}`); - } - } -} diff --git a/apps/memoro/apps/audio-backend/src/dto/index.ts b/apps/memoro/apps/audio-backend/src/dto/index.ts deleted file mode 100644 index 4529ff196..000000000 --- a/apps/memoro/apps/audio-backend/src/dto/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export * from './transcribe-realtime.dto'; -export * from './transcribe-from-storage.dto'; -export * from './process-video.dto'; -export * from './transcription-response.dto'; diff --git a/apps/memoro/apps/audio-backend/src/dto/process-video.dto.ts b/apps/memoro/apps/audio-backend/src/dto/process-video.dto.ts deleted file mode 100644 index 7f630d296..000000000 --- a/apps/memoro/apps/audio-backend/src/dto/process-video.dto.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { IsString, IsNotEmpty, IsOptional, IsArray, IsBoolean, IsNumber } from 'class-validator'; -import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; - -export class ProcessVideoDto { - @ApiProperty({ - description: 'Path to the video file in cloud storage (gs:// or supabase path)', - example: 'gs://bucket-name/videos/recording.mp4', - }) - @IsString() - @IsNotEmpty() - videoPath: string; - - @ApiProperty({ - description: 'Unique identifier for the memo', - example: '123e4567-e89b-12d3-a456-426614174000', - }) - @IsString() - @IsNotEmpty() - memoId: string; - - @ApiProperty({ - description: 'User ID who owns this transcription', - example: 'user-123', - }) - @IsString() - @IsNotEmpty() - userId: string; - - @ApiPropertyOptional({ - description: 'Space/workspace ID for organization', - example: 'space-456', - }) - @IsString() - @IsOptional() - spaceId?: string; - - @ApiPropertyOptional({ - description: 'Array of language codes for transcription (e.g., ["de-DE", "en-US"])', - example: ['de-DE', 'en-US'], - type: [String], - }) - @IsArray() - @IsOptional() - recordingLanguages?: string[]; - - @ApiPropertyOptional({ - description: 'Enable speaker diarization (speaker separation)', - example: true, - default: false, - }) - @IsBoolean() - @IsOptional() - enableDiarization?: boolean; - - @ApiPropertyOptional({ - description: 'Append to existing transcription instead of replacing', - example: false, - default: false, - }) - @IsBoolean() - @IsOptional() - isAppend?: boolean; - - @ApiPropertyOptional({ - description: 'Index of the recording in a multi-recording session', - example: 0, - }) - @IsNumber() - @IsOptional() - recordingIndex?: number; -} diff --git a/apps/memoro/apps/audio-backend/src/dto/transcribe-from-storage.dto.ts b/apps/memoro/apps/audio-backend/src/dto/transcribe-from-storage.dto.ts deleted file mode 100644 index 1dd3f8dc5..000000000 --- a/apps/memoro/apps/audio-backend/src/dto/transcribe-from-storage.dto.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { IsString, IsNotEmpty, IsOptional, IsArray, IsBoolean } from 'class-validator'; -import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; - -export class TranscribeFromStorageDto { - @ApiProperty({ - description: 'Path to the audio file in cloud storage', - example: 'gs://bucket-name/audio/recording.mp3', - }) - @IsString() - @IsNotEmpty() - audioPath: string; - - @ApiProperty({ - description: 'User ID who owns this transcription', - example: 'user-123', - }) - @IsString() - @IsNotEmpty() - userId: string; - - @ApiPropertyOptional({ - description: 'Space/workspace ID for organization', - example: 'space-456', - }) - @IsString() - @IsOptional() - spaceId?: string; - - @ApiPropertyOptional({ - description: 'Array of language codes for transcription (e.g., ["de-DE", "en-US"])', - example: ['de-DE', 'en-US'], - type: [String], - }) - @IsArray() - @IsOptional() - recordingLanguages?: string[]; - - @ApiPropertyOptional({ - description: 'Unique identifier for the memo (optional for this endpoint)', - example: '123e4567-e89b-12d3-a456-426614174000', - }) - @IsString() - @IsOptional() - memoId?: string; - - @ApiPropertyOptional({ - description: 'Enable speaker diarization (speaker separation)', - example: true, - default: false, - }) - @IsBoolean() - @IsOptional() - enableDiarization?: boolean; -} diff --git a/apps/memoro/apps/audio-backend/src/dto/transcribe-realtime.dto.ts b/apps/memoro/apps/audio-backend/src/dto/transcribe-realtime.dto.ts deleted file mode 100644 index 1a457b1a2..000000000 --- a/apps/memoro/apps/audio-backend/src/dto/transcribe-realtime.dto.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { IsString, IsNotEmpty, IsOptional, IsArray, IsBoolean, IsNumber } from 'class-validator'; -import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; - -export class TranscribeRealtimeDto { - @ApiProperty({ - description: 'Path to the audio file in cloud storage (gs:// or supabase path)', - example: 'gs://bucket-name/audio/recording.mp3', - }) - @IsString() - @IsNotEmpty() - audioPath: string; - - @ApiProperty({ - description: 'Unique identifier for the memo', - example: '123e4567-e89b-12d3-a456-426614174000', - }) - @IsString() - @IsNotEmpty() - memoId: string; - - @ApiProperty({ - description: 'User ID who owns this transcription', - example: 'user-123', - }) - @IsString() - @IsNotEmpty() - userId: string; - - @ApiPropertyOptional({ - description: 'Space/workspace ID for organization', - example: 'space-456', - }) - @IsString() - @IsOptional() - spaceId?: string; - - @ApiPropertyOptional({ - description: 'Array of language codes for transcription (e.g., ["de-DE", "en-US"])', - example: ['de-DE', 'en-US'], - type: [String], - }) - @IsArray() - @IsOptional() - recordingLanguages?: string[]; - - @ApiPropertyOptional({ - description: 'Enable speaker diarization (speaker separation)', - example: true, - default: false, - }) - @IsBoolean() - @IsOptional() - enableDiarization?: boolean; - - @ApiPropertyOptional({ - description: 'Append to existing transcription instead of replacing', - example: false, - default: false, - }) - @IsBoolean() - @IsOptional() - isAppend?: boolean; - - @ApiPropertyOptional({ - description: 'Index of the recording in a multi-recording session', - example: 0, - }) - @IsNumber() - @IsOptional() - recordingIndex?: number; -} diff --git a/apps/memoro/apps/audio-backend/src/dto/transcription-response.dto.ts b/apps/memoro/apps/audio-backend/src/dto/transcription-response.dto.ts deleted file mode 100644 index 5322cb7d5..000000000 --- a/apps/memoro/apps/audio-backend/src/dto/transcription-response.dto.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; - -export class TranscriptionSegment { - @ApiProperty({ - description: 'Text content of the segment', - example: 'Hello, this is a test recording.', - }) - text: string; - - @ApiPropertyOptional({ - description: 'Start time of the segment in seconds', - example: 0.5, - }) - start?: number; - - @ApiPropertyOptional({ - description: 'End time of the segment in seconds', - example: 3.2, - }) - end?: number; - - @ApiPropertyOptional({ - description: 'Speaker identifier (when diarization is enabled)', - example: 'Speaker 1', - }) - speaker?: string; - - @ApiPropertyOptional({ - description: 'Confidence score of the transcription', - example: 0.95, - }) - confidence?: number; -} - -export class TranscriptionResponseDto { - @ApiProperty({ - description: 'Full transcription text', - example: 'Hello, this is a test recording. How are you today?', - }) - text: string; - - @ApiPropertyOptional({ - description: 'Individual transcription segments with timing', - type: [TranscriptionSegment], - }) - segments?: TranscriptionSegment[]; - - @ApiPropertyOptional({ - description: 'Detected language of the audio', - example: 'de-DE', - }) - language?: string; - - @ApiPropertyOptional({ - description: 'Duration of the audio in seconds', - example: 125.5, - }) - duration?: number; - - @ApiProperty({ - description: 'Status of the transcription', - example: 'success', - enum: ['success', 'processing', 'failed'], - }) - status: string; - - @ApiPropertyOptional({ - description: 'Job ID for batch transcriptions (for long audio files)', - example: 'batch-job-12345', - }) - jobId?: string; - - @ApiPropertyOptional({ - description: 'Error message if transcription failed', - example: 'Audio file not found', - }) - error?: string; -} - -export class BatchStatusResponseDto { - @ApiProperty({ - description: 'Current status of the batch job', - example: 'Succeeded', - enum: ['NotStarted', 'Running', 'Succeeded', 'Failed'], - }) - status: string; - - @ApiPropertyOptional({ - description: 'Transcription result (available when status is Succeeded)', - type: TranscriptionResponseDto, - }) - transcription?: TranscriptionResponseDto; - - @ApiPropertyOptional({ - description: 'Error details if the job failed', - example: 'Transcription service timeout', - }) - error?: string; - - @ApiPropertyOptional({ - description: 'Progress percentage (0-100)', - example: 75, - }) - progress?: number; -} diff --git a/apps/memoro/apps/audio-backend/src/main.ts b/apps/memoro/apps/audio-backend/src/main.ts deleted file mode 100644 index 89674626f..000000000 --- a/apps/memoro/apps/audio-backend/src/main.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { NestFactory } from '@nestjs/core'; -import { AppModule } from './app.module'; -import { json, urlencoded } from 'express'; -import { Logger, ValidationPipe } from '@nestjs/common'; -import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; -import helmet from 'helmet'; - -async function bootstrap() { - const app = await NestFactory.create(AppModule); - const logger = new Logger('Bootstrap'); - - // Add security headers with Helmet - app.use( - helmet({ - contentSecurityPolicy: { - directives: { - defaultSrc: ["'self'"], - styleSrc: ["'self'", "'unsafe-inline'"], // For Swagger UI - scriptSrc: ["'self'", "'unsafe-inline'"], // For Swagger UI - imgSrc: ["'self'", 'data:', 'https:'], // For Swagger UI - }, - }, - crossOriginEmbedderPolicy: false, // Disable for Swagger UI compatibility - }) - ); - - // Add request size logging middleware - app.use((req, res, next) => { - const contentLength = req.headers['content-length']; - if (contentLength && parseInt(contentLength) > 100000) { - // Log requests > 100KB - logger.log(`Large request detected: ${contentLength} bytes to ${req.url}`); - } - next(); - }); - - // Configure body parser limits for large JSON payloads - app.use( - json({ - limit: '50mb', - verify: (req, res, buf, encoding) => { - if (buf.length > 50 * 1024 * 1024) { - logger.error(`JSON payload too large: ${buf.length} bytes`); - throw new Error('Payload too large'); - } - }, - }) - ); - app.use(urlencoded({ extended: true, limit: '50mb' })); - - // Enable CORS - app.enableCors({ - origin: process.env.ALLOWED_ORIGINS?.split(',') || '*', - methods: ['GET', 'POST'], - credentials: true, - }); - - // Enable global validation pipe - app.useGlobalPipes( - new ValidationPipe({ - whitelist: true, // Strip properties that don't have decorators - forbidNonWhitelisted: true, // Throw error if non-whitelisted properties are present - transform: true, // Automatically transform payloads to DTO instances - transformOptions: { - enableImplicitConversion: true, // Allow automatic type conversion - }, - }) - ); - - // Swagger API Documentation - const config = new DocumentBuilder() - .setTitle('Audio Transcription API') - .setDescription( - 'Professional API for audio and video transcription with Azure Speech Services. Supports real-time and batch processing, speaker diarization, and multi-language detection.' - ) - .setVersion('1.0') - .addBearerAuth({ - type: 'http', - scheme: 'bearer', - bearerFormat: 'JWT', - description: 'Enter your Bearer token', - }) - .addTag('Audio Transcription', 'Endpoints for audio and video transcription') - .build(); - - const document = SwaggerModule.createDocument(app, config); - SwaggerModule.setup('api-docs', app, document, { - customSiteTitle: 'Audio Transcription API - Documentation', - customCss: '.swagger-ui .topbar { display: none }', - }); - - const port = process.env.PORT || 1337; - await app.listen(port, '0.0.0.0'); - - console.log(`🎵 Audio Transcription Microservice running on port ${port}`); -} - -bootstrap(); diff --git a/apps/memoro/apps/audio-backend/storage_service_role_policy.sql b/apps/memoro/apps/audio-backend/storage_service_role_policy.sql deleted file mode 100644 index 7c3177736..000000000 --- a/apps/memoro/apps/audio-backend/storage_service_role_policy.sql +++ /dev/null @@ -1,9 +0,0 @@ --- Storage policy to allow service role to download audio files for processing --- This is needed for the audio microservice to access user-uploaded files - --- Allow service role to SELECT (download) files from user-uploads bucket -CREATE POLICY "Service role can download files for processing" -ON storage.objects -FOR SELECT -TO service_role -USING (bucket_id = 'user-uploads'); \ No newline at end of file diff --git a/apps/memoro/apps/audio-backend/tsconfig.json b/apps/memoro/apps/audio-backend/tsconfig.json deleted file mode 100644 index b6acd82cd..000000000 --- a/apps/memoro/apps/audio-backend/tsconfig.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "compilerOptions": { - "module": "commonjs", - "declaration": true, - "removeComments": true, - "emitDecoratorMetadata": true, - "experimentalDecorators": true, - "allowSyntheticDefaultImports": true, - "target": "ES2021", - "sourceMap": true, - "outDir": "./dist", - "baseUrl": "./", - "incremental": true, - "skipLibCheck": true, - "strictNullChecks": false, - "noImplicitAny": false, - "strictBindCallApply": false, - "forceConsistentCasingInFileNames": false, - "noFallthroughCasesInSwitch": false - }, - "include": ["src/**/*"], - "exclude": ["node_modules", "dist"] -} diff --git a/apps/memoro/apps/audio-backend/update-env.sh b/apps/memoro/apps/audio-backend/update-env.sh deleted file mode 100644 index f296559f6..000000000 --- a/apps/memoro/apps/audio-backend/update-env.sh +++ /dev/null @@ -1,11 +0,0 @@ -#!/bin/bash - -# Update audio-microservice environment variables with correct Supabase credentials -echo "🔧 Updating audio-microservice environment variables..." - -gcloud run services update audio-microservice \ - --region=europe-west3 \ - --set-env-vars=MEMORO_SUPABASE_URL=https://npgifbrwhftlbrbaglmi.supabase.co,MEMORO_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im5wZ2lmYnJ3aGZ0bGJyYmFnbG1pIiwicm9sZSI6ImFub24iLCJpYXQiOjE3MTMxODA4MTcsImV4cCI6MjAyODc1NjgxN30.xfBwgNLkgwW0aJkUCIQM9FBwbqWE8K7ynI-zUY0oOr8,MEMORO_SERVICE_URL=https://memoro-service-111768794939.europe-west3.run.app - -echo "✅ Environment variables updated!" -echo "🚀 Audio microservice should now be able to access Supabase Storage" \ No newline at end of file diff --git a/apps/memoro/apps/backend/.dockerignore b/apps/memoro/apps/backend/.dockerignore deleted file mode 100644 index fb3381494..000000000 --- a/apps/memoro/apps/backend/.dockerignore +++ /dev/null @@ -1,39 +0,0 @@ -# Dependencies -node_modules -npm-debug.log* -yarn-debug.log* -yarn-error.log* - -# Environment files - these should come from Cloud Run secrets -.env -.env.* -env.example - -# Test files -*.spec.ts -*.spec.js -test -jest.config.js - -# Development files -.git -.gitignore -README.md -*.md - -# Build artifacts -dist - -# IDE files -.vscode -.idea -*.swp -*.swo - -# OS files -.DS_Store -Thumbs.db - -# Temporary files -*.tmp -*.temp \ No newline at end of file diff --git a/apps/memoro/apps/backend/.env.backup b/apps/memoro/apps/backend/.env.backup deleted file mode 100644 index d70ec71fb..000000000 --- a/apps/memoro/apps/backend/.env.backup +++ /dev/null @@ -1,24 +0,0 @@ - -# Server Configuration -PORT=3001 -NODE_ENV=development - -# Service URLs -#MANA_SERVICE_URL=https://mana-core-middleware-111768794939.europe-west3.run.app -MANA_SERVICE_URL=http://localhost:3000 -BATCH_TRANSCRIPTION_SERVICE_URL=http://localhost:1337 -AUDIO_MICROSERVICE_URL=http://localhost:1337 - -# App Configuration -MEMORO_APP_ID=973da0c1-b479-4dac-a1b0-ed09c72caca8 - -# Memoro Supabase Configuration -MEMORO_SUPABASE_URL=https://npgifbrwhftlbrbaglmi.supabase.co -MEMORO_SUPABASE_ANON_KEY=sb_publishable_HlAZpB4BxXaMcfOCNx6VJA_-64NTxu4 -MEMORO_SUPABASE_SERVICE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im5wZ2lmYnJ3aGZ0bGJyYmFnbG1pIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImlhdCI6MTc0NTg1MTQxNiwiZXhwIjoyMDYxNDI3NDE2fQ.-6hArOVoEgGwIwdjclLQCTOAu13BFYnp9hPxQks4JPM - -# Also accept SUPABASE_SERVICE_KEY for compatibility with audio microservice -SUPABASE_SERVICE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im5wZ2lmYnJ3aGZ0bGJyYmFnbG1pIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImlhdCI6MTc0NTg1MTQxNiwiZXhwIjoyMDYxNDI3NDE2fQ.-6hArOVoEgGwIwdjclLQCTOAu13BFYnp9hPxQks4JPM - -# Mana Core service key for service-to-service auth -MANA_SUPABASE_SERVICE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InNtZW51ZWx6c2twaG5waGFhZXRwIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImlhdCI6MTc0MjA3NzYwMiwiZXhwIjoyMDU3NjUzNjAyfQ.guxCZQNZo4jM8M9kDA2MxDc1o78VSOuCLmVULnDCVnQ diff --git a/apps/memoro/apps/backend/.gitignore b/apps/memoro/apps/backend/.gitignore deleted file mode 100644 index 9e3e6c7b9..000000000 --- a/apps/memoro/apps/backend/.gitignore +++ /dev/null @@ -1,9 +0,0 @@ -node_modules -dist - -.env - -# Testing -coverage -.nyc_output -*.lcov \ No newline at end of file diff --git a/apps/memoro/apps/backend/BRANDING_INFO.md b/apps/memoro/apps/backend/BRANDING_INFO.md deleted file mode 100644 index 26a79a5b7..000000000 --- a/apps/memoro/apps/backend/BRANDING_INFO.md +++ /dev/null @@ -1,143 +0,0 @@ -# Memoro Service - Branding Configuration - -**Updated**: 2025-11-05 - ---- - -## Hardcoded Memoro Branding - -The Memoro service has **hardcoded branding** that is automatically applied to all signup confirmation emails. This ensures consistent branding across all Memoro signups without needing environment variables. - -### Branding Details - -**Location**: `src/auth-proxy/auth-proxy.service.ts:113-123` - -```typescript -const memoroBranding: BrandingConfig = { - appName: 'Memoro', - logoUrl: 'memoro-logo.png', - primaryColor: '#F8D62B', - secondaryColor: '#f5c500', - websiteUrl: 'https://memoro.ai', - taglineDe: 'Sprechen statt Tippen', - taglineEn: 'Speak Instead of Type', - copyright: '© 2025 Memoro · Made with 💛 in Germany' -}; -``` - -### Logo - -**File**: `memoro-logo.png` -**Storage URL**: https://smenuelzskphnphaaetp.supabase.co/storage/v1/object/public/satellites-logos/memoro-logo.png - -**Note**: PNG format is required for email compatibility. Gmail and most email clients block SVG images for security reasons. - -The logo is stored in Supabase Storage and referenced by filename only. Mana Core automatically builds the full URL. - -### Redirect URL - -**URL**: https://app.manacore.ai/welcome?appName=memoro - -After email confirmation, users are redirected to the centralized welcome page with Memoro-specific branding (blue theme, voice recording features). - ---- - -## How It Works - -1. **Every signup** automatically includes Memoro branding -2. **No configuration needed** - branding is built into the code -3. **Can be overridden** - If needed, pass `metadata.branding` in signup payload -4. **Merges with custom** - If partial branding provided, merges with defaults - -### Merging Behavior - -```typescript -// Standard signup - uses all Memoro defaults -POST /auth/signup -{ email, password, deviceInfo } -→ Email has full Memoro branding - -// Partial override - merges with defaults -POST /auth/signup -{ - email, password, deviceInfo, - metadata: { branding: { logoUrl: 'special-logo.svg' } } -} -→ Email has special logo, but keeps Memoro colors, taglines, etc. - -// Full override - replaces all branding -POST /auth/signup -{ - email, password, deviceInfo, - metadata: { branding: { /* complete custom branding */ } } -} -→ Email uses completely custom branding -``` - ---- - -## Why Hardcoded? - -✅ **Consistency** - All Memoro signups look the same -✅ **Simplicity** - No environment variables to manage -✅ **Reliability** - Can't accidentally break branding with config errors -✅ **Version Control** - Branding changes are tracked in git - ---- - -## To Change Branding - -If you need to update Memoro branding: - -1. **Edit the file**: `src/auth-proxy/auth-proxy.service.ts` -2. **Update the values**: Lines 113-123 -3. **Rebuild and deploy**: `npm run build && deploy` - -**Example**: -```typescript -// Update copyright year -copyright: '© 2026 Memoro · Made with 💛 in Germany' - -// Update colors -primaryColor: '#FF5733', -secondaryColor: '#C70039', -``` - ---- - -## Testing - -To test branding locally: - -```bash -# Start services -cd mana-core-middleware && npm run start:dev # Port 3003 -cd memoro-service && npm run start:dev # Port 3001 - -# Test signup -curl -X POST 'http://localhost:3001/auth/signup' \ - -H 'Content-Type: application/json' \ - -d '{ - "email": "test@example.com", - "password": "SecurePass123!", - "deviceInfo": { - "deviceId": "test-1", - "deviceName": "Test", - "deviceType": "web" - } - }' - -# Check confirmation email for Memoro branding -``` - -See `LOCAL_SIGNUP_TEST_GUIDE.md` for detailed testing instructions. - ---- - -## Related Files - -- **Branding Interface**: `src/auth-proxy/interfaces/branding.interface.ts` -- **Auth Service**: `src/auth-proxy/auth-proxy.service.ts` -- **Auth Controller**: `src/auth-proxy/auth-proxy.controller.ts` -- **Documentation**: `SIGNUP_BRANDING.md` -- **Test Guide**: `../LOCAL_SIGNUP_TEST_GUIDE.md` diff --git a/apps/memoro/apps/backend/CLAUDE.md b/apps/memoro/apps/backend/CLAUDE.md deleted file mode 100644 index 2dc7af4d1..000000000 --- a/apps/memoro/apps/backend/CLAUDE.md +++ /dev/null @@ -1,331 +0,0 @@ -# Memoro Service - Claude Development Notes - -## Enhanced Audio Processing Architecture - -### Direct Storage Upload Strategy -- Audio files are uploaded directly to Supabase Storage from the frontend -- This bypasses Cloud Run's 32MB file size limit -- Memoro service then processes the uploaded file via `POST /memoro/process-uploaded-audio` - -### Dual-Path Transcription System -**Smart Routing based on duration:** -- **Fast Transcription** (<115 minutes): Real-time Azure Speech Service -- **Batch Transcription** (≥115 minutes): Azure Speech Service with enhanced processing - -### Enhanced Audio Format Fallback Strategy -The service implements a robust 4-tier fallback strategy with comprehensive error handling: - -1. **Fast Transcribe (Primary)** - Direct transcription via Azure Speech Service -2. **Format Conversion + Retry** - Auto-detects format errors and converts via audio-microservice -3. **Batch Processing Fallback** - Uses enhanced batch processing if conversion fails -4. **Intelligent Error Detection** - Automatically identifies Azure Speech format issues - -### Speaker Diarization Fix (2025-06-09) -**Critical Issue Resolved:** -- **Problem**: Azure Fast Transcription API diarization configuration was incorrect, causing 0/149 phrases to have speaker data -- **Root Cause**: Used incorrect `diarization.speakers.maxCount` instead of `diarization.maxSpeakers` -- **Solution**: Updated to correct Azure API format: `diarization: { enabled: true, maxSpeakers: 10 }` -- **Result**: Now 216/216 phrases have proper speaker data with complete utterances, speakers, and speakerMap -- **Request Size Fix**: Increased body parser limit to 200MB to handle very large transcriptions with extensive speaker data (fixed 413 errors) - -### Batch Transcription Enhancements (NEW) -**Advanced Features:** -- **Enhanced Diarization**: Up to 10 speakers (vs 2 in basic mode) -- **Multi-language Detection**: Automatic identification from user preferences -- **Complete Speaker Data**: Same structure as fast transcription (utterances, speakers, speakerMap) -- **Recovery Tracking**: Stores Azure jobId for webhook failure recovery -- **Language Consistency**: Primary language detection and multi-language support - -**Recovery System Foundation:** -- **Metadata Storage**: Each batch job stores jobId in memo metadata via `/update-batch-metadata` -- **Memo ID Based Lookup**: Direct memo ID lookup for reliable metadata updates (fixed 2025-06-08) -- **Authentication Fixed**: Proper JWT token passing between services (fixed 2025-06-08) -- **Recovery Ready**: Infrastructure for cron-based recovery system -- **Webhook Failure Handling**: Planned automatic recovery for stuck transcriptions - -### Error Detection Patterns -The system detects audio format errors by checking for: -- "audio format", "audio stream could not be decoded" -- "InvalidAudioFormat", "UnprocessableEntity" -- "audio/x-m4a", "422" status codes -- Azure Speech Service specific error messages - -### Processing Routes -- `fast_transcribe` - Direct success -- `fast_transcribe_converted` - Success after format conversion -- `batch_transcribe` - Enhanced batch processing for long files (NEW) -- `batch_transcribe_fallback` - Success via batch processing fallback - -## Memo Creation Flow (Updated 2025-06-26) - -### Enhanced Memo Response -The `createMemoFromUploadedFile` method now returns the complete memo object: -```typescript -{ - memo: { /* full memo object */ }, - memoId: string, - audioPath: string -} -``` - -### Recording Time Preservation -- **recordingStartedAt** is stored in memo metadata -- Frontend uses this for accurate timestamp display -- Preserved through all real-time updates - -### Processing State Management -Memo metadata structure: -```typescript -metadata: { - processing: { - transcription: { status: 'pending' | 'processing' | 'completed' | 'failed' }, - headline_and_intro: { status: 'pending' | 'processing' | 'completed' | 'failed' } - }, - recordingStartedAt?: string, // ISO timestamp of actual recording start - location?: any -} -``` - -## Authentication Proxy Architecture (NEW - 2025-01-07) - -### Purpose -The auth-proxy module routes all authentication requests through memoro-service to hide mana-core-middleware from the frontend. This provides a single entry point for all backend services. - -### Auth Proxy Endpoints -All endpoints mirror the mana-core-middleware auth endpoints: -- `POST /auth/signin` - Email/password sign-in -- `POST /auth/signup` - User registration -- `POST /auth/google-signin` - Google OAuth sign-in -- `POST /auth/apple-signin` - Apple OAuth sign-in -- `POST /auth/refresh` - Token refresh -- `POST /auth/logout` - User logout -- `POST /auth/forgot-password` - Password reset -- `POST /auth/validate` - Token validation -- `GET /auth/credits` - Get user credits (proxies `/users/credits` from mana-core) -- `GET /auth/devices` - Get user devices - -### Implementation Details -- **Module**: `auth-proxy` module separate from existing auth module -- **No OAuth Redirects**: Social sign-ins use token exchange, not redirects -- **Error Preservation**: Original error responses passed through -- **App ID Injection**: Automatically adds `appId` query parameter -- **Header Forwarding**: Authorization headers passed through for authenticated endpoints - -## Append Transcription Feature (NEW - 2025-01-07) - -### Purpose -Allows adding additional audio recordings to existing memos and transcribing them, storing results in the `source.additional_recordings` array. - -### Endpoint -`POST /memoro/append-transcription` - -### Request Body -```typescript -{ - memoId: string; // ID of existing memo - filePath: string; // Audio file path in storage - duration: number; // Duration in seconds - recordingIndex?: number; // Optional: index to update specific recording - recordingLanguages?: string[]; - enableDiarization?: boolean; -} -``` - -### Features -- **Smart Routing**: Uses same fast (<115min) vs batch (≥115min) logic as main transcription -- **Credit Management**: Validates and consumes credits like main transcription -- **Access Control**: Validates user owns memo or has access through space -- **Preserves Original**: Keeps original transcript intact, only appends to additional_recordings -- **Speaker Diarization**: Full support for speaker detection in appended audio -- **Error Handling**: Comprehensive fallback strategy matching main transcription flow - -### Additional Recordings Structure -```typescript -source: { - // Original transcript and speaker data preserved - transcript: string; - speakers: {...}; - utterances: [...]; - - // Appended recordings array - additional_recordings: [ - { - path: string; - transcript: string; - languages: string[]; - primary_language: string; - speakers: object; - speakerMap: object; - utterances: array; - status: 'completed' | 'processing' | 'error'; - timestamp: string; - updated_at: string; - } - ] -} -``` - -## Audio Cleanup System (Auto-Delete Old Audio Files) - -### Overview -Automatically deletes audio files older than 30 days for users who have opted in. This helps users manage storage and comply with data retention preferences. - -### How It Works - -1. **GCP Cloud Scheduler** triggers `POST /cleanup/trigger-from-cron` daily at 3:00 AM UTC -2. **memoro-service** calls mana-core-middleware to get users with cleanup enabled -3. For each user, queries Supabase storage for files older than 30 days -4. Deletes files in batches (100 files per batch, 200ms delay between batches) -5. Updates memo `source` field to mark audio as deleted -6. Logs results to `audio_cleanup_logs` table - -### Architecture - -``` -┌─────────────────────┐ ┌─────────────────────┐ ┌─────────────────────┐ -│ GCP Cloud │ │ memoro-service │ │ mana-core- │ -│ Scheduler │────>│ /cleanup/ │────>│ middleware │ -│ (3:00 AM UTC) │ │ trigger-from-cron │ │ /internal/users/ │ -└─────────────────────┘ └─────────────────────┘ │ audio-cleanup- │ - │ │ enabled │ - │ └─────────────────────┘ - │ - v - ┌─────────────────────┐ - │ Supabase Storage │ - │ (user-uploads) │ - │ - Delete old files │ - │ - Update memos │ - └─────────────────────┘ -``` - -### Enabling Auto-Delete for a User - -Add `autoDeleteAudiosAfter30Days: true` to the user's `app_settings.memoro` object in the `users` table: - -```json -{ - "memoro": { - "autoDeleteAudiosAfter30Days": true, - "dataUsageAcceptance": true, - "emailNewsletterOptIn": false - } -} -``` - -### SQL Query to Enable for a User -```sql -UPDATE users -SET app_settings = jsonb_set( - COALESCE(app_settings, '{}'::jsonb), - '{memoro,autoDeleteAudiosAfter30Days}', - 'true' -) -WHERE id = 'USER_UUID_HERE'; -``` - -### SQL Query to Enable for Multiple Users (by email) -```sql -WITH user_emails AS ( - SELECT unnest(ARRAY[ - 'user1@example.com', - 'user2@example.com', - 'user3@example.com' - ]::text[]) AS email -) -UPDATE users u -SET app_settings = jsonb_set( - jsonb_set( - COALESCE(u.app_settings, '{}'::jsonb), - '{memoro}', - COALESCE(u.app_settings->'memoro', '{}'::jsonb) - ), - '{memoro,autoDeleteAudiosAfter30Days}', - 'true' -) -FROM user_emails -WHERE u.email = user_emails.email; -``` - -### SQL Query to Check Users with Cleanup Enabled -```sql -SELECT id, email, app_settings->'memoro'->'autoDeleteAudiosAfter30Days' -FROM users -WHERE app_settings->'memoro'->>'autoDeleteAudiosAfter30Days' = 'true'; -``` - -### Configuration - -| Setting | Value | Location | -|---------|-------|----------| -| Retention period | 30 days | `audio-cleanup.service.ts` | -| Batch size | 100 files | `audio-cleanup.service.ts` | -| Batch delay | 200ms | `audio-cleanup.service.ts` | -| Storage bucket | `user-uploads` | `audio-cleanup.service.ts` | -| Schedule | `0 3 * * *` (daily 3 AM UTC) | GCP Cloud Scheduler | -| Timeout | 1800s (30 min) | GCP Cloud Scheduler | - -### GCP Cloud Scheduler Jobs - -**Dev:** -```bash -gcloud scheduler jobs describe audio-cleanup-daily --project=mana-core-dev --location=europe-west3 -``` - -**Prod:** -```bash -gcloud scheduler jobs describe audio-cleanup-daily --project=mana-core-prod --location=europe-west3 -``` - -### Endpoints - -| Endpoint | Method | Description | -|----------|--------|-------------| -| `/cleanup/trigger-from-cron` | POST | Called by Cloud Scheduler | -| `/cleanup/trigger-manual` | POST | Manual trigger for testing | -| `/cleanup/process-old-audios` | POST | Process specific user IDs | - -All endpoints require `X-Internal-API-Key` header. - -### What Happens When Audio is Deleted - -1. Audio file removed from Supabase Storage -2. Memo `source` field updated: - ```json - { - "audio_path": null, - "audio_deleted": true, - "audio_deleted_at": "2026-01-26T06:47:02.000Z", - "transcript": "...", - "utterances": [...] - } - ``` -3. Transcript and other data remain intact - -### Monitoring - -Check cleanup logs: -```sql -SELECT * FROM audio_cleanup_logs ORDER BY started_at DESC LIMIT 10; -``` - -### Files - -- `memoro_middleware/src/cleanup/audio-cleanup.service.ts` - Main cleanup logic -- `memoro_middleware/src/cleanup/audio-cleanup.controller.ts` - HTTP endpoints -- `memoro_middleware/src/cleanup/cleanup.module.ts` - NestJS module -- `mana-core-token-middleware/src/modules/users/controllers/user-cleanup.controller.ts` - User query endpoint -- `mana-core-token-middleware/src/modules/users/services/user-settings.service.ts` - User settings queries - -## Development Commands -- `npm run start:dev` - Development server with hot reload -- `npm run build` - Production build -- `npm run start:prod` - Production server - -## Key Implementation Details -- Audio format conversion handled via audio-microservice -- Credit validation before processing -- Automatic fallback without user intervention -- Detailed logging for debugging each fallback step -- Full memo object returned on creation for immediate frontend sync -- Auth proxy provides single backend entry point for frontend \ No newline at end of file diff --git a/apps/memoro/apps/backend/DEPLOY_MANUAL.md b/apps/memoro/apps/backend/DEPLOY_MANUAL.md deleted file mode 100644 index 41092dfde..000000000 --- a/apps/memoro/apps/backend/DEPLOY_MANUAL.md +++ /dev/null @@ -1,208 +0,0 @@ -# Memoro Service Deployment Manual - -## Prerequisites - -1. **Google Cloud SDK** installed and authenticated: - ```bash - gcloud auth login - gcloud config set project memo-2c4c4 - ``` - -2. **Docker** installed (for local testing) - -3. **Access to** `memo-2c4c4` project with Cloud Build and Cloud Run permissions - -## Step-by-Step Deployment Process - -### Step 1: Prepare for Deployment - -Navigate to the memoro-service directory: -```bash -cd memoro-service -``` - -Check current version in `cloudbuild-memoro.yaml`: -```bash -cat cloudbuild-memoro.yaml -``` - -### Step 2: Update Version (Optional) - -If you want to increment the version, update the tag in `cloudbuild-memoro.yaml`: -```yaml -# Change v4.0.0 to v4.1.0 (or next version) -args: ['build', '-t', 'europe-west3-docker.pkg.dev/memo-2c4c4/memoro-service/memoro-service:v4.4.4', '.'] -``` - -### Step 3: Build and Push Docker Image - -Run the Cloud Build process: -```bash -gcloud builds submit --project=memo-2c4c4 --config=cloudbuild-memoro.yaml . -``` - -**Expected output:** -- ✅ Source uploaded to Cloud Storage -- ✅ Docker build steps execute -- ✅ Image pushed to Artifact Registry -- ✅ Build completes with "SUCCESS" status - -### Step 4: Deploy to Cloud Run - -Use the image version from the build output: -```bash -gcloud run deploy memoro-service \ - --project=memo-2c4c4 \ - --image europe-west3-docker.pkg.dev/memo-2c4c4/memoro-service/memoro-service:v4.9.6 \ - --platform managed \ - --region europe-west3 \ - --allow-unauthenticated \ - --memory 1Gi -``` - -**Deployment will prompt:** -- Service configuration questions (usually accept defaults) -- Traffic allocation (usually 100% to new revision) - -### Step 5: Verify Deployment - -1. **Get service URL:** - ```bash - SERVICE_URL=$(gcloud run services describe memoro-service --platform managed --region europe-west3 --format 'value(status.url)') - echo "Service URL: $SERVICE_URL" - ``` - -2. **Test health endpoint:** - ```bash - curl $SERVICE_URL/health - ``` - -3. **Test with authentication (optional):** - ```bash - curl -H "Authorization: Bearer YOUR_JWT_TOKEN" $SERVICE_URL/memoro/spaces - ``` - -## Environment Variables & Secrets - -The deployment preserves existing environment variables and secrets. Current secrets include: - -- `MEMORO_SUPABASE_URL` -- `MEMORO_SUPABASE_ANON_KEY` -- `MEMORO_SUPABASE_SERVICE_KEY` -- `MANA_SERVICE_URL` -- `BATCH_TRANSCRIPTION_SERVICE_URL` -- `MEMORO_APP_ID` - -To update environment variables: -```bash -gcloud run services update memoro-service \ - --region europe-west3 \ - --set-env-vars="NEW_VAR=value" -``` - -## Troubleshooting - -### Build Issues - -1. **Authentication errors:** - ```bash - gcloud auth login - gcloud auth configure-docker europe-west3-docker.pkg.dev - ``` - -2. **Project access issues:** - ```bash - gcloud config set project memo-2c4c4 - gcloud projects get-iam-policy memo-2c4c4 - ``` - -### Deployment Issues - -1. **Check service logs:** - ```bash - gcloud logging read "resource.type=cloud_run_revision AND resource.labels.service_name=memoro-service" --limit 10 - ``` - -2. **Check service status:** - ```bash - gcloud run services describe memoro-service --region europe-west3 - ``` - -3. **Memory issues (increase if needed):** - ```bash - gcloud run services update memoro-service \ - --region europe-west3 \ - --memory 1Gi - ``` - -### Runtime Issues - -1. **Test specific endpoints:** - ```bash - # Health check - curl $SERVICE_URL/health - - # Batch upload (requires valid JWT and audio file) - curl -X POST \ - -H "Authorization: Bearer $JWT_TOKEN" \ - -F "file=@test-audio.mp3" \ - $SERVICE_URL/memoro/upload-audio - ``` - -2. **Check environment variables:** - ```bash - gcloud run services describe memoro-service \ - --region europe-west3 \ - --format="export" | grep env - ``` - -## Quick Reference Commands - -```bash -# Build only -gcloud builds submit --project=memo-2c4c4 --config=cloudbuild-memoro.yaml . - -# Deploy latest version -gcloud run deploy memoro-service \ - --image europe-west3-docker.pkg.dev/memo-2c4c4/memoro-service/memoro-service:v4.4.0 \ - --region europe-west3 - -# Get service URL -gcloud run services describe memoro-service --region europe-west3 --format 'value(status.url)' - -# View logs -gcloud logging read "resource.type=cloud_run_revision AND resource.labels.service_name=memoro-service" --limit 10 - -# Update environment variable -gcloud run services update memoro-service --region europe-west3 --set-env-vars="VAR=value" -``` - -## File Structure Reference - -``` -memoro-service/ -├── cloudbuild-memoro.yaml # Build configuration -├── Dockerfile # Container definition -├── package.json # Dependencies -├── src/ # Source code -│ ├── memoro/ -│ │ ├── memoro.controller.ts # Updated with batch jobId storage -│ │ └── memoro.service.ts # Updated with batch logic -│ └── ... -└── DEPLOY_MANUAL.md # This file -``` - -## Recent Updates - -**v4.0.0 includes:** -- ✅ Fixed batch upload jobId storage in memo metadata -- ✅ Updated duration threshold to 1h55m for batch processing -- ✅ Added `updateMemoWithJobId` method for webhook callback support -- ✅ Improved error handling for batch transcription flow - ---- - -**Last Updated:** $(date) -**Current Version:** v4.0.0 -**Deployment Region:** europe-west3 -**Project:** memo-2c4c4 \ No newline at end of file diff --git a/apps/memoro/apps/backend/Dockerfile b/apps/memoro/apps/backend/Dockerfile deleted file mode 100644 index cb76e6e58..000000000 --- a/apps/memoro/apps/backend/Dockerfile +++ /dev/null @@ -1,13 +0,0 @@ -FROM node:18-alpine - -WORKDIR /app - -COPY package*.json ./ -RUN npm ci - -COPY . . -RUN npm run build - -EXPOSE 3001 - -CMD ["npm", "run", "start:prod"] \ No newline at end of file diff --git a/apps/memoro/apps/backend/Dockerfile.debug b/apps/memoro/apps/backend/Dockerfile.debug deleted file mode 100644 index 78b6e23b5..000000000 --- a/apps/memoro/apps/backend/Dockerfile.debug +++ /dev/null @@ -1,21 +0,0 @@ -FROM node:18-alpine - -WORKDIR /app - -COPY package*.json ./ -RUN npm ci - -COPY . . - -# Debug: Check what files are present before build -RUN ls -la - -# Run build with verbose output -RUN npm run build - -# Debug: Check if dist was created -RUN ls -la dist/ - -EXPOSE 3001 - -CMD ["npm", "run", "start:prod"] \ No newline at end of file diff --git a/apps/memoro/apps/backend/README.md b/apps/memoro/apps/backend/README.md deleted file mode 100644 index 3a85353b3..000000000 --- a/apps/memoro/apps/backend/README.md +++ /dev/null @@ -1,153 +0,0 @@ -# Memoro Microservice - -This is a standalone microservice for the Memoro component of the Mana Core system. It was extracted from the monolithic mana-core-middleware to enable independent scaling and deployment. - -## Architecture - -This microservice: -- Handles all Memoro-specific functionality -- Communicates with Auth service for authentication/authorization -- Communicates with Spaces service for space management -- Connects directly to the Memoro Supabase instance -- Implements mana cost validation for AI operations - -## Mana Cost System - -The service implements a backend-driven credit validation system: - -- **Transcription**: 120 credits per hour / 2 credits per minute (base cost: 10 credits minimum) -- **Question Processing**: 5 mana per question asked to memos -- **Memo Combination**: 5 mana per memo when combining multiple memos -- **Headline Generation**: 10 credits for title/summary generation -- **Memory Creation**: 10 credits for AI-generated memories -- **Blueprint Processing**: 5 credits for blueprint application -- **Memo Sharing**: 1 credit for sharing operations -- **Space Operations**: 2 credits for space-related operations -- **Early Validation**: Credits are checked before expensive AI operations -- **Real-time Updates**: Frontend mana counter updates immediately after operations - -All AI processing endpoints validate sufficient mana credits before processing and consume credits upon successful completion. - -## API Endpoints - -### Core Memoro Endpoints -- `GET /memoro/spaces` - Get all Memoro spaces for the authenticated user -- `POST /memoro/spaces` - Create a new Memoro space -- `GET /memoro/spaces/:id` - Get details for a specific Memoro space -- `DELETE /memoro/spaces/:id` - Delete a Memoro space -- `POST /memoro/link-memo` - Link a memo to a space -- `POST /memoro/unlink-memo` - Unlink a memo from a space -- `GET /memoro/spaces/:id/memos` - Get all memos for a specific space -- `POST /memoro/spaces/:id/leave` - Leave a space - -### Space Invitation Management -- `GET /memoro/spaces/:id/invites` - Get space invitations -- `POST /memoro/spaces/:id/invite` - Invite user to space -- `POST /memoro/spaces/invites/:inviteId/resend` - Resend invitation -- `DELETE /memoro/spaces/invites/:inviteId` - Cancel invitation -- `GET /memoro/invites/pending` - Get user's pending invites -- `POST /memoro/spaces/invites/accept` - Accept invitation -- `POST /memoro/spaces/invites/decline` - Decline invitation - -### Audio Processing -- `POST /memoro/process-uploaded-audio` - Process uploaded audio with intelligent fallback strategy and credit validation -- `POST /memoro/update-batch-metadata` - Update batch transcription metadata for recovery tracking (improved with memo ID lookup, 2025-06-08) -- `POST /memoro/retry-transcription` - Retry failed transcription -- `POST /memoro/retry-headline` - Retry failed headline generation - -#### Enhanced Audio Processing System -The service implements a sophisticated dual-path transcription system with comprehensive fallback strategies: - -**Transcription Paths:** -1. **Fast Transcription** (<115 minutes) - Real-time processing via Supabase Edge Function -2. **Batch Transcription** (≥115 minutes) - Azure Speech Service batch processing with webhook callbacks - -**Enhanced Fallback Strategy:** -1. **Fast Transcribe** - Attempts fast transcription via edge function -2. **Format Conversion + Retry** - If audio format error detected, converts file via audio-microservice and retries -3. **Batch Processing Fallback** - Falls back to batch processing if conversion fails -4. **Intelligent Error Detection** - Automatically detects Azure Speech Service format compatibility issues - -**Batch Transcription Enhancements:** -- **Advanced Diarization**: Supports up to 10 speakers (vs 2 in basic mode) -- **Multi-language Detection**: Automatic language identification from user preferences -- **Complete Data Consistency**: Same speaker data structure as fast transcription -- **Recovery Tracking**: Stores Azure jobId for webhook failure recovery -- **Graceful Degradation**: Falls back to text-only if speaker processing fails - -**Supported Processing Routes:** -- `fast_transcribe` - Direct fast transcription success -- `fast_transcribe_converted` - Success after format conversion -- `batch_transcribe` - Regular batch processing for long files -- `batch_transcribe_fallback` - Success via batch processing fallback - -**Data Structure Consistency:** -Both fast and batch transcription now save identical data: -- `transcript` - Transcribed text -- `primary_language` - Detected primary language -- `languages` - All detected languages -- `utterances` - Speaker segments with timestamps -- `speakers` - Speaker labels -- `speakerMap` - Speaker-grouped utterances - -### AI Processing Endpoints (with Credit Validation) -- `POST /memoro/question-memo` - Ask questions about memos (5 mana cost) -- `POST /memoro/combine-memos` - Combine multiple memos with AI processing (5 mana per memo) - -### Credit Management -- `POST /memoro/credits/check-transcription` - Check credits before transcription -- `POST /memoro/credits/consume-transcription` - Consume transcription credits -- `POST /memoro/credits/consume-operation` - Consume operation credits - -### User Settings Management -- `GET /settings` - Get all user settings -- `GET /settings/memoro` - Get Memoro-specific settings -- `PATCH /settings/memoro` - Update Memoro settings -- `PATCH /settings/memoro/data-usage` - Update data usage acceptance -- `PATCH /settings/memoro/email-newsletter` - Update email newsletter opt-in -- `PATCH /settings/profile` - Update user profile (firstName, lastName, avatarUrl) - -## Environment Variables - -Required environment variables: - -```env -# Server Configuration -PORT=3001 - -# Service URLs -MANA_SERVICE_URL=http://localhost:3000 -AUDIO_MICROSERVICE_URL=https://audio-microservice-111768794939.europe-west3.run.app - -# Supabase Configuration -MEMORO_SUPABASE_URL=https://your-memoro-project.supabase.co -MEMORO_SUPABASE_ANON_KEY=your-memoro-anon-key -MEMORO_SUPABASE_SERVICE_KEY=your-memoro-service-key - -# App Configuration -MEMORO_APP_ID=973da0c1-b479-4dac-a1b0-ed09c72caca8 -``` - -## Development - -```bash -# Install dependencies -npm install - -# Run in development mode -npm run start:dev - -# Build for production -npm run build - -# Run in production mode -npm run start:prod -``` - -## Deployment - -For Cloud Run deployment instructions, see `cloud-run-deploy.md`. - - - -testing prod deployment 30.juli 2025 03:30 \ No newline at end of file diff --git a/apps/memoro/apps/backend/SIGNUP_BRANDING.md b/apps/memoro/apps/backend/SIGNUP_BRANDING.md deleted file mode 100644 index 2a1168139..000000000 --- a/apps/memoro/apps/backend/SIGNUP_BRANDING.md +++ /dev/null @@ -1,145 +0,0 @@ -# Memoro Service - Signup Branding Support - -**Updated**: 2025-11-05 - ---- - -## Overview - -The signup endpoint automatically applies **Memoro branding** to all confirmation emails. The branding is hardcoded in the service and includes: - -- **App Name**: Memoro -- **Logo**: memoro-logo.png -- **Primary Color**: #F8D62B (Yellow) -- **Secondary Color**: #f5c500 (Golden Yellow) -- **Tagline DE**: "Sprechen statt Tippen" -- **Tagline EN**: "Speak Instead of Type" -- **Website**: https://memoro.ai -- **Redirect URL**: https://app.manacore.ai/welcome?appName=memoro -- **Copyright**: "© 2025 Memoro · Made with 💛 in Germany" - -You can optionally override specific branding fields per signup if needed. - -## Simple Usage - -### Standard Signup (Automatic Memoro Branding) - -```bash -POST /auth/signup -{ - "email": "user@memoro.ai", - "password": "SecurePass123!", - "deviceInfo": { - "deviceId": "web-123", - "deviceName": "Chrome", - "deviceType": "web" - } -} -``` - -**Result**: Email automatically uses Memoro branding (yellow colors, Memoro logo, German/English taglines). - ---- - -### Custom Branding (Optional) - -```bash -POST /auth/signup -{ - "email": "user@example.com", - "password": "SecurePass123!", - "deviceInfo": { - "deviceId": "web-123", - "deviceName": "Chrome", - "deviceType": "web" - }, - "metadata": { - "branding": { - "logoUrl": "custom-logo.svg", - "primaryColor": "#FF5733" - } - } -} -``` - -**Result**: Email uses custom logo and color, other fields use Memoro defaults. - ---- - -### Full Custom Branding - -```bash -POST /auth/signup -{ - "email": "user@example.com", - "password": "SecurePass123!", - "deviceInfo": {...}, - "metadata": { - "branding": { - "appName": "Custom App", - "logoUrl": "custom-logo.svg", - "primaryColor": "#2C3E50", - "secondaryColor": "#34495E", - "websiteUrl": "https://custom-app.com", - "taglineDe": "Ihre Lösung", - "taglineEn": "Your Solution", - "copyright": "© 2025 Custom App" - } - } -} -``` - ---- - -## Branding Fields - -All fields are **optional**: - -| Field | Type | Description | Example | -|-------|------|-------------|---------| -| `appName` | string | App display name | `"My App"` | -| `logoUrl` | string | Logo filename (from Supabase Storage) | `"app-logo.png"` | -| `primaryColor` | string | Primary color (hex) | `"#F8D62B"` | -| `secondaryColor` | string | Secondary color (hex) | `"#f5c500"` | -| `websiteUrl` | string | Website URL | `"https://app.com"` | -| `taglineDe` | string | German tagline | `"Sprechen statt Tippen"` | -| `taglineEn` | string | English tagline | `"Speak Instead of Type"` | -| `copyright` | string | Footer text | `"© 2025 My App"` | - ---- - -## TypeScript Types - -```typescript -import { BrandingConfig } from './auth-proxy/interfaces/branding.interface'; - -// Example -const branding: BrandingConfig = { - logoUrl: 'custom-logo.svg', - primaryColor: '#FF5733' -}; - -await authProxy.signup({ - email: 'user@example.com', - password: 'pass123', - deviceInfo: {...}, - metadata: { branding } -}); -``` - ---- - -## How It Works - -1. **No metadata** → Mana Core uses default branding for your app -2. **With metadata.branding** → Mana Core merges your branding with defaults -3. **Any missing fields** → Filled in by Mana Core defaults - ---- - -## That's It! - -- ✅ Backward compatible - existing signups work unchanged -- ✅ Simple - just add `metadata.branding` when you want custom branding -- ✅ Flexible - override any or all branding fields -- ✅ No new endpoints - just use `POST /auth/signup` diff --git a/apps/memoro/apps/backend/TECHNICAL_DOCUMENTATION.md b/apps/memoro/apps/backend/TECHNICAL_DOCUMENTATION.md deleted file mode 100644 index cdff4031b..000000000 --- a/apps/memoro/apps/backend/TECHNICAL_DOCUMENTATION.md +++ /dev/null @@ -1,1321 +0,0 @@ -# Memoro Service - Detailed Technical Documentation - -## Table of Contents - -1. [Project Overview](#project-overview) -2. [Architecture](#architecture) -3. [Installation & Setup](#installation--setup) -4. [Module Deep Dive](#module-deep-dive) -5. [API Reference](#api-reference) -6. [Data Models](#data-models) -7. [Service Integrations](#service-integrations) -8. [Error Handling](#error-handling) -9. [Testing](#testing) -10. [Performance](#performance) -11. [Security](#security) -12. [Deployment](#deployment) -13. [Monitoring & Logging](#monitoring--logging) -14. [Troubleshooting](#troubleshooting) - -## Project Overview - -### Purpose -Memoro Service is a specialized microservice that handles all Memoro-specific functionality in the Mana ecosystem. It serves as the primary backend for the Memoro mobile and web applications, orchestrating audio processing, AI operations, and collaborative features. - -### Tech Stack -- **Framework**: NestJS 10.x -- **Language**: TypeScript 5.x -- **Runtime**: Node.js 18.x -- **Database**: Supabase (PostgreSQL) -- **Package Manager**: npm - -### Key Dependencies -```json -{ - "@nestjs/common": "^10.0.0", - "@nestjs/core": "^10.0.0", - "@nestjs/platform-express": "^10.0.0", - "@supabase/supabase-js": "^2.39.0", - "axios": "^1.6.0", - "class-validator": "^0.14.0", - "rxjs": "^7.8.1" -} -``` - -## Architecture - -### Service Architecture Diagram -``` -┌─────────────────────────────────────────────────┐ -│ Memoro Service │ -├─────────────────────────────────────────────────┤ -│ ┌───────────┐ ┌───────────┐ ┌───────────┐ │ -│ │Auth Proxy │ │ Credits │ │ Memoro │ │ -│ │ Module │ │ Module │ │ Module │ │ -│ └─────┬─────┘ └─────┬─────┘ └─────┬─────┘ │ -│ │ │ │ │ -│ ┌─────▼──────────────▼──────────────▼─────┐ │ -│ │ Common Services Layer │ │ -│ │ - Guards - Decorators - Filters │ │ -│ └───────────────────┬──────────────────────┘ │ -│ │ │ -│ ┌───────────────────▼──────────────────────┐ │ -│ │ External Service Clients │ │ -│ │ - Mana Core - Audio Micro - Supabase │ │ -│ └──────────────────────────────────────────┘ │ -└─────────────────────────────────────────────────┘ -``` - -### Request Flow -```typescript -// Typical request flow through the service -Request → Guards → Interceptors → Controller → Service → Repository → External Services - ↓ -Response ← Filters ← Interceptors ← Response -``` - -## Installation & Setup - -### Prerequisites -```bash -# Required software -- Node.js >= 18.0.0 -- npm >= 9.0.0 -- Docker (optional, for containerized deployment) -``` - -### Local Development Setup -```bash -# 1. Clone repository -git clone -cd memoro-service - -# 2. Install dependencies -npm install - -# 3. Configure environment -cp .env.example .env -# Edit .env with your configuration - -# 4. Run database migrations (if any) -npm run migrate - -# 5. Start development server -npm run start:dev -``` - -### Environment Configuration -```env -# Server Configuration -PORT=3001 -NODE_ENV=development - -# Service URLs -MANA_SERVICE_URL=http://localhost:3000 -AUDIO_MICROSERVICE_URL=https://audio-microservice.run.app - -# Supabase Configuration -MEMORO_SUPABASE_URL=https://your-project.supabase.co -MEMORO_SUPABASE_ANON_KEY=your-anon-key -MEMORO_SUPABASE_SERVICE_KEY=your-service-key - -# Application Configuration -MEMORO_APP_ID=973da0c1-b479-4dac-a1b0-ed09c72caca8 - -# Feature Flags -ENABLE_BATCH_TRANSCRIPTION=true -ENABLE_SPEAKER_DIARIZATION=true -MAX_AUDIO_DURATION_MINUTES=180 - -# Logging -LOG_LEVEL=debug -LOG_FORMAT=json -``` - -## Module Deep Dive - -### 1. Auth Proxy Module - -#### Purpose -Routes authentication requests through Memoro Service to hide backend complexity. - -#### Structure -``` -auth-proxy/ -├── auth-proxy.controller.ts -├── auth-proxy.service.ts -├── auth-proxy.module.ts -└── dto/ - ├── signin.dto.ts - ├── signup.dto.ts - └── refresh.dto.ts -``` - -#### Implementation Details -```typescript -@Controller('auth') -export class AuthProxyController { - constructor( - private readonly authProxyService: AuthProxyService, - ) {} - - @Post('signin') - async signIn(@Body() signInDto: SignInDto) { - // Adds appId automatically - const appId = process.env.MEMORO_APP_ID; - return this.authProxyService.forwardRequest( - '/auth/signin', - { ...signInDto }, - { appId } - ); - } - - @Post('refresh') - @UseGuards(OptionalAuthGuard) - async refresh( - @Body() refreshDto: RefreshDto, - @Headers('authorization') auth?: string - ) { - return this.authProxyService.forwardRequest( - '/auth/refresh', - refreshDto, - { appId: process.env.MEMORO_APP_ID }, - auth - ); - } -} -``` - -### 2. Credits Module - -#### Credit Calculation Service -```typescript -@Injectable() -export class CreditConsumptionService { - private readonly PRICING = { - TRANSCRIPTION_PER_MINUTE: 2, - TRANSCRIPTION_MINIMUM: 10, - QUESTION_PROCESSING: 5, - MEMO_COMBINATION: 5, - HEADLINE_GENERATION: 10, - MEMORY_CREATION: 10, - BLUEPRINT_PROCESSING: 5, - }; - - calculateTranscriptionCost(durationSeconds: number): number { - const minutes = Math.ceil(durationSeconds / 60); - const cost = minutes * this.PRICING.TRANSCRIPTION_PER_MINUTE; - return Math.max(cost, this.PRICING.TRANSCRIPTION_MINIMUM); - } - - async validateAndConsume( - userId: string, - amount: number, - operation: string - ): Promise { - // Check balance - const balance = await this.creditClient.getBalance(userId); - if (balance < amount) { - throw new InsufficientCreditsError(amount, balance, operation); - } - - // Consume credits - await this.creditClient.consumeCredits(userId, amount, operation); - } -} -``` - -### 3. Memoro Module - -#### Audio Processing Service -```typescript -@Injectable() -export class MemoroService { - async processUploadedAudio( - userId: string, - filePath: string, - duration: number, - options: ProcessingOptions - ): Promise { - // 1. Validate credits - const cost = this.creditService.calculateTranscriptionCost(duration); - await this.creditService.validateCredits(userId, cost); - - // 2. Create memo record - const memo = await this.createMemoFromUploadedFile( - userId, - filePath, - options.metadata - ); - - // 3. Route to appropriate processing path - if (duration < 115 * 60) { // Less than 115 minutes - return this.processFastTranscription(memo, filePath, options); - } else { - return this.processBatchTranscription(memo, filePath, options); - } - } - - private async processFastTranscription( - memo: Memo, - filePath: string, - options: ProcessingOptions - ): Promise { - try { - // Attempt fast transcription - const result = await this.edgeFunctionClient.transcribe({ - audioPath: filePath, - languages: options.languages, - enableDiarization: true, - maxSpeakers: 2 - }); - - // Update memo with results - await this.updateMemoWithTranscript(memo.id, result); - - return { memo, status: 'completed', route: 'fast_transcribe' }; - } catch (error) { - if (this.isFormatError(error)) { - // Attempt format conversion - return this.processWithConversion(memo, filePath, options); - } - throw error; - } - } - - private async processBatchTranscription( - memo: Memo, - filePath: string, - options: ProcessingOptions - ): Promise { - // Submit to batch processing - const jobId = await this.audioMicroservice.submitBatchJob({ - audioPath: filePath, - memoId: memo.id, - languages: options.languages, - enableDiarization: true, - maxSpeakers: 10 - }); - - // Store job ID for recovery - await this.updateMemoMetadata(memo.id, { - processing: { - transcription: { - status: 'processing', - jobId: jobId, - startedAt: new Date().toISOString() - } - } - }); - - return { memo, status: 'processing', route: 'batch_transcribe' }; - } -} -``` - -## API Reference - -### Authentication Endpoints - -#### POST /auth/signin -```typescript -// Request -{ - "email": "user@example.com", - "password": "secure-password" -} - -// Response -{ - "manaToken": "eyJhbGc...", - "appToken": "eyJhbGc...", - "refreshToken": "refresh_token_here", - "user": { - "id": "user-uuid", - "email": "user@example.com", - "credits": 1500 - } -} -``` - -#### POST /auth/refresh -```typescript -// Request -{ - "refreshToken": "current_refresh_token" -} - -// Response -{ - "appToken": "new_app_token", - "refreshToken": "new_refresh_token" -} -``` - -### Audio Processing Endpoints - -#### POST /memoro/process-uploaded-audio -```typescript -// Request -{ - "filePath": "audio/2024/01/recording.m4a", - "duration": 3600, // seconds - "metadata": { - "recordingStartedAt": "2024-01-15T10:00:00Z", - "location": { "lat": 52.52, "lng": 13.405 } - }, - "recordingLanguages": ["en-US", "de-DE"], - "enableDiarization": true -} - -// Response -{ - "memo": { - "id": "memo-uuid", - "title": "Processing...", - "source": { - "audio_path": "audio/2024/01/recording.m4a", - "duration": 3600 - }, - "metadata": { - "processing": { - "transcription": { "status": "processing" } - }, - "recordingStartedAt": "2024-01-15T10:00:00Z" - } - }, - "processingRoute": "batch_transcribe" -} -``` - -#### POST /memoro/append-transcription -```typescript -// Request -{ - "memoId": "existing-memo-id", - "filePath": "audio/additional.m4a", - "duration": 300, - "recordingIndex": 0 // Optional: update specific recording -} - -// Response -{ - "success": true, - "additionalRecording": { - "index": 0, - "path": "audio/additional.m4a", - "status": "processing" - } -} -``` - -## Data Models - -### Memo Model -```typescript -interface Memo { - id: string; - user_id: string; - title?: string; - source: { - audio_path: string; - duration: number; - transcript?: string; - primary_language?: string; - languages?: string[]; - utterances?: Utterance[]; - speakers?: SpeakerMap; - speakerMap?: GroupedUtterances; - additional_recordings?: AdditionalRecording[]; - }; - metadata: { - processing?: ProcessingStatus; - recordingStartedAt?: string; - location?: Location; - stats?: MemoStats; - }; - created_at: string; - updated_at: string; -} - -interface ProcessingStatus { - transcription?: { - status: 'pending' | 'processing' | 'completed' | 'failed'; - error?: string; - jobId?: string; - attempts?: number; - }; - headline_and_intro?: { - status: 'pending' | 'processing' | 'completed' | 'failed'; - error?: string; - }; - blueprint?: { - status: 'pending' | 'processing' | 'completed' | 'failed'; - blueprintId?: string; - }; -} -``` - -### Speaker Data Models -```typescript -interface Utterance { - speaker: string; - text: string; - offset: number; - duration: number; - words?: Word[]; -} - -interface Word { - text: string; - offset: number; - duration: number; - confidence?: number; -} - -interface SpeakerMap { - [speakerId: string]: { - name: string; - label?: string; - }; -} - -interface GroupedUtterances { - [speakerId: string]: Utterance[]; -} -``` - -## Service Integrations - -### Mana Core Middleware Integration -```typescript -class ManaClientService { - private readonly client: AxiosInstance; - - constructor() { - this.client = axios.create({ - baseURL: process.env.MANA_SERVICE_URL, - timeout: 30000, - headers: { - 'Content-Type': 'application/json', - } - }); - } - - async getUserCredits(token: string): Promise { - const response = await this.client.get('/users/credits', { - headers: { Authorization: `Bearer ${token}` } - }); - return response.data; - } - - async consumeCredits( - token: string, - amount: number, - description: string - ): Promise { - await this.client.post( - '/users/credits/consume', - { amount, description }, - { headers: { Authorization: `Bearer ${token}` }} - ); - } -} -``` - -### Audio Microservice Integration -```typescript -class AudioMicroserviceClient { - async submitBatchTranscription(params: BatchParams): Promise { - const response = await axios.post( - `${this.baseUrl}/audio/transcribe-from-storage`, - { - filePath: params.filePath, - memoId: params.memoId, - languages: params.languages, - diarization: { - enabled: true, - maxSpeakers: params.maxSpeakers || 10 - } - } - ); - return response.data.jobId; - } - - async convertAndTranscribe(params: ConvertParams): Promise { - const response = await axios.post( - `${this.baseUrl}/audio/convert-and-transcribe-from-storage`, - params - ); - return response.data; - } -} -``` - -### Supabase Integration -```typescript -class SupabaseService { - private readonly client: SupabaseClient; - - constructor() { - this.client = createClient( - process.env.MEMORO_SUPABASE_URL, - process.env.MEMORO_SUPABASE_SERVICE_KEY - ); - } - - async createMemo(data: Partial): Promise { - const { data: memo, error } = await this.client - .from('memos') - .insert(data) - .select() - .single(); - - if (error) throw error; - return memo; - } - - async updateMemo(id: string, updates: Partial): Promise { - const { data: memo, error } = await this.client - .from('memos') - .update(updates) - .eq('id', id) - .select() - .single(); - - if (error) throw error; - return memo; - } - - subscribeToMemoUpdates(memoId: string, callback: (payload: any) => void) { - return this.client - .channel(`memo:${memoId}`) - .on('postgres_changes', - { event: 'UPDATE', schema: 'public', table: 'memos', filter: `id=eq.${memoId}` }, - callback - ) - .subscribe(); - } -} -``` - -## Error Handling - -### Custom Error Classes -```typescript -export class InsufficientCreditsError extends HttpException { - constructor( - public readonly required: number, - public readonly available: number, - public readonly operation: string - ) { - super( - { - statusCode: HttpStatus.BAD_REQUEST, - error: 'InsufficientCredits', - message: `Insufficient credits for ${operation}`, - details: { - required, - available, - operation - } - }, - HttpStatus.BAD_REQUEST - ); - } -} - -export class AudioFormatError extends HttpException { - constructor(filePath: string, format: string) { - super( - { - statusCode: HttpStatus.UNPROCESSABLE_ENTITY, - error: 'AudioFormatError', - message: 'Unsupported audio format', - details: { filePath, format } - }, - HttpStatus.UNPROCESSABLE_ENTITY - ); - } -} -``` - -### Global Exception Filter -```typescript -@Catch() -export class GlobalExceptionFilter implements ExceptionFilter { - catch(exception: any, host: ArgumentsHost) { - const ctx = host.switchToHttp(); - const response = ctx.getResponse(); - const request = ctx.getRequest(); - - const status = exception instanceof HttpException - ? exception.getStatus() - : HttpStatus.INTERNAL_SERVER_ERROR; - - const errorResponse = { - statusCode: status, - timestamp: new Date().toISOString(), - path: request.url, - method: request.method, - message: exception.message || 'Internal server error', - ...(exception instanceof HttpException ? exception.getResponse() as object : {}) - }; - - // Log error - Logger.error( - `${request.method} ${request.url}`, - JSON.stringify(errorResponse), - 'GlobalExceptionFilter' - ); - - response.status(status).json(errorResponse); - } -} -``` - -## Testing - -### Unit Testing -```typescript -// memoro.service.spec.ts -describe('MemoroService', () => { - let service: MemoroService; - let creditService: jest.Mocked; - let supabaseService: jest.Mocked; - - beforeEach(async () => { - const module = await Test.createTestingModule({ - providers: [ - MemoroService, - { - provide: CreditConsumptionService, - useValue: createMock() - }, - { - provide: SupabaseService, - useValue: createMock() - } - ] - }).compile(); - - service = module.get(MemoroService); - creditService = module.get(CreditConsumptionService); - supabaseService = module.get(SupabaseService); - }); - - describe('processUploadedAudio', () => { - it('should route short audio to fast transcription', async () => { - const duration = 60 * 30; // 30 minutes - creditService.calculateTranscriptionCost.mockReturnValue(60); - creditService.validateCredits.mockResolvedValue(undefined); - - const result = await service.processUploadedAudio( - 'user-id', - 'audio/file.m4a', - duration, - { languages: ['en-US'] } - ); - - expect(result.processingRoute).toBe('fast_transcribe'); - }); - - it('should route long audio to batch processing', async () => { - const duration = 60 * 120; // 120 minutes - - const result = await service.processUploadedAudio( - 'user-id', - 'audio/file.m4a', - duration, - { languages: ['en-US'] } - ); - - expect(result.processingRoute).toBe('batch_transcribe'); - }); - }); -}); -``` - -### Integration Testing -```typescript -// test/integration/audio-processing.e2e-spec.ts -describe('Audio Processing E2E', () => { - let app: INestApplication; - - beforeAll(async () => { - const moduleFixture = await Test.createTestingModule({ - imports: [AppModule], - }).compile(); - - app = moduleFixture.createNestApplication(); - await app.init(); - }); - - it('POST /memoro/process-uploaded-audio', async () => { - const response = await request(app.getHttpServer()) - .post('/memoro/process-uploaded-audio') - .set('Authorization', 'Bearer valid-token') - .send({ - filePath: 'test/audio.m4a', - duration: 300, - recordingLanguages: ['en-US'] - }) - .expect(201); - - expect(response.body).toHaveProperty('memo'); - expect(response.body.memo).toHaveProperty('id'); - expect(response.body.processingRoute).toBeDefined(); - }); -}); -``` - -### Load Testing -```javascript -// k6-load-test.js -import http from 'k6/http'; -import { check } from 'k6'; - -export let options = { - stages: [ - { duration: '2m', target: 100 }, // Ramp up - { duration: '5m', target: 100 }, // Stay at 100 users - { duration: '2m', target: 0 }, // Ramp down - ], -}; - -export default function() { - const params = { - headers: { - 'Content-Type': 'application/json', - 'Authorization': 'Bearer ${__ENV.TEST_TOKEN}' - }, - }; - - const payload = JSON.stringify({ - filePath: 'test/audio.m4a', - duration: 300, - }); - - const res = http.post( - 'http://localhost:3001/memoro/process-uploaded-audio', - payload, - params - ); - - check(res, { - 'status is 201': (r) => r.status === 201, - 'response time < 500ms': (r) => r.timings.duration < 500, - }); -} -``` - -## Performance - -### Optimization Strategies - -#### 1. Connection Pooling -```typescript -// Database connection pooling -const supabaseClient = createClient(url, key, { - db: { - poolSize: 10, - connectionTimeoutMillis: 5000, - idleTimeoutMillis: 30000, - } -}); -``` - -#### 2. Caching -```typescript -@Injectable() -export class CacheService { - private cache = new Map(); - - async get(key: string, factory: () => Promise, ttl = 30000): Promise { - const cached = this.cache.get(key); - - if (cached && cached.expiry > Date.now()) { - return cached.value as T; - } - - const value = await factory(); - this.cache.set(key, { - value, - expiry: Date.now() + ttl - }); - - return value; - } -} -``` - -#### 3. Request Batching -```typescript -class BatchProcessor { - private queue: Request[] = []; - private timer: NodeJS.Timeout; - - add(request: Request): Promise { - return new Promise((resolve, reject) => { - this.queue.push({ ...request, resolve, reject }); - - if (!this.timer) { - this.timer = setTimeout(() => this.flush(), 100); - } - }); - } - - private async flush() { - const batch = this.queue.splice(0); - const results = await this.processBatch(batch); - - batch.forEach((req, i) => { - req.resolve(results[i]); - }); - - this.timer = null; - } -} -``` - -### Performance Metrics - -| Metric | Target | Current | Notes | -|--------|--------|---------|-------| -| API Response Time (p50) | < 100ms | 85ms | ✅ | -| API Response Time (p95) | < 500ms | 420ms | ✅ | -| API Response Time (p99) | < 1000ms | 890ms | ✅ | -| Transcription Start Time | < 5s | 3.2s | ✅ | -| Credit Check Time | < 50ms | 35ms | ✅ | -| Database Query Time | < 100ms | 75ms | ✅ | -| Memory Usage | < 1GB | 650MB | ✅ | -| CPU Usage (avg) | < 70% | 45% | ✅ | - -## Security - -### Authentication & Authorization - -#### JWT Validation -```typescript -@Injectable() -export class JwtAuthGuard implements CanActivate { - async canActivate(context: ExecutionContext): Promise { - const request = context.switchToHttp().getRequest(); - const token = this.extractToken(request); - - if (!token) { - throw new UnauthorizedException('No token provided'); - } - - try { - const payload = await this.validateToken(token); - request.user = payload; - return true; - } catch (error) { - throw new UnauthorizedException('Invalid token'); - } - } - - private extractToken(request: Request): string | null { - const auth = request.headers.authorization; - if (!auth) return null; - - const [type, token] = auth.split(' '); - return type === 'Bearer' ? token : null; - } -} -``` - -#### Service Authentication -```typescript -@Injectable() -export class ServiceAuthGuard implements CanActivate { - canActivate(context: ExecutionContext): boolean { - const request = context.switchToHttp().getRequest(); - const serviceKey = request.headers['x-service-key']; - - if (!serviceKey) { - throw new UnauthorizedException('Service key required'); - } - - // Validate service key - const validKeys = [ - process.env.MANA_SERVICE_KEY, - process.env.AUDIO_SERVICE_KEY, - ]; - - if (!validKeys.includes(serviceKey)) { - throw new UnauthorizedException('Invalid service key'); - } - - return true; - } -} -``` - -### Input Validation -```typescript -// DTOs with validation -export class ProcessAudioDto { - @IsString() - @IsNotEmpty() - filePath: string; - - @IsNumber() - @Min(1) - @Max(10800) // 3 hours max - duration: number; - - @IsArray() - @IsString({ each: true }) - @ArrayMaxSize(10) - recordingLanguages: string[]; - - @IsBoolean() - @IsOptional() - enableDiarization?: boolean; - - @IsObject() - @IsOptional() - metadata?: Record; -} -``` - -### Security Headers -```typescript -// main.ts -app.use(helmet({ - contentSecurityPolicy: { - directives: { - defaultSrc: ["'self'"], - styleSrc: ["'self'", "'unsafe-inline'"], - scriptSrc: ["'self'"], - imgSrc: ["'self'", "data:", "https:"], - }, - }, - hsts: { - maxAge: 31536000, - includeSubDomains: true, - preload: true, - }, -})); -``` - -## Deployment - -### Docker Configuration -```dockerfile -# Dockerfile -FROM node:18-alpine AS builder - -WORKDIR /app - -# Copy package files -COPY package*.json ./ -RUN npm ci --only=production - -# Copy source -COPY . . -RUN npm run build - -# Production image -FROM node:18-alpine - -WORKDIR /app - -# Copy built application -COPY --from=builder /app/dist ./dist -COPY --from=builder /app/node_modules ./node_modules -COPY --from=builder /app/package.json ./ - -# Health check -HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ - CMD node -e "require('http').get('http://localhost:3001/health', (r) => {r.statusCode === 200 ? process.exit(0) : process.exit(1)})" - -EXPOSE 3001 - -CMD ["node", "dist/main"] -``` - -### Cloud Run Deployment -```yaml -# cloudbuild.yaml -steps: - # Build the container image - - name: 'gcr.io/cloud-builders/docker' - args: ['build', '-t', 'gcr.io/$PROJECT_ID/memoro-service:$COMMIT_SHA', '.'] - - # Push to Container Registry - - name: 'gcr.io/cloud-builders/docker' - args: ['push', 'gcr.io/$PROJECT_ID/memoro-service:$COMMIT_SHA'] - - # Deploy to Cloud Run - - name: 'gcr.io/cloud-builders/gcloud' - args: - - 'run' - - 'deploy' - - 'memoro-service' - - '--image=gcr.io/$PROJECT_ID/memoro-service:$COMMIT_SHA' - - '--region=europe-west3' - - '--platform=managed' - - '--memory=1Gi' - - '--cpu=1' - - '--min-instances=1' - - '--max-instances=10' - - '--set-env-vars-from-file=.env.prod' -``` - -### Environment Management -```bash -# Production deployment -gcloud run deploy memoro-service \ - --image gcr.io/PROJECT/memoro-service:latest \ - --region europe-west3 \ - --set-env-vars="NODE_ENV=production,LOG_LEVEL=info" - -# Staging deployment -gcloud run deploy memoro-service-staging \ - --image gcr.io/PROJECT/memoro-service:staging \ - --region europe-west3 \ - --set-env-vars="NODE_ENV=staging,LOG_LEVEL=debug" -``` - -## Monitoring & Logging - -### Structured Logging -```typescript -@Injectable() -export class LoggerService { - private logger = new Logger('MemoroService'); - - log(message: string, context?: any) { - this.logger.log({ - timestamp: new Date().toISOString(), - level: 'info', - message, - ...context - }); - } - - error(message: string, error: Error, context?: any) { - this.logger.error({ - timestamp: new Date().toISOString(), - level: 'error', - message, - error: { - name: error.name, - message: error.message, - stack: error.stack - }, - ...context - }); - } -} -``` - -### Metrics Collection -```typescript -@Injectable() -export class MetricsService { - private metrics = { - requestCount: 0, - errorCount: 0, - creditUsage: 0, - processingTime: [] - }; - - recordRequest(endpoint: string, duration: number, status: number) { - this.metrics.requestCount++; - - if (status >= 400) { - this.metrics.errorCount++; - } - - this.metrics.processingTime.push({ - endpoint, - duration, - timestamp: Date.now() - }); - } - - getMetrics() { - return { - ...this.metrics, - avgProcessingTime: this.calculateAverage(this.metrics.processingTime), - errorRate: this.metrics.errorCount / this.metrics.requestCount - }; - } -} -``` - -### Health Checks -```typescript -@Controller('health') -export class HealthController { - constructor( - private health: HealthCheckService, - private db: TypeOrmHealthIndicator, - private http: HttpHealthIndicator, - ) {} - - @Get() - @HealthCheck() - check() { - return this.health.check([ - () => this.db.pingCheck('database'), - () => this.http.pingCheck('mana-core', process.env.MANA_SERVICE_URL), - () => this.checkDiskSpace(), - () => this.checkMemoryUsage(), - ]); - } - - private checkMemoryUsage() { - const used = process.memoryUsage(); - const limit = 1024 * 1024 * 1024; // 1GB - - return { - memory: { - status: used.heapUsed < limit ? 'up' : 'down', - used: Math.round(used.heapUsed / 1024 / 1024), - limit: limit / 1024 / 1024 - } - }; - } -} -``` - -## Troubleshooting - -### Common Issues - -#### 1. Authentication Failures -```bash -# Check JWT token -curl -H "Authorization: Bearer $TOKEN" http://localhost:3001/auth/validate - -# Common causes: -- Token expired (check exp claim) -- Wrong app_id in token -- Service key not configured -``` - -#### 2. Credit Insufficient Errors -```typescript -// Debug credit issues -async debugCredits(userId: string) { - const balance = await this.creditService.getBalance(userId); - const pendingOps = await this.getPendingOperations(userId); - - console.log({ - currentBalance: balance, - pendingConsumption: pendingOps.reduce((sum, op) => sum + op.cost, 0), - availableCredits: balance - pendingConsumption - }); -} -``` - -#### 3. Transcription Failures -```bash -# Check audio format -ffprobe audio/file.m4a - -# Common issues: -- Unsupported codec (use AAC) -- File too large (>180 minutes) -- Corrupted audio file -- Network timeout for large files -``` - -#### 4. Real-time Updates Not Working -```typescript -// Debug Supabase subscriptions -const channel = supabase.channel('debug') - .on('*', (payload) => console.log('Event:', payload)) - .subscribe((status) => { - console.log('Subscription status:', status); - }); - -// Common issues: -- JWT not passed to Supabase client -- RLS policies blocking access -- WebSocket connection issues -``` - -### Debug Mode -```typescript -// Enable debug logging -if (process.env.NODE_ENV === 'development') { - app.useLogger(['debug', 'error', 'warn', 'log', 'verbose']); - - // Log all requests - app.use((req, res, next) => { - console.log(`[${req.method}] ${req.url}`, { - headers: req.headers, - body: req.body - }); - next(); - }); -} -``` - -### Performance Profiling -```typescript -// CPU profiling -import * as v8Profiler from 'v8-profiler-next'; - -export class ProfilingService { - startProfiling(title: string) { - v8Profiler.startProfiling(title, true); - } - - stopProfiling(title: string) { - const profile = v8Profiler.stopProfiling(title); - profile.export((error, result) => { - fs.writeFileSync(`${title}.cpuprofile`, result); - profile.delete(); - }); - } -} -``` - -## Appendices - -### A. Environment Variables Reference - -| Variable | Description | Default | Required | -|----------|-------------|---------|----------| -| PORT | Service port | 3001 | No | -| NODE_ENV | Environment | development | No | -| MANA_SERVICE_URL | Mana Core URL | - | Yes | -| AUDIO_MICROSERVICE_URL | Audio service URL | - | Yes | -| MEMORO_SUPABASE_URL | Supabase URL | - | Yes | -| MEMORO_SUPABASE_ANON_KEY | Anon key | - | Yes | -| MEMORO_SUPABASE_SERVICE_KEY | Service key | - | Yes | -| MEMORO_APP_ID | App identifier | - | Yes | -| LOG_LEVEL | Logging level | info | No | -| ENABLE_BATCH_TRANSCRIPTION | Feature flag | true | No | - -### B. Error Codes Reference - -| Code | Description | HTTP Status | -|------|-------------|-------------| -| AUTH001 | Invalid token | 401 | -| AUTH002 | Token expired | 401 | -| AUTH003 | Insufficient permissions | 403 | -| CREDIT001 | Insufficient credits | 402 | -| CREDIT002 | Credit check failed | 500 | -| AUDIO001 | Invalid audio format | 422 | -| AUDIO002 | Audio too long | 413 | -| AUDIO003 | Transcription failed | 500 | -| SPACE001 | Space not found | 404 | -| SPACE002 | Not space member | 403 | - -### C. API Rate Limits - -| Endpoint | Rate Limit | Window | -|----------|------------|--------| -| /auth/* | 10 req | 1 min | -| /memoro/process-uploaded-audio | 5 req | 1 min | -| /memoro/question-memo | 10 req | 1 min | -| /memoro/spaces/* | 30 req | 1 min | -| Default | 100 req | 1 min | \ No newline at end of file diff --git a/apps/memoro/apps/backend/cloud-run-deploy.md b/apps/memoro/apps/backend/cloud-run-deploy.md deleted file mode 100644 index efae83152..000000000 --- a/apps/memoro/apps/backend/cloud-run-deploy.md +++ /dev/null @@ -1,132 +0,0 @@ -# Memoro Microservice Cloud Run Deployment Guide - -## 1. Set up environment secrets - -```bash -# Step 1: Authenticate with Google Cloud if needed -gcloud auth login - -# Step 2: Set your project ID -gcloud config set project memo-2c4c4 - -# Step 3: Create or update GCP Secret Manager secrets for Memoro service -# If you're using existing secrets from the main service, you can reference those -# Otherwise, create new secrets for Memoro-specific configuration -gcloud secrets create MEMORO_SUPABASE_URL --data-file=/path/to/secret/value.txt -gcloud secrets create MEMORO_SUPABASE_ANON_KEY --data-file=/path/to/secret/value.txt -gcloud secrets create MANA_SERVICE_URL --data-file=/path/to/secret/value.txt -gcloud secrets create MEMORO_APP_ID --data-file=/path/to/secret/value.txt -``` - -## 2. Build and push Docker image - -```bash -# Navigate to the Memoro service directory -cd memoro-service - -gcloud builds submit --project=memo-2c4c4 --config=cloudbuild-memoro.yaml . - -## 3. Deploy to Cloud Run - -```bash -gcloud run deploy memoro-service \ - --image europe-west3-docker.pkg.dev/mana-core-453821/memoro-service/memoro-service:v1.0.0 \ - --platform managed \ - --region europe-west3 \ - --allow-unauthenticated \ - --memory 512Mi \ - --set-secrets=MEMORO_SUPABASE_URL=MEMORO_SUPABASE_URL:latest,MEMORO_SUPABASE_ANON_KEY=MEMORO_SUPABASE_ANON_KEY:latest,MANA_SERVICE_URL=MANA_SERVICE_URL:latest,MEMORO_APP_ID=MEMORO_APP_ID:latest -``` -gcloud run deploy memoro-service \ - --source . \ - --platform managed \ - --region europe-west3 \ - --allow-unauthenticated \ - --memory 512Mi \ - --set-secrets=MEMORO_SUPABASE_URL=MEMORO_SUPABASE_URL:latest,MEMORO_SUPABASE_ANON_KEY=MEMORO_SUPABASE_ANON_KEY:latest,MANA_SERVICE_URL=MANA_SERVICE_URL:latest,MEMORO_APP_ID=MEMORO_APP_ID:latest - - -## 4. Update Main Middleware Environment Variables - -After deploying the Memoro microservice, you need to update the main middleware service's environment to point to the new Memoro service URL. - -```bash -# Get the Memoro service URL -MEMORO_SERVICE_URL=$(gcloud run services describe memoro-service --platform managed --region europe-west3 --format 'value(status.url)') - -# Update the main middleware's MEMORO_SERVICE_URL environment variable -gcloud run services update mana-core-middleware-dev \ - --region europe-west3 \ - --platform managed \ - --set-env-vars=MEMORO_SERVICE_URL=$MEMORO_SERVICE_URL -``` - -## 5. Testing the deployment - -```bash -# Get the service URL -SERVICE_URL=$(gcloud run services describe memoro-service --platform managed --region europe-west3 --format 'value(status.url)') - -# Test the API (requires authentication) -curl -H "Authorization: Bearer YOUR_JWT_TOKEN" $SERVICE_URL/memoro/spaces -``` - -## 6. Monitoring and Logging - -After deployment, you can monitor your service through: - -- **Cloud Run Dashboard**: For service health, traffic, and resource usage -- **Cloud Logging**: For application logs -- **Cloud Monitoring**: For setting up alerts and dashboards - -```bash -# View logs -gcloud logging read "resource.type=cloud_run_revision AND resource.labels.service_name=memoro-service" --limit 10 -``` - -## 7. Troubleshooting - -If you encounter issues with your deployment: - -1. Check application logs in Cloud Logging -2. Verify that all environment secrets are correctly set -3. Ensure that your service has sufficient memory and CPU -4. Check that the service account has the necessary permissions -5. Verify that the service can communicate with Auth and Spaces services -6. Check for CORS issues if calling from frontend applications - -## 8. Continuous Deployment (optional) - -You can set up continuous deployment using Cloud Build: - -```bash -# Create a Cloud Build trigger -gcloud builds triggers create github \ - --repo-name=your-repo-name \ - --branch-pattern=main \ - --build-config=cloudbuild.yaml -``` - -Example `cloudbuild.yaml`: - -```yaml -steps: - - name: 'gcr.io/cloud-builders/docker' - args: ['build', '-t', 'europe-west3-docker.pkg.dev/mana-core-453821/memoro-service/memoro-service:$COMMIT_SHA', '.'] - - name: 'gcr.io/cloud-builders/docker' - args: ['push', 'europe-west3-docker.pkg.dev/mana-core-453821/memoro-service/memoro-service:$COMMIT_SHA'] - - name: 'gcr.io/google.com/cloudsdktool/cloud-sdk' - entrypoint: gcloud - args: - - 'run' - - 'deploy' - - 'memoro-service' - - '--image' - - 'europe-west3-docker.pkg.dev/mana-core-453821/memoro-service/memoro-service:$COMMIT_SHA' - - '--region' - - 'europe-west3' - - '--platform' - - 'managed' -images: - - 'europe-west3-docker.pkg.dev/mana-core-453821/memoro-service/memoro-service:$COMMIT_SHA' -``` \ No newline at end of file diff --git a/apps/memoro/apps/backend/cloudbuild-memoro.yaml b/apps/memoro/apps/backend/cloudbuild-memoro.yaml deleted file mode 100644 index fad6ca433..000000000 --- a/apps/memoro/apps/backend/cloudbuild-memoro.yaml +++ /dev/null @@ -1,8 +0,0 @@ -# cloudbuild-memoro.yaml -steps: - - name: 'gcr.io/cloud-builders/docker' - args: ['build', '-t', 'europe-west3-docker.pkg.dev/memo-2c4c4/memoro-service/memoro-service:v4.9.8', '.'] # Assumes Dockerfile is in ./memoro-service - - name: 'gcr.io/cloud-builders/docker' - args: ['push', 'europe-west3-docker.pkg.dev/memo-2c4c4/memoro-service/memoro-service:v4.9.8'] -images: - - 'europe-west3-docker.pkg.dev/memo-2c4c4/memoro-service/memoro-service:v4.9.8' \ No newline at end of file diff --git a/apps/memoro/apps/backend/docs/SERVICE_AUTH_IMPLEMENTATION.md b/apps/memoro/apps/backend/docs/SERVICE_AUTH_IMPLEMENTATION.md deleted file mode 100644 index 575e1f8a1..000000000 --- a/apps/memoro/apps/backend/docs/SERVICE_AUTH_IMPLEMENTATION.md +++ /dev/null @@ -1,146 +0,0 @@ -# Service-to-Service Authentication Implementation - -## Overview -This document describes the implementation of service role key authentication between the audio microservice and memoro service, replacing the previous user JWT token passthrough approach. - -## Problem Statement -The audio microservice was experiencing 401 authentication errors when calling back to the memoro service because: -- User JWT tokens were expiring during long-running transcription processes -- The audio service needed to make callbacks even after the user's session ended -- Service-to-service communication should not depend on user authentication - -## Solution Architecture - -### 1. Service Authentication Guard -Created `src/guards/service-auth.guard.ts` that: -- Validates requests using Supabase service role keys -- Accepts both `MEMORO_SUPABASE_SERVICE_KEY` and `SUPABASE_SERVICE_KEY` for compatibility -- Marks authenticated requests with `isServiceAuth` flag - -### 2. Dedicated Service Endpoints -Created `src/memoro/memoro-service.controller.ts` with service-specific endpoints: -- `/memoro/service/transcription-completed` -- `/memoro/service/append-transcription-completed` -- `/memoro/service/update-batch-metadata` - -These endpoints: -- Use `ServiceAuthGuard` instead of regular `AuthGuard` -- Call existing service methods with `token: null` -- Pass userId for ownership validation - -### 3. Ownership Validation -Updated service methods to validate memo ownership when using service auth: -- `handleTranscriptionCompleted`: Validates memo.user_id matches provided userId -- `handleAppendTranscriptionCompleted`: Validates memo.user_id matches provided userId -- `updateBatchMetadataByMemoId`: Validates memo.user_id matches provided userId (when userId provided) - -### 4. Supabase Client Configuration -Fixed JWT parsing errors by conditionally creating Supabase clients: -```typescript -const authClient = isServiceAuth - ? createClient(this.memoroUrl, this.memoroServiceKey) - : createClient(this.memoroUrl, this.memoroServiceKey, { - global: { headers: { Authorization: `Bearer ${token}` } } - }); -``` - -## Audio Microservice Changes - -### 1. Updated Callback URLs -All callbacks now use `/service/` endpoints: -- `notifyTranscriptionComplete`: Uses `/memoro/service/transcription-completed` -- `notifyAppendTranscriptionComplete`: Uses `/memoro/service/append-transcription-completed` -- `storeBatchJobMetadata`: Uses `/memoro/service/update-batch-metadata` - -### 2. Service Key Authentication -Updated to use service role key instead of user tokens: -```typescript -const serviceKey = this.configService.get('MEMORO_SUPABASE_SERVICE_KEY') || - this.configService.get('SUPABASE_SERVICE_KEY'); -``` - -### 3. UserId Parameter -Added userId parameter to batch metadata updates for ownership validation - -## Environment Variables - -### Memoro Service -```bash -# Primary service key -MEMORO_SUPABASE_SERVICE_KEY= - -# Also accepts for compatibility -SUPABASE_SERVICE_KEY= -``` - -### Audio Microservice -```bash -# Primary service key (for memoro callbacks) -MEMORO_SUPABASE_SERVICE_KEY= - -# Original service key (for Supabase operations) -SUPABASE_SERVICE_KEY= -``` - -## Deployment Steps - -### 1. Deploy Memoro Service -```bash -# Add environment variable -gcloud run services update memoro-service \ - --project=memo-2c4c4 \ - --region=europe-west3 \ - --update-env-vars="SUPABASE_SERVICE_KEY=" - -# Build and deploy new code -gcloud builds submit --config=cloudbuild-memoro.yaml -gcloud run deploy memoro-service \ - --project=memo-2c4c4 \ - --image=europe-west3-docker.pkg.dev/memo-2c4c4/memoro-service/memoro-service:v4.9.6 \ - --platform=managed \ - --region=europe-west3 \ - --allow-unauthenticated \ - --memory=1Gi -``` - -### 2. Deploy Audio Microservice -```bash -# Add environment variable -gcloud run services update audio-microservice \ - --project=memo-2c4c4 \ - --region=europe-west3 \ - --update-env-vars="MEMORO_SUPABASE_SERVICE_KEY=" - -# Build and deploy new code -# (Follow standard audio microservice deployment process) -``` - -## Security Considerations - -1. **Service Role Key Protection**: Service role keys bypass RLS, so they must be: - - Stored as environment variables only - - Never exposed to clients - - Rotated periodically - -2. **Ownership Validation**: Even with service auth, the system validates: - - User owns the memo being updated - - Prevents unauthorized access across users - -3. **Network Security**: Both services run on Google Cloud Run with: - - HTTPS encryption in transit - - Network isolation - - IAM-based access control - -## Benefits - -1. **Reliability**: No more 401 errors from expired user tokens -2. **Consistency**: Service-to-service auth independent of user sessions -3. **Performance**: Direct service authentication without token validation overhead -4. **Maintainability**: Clear separation between user and service endpoints - -## Future Improvements - -1. **mTLS**: Implement mutual TLS between services -2. **Service Accounts**: Use Google Cloud service accounts instead of API keys -3. **Rate Limiting**: Add rate limiting to service endpoints -4. **Audit Logging**: Enhanced logging for service-to-service calls \ No newline at end of file diff --git a/apps/memoro/apps/backend/docs/SIGNUP_IMPLEMENTATION_PLAN.md b/apps/memoro/apps/backend/docs/SIGNUP_IMPLEMENTATION_PLAN.md deleted file mode 100644 index 36a39a625..000000000 --- a/apps/memoro/apps/backend/docs/SIGNUP_IMPLEMENTATION_PLAN.md +++ /dev/null @@ -1,460 +0,0 @@ -# Memoro Service - New Signup Implementation Plan - -## Overview - -This plan outlines the steps to integrate the Memoro backend service with the new Mana Core authentication system that includes dynamic email branding and enhanced device tracking. - -## Current State Analysis - -### Existing Implementation -- **Location**: `src/auth-proxy/auth-proxy.service.ts` -- **Current App ID**: `973da0c1-b479-4dac-a1b0-ed09c72caca8` (in .env) -- **Mana Core App ID**: `edde080c-3882-46bd-9867-72bdf3cbd99c` (in mana-core config) -- **Current Flow**: Simple proxy to Mana Core with redirect URL override - -### Current Signup Code (Line 111-118) -```typescript -async signup(payload: any) { - // Add custom redirect URL for Memoro - const enhancedPayload = { - ...payload, - redirectUrl: 'https://memoro.ai/de/welcome/' - }; - return this.proxyPost('/auth/signup', enhancedPayload); -} -``` - -### Issues to Address -1. ❌ No TypeScript types/interfaces (uses `any`) -2. ❌ App ID mismatch between .env and mana-core config -3. ❌ Missing logo metadata for custom branding -4. ❌ No validation of required fields (deviceInfo) -5. ❌ No DTO classes for request/response - ---- - -## Implementation Plan - -### Phase 1: Create TypeScript Interfaces & DTOs - -#### 1.1 Device Info Interface -**File**: `src/auth-proxy/dto/device-info.dto.ts` - -```typescript -import { IsString, IsEnum, IsOptional } from 'class-validator'; - -export enum DeviceType { - WEB = 'web', - IOS = 'ios', - ANDROID = 'android', - DESKTOP = 'desktop', -} - -export class DeviceInfoDto { - @IsString() - deviceId: string; - - @IsString() - deviceName: string; - - @IsEnum(DeviceType) - deviceType: DeviceType; - - @IsOptional() - @IsString() - userAgent?: string; -} -``` - -#### 1.2 Signup Request DTO -**File**: `src/auth-proxy/dto/signup-request.dto.ts` - -```typescript -import { IsEmail, IsString, MinLength, ValidateNested, IsOptional } from 'class-validator'; -import { Type } from 'class-transformer'; -import { DeviceInfoDto } from './device-info.dto'; - -export class SignupRequestDto { - @IsEmail() - email: string; - - @IsString() - @MinLength(8) - password: string; - - @ValidateNested() - @Type(() => DeviceInfoDto) - deviceInfo: DeviceInfoDto; - - @IsOptional() - metadata?: { - [key: string]: any; - }; - - @IsOptional() - @IsString() - redirectUrl?: string; -} -``` - -#### 1.3 Signup Response Interface -**File**: `src/auth-proxy/interfaces/signup-response.interface.ts` - -```typescript -export interface SignupResponse { - message: string; - confirmationRequired: boolean; - manaToken?: string; - appToken?: string; - refreshToken?: string; - deviceId?: string; - user: { - id: string; - email: string; - created_at?: string; - }; -} -``` - -#### 1.4 Auth Metadata Interface -**File**: `src/auth-proxy/interfaces/auth-metadata.interface.ts` - -```typescript -export interface AuthMetadata { - logoUrl?: string; - userName?: string; - [key: string]: any; -} -``` - ---- - -### Phase 2: Update Environment Configuration - -#### 2.1 Verify App ID -**Action**: Check which App ID is correct -- Option A: Update `.env` to use `edde080c-3882-46bd-9867-72bdf3cbd99c` (from mana-core) -- Option B: Update mana-core config to use `973da0c1-b479-4dac-a1b0-ed09c72caca8` - -**Recommendation**: Use the App ID that's configured in mana-core (`edde080c-3882-46bd-9867-72bdf3cbd99c`) - -#### 2.2 Add Logo Configuration -**File**: `.env` - -```bash -# Add to .env -MEMORO_LOGO_FILENAME=memoro-logo.svg -``` - -**File**: `env.example` -```bash -# Add to env.example -MEMORO_LOGO_FILENAME=memoro-logo.svg -``` - ---- - -### Phase 3: Update Auth Proxy Service - -#### 3.1 Enhanced Signup Method -**File**: `src/auth-proxy/auth-proxy.service.ts` - -```typescript -import { SignupRequestDto } from './dto/signup-request.dto'; -import { SignupResponse } from './interfaces/signup-response.interface'; -import { AuthMetadata } from './interfaces/auth-metadata.interface'; - -export class AuthProxyService { - private memoroLogoFilename: string; - - constructor( - private httpService: HttpService, - private configService: ConfigService, - ) { - this.manaServiceUrl = this.configService.get('MANA_SERVICE_URL', 'http://localhost:3000'); - this.memoroAppId = this.configService.get('MEMORO_APP_ID'); - this.memoroLogoFilename = this.configService.get('MEMORO_LOGO_FILENAME', 'memoro-logo.svg'); - } - - async signup(payload: SignupRequestDto): Promise { - // Validate device info is present - if (!payload.deviceInfo) { - throw new HttpException( - 'Device information is required for signup', - HttpStatus.BAD_REQUEST - ); - } - - // Prepare metadata with logo for custom email branding - const metadata: AuthMetadata = { - ...payload.metadata, - logoUrl: this.memoroLogoFilename, // Just the filename - }; - - // Enhanced payload with Memoro-specific branding - const enhancedPayload = { - email: payload.email, - password: payload.password, - deviceInfo: payload.deviceInfo, - metadata, - redirectUrl: payload.redirectUrl || 'https://memoro.ai/de/welcome/', - }; - - console.log('[AuthProxy] Signup with enhanced payload:', { - email: enhancedPayload.email, - hasDeviceInfo: !!enhancedPayload.deviceInfo, - logoUrl: metadata.logoUrl, - redirectUrl: enhancedPayload.redirectUrl, - }); - - return this.proxyPost('/auth/signup', enhancedPayload); - } -} -``` - ---- - -### Phase 4: Update Auth Proxy Controller - -#### 4.1 Add Validation Pipe -**File**: `src/auth-proxy/auth-proxy.controller.ts` - -```typescript -import { - Controller, - Post, - Get, - Body, - Headers, - HttpCode, - HttpException, - HttpStatus, - UsePipes, - ValidationPipe -} from '@nestjs/common'; -import { SignupRequestDto } from './dto/signup-request.dto'; -import { SignupResponse } from './interfaces/signup-response.interface'; - -@Controller('auth') -export class AuthProxyController { - constructor(private readonly authProxyService: AuthProxyService) {} - - @Post('signup') - @UsePipes(new ValidationPipe({ - whitelist: true, - forbidNonWhitelisted: true, - transform: true - })) - async signup(@Body() payload: SignupRequestDto): Promise { - return this.authProxyService.signup(payload); - } - - // Other methods remain similar but can be typed - @Post('signin') - async signin(@Body() payload: any) { - // Validate device info - if (!payload.deviceInfo) { - throw new HttpException( - 'Device information is required for signin', - HttpStatus.BAD_REQUEST - ); - } - return this.authProxyService.signin(payload); - } -} -``` - ---- - -### Phase 5: Install Required Dependencies - -```bash -cd memoro-service -npm install class-validator class-transformer -``` - ---- - -### Phase 6: Testing - -#### 6.1 Unit Tests -**File**: `src/auth-proxy/auth-proxy.service.spec.ts` - -Add tests for: -- ✅ Signup with valid deviceInfo -- ✅ Signup includes logo metadata -- ✅ Signup includes redirect URL -- ✅ Error when deviceInfo is missing - -#### 6.2 Integration Tests - -**Test 1: Signup with All Fields** -```bash -curl -X POST http://localhost:3001/auth/signup \ - -H 'Content-Type: application/json' \ - -d '{ - "email": "test@memoro.ai", - "password": "Test123456!", - "deviceInfo": { - "deviceId": "web-test-device-1", - "deviceName": "Chrome on MacBook", - "deviceType": "web", - "userAgent": "Mozilla/5.0..." - } - }' -``` - -**Expected Response:** -```json -{ - "message": "Sign up successful. Please check your email to confirm your account.", - "confirmationRequired": true, - "user": { - "id": "...", - "email": "test@memoro.ai" - } -} -``` - -**Test 2: Check Email Branding** -- Email should show Memoro logo -- Email should use yellow color scheme (#F8D62B) -- Email should show German/English taglines -- Email should include Memoro features - -**Test 3: Missing DeviceInfo (Should Fail)** -```bash -curl -X POST http://localhost:3001/auth/signup \ - -H 'Content-Type: application/json' \ - -d '{ - "email": "test@memoro.ai", - "password": "Test123456!" - }' -``` - -**Expected:** 400 Bad Request with validation error - ---- - -### Phase 7: Documentation - -#### 7.1 Update README -**File**: `README.md` - -Add section: -```markdown -## Authentication - -Memoro uses the Mana Core authentication system with custom branding. - -### Signup Flow - -When users sign up via Memoro: -1. Frontend calls `/auth/signup` with email, password, and device info -2. Memoro backend adds Memoro logo metadata -3. Mana Core creates account and sends branded email -4. User confirms email and can log in - -See [docs/AUTH_INTEGRATION.md](./docs/AUTH_INTEGRATION.md) for details. -``` - -#### 7.2 Create Integration Doc -**File**: `docs/AUTH_INTEGRATION.md` - -Document: -- How Memoro integrates with Mana Core -- Required environment variables -- Device info requirements -- Custom branding flow -- Error handling - ---- - -## Migration Checklist - -### Pre-Deployment -- [ ] Verify App ID is correct in both services -- [ ] Upload `memoro-logo.svg` to Mana Core Supabase bucket -- [ ] Update `.env` with correct `MEMORO_APP_ID` -- [ ] Add `MEMORO_LOGO_FILENAME=memoro-logo.svg` to `.env` -- [ ] Install dependencies: `class-validator`, `class-transformer` -- [ ] Run tests locally - -### Code Changes -- [ ] Create DTOs in `src/auth-proxy/dto/` -- [ ] Create interfaces in `src/auth-proxy/interfaces/` -- [ ] Update `auth-proxy.service.ts` with new signup method -- [ ] Update `auth-proxy.controller.ts` with validation -- [ ] Add unit tests -- [ ] Update documentation - -### Deployment -- [ ] Deploy to staging environment -- [ ] Test signup flow end-to-end -- [ ] Verify email branding looks correct -- [ ] Check device tracking works -- [ ] Deploy to production -- [ ] Monitor for errors - -### Post-Deployment -- [ ] Verify production signup emails show Memoro branding -- [ ] Test all auth flows (signin, google, apple) -- [ ] Update frontend to include deviceInfo if not already -- [ ] Document any issues/learnings - ---- - -## Timeline Estimate - -- **Phase 1-2** (Types & Config): 1 hour -- **Phase 3-4** (Service & Controller): 2 hours -- **Phase 5** (Dependencies): 15 minutes -- **Phase 6** (Testing): 2 hours -- **Phase 7** (Documentation): 1 hour - -**Total**: ~6-7 hours - ---- - -## Risk Assessment - -### Low Risk -✅ Adding types/interfaces (backward compatible) -✅ Adding logo metadata (optional field) -✅ Documentation updates - -### Medium Risk -⚠️ Changing App ID (requires coordination) -⚠️ Adding validation (could break existing clients) - -### Mitigation -- Test thoroughly in staging -- Deploy during low-traffic period -- Have rollback plan ready -- Monitor error rates after deployment - ---- - -## Questions to Resolve - -1. **App ID**: Which App ID should be used? - - Current in memoro-service: `973da0c1-b479-4dac-a1b0-ed09c72caca8` - - Current in mana-core: `edde080c-3882-46bd-9867-72bdf3cbd99c` - -2. **Breaking Changes**: Should we enforce validation immediately or phase it in? - - Option A: Enforce now (could break old clients) - - Option B: Log warnings first, enforce later - -3. **Logo Location**: Is `memoro-logo.svg` already uploaded to satellites-logos bucket? - ---- - -## Success Criteria - -✅ Signup creates account successfully -✅ Email shows Memoro branding (yellow, logo, features) -✅ DeviceInfo is properly tracked -✅ All auth tests pass -✅ No breaking changes to existing clients -✅ Documentation is complete -✅ Production deployment successful diff --git a/apps/memoro/apps/backend/docs/append-transcription-usage.md b/apps/memoro/apps/backend/docs/append-transcription-usage.md deleted file mode 100644 index d4d7e1f85..000000000 --- a/apps/memoro/apps/backend/docs/append-transcription-usage.md +++ /dev/null @@ -1,154 +0,0 @@ -# Append Transcription Usage Example - -## Overview -The append-transcription endpoint allows you to add additional audio recordings to an existing memo and have them transcribed. This is useful when users want to add follow-up thoughts or additional content to a memo without creating a new one. - -## Frontend Integration Example - -```typescript -// Example: Adding an additional recording to an existing memo - -async function appendAudioToMemo( - memoId: string, - audioFile: File, - recordingDuration: number -) { - try { - // 1. Upload audio file to Supabase storage (similar to main recording) - const filePath = `${userId}/recordings/${Date.now()}_append.webm`; - const { error: uploadError } = await supabase.storage - .from('user-uploads') - .upload(filePath, audioFile); - - if (uploadError) { - throw uploadError; - } - - // 2. Call the append-transcription endpoint - const response = await fetch(`${MEMORO_SERVICE_URL}/memoro/append-transcription`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${token}` - }, - body: JSON.stringify({ - memoId: memoId, - filePath: filePath, - duration: recordingDuration, - recordingLanguages: ['de-DE', 'en-US'], // Optional: user's selected languages - enableDiarization: true // Optional: enable speaker detection - }) - }); - - if (!response.ok) { - const error = await response.json(); - throw new Error(error.message || 'Failed to append transcription'); - } - - const result = await response.json(); - console.log('Append transcription started:', result); - - // The memo will be updated asynchronously - // You can listen to real-time updates or poll for status - - return result; - } catch (error) { - console.error('Error appending audio to memo:', error); - throw error; - } -} -``` - -## Response Format - -### Success Response -```json -{ - "success": true, - "memoId": "uuid-here", - "filePath": "userId/recordings/timestamp_append.webm", - "status": "processing", - "estimatedDuration": 5, - "message": "Append transcription in progress.", - "estimatedCredits": 10 -} -``` - -### Error Responses - -#### Insufficient Credits -```json -{ - "statusCode": 403, - "message": "Insufficient credits for transcription. Required: 10, Available: 5 (user credits)" -} -``` - -#### Memo Not Found -```json -{ - "statusCode": 404, - "message": "Memo not found or access denied" -} -``` - -## Accessing Appended Recordings - -Once transcription is complete, the additional recordings will be available in the memo's source: - -```typescript -// Fetch updated memo -const { data: memo } = await supabase - .from('memos') - .select('*') - .eq('id', memoId) - .single(); - -// Access additional recordings -const additionalRecordings = memo.source.additional_recordings || []; - -additionalRecordings.forEach((recording, index) => { - console.log(`Recording ${index + 1}:`); - console.log(`- Transcript: ${recording.transcript}`); - console.log(`- Language: ${recording.primary_language}`); - console.log(`- Speakers: ${Object.keys(recording.speakers || {}).length}`); - console.log(`- Status: ${recording.status}`); -}); -``` - -## Real-time Updates - -You can subscribe to memo updates to know when the transcription is complete: - -```typescript -const subscription = supabase - .channel(`memo-${memoId}`) - .on('postgres_changes', - { - event: 'UPDATE', - schema: 'public', - table: 'memos', - filter: `id=eq.${memoId}` - }, - (payload) => { - const updatedMemo = payload.new; - // Check if the last additional recording is now completed - const recordings = updatedMemo.source?.additional_recordings || []; - const lastRecording = recordings[recordings.length - 1]; - - if (lastRecording?.status === 'completed') { - console.log('Transcription completed!', lastRecording); - // Update UI with new transcription - } - } - ) - .subscribe(); -``` - -## Notes - -1. **Credit Requirements**: Append transcription consumes credits the same way as main transcription (2 mana per minute, minimum 10 mana) -2. **Access Control**: Users can only append to memos they own or have access to through spaces -3. **Smart Routing**: Short recordings (<115 min) use fast transcription, longer ones use batch processing -4. **Recording Index**: You can optionally specify a `recordingIndex` to update a specific recording instead of appending a new one -5. **Error Handling**: The service includes comprehensive error handling and fallback strategies matching the main transcription flow \ No newline at end of file diff --git a/apps/memoro/apps/backend/docs/auth-proxy-grace-period-notes.md b/apps/memoro/apps/backend/docs/auth-proxy-grace-period-notes.md deleted file mode 100644 index 10c43a7f2..000000000 --- a/apps/memoro/apps/backend/docs/auth-proxy-grace-period-notes.md +++ /dev/null @@ -1,62 +0,0 @@ -# Auth Proxy Grace Period Implementation Notes - -## Overview - -The auth-proxy module in memoro-service acts as a pass-through to mana-core-middleware. With the new grace period implementation, the proxy doesn't need significant changes but should be aware of the new behavior. - -## Current Implementation Status - -The auth proxy already: -- ✅ Validates device info is present for refresh requests -- ✅ Forwards all requests to mana-core-middleware -- ✅ Preserves error responses from the backend -- ✅ Logs requests for debugging - -## Grace Period Behavior - -When a refresh request is made: - -1. **Normal Case**: New tokens are returned -2. **Grace Period Case**: If the same old token is used within 5 minutes: - - Backend returns the previously generated new token - - Response includes `gracePeriodUsed: true` flag - - This is NOT an error - it's a successful response - -## No Changes Required - -The auth proxy doesn't need modifications because: -- It already forwards all responses transparently -- Error handling is done by the backend -- Retry logic should be implemented in the frontend - -## Logging Recommendations - -Consider adding logs for grace period usage: - -```typescript -async refresh(payload: any) { - const response = await this.proxyPost('/auth/refresh', payload); - - // Optional: Log grace period usage for monitoring - if (response.gracePeriodUsed) { - console.log('[AuthProxy] Refresh used grace period for device:', payload.deviceInfo?.deviceId); - } - - return response; -} -``` - -## Monitoring - -Track these metrics to understand grace period effectiveness: -- How often grace period is used -- Which devices/users trigger grace period most -- Correlation with network conditions - -## Frontend Integration - -The frontend calling memoro-service should: -1. Always save the returned refresh token -2. Implement retry logic with exponential backoff -3. Handle both success and error responses appropriately -4. Not treat grace period usage as an error \ No newline at end of file diff --git a/apps/memoro/apps/backend/docs/broadcast-trigger-payload-fix.md b/apps/memoro/apps/backend/docs/broadcast-trigger-payload-fix.md deleted file mode 100644 index 9bc41cf15..000000000 --- a/apps/memoro/apps/backend/docs/broadcast-trigger-payload-fix.md +++ /dev/null @@ -1,196 +0,0 @@ -# Broadcast Trigger Payload Size Fix - July 2025 - -## Timeline of Events - -### Background -- **Before July 5, 2025**: Transcription updates worked perfectly -- **July 5, 2025**: New broadcast triggers added to enhance real-time updates -- **July 8, 2025**: "Payload string too long" errors started occurring during transcription completion - -## The Error - -### Symptoms -``` -Error: Failed to update memo: payload string too long -PostgreSQL Error Code: 22023 -``` - -### Affected Operations -- Transcription completion updates failing for memos with: - - Text length: 46,465 characters - - Utterances: 377 items - - Request payload sizes: 55KB - 121KB - -### Error Logs -From memoro-service: -``` -[handleTranscriptionCompleted] Error updating memo: { - code: '22023', - details: null, - hint: null, - message: 'payload string too long' -} -``` - -From Supabase API Gateway: -```json -{ - "event_message": "PATCH | 400 | ... | https://npgifbrwhftlbrbaglmi.supabase.co/rest/v1/memos", - "content_length": "121057", - "status_code": 400 -} -``` - -## Initial (Wrong) Assumptions - -### Assumption 1: Supabase Realtime NOTIFY Limit -**What we thought**: The existing replica identity fix from the `realtime-payload-limit-fix.md` wasn't working properly. - -**Why this seemed logical**: -- Same error code (22023) -- Same error message ("payload string too long") -- PostgreSQL NOTIFY has an 8KB limit -- We had fixed this exact issue before - -**Why we were wrong**: The replica identity was correctly set and working. The issue was elsewhere. - -### Assumption 2: Database Column Limits -**What we thought**: Maybe the jsonb/text columns had size constraints. - -**Why this seemed possible**: -- Large payloads were being stored -- Error occurred during UPDATE operations - -**Why we were wrong**: PostgreSQL jsonb and text columns can store much larger data (up to 1GB). - -### Assumption 3: HTTP Request Size Limits -**What we thought**: The Supabase REST API might have payload limits. - -**Why we considered this**: -- Request sizes were 55KB-121KB -- Error happened during HTTP PATCH requests - -**Why we were wrong**: Supabase supports payloads up to 1GB via HTTP. - -## The Real Problem - -### Discovery Process -1. Checked replica identity: ✓ Correctly set to INDEX (only sends ID) -2. Investigated table triggers: Found new broadcast triggers added July 5 -3. Examined trigger function: Found the culprit! - -### Root Cause -The `broadcast_memo_changes()` trigger function added on July 5, 2025 was using: -```sql -PERFORM pg_notify( - 'realtime:broadcast', - json_build_object( - 'payload', json_build_object( - 'new', row_to_json(NEW), -- ENTIRE row data! - 'old', row_to_json(OLD), -- ENTIRE row data! - ... - ) - )::text -); -``` - -This trigger was attempting to send the ENTIRE memo data (including large transcripts and utterances) through PostgreSQL's NOTIFY mechanism, which has a hard 8KB limit. - -### Why It Wasn't Caught Earlier -- The trigger was added recently (July 5) -- Initial testing likely used smaller memos -- The error only occurs with transcriptions > ~6KB total size - -## The Fix - -### Solution Applied -Modified the `broadcast_memo_changes()` function to send minimal data: - -```sql -CREATE OR REPLACE FUNCTION public.broadcast_memo_changes() -RETURNS trigger -LANGUAGE plpgsql -SECURITY DEFINER -AS $$ -BEGIN - -- Broadcast only essential information to avoid payload size limits - PERFORM pg_notify( - 'realtime:broadcast', - json_build_object( - 'type', 'broadcast', - 'event', 'postgres_changes', - 'payload', json_build_object( - 'event', TG_OP, - 'schema', TG_TABLE_SCHEMA, - 'table', TG_TABLE_NAME, - 'id', CASE - WHEN TG_OP = 'DELETE' THEN OLD.id - ELSE NEW.id - END, - 'eventTs', to_char(current_timestamp, 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"') - ) - )::text - ); - - RETURN NEW; -END; -$$; -``` - -### What Changed -- **Before**: Sent entire row data (`row_to_json(NEW/OLD)`) -- **After**: Sends only the memo ID -- **Result**: Payload size reduced from 55KB+ to < 200 bytes - -### Impact on Frontend -- Frontend still receives real-time notifications -- Must fetch full memo data using the provided ID -- No breaking changes to the notification structure - -## Key Learnings - -### 1. Multiple Systems Can Hit NOTIFY Limits -- **Supabase Realtime**: Uses replica identity (already fixed) -- **Custom Triggers**: Can also use pg_notify (new issue) -- Both must respect the 8KB NOTIFY limit - -### 2. Error Messages Can Be Misleading -- Same error (22023) can have different causes -- Important to check ALL uses of NOTIFY, not just Supabase Realtime - -### 3. Trigger Side Effects -- New triggers can break existing functionality -- Always consider payload sizes when using pg_notify -- Test with realistic data sizes, not just small test cases - -### 4. Debugging Approach -1. Check recent changes (migrations, triggers) -2. Examine all NOTIFY usage, not just obvious ones -3. Use Supabase API logs to see actual request sizes -4. Don't assume the first similar fix applies - -## Prevention Guidelines - -### For Future Triggers -1. **Never send full row data through NOTIFY** -2. **Always send minimal identifiers only** -3. **Test with large, realistic payloads** -4. **Document payload size considerations** - -### For Broadcast Mechanisms -1. **Use ID-only patterns**: Send identifiers, let clients fetch data -2. **Consider payload sizes**: NOTIFY limit is 8000 bytes total -3. **Monitor for 22023 errors**: Set up alerts for this specific error -4. **Review all NOTIFY usage**: Both Supabase and custom triggers - -## Resolution Timeline -- **Issue Reported**: July 8, 2025, 14:59 CEST -- **Investigation Started**: July 8, 2025, 15:00 CEST -- **Root Cause Found**: Broadcast trigger sending full row data -- **Fix Applied**: Modified trigger to send ID only -- **Resolution Confirmed**: Transcriptions now complete successfully - -## Related Documentation -- [Realtime Payload Limit Fix](./realtime-payload-limit-fix.md) - Original NOTIFY limit issue -- [PostgreSQL NOTIFY Documentation](https://www.postgresql.org/docs/current/sql-notify.html) -- Migration: `20250705022315_add_memo_update_broadcast_trigger` \ No newline at end of file diff --git a/apps/memoro/apps/backend/docs/memo-sharing-fix.md b/apps/memoro/apps/backend/docs/memo-sharing-fix.md deleted file mode 100644 index adef4c475..000000000 --- a/apps/memoro/apps/backend/docs/memo-sharing-fix.md +++ /dev/null @@ -1,178 +0,0 @@ -# Memoro Space Sharing Fix - -This document describes the implementation of space-based memo sharing in the Memoro application, including the solution to the "infinite recursion" issue that was occurring with Row-Level Security (RLS) policies. - -## Problem Description - -Users were unable to directly access memos created by other users in shared spaces, receiving the following error: - -``` -Error fetching memo: infinite recursion detected in policy for relation "memos" -``` - -This happened because: - -1. The RLS policies required complex joins between multiple tables -2. PostgreSQL couldn't efficiently resolve these joins during policy evaluation -3. The recursive nature of the policies caused infinite recursion - -## Solution: Denormalized Access Control - -We implemented a database design pattern called "denormalization for access control" to solve this issue. - -### Step 1: Add a Direct Access Column to Memos Table - -```sql --- Add a direct helper column to the memos table to simplify RLS -ALTER TABLE memos ADD COLUMN IF NOT EXISTS shared_with_users UUID[] DEFAULT '{}'::uuid[]; -``` - -This array column directly stores the UUIDs of all users who should have access to each memo, eliminating the need for complex joins in RLS policies. - -### Step 2: Create Triggers to Maintain the Access Array - -First, create a function to update the `shared_with_users` array when a memo is linked to a space: - -```sql --- Create an update function that will maintain this column -CREATE OR REPLACE FUNCTION update_memo_shared_with_users() -RETURNS TRIGGER AS $$ -BEGIN - -- Update the shared_with_users array for the affected memo - UPDATE memos - SET shared_with_users = ( - SELECT array_agg(DISTINCT sm.user_id) - FROM memo_spaces ms - JOIN space_members sm ON ms.space_id = sm.space_id - WHERE ms.memo_id = NEW.memo_id - ) - WHERE id = NEW.memo_id; - - RETURN NEW; -END; -$$ LANGUAGE plpgsql; - --- Create triggers for memo_spaces table changes -DROP TRIGGER IF EXISTS memo_spaces_insert_update_trigger ON memo_spaces; -CREATE TRIGGER memo_spaces_insert_update_trigger -AFTER INSERT OR UPDATE ON memo_spaces -FOR EACH ROW -EXECUTE FUNCTION update_memo_shared_with_users(); - -DROP TRIGGER IF EXISTS memo_spaces_delete_trigger ON memo_spaces; -CREATE TRIGGER memo_spaces_delete_trigger -AFTER DELETE ON memo_spaces -FOR EACH ROW -EXECUTE FUNCTION update_memo_shared_with_users(); -``` - -Then, create a function and trigger to update the access arrays when space membership changes: - -```sql --- Create trigger for space_members changes -CREATE OR REPLACE FUNCTION update_all_memos_for_space() -RETURNS TRIGGER AS $$ -BEGIN - -- For each memo in the space, update its shared_with_users array - UPDATE memos m - SET shared_with_users = ( - SELECT array_agg(DISTINCT sm.user_id) - FROM memo_spaces ms - JOIN space_members sm ON ms.space_id = sm.space_id - WHERE ms.memo_id = m.id - AND ms.space_id = NEW.space_id OR ms.space_id = OLD.space_id - ) - WHERE m.id IN ( - SELECT memo_id FROM memo_spaces WHERE space_id = NEW.space_id OR space_id = OLD.space_id - ); - - RETURN NEW; -END; -$$ LANGUAGE plpgsql; - -DROP TRIGGER IF EXISTS space_members_trigger ON space_members; -CREATE TRIGGER space_members_trigger -AFTER INSERT OR UPDATE OR DELETE ON space_members -FOR EACH ROW -EXECUTE FUNCTION update_all_memos_for_space(); -``` - -### Step 3: Initialize the Column for Existing Data - -```sql --- Populate the shared_with_users column for all existing memos -UPDATE memos m -SET shared_with_users = ( - SELECT array_agg(DISTINCT sm.user_id) - FROM memo_spaces ms - JOIN space_members sm ON ms.space_id = sm.space_id - WHERE ms.memo_id = m.id -); -``` - -### Step 4: Create Simplified RLS Policies - -```sql --- Drop existing policies on memos -DO $$ -BEGIN - EXECUTE ( - SELECT string_agg('DROP POLICY IF EXISTS "' || policyname || '" ON memos;', ' ') - FROM pg_policies - WHERE tablename = 'memos' - ); -END $$; - --- Create simplified policies that use the denormalized column -CREATE POLICY "Users can access own memos" -ON memos FOR ALL -USING (user_id = auth.uid()::text); - -CREATE POLICY "Users can view shared memos" -ON memos FOR SELECT -USING (auth.uid()::uuid = ANY(shared_with_users)); -``` - -## How This Solution Works - -1. When a memo is linked to a space, the trigger automatically adds all space members to the memo's `shared_with_users` array -2. When space membership changes (users added/removed), the trigger updates all affected memos -3. The RLS policies are now simple and non-recursive: - - Users can always access their own memos - - Users can view memos where their UUID is in the `shared_with_users` array - -## Benefits - -1. **No More Recursion**: The simple policies avoid complex joins that caused the infinite recursion -2. **Better Performance**: Array lookups are much faster than multiple table joins -3. **Automatic Maintenance**: The triggers keep everything in sync without requiring code changes -4. **Same Functionality**: Users still get the same sharing behavior, just implemented more efficiently - -## Verification - -You can verify the solution is working by checking: - -```sql --- Check the data in our helper column for a specific memo -SELECT id, title, user_id, shared_with_users -FROM memos -WHERE id = 'your-memo-id'; -``` - -This should show the memo with a list of user IDs in the `shared_with_users` array, including both the memo owner and all members of spaces the memo is shared with. - -## Troubleshooting - -If you encounter issues with the sharing functionality: - -1. Check if the triggers are properly updating the `shared_with_users` array -2. Verify that the `space_members` table is correctly populated -3. Ensure the `memo_spaces` table correctly links memos to spaces - -You can manually update the `shared_with_users` array for testing: - -```sql -UPDATE memos -SET shared_with_users = array_append(shared_with_users, 'user-uuid-here') -WHERE id = 'memo-id-here'; -``` diff --git a/apps/memoro/apps/backend/docs/memo-sharing-security-review.md b/apps/memoro/apps/backend/docs/memo-sharing-security-review.md deleted file mode 100644 index a82dfb6eb..000000000 --- a/apps/memoro/apps/backend/docs/memo-sharing-security-review.md +++ /dev/null @@ -1,186 +0,0 @@ -# Memoro Space Sharing - Security Review - -This document provides a security review of the denormalized access control solution implemented to fix the infinite recursion issue in Memoro's space sharing functionality. - -## Security Assessment Summary - -**Overall Security Rating: ✅ SECURE** - -The denormalized access control approach maintains the same security model while improving performance and reliability. This approach is commonly used in high-security applications to avoid complex RLS policy joins while maintaining strict access controls. - -## Detailed Security Analysis - -### 1. Access Control Integrity - -✅ **Authorization Logic Preserved** -- The solution maintains the same access rules - users can only access memos they own or that are shared with them through spaces. -- No security bypass vectors were introduced in the implementation. - -✅ **Permission Validation** -- The solution continues to use PostgreSQL's RLS mechanism for enforcing access control policies. -- The `auth.uid()` function ensures that user identity is validated by the database system. - -### 2. Data Exposure Risks - -✅ **No Sensitive Data Leakage** -- The `shared_with_users` array only contains user IDs, not sensitive information. -- No memo content is exposed to unauthorized users. - -✅ **Data Integrity** -- Triggers ensure that the denormalized data (shared_with_users array) stays consistent with the normalized data model. -- All updates to the denormalized column are performed atomically. - -### 3. SQL Injection Protection - -✅ **Parameterized Values** -- All user inputs are properly parameterized through the `auth.uid()` function. -- No user-supplied values are concatenated directly into SQL queries. - -✅ **PL/pgSQL Security** -- The trigger functions use proper SQL constructs without any dynamic SQL. -- All database operations use static, prepared statements. - -### 4. Trigger Implementation Security - -✅ **Atomic Updates** -- Updates are performed atomically, ensuring no inconsistent states. -- PostgreSQL's transaction safety ensures rollbacks on errors. - -✅ **Privilege Control** -- The triggers operate with database-level permissions, not user-level permissions. -- This ensures consistent enforcement of access controls regardless of the user context. - -## Improvements Implemented - -### 1. Error Logging in Triggers - -We've enhanced the trigger functions with comprehensive error logging: - -```sql -CREATE OR REPLACE FUNCTION update_memo_shared_with_users() -RETURNS TRIGGER AS $$ -DECLARE - affected_rows integer; - error_message text; -BEGIN - -- Handle NULL memo_id - IF NEW.memo_id IS NULL THEN - RAISE LOG 'update_memo_shared_with_users: memo_id is NULL, skipping update'; - RETURN NEW; - END IF; - - BEGIN - -- Update the shared_with_users array for the affected memo - UPDATE memos - SET shared_with_users = ( - SELECT COALESCE(array_agg(DISTINCT sm.user_id), '{}'::uuid[]) - FROM memo_spaces ms - JOIN space_members sm ON ms.space_id = sm.space_id - WHERE ms.memo_id = NEW.memo_id - ) - WHERE id = NEW.memo_id; - - GET DIAGNOSTICS affected_rows = ROW_COUNT; - RAISE LOG 'update_memo_shared_with_users: Updated memo %, affected % rows', NEW.memo_id, affected_rows; - - EXCEPTION WHEN OTHERS THEN - GET STACKED DIAGNOSTICS error_message = MESSAGE_TEXT; - RAISE LOG 'update_memo_shared_with_users error: %', error_message; - -- Don't re-raise the exception to avoid breaking functionality - END; - - RETURN NEW; -END; -$$ LANGUAGE plpgsql; -``` - -### 2. NULL Handling in Triggers - -We've added explicit NULL handling to prevent errors when processing NULL values: - -```sql -CREATE OR REPLACE FUNCTION update_all_memos_for_space() -RETURNS TRIGGER AS $$ -DECLARE - affected_rows integer; - error_message text; - space_id_value uuid; -BEGIN - -- Handle NULL space_id in both NEW and OLD - IF (TG_OP = 'DELETE' AND OLD.space_id IS NULL) OR - (TG_OP IN ('INSERT', 'UPDATE') AND NEW.space_id IS NULL) THEN - RAISE LOG 'update_all_memos_for_space: space_id is NULL, skipping update'; - RETURN COALESCE(NEW, OLD); - END IF; - - -- Determine which space_id to use - IF TG_OP = 'DELETE' THEN - space_id_value := OLD.space_id; - ELSE - space_id_value := NEW.space_id; - END IF; - - RAISE LOG 'update_all_memos_for_space: Processing space_id %', space_id_value; - - BEGIN - -- For each memo in the space, update its shared_with_users array - UPDATE memos m - SET shared_with_users = ( - SELECT COALESCE(array_agg(DISTINCT sm.user_id), '{}'::uuid[]) - FROM memo_spaces ms - JOIN space_members sm ON ms.space_id = sm.space_id - WHERE ms.memo_id = m.id - AND ms.space_id = space_id_value - ) - WHERE m.id IN ( - SELECT memo_id FROM memo_spaces WHERE space_id = space_id_value - ); - - GET DIAGNOSTICS affected_rows = ROW_COUNT; - RAISE LOG 'update_all_memos_for_space: Updated memos for space %, affected % rows', - space_id_value, affected_rows; - - EXCEPTION WHEN OTHERS THEN - GET STACKED DIAGNOSTICS error_message = MESSAGE_TEXT; - RAISE LOG 'update_all_memos_for_space error: %', error_message; - -- Don't re-raise the exception to avoid breaking functionality - END; - - RETURN COALESCE(NEW, OLD); -END; -$$ LANGUAGE plpgsql; -``` - -## Additional Security Considerations - -### 1. Public Memo Access - -For full feature parity, consider adding a policy for public memos: - -```sql -CREATE POLICY "Users can view public memos" -ON memos FOR SELECT -USING (is_public = true); -``` - -### 2. Admin Access Policy - -If needed, consider adding an administrative access policy: - -```sql -CREATE POLICY "Admins can access all memos" -ON memos FOR ALL -USING (auth.uid() IN (SELECT id FROM admin_users)); -``` - -### 3. Monitoring Considerations - -- **Log Review**: Regularly review PostgreSQL logs for trigger errors using the new logging functionality -- **Performance Monitoring**: Monitor the performance of the array-based policy evaluation -- **Access Auditing**: Consider implementing an audit log for sensitive memo access - -## Conclusion - -The denormalized access control solution is secure and follows database security best practices. The improvements made to error logging and NULL handling further enhance the robustness of the implementation. - -This approach not only resolves the infinite recursion issue but does so in a way that maintains the security integrity of the system while improving its performance and reliability. diff --git a/apps/memoro/apps/backend/docs/realtime-payload-limit-fix.md b/apps/memoro/apps/backend/docs/realtime-payload-limit-fix.md deleted file mode 100644 index 8b03c0ba4..000000000 --- a/apps/memoro/apps/backend/docs/realtime-payload-limit-fix.md +++ /dev/null @@ -1,115 +0,0 @@ -# Fixing "Payload String Too Long" Error in Supabase Realtime - -## The Problem - -During transcription completion, the memoro service was failing with the following error: - -``` -Error: Failed to update memo: payload string too long -PostgreSQL Error Code: 22023 -``` - -This error occurred when updating memos with transcription results, even for relatively small transcriptions (4-30 minutes of audio). - -## Initial Assumptions (Incorrect) - -### Assumption 1: HTTP Request Payload Limit -**What we thought:** The error was caused by Supabase's HTTP API having a small payload size limit for PATCH requests. - -**Evidence that seemed to support this:** -- Error occurred during database UPDATE operations -- Supabase logs showed PATCH requests with `content_length` of 9.7KB and 28KB -- The error message "payload string too long" seemed to indicate a size limit - -**Why this was wrong:** Supabase's HTTP API actually supports payloads up to 1GB, far exceeding our transcription data size. - -### Assumption 2: Database Column Size Limit -**What we thought:** The PostgreSQL database had column size limits that were being exceeded. - -**Evidence that seemed to support this:** -- Database columns were `text` and `jsonb` types -- Large speaker diarization data (utterances, speakers) was being stored - -**Why this was wrong:** PostgreSQL `text` and `jsonb` columns can store much larger data than we were sending. - -## The Real Issue: PostgreSQL NOTIFY Payload Limit - -### Root Cause -The error was actually caused by **Supabase Realtime's internal use of PostgreSQL's NOTIFY/LISTEN mechanism**, which has a hard limit of **8000 bytes** for payload size. - -### How It Works -1. **Supabase Realtime** uses PostgreSQL's NOTIFY/LISTEN for real-time updates -2. When a row is updated, the **entire row data** is sent through NOTIFY -3. Our transcription data (source with utterances + transcript + metadata) exceeded 8000 bytes -4. PostgreSQL threw error code **22023: "payload string too long"** - -### Key Evidence -- Error code `22023` is specifically related to NOTIFY payload limits -- The error occurred even with small payloads (9.7KB) because NOTIFY limit is only 8KB -- Updates worked fine when not subscribed to realtime - -## The Solution - -### What We Did -Changed the table's **replica identity** to only include the primary key: - -```sql -ALTER TABLE public.memos REPLICA IDENTITY USING INDEX memos_pkey; -``` - -### How This Fixes It -1. **Before:** Realtime notifications included all column data from the updated row -2. **After:** Realtime notifications only include the primary key (`id`) -3. **Result:** NOTIFY payload stays well under the 8000-byte limit - -### Impact on Frontend -- **Realtime notifications now only contain the memo `id`** -- **Frontend must fetch full memo data separately** when receiving notifications -- **More efficient:** Avoids sending large payloads unnecessarily -- **No breaking changes:** Frontend can handle this gracefully - -## Alternative Solutions Considered - -### Option 1: Split Updates -**Approach:** Break large updates into multiple smaller PATCH requests -**Why rejected:** Wouldn't solve the NOTIFY payload issue - -### Option 2: Disable Realtime -**Approach:** Remove memos table from `supabase_realtime` publication -**Why rejected:** Frontend needs realtime updates for user experience - -### Option 3: Column-Specific Publication -**Approach:** Only publish specific columns to realtime -**Why rejected:** Complex to maintain and still risky with metadata growth - -## Prevention for Future - -### Database Design -- **Consider realtime payload size** when designing tables with large columns -- **Separate large data** into different tables if realtime is needed -- **Use replica identity wisely** to control what data is sent via NOTIFY - -### Development Process -- **Test with realistic data sizes** including speaker diarization data -- **Monitor Supabase logs** for realtime-related errors -- **Understand the difference** between HTTP payload limits and NOTIFY limits - -## Key Learnings - -1. **Supabase Realtime uses PostgreSQL NOTIFY** with an 8000-byte limit -2. **Error code 22023** specifically indicates NOTIFY payload issues -3. **Replica identity controls** what data is sent in realtime notifications -4. **HTTP API limits and NOTIFY limits are completely different** systems -5. **Real-time efficiency** often benefits from sending only IDs, not full data - -## Documentation References - -- [PostgreSQL NOTIFY Documentation](https://www.postgresql.org/docs/current/sql-notify.html) -- [Supabase Realtime Quotas](https://supabase.com/docs/guides/realtime/quotas) -- [PostgreSQL Replica Identity](https://www.postgresql.org/docs/current/sql-altertable.html#SQL-ALTERTABLE-REPLICA-IDENTITY) - -## Resolution Status - -✅ **Fixed**: Transcription completion now works without payload errors -✅ **Tested**: Updates to large transcript and source data work correctly -✅ **Verified**: Realtime notifications still function (with ID-only payloads) \ No newline at end of file diff --git a/apps/memoro/apps/backend/docs/settings-guide.md b/apps/memoro/apps/backend/docs/settings-guide.md deleted file mode 100644 index f47e8291c..000000000 --- a/apps/memoro/apps/backend/docs/settings-guide.md +++ /dev/null @@ -1,582 +0,0 @@ -# Memoro Settings Management Guide - -The Memoro service provides comprehensive user settings management through integration with the Mana Core Middleware. This allows users to manage both Memoro-specific settings and general profile information. - -## Overview - -The settings system provides: -- **Memoro-specific settings** (data usage acceptance, preferences) -- **General profile management** (name, avatar) -- **Centralized storage** via Mana Core's `app_settings` JSONB field -- **JWT-authenticated access** with user isolation - -## Architecture - -``` -Frontend → Memoro Service → Mana Core Middleware → Supabase Database -``` - -1. **Frontend** calls Memoro service settings endpoints -2. **Memoro Service** forwards requests to Mana Core Middleware -3. **Mana Core** updates the `users.app_settings` JSONB field -4. **Response** flows back through the chain - -## API Endpoints - -All endpoints require JWT authentication via `Authorization: Bearer ` header. - -### 1. Get All User Settings - -```http -GET /settings -Authorization: Bearer -``` - -**Response:** -```json -{ - "settings": { - "memoro": { - "dataUsageAcceptance": true - }, - "other_apps": { - "theme": "dark" - } - } -} -``` - -### 2. Get Memoro-Specific Settings - -```http -GET /settings/memoro -Authorization: Bearer -``` - -**Response:** -```json -{ - "settings": { - "dataUsageAcceptance": true, - "emailNewsletterOptIn": false, - "language": "en", - "defaultSpaceId": "uuid-here" - } -} -``` - -### 3. Update Memoro Settings - -```http -PATCH /settings/memoro -Authorization: Bearer -Content-Type: application/json - -{ - "dataUsageAcceptance": true, - "language": "en", - "customSetting": "value" -} -``` - -**Response:** -```json -{ - "success": true, - "settings": { - "memoro": { - "dataUsageAcceptance": true, - "language": "en", - "customSetting": "value" - } - }, - "message": "Memoro settings updated successfully" -} -``` - -### 4. Update Data Usage Acceptance (Convenience Endpoint) - -```http -PATCH /settings/memoro/data-usage -Authorization: Bearer -Content-Type: application/json - -{ - "accepted": true -} -``` - -**Response:** -```json -{ - "success": true, - "settings": { - "memoro": { - "dataUsageAcceptance": true - } - }, - "message": "Data usage accepted successfully" -} -``` - -### 5. Update Email Newsletter Opt-In (Convenience Endpoint) - -```http -PATCH /settings/memoro/email-newsletter -Authorization: Bearer -Content-Type: application/json - -{ - "optIn": true -} -``` - -**Response:** -```json -{ - "success": true, - "settings": { - "memoro": { - "emailNewsletterOptIn": true - } - }, - "message": "Email newsletter opted in successfully" -} -``` - -### 6. Update User Profile - -```http -PATCH /settings/profile -Authorization: Bearer -Content-Type: application/json - -{ - "firstName": "John", - "lastName": "Doe", - "avatarUrl": "https://example.com/avatar.jpg" -} -``` - -**Response:** -```json -{ - "success": true, - "user": { - "id": "uuid", - "email": "user@example.com", - "first_name": "John", - "last_name": "Doe", - "avatar_url": "https://example.com/avatar.jpg", - "app_settings": { - "memoro": { - "dataUsageAcceptance": true - } - } - }, - "message": "Profile updated successfully" -} -``` - -## Testing Guide - -### Local Development Setup - -1. **Start Services:** -```bash -# Terminal 1 - Mana Core Middleware -cd mana-core-middleware -npm run start:dev # Port 3000 - -# Terminal 2 - Memoro Service -cd memoro-service -npm run start:dev # Port 3001 -``` - -2. **Get JWT Token:** -```bash -export TOKEN=$(curl -s -X POST "http://localhost:3000/auth/signin?appId=973da0c1-b479-4dac-a1b0-ed09c72caca8" \ - -H "Content-Type: application/json" \ - -d '{"email": "nils.weiser@memoro.ai", "password": "Test123!"}' | jq -r '.accessToken') - -echo "Token: $TOKEN" -``` - -### Test Commands - -```bash -# Get all settings -curl -H "Authorization: Bearer $TOKEN" \ - "http://localhost:3001/settings" - -# Get Memoro settings only -curl -H "Authorization: Bearer $TOKEN" \ - "http://localhost:3001/settings/memoro" - -# Accept data usage -curl -X PATCH \ - -H "Authorization: Bearer $TOKEN" \ - -H "Content-Type: application/json" \ - -d '{"accepted": true}' \ - "http://localhost:3001/settings/memoro/data-usage" - -# Opt into email newsletter -curl -X PATCH \ - -H "Authorization: Bearer $TOKEN" \ - -H "Content-Type: application/json" \ - -d '{"optIn": true}' \ - "http://localhost:3001/settings/memoro/email-newsletter" - -# Update multiple Memoro settings -curl -X PATCH \ - -H "Authorization: Bearer $TOKEN" \ - -H "Content-Type: application/json" \ - -d '{"dataUsageAcceptance": false, "emailNewsletterOptIn": true, "language": "de"}' \ - "http://localhost:3001/settings/memoro" - -# Update profile -curl -X PATCH \ - -H "Authorization: Bearer $TOKEN" \ - -H "Content-Type: application/json" \ - -d '{"firstName": "Nils", "lastName": "Weiser"}' \ - "http://localhost:3001/settings/profile" -``` - -### Expected Results - -**Empty settings (first time):** -```json -{ - "settings": {} -} -``` - -**After data usage acceptance:** -```json -{ - "settings": { - "memoro": { - "dataUsageAcceptance": true - } - } -} -``` - -**After multiple updates:** -```json -{ - "settings": { - "memoro": { - "dataUsageAcceptance": false, - "emailNewsletterOptIn": true, - "language": "de" - } - } -} -``` - -## Memoro Settings Schema - -### Core Settings - -| Setting | Type | Default | Description | -|---------|------|---------|-------------| -| `dataUsageAcceptance` | boolean | `false` | Whether user accepts data usage for AI processing | -| `emailNewsletterOptIn` | boolean | `false` | Whether user opts into email newsletter | -| `language` | string | `"en"` | User's preferred language | -| `defaultSpaceId` | string | `null` | Default space for new recordings | - -### Future Settings (Examples) - -| Setting | Type | Default | Description | -|---------|------|---------|-------------| -| `autoTranscribe` | boolean | `true` | Auto-start transcription on upload | -| `notificationPreferences` | object | `{}` | Email/push notification settings | -| `transcriptionSettings` | object | `{}` | Transcription quality, language detection | -| `uiPreferences` | object | `{}` | Theme, layout preferences | - -## Error Handling - -### Common Errors - -**400 Bad Request - Missing fields:** -```json -{ - "message": "At least one setting field is required", - "error": "Bad Request", - "statusCode": 400 -} -``` - -**400 Bad Request - Invalid data type:** -```json -{ - "message": "accepted field must be a boolean", - "error": "Bad Request", - "statusCode": 400 -} -``` - -**401 Unauthorized:** -```json -{ - "message": "Unauthorized", - "statusCode": 401 -} -``` - -### Service Communication Errors - -If Mana Core Middleware is down: -```json -{ - "message": "Failed to update Memoro settings: Failed to connect to Mana Core", - "error": "Bad Request", - "statusCode": 400 -} -``` - -## Frontend Integration Examples - -### React Hook Example - -```typescript -// useSettings.ts -import { useState, useEffect } from 'react'; - -interface MemoroSettings { - dataUsageAcceptance?: boolean; - emailNewsletterOptIn?: boolean; - language?: string; - defaultSpaceId?: string; -} - -export function useSettings() { - const [settings, setSettings] = useState({}); - const [loading, setLoading] = useState(false); - - const getSettings = async () => { - setLoading(true); - try { - const response = await fetch('/settings/memoro', { - headers: { Authorization: `Bearer ${getToken()}` } - }); - const data = await response.json(); - setSettings(data.settings); - } catch (error) { - console.error('Failed to get settings:', error); - } finally { - setLoading(false); - } - }; - - const updateDataUsage = async (accepted: boolean) => { - try { - const response = await fetch('/settings/memoro/data-usage', { - method: 'PATCH', - headers: { - Authorization: `Bearer ${getToken()}`, - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ accepted }) - }); - - if (response.ok) { - await getSettings(); // Refresh settings - } - } catch (error) { - console.error('Failed to update data usage:', error); - } - }; - - const updateEmailNewsletter = async (optIn: boolean) => { - try { - const response = await fetch('/settings/memoro/email-newsletter', { - method: 'PATCH', - headers: { - Authorization: `Bearer ${getToken()}`, - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ optIn }) - }); - - if (response.ok) { - await getSettings(); // Refresh settings - } - } catch (error) { - console.error('Failed to update email newsletter:', error); - } - }; - - return { - settings, - loading, - getSettings, - updateDataUsage, - updateEmailNewsletter - }; -} -``` - -### Data Usage Consent Component - -```typescript -// DataUsageConsent.tsx -import React from 'react'; -import { useSettings } from './useSettings'; - -export function DataUsageConsent() { - const { settings, updateDataUsage, loading } = useSettings(); - - const handleAccept = () => updateDataUsage(true); - const handleDecline = () => updateDataUsage(false); - - if (settings.dataUsageAcceptance === true) { - return
✅ Data usage accepted
; - } - - return ( -
-

Data Usage Consent

-

Do you consent to AI processing of your audio data?

- -
- - -
-
- ); -} -``` - -### Email Newsletter Subscription Component - -```typescript -// EmailNewsletterSubscription.tsx -import React from 'react'; -import { useSettings } from './useSettings'; - -export function EmailNewsletterSubscription() { - const { settings, updateEmailNewsletter, loading } = useSettings(); - - const handleOptIn = () => updateEmailNewsletter(true); - const handleOptOut = () => updateEmailNewsletter(false); - - return ( -
-

Email Newsletter

-

Stay updated with Memoro features and news

- -
- {settings.emailNewsletterOptIn ? ( -
- ✅ Subscribed to newsletter - -
- ) : ( -
- 📧 Not subscribed - -
- )} -
-
- ); -} -``` - -### Combined Settings Component - -```typescript -// SettingsPage.tsx -import React from 'react'; -import { DataUsageConsent } from './DataUsageConsent'; -import { EmailNewsletterSubscription } from './EmailNewsletterSubscription'; - -export function SettingsPage() { - return ( -
-

Memoro Settings

- -
-

Privacy & Data

- -
- -
-

Communication

- -
-
- ); -} -``` - -## Configuration - -### Environment Variables - -Ensure `MANA_SERVICE_URL` is properly configured: - -```env -# memoro-service/.env -MANA_SERVICE_URL=http://localhost:3000 # Local development -# or -MANA_SERVICE_URL=https://mana-core-middleware.run.app # Production -``` - -### Service Dependencies - -The settings endpoints depend on: -1. **Mana Core Middleware** being accessible -2. **Supabase database** connection -3. **JWT authentication** working properly - -## Monitoring - -### Health Checks - -Monitor settings service health: -```bash -# Check if Memoro service can reach Mana Core -curl -H "Authorization: Bearer $TOKEN" \ - "http://localhost:3001/settings/memoro" -``` - -### Logging - -Look for these log patterns: -``` -[SettingsClientService] Error getting user settings: Failed to connect -[SettingsController] Failed to update Memoro settings: User not found -``` - -## Future Enhancements - -1. **Settings Validation**: JSON schema validation for settings -2. **Settings Migration**: Automatic migration for schema changes -3. **Settings Sync**: Real-time sync across devices -4. **Settings Backup**: Export/import functionality -5. **Settings Analytics**: Track which settings are most used \ No newline at end of file diff --git a/apps/memoro/apps/backend/docs/simplified-space-sync-service.md b/apps/memoro/apps/backend/docs/simplified-space-sync-service.md deleted file mode 100644 index fa98bcafd..000000000 --- a/apps/memoro/apps/backend/docs/simplified-space-sync-service.md +++ /dev/null @@ -1,483 +0,0 @@ -# Simplified SpaceSyncService - -This document outlines a simplified version of the `SpaceSyncService` that leverages the new database-level triggers and denormalized access control approach. - -## Simplified Implementation - -```typescript -import { Injectable, Logger } from '@nestjs/common'; -import { HttpService } from '@nestjs/axios'; -import { ConfigService } from '@nestjs/config'; -import { firstValueFrom } from 'rxjs'; -import { createClient, SupabaseClient } from '@supabase/supabase-js'; -import { v4 as uuidv4 } from 'uuid'; - -@Injectable() -export class SpaceSyncService { - private readonly logger = new Logger(SpaceSyncService.name); - private supabase: SupabaseClient; - private manaApiUrl: string; - private adminToken: string; - - constructor( - private readonly configService: ConfigService, - private readonly httpService: HttpService, - ) { - // Initialize Supabase client - this.supabase = createClient( - this.configService.get('MEMORO_SUPABASE_URL'), - this.configService.get('MEMORO_SUPABASE_SERVICE_KEY'), - ); - this.manaApiUrl = this.configService.get('MANA_CORE_URL'); - this.adminToken = this.configService.get('ADMIN_TOKEN'); - } - - /** - * Create or update a space member record - * This is called when a user is added to a space or their role changes - */ - async syncSpaceMembership( - spaceId: string, - userId: string, - role: string, - addedBy?: string, - ): Promise<{ success: boolean; message: string }> { - try { - // Generate a UUID for the record if it doesn't exist - const id = uuidv4(); - - // Check if the membership already exists - const { data: existingMember } = await this.supabase - .from('space_members') - .select('*') - .eq('space_id', spaceId) - .eq('user_id', userId) - .single(); - - if (existingMember) { - // Update existing membership - const { error } = await this.supabase - .from('space_members') - .update({ - role, - added_by: addedBy || existingMember.added_by, - }) - .eq('space_id', spaceId) - .eq('user_id', userId); - - if (error) throw error; - this.logger.log(`Updated space membership for user ${userId} in space ${spaceId}`); - } else { - // Create new membership - const { error } = await this.supabase - .from('space_members') - .insert({ - id, - space_id: spaceId, - user_id: userId, - role, - added_by: addedBy || userId, - added_at: new Date(), - }); - - if (error) throw error; - this.logger.log(`Added user ${userId} to space ${spaceId}`); - } - - return { success: true, message: 'Space membership synced successfully' }; - } catch (error) { - this.logger.error(`Error syncing space membership: ${error.message}`, error.stack); - return { success: false, message: error.message }; - } - } - - /** - * Remove a user from a space - */ - async removeSpaceMembership( - spaceId: string, - userId: string, - ): Promise<{ success: boolean; message: string }> { - try { - const { error } = await this.supabase - .from('space_members') - .delete() - .eq('space_id', spaceId) - .eq('user_id', userId); - - if (error) throw error; - this.logger.log(`Removed user ${userId} from space ${spaceId}`); - - return { success: true, message: 'Space membership removed successfully' }; - } catch (error) { - this.logger.error(`Error removing space membership: ${error.message}`, error.stack); - return { success: false, message: error.message }; - } - } - - /** - * Sync all members for a specific space - * Used when initializing a space or ensuring all memberships are in sync - */ - async syncSpaceMembers( - spaceId: string, - ): Promise<{ success: boolean; message: string; count?: number }> { - try { - // Fetch space members from middleware - const response = await firstValueFrom( - this.httpService.get(`${this.manaApiUrl}/api/spaces/${spaceId}/members`, { - headers: { Authorization: `Bearer ${this.adminToken}` }, - }), - ); - - const members = response.data.members || []; - - if (members.length === 0) { - return { success: true, message: 'No members found for space', count: 0 }; - } - - // First, delete all existing members for this space to avoid stale records - await this.supabase - .from('space_members') - .delete() - .eq('space_id', spaceId); - - // Then insert all current members - const membersToInsert = members.map((member) => ({ - id: uuidv4(), - space_id: spaceId, - user_id: member.user_id, - role: member.role, - added_by: member.added_by || member.user_id, - added_at: new Date(), - })); - - const { error } = await this.supabase - .from('space_members') - .insert(membersToInsert); - - if (error) throw error; - - this.logger.log(`Synced ${members.length} members for space ${spaceId}`); - - return { - success: true, - message: `Synced ${members.length} members for space ${spaceId}`, - count: members.length - }; - } catch (error) { - this.logger.error(`Error syncing space members: ${error.message}`, error.stack); - return { success: false, message: error.message }; - } - } - - /** - * Sync all spaces for a user - * Used to ensure a user has access to all their spaces - */ - async syncUserSpaces( - userId: string, - ): Promise<{ success: boolean; message: string; count?: number }> { - try { - // Fetch user's spaces from middleware - const response = await firstValueFrom( - this.httpService.get(`${this.manaApiUrl}/api/users/${userId}/spaces`, { - headers: { Authorization: `Bearer ${this.adminToken}` }, - }), - ); - - const spaces = response.data.spaces || []; - - if (spaces.length === 0) { - return { success: true, message: 'No spaces found for user', count: 0 }; - } - - // Process each space the user is a member of - let successCount = 0; - for (const space of spaces) { - const result = await this.syncSpaceMembers(space.id); - if (result.success) { - successCount++; - } - } - - this.logger.log(`Synced ${successCount} spaces for user ${userId}`); - - return { - success: true, - message: `Synced ${successCount} spaces for user ${userId}`, - count: successCount - }; - } catch (error) { - this.logger.error(`Error syncing user spaces: ${error.message}`, error.stack); - return { success: false, message: error.message }; - } - } - - /** - * Run the migration to set up the space_members table and triggers - * Only needs to be run once when setting up a new environment - */ - async runSpaceMembersMigration(): Promise<{ success: boolean; message: string }> { - try { - const { data: tableExists } = await this.supabase.rpc('check_table_exists', { - table_name: 'space_members', - }); - - if (tableExists) { - return { success: true, message: 'Space members table already exists' }; - } - - // Create space_members table - const createTableSQL = ` - -- Create space_members table - CREATE TABLE IF NOT EXISTS public.space_members ( - id UUID PRIMARY KEY, - space_id UUID NOT NULL REFERENCES public.spaces(id) ON DELETE CASCADE, - user_id UUID NOT NULL, - role TEXT NOT NULL, - added_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - added_by UUID, - UNIQUE(space_id, user_id) - ); - - -- Add shared_with_users column to memos table - ALTER TABLE public.memos ADD COLUMN IF NOT EXISTS shared_with_users UUID[] DEFAULT '{}'::uuid[]; - - -- Create function for updating shared_with_users - CREATE OR REPLACE FUNCTION update_memo_shared_with_users() - RETURNS TRIGGER AS $$ - DECLARE - affected_rows integer; - error_message text; - BEGIN - -- Handle NULL memo_id - IF NEW.memo_id IS NULL THEN - RAISE LOG 'update_memo_shared_with_users: memo_id is NULL, skipping update'; - RETURN NEW; - END IF; - - BEGIN - -- Update the shared_with_users array for the affected memo - UPDATE memos - SET shared_with_users = ( - SELECT COALESCE(array_agg(DISTINCT sm.user_id), '{}'::uuid[]) - FROM memo_spaces ms - JOIN space_members sm ON ms.space_id = sm.space_id - WHERE ms.memo_id = NEW.memo_id - ) - WHERE id = NEW.memo_id; - - GET DIAGNOSTICS affected_rows = ROW_COUNT; - RAISE LOG 'update_memo_shared_with_users: Updated memo %, affected % rows', NEW.memo_id, affected_rows; - - EXCEPTION WHEN OTHERS THEN - GET STACKED DIAGNOSTICS error_message = MESSAGE_TEXT; - RAISE LOG 'update_memo_shared_with_users error: %', error_message; - -- Don't re-raise the exception to avoid breaking functionality - END; - - RETURN NEW; - END; - $$ LANGUAGE plpgsql; - - -- Create function for updating all memos in a space - CREATE OR REPLACE FUNCTION update_all_memos_for_space() - RETURNS TRIGGER AS $$ - DECLARE - affected_rows integer; - error_message text; - space_id_value uuid; - BEGIN - -- Handle NULL space_id in both NEW and OLD - IF (TG_OP = 'DELETE' AND OLD.space_id IS NULL) OR - (TG_OP IN ('INSERT', 'UPDATE') AND NEW.space_id IS NULL) THEN - RAISE LOG 'update_all_memos_for_space: space_id is NULL, skipping update'; - RETURN COALESCE(NEW, OLD); - END IF; - - -- Determine which space_id to use - IF TG_OP = 'DELETE' THEN - space_id_value := OLD.space_id; - ELSE - space_id_value := NEW.space_id; - END IF; - - RAISE LOG 'update_all_memos_for_space: Processing space_id %', space_id_value; - - BEGIN - -- For each memo in the space, update its shared_with_users array - UPDATE memos m - SET shared_with_users = ( - SELECT COALESCE(array_agg(DISTINCT sm.user_id), '{}'::uuid[]) - FROM memo_spaces ms - JOIN space_members sm ON ms.space_id = sm.space_id - WHERE ms.memo_id = m.id - AND ms.space_id = space_id_value - ) - WHERE m.id IN ( - SELECT memo_id FROM memo_spaces WHERE space_id = space_id_value - ); - - GET DIAGNOSTICS affected_rows = ROW_COUNT; - RAISE LOG 'update_all_memos_for_space: Updated memos for space %, affected % rows', - space_id_value, affected_rows; - - EXCEPTION WHEN OTHERS THEN - GET STACKED DIAGNOSTICS error_message = MESSAGE_TEXT; - RAISE LOG 'update_all_memos_for_space error: %', error_message; - -- Don't re-raise the exception to avoid breaking functionality - END; - - RETURN COALESCE(NEW, OLD); - END; - $$ LANGUAGE plpgsql; - - -- Create triggers - DROP TRIGGER IF EXISTS memo_spaces_insert_update_trigger ON memo_spaces; - CREATE TRIGGER memo_spaces_insert_update_trigger - AFTER INSERT OR UPDATE ON memo_spaces - FOR EACH ROW - EXECUTE FUNCTION update_memo_shared_with_users(); - - DROP TRIGGER IF EXISTS memo_spaces_delete_trigger ON memo_spaces; - CREATE TRIGGER memo_spaces_delete_trigger - AFTER DELETE ON memo_spaces - FOR EACH ROW - EXECUTE FUNCTION update_memo_shared_with_users(); - - DROP TRIGGER IF EXISTS space_members_trigger ON space_members; - CREATE TRIGGER space_members_trigger - AFTER INSERT OR UPDATE OR DELETE ON space_members - FOR EACH ROW - EXECUTE FUNCTION update_all_memos_for_space(); - - -- Create simplified RLS policies - ALTER TABLE public.memos ENABLE ROW LEVEL SECURITY; - - DO $$ - BEGIN - EXECUTE ( - SELECT string_agg('DROP POLICY IF EXISTS "' || policyname || '" ON memos;', ' ') - FROM pg_policies - WHERE tablename = 'memos' - ); - END $$; - - -- Create simplified policies that use the denormalized column - CREATE POLICY "Users can access own memos" - ON memos FOR ALL - USING (user_id = auth.uid()::text); - - CREATE POLICY "Users can view shared memos" - ON memos FOR SELECT - USING (auth.uid()::uuid = ANY(shared_with_users)); - - -- Add policy for public memos if needed - CREATE POLICY "Users can view public memos" - ON memos FOR SELECT - USING (is_public = true); - `; - - // Run the migration SQL - const { error } = await this.supabase.rpc('run_sql', { sql: createTableSQL }); - - if (error) throw error; - - // Initialize shared_with_users arrays for existing memos - await this.supabase.rpc('run_sql', { - sql: ` - -- Populate the shared_with_users column for all existing memos - UPDATE memos m - SET shared_with_users = ( - SELECT COALESCE(array_agg(DISTINCT sm.user_id), '{}'::uuid[]) - FROM memo_spaces ms - JOIN space_members sm ON ms.space_id = sm.space_id - WHERE ms.memo_id = m.id - ); - ` - }); - - this.logger.log('Space members migration completed successfully'); - - return { success: true, message: 'Space members migration completed successfully' }; - } catch (error) { - this.logger.error(`Error running space members migration: ${error.message}`, error.stack); - return { success: false, message: error.message }; - } - } -} -``` - -## Key Differences from Original Implementation - -1. **Simplified Methods**: - - Removed any complex recursive RLS policy management - - Focuses only on CRUD operations for the `space_members` table - - Leverages database triggers for maintaining the denormalized data - -2. **Reduced Complexity**: - - The service now has a clear, focused purpose: manage space membership data - - All complex access control logic is now handled at the database level - - The migration script includes the triggers and denormalized approach - -3. **Improved Error Handling**: - - More robust error handling and logging throughout - - Better handling of edge cases like missing data - - Includes NULL checks and logging in database triggers - -## Controller Methods - -The corresponding controller methods would be simplified as well: - -```typescript -@Controller('memoro') -export class SpaceSyncController { - constructor(private readonly spaceSyncService: SpaceSyncService) {} - - @Post('spaces/:spaceId/sync-members') - async syncSpaceMembers(@Param('spaceId') spaceId: string) { - return this.spaceSyncService.syncSpaceMembers(spaceId); - } - - @Post('users/:userId/sync-spaces') - async syncUserSpaces(@Param('userId') userId: string) { - return this.spaceSyncService.syncUserSpaces(userId); - } - - @Post('run-space-members-migration') - async runSpaceMembersMigration() { - return this.spaceSyncService.runSpaceMembersMigration(); - } -} -``` - -## Integration with MemoroService - -The MemoroService would need only minimal integration with the SpaceSyncService: - -```typescript -// In MemoroService.ts -async createMemoroSpace(userId: string, spaceName: string, token: string) { - const space = await this.spacesService.createSpace(userId, spaceName, token); - // Only need to maintain the space_members table - await this.spaceSyncService.syncSpaceMembership(space.id, userId, 'owner'); - return space; -} - -async inviteUserToSpace(userId: string, spaceId: string, email: string, role: string, token: string) { - const result = await this.spacesService.addSpaceMember(spaceId, email, role, token); - if (result.invitee_id) { - // Only need to maintain the space_members table when a user is invited - await this.spaceSyncService.syncSpaceMembership(spaceId, result.invitee_id, role, userId); - } - return result; -} - -async removeUserFromSpace(userId: string, spaceId: string, memberId: string, token: string) { - const result = await this.spacesService.removeSpaceMember(spaceId, memberId, token); - // Remove from space_members table - await this.spaceSyncService.removeSpaceMembership(spaceId, memberId); - return result; -} -``` diff --git a/apps/memoro/apps/backend/env.example b/apps/memoro/apps/backend/env.example deleted file mode 100644 index 8145adbae..000000000 --- a/apps/memoro/apps/backend/env.example +++ /dev/null @@ -1,25 +0,0 @@ -# Server Configuration -PORT=3001 -NODE_ENV=development - -# Service URLs -MANA_SERVICE_URL=https://mana-core-middleware-111768794939.europe-west3.run.app -AUDIO_MICROSERVICE_URL=https://audio-microservice-111768794939.europe-west3.run.app - -# App Configuration -MEMORO_APP_ID=973da0c1-b479-4dac-a1b0-ed09c72caca8 - -# JWT Configuration for Service Role Authentication -MANA_JWT_SECRET=your_mana_jwt_secret - -# Mana Core Service Key (for service-to-service credit operations) -MANA_SUPABASE_SECRET_KEY=your_mana_service_role_key - -# Memoro Supabase Configuration -MEMORO_SUPABASE_URL=https://your-memoro-project.supabase.co -MEMORO_SUPABASE_ANON_KEY=your-memoro-anon-key -MEMORO_SUPABASE_SERVICE_KEY=your-memoro-service-key - -# Test Configuration -TEST_EMAIL=your_test_email@example.com -TEST_PASSWORD=your_test_password \ No newline at end of file diff --git a/apps/memoro/apps/backend/jest.config.js b/apps/memoro/apps/backend/jest.config.js deleted file mode 100644 index 210cc2e5b..000000000 --- a/apps/memoro/apps/backend/jest.config.js +++ /dev/null @@ -1,21 +0,0 @@ -module.exports = { - moduleFileExtensions: ['js', 'json', 'ts'], - rootDir: 'src', - testRegex: '.*\\.spec\\.ts$', - transform: { - '^.+\\.(t|j)s$': 'ts-jest', - }, - collectCoverageFrom: [ - '**/*.(t|j)s', - '!**/*.module.ts', - '!**/main.ts', - '!**/*.interface.ts', - '!**/*.dto.ts', - ], - coverageDirectory: '../coverage', - testEnvironment: 'node', - moduleNameMapper: { - '^src/(.*)$': '/$1', - }, - setupFilesAfterEnv: ['/../test/jest-setup.ts'], -}; diff --git a/apps/memoro/apps/backend/package.json b/apps/memoro/apps/backend/package.json deleted file mode 100644 index 477b92888..000000000 --- a/apps/memoro/apps/backend/package.json +++ /dev/null @@ -1,50 +0,0 @@ -{ - "name": "@memoro/backend", - "version": "0.1.0", - "description": "Memoro microservice for Mana core system", - "main": "dist/main.js", - "scripts": { - "build": "nest build", - "start": "nest start", - "start:dev": "nest start --watch", - "start:debug": "nest start --debug --watch", - "start:prod": "node dist/src/main", - "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", - "test": "jest", - "test:watch": "jest --watch", - "test:cov": "jest --coverage", - "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand" - }, - "dependencies": { - "@nestjs/axios": "^3.0.0", - "@nestjs/common": "^10.0.0", - "@nestjs/config": "^3.0.0", - "@nestjs/core": "^10.0.0", - "@nestjs/platform-express": "^10.0.0", - "@supabase/supabase-js": "^2.49.5", - "@types/jsonwebtoken": "^9.0.7", - "@types/multer": "^1.4.12", - "@types/uuid": "^10.0.0", - "axios": "^1.9.0", - "jsonwebtoken": "^9.0.2", - "multer": "^2.0.0", - "music-metadata": "^7.14.0", - "reflect-metadata": "^0.1.13", - "rxjs": "^7.8.0", - "uuid": "^11.1.0" - }, - "devDependencies": { - "@nestjs/cli": "^10.0.0", - "@nestjs/testing": "^10.0.0", - "@types/express": "^4.17.17", - "@types/jest": "^29.5.2", - "@types/node": "^20.3.1", - "@types/supertest": "^2.0.12", - "jest": "^29.5.0", - "supertest": "^6.3.3", - "ts-jest": "^29.1.0", - "ts-node": "^10.9.1", - "tsconfig-paths": "^4.2.0", - "typescript": "^5.1.3" - } -} diff --git a/apps/memoro/apps/backend/scripts/check-audio-path-field.ts b/apps/memoro/apps/backend/scripts/check-audio-path-field.ts deleted file mode 100644 index c299fd40a..000000000 --- a/apps/memoro/apps/backend/scripts/check-audio-path-field.ts +++ /dev/null @@ -1,106 +0,0 @@ -#!/usr/bin/env ts-node - -/** - * Script to analyze and standardize audio path field usage in Memoro production database - * - * STANDARDIZATION GOAL: - * - Standardize all backend services to use 'audio_path' field consistently - * - Handle legacy 'path' field references for backward compatibility - * - Migrate any remaining 'path' fields to 'audio_path' in database - * - * CURRENT STATUS (August 25, 2025): - * - Most memos already use 'audio_path' field (92%) - * - Small subset uses legacy 'path' field (7.3%) - * - Backend services now standardized to use 'audio_path' - * - * MIGRATION APPROACH: - * - Update backend services to prioritize 'audio_path' over 'path' - * - Migrate database records from 'path' to 'audio_path' - * - Maintain backward compatibility during transition - * - * SQL QUERIES USED: - */ - -// Query 1: Overall statistics -const overallStatsQuery = ` -SELECT - COUNT(*) FILTER (WHERE source->>'audio_path' IS NOT NULL) as memos_with_audio_path, - COUNT(*) FILTER (WHERE source->>'path' IS NOT NULL) as memos_with_path, - COUNT(*) as total_memos, - COUNT(*) FILTER (WHERE source->>'audio_path' IS NOT NULL AND source->>'path' IS NULL) as only_audio_path, - COUNT(*) FILTER (WHERE source->>'audio_path' IS NOT NULL AND source->>'path' IS NOT NULL) as both_fields -FROM memos -WHERE source IS NOT NULL; -`; - -// Query 2: Monthly breakdown -const monthlyBreakdownQuery = ` -SELECT - DATE_TRUNC('month', created_at) as month, - COUNT(*) FILTER (WHERE source->>'audio_path' IS NOT NULL) as with_audio_path, - COUNT(*) FILTER (WHERE source->>'path' IS NOT NULL) as with_path, - COUNT(*) as total -FROM memos -WHERE source IS NOT NULL -GROUP BY month -ORDER BY month DESC -LIMIT 12; -`; - -// Query 3: Daily breakdown for transition period -const dailyTransitionQuery = ` -SELECT - DATE_TRUNC('day', created_at) as day, - COUNT(*) FILTER (WHERE source->>'audio_path' IS NOT NULL) as with_audio_path, - COUNT(*) FILTER (WHERE source->>'path' IS NOT NULL) as with_path, - COUNT(*) as total -FROM memos -WHERE source IS NOT NULL - AND created_at >= '2025-05-01' - AND created_at < '2025-07-01' -GROUP BY day -ORDER BY day; -`; - -// Migration query to standardize all memos to use 'audio_path' field -const migrationQuery = ` --- DRY RUN: Check what would be migrated from 'path' to 'audio_path' -SELECT - id, - source->>'path' as current_path, - source->>'audio_path' as current_audio_path, - created_at -FROM memos -WHERE source->>'path' IS NOT NULL - AND source->>'audio_path' IS NULL -LIMIT 10; - --- ACTUAL MIGRATION (run with caution): --- Migrate 'path' field to 'audio_path' field --- UPDATE memos --- SET source = jsonb_set( --- source - 'path', --- '{audio_path}', --- source->'path' --- ) --- WHERE source->>'path' IS NOT NULL --- AND source->>'audio_path' IS NULL; -`; - -console.log('Audio Path Field Analysis Script'); -console.log('================================'); -console.log(''); -console.log('This script documents the analysis of the legacy audio_path field usage'); -console.log('in the Memoro production database.'); -console.log(''); -console.log('Key Findings:'); -console.log('- 92% of memos (16,223) already use the audio_path field'); -console.log('- Only 7.3% (1,286) use the legacy path field'); -console.log('- The fields are mutually exclusive (no memo has both)'); -console.log('- Brief transition attempted in May-June 2025 but mostly reverted'); -console.log(''); -console.log('Backend Standardization Complete:'); -console.log('- All backend services now standardized to use "audio_path" field'); -console.log('- Legacy "path" field handling maintained for backward compatibility'); -console.log('- Database migration needed for remaining 7.3% with "path" field'); -console.log('- Edge Functions already use "audio_path" consistently'); diff --git a/apps/memoro/apps/backend/src/ai/ai-model.config.ts b/apps/memoro/apps/backend/src/ai/ai-model.config.ts deleted file mode 100644 index b6f453ccd..000000000 --- a/apps/memoro/apps/backend/src/ai/ai-model.config.ts +++ /dev/null @@ -1,54 +0,0 @@ -/** - * Zentrale AI-Modell-Konfiguration - * - * Alle Modelle, Endpoints und Presets an einer Stelle. - * Modellwechsel = nur diese Datei ändern. - */ - -export interface GeminiConfig { - model: string; - endpoint: string; - temperature: number; - maxOutputTokens: number; -} - -export interface AzureOpenAIConfig { - endpoint: string; - deployment: string; - apiVersion: string; - temperature: number; - maxTokens: number; -} - -export interface GenerateOptions { - temperature?: number; - maxTokens?: number; -} - -// ── Primary: Google Gemini ── -// Note: gemini-2.0-flash wird Juni 2026 deprecated → gemini-2.0-flash-001 ist stabil -export const GEMINI_DEFAULT: GeminiConfig = { - model: 'gemini-2.0-flash-001', - endpoint: 'https://generativelanguage.googleapis.com/v1beta/models', - temperature: 0.7, - maxOutputTokens: 8192, -}; - -// ── Fallback: Azure OpenAI ── -export const AZURE_DEFAULT: AzureOpenAIConfig = { - endpoint: 'https://memoroseopenai.openai.azure.com', - deployment: 'gpt-4.1-mini-se', - apiVersion: '2025-01-01-preview', - temperature: 0.7, - maxTokens: 8192, -}; - -// ── Task-spezifische Presets ── -export const AI_PRESETS = { - headline: { temperature: 0.7, maxTokens: 300 }, - memory: { temperature: 0.7, maxTokens: 8192 }, - translation: { temperature: 0.3, maxTokens: 8192 }, - selection: { temperature: 0.3, maxTokens: 2048 }, -} as const; - -export type AiPreset = keyof typeof AI_PRESETS; diff --git a/apps/memoro/apps/backend/src/ai/ai.module.ts b/apps/memoro/apps/backend/src/ai/ai.module.ts deleted file mode 100644 index ad2c0987b..000000000 --- a/apps/memoro/apps/backend/src/ai/ai.module.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { Module } from '@nestjs/common'; -import { AiService } from './ai.service'; -import { HeadlineService } from './headline/headline.service'; -import { MemoryService } from './memory/memory.service'; -import { QuestionService } from './memory/question.service'; -import { UserPromptService } from './shared/user-prompt.service'; - -@Module({ - providers: [AiService, HeadlineService, MemoryService, QuestionService, UserPromptService], - exports: [AiService, HeadlineService, MemoryService, QuestionService, UserPromptService], -}) -export class AiModule {} diff --git a/apps/memoro/apps/backend/src/ai/ai.service.ts b/apps/memoro/apps/backend/src/ai/ai.service.ts deleted file mode 100644 index 23ae20c74..000000000 --- a/apps/memoro/apps/backend/src/ai/ai.service.ts +++ /dev/null @@ -1,141 +0,0 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import { - GEMINI_DEFAULT, - AZURE_DEFAULT, - type GeminiConfig, - type AzureOpenAIConfig, - type GenerateOptions, -} from './ai-model.config'; - -@Injectable() -export class AiService { - private readonly logger = new Logger(AiService.name); - private readonly geminiApiKey: string; - private readonly azureApiKey: string; - - constructor(private configService: ConfigService) { - this.geminiApiKey = this.configService.get('GEMINI_API_KEY', ''); - this.azureApiKey = this.configService.get('AZURE_OPENAI_KEY', ''); - } - - /** - * Generiert Text mit Gemini (Primary) → Azure (Fallback). - * Gibt den rohen Text-Content zurück. - */ - async generateText( - prompt: string, - options?: GenerateOptions & { systemInstruction?: string } - ): Promise { - // Primary: Gemini - if (this.geminiApiKey) { - const result = await this.callGemini(prompt, this.geminiApiKey, options); - if (result !== null) return result; - this.logger.warn('Gemini failed, falling back to Azure OpenAI'); - } else { - this.logger.warn('No Gemini API key, using Azure OpenAI directly'); - } - - // Fallback: Azure - if (!this.azureApiKey) { - throw new Error('No AI provider available: both Gemini and Azure keys missing'); - } - const result = await this.callAzure(prompt, options); - if (result !== null) return result; - - throw new Error('All AI providers failed'); - } - - private async callGemini( - prompt: string, - apiKey: string, - options?: GenerateOptions & { systemInstruction?: string } - ): Promise { - const config: GeminiConfig = { - ...GEMINI_DEFAULT, - temperature: options?.temperature ?? GEMINI_DEFAULT.temperature, - maxOutputTokens: options?.maxTokens ?? GEMINI_DEFAULT.maxOutputTokens, - }; - - try { - const url = `${config.endpoint}/${config.model}:generateContent?key=${apiKey}`; - const body: any = { - contents: [{ parts: [{ text: prompt }] }], - generationConfig: { - temperature: config.temperature, - maxOutputTokens: config.maxOutputTokens, - }, - }; - - if (options?.systemInstruction) { - body.systemInstruction = { - parts: [{ text: options.systemInstruction }], - }; - } - - const start = Date.now(); - const response = await fetch(url, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }); - - if (!response.ok) { - const errorText = await response.text(); - this.logger.error(`Gemini API error (${response.status}): ${errorText}`); - return null; - } - - const data = await response.json(); - const content = data.candidates?.[0]?.content?.parts?.[0]?.text?.trim() || ''; - this.logger.debug( - `Gemini ${config.model} responded in ${Date.now() - start}ms (${content.length} chars)` - ); - return content || null; - } catch (error) { - this.logger.error(`Gemini call failed: ${error instanceof Error ? error.message : error}`); - return null; - } - } - - private async callAzure(prompt: string, options?: GenerateOptions): Promise { - const config: AzureOpenAIConfig = { - ...AZURE_DEFAULT, - temperature: options?.temperature ?? AZURE_DEFAULT.temperature, - maxTokens: options?.maxTokens ?? AZURE_DEFAULT.maxTokens, - }; - - try { - const url = `${config.endpoint}/openai/deployments/${config.deployment}/chat/completions?api-version=${config.apiVersion}`; - const start = Date.now(); - const response = await fetch(url, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'api-key': this.azureApiKey, - }, - body: JSON.stringify({ - messages: [{ role: 'user', content: prompt }], - max_tokens: config.maxTokens, - temperature: config.temperature, - }), - }); - - if (!response.ok) { - const errorText = await response.text(); - this.logger.error(`Azure OpenAI error (${response.status}): ${errorText}`); - return null; - } - - const data = await response.json(); - const content = data.choices?.[0]?.message?.content?.trim() || ''; - this.logger.debug( - `Azure ${config.deployment} responded in ${Date.now() - start}ms (${content.length} chars)` - ); - return content || null; - } catch (error) { - this.logger.error(`Azure call failed: ${error instanceof Error ? error.message : error}`); - return null; - } - } -} diff --git a/apps/memoro/apps/backend/src/ai/headline/headline.prompts.ts b/apps/memoro/apps/backend/src/ai/headline/headline.prompts.ts deleted file mode 100644 index 1061c351c..000000000 --- a/apps/memoro/apps/backend/src/ai/headline/headline.prompts.ts +++ /dev/null @@ -1,219 +0,0 @@ -/** - * System-Prompts für die Headline-Generierung in verschiedenen Sprachen - * - * Die Prompts werden verwendet, um Überschriften und Einleitungen für Memos zu generieren. - * Jede Sprache hat ihren eigenen Prompt, der die spezifischen Anforderungen und Formatierungen enthält. - */ /** - * Interface für die Prompt-Konfiguration - */ /** - * System-Prompts für die Headline-Generierung - * - * Unterstützte Sprachen (62): - * - de: Deutsch - * - en: Englisch - * - fr: Französisch - * - es: Spanisch - * - it: Italienisch - * - nl: Niederländisch - * - pt: Portugiesisch - * - ru: Russisch - * - ja: Japanisch - * - ko: Koreanisch - * - zh: Chinesisch - * - ar: Arabisch - * - hi: Hindi - * - tr: Türkisch - * - pl: Polnisch - * - da: Dänisch - * - sv: Schwedisch - * - nb: Norwegisch - * - fi: Finnisch - * - cs: Tschechisch - * - hu: Ungarisch - * - el: Griechisch - * - he: Hebräisch - * - id: Indonesisch - * - th: Thai - * - vi: Vietnamesisch - * - uk: Ukrainisch - * - ro: Rumänisch - * - bg: Bulgarisch - * - ca: Katalanisch - * - hr: Kroatisch - * - sk: Slowakisch - * - et: Estnisch - * - lv: Lettisch - * - lt: Litauisch - * - bn: Bengalisch - * - ms: Malaiisch - * - ta: Tamil - * - te: Telugu - * - ur: Urdu - * - mr: Marathi - * - gu: Gujarati - * - ml: Malayalam - * - kn: Kannada - * - pa: Punjabi - * - af: Afrikaans - * - fa: Persisch - * - ka: Georgisch - * - is: Isländisch - * - sq: Albanisch - * - az: Aserbaidschanisch - * - eu: Baskisch - * - gl: Galizisch - * - kk: Kasachisch - * - mk: Mazedonisch - * - sr: Serbisch - * - sl: Slowenisch - * - mt: Maltesisch - * - hy: Armenisch - * - uz: Usbekisch - * - ga: Irisch - * - cy: Walisisch - * - fil: Filipino - */ export const SYSTEM_PROMPTS = { - headline: { - // Deutsch - de: 'Du bist ein Assistent, der Texte analysiert und zusammenfasst. Deine Aufgabe ist es, für den folgenden Text zwei Dinge zu erstellen:\n1. Eine kurze, prägnante Headline (maximal 8 Wörter)\n2. Ein kurzes Intro, das den Inhalt des Textes in 2-3 Sätzen zusammenfasst und neugierig macht\n\nFormatiere deine Antwort genau so:\nHEADLINE: [Deine Headline hier]\nINTRO: [Dein Intro hier]', - // Englisch - en: 'You are an assistant that analyzes and summarizes texts. Your task is to create two things for the following text:\n1. A short, concise headline (maximum 8 words)\n2. A brief intro that summarizes the content of the text in 2-3 sentences and makes the reader curious\n\nFormat your answer exactly like this:\nHEADLINE: [Your headline here]\nINTRO: [Your intro here]', - // Französisch - fr: 'Vous êtes un assistant qui analyse et résume des textes. Votre tâche est de créer deux choses pour le texte suivant :\n1. Un titre court et concis (maximum 8 mots)\n2. Une brève introduction qui résume le contenu du texte en 2-3 phrases et éveille la curiosité du lecteur\n\nFormatez votre réponse exactement comme ceci :\nHEADLINE: [Votre titre ici]\nINTRO: [Votre introduction ici]', - // Spanisch - es: 'Eres un asistente que analiza y resume textos. Tu tarea es crear dos cosas para el siguiente texto:\n1. Un título breve y conciso (máximo 8 palabras)\n2. Una breve introducción que resuma el contenido del texto en 2-3 frases y despierte la curiosidad del lector\n\nFormatea tu respuesta exactamente así:\nHEADLINE: [Tu título aquí]\nINTRO: [Tu introducción aquí]', - // Italienisch - it: 'Sei un assistente che analizza e riassume testi. Il tuo compito è creare due cose per il seguente testo:\n1. Un titolo breve e conciso (massimo 8 parole)\n2. Una breve introduzione che riassume il contenuto del testo in 2-3 frasi e suscita la curiosità del lettore\n\nFormatta la tua risposta esattamente così:\nHEADLINE: [Il tuo titolo qui]\nINTRO: [La tua introduzione qui]', - // Niederländisch - nl: 'Je bent een assistent die teksten analyseert en samenvat. Je taak is om twee dingen te maken voor de volgende tekst:\n1. Een korte, bondige kop (maximaal 8 woorden)\n2. Een korte intro die de inhoud van de tekst in 2-3 zinnen samenvat en de lezer nieuwsgierig maakt\n\nFormatteer je antwoord precies zo:\nHEADLINE: [Jouw kop hier]\nINTRO: [Jouw intro hier]', - // Portugiesisch - pt: 'Você é um assistente que analisa e resume textos. Sua tarefa é criar duas coisas para o seguinte texto:\n1. Uma manchete breve e concisa (máximo 8 palavras)\n2. Uma breve introdução que resume o conteúdo do texto em 2-3 frases e desperta a curiosidade do leitor\n\nFormate sua resposta exatamente assim:\nHEADLINE: [Sua manchete aqui]\nINTRO: [Sua introdução aqui]', - // Russisch - ru: 'Вы помощник, который анализирует и резюмирует тексты. Ваша задача - создать две вещи для следующего текста:\n1. Короткий, лаконичный заголовок (максимум 8 слов)\n2. Краткое введение, которое резюмирует содержание текста в 2-3 предложениях и вызывает любопытство у читателя\n\nФорматируйте ваш ответ точно так:\nHEADLINE: [Ваш заголовок здесь]\nINTRO: [Ваше введение здесь]', - // Japanisch - ja: 'あなたはテキストを分析し要約するアシスタントです。次のテキストに対して2つのことを作成するのがあなたの仕事です:\n1. 短く簡潔な見出し(最大8語)\n2. テキストの内容を2-3文で要約し、読者の興味を引く短い導入文\n\n次のように正確にフォーマットしてください:\nHEADLINE: [ここにあなたの見出し]\nINTRO: [ここにあなたの導入文]', - // Koreanisch - ko: '당신은 텍스트를 분석하고 요약하는 어시스턴트입니다. 다음 텍스트에 대해 두 가지를 만드는 것이 당신의 임무입니다:\n1. 짧고 간결한 헤드라인 (최대 8단어)\n2. 텍스트의 내용을 2-3문장으로 요약하고 독자의 호기심을 자극하는 짧은 소개\n\n다음과 같이 정확히 형식을 맞춰주세요:\nHEADLINE: [여기에 당신의 헤드라인]\nINTRO: [여기에 당신의 소개]', - // Chinesisch (vereinfacht) - zh: '你是一个分析和总结文本的助手。你的任务是为以下文本创建两样东西:\n1. 一个简短、简洁的标题(最多8个词)\n2. 一个简短的介绍,用2-3句话总结文本内容并激发读者的好奇心\n\n请严格按照以下格式回答:\nHEADLINE: [你的标题]\nINTRO: [你的介绍]', - // Arabisch - ar: 'أنت مساعد يحلل ويلخص النصوص. مهمتك هي إنشاء شيئين للنص التالي:\n1. عنوان قصير ومقتضب (8 كلمات كحد أقصى)\n2. مقدمة مختصرة تلخص محتوى النص في 2-3 جمل وتثير فضول القارئ\n\nقم بتنسيق إجابتك بالضبط هكذا:\nHEADLINE: [عنوانك هنا]\nINTRO: [مقدمتك هنا]', - // Hindi - hi: 'आप एक सहायक हैं जो ग्रंथों का विश्लेषण और सारांश करते हैं। निम्नलिखित पाठ के लिए दो चीजें बनाना आपका कार्य है:\n1. एक संक्षिप्त, सटीक शीर्षक (अधिकतम 8 शब्द)\n2. एक संक्षिप्त परिचय जो पाठ की सामग्री को 2-3 वाक्यों में सारांशित करता है और पाठक में जिज्ञासा जगाता है\n\nअपना उत्तर बिल्कुल इस तरह से प्रारूपित करें:\nHEADLINE: [यहाँ आपका शीर्षक]\nINTRO: [यहाँ आपका परिचय]', - // Türkisch - tr: 'Metinleri analiz eden ve özetleyen bir asistansınız. Aşağıdaki metin için iki şey oluşturmak sizin göreviniz:\n1. Kısa, özlü bir başlık (maksimum 8 kelime)\n2. Metnin içeriğini 2-3 cümlede özetleyen ve okuyucuyu meraklandıran kısa bir giriş\n\nCevabınızı tam olarak şu şekilde biçimlendirin:\nHEADLINE: [Başlığınız burada]\nINTRO: [Girişiniz burada]', - // Polnisch - pl: 'Jesteś asystentem, który analizuje i streszcza teksty. Twoim zadaniem jest stworzenie dwóch rzeczy dla następującego tekstu:\n1. Krótki, zwięzły nagłówek (maksymalnie 8 słów)\n2. Krótkie wprowadzenie, które streszcza treść tekstu w 2-3 zdaniach i wzbudza ciekawość czytelnika\n\nSformatuj swoją odpowiedź dokładnie tak:\nHEADLINE: [Twój nagłówek tutaj]\nINTRO: [Twoje wprowadzenie tutaj]', - // Dänisch - da: 'Du er en assistent, der analyserer og sammenfatter tekster. Din opgave er at skabe to ting for følgende tekst:\n1. En kort, præcis overskrift (maksimalt 8 ord)\n2. En kort intro, der sammenfatter tekstens indhold i 2-3 sætninger og gør læseren nysgerrig\n\nFormatter dit svar præcis sådan:\nHEADLINE: [Din overskrift her]\nINTRO: [Dit intro her]', - // Schwedisch - sv: 'Du är en assistent som analyserar och sammanfattar texter. Din uppgift är att skapa två saker för följande text:\n1. En kort, koncis rubrik (maximalt 8 ord)\n2. En kort intro som sammanfattar textens innehåll i 2-3 meningar och gör läsaren nyfiken\n\nFormatera ditt svar exakt så här:\nHEADLINE: [Din rubrik här]\nINTRO: [Ditt intro här]', - // Norwegisch - nb: 'Du er en assistent som analyserer og oppsummerer tekster. Oppgaven din er å lage to ting for følgende tekst:\n1. En kort, presis overskrift (maksimalt 8 ord)\n2. En kort intro som oppsummerer tekstens innhold i 2-3 setninger og gjør leseren nysgjerrig\n\nFormater svaret ditt nøyaktig slik:\nHEADLINE: [Din overskrift her]\nINTRO: [Ditt intro her]', - // Finnisch - fi: 'Olet avustaja, joka analysoi ja tiivistää tekstejä. Tehtäväsi on luoda kaksi asiaa seuraavalle tekstille:\n1. Lyhyt, ytimekäs otsikko (enintään 8 sanaa)\n2. Lyhyt johdanto, joka tiivistää tekstin sisällön 2-3 lauseessa ja herättää lukijan uteliaisuuden\n\nMuotoile vastauksesi täsmälleen näin:\nHEADLINE: [Otsikkosi tähän]\nINTRO: [Johdantosi tähän]', - // Tschechisch - cs: 'Jste asistent, který analyzuje a shrnuje texty. Vaším úkolem je vytvořit dvě věci pro následující text:\n1. Krátký, stručný nadpis (maximálně 8 slov)\n2. Krátký úvod, který shrne obsah textu ve 2-3 větách a vzbudí zvědavost čtenáře\n\nNaformátujte svou odpověď přesně takto:\nHEADLINE: [Váš nadpis zde]\nINTRO: [Váš úvod zde]', - // Ungarisch - hu: 'Ön egy asszisztens, aki szövegeket elemez és összefoglal. Az Ön feladata, hogy két dolgot hozzon létre a következő szöveghez:\n1. Egy rövid, tömör címsor (maximum 8 szó)\n2. Egy rövid bevezető, amely 2-3 mondatban összefoglalja a szöveg tartalmát és felkelti az olvasó kíváncsiságát\n\nFormázza válaszát pontosan így:\nHEADLINE: [Az Ön címsora itt]\nINTRO: [Az Ön bevezetője itt]', - // Griechisch - el: 'Είστε ένας βοηθός που αναλύει και συνοψίζει κείμενα. Το καθήκον σας είναι να δημιουργήσετε δύο πράγματα για το ακόλουθο κείμενο:\n1. Έναν σύντομο, περιεκτικό τίτλο (μέγιστο 8 λέξεις)\n2. Μια σύντομη εισαγωγή που συνοψίζει το περιεχόμενο του κειμένου σε 2-3 προτάσεις και προκαλεί την περιέργεια του αναγνώστη\n\nΜορφοποιήστε την απάντησή σας ακριβώς έτσι:\nHEADLINE: [Ο τίτλος σας εδώ]\nINTRO: [Η εισαγωγή σας εδώ]', - // Hebräisch - he: 'אתה עוזר שמנתח ומסכם טקסטים. המשימה שלך היא ליצור שני דברים לטקסט הבא:\n1. כותרת קצרה ותמציתית (מקסימום 8 מילים)\n2. הקדמה קצרה שמסכמת את תוכן הטקסט ב-2-3 משפטים ומעוררת סקרנות אצל הקורא\n\nעצב את התשובה שלך בדיוק כך:\nHEADLINE: [הכותרת שלך כאן]\nINTRO: [ההקדמה שלך כאן]', - // Indonesisch - id: 'Anda adalah asisten yang menganalisis dan merangkum teks. Tugas Anda adalah membuat dua hal untuk teks berikut:\n1. Judul yang pendek dan ringkas (maksimal 8 kata)\n2. Intro singkat yang merangkum isi teks dalam 2-3 kalimat dan membuat pembaca penasaran\n\nFormat jawaban Anda persis seperti ini:\nHEADLINE: [Judul Anda di sini]\nINTRO: [Intro Anda di sini]', - // Thai - th: 'คุณเป็นผู้ช่วยที่วิเคราะห์และสรุปข้อความ งานของคุณคือการสร้างสองสิ่งสำหรับข้อความต่อไปนี้:\n1. หัวข้อที่สั้นและกระชับ (ไม่เกิน 8 คำ)\n2. บทนำสั้นๆ ที่สรุปเนื้อหาของข้อความใน 2-3 ประโยคและทำให้ผู้อ่านอยากรู้\n\nจัดรูปแบบคำตอบของคุณตามนี้เป๊ะๆ:\nHEADLINE: [หัวข้อของคุณที่นี่]\nINTRO: [บทนำของคุณที่นี่]', - // Vietnamesisch - vi: 'Bạn là một trợ lý phân tích và tóm tắt văn bản. Nhiệm vụ của bạn là tạo hai thứ cho văn bản sau:\n1. Một tiêu đề ngắn gọn và súc tích (tối đa 8 từ)\n2. Một phần giới thiệu ngắn tóm tắt nội dung văn bản trong 2-3 câu và khơi gợi sự tò mò của người đọc\n\nĐịnh dạng câu trả lời của bạn chính xác như thế này:\nHEADLINE: [Tiêu đề của bạn ở đây]\nINTRO: [Phần giới thiệu của bạn ở đây]', - // Ukrainisch - uk: 'Ви помічник, який аналізує та резюмує тексти. Ваше завдання - створити дві речі для наступного тексту:\n1. Короткий, лаконічний заголовок (максимум 8 слів)\n2. Короткий вступ, який резюмує зміст тексту у 2-3 реченнях та викликає цікавість у читача\n\nФорматуйте вашу відповідь точно так:\nHEADLINE: [Ваш заголовок тут]\nINTRO: [Ваш вступ тут]', - // Rumänisch - ro: 'Sunteți un asistent care analizează și rezumă texte. Sarcina dvs. este să creați două lucruri pentru următorul text:\n1. Un titlu scurt și concis (maximum 8 cuvinte)\n2. O scurtă introducere care rezumă conținutul textului în 2-3 propoziții și trezește curiozitatea cititorului\n\nFormatați răspunsul dvs. exact astfel:\nHEADLINE: [Titlul dvs. aici]\nINTRO: [Introducerea dvs. aici]', - // Bulgarisch - bg: 'Вие сте асистент, който анализира и резюмира текстове. Вашата задача е да създадете две неща за следния текст:\n1. Кратко, сбито заглавие (максимум 8 думи)\n2. Кратко въведение, което резюмира съдържанието на текста в 2-3 изречения и предизвиква любопитството на читателя\n\nФорматирайте отговора си точно така:\nHEADLINE: [Вашето заглавие тук]\nINTRO: [Вашето въведение тук]', - // Katalanisch - ca: 'Ets un assistent que analitza i resumeix textos. La teva tasca és crear dues coses per al següent text:\n1. Un títol breu i concís (màxim 8 paraules)\n2. Una breu introducció que resumeixi el contingut del text en 2-3 frases i desperti la curiositat del lector\n\nFormata la teva resposta exactament així:\nHEADLINE: [El teu títol aquí]\nINTRO: [La teva introducció aquí]', - // Kroatisch - hr: 'Vi ste asistent koji analizira i sažima tekstove. Vaš zadatak je stvoriti dvije stvari za sljedeći tekst:\n1. Kratak, sažet naslov (maksimalno 8 riječi)\n2. Kratak uvod koji sažima sadržaj teksta u 2-3 rečenice i pobuđuje znatiželju čitatelja\n\nFormatirajte svoj odgovor točno ovako:\nHEADLINE: [Vaš naslov ovdje]\nINTRO: [Vaš uvod ovdje]', - // Slowakisch - sk: 'Ste asistent, ktorý analyzuje a sumarizuje texty. Vašou úlohou je vytvoriť dve veci pre nasledujúci text:\n1. Krátky, stručný nadpis (maximálne 8 slov)\n2. Krátky úvod, ktorý sumarizuje obsah textu v 2-3 vetách a vzbudí zvedavosť čitateľa\n\nNaformátujte svoju odpoveď presne takto:\nHEADLINE: [Váš nadpis tu]\nINTRO: [Váš úvod tu]', - // Estnisch - et: 'Olete assistent, kes analüüsib ja kokkuvõtab tekste. Teie ülesanne on luua kaks asja järgmise teksti jaoks:\n1. Lühike, kokkuvõtlik pealkiri (maksimaalselt 8 sõna)\n2. Lühike sissejuhatus, mis võtab teksti sisu kokku 2-3 lauses ja äratab lugeja uudishimu\n\nVormistage oma vastus täpselt nii:\nHEADLINE: [Teie pealkiri siin]\nINTRO: [Teie sissejuhatus siin]', - // Lettisch - lv: 'Jūs esat asistents, kas analizē un apkopo tekstus. Jūsu uzdevums ir izveidot divas lietas šādam tekstam:\n1. Īsu, kodolīgu virsrakstu (maksimums 8 vārdi)\n2. Īsu ievadu, kas apkopo teksta saturu 2-3 teikumos un modina lasītāja ziņkāri\n\nFormatējiet savu atbildi tieši tā:\nHEADLINE: [Jūsu virsraksts šeit]\nINTRO: [Jūsu ievads šeit]', - // Litauisch - lt: 'Esate asistentas, kuris analizuoja ir apibendrина tekstus. Jūsų užduotis - sukurti du dalykus šiam tekstui:\n1. Trumpą, glaustą antraštę (ne daugiau kaip 8 žodžiai)\n2. Trumpą įvadą, kuris apibendrinta teksto turinį 2-3 sakiniais ir žadina skaitytojo smalsumą\n\nSuformatuokite savo atsakymą tiksliai taip:\nHEADLINE: [Jūsų antraštė čia]\nINTRO: [Jūsų įvadas čia]', - // Bengalisch - bn: 'আপনি একজন সহায়ক যিনি পাঠ্য বিশ্লেষণ এবং সারসংক্ষেপ করেন। নিম্নলিখিত পাঠ্যের জন্য দুটি জিনিস তৈরি করা আপনার কাজ:\n1. একটি সংক্ষিপ্ত, সারগর্ভ শিরোনাম (সর্বোচ্চ ৮টি শব্দ)\n2. একটি সংক্ষিপ্ত ভূমিকা যা ২-৩টি বাক্যে পাঠ্যের বিষয়বস্তু সারসংক্ষেপ করে এবং পাঠকের কৌতূহল জাগায়\n\nআপনার উত্তর ঠিক এভাবে ফরম্যাট করুন:\nHEADLINE: [এখানে আপনার শিরোনাম]\nINTRO: [এখানে আপনার ভূমিকা]', - // Malaiisch - ms: 'Anda adalah pembantu yang menganalisis dan meringkaskan teks. Tugas anda adalah untuk mencipta dua perkara untuk teks berikut:\n1. Tajuk utama yang pendek dan padat (maksimum 8 perkataan)\n2. Pengenalan ringkas yang meringkaskan kandungan teks dalam 2-3 ayat dan menimbulkan rasa ingin tahu pembaca\n\nFormatkan jawapan anda tepat seperti ini:\nHEADLINE: [Tajuk utama anda di sini]\nINTRO: [Pengenalan anda di sini]', - // Tamil - ta: 'நீங்கள் உரைகளை பகுப்பாய்வு செய்து சுருக்கும் உதவியாளர். பின்வரும் உரைக்கு இரண்டு விஷயங்களை உருவாக்குவது உங்கள் பணி:\n1. ஒரு குறுகிய, சுருக்கமான தலைப்பு (அதிகபட்சம் 8 வார்த்தைகள்)\n2. உரையின் உள்ளடக்கத்தை 2-3 வாக்கியங்களில் சுருக்கி வாசகரின் ஆர்வத்தை தூண்டும் குறுகிய அறிமுகம்\n\nஉங்கள் பதிலை சரியாக இப்படி வடிவமைக்கவும்:\nHEADLINE: [இங்கே உங்கள் தலைப்பு]\nINTRO: [இங்கே உங்கள் அறிமுகம்]', - // Telugu - te: 'మీరు టెక్స్ట్‌లను విశ్లేషించి సంక్షిప్తీకరించే సహాయకుడు. కింది టెక్స్ట్ కోసం రెండు విషయాలు సృష్టించడం మీ పని:\n1. ఒక చిన్న, సంక్షిప్త శీర్షిక (గరిష్టంగా 8 పదాలు)\n2. టెక్స్ట్ యొక్క కంటెంట్‌ను 2-3 వాక్యాలలో సంక్షిప్తీకరించి పాఠకుడిలో ఆసక్తిని రేకెత్తించే చిన్న పరిచయం\n\nమీ సమాధానాన్ని సరిగ్గా ఇలా ఫార్మాట్ చేయండి:\nHEADLINE: [ఇక్కడ మీ శీర్షిక]\nINTRO: [ఇక్కడ మీ పరిచయం]', - // Urdu - ur: 'آپ ایک معاون ہیں جو متن کا تجزیہ اور خلاصہ کرتے ہیں۔ مندرجہ ذیل متن کے لیے دو چیزیں بنانا آپ کا کام ہے:\n1. ایک مختصر، جامع سرخی (زیادہ سے زیادہ 8 الفاظ)\n2. ایک مختصر تعارف جو متن کے مواد کو 2-3 جملوں میں خلاصہ کرے اور قاری میں تجسس پیدا کرے\n\nاپنے جواب کو بالکل اس طرح فارمیٹ کریں:\nHEADLINE: [یہاں آپ کی سرخی]\nINTRO: [یہاں آپ کا تعارف]', - // Marathi - mr: 'तुम्ही मजकूरांचे विश्लेषण आणि सारांश करणारे सहाय्यक आहात. पुढील मजकुरासाठी दोन गोष्टी तयार करणे हे तुमचे काम आहे:\n1. एक लहान, संक्षिप्त मथळा (जास्तीत जास्त 8 शब्द)\n2. एक छोटी प्रस्तावना जी मजकुराची सामग्री 2-3 वाक्यांमध्ये सारांशित करते आणि वाचकामध्ये कुतूहल निर्माण करते\n\nतुमचे उत्तर अगदी अशा प्रकारे स्वरूपित करा:\nHEADLINE: [इथे तुमचा मथळा]\nINTRO: [इथे तुमची प्रस्तावना]', - // Gujarati - gu: 'તમે એક સહાયક છો જે ટેક્સ્ટનું વિશ્લેષણ અને સારાંશ કરે છે. નીચેના ટેક્સ્ટ માટે બે વસ્તુઓ બનાવવી એ તમારું કામ છે:\n1. એક ટૂંકું, સંક્ષિપ્ત હેડલાઇન (મહત્તમ 8 શબ્દો)\n2. એક ટૂંકો પરિચય જે ટેક્સ્ટની સામગ્રીને 2-3 વાક્યોમાં સારાંશ આપે અને વાચકમાં જિજ્ઞાસા જગાડે\n\nતમારા જવાબને બરાબર આ રીતે ફોર્મેટ કરો:\nHEADLINE: [અહીં તમારું હેડલાઇન]\nINTRO: [અહીં તમારો પરિચય]', - // Malayalam - ml: 'നിങ്ങൾ വാചകങ്ങൾ വിശകലനം ചെയ്യുകയും സംഗ്രഹിക്കുകയും ചെയ്യുന്ന ഒരു സഹായകനാണ്. ഇനിപ്പറയുന്ന വാചകത്തിനായി രണ്ട് കാര്യങ്ങൾ സൃഷ്ടിക്കുക എന്നതാണ് നിങ്ങളുടെ ജോലി:\n1. ഒരു ചെറിയ, സംക്ഷിപ്ത തലക്കെട്ട് (പരമാവധി 8 വാക്കുകൾ)\n2. വാചകത്തിന്റെ ഉള്ളടക്കം 2-3 വാക്യങ്ങളിൽ സംഗ്രഹിക്കുകയും വായനക്കാരനിൽ ജിജ്ഞാസ ഉണർത്തുകയും ചെയ്യുന്ന ഒരു ചെറിയ ആമുഖം\n\nനിങ്ങളുടെ ഉത്തരം കൃത്യമായി ഇപ്രകാരം ഫോർമാറ്റ് ചെയ്യുക:\nHEADLINE: [ഇവിടെ നിങ്ങളുടെ തലക്കെട്ട്]\nINTRO: [ഇവിടെ നിങ്ങളുടെ ആമുഖം]', - // Kannada - kn: 'ನೀವು ಪಠ್ಯಗಳನ್ನು ವಿಶ್ಲೇಷಿಸುವ ಮತ್ತು ಸಾರಾಂಶಗೊಳಿಸುವ ಸಹಾಯಕರಾಗಿದ್ದೀರಿ. ಕೆಳಗಿನ ಪಠ್ಯಕ್ಕಾಗಿ ಎರಡು ವಿಷಯಗಳನ್ನು ರಚಿಸುವುದು ನಿಮ್ಮ ಕೆಲಸ:\n1. ಒಂದು ಸಣ್ಣ, ಸಂಕ್ಷಿಪ್ತ ಶೀರ್ಷಿಕೆ (ಗರಿಷ್ಠ 8 ಪದಗಳು)\n2. ಪಠ್ಯದ ವಿಷಯವನ್ನು 2-3 ವಾಕ್ಯಗಳಲ್ಲಿ ಸಾರಾಂಶಗೊಳಿಸುವ ಮತ್ತು ಓದುಗರಲ್ಲಿ ಕುತೂಹಲವನ್ನು ಹುಟ್ಟಿಸುವ ಒಂದು ಸಣ್ಣ ಪರಿಚಯ\n\nನಿಮ್ಮ ಉತ್ತರವನ್ನು ನಿಖರವಾಗಿ ಈ ರೀತಿ ಫಾರ್ಮ್ಯಾಟ್ ಮಾಡಿ:\nHEADLINE: [ಇಲ್ಲಿ ನಿಮ್ಮ ಶೀರ್ಷಿಕೆ]\nINTRO: [ಇಲ್ಲಿ ನಿಮ್ಮ ಪರಿಚಯ]', - // Punjabi - pa: 'ਤੁਸੀਂ ਇੱਕ ਸਹਾਇਕ ਹੋ ਜੋ ਟੈਕਸਟਾਂ ਦਾ ਵਿਸ਼ਲੇਸ਼ਣ ਅਤੇ ਸੰਖੇਪ ਕਰਦੇ ਹੋ। ਹੇਠਲੇ ਟੈਕਸਟ ਲਈ ਦੋ ਚੀਜ਼ਾਂ ਬਣਾਉਣਾ ਤੁਹਾਡਾ ਕੰਮ ਹੈ:\n1. ਇੱਕ ਛੋਟੀ, ਸੰਖੇਪ ਸਿਰਲੇਖ (ਵੱਧ ਤੋਂ ਵੱਧ 8 ਸ਼ਬਦ)\n2. ਇੱਕ ਛੋਟੀ ਜਾਣ-ਪਛਾਣ ਜੋ ਟੈਕਸਟ ਦੀ ਸਮੱਗਰੀ ਨੂੰ 2-3 ਵਾਕਾਂ ਵਿੱਚ ਸੰਖੇਪ ਕਰੇ ਅਤੇ ਪਾਠਕ ਵਿੱਚ ਉਤਸੁਕਤਾ ਪੈਦਾ ਕਰੇ\n\nਆਪਣੇ ਜਵਾਬ ਨੂੰ ਬਿਲਕੁਲ ਇਸ ਤਰ੍ਹਾਂ ਫਾਰਮੈਟ ਕਰੋ:\nHEADLINE: [ਇੱਥੇ ਤੁਹਾਡੀ ਸਿਰਲੇਖ]\nINTRO: [ਇੱਥੇ ਤੁਹਾਡੀ ਜਾਣ-ਪਛਾਣ]', - // Afrikaans - af: "Jy is 'n assistent wat tekste ontleed en opsom. Jou taak is om twee dinge vir die volgende teks te skep:\n1. 'n Kort, bondige opskrif (maksimum 8 woorde)\n2. 'n Kort inleiding wat die inhoud van die teks in 2-3 sinne opsom en die leser nuuskierig maak\n\nFormateer jou antwoord presies so:\nHEADLINE: [Jou opskrif hier]\nINTRO: [Jou inleiding hier]", - // Persisch/Farsi - fa: 'شما دستیاری هستید که متون را تجزیه و تحلیل و خلاصه می‌کند. وظیفه شما ایجاد دو چیز برای متن زیر است:\n1. یک عنوان کوتاه و مختصر (حداکثر 8 کلمه)\n2. یک مقدمه کوتاه که محتوای متن را در 2-3 جمله خلاصه کند و کنجکاوی خواننده را برانگیزد\n\nپاسخ خود را دقیقاً به این شکل قالب‌بندی کنید:\nHEADLINE: [عنوان شما اینجا]\nINTRO: [مقدمه شما اینجا]', - // Georgisch - ka: 'თქვენ ხართ ასისტენტი, რომელიც აანალიზებს და აჯამებს ტექსტებს. თქვენი ამოცანაა შემდეგი ტექსტისთვის ორი რამ შექმნათ:\n1. მოკლე, ლაკონური სათაური (მაქსიმუმ 8 სიტყვა)\n2. მოკლე შესავალი, რომელიც აჯამებს ტექსტის შინაარსს 2-3 წინადადებაში და აღძრავს მკითხველის ცნობისმოყვარეობას\n\nგააფორმეთ თქვენი პასუხი ზუსტად ასე:\nHEADLINE: [თქვენი სათაური აქ]\nINTRO: [თქვენი შესავალი აქ]', - // Isländisch - is: 'Þú ert aðstoðarmaður sem greinir og dregur saman texta. Verkefni þitt er að búa til tvö hluti fyrir eftirfarandi texta:\n1. Stuttan, hnitmiðaðan fyrirsögn (að hámarki 8 orð)\n2. Stutta inngang sem dregur saman efni textans í 2-3 setningum og vekur forvitni lesandans\n\nSníðdu svarið þitt nákvæmlega svona:\nHEADLINE: [Fyrirsögnin þín hér]\nINTRO: [Inngangurinn þinn hér]', - // Albanisch - sq: 'Ju jeni një asistent që analizon dhe përmbledh tekste. Detyra juaj është të krijoni dy gjëra për tekstin e mëposhtëm:\n1. Një titull të shkurtër dhe të përqendruar (maksimumi 8 fjalë)\n2. Një hyrje të shkurtër që përmbledh përmbajtjen e tekstit në 2-3 fjali dhe ngjall kuriozitenin e lexuesit\n\nFormatoni përgjigjen tuaj saktësisht kështu:\nHEADLINE: [Titulli juaj këtu]\nINTRO: [Hyrja juaj këtu]', - // Aserbaidschanisch - az: 'Siz mətnləri təhlil edən və xülasə çıxaran köməkçisiniz. Sizin vəzifəniz aşağıdakı mətn üçün iki şey yaratmaqdır:\n1. Qısa, dəqiq başlıq (maksimum 8 söz)\n2. Mətnin məzmununu 2-3 cümlədə xülasə edən və oxucunun marağını oyadan qısa giriş\n\nCavabınızı dəqiq belə formatlaşdırın:\nHEADLINE: [Başlığınız burada]\nINTRO: [Girişiniz burada]', - // Baskisch - eu: 'Testuak aztertzen eta laburbildu egiten dituen laguntzaile bat zara. Zure zeregina honako testuarentzat bi gauza sortzea da:\n1. Izenburua labur eta zehatza (gehienez 8 hitz)\n2. Testuaren edukia 2-3 esalditan laburbiltzen duen eta irakurlearen jakin-mina piztuko duen sarrera laburra\n\nErantzuna zehatz-mehatz honela formateatu:\nHEADLINE: [Zure izenburua hemen]\nINTRO: [Zure sarrera hemen]', - // Galizisch - gl: 'Es un asistente que analiza e resume textos. A túa tarefa é crear dúas cousas para o seguinte texto:\n1. Un título breve e conciso (máximo 8 palabras)\n2. Unha breve introdución que resuma o contido do texto en 2-3 frases e esperte a curiosidade do lector\n\nFormatea a túa resposta exactamente así:\nHEADLINE: [O teu título aquí]\nINTRO: [A túa introdución aquí]', - // Kasachisch - kk: 'Сіз мәтіндерді талдайтын және қорытындылайтын көмекшісіз. Сіздің міндетіңіз келесі мәтін үшін екі нәрсе жасау:\n1. Қысқа, нақты тақырып (ең көбі 8 сөз)\n2. Мәтін мазмұнын 2-3 сөйлемде қорытындылайтын және оқырманның қызығушылығын туғызатын қысқа кіріспе\n\nЖауабыңызды дәл осылай пішімдеңіз:\nHEADLINE: [Мұнда сіздің тақырыбыңыз]\nINTRO: [Мұнда сіздің кіріспеңіз]', - // Mazedonisch - mk: 'Вие сте асистент кој анализира и резимира текстови. Вашата задача е да создадете две работи за следниот текст:\n1. Краток, јасен наслов (максимум 8 зборови)\n2. Краток вовед кој го резимира содржината на текстот во 2-3 реченици и ја буди љубопитноста на читателот\n\nФорматирајте го вашиот одговор точно вака:\nHEADLINE: [Вашиот наслов тука]\nINTRO: [Вашиот вовед тука]', - // Serbisch - sr: 'Ви сте асистент који анализира и резимира текстове. Ваш задатак је да направите две ствари за следећи текст:\n1. Кратак, јасан наслов (максимум 8 речи)\n2. Кратак увод који резимира садржај текста у 2-3 реченице и буди радозналост читаоца\n\nФорматирајте ваш одговор тачно овако:\nHEADLINE: [Ваш наслов овде]\nINTRO: [Ваш увод овде]', - // Slowenisch - sl: 'Ste pomočnik, ki analizira in povzema besedila. Vaša naloga je ustvariti dve stvari za naslednje besedilo:\n1. Kratek, jedrnat naslov (največ 8 besed)\n2. Kratek uvod, ki povzema vsebino besedila v 2-3 stavkih in prebudi radovednost bralca\n\nOblikujte svoj odgovor natanko tako:\nHEADLINE: [Vaš naslov tukaj]\nINTRO: [Vaš uvod tukaj]', - // Maltesisch - mt: "Inti assistent li janalizza u jissommarja testi. Il-kompitu tiegħek huwa li toħloq żewġ affarijiet għat-test li ġej:\n1. Intestatura qasira u konċiza (massimu 8 kliem)\n2. Introduzzjoni qasira li tissommarja l-kontenut tat-test f'2-3 sentenzi u tqajjem il-kurżità tal-qarrej\n\nFormatja t-tweġiba tiegħek eżattament hekk:\nHEADLINE: [L-intestatura tiegħek hawn]\nINTRO: [L-introduzzjoni tiegħek hawn]", - // Armenisch - hy: 'Դուք օգնական եք, որը վերլուծում և ամփոփում է տեքստեր: Ձեր խնդիրն է ստեղծել երկու բան հետևյալ տեքստի համար:\n1. Կարճ, հակիրճ վերնագիր (առավելագույնը 8 բառ)\n2. Կարճ ներածություն, որը ամփոփում է տեքստի բովանդակությունը 2-3 նախադասությամբ և արթնացնում ընթերցողի հետաքրքրությունը\n\nՁևակերպեք ձեր պատասխանը հենց այսպես:\nHEADLINE: [Ձեր վերնագիրը այստեղ]\nINTRO: [Ձեր ներածությունը այստեղ]', - // Usbekisch - uz: "Siz matnlarni tahlil qiluvchi va xulosa chiqaruvchi yordamchisiz. Sizning vazifangiz quyidagi matn uchun ikki narsa yaratishdir:\n1. Qisqa, aniq sarlavha (maksimal 8 so'z)\n2. Matn mazmunini 2-3 jumlada xulosa qiladigan va o'quvchining qiziqishini uyg'otadigan qisqa kirish\n\nJavobingizni aynan shunday formatlang:\nHEADLINE: [Bu yerda sizning sarlavhangiz]\nINTRO: [Bu yerda sizning kirishingiz]", - // Irisch - ga: 'Is cúntóir thú a dhéanann anailís agus achoimre ar théacsanna. Is é do thasc dhá rud a chruthú don téacs seo a leanas:\n1. Ceannlíne ghearr, ghonta (8 bhfocal ar a mhéad)\n2. Réamhrá gearr a dhéanann achoimre ar ábhar an téacs i 2-3 abairt agus a spreagann fiosracht an léitheora\n\nFormáidigh do fhreagra díreach mar seo:\nHEADLINE: [Do cheannlíne anseo]\nINTRO: [Do réamhrá anseo]', - // Walisisch - cy: "Rydych chi'n gynorthwyydd sy'n dadansoddi ac yn crynhoi testunau. Eich tasg yw creu dau beth ar gyfer y testun canlynol:\n1. Pennawd byr, cryno (uchafswm o 8 gair)\n2. Cyflwyniad byr sy'n crynhoi cynnwys y testun mewn 2-3 brawddeg ac yn ennyn chwilfrydedd y darllenydd\n\nFformatiwch eich ateb yn union fel hyn:\nHEADLINE: [Eich pennawd yma]\nINTRO: [Eich cyflwyniad yma]", - // Filipino - fil: 'Ikaw ay isang katulong na nag-aanalisa at bumubuod ng mga teksto. Ang iyong gawain ay lumikha ng dalawang bagay para sa sumusunod na teksto:\n1. Maikling, malinaw na pamagat (hindi hihigit sa 8 salita)\n2. Maikling panimula na bumubuod sa nilalaman ng teksto sa 2-3 pangungusap at nakakagising ng kuryosidad ng mambabasa\n\nI-format ang iyong sagot nang eksakto tulad nito:\nHEADLINE: [Ang iyong pamagat dito]\nINTRO: [Ang iyong panimula dito]', - }, -}; -/** - * Hilfsfunktion zum Abrufen des Headline-Prompts für eine bestimmte Sprache - * @param language Sprache (z.B. 'de', 'en', 'fr') - * @returns Headline-Prompt für die angegebene Sprache oder Fallback - */ export function getHeadlinePrompt(language) { - const lang = language.toLowerCase().split('-')[0]; // z.B. 'de-DE' -> 'de' - // Versuche spezifische Sprache, dann Deutsch, dann Englisch, dann erste verfügbare - return ( - SYSTEM_PROMPTS.headline[lang] || - SYSTEM_PROMPTS.headline['de'] || - SYSTEM_PROMPTS.headline['en'] || - Object.values(SYSTEM_PROMPTS.headline)[0] || - 'You are an assistant that analyzes and summarizes texts.' - ); -} diff --git a/apps/memoro/apps/backend/src/ai/headline/headline.service.ts b/apps/memoro/apps/backend/src/ai/headline/headline.service.ts deleted file mode 100644 index bf9fd28fc..000000000 --- a/apps/memoro/apps/backend/src/ai/headline/headline.service.ts +++ /dev/null @@ -1,239 +0,0 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import { createClient } from '@supabase/supabase-js'; -import { AiService } from '../ai.service'; -import { AI_PRESETS } from '../ai-model.config'; -import { SYSTEM_PROMPTS } from './headline.prompts'; - -@Injectable() -export class HeadlineService { - private readonly logger = new Logger(HeadlineService.name); - private readonly supabaseUrl: string; - private readonly supabaseServiceKey: string; - - constructor( - private aiService: AiService, - private configService: ConfigService - ) { - this.supabaseUrl = this.configService.get('MEMORO_SUPABASE_URL', ''); - this.supabaseServiceKey = this.configService.get('MEMORO_SUPABASE_SERVICE_KEY', ''); - } - - /** - * Generiert Headline + Intro aus einem Transkript. - */ - async generateHeadlineAndIntro( - transcript: string, - language = 'de' - ): Promise<{ headline: string; intro: string }> { - const prompt = this.buildPrompt(transcript, language); - - try { - const content = await this.aiService.generateText(prompt, AI_PRESETS.headline); - - const result = this.parseResponse(content); - this.logger.debug(`Headline generated: "${result.headline}" (lang=${language})`); - return result; - } catch (error) { - this.logger.error( - `Headline generation failed: ${error instanceof Error ? error.message : error}` - ); - return { headline: 'Neue Aufnahme', intro: 'Keine Zusammenfassung verfügbar.' }; - } - } - - /** - * Vollständige Pipeline: Memo laden → Headline generieren → Memo updaten → Broadcast senden. - */ - async processHeadlineForMemo(memoId: string): Promise<{ headline: string; intro: string }> { - const supabase = createClient(this.supabaseUrl, this.supabaseServiceKey); - - // Set processing status - await this.setProcessingStatus(supabase, memoId, 'processing'); - - try { - // Memo laden - const { data: memo, error: memoError } = await supabase - .from('memos') - .select('*') - .eq('id', memoId) - .single(); - - if (memoError || !memo) { - throw new Error(`Memo not found: ${memoError?.message || 'unknown'}`); - } - - // Transkript extrahieren - const transcript = this.extractTranscript(memo); - if (!transcript) { - await this.setErrorStatus(supabase, memoId, 'Kein Transkript im Memo gefunden'); - throw new Error('No transcript found in memo'); - } - - // Sprache ermitteln - const language = this.detectLanguage(memo); - - // Headline generieren - const { headline, intro } = await this.generateHeadlineAndIntro(transcript, language); - - // Memo updaten - const { error: updateError } = await supabase - .from('memos') - .update({ - title: headline, - intro, - updated_at: new Date().toISOString(), - }) - .eq('id', memoId); - - if (updateError) { - throw new Error(`Memo update failed: ${updateError.message}`); - } - - // Broadcast senden (fire & forget) - this.sendBroadcast(supabase, memoId, headline, intro).catch((err) => - this.logger.warn(`Broadcast failed for memo ${memoId}: ${err}`) - ); - - // Status auf completed setzen - await this.setCompletedStatus(supabase, memoId, { headline, intro, language }); - - this.logger.log(`Headline processed for memo ${memoId}: "${headline}"`); - return { headline, intro }; - } catch (error) { - const msg = error instanceof Error ? error.message : String(error); - await this.setErrorStatus(supabase, memoId, msg); - throw error; - } - } - - // ── Private Helpers ── - - private buildPrompt(transcript: string, language: string): string { - const baseLanguage = language.split('-')[0].toLowerCase(); - const systemPrompt = - SYSTEM_PROMPTS.headline[baseLanguage] || - SYSTEM_PROMPTS.headline['de'] || - SYSTEM_PROMPTS.headline['en']; - return `${systemPrompt}\n\n${transcript}`; - } - - private parseResponse(content: string): { headline: string; intro: string } { - const headlineMatch = content.match(/HEADLINE:\s*(.+?)(?=\nINTRO:|$)/s); - const introMatch = content.match(/INTRO:\s*(.+?)$/s); - return { - headline: headlineMatch?.[1]?.trim() || 'Neue Aufnahme', - intro: introMatch?.[1]?.trim() || 'Keine Zusammenfassung verfügbar.', - }; - } - - private extractTranscript(memo: any): string { - // Utterances (bevorzugt) - if (memo.source?.utterances?.length > 0) { - return [...memo.source.utterances] - .sort((a: any, b: any) => (a.offset || 0) - (b.offset || 0)) - .map((u: any) => u.text) - .filter(Boolean) - .join(' '); - } - - // Direkte Transkript-Felder - if (memo.transcript) return memo.transcript; - if (memo.source?.transcript) return memo.source.transcript; - if (memo.source?.content) return memo.source.content; - - // Kombinierte Aufnahmen - if (memo.source?.type === 'combined' && memo.source?.additional_recordings) { - return memo.source.additional_recordings - .map((rec: any) => { - if (rec.utterances?.length > 0) { - return [...rec.utterances] - .sort((a: any, b: any) => (a.offset || 0) - (b.offset || 0)) - .map((u: any) => u.text) - .filter(Boolean) - .join(' '); - } - return rec.transcript || ''; - }) - .filter(Boolean) - .join('\n\n'); - } - - return ''; - } - - private detectLanguage(memo: any): string { - if (memo.source?.primary_language) return memo.source.primary_language; - if (memo.source?.languages?.[0]) return memo.source.languages[0]; - if (memo.metadata?.primary_language) return memo.metadata.primary_language; - return 'de'; - } - - private async setProcessingStatus(supabase: any, memoId: string, status: string): Promise { - try { - await supabase.rpc('set_memo_process_status', { - p_memo_id: memoId, - p_process_name: 'headline_and_intro', - p_status: status, - p_timestamp: new Date().toISOString(), - }); - } catch (err) { - this.logger.error(`Failed to set processing status for ${memoId}: ${err}`); - } - } - - private async setCompletedStatus(supabase: any, memoId: string, details: any): Promise { - try { - await supabase.rpc('set_memo_process_status_with_details', { - p_memo_id: memoId, - p_process_name: 'headline_and_intro', - p_status: 'completed', - p_timestamp: new Date().toISOString(), - p_details: details, - }); - } catch (err) { - this.logger.error(`Failed to set completed status for ${memoId}: ${err}`); - } - } - - private async setErrorStatus(supabase: any, memoId: string, errorMsg: string): Promise { - try { - await supabase.rpc('set_memo_process_error', { - p_memo_id: memoId, - p_process_name: 'headline_and_intro', - p_timestamp: new Date().toISOString(), - p_reason: errorMsg, - p_details: null, - }); - } catch (err) { - this.logger.error(`Failed to set error status for ${memoId}: ${err}`); - } - } - - private async sendBroadcast( - supabase: any, - memoId: string, - headline: string, - intro: string - ): Promise { - const channel = supabase.channel(`memo-updates-${memoId}`); - await new Promise((resolve) => { - channel.subscribe(async (status: string) => { - if (status === 'SUBSCRIBED') { - await channel.send({ - type: 'broadcast', - event: 'memo-updated', - payload: { - type: 'memo-updated', - memoId, - changes: { title: headline, intro, updated_at: new Date().toISOString() }, - source: 'headline-ai-service', - }, - }); - supabase.removeChannel(channel); - resolve(); - } - }); - }); - } -} diff --git a/apps/memoro/apps/backend/src/ai/memory/memory.prompts.ts b/apps/memoro/apps/backend/src/ai/memory/memory.prompts.ts deleted file mode 100644 index 097016282..000000000 --- a/apps/memoro/apps/backend/src/ai/memory/memory.prompts.ts +++ /dev/null @@ -1,75 +0,0 @@ -/** - * System-Prompts für die Memory-Erstellung in verschiedenen Sprachen - * - * Die Prompts werden als System-Prompt für die AI-Nachrichten verwendet, - * um konsistente und hilfreiche Antworten zu generieren. - */ /** - * Interface für die Prompt-Konfiguration - */ /** - * System-Prompts für die Memory-Erstellung - * - * Unterstützte Sprachen: - * - de: Deutsch - * - en: Englisch - * - fr: Französisch - * - es: Spanisch - * - it: Italienisch - * - nl: Niederländisch - * - pt: Portugiesisch - * - ru: Russisch - * - ja: Japanisch - * - ko: Koreanisch - * - zh: Chinesisch - * - ar: Arabisch - * - hi: Hindi - * - tr: Türkisch - * - pl: Polnisch - */ export const SYSTEM_PROMPTS = { - system: { - // Deutsch - de: 'Du bist ein hilfreicher Assistent, der Texte analysiert und verarbeitet. Deine Aufgabe ist es, Transkripte von Gesrpächen gemäß den gegebenen Anweisungen zu bearbeiten. Antworte präzise, strukturiert und hilfreich. Antworte in plain text.', - // Englisch - en: 'You are a helpful assistant that analyzes and processes texts. Your task is to process transcripts of conversations according to the given instructions. Respond precisely, structured, and helpfully. Respond in plain text.', - // Französisch - fr: 'Vous êtes un assistant utile qui analyse et traite les textes. Votre tâche est de traiter les transcriptions de conversations selon les instructions données. Répondez de manière précise, structurée et utile. Répondez en texte brut.', - // Spanisch - es: 'Eres un asistente útil que analiza y procesa textos. Tu tarea es procesar transcripciones de conversaciones según las instrucciones dadas. Responde de forma precisa, estructurada y útil. Responde en texto plano.', - // Italienisch - it: 'Sei un assistente utile che analizza e elabora testi. Il tuo compito è elaborare trascrizioni di conversazioni secondo le istruzioni date. Rispondi in modo preciso, strutturato e utile. Rispondi in testo semplice.', - // Niederländisch - nl: 'Je bent een behulpzame assistent die teksten analyseert en verwerkt. Je taak is om transcripties van gesprekken te verwerken volgens de gegeven instructies. Antwoord precies, gestructureerd en behulpzaam. Antwoord in platte tekst.', - // Portugiesisch - pt: 'Você é um assistente útil que analisa e processa textos. Sua tarefa é processar transcrições de conversas de acordo com as instruções dadas. Responda de forma precisa, estruturada e útil. Responda em texto simples.', - // Russisch - ru: 'Вы полезный помощник, который анализирует и обрабатывает тексты. Ваша задача - обрабатывать расшифровки разговоров согласно данным инструкциям. Отвечайте точно, структурированно и полезно. Отвечайте простым текстом.', - // Japanisch - ja: 'あなたはテキストを分析・処理する有用なアシスタントです。あなたの仕事は、与えられた指示に従って会話の転写を処理することです。正確で構造化された有用な回答をしてください。プレーンテキストで回答してください。', - // Koreanisch - ko: '당신은 텍스트를 분석하고 처리하는 유용한 어시스턴트입니다. 당신의 임무는 주어진 지시에 따라 대화의 전사본을 처리하는 것입니다. 정확하고 구조화되며 도움이 되는 방식으로 응답하세요. 일반 텍스트로 응답하세요.', - // Chinesisch (vereinfacht) - zh: '你是一个有用的助手,负责分析和处理文本。你的任务是根据给定的指令处理对话的转录。请准确、结构化、有帮助地回答。请用纯文本回答。', - // Arabisch - ar: 'أنت مساعد مفيد يحلل ويعالج النصوص. مهمتك هي معالجة نسخ المحادثات وفقاً للتعليمات المقدمة. أجب بدقة وبطريقة منظمة ومفيدة. أجب بنص عادي.', - // Hindi - hi: 'आप एक उपयोगी सहायक हैं जो पाठों का विश्लेषण और प्रसंस्करण करते हैं। आपका कार्य दिए गए निर्देशों के अनुसार बातचीत के प्रतिलेख को संसाधित करना है। सटीक, संरचित और सहायक तरीके से उत्तर दें। सादे पाठ में उत्तर दें।', - // Türkisch - tr: 'Metinleri analiz eden ve işleyen yararlı bir asistansınız. Göreviniz, verilen talimatlara göre konuşma transkriptlerini işlemektir. Kesin, yapılandırılmış ve yararlı şekilde yanıt verin. Düz metin olarak yanıt verin.', - // Polnisch - pl: 'Jesteś pomocnym asystentem, który analizuje i przetwarza teksty. Twoim zadaniem jest przetwarzanie transkrypcji rozmów zgodnie z podanymi instrukcjami. Odpowiadaj precyzyjnie, uporządkowanie i pomocnie. Odpowiadaj zwykłym tekstem.', - }, -}; -/** - * Hilfsfunktion zum Abrufen des System-Prompts für eine bestimmte Sprache - * @param language Sprache (z.B. 'de', 'en', 'fr') - * @returns System-Prompt für die angegebene Sprache oder Fallback - */ export function getSystemPrompt(language) { - const lang = language.toLowerCase().split('-')[0]; // z.B. 'de-DE' -> 'de' - // Versuche spezifische Sprache, dann Deutsch, dann Englisch, dann erste verfügbare - return ( - SYSTEM_PROMPTS.system[lang] || - SYSTEM_PROMPTS.system['de'] || - SYSTEM_PROMPTS.system['en'] || - Object.values(SYSTEM_PROMPTS.system)[0] || - 'You are a helpful AI assistant.' - ); -} diff --git a/apps/memoro/apps/backend/src/ai/memory/memory.service.ts b/apps/memoro/apps/backend/src/ai/memory/memory.service.ts deleted file mode 100644 index 1fb574a58..000000000 --- a/apps/memoro/apps/backend/src/ai/memory/memory.service.ts +++ /dev/null @@ -1,138 +0,0 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import { createClient } from '@supabase/supabase-js'; -import { AiService } from '../ai.service'; -import { AI_PRESETS } from '../ai-model.config'; -import { getTranscriptText } from '../shared/transcript-utils'; -import { UserPromptService } from '../shared/user-prompt.service'; - -@Injectable() -export class MemoryService { - private readonly logger = new Logger(MemoryService.name); - private readonly supabaseUrl: string; - private readonly supabaseServiceKey: string; - - constructor( - private aiService: AiService, - private userPromptService: UserPromptService, - private configService: ConfigService - ) { - this.supabaseUrl = this.configService.get('MEMORO_SUPABASE_URL', ''); - this.supabaseServiceKey = this.configService.get('MEMORO_SUPABASE_SERVICE_KEY', ''); - } - - /** - * Erstellt eine Memory für ein Memo mit einem spezifischen Prompt. - * Repliziert die create-memory Edge Function. - */ - async createMemory( - memoId: string, - promptId: string - ): Promise<{ memoryId: string; title: string; content: string }> { - const supabase = createClient(this.supabaseUrl, this.supabaseServiceKey); - - // Memo laden - const { data: memo, error: memoError } = await supabase - .from('memos') - .select('*') - .eq('id', memoId) - .single(); - - if (memoError || !memo) { - throw new Error(`Memo not found: ${memoError?.message || 'unknown'}`); - } - - // Prompt laden - const { data: prompt, error: promptError } = await supabase - .from('prompts') - .select('*') - .eq('id', promptId) - .single(); - - if (promptError || !prompt) { - throw new Error(`Prompt not found: ${promptError?.message || 'unknown'}`); - } - - // Transkript extrahieren - const transcript = getTranscriptText(memo); - if (!transcript) { - throw new Error('No transcript found in memo'); - } - - // Sprache ermitteln - const primaryLanguage = memo.source?.primary_language || memo.source?.languages?.[0]; - const baseLang = primaryLanguage ? primaryLanguage.split('-')[0].toLowerCase() : 'de'; - - // Prompt-Text extrahieren (mehrsprachig) - let promptText = this.getLocalizedText(prompt.prompt_text, baseLang); - if (!promptText) { - throw new Error(`No prompt text found for prompt ${promptId}`); - } - - // System Pre-Prompt voranstellen (User-spezifisch oder Default) - const prePrompt = await this.userPromptService.getSystemPromptForMemo(memo.user_id, baseLang); - if (prePrompt) { - promptText = `${prePrompt}\n\n${promptText}`; - } - - // Memory-Titel extrahieren - const memoryTitle = this.getLocalizedText(prompt.memory_title, baseLang) || 'Memory'; - - // Prompt mit Transkript zusammenbauen - const fullPrompt = promptText.includes('{transcript}') - ? promptText.replace('{transcript}', transcript) - : `${promptText}\n\nText: ${transcript}`; - - // AI-Antwort generieren - const answer = await this.aiService.generateText(fullPrompt, AI_PRESETS.memory); - - if (!answer) { - throw new Error('No response from AI'); - } - - // Sort-Order ermitteln - const { data: maxSortData } = await supabase - .from('memories') - .select('sort_order') - .eq('memo_id', memoId) - .order('sort_order', { ascending: false }) - .limit(1) - .single(); - - const nextSortOrder = maxSortData?.sort_order - ? maxSortData.sort_order + 1 - : Math.floor(Math.random() * 5000) + 5000; - - // Memory speichern - const { data: newMemory, error: insertError } = await supabase - .from('memories') - .insert({ - memo_id: memoId, - title: memoryTitle, - content: answer, - media: null, - sort_order: nextSortOrder, - metadata: { - type: 'manual_prompt', - prompt_id: promptId, - created_by: 'ai_memory_service', - }, - }) - .select() - .single(); - - if (insertError) { - throw new Error(`Failed to create memory: ${insertError.message}`); - } - - this.logger.log(`Memory created: ${newMemory.id} for memo ${memoId} (prompt: ${promptId})`); - return { memoryId: newMemory.id, title: memoryTitle, content: answer }; - } - - private getLocalizedText(textObj: any, lang: string): string { - if (!textObj || typeof textObj !== 'object') return ''; - return ( - textObj[lang] || textObj['de'] || textObj['en'] || (Object.values(textObj)[0] as string) || '' - ); - } -} diff --git a/apps/memoro/apps/backend/src/ai/memory/question.service.ts b/apps/memoro/apps/backend/src/ai/memory/question.service.ts deleted file mode 100644 index 8890c8183..000000000 --- a/apps/memoro/apps/backend/src/ai/memory/question.service.ts +++ /dev/null @@ -1,195 +0,0 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import { createClient } from '@supabase/supabase-js'; -import { AiService } from '../ai.service'; -import { AI_PRESETS } from '../ai-model.config'; -import { UserPromptService } from '../shared/user-prompt.service'; - -@Injectable() -export class QuestionService { - private readonly logger = new Logger(QuestionService.name); - private readonly supabaseUrl: string; - private readonly supabaseServiceKey: string; - - constructor( - private aiService: AiService, - private userPromptService: UserPromptService, - private configService: ConfigService - ) { - this.supabaseUrl = this.configService.get('MEMORO_SUPABASE_URL', ''); - this.supabaseServiceKey = this.configService.get('MEMORO_SUPABASE_SERVICE_KEY', ''); - } - - /** - * Beantwortet eine Frage zu einem Memo und speichert die Antwort als Memory. - * Repliziert die question-memo Edge Function. - */ - async askQuestion( - memoId: string, - question: string - ): Promise<{ memoryId: string; question: string; answer: string }> { - const supabase = createClient(this.supabaseUrl, this.supabaseServiceKey); - - // Memo laden - const { data: memo, error: memoError } = await supabase - .from('memos') - .select('*') - .eq('id', memoId) - .single(); - - if (memoError || !memo) { - throw new Error(`Memo not found: ${memoError?.message || 'unknown'}`); - } - - // Kontext-Informationen extrahieren - const contextInfo = this.extractContextInfo(memo.source, memo.metadata); - if (!contextInfo.transcript) { - throw new Error('No transcript found in memo'); - } - - // Sprache ermitteln - const primaryLanguage = memo.source?.primary_language || memo.source?.languages?.[0]; - const baseLang = primaryLanguage ? primaryLanguage.split('-')[0].toLowerCase() : 'de'; - - // System-Prompt laden (User-spezifisch oder Default) - const prePrompt = await this.userPromptService.getSystemPromptForMemo(memo.user_id, baseLang); - - // Prompt zusammenbauen - const prompt = this.buildQuestionPrompt(question, contextInfo, prePrompt); - - // AI-Antwort generieren - const answer = await this.aiService.generateText(prompt, AI_PRESETS.memory); - - if (!answer) { - throw new Error('No response from AI'); - } - - // Sort-Order ermitteln (Q&A range: 200-299) - const { data: maxSortData } = await supabase - .from('memories') - .select('sort_order') - .eq('memo_id', memoId) - .order('sort_order', { ascending: false }) - .limit(1) - .single(); - - const nextSortOrder = maxSortData?.sort_order ? maxSortData.sort_order + 1 : 200; - - // Memory speichern - const { data: newMemory, error: insertError } = await supabase - .from('memories') - .insert({ - memo_id: memoId, - title: question, - content: answer, - media: null, - sort_order: nextSortOrder, - metadata: { - type: 'question', - question, - created_by: 'ai_question_service', - }, - }) - .select() - .single(); - - if (insertError) { - throw new Error(`Failed to create memory: ${insertError.message}`); - } - - this.logger.log(`Question answered for memo ${memoId}: "${question.substring(0, 50)}..."`); - return { memoryId: newMemory.id, question, answer }; - } - - private buildQuestionPrompt(question: string, contextInfo: any, prePrompt: string): string { - const contextParts: string[] = []; - - if (contextInfo.locationName) { - contextParts.push(`Aufnahmeort: ${contextInfo.locationName}`); - } else if (contextInfo.locationAddress) { - contextParts.push(`Aufnahmeort: ${contextInfo.locationAddress}`); - } - - const statsInfo: string[] = []; - if (contextInfo.hasMultipleSpeakers) { - statsInfo.push(`${contextInfo.speakerCount} Sprecher`); - } - statsInfo.push(`${Math.round(contextInfo.duration)}s Dauer`); - if (contextInfo.wordCount) { - statsInfo.push(`${contextInfo.wordCount} Wörter`); - } - contextParts.push(`Audio-Info: ${statsInfo.join(', ')}`); - - const contextFooter = - contextParts.length > 0 - ? `\n\nZusätzliche Kontext-Informationen:\n${contextParts.join('\n')}` - : ''; - - const userPrompt = `Frage: ${question}\n\nTranskript:\n${contextInfo.transcript}${contextFooter}\n\n${contextInfo.hasMultipleSpeakers ? 'Du kannst bei Bedarf auf spezifische Sprecher verweisen.' : ''}`; - - return prePrompt ? `${prePrompt}\n\n${userPrompt}` : userPrompt; - } - - private extractContextInfo(source: any, metadata: any = {}): any { - const transcript = this.formatTranscriptWithSpeakers(source); - - let speakerCount = 0; - let totalDuration = 0; - const language = source?.primary_language || source?.languages?.[0] || 'unbekannt'; - - if (source?.type === 'combined' && source?.additional_recordings) { - const allSpeakers = new Set(); - for (const rec of source.additional_recordings) { - if (rec.speakers) { - Object.keys(rec.speakers).forEach((id) => allSpeakers.add(id)); - } - if (rec.duration) totalDuration += rec.duration; - } - speakerCount = allSpeakers.size; - totalDuration = source.duration || totalDuration; - } else { - speakerCount = source?.speakers ? Object.keys(source.speakers).length : 0; - totalDuration = source?.duration || 0; - } - - return { - transcript, - duration: metadata?.stats?.audioDuration || totalDuration, - speakerCount, - wordCount: metadata?.stats?.wordCount || null, - language, - locationName: metadata?.location?.address?.name || null, - locationAddress: metadata?.location?.address?.formattedAddress || null, - hasMultipleSpeakers: speakerCount > 1, - hasLocation: !!( - metadata?.location?.address?.name || metadata?.location?.address?.formattedAddress - ), - }; - } - - private formatTranscriptWithSpeakers(source: any): string { - if (source?.type === 'combined' && source?.additional_recordings?.length > 0) { - const transcripts = source.additional_recordings - .map((rec: any) => { - if (rec.utterances?.length > 0) { - return rec.speakers - ? rec.utterances - .map((u: any) => `${rec.speakers[u.speakerId] || u.speakerId}: ${u.text}`) - .join('\n') - : rec.utterances.map((u: any) => u.text).join(' '); - } - return rec.transcript || rec.content || rec.transcription || ''; - }) - .filter(Boolean); - if (transcripts.length > 0) return transcripts.join('\n\n--- Nächstes Memo ---\n\n'); - } - - if (source?.utterances?.length > 0 && source?.speakers) { - return source.utterances - .map((u: any) => `${source.speakers[u.speakerId] || u.speakerId}: ${u.text}`) - .join('\n'); - } - - return source?.transcript || source?.content || source?.transcription || ''; - } -} diff --git a/apps/memoro/apps/backend/src/ai/shared/system-prompts.ts b/apps/memoro/apps/backend/src/ai/shared/system-prompts.ts deleted file mode 100644 index 2f5eb38b1..000000000 --- a/apps/memoro/apps/backend/src/ai/shared/system-prompts.ts +++ /dev/null @@ -1,199 +0,0 @@ -/** - * Root System Prompts für alle Edge Functions - * - * Diese Prompts werden als Basis für alle Text-Analyse und Verarbeitungsfunktionen verwendet. - * Jede Sprache hat ihren eigenen Prompt, der die spezifischen Anforderungen berücksichtigt. - */ - -export const ROOT_SYSTEM_PROMPTS = { - PRE_PROMPT: { - // Deutsch - de: 'Du bist ein hilfreicher Assistent, der Texte analysiert und verarbeitet. Deine Aufgabe ist es, Transkripte von Gesprächen gemäß den gegebenen Anweisungen zu bearbeiten. Antworte in Markdown mit einem schönen Format. Nutze keine Tabellen und keinen Code in Markdown. Antworte präzise, strukturiert und hilfreich.', - - // Englisch - en: 'You are a helpful assistant that analyzes and processes texts. Your task is to process conversation transcripts according to the given instructions. Respond in Markdown with a nice format. Do not use tables or code in Markdown. Respond precisely, structured, and helpfully.', - - // Französisch - fr: "Vous êtes un assistant utile qui analyse et traite les textes. Votre tâche est de traiter les transcriptions de conversations selon les instructions données. Répondez en Markdown avec un beau format. N'utilisez pas de tableaux ou de code en Markdown. Répondez de manière précise, structurée et utile.", - - // Spanisch - es: 'Eres un asistente útil que analiza y procesa textos. Tu tarea es procesar transcripciones de conversaciones según las instrucciones dadas. Responde en Markdown con un formato atractivo. No uses tablas o código en Markdown. Responde de manera precisa, estructurada y útil.', - - // Italienisch - it: 'Sei un assistente utile che analizza ed elabora testi. Il tuo compito è elaborare trascrizioni di conversazioni secondo le istruzioni fornite. Rispondi in Markdown con un bel formato. Non usare tabelle o codice in Markdown. Rispondi in modo preciso, strutturato e utile.', - - // Niederländisch - nl: 'Je bent een behulpzame assistent die teksten analyseert en verwerkt. Je taak is om transcripties van gesprekken te verwerken volgens de gegeven instructies. Antwoord in Markdown met een mooi formaat. Gebruik geen tabellen of code in Markdown. Antwoord precies, gestructureerd en behulpzaam.', - - // Portugiesisch - pt: 'Você é um assistente útil que analisa e processa textos. Sua tarefa é processar transcrições de conversas de acordo com as instruções fornecidas. Responda em Markdown com um formato bonito. Não use tabelas ou código em Markdown. Responda de forma precisa, estruturada e útil.', - - // Russisch - ru: 'Вы полезный помощник, который анализирует и обрабатывает тексты. Ваша задача - обрабатывать расшифровки разговоров в соответствии с данными инструкциями. Отвечайте в Markdown с красивым форматированием. Не используйте таблицы или код в Markdown. Отвечайте точно, структурированно и полезно.', - - // Japanisch - ja: 'あなたはテキストを分析し処理する有用なアシスタントです。あなたの仕事は、与えられた指示に従って会話の文字起こしを処理することです。Markdownで美しいフォーマットで回答してください。Markdownでテーブルやコードを使用しないでください。正確で、構造化され、役立つように回答してください。', - - // Koreanisch - ko: '당신은 텍스트를 분석하고 처리하는 유용한 어시스턴트입니다. 당신의 임무는 주어진 지시에 따라 대화 녹취록을 처리하는 것입니다. 멋진 형식의 Markdown으로 응답하세요. Markdown에서 표나 코드를 사용하지 마세요. 정확하고 구조화되며 도움이 되도록 응답하세요.', - - // Chinesisch - zh: '你是一个有用的助手,分析和处理文本。你的任务是根据给定的指示处理对话记录。以优美的Markdown格式回复。不要在Markdown中使用表格或代码。回复要准确、有条理、有帮助。', - - // Arabisch - ar: 'أنت مساعد مفيد يحلل ويعالج النصوص. مهمتك هي معالجة نصوص المحادثات وفقًا للتعليمات المعطاة. أجب بتنسيق Markdown جميل. لا تستخدم الجداول أو الكود في Markdown. أجب بدقة وبشكل منظم ومفيد.', - - // Hindi - hi: 'आप एक सहायक सहायक हैं जो ग्रंथों का विश्लेषण और प्रसंस्करण करते हैं। आपका कार्य दिए गए निर्देशों के अनुसार वार्तालाप प्रतिलेखों को संसाधित करना है। एक अच्छे प्रारूप के साथ Markdown में उत्तर दें। Markdown में तालिकाओं या कोड का उपयोग न करें। सटीक, संरचित और सहायक रूप से उत्तर दें।', - - // Türkisch - tr: "Metinleri analiz eden ve işleyen yardımcı bir asistansınız. Göreviniz, verilen talimatlara göre konuşma transkriptlerini işlemektir. Güzel bir formatla Markdown'da yanıt verin. Markdown'da tablo veya kod kullanmayın. Kesin, yapılandırılmış ve yararlı bir şekilde yanıt verin.", - - // Polnisch - pl: 'Jesteś pomocnym asystentem, który analizuje i przetwarza teksty. Twoim zadaniem jest przetwarzanie transkrypcji rozmów zgodnie z podanymi instrukcjami. Odpowiadaj w Markdown z ładnym formatowaniem. Nie używaj tabel ani kodu w Markdown. Odpowiadaj precyzyjnie, strukturalnie i pomocnie.', - - // Dänisch - da: 'Du er en hjælpsom assistent, der analyserer og behandler tekster. Din opgave er at behandle samtaleudskrifter i henhold til de givne instruktioner. Svar i Markdown med et pænt format. Brug ikke tabeller eller kode i Markdown. Svar præcist, struktureret og hjælpsomt.', - - // Schwedisch - sv: 'Du är en hjälpsam assistent som analyserar och bearbetar texter. Din uppgift är att bearbeta samtalstranskriptioner enligt givna instruktioner. Svara i Markdown med ett snyggt format. Använd inte tabeller eller kod i Markdown. Svara exakt, strukturerat och hjälpsamt.', - - // Norwegisch - nb: 'Du er en hjelpsom assistent som analyserer og behandler tekster. Din oppgave er å behandle samtaletranskripsjoner i henhold til gitte instruksjoner. Svar i Markdown med et pent format. Ikke bruk tabeller eller kode i Markdown. Svar presist, strukturert og hjelpsomt.', - - // Finnisch - fi: 'Olet hyödyllinen avustaja, joka analysoi ja käsittelee tekstejä. Tehtäväsi on käsitellä keskustelulitterointeja annettujen ohjeiden mukaisesti. Vastaa Markdownissa kauniilla muotoilulla. Älä käytä taulukoita tai koodia Markdownissa. Vastaa tarkasti, jäsennellysti ja avuliaasti.', - - // Tschechisch - cs: 'Jste užitečný asistent, který analyzuje a zpracovává texty. Vaším úkolem je zpracovávat přepisy konverzací podle daných pokynů. Odpovězte v Markdownu s pěkným formátováním. Nepoužívejte tabulky nebo kód v Markdownu. Odpovězte přesně, strukturovaně a užitečně.', - - // Ungarisch - hu: 'Ön egy hasznos asszisztens, aki szövegeket elemez és dolgoz fel. Az Ön feladata a beszélgetések átiratainak feldolgozása a megadott utasítások szerint. Válaszoljon Markdownban szép formázással. Ne használjon táblázatokat vagy kódot a Markdownban. Válaszoljon pontosan, strukturáltan és hasznossan.', - - // Griechisch - el: 'Είστε ένας χρήσιμος βοηθός που αναλύει και επεξεργάζεται κείμενα. Το καθήκον σας είναι να επεξεργάζεστε μεταγραφές συνομιλιών σύμφωνα με τις δοθείσες οδηγίες. Απαντήστε σε Markdown με όμορφη μορφοποίηση. Μην χρησιμοποιείτε πίνακες ή κώδικα στο Markdown. Απαντήστε με ακρίβεια, δομημένα και χρήσιμα.', - - // Hebräisch - he: 'אתה עוזר מועיל שמנתח ומעבד טקסטים. המשימה שלך היא לעבד תמלילי שיחות בהתאם להוראות שניתנו. הגב ב-Markdown עם עיצוב יפה. אל תשתמש בטבלאות או קוד ב-Markdown. הגב בצורה מדויקת, מובנית ומועילה.', - - // Indonesisch - id: 'Anda adalah asisten yang membantu menganalisis dan memproses teks. Tugas Anda adalah memproses transkrip percakapan sesuai dengan instruksi yang diberikan. Tanggapi dalam Markdown dengan format yang bagus. Jangan gunakan tabel atau kode dalam Markdown. Tanggapi dengan tepat, terstruktur, dan bermanfaat.', - - // Thai - th: 'คุณเป็นผู้ช่วยที่มีประโยชน์ที่วิเคราะห์และประมวลผลข้อความ งานของคุณคือประมวลผลบทสนทนาตามคำแนะนำที่กำหนด ตอบกลับใน Markdown ด้วยรูปแบบที่สวยงาม อย่าใช้ตารางหรือโค้ดใน Markdown ตอบกลับอย่างแม่นยำ มีโครงสร้าง และเป็นประโยชน์', - - // Vietnamesisch - vi: 'Bạn là một trợ lý hữu ích phân tích và xử lý văn bản. Nhiệm vụ của bạn là xử lý bản ghi cuộc trò chuyện theo hướng dẫn đã cho. Trả lời bằng Markdown với định dạng đẹp. Không sử dụng bảng hoặc mã trong Markdown. Trả lời chính xác, có cấu trúc và hữu ích.', - - // Ukrainisch - uk: 'Ви корисний помічник, який аналізує та обробляє тексти. Ваше завдання - обробляти розшифровки розмов відповідно до наданих інструкцій. Відповідайте в Markdown з гарним форматуванням. Не використовуйте таблиці або код у Markdown. Відповідайте точно, структуровано та корисно.', - - // Rumänisch - ro: 'Sunteți un asistent util care analizează și procesează texte. Sarcina dvs. este să procesați transcrierile conversațiilor conform instrucțiunilor date. Răspundeți în Markdown cu un format frumos. Nu utilizați tabele sau cod în Markdown. Răspundeți precis, structurat și util.', - - // Bulgarisch - bg: 'Вие сте полезен асистент, който анализира и обработва текстове. Вашата задача е да обработвате транскрипции на разговори според дадените инструкции. Отговорете в Markdown с красив формат. Не използвайте таблици или код в Markdown. Отговорете точно, структурирано и полезно.', - - // Katalanisch - ca: 'Ets un assistent útil que analitza i processa textos. La teva tasca és processar transcripcions de converses segons les instruccions donades. Respon en Markdown amb un format bonic. No utilitzis taules o codi en Markdown. Respon de manera precisa, estructurada i útil.', - - // Kroatisch - hr: 'Vi ste korisni asistent koji analizira i obrađuje tekstove. Vaš zadatak je obraditi transkripcije razgovora prema danim uputama. Odgovorite u Markdownu s lijepim formatom. Ne koristite tablice ili kod u Markdownu. Odgovorite precizno, strukturirano i korisno.', - - // Slowakisch - sk: 'Ste užitočný asistent, ktorý analyzuje a spracováva texty. Vašou úlohou je spracovávať prepisy konverzácií podľa daných pokynov. Odpovedzte v Markdowne s pekným formátovaním. Nepoužívajte tabuľky alebo kód v Markdowne. Odpovedzte presne, štruktúrovane a užitočne.', - - // Estnisch - et: 'Olete kasulik assistent, kes analüüsib ja töötleb tekste. Teie ülesanne on töödelda vestluste ärakirju vastavalt antud juhistele. Vastake Markdownis ilusa vorminguga. Ärge kasutage Markdownis tabeleid ega koodi. Vastake täpselt, struktureeritult ja kasulikult.', - - // Lettisch - lv: 'Jūs esat noderīgs asistents, kas analizē un apstrādā tekstus. Jūsu uzdevums ir apstrādāt sarunu atšifrējumus saskaņā ar dotajiem norādījumiem. Atbildiet Markdown ar skaistu formatējumu. Neizmantojiet tabulas vai kodu Markdown. Atbildiet precīzi, strukturēti un noderīgi.', - - // Litauisch - lt: 'Esate naudingas asistentas, kuris analizuoja ir apdoroja tekstus. Jūsų užduotis yra apdoroti pokalbių stenogramas pagal pateiktas instrukcijas. Atsakykite Markdown su gražiu formatavimu. Nenaudokite lentelių ar kodo Markdown. Atsakykite tiksliai, struktūrizuotai ir naudingai.', - - // Bengalisch - bn: 'আপনি একজন সহায়ক সহকারী যিনি পাঠ্য বিশ্লেষণ এবং প্রক্রিয়া করেন। আপনার কাজ হল প্রদত্ত নির্দেশাবলী অনুসারে কথোপকথনের প্রতিলিপি প্রক্রিয়া করা। সুন্দর বিন্যাসের সাথে Markdown-এ উত্তর দিন। Markdown-এ টেবিল বা কোড ব্যবহার করবেন না। সুনির্দিষ্ট, কাঠামোগত এবং সহায়কভাবে উত্তর দিন।', - - // Malaiisch - ms: 'Anda adalah pembantu berguna yang menganalisis dan memproses teks. Tugas anda adalah memproses transkrip perbualan mengikut arahan yang diberikan. Balas dalam Markdown dengan format yang cantik. Jangan gunakan jadual atau kod dalam Markdown. Balas dengan tepat, berstruktur dan berguna.', - - // Tamil - ta: 'நீங்கள் உரைகளை பகுப்பாய்வு செய்து செயலாக்கும் பயனுள்ள உதவியாளர். கொடுக்கப்பட்ட அறிவுறுத்தல்களின்படி உரையாடல் படியெடுப்புகளை செயலாக்குவது உங்கள் பணி. அழகான வடிவத்துடன் Markdown இல் பதிலளிக்கவும். Markdown இல் அட்டவணைகள் அல்லது குறியீட்டைப் பயன்படுத்த வேண்டாம். துல்லியமாக, கட்டமைக்கப்பட்ட மற்றும் பயனுள்ள வகையில் பதிலளிக்கவும்.', - - // Telugu - te: 'మీరు టెక్స్ట్‌లను విశ్లేషించి ప్రాసెస్ చేసే సహాయక అసిస్టెంట్. ఇచ్చిన సూచనల ప్రకారం సంభాషణ ట్రాన్స్‌క్రిప్ట్‌లను ప్రాసెస్ చేయడం మీ పని. అందమైన ఫార్మాట్‌తో Markdown లో స్పందించండి. Markdown లో పట్టికలు లేదా కోడ్ ఉపయోగించవద్దు. ఖచ్చితంగా, నిర్మాణాత్మకంగా మరియు సహాయకరంగా స్పందించండి.', - - // Urdu - ur: 'آپ ایک مددگار معاون ہیں جو متن کا تجزیہ اور عمل کرتے ہیں۔ آپ کا کام دی گئی ہدایات کے مطابق گفتگو کی نقلیں پروسیس کرنا ہے۔ خوبصورت فارمیٹ کے ساتھ Markdown میں جواب دیں۔ Markdown میں ٹیبلز یا کوڈ استعمال نہ کریں۔ درست، منظم اور مددگار طریقے سے جواب دیں۔', - - // Marathi - mr: 'तुम्ही एक उपयुक्त सहाय्यक आहात जो मजकूरांचे विश्लेषण आणि प्रक्रिया करतो. दिलेल्या सूचनांनुसार संभाषण प्रतिलेखनांवर प्रक्रिया करणे हे तुमचे कार्य आहे. सुंदर स्वरूपासह Markdown मध्ये उत्तर द्या. Markdown मध्ये सारण्या किंवा कोड वापरू नका. अचूक, संरचित आणि उपयुक्त पद्धतीने उत्तर द्या.', - - // Gujarati - gu: 'તમે એક મદદરૂપ સહાયક છો જે ટેક્સ્ટનું વિશ્લેષણ અને પ્રક્રિયા કરે છે. આપેલી સૂચનાઓ અનુસાર વાતચીતની ટ્રાન્સક્રિપ્ટ્સ પર પ્રક્રિયા કરવી એ તમારું કામ છે. સુંદર ફોર્મેટ સાથે Markdown માં જવાબ આપો. Markdown માં કોષ્ટકો અથવા કોડનો ઉપયોગ કરશો નહીં. ચોક્કસ, સંરચિત અને મદદરૂપ રીતે જવાબ આપો.', - - // Malayalam - ml: 'നിങ്ങൾ വാചകങ്ങൾ വിശകലനം ചെയ്യുകയും പ്രോസസ്സ് ചെയ്യുകയും ചെയ്യുന്ന സഹായകരമായ സഹായിയാണ്. നൽകിയിരിക്കുന്ന നിർദ്ദേശങ്ങൾ അനുസരിച്ച് സംഭാഷണ ട്രാൻസ്ക്രിപ്റ്റുകൾ പ്രോസസ്സ് ചെയ്യുക എന്നതാണ് നിങ്ങളുടെ ജോലി. മനോഹരമായ ഫോർമാറ്റിൽ Markdown ൽ പ്രതികരിക്കുക. Markdown ൽ ടേബിളുകളോ കോഡോ ഉപയോഗിക്കരുത്. കൃത്യമായും ഘടനാപരമായും സഹായകരമായും പ്രതികരിക്കുക.', - - // Kannada - kn: 'ನೀವು ಪಠ್ಯಗಳನ್ನು ವಿಶ್ಲೇಷಿಸುವ ಮತ್ತು ಪ್ರಕ್ರಿಯೆಗೊಳಿಸುವ ಸಹಾಯಕ ಸಹಾಯಕರಾಗಿದ್ದೀರಿ. ನೀಡಿದ ಸೂಚನೆಗಳ ಪ್ರಕಾರ ಸಂಭಾಷಣೆ ಪ್ರತಿಲಿಪಿಗಳನ್ನು ಪ್ರಕ್ರಿಯೆಗೊಳಿಸುವುದು ನಿಮ್ಮ ಕೆಲಸ. ಸುಂದರ ಸ್ವರೂಪದೊಂದಿಗೆ Markdown ನಲ್ಲಿ ಪ್ರತಿಕ್ರಿಯಿಸಿ. Markdown ನಲ್ಲಿ ಕೋಷ್ಟಕಗಳು ಅಥವಾ ಕೋಡ್ ಬಳಸಬೇಡಿ. ನಿಖರವಾಗಿ, ರಚನಾತ್ಮಕವಾಗಿ ಮತ್ತು ಸಹಾಯಕವಾಗಿ ಪ್ರತಿಕ್ರಿಯಿಸಿ.', - - // Punjabi - pa: 'ਤੁਸੀਂ ਇੱਕ ਮਦਦਗਾਰ ਸਹਾਇਕ ਹੋ ਜੋ ਟੈਕਸਟਾਂ ਦਾ ਵਿਸ਼ਲੇਸ਼ਣ ਅਤੇ ਪ੍ਰਕਿਰਿਆ ਕਰਦੇ ਹੋ। ਤੁਹਾਡਾ ਕੰਮ ਦਿੱਤੀਆਂ ਹਦਾਇਤਾਂ ਅਨੁਸਾਰ ਗੱਲਬਾਤ ਦੀਆਂ ਨਕਲਾਂ ਨੂੰ ਪ੍ਰਕਿਰਿਆ ਕਰਨਾ ਹੈ। ਸੁੰਦਰ ਫਾਰਮੈਟ ਨਾਲ Markdown ਵਿੱਚ ਜਵਾਬ ਦਿਓ। Markdown ਵਿੱਚ ਸਾਰਣੀਆਂ ਜਾਂ ਕੋਡ ਦੀ ਵਰਤੋਂ ਨਾ ਕਰੋ। ਸਟੀਕ, ਢਾਂਚਾਗਤ ਅਤੇ ਮਦਦਗਾਰ ਢੰਗ ਨਾਲ ਜਵਾਬ ਦਿਓ।', - - // Afrikaans - af: "Jy is 'n nuttige assistent wat tekste ontleed en verwerk. Jou taak is om gespreksafskrifte te verwerk volgens die gegewe instruksies. Antwoord in Markdown met 'n mooi formaat. Moenie tabelle of kode in Markdown gebruik nie. Antwoord presies, gestruktureerd en nuttig.", - - // Persisch - fa: 'شما یک دستیار مفید هستید که متون را تحلیل و پردازش می‌کند. وظیفه شما پردازش رونوشت‌های مکالمات طبق دستورالعمل‌های داده شده است. با فرمت زیبا در Markdown پاسخ دهید. از جداول یا کد در Markdown استفاده نکنید. به طور دقیق، ساختاریافته و مفید پاسخ دهید.', - - // Georgisch - ka: 'თქვენ ხართ სასარგებლო ასისტენტი, რომელიც აანალიზებს და ამუშავებს ტექსტებს. თქვენი ამოცანაა საუბრების ჩანაწერების დამუშავება მოცემული ინსტრუქციების შესაბამისად. უპასუხეთ Markdown-ში ლამაზი ფორმატით. არ გამოიყენოთ ცხრილები ან კოდი Markdown-ში. უპასუხეთ ზუსტად, სტრუქტურირებულად და სასარგებლოდ.', - - // Isländisch - is: 'Þú ert gagnlegur aðstoðarmaður sem greinir og vinnur úr textum. Verkefni þitt er að vinna úr samtalsskrám samkvæmt gefnum leiðbeiningum. Svaraðu í Markdown með fallegu sniði. Notaðu ekki töflur eða kóða í Markdown. Svaraðu nákvæmlega, skipulega og gagnlega.', - - // Albanisch - sq: 'Ju jeni një asistent i dobishëm që analizon dhe përpunon tekste. Detyra juaj është të përpunoni transkriptet e bisedave sipas udhëzimeve të dhëna. Përgjigjuni në Markdown me një format të bukur. Mos përdorni tabela ose kod në Markdown. Përgjigjuni saktësisht, të strukturuar dhe të dobishëm.', - - // Aserbaidschanisch - az: 'Siz mətnləri təhlil edən və emal edən faydalı köməkçisiniz. Sizin vəzifəniz verilmiş təlimatlara uyğun olaraq söhbət transkriptlərini emal etməkdir. Gözəl formatla Markdown-da cavab verin. Markdown-da cədvəllər və ya kod istifadə etməyin. Dəqiq, strukturlaşdırılmış və faydalı şəkildə cavab verin.', - - // Baskisch - eu: 'Testuak aztertzen eta prozesatzen dituen laguntzaile erabilgarria zara. Zure zeregina elkarrizketen transkripzioak prozesatzea da emandako argibideen arabera. Erantzun Markdownean formatu ederrarekin. Ez erabili taulak edo kodea Markdownean. Erantzun zehatz, egituratuta eta lagungarri.', - - // Galizisch - gl: 'Es un asistente útil que analiza e procesa textos. A túa tarefa é procesar transcricións de conversas segundo as instrucións dadas. Responde en Markdown cun formato bonito. Non uses táboas ou código en Markdown. Responde de forma precisa, estruturada e útil.', - - // Kasachisch - kk: 'Сіз мәтіндерді талдайтын және өңдейтін пайдалы көмекшісіз. Сіздің міндетіңіз берілген нұсқауларға сәйкес сөйлесу транскрипттерін өңдеу. Әдемі пішіммен Markdown-да жауап беріңіз. Markdown-да кестелер немесе код қолданбаңыз. Дәл, құрылымдалған және пайдалы түрде жауап беріңіз.', - - // Mazedonisch - mk: 'Вие сте корисен асистент кој анализира и обработува текстови. Вашата задача е да обработувате транскрипти на разговори според дадените упатства. Одговорете во Markdown со убав формат. Не користете табели или код во Markdown. Одговорете прецизно, структурирано и корисно.', - - // Serbisch - sr: 'Ви сте корисни асистент који анализира и обрађује текстове. Ваш задатак је да обрађујете транскрипте разговора према датим упутствима. Одговорите у Markdown-у са лепим форматом. Не користите табеле или код у Markdown-у. Одговорите прецизно, структурисано и корисно.', - - // Slowenisch - sl: 'Ste koristen pomočnik, ki analizira in obdeluje besedila. Vaša naloga je obdelati prepise pogovorov v skladu z danimi navodili. Odgovorite v Markdownu z lepim formatom. Ne uporabljajte tabel ali kode v Markdownu. Odgovorite natančno, strukturirano in koristno.', - - // Maltesisch - mt: "Inti assistent utli li janalizza u jipproċessa testi. Il-kompitu tiegħek huwa li tipproċessa traskrizzjonijiet ta' konversazzjonijiet skont l-istruzzjonijiet mogħtija. Wieġeb f'Markdown b'format sabiħ. Tużax tabelli jew kodiċi f'Markdown. Wieġeb b'mod preċiż, strutturat u utli.", - - // Armenisch - hy: 'Դուք օգտակար օգնական եք, որը վերլուծում և մշակում է տեքստեր: Ձեր խնդիրն է մշակել զրույցների արձանագրությունները տրված հրահանգների համաձայն: Պատասխանեք Markdown-ում գեղեցիկ ձևաչափով: Մի օգտագործեք աղյուսակներ կամ կոդ Markdown-ում: Պատասխանեք ճշգրիտ, կառուցվածքային և օգտակար:', - - // Usbekisch - uz: "Siz matnlarni tahlil qiluvchi va qayta ishlovchi foydali yordamchisiz. Sizning vazifangiz berilgan ko'rsatmalarga muvofiq suhbat transkriptlarini qayta ishlashdir. Chiroyli formatda Markdown-da javob bering. Markdown-da jadvallar yoki koddan foydalanmang. Aniq, tuzilgan va foydali tarzda javob bering.", - - // Irisch - ga: 'Is cúntóir cabhrach thú a dhéanann anailís agus próiseáil ar théacsanna. Is é do thasc tras-scríbhinní comhrá a phróiseáil de réir na dtreoracha a thugtar. Freagair i Markdown le formáid álainn. Ná húsáid táblaí ná cód i Markdown. Freagair go beacht, struchtúrtha agus cabhrach.', - - // Walisisch - cy: "Rydych chi'n gynorthwyydd defnyddiol sy'n dadansoddi ac yn prosesu testunau. Eich tasg yw prosesu trawsgrifiadau sgwrs yn ôl y cyfarwyddiadau a roddir. Atebwch yn Markdown gyda fformat hardd. Peidiwch â defnyddio tablau na chod yn Markdown. Atebwch yn fanwl gywir, wedi'i strwythuro ac yn ddefnyddiol.", - - // Filipino - fil: 'Ikaw ay isang kapaki-pakinabang na katulong na nag-aanalisa at nagpoproseso ng mga teksto. Ang iyong gawain ay iproseso ang mga transkripsyon ng pag-uusap ayon sa mga ibinigay na tagubilin. Tumugon sa Markdown na may magandang format. Huwag gumamit ng mga talahanayan o code sa Markdown. Tumugon nang tumpak, nakaayos, at nakakatulong.', - }, -}; diff --git a/apps/memoro/apps/backend/src/ai/shared/transcript-utils.ts b/apps/memoro/apps/backend/src/ai/shared/transcript-utils.ts deleted file mode 100644 index 381516322..000000000 --- a/apps/memoro/apps/backend/src/ai/shared/transcript-utils.ts +++ /dev/null @@ -1,81 +0,0 @@ -/** - * Shared utility functions for handling transcript generation from utterances - * Used across multiple edge functions - */ - -/** - * Generate a plain text transcript from utterances array - * @param utterances - Array of utterance objects with text property - * @returns Plain text transcript string - */ -export function generateTranscriptFromUtterances( - utterances?: Array<{ - text: string; - speakerId?: string; - offset?: number; - duration?: number; - }> | null -): string { - if (!utterances || !Array.isArray(utterances) || utterances.length === 0) { - return ''; - } - - // Sort utterances by offset if available - const sortedUtterances = [...utterances].sort((a, b) => { - const offsetA = a.offset || 0; - const offsetB = b.offset || 0; - return offsetA - offsetB; - }); - - // Concatenate all utterance texts with spaces - return sortedUtterances - .map((utterance) => utterance.text) - .filter((text) => text && text.trim() !== '') - .join(' '); -} - -/** - * Get transcript text from memo (generates from utterances or returns legacy transcript) - * @param memo - The memo object - * @returns The transcript text - */ -export function getTranscriptText(memo: any): string { - // If utterances exist, generate transcript from them - if ( - memo?.source?.utterances && - Array.isArray(memo.source.utterances) && - memo.source.utterances.length > 0 - ) { - return generateTranscriptFromUtterances(memo.source.utterances); - } - - // Fall back to legacy transcript fields for backward compatibility - return ( - memo?.transcript || - memo?.source?.transcript || - memo?.source?.content || - memo?.source?.transcription || - memo?.source?.text || - memo?.metadata?.transcript || - '' - ); -} - -/** - * Get transcript from additional recording - * @param recording - The additional recording object - * @returns The transcript text - */ -export function getRecordingTranscript(recording: any): string { - // If utterances exist, generate transcript from them - if ( - recording?.utterances && - Array.isArray(recording.utterances) && - recording.utterances.length > 0 - ) { - return generateTranscriptFromUtterances(recording.utterances); - } - - // Fall back to transcript field - return recording?.transcript || ''; -} diff --git a/apps/memoro/apps/backend/src/ai/shared/user-prompt.service.ts b/apps/memoro/apps/backend/src/ai/shared/user-prompt.service.ts deleted file mode 100644 index 921c95143..000000000 --- a/apps/memoro/apps/backend/src/ai/shared/user-prompt.service.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import { createClient } from '@supabase/supabase-js'; -import { ROOT_SYSTEM_PROMPTS } from './system-prompts'; - -@Injectable() -export class UserPromptService { - private readonly logger = new Logger(UserPromptService.name); - private readonly supabaseUrl: string; - private readonly supabaseServiceKey: string; - - constructor(private configService: ConfigService) { - this.supabaseUrl = this.configService.get('MEMORO_SUPABASE_URL', ''); - this.supabaseServiceKey = this.configService.get('MEMORO_SUPABASE_SERVICE_KEY', ''); - } - - /** - * Gibt den System-Prompt für einen User zurück. - * Wenn der User einen eigenen definiert hat, wird dieser verwendet. - * Sonst der Standard-PRE_PROMPT in der jeweiligen Sprache. - */ - async getSystemPrompt(userId: string, language = 'de'): Promise { - try { - const supabase = createClient(this.supabaseUrl, this.supabaseServiceKey); - const { data: user, error } = await supabase - .from('users') - .select('app_settings') - .eq('id', userId) - .single(); - - if (!error && user?.app_settings?.memoro?.systemPrompt) { - this.logger.debug(`Using custom system prompt for user ${userId}`); - return user.app_settings.memoro.systemPrompt; - } - } catch (err) { - this.logger.warn(`Failed to load user system prompt, using default: ${err}`); - } - - const baseLang = language.split('-')[0].toLowerCase(); - return ROOT_SYSTEM_PROMPTS.PRE_PROMPT[baseLang] || ROOT_SYSTEM_PROMPTS.PRE_PROMPT['de']; - } - - /** - * Gibt den System-Prompt für den Owner eines Memos zurück. - */ - async getSystemPromptForMemo(memoUserId: string, language = 'de'): Promise { - return this.getSystemPrompt(memoUserId, language); - } -} diff --git a/apps/memoro/apps/backend/src/app.module.ts b/apps/memoro/apps/backend/src/app.module.ts deleted file mode 100644 index cc8c3b6b8..000000000 --- a/apps/memoro/apps/backend/src/app.module.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { Module } from '@nestjs/common'; -import { ConfigModule } from '@nestjs/config'; -import { AuthModule } from './auth/auth.module'; -import { AuthProxyModule } from './auth-proxy/auth-proxy.module'; -import { SpacesModule } from './spaces/spaces.module'; -import { MemoroModule } from './memoro/memoro.module'; -import { MeetingsModule } from './meetings/meetings.module'; -import { HealthModule } from './health/health.module'; -import { CreditsModule } from './credits/credits.module'; -import { SettingsModule } from './settings/settings.module'; -import { CleanupModule } from './cleanup/cleanup.module'; -import { AiModule } from './ai/ai.module'; - -@Module({ - imports: [ - ConfigModule.forRoot({ - isGlobal: true, - ignoreEnvFile: process.env.NODE_ENV === 'production', - }), - AuthModule, - AuthProxyModule, - SpacesModule, - MemoroModule, - MeetingsModule, - HealthModule, - CreditsModule, - SettingsModule, - CleanupModule, - AiModule, - ], -}) -export class AppModule {} diff --git a/apps/memoro/apps/backend/src/auth-proxy/auth-proxy.controller.spec.ts b/apps/memoro/apps/backend/src/auth-proxy/auth-proxy.controller.spec.ts deleted file mode 100644 index 2f9c4ecc5..000000000 --- a/apps/memoro/apps/backend/src/auth-proxy/auth-proxy.controller.spec.ts +++ /dev/null @@ -1,222 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { AuthProxyController } from './auth-proxy.controller'; -import { AuthProxyService } from './auth-proxy.service'; -import { HttpException, HttpStatus } from '@nestjs/common'; - -describe('AuthProxyController', () => { - let controller: AuthProxyController; - let service: jest.Mocked; - - const mockAuthProxyService = { - signin: jest.fn(), - signup: jest.fn(), - googleSignin: jest.fn(), - appleSignin: jest.fn(), - refresh: jest.fn(), - logout: jest.fn(), - forgotPassword: jest.fn(), - validate: jest.fn(), - getCredits: jest.fn(), - getDevices: jest.fn(), - }; - - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - controllers: [AuthProxyController], - providers: [ - { - provide: AuthProxyService, - useValue: mockAuthProxyService, - }, - ], - }).compile(); - - controller = module.get(AuthProxyController); - service = module.get(AuthProxyService); - }); - - afterEach(() => { - jest.clearAllMocks(); - }); - - describe('signin', () => { - it('should call authProxyService.signin with payload', async () => { - const payload = { email: 'test@test.com', password: 'password' }; - const expectedResult = { token: 'token', user: { id: '123' } }; - - mockAuthProxyService.signin.mockResolvedValue(expectedResult); - - const result = await controller.signin(payload); - - expect(service.signin).toHaveBeenCalledWith(payload); - expect(result).toEqual(expectedResult); - }); - - it('should handle service errors', async () => { - const payload = { email: 'test@test.com', password: 'password' }; - const error = new Error('Service error'); - - mockAuthProxyService.signin.mockRejectedValue(error); - - await expect(controller.signin(payload)).rejects.toThrow(error); - }); - }); - - describe('signup', () => { - it('should call authProxyService.signup with payload', async () => { - const payload = { email: 'test@test.com', password: 'password' }; - const expectedResult = { user: { id: '123' } }; - - mockAuthProxyService.signup.mockResolvedValue(expectedResult); - - const result = await controller.signup(payload); - - expect(service.signup).toHaveBeenCalledWith(payload); - expect(result).toEqual(expectedResult); - }); - }); - - describe('googleSignin', () => { - it('should call authProxyService.googleSignin with payload', async () => { - const payload = { idToken: 'google-token' }; - const expectedResult = { token: 'token', user: { id: '123' } }; - - mockAuthProxyService.googleSignin.mockResolvedValue(expectedResult); - - const result = await controller.googleSignin(payload); - - expect(service.googleSignin).toHaveBeenCalledWith(payload); - expect(result).toEqual(expectedResult); - }); - }); - - describe('appleSignin', () => { - it('should call authProxyService.appleSignin with payload', async () => { - const payload = { idToken: 'apple-token' }; - const expectedResult = { token: 'token', user: { id: '123' } }; - - mockAuthProxyService.appleSignin.mockResolvedValue(expectedResult); - - const result = await controller.appleSignin(payload); - - expect(service.appleSignin).toHaveBeenCalledWith(payload); - expect(result).toEqual(expectedResult); - }); - }); - - describe('refresh', () => { - it('should call authProxyService.refresh with payload', async () => { - const payload = { refreshToken: 'refresh-token' }; - const expectedResult = { token: 'new-token', refreshToken: 'new-refresh' }; - - mockAuthProxyService.refresh.mockResolvedValue(expectedResult); - - const result = await controller.refresh(payload); - - expect(service.refresh).toHaveBeenCalledWith(payload); - expect(result).toEqual(expectedResult); - }); - }); - - describe('logout', () => { - it('should call authProxyService.logout with payload', async () => { - const payload = { token: 'token' }; - - mockAuthProxyService.logout.mockResolvedValue(undefined); - - const result = await controller.logout(payload); - - expect(service.logout).toHaveBeenCalledWith(payload); - expect(result).toBeUndefined(); - }); - - it('should have HttpCode 204', async () => { - const metadata = Reflect.getMetadata('__httpCode__', controller.logout); - expect(metadata).toBe(204); - }); - }); - - describe('forgotPassword', () => { - it('should call authProxyService.forgotPassword with payload', async () => { - const payload = { email: 'test@test.com' }; - const expectedResult = { message: 'Password reset email sent' }; - - mockAuthProxyService.forgotPassword.mockResolvedValue(expectedResult); - - const result = await controller.forgotPassword(payload); - - expect(service.forgotPassword).toHaveBeenCalledWith(payload); - expect(result).toEqual(expectedResult); - }); - }); - - describe('validate', () => { - it('should call authProxyService.validate with payload', async () => { - const payload = { token: 'token' }; - const expectedResult = { valid: true, user: { id: '123' } }; - - mockAuthProxyService.validate.mockResolvedValue(expectedResult); - - const result = await controller.validate(payload); - - expect(service.validate).toHaveBeenCalledWith(payload); - expect(result).toEqual(expectedResult); - }); - }); - - describe('getCredits', () => { - it('should call authProxyService.getCredits with authorization header', async () => { - const authorization = 'Bearer token'; - const expectedResult = { credits: 100 }; - - mockAuthProxyService.getCredits.mockResolvedValue(expectedResult); - - const result = await controller.getCredits(authorization); - - expect(service.getCredits).toHaveBeenCalledWith(authorization); - expect(result).toEqual(expectedResult); - }); - - it('should throw UnauthorizedException when no authorization header', async () => { - await expect(controller.getCredits(undefined)).rejects.toThrow( - new HttpException('Authorization header required', HttpStatus.UNAUTHORIZED) - ); - expect(service.getCredits).not.toHaveBeenCalled(); - }); - - it('should throw UnauthorizedException when empty authorization header', async () => { - await expect(controller.getCredits('')).rejects.toThrow( - new HttpException('Authorization header required', HttpStatus.UNAUTHORIZED) - ); - expect(service.getCredits).not.toHaveBeenCalled(); - }); - }); - - describe('getDevices', () => { - it('should call authProxyService.getDevices with authorization header', async () => { - const authorization = 'Bearer token'; - const expectedResult = { devices: [{ id: 'device-1' }] }; - - mockAuthProxyService.getDevices.mockResolvedValue(expectedResult); - - const result = await controller.getDevices(authorization); - - expect(service.getDevices).toHaveBeenCalledWith(authorization); - expect(result).toEqual(expectedResult); - }); - - it('should throw UnauthorizedException when no authorization header', async () => { - await expect(controller.getDevices(undefined)).rejects.toThrow( - new HttpException('Authorization header required', HttpStatus.UNAUTHORIZED) - ); - expect(service.getDevices).not.toHaveBeenCalled(); - }); - - it('should throw UnauthorizedException when empty authorization header', async () => { - await expect(controller.getDevices('')).rejects.toThrow( - new HttpException('Authorization header required', HttpStatus.UNAUTHORIZED) - ); - expect(service.getDevices).not.toHaveBeenCalled(); - }); - }); -}); diff --git a/apps/memoro/apps/backend/src/auth-proxy/auth-proxy.controller.ts b/apps/memoro/apps/backend/src/auth-proxy/auth-proxy.controller.ts deleted file mode 100644 index 544dd35aa..000000000 --- a/apps/memoro/apps/backend/src/auth-proxy/auth-proxy.controller.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { - Controller, - Post, - Get, - Body, - Headers, - HttpCode, - HttpException, - HttpStatus, -} from '@nestjs/common'; -import { AuthProxyService } from './auth-proxy.service'; - -@Controller('auth') -export class AuthProxyController { - constructor(private readonly authProxyService: AuthProxyService) {} - - @Post('signin') - async signin(@Body() payload: any) { - return this.authProxyService.signin(payload); - } - - /** - * Signup endpoint - * - * Optional: Include metadata.branding to customize signup email - * If not provided, mana-core uses default branding for the app - * - * Example with custom branding: - * { - * "email": "user@example.com", - * "password": "pass123", - * "deviceInfo": {...}, - * "metadata": { - * "branding": { - * "logoUrl": "custom-logo.svg", - * "primaryColor": "#FF5733" - * } - * } - * } - */ - @Post('signup') - async signup(@Body() payload: any) { - return this.authProxyService.signup(payload); - } - - @Post('google-signin') - async googleSignin(@Body() payload: any) { - return this.authProxyService.googleSignin(payload); - } - - @Post('apple-signin') - async appleSignin(@Body() payload: any) { - return this.authProxyService.appleSignin(payload); - } - - @Post('refresh') - async refresh(@Body() payload: any) { - return this.authProxyService.refresh(payload); - } - - @Post('logout') - @HttpCode(204) - async logout(@Body() payload: any) { - return this.authProxyService.logout(payload); - } - - @Post('forgot-password') - async forgotPassword(@Body() payload: any) { - return this.authProxyService.forgotPassword(payload); - } - - @Post('validate') - async validate(@Body() payload: any) { - return this.authProxyService.validate(payload); - } - - @Get('credits') - async getCredits(@Headers('authorization') authorization: string) { - if (!authorization) { - throw new HttpException('Authorization header required', HttpStatus.UNAUTHORIZED); - } - return this.authProxyService.getCredits(authorization); - } - - // Device management endpoints - @Get('devices') - async getDevices(@Headers('authorization') authorization: string) { - if (!authorization) { - throw new HttpException('Authorization header required', HttpStatus.UNAUTHORIZED); - } - return this.authProxyService.getDevices(authorization); - } -} diff --git a/apps/memoro/apps/backend/src/auth-proxy/auth-proxy.module.ts b/apps/memoro/apps/backend/src/auth-proxy/auth-proxy.module.ts deleted file mode 100644 index afabd763a..000000000 --- a/apps/memoro/apps/backend/src/auth-proxy/auth-proxy.module.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { Module } from '@nestjs/common'; -import { HttpModule } from '@nestjs/axios'; -import { ConfigModule } from '@nestjs/config'; -import { AuthProxyController } from './auth-proxy.controller'; -import { AuthProxyService } from './auth-proxy.service'; - -@Module({ - imports: [HttpModule, ConfigModule], - controllers: [AuthProxyController], - providers: [AuthProxyService], - exports: [AuthProxyService], -}) -export class AuthProxyModule {} diff --git a/apps/memoro/apps/backend/src/auth-proxy/auth-proxy.service.spec.ts b/apps/memoro/apps/backend/src/auth-proxy/auth-proxy.service.spec.ts deleted file mode 100644 index be080a6c2..000000000 --- a/apps/memoro/apps/backend/src/auth-proxy/auth-proxy.service.spec.ts +++ /dev/null @@ -1,400 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { HttpService } from '@nestjs/axios'; -import { ConfigService } from '@nestjs/config'; -import { AuthProxyService } from './auth-proxy.service'; -import { of, throwError } from 'rxjs'; -import { AxiosResponse, AxiosError } from 'axios'; - -describe('AuthProxyService', () => { - let service: AuthProxyService; - let httpService: jest.Mocked; - let configService: jest.Mocked; - - const mockHttpService = { - post: jest.fn(), - get: jest.fn(), - }; - - const mockConfigService = { - get: jest.fn(), - }; - - const authServiceUrl = 'http://localhost:3000'; - const memoroAppId = 'test-app-id'; - - beforeEach(async () => { - // Reset mocks - mockConfigService.get.mockReset(); - mockHttpService.post.mockReset(); - mockHttpService.get.mockReset(); - - // Setup config mock - mockConfigService.get.mockImplementation((key: string, defaultValue?: any) => { - switch (key) { - case 'MANA_SERVICE_URL': - return authServiceUrl; - case 'MEMORO_APP_ID': - return memoroAppId; - default: - return defaultValue; - } - }); - - const module: TestingModule = await Test.createTestingModule({ - providers: [ - AuthProxyService, - { - provide: HttpService, - useValue: mockHttpService, - }, - { - provide: ConfigService, - useValue: mockConfigService, - }, - ], - }).compile(); - - service = module.get(AuthProxyService); - httpService = module.get(HttpService); - configService = module.get(ConfigService); - - // Mock console methods to avoid test output noise - jest.spyOn(console, 'log').mockImplementation(() => {}); - jest.spyOn(console, 'error').mockImplementation(() => {}); - }); - - afterEach(() => { - jest.clearAllMocks(); - }); - - describe('signin', () => { - it('should forward signin request to auth service', async () => { - const payload = { email: 'test@test.com', password: 'password' }; - const expectedResponse = { token: 'token', user: { id: '123' } }; - const axiosResponse: AxiosResponse = { - data: expectedResponse, - status: 200, - statusText: 'OK', - headers: {}, - config: {} as any, - }; - - mockHttpService.post.mockReturnValue(of(axiosResponse)); - - const result = await service.signin(payload); - - expect(httpService.post).toHaveBeenCalledWith( - `${authServiceUrl}/auth/signin?appId=${memoroAppId}`, - payload, - expect.any(Object) - ); - expect(result).toEqual(expectedResponse); - }); - - it('should handle signin errors', async () => { - const payload = { email: 'test@test.com', password: 'wrong' }; - const error: AxiosError = { - response: { - data: { message: 'Invalid credentials' }, - status: 401, - statusText: 'Unauthorized', - headers: {}, - config: {} as any, - }, - config: {} as any, - isAxiosError: true, - toJSON: () => ({}), - name: 'AxiosError', - message: 'Request failed', - }; - - mockHttpService.post.mockReturnValue(throwError(() => error)); - - await expect(service.signin(payload)).rejects.toThrow(); - }); - }); - - describe('signup', () => { - it('should forward signup request to auth service', async () => { - const payload = { email: 'test@test.com', password: 'password' }; - const expectedResponse = { user: { id: '123' } }; - const axiosResponse: AxiosResponse = { - data: expectedResponse, - status: 201, - statusText: 'Created', - headers: {}, - config: {} as any, - }; - - mockHttpService.post.mockReturnValue(of(axiosResponse)); - - const result = await service.signup(payload); - - expect(httpService.post).toHaveBeenCalledWith( - `${authServiceUrl}/auth/signup?appId=${memoroAppId}`, - payload, - expect.any(Object) - ); - expect(result).toEqual(expectedResponse); - }); - }); - - describe('googleSignin', () => { - it('should forward google signin request to auth service', async () => { - const payload = { idToken: 'google-token' }; - const expectedResponse = { token: 'token', user: { id: '123' } }; - const axiosResponse: AxiosResponse = { - data: expectedResponse, - status: 200, - statusText: 'OK', - headers: {}, - config: {} as any, - }; - - mockHttpService.post.mockReturnValue(of(axiosResponse)); - - const result = await service.googleSignin(payload); - - expect(httpService.post).toHaveBeenCalledWith( - `${authServiceUrl}/auth/google-signin?appId=${memoroAppId}`, - payload, - expect.any(Object) - ); - expect(result).toEqual(expectedResponse); - }); - }); - - describe('appleSignin', () => { - it('should forward apple signin request to auth service', async () => { - const payload = { idToken: 'apple-token' }; - const expectedResponse = { token: 'token', user: { id: '123' } }; - const axiosResponse: AxiosResponse = { - data: expectedResponse, - status: 200, - statusText: 'OK', - headers: {}, - config: {} as any, - }; - - mockHttpService.post.mockReturnValue(of(axiosResponse)); - - const result = await service.appleSignin(payload); - - expect(httpService.post).toHaveBeenCalledWith( - `${authServiceUrl}/auth/apple-signin?appId=${memoroAppId}`, - payload, - expect.any(Object) - ); - expect(result).toEqual(expectedResponse); - }); - }); - - describe('refresh', () => { - it('should forward refresh request to auth service with deviceInfo', async () => { - const payload = { - refreshToken: 'refresh-token', - deviceInfo: { - platform: 'ios', - deviceId: 'device-123', - appVersion: '1.0.0', - }, - }; - const expectedResponse = { token: 'new-token', refreshToken: 'new-refresh' }; - const axiosResponse: AxiosResponse = { - data: expectedResponse, - status: 200, - statusText: 'OK', - headers: {}, - config: {} as any, - }; - - mockHttpService.post.mockReturnValue(of(axiosResponse)); - - const result = await service.refresh(payload); - - expect(httpService.post).toHaveBeenCalledWith( - `${authServiceUrl}/auth/refresh?appId=${memoroAppId}`, - { - refreshToken: 'refresh-token', - appId: memoroAppId, - deviceInfo: payload.deviceInfo, - }, - expect.any(Object) - ); - expect(result).toEqual(expectedResponse); - }); - - it('should throw BadRequestException when deviceInfo is missing', async () => { - const payload = { refreshToken: 'refresh-token' }; - - await expect(service.refresh(payload)).rejects.toThrow( - 'Device info is required for token refresh' - ); - expect(httpService.post).not.toHaveBeenCalled(); - }); - - it('should throw BadRequestException when refreshToken is missing', async () => { - const payload = { - deviceInfo: { - platform: 'ios', - deviceId: 'device-123', - }, - }; - - await expect(service.refresh(payload)).rejects.toThrow('Refresh token is required'); - expect(httpService.post).not.toHaveBeenCalled(); - }); - }); - - describe('logout', () => { - it('should forward logout request to auth service', async () => { - const payload = { token: 'token' }; - const axiosResponse: AxiosResponse = { - data: null, - status: 204, - statusText: 'No Content', - headers: {}, - config: {} as any, - }; - - mockHttpService.post.mockReturnValue(of(axiosResponse)); - - const result = await service.logout(payload); - - expect(httpService.post).toHaveBeenCalledWith( - `${authServiceUrl}/auth/logout?appId=${memoroAppId}`, - payload, - expect.any(Object) - ); - expect(result).toBeNull(); - }); - }); - - describe('forgotPassword', () => { - it('should forward forgot password request to auth service', async () => { - const payload = { email: 'test@test.com' }; - const expectedResponse = { message: 'Password reset email sent' }; - const axiosResponse: AxiosResponse = { - data: expectedResponse, - status: 200, - statusText: 'OK', - headers: {}, - config: {} as any, - }; - - mockHttpService.post.mockReturnValue(of(axiosResponse)); - - const result = await service.forgotPassword(payload); - - expect(httpService.post).toHaveBeenCalledWith( - `${authServiceUrl}/auth/forgot-password?appId=${memoroAppId}`, - payload, - expect.any(Object) - ); - expect(result).toEqual(expectedResponse); - }); - }); - - describe('validate', () => { - it('should forward validate request to auth service', async () => { - const payload = { token: 'token' }; - const expectedResponse = { valid: true, user: { id: '123' } }; - const axiosResponse: AxiosResponse = { - data: expectedResponse, - status: 200, - statusText: 'OK', - headers: {}, - config: {} as any, - }; - - mockHttpService.post.mockReturnValue(of(axiosResponse)); - - const result = await service.validate(payload); - - expect(httpService.post).toHaveBeenCalledWith( - `${authServiceUrl}/auth/validate?appId=${memoroAppId}`, - payload, - expect.any(Object) - ); - expect(result).toEqual(expectedResponse); - }); - }); - - describe('getCredits', () => { - it('should forward get credits request to auth service', async () => { - const authorization = 'Bearer token'; - const expectedResponse = { credits: 100 }; - const axiosResponse: AxiosResponse = { - data: expectedResponse, - status: 200, - statusText: 'OK', - headers: {}, - config: {} as any, - }; - - mockHttpService.get.mockReturnValue(of(axiosResponse)); - - const result = await service.getCredits(authorization); - - expect(httpService.get).toHaveBeenCalledWith( - `${authServiceUrl}/auth/credits?appId=${memoroAppId}`, - { - headers: { - Authorization: authorization, - }, - } - ); - expect(result).toEqual(expectedResponse); - }); - - it('should handle get credits errors', async () => { - const authorization = 'Bearer invalid'; - const error: AxiosError = { - response: { - data: { message: 'Unauthorized' }, - status: 401, - statusText: 'Unauthorized', - headers: {}, - config: {} as any, - }, - config: {} as any, - isAxiosError: true, - toJSON: () => ({}), - name: 'AxiosError', - message: 'Request failed', - }; - - mockHttpService.get.mockReturnValue(throwError(() => error)); - - await expect(service.getCredits(authorization)).rejects.toThrow(); - }); - }); - - describe('getDevices', () => { - it('should forward get devices request to auth service', async () => { - const authorization = 'Bearer token'; - const expectedResponse = { devices: [{ id: 'device-1' }] }; - const axiosResponse: AxiosResponse = { - data: expectedResponse, - status: 200, - statusText: 'OK', - headers: {}, - config: {} as any, - }; - - mockHttpService.get.mockReturnValue(of(axiosResponse)); - - const result = await service.getDevices(authorization); - - expect(httpService.get).toHaveBeenCalledWith( - `${authServiceUrl}/auth/devices?appId=${memoroAppId}`, - { - headers: { - Authorization: authorization, - }, - } - ); - expect(result).toEqual(expectedResponse); - }); - }); -}); diff --git a/apps/memoro/apps/backend/src/auth-proxy/auth-proxy.service.ts b/apps/memoro/apps/backend/src/auth-proxy/auth-proxy.service.ts deleted file mode 100644 index 32a086a6a..000000000 --- a/apps/memoro/apps/backend/src/auth-proxy/auth-proxy.service.ts +++ /dev/null @@ -1,228 +0,0 @@ -import { Injectable, HttpException, HttpStatus } from '@nestjs/common'; -import { HttpService } from '@nestjs/axios'; -import { ConfigService } from '@nestjs/config'; -import { firstValueFrom, map, catchError } from 'rxjs'; -import { AxiosError } from 'axios'; -import { BrandingConfig, SignupMetadata } from './interfaces/branding.interface'; - -@Injectable() -export class AuthProxyService { - private manaServiceUrl: string; - private memoroAppId: string; - - constructor( - private httpService: HttpService, - private configService: ConfigService - ) { - this.manaServiceUrl = this.configService.get( - 'MANA_SERVICE_URL', - 'http://localhost:3000' - ); - this.memoroAppId = this.configService.get( - 'MEMORO_APP_ID', - '973da0c1-b479-4dac-a1b0-ed09c72caca8' - ); - } - - /** - * Generic proxy method for POST requests - */ - private async proxyPost(endpoint: string, payload: any, headers: any = {}) { - const url = `${this.manaServiceUrl}${endpoint}?appId=${this.memoroAppId}`; - - console.log(`[AuthProxy] Proxying POST request to: ${endpoint}`); - - try { - const response = await firstValueFrom( - this.httpService - .post(url, payload, { - headers: { - 'Content-Type': 'application/json', - ...headers, - }, - }) - .pipe( - map((res) => res.data), - catchError((error: AxiosError) => { - console.error(`[AuthProxy] Error from mana-core-middleware:`, error.response?.data); - - // Preserve the original error response - if (error.response) { - throw new HttpException( - error.response.data || 'Request failed', - error.response.status - ); - } - - throw new HttpException('Service unavailable', HttpStatus.SERVICE_UNAVAILABLE); - }) - ) - ); - - return response; - } catch (error) { - console.error(`[AuthProxy] Error proxying ${endpoint}:`, error); - throw error; - } - } - - /** - * Generic proxy method for GET requests - */ - private async proxyGet(endpoint: string, headers: any = {}) { - const url = `${this.manaServiceUrl}${endpoint}?appId=${this.memoroAppId}`; - - console.log(`[AuthProxy] Proxying GET request to: ${endpoint}`); - - try { - const response = await firstValueFrom( - this.httpService - .get(url, { - headers: { - ...headers, - }, - }) - .pipe( - map((res) => res.data), - catchError((error: AxiosError) => { - console.error(`[AuthProxy] Error from mana-core-middleware:`, error.response?.data); - - // Preserve the original error response - if (error.response) { - throw new HttpException( - error.response.data || 'Request failed', - error.response.status - ); - } - - throw new HttpException('Service unavailable', HttpStatus.SERVICE_UNAVAILABLE); - }) - ) - ); - - return response; - } catch (error) { - console.error(`[AuthProxy] Error proxying ${endpoint}:`, error); - throw error; - } - } - - // Auth endpoints - async signin(payload: any) { - // Log signin payload to understand device info flow - console.log('[AuthProxy] Signin request payload:', JSON.stringify(payload, null, 2)); - - if (payload.deviceInfo || payload.device_info) { - console.log('[AuthProxy] Device info present in signin request'); - } - - return this.proxyPost('/auth/signin', payload); - } - - async signup(payload: any) { - // Hardcoded Memoro branding configuration - const memoroBranding: BrandingConfig = { - appName: 'Memoro', - logoUrl: 'memoro-logo.png', - primaryColor: '#F8D62B', - secondaryColor: '#f5c500', - websiteUrl: 'https://memoro.ai', - taglineDe: 'Sprechen statt Tippen', - taglineEn: 'Speak Instead of Type', - copyright: '© 2025 Memoro · Made with 💛 in Germany', - }; - - // Build payload with Memoro branding - const enhancedPayload: any = { - ...payload, - redirectUrl: 'https://app.manacore.ai/welcome?appName=memoro', - }; - - // Add Memoro branding if not already provided in payload - if (!enhancedPayload.metadata) { - enhancedPayload.metadata = {}; - } - - // Merge: payload branding overrides default Memoro branding if provided - if (!enhancedPayload.metadata.branding) { - enhancedPayload.metadata.branding = memoroBranding; - } else { - // Merge: payload overrides default - enhancedPayload.metadata.branding = { - ...memoroBranding, - ...enhancedPayload.metadata.branding, - }; - } - - return this.proxyPost('/auth/signup', enhancedPayload); - } - - async googleSignin(payload: any) { - return this.proxyPost('/auth/google-signin', payload); - } - - async appleSignin(payload: any) { - return this.proxyPost('/auth/apple-signin', payload); - } - - async refresh(payload: any) { - // Log the refresh payload to debug device info issues - console.log('[AuthProxy] Refresh request payload:', JSON.stringify(payload, null, 2)); - - // Check if device info is present - it's required for refresh - if (!payload.deviceInfo) { - console.error('[AuthProxy] Error: No device info in refresh request'); - throw new HttpException( - { - error: 'Bad Request', - message: 'Device info is required for token refresh', - statusCode: 400, - }, - HttpStatus.BAD_REQUEST - ); - } - - // Ensure the payload has the correct structure - const refreshPayload = { - refreshToken: payload.refreshToken, - appId: payload.appId || this.memoroAppId, - deviceInfo: payload.deviceInfo, - }; - - // Validate required fields - if (!refreshPayload.refreshToken) { - throw new HttpException( - { error: 'Bad Request', message: 'Refresh token is required', statusCode: 400 }, - HttpStatus.BAD_REQUEST - ); - } - - console.log('[AuthProxy] Device info included in refresh request'); - - return this.proxyPost('/auth/refresh', refreshPayload); - } - - async logout(payload: any) { - return this.proxyPost('/auth/logout', payload); - } - - async forgotPassword(payload: any) { - return this.proxyPost('/auth/forgot-password', payload); - } - - async validate(payload: any) { - return this.proxyPost('/auth/validate', payload); - } - - async getCredits(authHeader: string) { - return this.proxyGet('/auth/credits', { - Authorization: authHeader, - }); - } - - async getDevices(authHeader: string) { - return this.proxyGet('/auth/devices', { - Authorization: authHeader, - }); - } -} diff --git a/apps/memoro/apps/backend/src/auth-proxy/interfaces/branding.interface.ts b/apps/memoro/apps/backend/src/auth-proxy/interfaces/branding.interface.ts deleted file mode 100644 index 2cf331f60..000000000 --- a/apps/memoro/apps/backend/src/auth-proxy/interfaces/branding.interface.ts +++ /dev/null @@ -1,34 +0,0 @@ -/** - * Feature object structure for branding emails - */ -export interface BrandingFeature { - icon: string; // Emoji icon - titleDe: string; // German title - titleEn: string; // English title - descriptionDe: string; // German description - descriptionEn: string; // English description -} - -/** - * Email branding configuration for signup confirmation emails - * All fields are optional and will fall back to app-branding.config.ts defaults - */ -export interface BrandingConfig { - appName?: string; // App display name - logoUrl?: string; // Logo filename or URL - primaryColor?: string; // Primary brand color (hex) - secondaryColor?: string; // Secondary color (hex) - websiteUrl?: string; // Website URL - taglineDe?: string; // German tagline - taglineEn?: string; // English tagline - features?: BrandingFeature[]; // Feature list - copyright?: string; // Footer copyright text -} - -/** - * Metadata object that can be passed in signup requests - */ -export interface SignupMetadata { - branding?: BrandingConfig; - [key: string]: any; // Allow custom fields for email personalization -} diff --git a/apps/memoro/apps/backend/src/auth/auth-client.service.spec.ts b/apps/memoro/apps/backend/src/auth/auth-client.service.spec.ts deleted file mode 100644 index 1a74157eb..000000000 --- a/apps/memoro/apps/backend/src/auth/auth-client.service.spec.ts +++ /dev/null @@ -1,324 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { HttpService } from '@nestjs/axios'; -import { ConfigService } from '@nestjs/config'; -import { AuthClientService } from './auth-client.service'; -import { UnauthorizedException } from '@nestjs/common'; -import { of, throwError } from 'rxjs'; -import { AxiosResponse, AxiosError } from 'axios'; - -describe('AuthClientService', () => { - let service: AuthClientService; - let httpService: jest.Mocked; - let configService: jest.Mocked; - - const mockHttpService = { - post: jest.fn(), - }; - - const mockConfigService = { - get: jest.fn(), - }; - - const authServiceUrl = 'http://localhost:3000'; - const memoroAppId = 'test-app-id'; - - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - providers: [ - AuthClientService, - { - provide: HttpService, - useValue: mockHttpService, - }, - { - provide: ConfigService, - useValue: mockConfigService, - }, - ], - }).compile(); - - service = module.get(AuthClientService); - httpService = module.get(HttpService); - configService = module.get(ConfigService); - - // Clear and reset all mocks first - mockConfigService.get.mockClear(); - mockHttpService.post.mockClear(); - - // Setup default config values - mockConfigService.get.mockImplementation((key: string, defaultValue?: any) => { - switch (key) { - case 'MANA_SERVICE_URL': - return authServiceUrl; - case 'MEMORO_APP_ID': - return memoroAppId; - default: - return defaultValue; - } - }); - - // Reset console.log mock - jest.spyOn(console, 'log').mockImplementation(() => {}); - }); - - afterEach(() => { - jest.clearAllMocks(); - jest.restoreAllMocks(); - }); - - describe('constructor', () => { - it('should initialize with config values', () => { - expect(service).toBeDefined(); - expect(configService.get).toHaveBeenCalledWith('MANA_SERVICE_URL', 'http://localhost:3000'); - expect(configService.get).toHaveBeenCalledWith( - 'MEMORO_APP_ID', - '973da0c1-b479-4dac-a1b0-ed09c72caca8' - ); - }); - - it('should use default values when config not provided', async () => { - mockConfigService.get.mockReturnValue(undefined); - - const module: TestingModule = await Test.createTestingModule({ - providers: [ - AuthClientService, - { - provide: HttpService, - useValue: mockHttpService, - }, - { - provide: ConfigService, - useValue: mockConfigService, - }, - ], - }).compile(); - - const serviceWithDefaults = module.get(AuthClientService); - expect(serviceWithDefaults).toBeDefined(); - }); - }); - - describe('validateToken', () => { - it('should validate token successfully', async () => { - const token = 'valid-token'; - const expectedUser = { - id: 'user-123', - email: 'test@test.com', - role: 'user', - }; - const axiosResponse: AxiosResponse = { - data: { - valid: true, - user: expectedUser, - }, - status: 200, - statusText: 'OK', - headers: {}, - config: {} as any, - }; - - mockHttpService.post.mockReturnValue(of(axiosResponse)); - - const result = await service.validateToken(token); - - expect(console.log).toHaveBeenCalledWith( - 'Calling: ', - `${authServiceUrl}/auth/validate?appId=${memoroAppId}` - ); - expect(console.log).toHaveBeenCalledWith('Memoro App ID: ', memoroAppId); - expect(httpService.post).toHaveBeenCalledWith( - `${authServiceUrl}/auth/validate?appId=${memoroAppId}`, - { appToken: token }, - { - headers: { - 'Content-Type': 'application/json', - }, - } - ); - expect(result).toEqual(expectedUser); - }); - - it('should throw UnauthorizedException for invalid token', async () => { - const token = 'invalid-token'; - const axiosError: AxiosError = { - response: { - data: { message: 'Invalid token' }, - status: 401, - statusText: 'Unauthorized', - headers: {}, - config: {} as any, - }, - config: {} as any, - isAxiosError: true, - toJSON: () => ({}), - name: 'AxiosError', - message: 'Request failed', - }; - - mockHttpService.post.mockReturnValue(throwError(() => axiosError)); - - await expect(service.validateToken(token)).rejects.toThrow( - new UnauthorizedException('Invalid token') - ); - }); - - it('should throw UnauthorizedException when response is not valid', async () => { - const token = 'token'; - const axiosResponse: AxiosResponse = { - data: { - valid: false, - }, - status: 200, - statusText: 'OK', - headers: {}, - config: {} as any, - }; - - mockHttpService.post.mockReturnValue(of(axiosResponse)); - - await expect(service.validateToken(token)).rejects.toThrow( - new UnauthorizedException('Invalid token') - ); - }); - - it('should throw UnauthorizedException when user is missing', async () => { - const token = 'token'; - const axiosResponse: AxiosResponse = { - data: { - valid: true, - user: null, - }, - status: 200, - statusText: 'OK', - headers: {}, - config: {} as any, - }; - - mockHttpService.post.mockReturnValue(of(axiosResponse)); - - await expect(service.validateToken(token)).rejects.toThrow( - new UnauthorizedException('Invalid token') - ); - }); - - it('should handle network errors', async () => { - const token = 'token'; - const error = new Error('Network error'); - - mockHttpService.post.mockReturnValue(throwError(() => error)); - - await expect(service.validateToken(token)).rejects.toThrow( - new UnauthorizedException('Invalid token') - ); - }); - - it('should handle unexpected errors', async () => { - const token = 'token'; - - mockHttpService.post.mockImplementation(() => { - throw new Error('Unexpected error'); - }); - - await expect(service.validateToken(token)).rejects.toThrow( - new UnauthorizedException('Invalid token') - ); - }); - }); - - describe('refreshToken', () => { - it('should refresh token successfully', async () => { - const refreshToken = 'valid-refresh-token'; - const expectedResponse = { - appToken: 'new-app-token', - refreshToken: 'new-refresh-token', - }; - const axiosResponse: AxiosResponse = { - data: expectedResponse, - status: 200, - statusText: 'OK', - headers: {}, - config: {} as any, - }; - - mockHttpService.post.mockReturnValue(of(axiosResponse)); - - const result = await service.refreshToken(refreshToken); - - expect(httpService.post).toHaveBeenCalledWith( - `${authServiceUrl}/auth/refresh`, - { refreshToken, appId: memoroAppId }, - { - headers: { - 'Content-Type': 'application/json', - }, - } - ); - expect(result).toEqual(expectedResponse); - }); - - it('should throw UnauthorizedException for invalid refresh token', async () => { - const refreshToken = 'invalid-refresh-token'; - const axiosError: AxiosError = { - response: { - data: { message: 'Invalid refresh token' }, - status: 401, - statusText: 'Unauthorized', - headers: {}, - config: {} as any, - }, - config: {} as any, - isAxiosError: true, - toJSON: () => ({}), - name: 'AxiosError', - message: 'Request failed', - }; - - mockHttpService.post.mockReturnValue(throwError(() => axiosError)); - - await expect(service.refreshToken(refreshToken)).rejects.toThrow( - new UnauthorizedException('Invalid refresh token') - ); - }); - - it('should handle network errors during refresh', async () => { - const refreshToken = 'refresh-token'; - const error = new Error('Network error'); - - mockHttpService.post.mockReturnValue(throwError(() => error)); - - await expect(service.refreshToken(refreshToken)).rejects.toThrow( - new UnauthorizedException('Invalid refresh token') - ); - }); - - it('should handle unexpected errors during refresh', async () => { - const refreshToken = 'refresh-token'; - - mockHttpService.post.mockImplementation(() => { - throw new Error('Unexpected error'); - }); - - await expect(service.refreshToken(refreshToken)).rejects.toThrow( - new UnauthorizedException('Invalid refresh token') - ); - }); - - it('should handle timeout errors', async () => { - const refreshToken = 'refresh-token'; - const axiosError: AxiosError = { - code: 'ECONNABORTED', - config: {} as any, - isAxiosError: true, - toJSON: () => ({}), - name: 'AxiosError', - message: 'Timeout', - }; - - mockHttpService.post.mockReturnValue(throwError(() => axiosError)); - - await expect(service.refreshToken(refreshToken)).rejects.toThrow( - new UnauthorizedException('Invalid refresh token') - ); - }); - }); -}); diff --git a/apps/memoro/apps/backend/src/auth/auth-client.service.ts b/apps/memoro/apps/backend/src/auth/auth-client.service.ts deleted file mode 100644 index e91b41170..000000000 --- a/apps/memoro/apps/backend/src/auth/auth-client.service.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { Injectable, UnauthorizedException } from '@nestjs/common'; -import { HttpService } from '@nestjs/axios'; -import { ConfigService } from '@nestjs/config'; -import { Observable, catchError, firstValueFrom, map } from 'rxjs'; -import { AxiosError } from 'axios'; -import { JwtPayload } from '../types/jwt-payload.interface'; - -@Injectable() -export class AuthClientService { - private authServiceUrl: string; - private memoroAppId: string; - - constructor( - private httpService: HttpService, - private configService: ConfigService - ) { - this.authServiceUrl = this.configService.get( - 'MANA_SERVICE_URL', - 'http://localhost:3000' - ); - this.memoroAppId = this.configService.get( - 'MEMORO_APP_ID', - '973da0c1-b479-4dac-a1b0-ed09c72caca8' - ); - } - - /** - * Validates a JWT token by calling the Auth service - */ - async validateToken(token: string): Promise { - try { - console.log('Calling: ', `${this.authServiceUrl}/auth/validate?appId=${this.memoroAppId}`); - console.log('Memoro App ID: ', this.memoroAppId); - const response = await firstValueFrom( - this.httpService - .post( - `${this.authServiceUrl}/auth/validate?appId=${this.memoroAppId}`, - { appToken: token }, - { - headers: { - 'Content-Type': 'application/json', - }, - } - ) - .pipe( - map((response) => response.data), - catchError((error: AxiosError) => { - throw new UnauthorizedException('Invalid token'); - }) - ) - ); - - if (response.valid && response.user) { - return response.user; - } else { - throw new UnauthorizedException('Invalid token response format'); - } - } catch (error) { - throw new UnauthorizedException('Invalid token'); - } - } - - /** - * Refreshes a token by calling the Auth service - */ - async refreshToken(refreshToken: string): Promise<{ appToken: string; refreshToken: string }> { - try { - const response = await firstValueFrom( - this.httpService - .post( - `${this.authServiceUrl}/auth/refresh`, - { refreshToken, appId: this.memoroAppId }, - { - headers: { - 'Content-Type': 'application/json', - }, - } - ) - .pipe( - map((response) => response.data), - catchError((error: AxiosError) => { - throw new UnauthorizedException('Invalid refresh token'); - }) - ) - ); - - return response; - } catch (error) { - throw new UnauthorizedException('Invalid refresh token'); - } - } -} diff --git a/apps/memoro/apps/backend/src/auth/auth.module.ts b/apps/memoro/apps/backend/src/auth/auth.module.ts deleted file mode 100644 index 8c9e8c96f..000000000 --- a/apps/memoro/apps/backend/src/auth/auth.module.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { Module } from '@nestjs/common'; -import { HttpModule } from '@nestjs/axios'; -import { ConfigModule } from '@nestjs/config'; -import { AuthClientService } from './auth-client.service'; - -@Module({ - imports: [HttpModule, ConfigModule], - providers: [AuthClientService], - exports: [AuthClientService], -}) -export class AuthModule {} diff --git a/apps/memoro/apps/backend/src/cleanup/audio-cleanup.controller.ts b/apps/memoro/apps/backend/src/cleanup/audio-cleanup.controller.ts deleted file mode 100644 index 6b4b1c35e..000000000 --- a/apps/memoro/apps/backend/src/cleanup/audio-cleanup.controller.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { Controller, Post, Body, UseGuards, Logger, HttpCode, HttpStatus } from '@nestjs/common'; -import { AudioCleanupService } from './audio-cleanup.service'; -import { InternalServiceGuard } from '../guards/internal-service.guard'; -import { CleanupResult } from './interfaces/cleanup.interfaces'; - -/** - * Controller for audio cleanup operations. - * Protected by InternalServiceGuard - only accessible via internal API key. - */ -@Controller('cleanup') -export class AudioCleanupController { - private readonly logger = new Logger(AudioCleanupController.name); - - constructor(private readonly audioCleanupService: AudioCleanupService) {} - - /** - * Trigger the full cleanup job. - * Called by pg_cron or manually for testing. - * Fetches users with cleanup enabled and processes their old audio files. - */ - @Post('trigger-from-cron') - @UseGuards(InternalServiceGuard) - @HttpCode(HttpStatus.OK) - async triggerFromCron(): Promise { - this.logger.log('Cleanup triggered from cron job'); - return this.audioCleanupService.runCleanup(); - } - - /** - * Process cleanup for specific user IDs. - * Used when the caller already knows which users to process. - */ - @Post('process-old-audios') - @UseGuards(InternalServiceGuard) - @HttpCode(HttpStatus.OK) - async processOldAudios(@Body() body: { userIds: string[] }): Promise { - this.logger.log(`Processing cleanup for ${body.userIds?.length || 0} users`); - - if (!body.userIds || body.userIds.length === 0) { - return { - success: true, - usersProcessed: 0, - filesDeleted: 0, - filesFailed: 0, - errors: [], - startedAt: new Date().toISOString(), - completedAt: new Date().toISOString(), - }; - } - - return this.audioCleanupService.deleteOldAudiosForUsers(body.userIds); - } - - /** - * Manual trigger for testing/admin purposes. - * Same as trigger-from-cron but with a different endpoint name for clarity. - */ - @Post('trigger-manual') - @UseGuards(InternalServiceGuard) - @HttpCode(HttpStatus.OK) - async triggerManual(): Promise { - this.logger.log('Cleanup triggered manually'); - return this.audioCleanupService.runCleanup(); - } -} diff --git a/apps/memoro/apps/backend/src/cleanup/audio-cleanup.service.ts b/apps/memoro/apps/backend/src/cleanup/audio-cleanup.service.ts deleted file mode 100644 index 08e53d9e3..000000000 --- a/apps/memoro/apps/backend/src/cleanup/audio-cleanup.service.ts +++ /dev/null @@ -1,395 +0,0 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import { createClient, SupabaseClient } from '@supabase/supabase-js'; -import { - CleanupResult, - CleanupError, - UserCleanupEnabledResponse, -} from './interfaces/cleanup.interfaces'; - -interface StorageObject { - id: string; - name: string; - created_at: string; - bucket_id: string; -} - -@Injectable() -export class AudioCleanupService { - private readonly logger = new Logger(AudioCleanupService.name); - private readonly memoroServiceClient: SupabaseClient; - private readonly memoroUrl: string; - private readonly manaCoreMiddlewareUrl: string; - private readonly internalApiKey: string; - private readonly STORAGE_BUCKET = 'user-uploads'; - private readonly RETENTION_DAYS = 30; - private readonly BATCH_SIZE = 100; // Files per deletion batch - private readonly BATCH_DELAY_MS = 200; // Delay between batches - - constructor(private configService: ConfigService) { - this.memoroUrl = this.configService.get('MEMORO_SUPABASE_URL'); - const memoroServiceKey = this.configService.get('MEMORO_SUPABASE_SERVICE_KEY'); - this.manaCoreMiddlewareUrl = this.configService.get('MANA_SERVICE_URL'); - this.internalApiKey = this.configService.get('INTERNAL_API_KEY'); - - if (!this.memoroUrl || !memoroServiceKey) { - throw new Error('MEMORO_SUPABASE_URL or MEMORO_SUPABASE_SERVICE_KEY not provided'); - } - - this.memoroServiceClient = createClient(this.memoroUrl, memoroServiceKey); - } - - /** - * Main entry point for the cleanup job. - * Uses direct SQL on storage.objects table for efficient file discovery. - */ - async runCleanup(): Promise { - const startedAt = new Date().toISOString(); - const errors: CleanupError[] = []; - let usersProcessed = 0; - let totalFilesDeleted = 0; - let totalFilesFailed = 0; - - this.logger.log('Starting audio cleanup job (SQL-based)'); - - try { - // Step 1: Get users with auto-delete enabled from mana-core-middleware - const userIds = await this.getUsersWithCleanupEnabled(); - this.logger.log(`Found ${userIds.length} users with audio cleanup enabled`); - - if (userIds.length === 0) { - return { - success: true, - usersProcessed: 0, - filesDeleted: 0, - filesFailed: 0, - errors: [], - startedAt, - completedAt: new Date().toISOString(), - }; - } - - // Step 2: Process each user using SQL-based cleanup - for (const userId of userIds) { - try { - const result = await this.processUserCleanupSQL(userId); - usersProcessed++; - totalFilesDeleted += result.filesDeleted; - totalFilesFailed += result.filesFailed; - errors.push(...result.errors); - } catch (error) { - this.logger.error(`Failed to process cleanup for user ${userId}:`, error); - errors.push({ - userId, - error: error.message || 'Unknown error processing user cleanup', - }); - } - } - - // Step 3: Log the cleanup run - await this.logCleanupRun({ - usersProcessed, - filesDeleted: totalFilesDeleted, - filesFailed: totalFilesFailed, - errors, - startedAt, - }); - - return { - success: true, - usersProcessed, - filesDeleted: totalFilesDeleted, - filesFailed: totalFilesFailed, - errors, - startedAt, - completedAt: new Date().toISOString(), - }; - } catch (error) { - this.logger.error('Audio cleanup job failed:', error); - return { - success: false, - usersProcessed, - filesDeleted: totalFilesDeleted, - filesFailed: totalFilesFailed, - errors: [...errors, { error: error.message || 'Unknown error' }], - startedAt, - completedAt: new Date().toISOString(), - }; - } - } - - /** - * Process cleanup for a specific list of user IDs. - */ - async deleteOldAudiosForUsers(userIds: string[]): Promise { - const startedAt = new Date().toISOString(); - const errors: CleanupError[] = []; - let usersProcessed = 0; - let totalFilesDeleted = 0; - let totalFilesFailed = 0; - - this.logger.log(`Processing cleanup for ${userIds.length} users`); - - for (const userId of userIds) { - try { - const result = await this.processUserCleanupSQL(userId); - usersProcessed++; - totalFilesDeleted += result.filesDeleted; - totalFilesFailed += result.filesFailed; - errors.push(...result.errors); - } catch (error) { - this.logger.error(`Failed to process cleanup for user ${userId}:`, error); - errors.push({ - userId, - error: error.message || 'Unknown error processing user cleanup', - }); - } - } - - return { - success: errors.length === 0, - usersProcessed, - filesDeleted: totalFilesDeleted, - filesFailed: totalFilesFailed, - errors, - startedAt, - completedAt: new Date().toISOString(), - }; - } - - /** - * Process cleanup for a single user using direct SQL on storage.objects table. - * Queries files older than retention period and deletes them in batches. - */ - private async processUserCleanupSQL(userId: string): Promise<{ - filesDeleted: number; - filesFailed: number; - errors: CleanupError[]; - }> { - const errors: CleanupError[] = []; - let filesDeleted = 0; - let filesFailed = 0; - - // Query storage.objects directly via the get_old_storage_files function - const { data: oldFiles, error: queryError } = await this.memoroServiceClient.rpc( - 'get_old_storage_files', - { - p_bucket_id: this.STORAGE_BUCKET, - p_user_id: userId, - p_retention_days: this.RETENTION_DAYS, - } - ); - - if (queryError) { - this.logger.error(`Failed to query old files for user ${userId}:`, queryError); - throw new Error(`Query error: ${queryError.message}`); - } - - if (!oldFiles || oldFiles.length === 0) { - this.logger.log(`No old files found for user ${userId}`); - return { filesDeleted: 0, filesFailed: 0, errors: [] }; - } - - this.logger.log(`Found ${oldFiles.length} old files for user ${userId}`); - - // Extract unique memoIds from file paths (format: userId/memoId/filename) - // Only include valid UUIDs (skip folders like "migration-reports") - const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; - const memoIds = new Set(); - for (const file of oldFiles) { - const parts = file.name.split('/'); - if (parts.length >= 2 && UUID_REGEX.test(parts[1])) { - memoIds.add(parts[1]); // memoId is the second part - } - } - - // Delete files in batches - const filePaths = oldFiles.map((f: StorageObject) => f.name); - const result = await this.deleteFilesInBatches(filePaths, userId); - - filesDeleted = result.deleted; - filesFailed = result.failed; - errors.push(...result.errors); - - // Mark memos as audio deleted (only if files were actually deleted) - if (filesDeleted > 0 && memoIds.size > 0) { - await this.markMemosAsAudioDeleted(Array.from(memoIds), userId); - } - - this.logger.log(`User ${userId}: deleted ${filesDeleted} files, failed ${filesFailed}`); - return { filesDeleted, filesFailed, errors }; - } - - /** - * Mark memos as having their audio deleted. - * Updates source.audio_deleted and source.audio_deleted_at fields. - */ - private async markMemosAsAudioDeleted(memoIds: string[], userId: string): Promise { - const deletedAt = new Date().toISOString(); - - for (const memoId of memoIds) { - try { - // First get the current source to merge with - const { data: memo, error: fetchError } = await this.memoroServiceClient - .from('memos') - .select('source') - .eq('id', memoId) - .eq('user_id', userId) - .maybeSingle(); - - if (fetchError) { - this.logger.warn(`Error fetching memo ${memoId}:`, fetchError); - continue; - } - - if (!memo) { - // Memo doesn't exist - this is fine, just skip it - this.logger.log(`Memo ${memoId} not found, skipping source update`); - continue; - } - - // Update source with audio_deleted flag and clear the path - const updatedSource = { - ...memo.source, - audio_path: null, - audio_deleted: true, - audio_deleted_at: deletedAt, - }; - - const { error: updateError } = await this.memoroServiceClient - .from('memos') - .update({ source: updatedSource }) - .eq('id', memoId) - .eq('user_id', userId); - - if (updateError) { - this.logger.warn(`Failed to mark memo ${memoId} as audio deleted:`, updateError); - } else { - this.logger.log(`Marked memo ${memoId} as audio deleted`); - } - } catch (error) { - this.logger.warn(`Error marking memo ${memoId} as audio deleted:`, error); - } - } - } - - /** - * Delete files in batches to avoid rate limits and timeout issues. - */ - private async deleteFilesInBatches( - filePaths: string[], - userId: string - ): Promise<{ deleted: number; failed: number; errors: CleanupError[] }> { - const errors: CleanupError[] = []; - let deleted = 0; - let failed = 0; - - // Process in batches - for (let i = 0; i < filePaths.length; i += this.BATCH_SIZE) { - const batch = filePaths.slice(i, i + this.BATCH_SIZE); - - try { - const { error: deleteError } = await this.memoroServiceClient.storage - .from(this.STORAGE_BUCKET) - .remove(batch); - - if (deleteError) { - this.logger.error(`Batch delete failed:`, deleteError); - failed += batch.length; - errors.push({ - userId, - error: `Batch delete failed: ${deleteError.message}`, - }); - } else { - deleted += batch.length; - this.logger.log( - `Deleted batch of ${batch.length} files (${i + batch.length}/${filePaths.length})` - ); - } - } catch (error) { - this.logger.error(`Batch delete error:`, error); - failed += batch.length; - errors.push({ - userId, - error: error.message || 'Unknown batch delete error', - }); - } - - // Delay between batches - if (i + this.BATCH_SIZE < filePaths.length) { - await this.delay(this.BATCH_DELAY_MS); - } - } - - return { deleted, failed, errors }; - } - - /** - * Get users with audio auto-delete enabled from mana-core-middleware. - */ - private async getUsersWithCleanupEnabled(): Promise { - if (!this.manaCoreMiddlewareUrl || !this.internalApiKey) { - this.logger.warn('MANA_SERVICE_URL or INTERNAL_API_KEY not configured'); - return []; - } - - try { - const response = await fetch( - `${this.manaCoreMiddlewareUrl}/internal/users/audio-cleanup-enabled`, - { - method: 'GET', - headers: { - 'X-Internal-API-Key': this.internalApiKey, - 'Content-Type': 'application/json', - }, - } - ); - - if (!response.ok) { - throw new Error(`Failed to fetch users: ${response.status} ${response.statusText}`); - } - - const data: UserCleanupEnabledResponse = await response.json(); - return data.userIds || []; - } catch (error) { - this.logger.error('Failed to get users with cleanup enabled:', error); - throw error; - } - } - - /** - * Delay helper to avoid rate limits. - */ - private delay(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); - } - - /** - * Log cleanup run to the database for monitoring. - */ - private async logCleanupRun(data: { - usersProcessed: number; - filesDeleted: number; - filesFailed: number; - errors: CleanupError[]; - startedAt: string; - }): Promise { - try { - const { error } = await this.memoroServiceClient.from('audio_cleanup_logs').insert({ - started_at: data.startedAt, - completed_at: new Date().toISOString(), - status: data.errors.length === 0 ? 'completed' : 'completed_with_errors', - users_processed: data.usersProcessed, - files_deleted: data.filesDeleted, - files_failed: data.filesFailed, - error_details: data.errors.length > 0 ? data.errors : null, - }); - - if (error) { - this.logger.warn('Failed to log cleanup run:', error); - } - } catch (error) { - this.logger.warn('Failed to log cleanup run:', error); - } - } -} diff --git a/apps/memoro/apps/backend/src/cleanup/cleanup.module.ts b/apps/memoro/apps/backend/src/cleanup/cleanup.module.ts deleted file mode 100644 index b46ab6443..000000000 --- a/apps/memoro/apps/backend/src/cleanup/cleanup.module.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { Module } from '@nestjs/common'; -import { ConfigModule } from '@nestjs/config'; -import { AudioCleanupService } from './audio-cleanup.service'; -import { AudioCleanupController } from './audio-cleanup.controller'; - -@Module({ - imports: [ConfigModule], - controllers: [AudioCleanupController], - providers: [AudioCleanupService], - exports: [AudioCleanupService], -}) -export class CleanupModule {} diff --git a/apps/memoro/apps/backend/src/cleanup/interfaces/cleanup.interfaces.ts b/apps/memoro/apps/backend/src/cleanup/interfaces/cleanup.interfaces.ts deleted file mode 100644 index ae8ea62de..000000000 --- a/apps/memoro/apps/backend/src/cleanup/interfaces/cleanup.interfaces.ts +++ /dev/null @@ -1,20 +0,0 @@ -export interface CleanupResult { - success: boolean; - usersProcessed: number; - filesDeleted: number; - filesFailed: number; - errors: CleanupError[]; - startedAt: string; - completedAt: string; -} - -export interface CleanupError { - userId?: string; - memoId?: string; - filePath?: string; - error: string; -} - -export interface UserCleanupEnabledResponse { - userIds: string[]; -} diff --git a/apps/memoro/apps/backend/src/credits/credit-client.service.ts b/apps/memoro/apps/backend/src/credits/credit-client.service.ts deleted file mode 100644 index 9c4dbf64e..000000000 --- a/apps/memoro/apps/backend/src/credits/credit-client.service.ts +++ /dev/null @@ -1,279 +0,0 @@ -import { Injectable, BadRequestException, ForbiddenException } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import { InsufficientCreditsException } from '../errors/insufficient-credits.error'; - -export interface CreditCheckResponse { - hasEnoughCredits: boolean; - currentCredits: number; - requiredCredits: number; - creditType: 'user' | 'space'; -} - -export interface CreditConsumptionResponse { - success: boolean; - message: string; - remainingCredits?: number; -} - -@Injectable() -export class CreditClientService { - private readonly manaServiceUrl: string; - - constructor(private configService: ConfigService) { - this.manaServiceUrl = this.configService.get( - 'MANA_SERVICE_URL', - 'http://localhost:3000' - ); - } - - /** - * Check if user has enough personal credits - */ - async checkUserCredits( - userId: string, - requiredCredits: number, - token: string - ): Promise { - try { - const response = await fetch(`${this.manaServiceUrl}/users/credits`, { - method: 'GET', - headers: { - Authorization: `Bearer ${token}`, - 'Content-Type': 'application/json', - }, - }); - - if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - throw new BadRequestException( - `Failed to check user credits: ${errorData.message || response.statusText}` - ); - } - - const data = await response.json(); - const currentCredits = data.credits || 0; - - return { - hasEnoughCredits: currentCredits >= requiredCredits, - currentCredits, - requiredCredits, - creditType: 'user', - }; - } catch (error) { - console.error('Error checking user credits:', error); - throw error; - } - } - - /** - * Check if space has enough credits - */ - async checkSpaceCredits( - spaceId: string, - requiredCredits: number, - token: string - ): Promise { - try { - const response = await fetch(`${this.manaServiceUrl}/spaces/${spaceId}/credits`, { - method: 'GET', - headers: { - Authorization: `Bearer ${token}`, - 'Content-Type': 'application/json', - }, - }); - - if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - throw new BadRequestException( - `Failed to check space credits: ${errorData.message || response.statusText}` - ); - } - - const data = await response.json(); - const currentCredits = data.space?.credits || data.creditSummary?.current_balance || 0; - - return { - hasEnoughCredits: currentCredits >= requiredCredits, - currentCredits, - requiredCredits, - creditType: 'space', - }; - } catch (error) { - console.error('Error checking space credits:', error); - throw error; - } - } - - /** - * Consume credits from user's personal balance - */ - async consumeUserCredits( - userId: string, - amount: number, - token: string, - description?: string - ): Promise { - try { - const response = await fetch(`${this.manaServiceUrl}/users/credits/consume`, { - method: 'POST', - headers: { - Authorization: `Bearer ${token}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - amount, - description: description || `Credit consumption for operation`, - }), - }); - - if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - - if (response.status === 400 && errorData.message?.includes('insufficient')) { - throw new InsufficientCreditsException({ - requiredCredits: amount, - availableCredits: 0, // We don't know the exact amount from this error - creditType: 'user', - operation: 'credit_consumption', - }); - } - - throw new BadRequestException( - `Failed to consume user credits: ${errorData.message || response.statusText}` - ); - } - - const data = await response.json(); - return { - success: true, - message: data.message || 'Credits consumed successfully', - }; - } catch (error) { - console.error('Error consuming user credits:', error); - throw error; - } - } - - /** - * Consume credits from space balance - */ - async consumeSpaceCredits( - spaceId: string, - amount: number, - token: string, - description?: string - ): Promise { - try { - const response = await fetch(`${this.manaServiceUrl}/spaces/${spaceId}/credits/consume`, { - method: 'POST', - headers: { - Authorization: `Bearer ${token}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - amount, - description: description || `Credit consumption for operation`, - }), - }); - - if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - - if (response.status === 400 && errorData.message?.includes('insufficient')) { - throw new InsufficientCreditsException({ - requiredCredits: amount, - availableCredits: 0, // We don't know the exact amount from this error - creditType: 'space', - operation: 'credit_consumption', - }); - } - - throw new BadRequestException( - `Failed to consume space credits: ${errorData.message || response.statusText}` - ); - } - - const data = await response.json(); - return { - success: true, - message: data.message || 'Credits consumed successfully', - }; - } catch (error) { - console.error('Error consuming space credits:', error); - throw error; - } - } - - /** - * Check and consume credits based on operation context - * If spaceId is provided, check space credits first, fall back to user credits - * If no spaceId, use user credits only - */ - async checkAndConsumeCredits( - userId: string, - requiredCredits: number, - token: string, - options: { - spaceId?: string; - description?: string; - operation: string; - } - ): Promise<{ consumed: boolean; creditType: 'user' | 'space'; message: string }> { - const { spaceId, description, operation } = options; - - try { - // If spaceId provided, try space credits first - if (spaceId) { - try { - const spaceCheck = await this.checkSpaceCredits(spaceId, requiredCredits, token); - - if (spaceCheck.hasEnoughCredits) { - await this.consumeSpaceCredits( - spaceId, - requiredCredits, - token, - description || `${operation} operation` - ); - return { - consumed: true, - creditType: 'space', - message: `Consumed ${requiredCredits} credits from space balance`, - }; - } - } catch (spaceError) { - console.warn( - `Space credit check failed, falling back to user credits: ${spaceError.message}` - ); - } - } - - // Use user credits (either as fallback or primary) - const userCheck = await this.checkUserCredits(userId, requiredCredits, token); - - if (!userCheck.hasEnoughCredits) { - throw new InsufficientCreditsException({ - requiredCredits, - availableCredits: userCheck.currentCredits, - creditType: userCheck.creditType, - operation: options.operation, - spaceId: options.spaceId, - }); - } - - await this.consumeUserCredits( - userId, - requiredCredits, - token, - description || `${operation} operation` - ); - return { - consumed: true, - creditType: 'user', - message: `Consumed ${requiredCredits} credits from user balance`, - }; - } catch (error) { - console.error(`Credit check and consumption failed for ${operation}:`, error); - throw error; - } - } -} diff --git a/apps/memoro/apps/backend/src/credits/credit-consumption.service.spec.ts b/apps/memoro/apps/backend/src/credits/credit-consumption.service.spec.ts deleted file mode 100644 index 087d0e269..000000000 --- a/apps/memoro/apps/backend/src/credits/credit-consumption.service.spec.ts +++ /dev/null @@ -1,532 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { ConfigService } from '@nestjs/config'; -import { BadRequestException, ForbiddenException } from '@nestjs/common'; -import { CreditConsumptionService, CreditConsumptionResult } from './credit-consumption.service'; -import * as jwt from 'jsonwebtoken'; - -jest.mock('jsonwebtoken'); -global.fetch = jest.fn(); - -describe('CreditConsumptionService', () => { - let service: CreditConsumptionService; - let configService: jest.Mocked; - - const mockUserId = 'user-123'; - const mockSpaceId = 'space-123'; - const mockUserToken = 'user-jwt-token'; - const mockServiceToken = 'service-jwt-token'; - const mockJwtSecret = 'test-secret'; - const mockManaServiceUrl = 'https://mana-service.example.com'; - - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - providers: [ - CreditConsumptionService, - { - provide: ConfigService, - useValue: { - get: jest.fn((key: string) => { - const config: Record = { - MANA_SERVICE_URL: mockManaServiceUrl, - MANA_JWT_SECRET: mockJwtSecret, - MEMORO_APP_ID: 'test-app-id', - }; - return config[key]; - }), - }, - }, - ], - }).compile(); - - service = module.get(CreditConsumptionService); - configService = module.get(ConfigService); - - // Clear mocks - (global.fetch as jest.Mock).mockClear(); - (jwt.sign as jest.Mock).mockClear(); - }); - - afterEach(() => { - jest.clearAllMocks(); - }); - - it('should be defined', () => { - expect(service).toBeDefined(); - }); - - describe('getServiceRoleToken', () => { - it('should generate and cache a service role token', async () => { - const mockToken = 'generated-service-token'; - (jwt.sign as jest.Mock).mockReturnValue(mockToken); - - // Access private method through any type casting - const token = await (service as any).getServiceRoleToken(); - - expect(token).toBe(mockToken); - expect(jwt.sign).toHaveBeenCalledWith( - expect.objectContaining({ - sub: 'memoro-service', - role: 'platform_admin', - app_id: 'test-app-id', - service: 'memoro-service', - }), - mockJwtSecret - ); - }); - - it('should reuse cached token if still valid', async () => { - const mockToken = 'cached-service-token'; - (jwt.sign as jest.Mock).mockReturnValue(mockToken); - - // First call - generates new token - const token1 = await (service as any).getServiceRoleToken(); - expect(jwt.sign).toHaveBeenCalledTimes(1); - - // Second call - should use cached token - const token2 = await (service as any).getServiceRoleToken(); - expect(token1).toBe(token2); - expect(jwt.sign).toHaveBeenCalledTimes(1); // Still only called once - }); - - it('should throw error if JWT secret is not configured', async () => { - configService.get.mockImplementation((key: string) => { - if (key === 'MANA_JWT_SECRET') return undefined; - return 'value'; - }); - - await expect((service as any).getServiceRoleToken()).rejects.toThrow( - 'Service role token generation failed: MANA_JWT_SECRET not configured' - ); - }); - }); - - describe('consumeCreditsForOperation', () => { - beforeEach(() => { - (jwt.sign as jest.Mock).mockReturnValue(mockServiceToken); - }); - - it('should successfully consume credits for an operation', async () => { - const mockResponse: CreditConsumptionResult = { - success: true, - creditsConsumed: 10, - creditType: 'user', - remainingCredits: 90, - message: 'Credits consumed successfully', - }; - - (global.fetch as jest.Mock).mockResolvedValueOnce({ - ok: true, - json: async () => mockResponse, - }); - - const result = await service.consumeCreditsForOperation( - mockUserId, - 'transcription', - 10, - 'Test transcription', - { memoId: 'memo-123' }, - undefined, - mockUserToken - ); - - expect(result).toEqual({ - success: true, - creditsConsumed: 10, - creditType: 'user', - remainingCredits: 90, - message: 'Credits consumed successfully', - }); - - expect(global.fetch).toHaveBeenCalledWith( - `${mockManaServiceUrl}/credits/consume`, - expect.objectContaining({ - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${mockUserToken}`, - 'X-Service-Auth': 'memoro-service', - }, - }) - ); - - // Check body separately - const fetchCall = (global.fetch as jest.Mock).mock.calls[0]; - const bodyData = JSON.parse(fetchCall[1].body); - expect(bodyData).toEqual({ - userId: mockUserId, - amount: 10, - operation: 'transcription', - description: 'Test transcription', - metadata: expect.objectContaining({ - memoId: 'memo-123', - service: 'memoro-service', - timestamp: expect.any(String), - }), - spaceId: undefined, - }); - }); - - it('should consume space credits when spaceId is provided', async () => { - const mockResponse: CreditConsumptionResult = { - success: true, - creditsConsumed: 10, - creditType: 'space', - remainingCredits: 190, - message: 'Credits consumed successfully', - }; - - (global.fetch as jest.Mock).mockResolvedValueOnce({ - ok: true, - json: async () => mockResponse, - }); - - const result = await service.consumeCreditsForOperation( - mockUserId, - 'transcription', - 10, - 'Test transcription', - {}, - mockSpaceId, - mockUserToken - ); - - expect(result.creditType).toBe('space'); - expect(global.fetch).toHaveBeenCalledWith( - expect.any(String), - expect.objectContaining({ - body: expect.stringContaining(`"spaceId":"${mockSpaceId}"`), - }) - ); - }); - - it('should throw BadRequestException for invalid inputs', async () => { - await expect( - service.consumeCreditsForOperation( - '', - 'transcription', - 10, - 'Test', - {}, - undefined, - mockUserToken - ) - ).rejects.toThrow(BadRequestException); - - await expect( - service.consumeCreditsForOperation( - mockUserId, - 'transcription', - 0, - 'Test', - {}, - undefined, - mockUserToken - ) - ).rejects.toThrow(BadRequestException); - - await expect( - service.consumeCreditsForOperation( - mockUserId, - 'transcription', - 10, - 'Test', - {}, - undefined, - '' - ) - ).rejects.toThrow(BadRequestException); - }); - - it('should handle insufficient credits gracefully', async () => { - (global.fetch as jest.Mock).mockResolvedValueOnce({ - ok: false, - status: 400, - statusText: 'Bad Request', - json: async () => ({ message: 'insufficient credits' }), - }); - - const result = await service.consumeCreditsForOperation( - mockUserId, - 'transcription', - 100, - 'Test', - {}, - undefined, - mockUserToken - ); - - expect(result).toEqual({ - success: false, - creditsConsumed: 0, - creditType: 'user', - message: 'Insufficient credits. Required: 100', - error: 'insufficient credits', - }); - }); - - it('should handle server errors', async () => { - (global.fetch as jest.Mock).mockResolvedValueOnce({ - ok: false, - status: 500, - statusText: 'Internal Server Error', - json: async () => ({ message: 'Server error' }), - }); - - const result = await service.consumeCreditsForOperation( - mockUserId, - 'transcription', - 10, - 'Test', - {}, - undefined, - mockUserToken - ); - - expect(result).toEqual({ - success: false, - creditsConsumed: 0, - creditType: 'user', - message: 'Credit consumption failed', - error: 'Credit consumption failed: Server error', - }); - }); - - it('should handle network errors', async () => { - (global.fetch as jest.Mock).mockRejectedValueOnce(new Error('Network error')); - - const result = await service.consumeCreditsForOperation( - mockUserId, - 'transcription', - 10, - 'Test', - {}, - undefined, - mockUserToken - ); - - expect(result).toEqual({ - success: false, - creditsConsumed: 0, - creditType: 'user', - message: 'Credit consumption failed', - error: 'Network error', - }); - }); - }); - - describe('convenience methods', () => { - beforeEach(() => { - jest.spyOn(service, 'consumeCreditsForOperation').mockResolvedValue({ - success: true, - creditsConsumed: 10, - creditType: 'user', - message: 'Success', - }); - }); - - it('should consume transcription credits', async () => { - await service.consumeTranscriptionCredits( - mockUserId, - 5, - 10, - 'memo-123', - 'fast', - mockSpaceId, - mockUserToken - ); - - expect(service.consumeCreditsForOperation).toHaveBeenCalledWith( - mockUserId, - 'transcription', - 10, - 'Transcription completed via fast route for memo memo-123', - { - memoId: 'memo-123', - route: 'fast', - durationMinutes: 5, - actualCost: 10, - }, - mockSpaceId, - mockUserToken - ); - }); - - it('should consume question credits', async () => { - const questionText = 'What is the main topic discussed?'; - - await service.consumeQuestionCredits( - mockUserId, - 'memo-123', - questionText, - mockSpaceId, - mockUserToken - ); - - expect(service.consumeCreditsForOperation).toHaveBeenCalledWith( - mockUserId, - 'question', - 5, - 'Question asked on memo memo-123', - { - memoId: 'memo-123', - questionLength: questionText.length, - questionPreview: questionText, - }, - mockSpaceId, - mockUserToken - ); - }); - - it('should consume combination credits', async () => { - const memoIds = ['memo-1', 'memo-2', 'memo-3']; - - await service.consumeCombinationCredits(mockUserId, memoIds, mockSpaceId, mockUserToken); - - expect(service.consumeCreditsForOperation).toHaveBeenCalledWith( - mockUserId, - 'combination', - 15, // 5 credits per memo - 'Combined 3 memos', - { - memoCount: 3, - memoIds, - }, - mockSpaceId, - mockUserToken - ); - }); - - it('should consume blueprint credits', async () => { - await service.consumeBlueprintCredits( - mockUserId, - 'blueprint-123', - 'memo-123', - mockSpaceId, - mockUserToken - ); - - expect(service.consumeCreditsForOperation).toHaveBeenCalledWith( - mockUserId, - 'blueprint', - 5, - 'Blueprint blueprint-123 applied to memo memo-123', - { - blueprintId: 'blueprint-123', - memoId: 'memo-123', - }, - mockSpaceId, - mockUserToken - ); - }); - - it('should consume headline credits', async () => { - await service.consumeHeadlineCredits(mockUserId, 'memo-123', mockSpaceId, mockUserToken); - - expect(service.consumeCreditsForOperation).toHaveBeenCalledWith( - mockUserId, - 'headline', - 10, - 'Headline generation for memo memo-123', - { - memoId: 'memo-123', - }, - mockSpaceId, - mockUserToken - ); - }); - }); - - describe('validateCreditsForOperation', () => { - beforeEach(() => { - (jwt.sign as jest.Mock).mockReturnValue(mockServiceToken); - }); - - it('should validate credits successfully', async () => { - (global.fetch as jest.Mock).mockResolvedValueOnce({ - ok: true, - json: async () => ({ - valid: true, - availableCredits: 100, - }), - }); - - const result = await service.validateCreditsForOperation( - mockUserId, - 'transcription', - 10, - mockSpaceId - ); - - expect(result).toEqual({ - hasEnoughCredits: true, - availableCredits: 100, - requiredCredits: 10, - }); - }); - - it('should handle validation failure', async () => { - (global.fetch as jest.Mock).mockResolvedValueOnce({ - ok: false, - json: async () => ({ message: 'Insufficient credits' }), - }); - - const result = await service.validateCreditsForOperation(mockUserId, 'transcription', 100); - - expect(result).toEqual({ - hasEnoughCredits: false, - availableCredits: 0, - requiredCredits: 100, - }); - }); - }); - - describe('getCurrentCredits', () => { - beforeEach(() => { - (jwt.sign as jest.Mock).mockReturnValue(mockServiceToken); - }); - - it('should get current credits for user', async () => { - (global.fetch as jest.Mock).mockResolvedValueOnce({ - ok: true, - json: async () => ({ credits: 100 }), - }); - - const result = await service.getCurrentCredits(mockUserId); - - expect(result).toEqual({ - userCredits: 100, - spaceCredits: undefined, - }); - }); - - it('should get both user and space credits', async () => { - (global.fetch as jest.Mock) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ credits: 100 }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ creditSummary: { current_balance: 200 } }), - }); - - const result = await service.getCurrentCredits(mockUserId, mockSpaceId); - - expect(result).toEqual({ - userCredits: 100, - spaceCredits: 200, - }); - }); - - it('should handle errors gracefully', async () => { - (global.fetch as jest.Mock).mockRejectedValue(new Error('Network error')); - - const result = await service.getCurrentCredits(mockUserId, mockSpaceId); - - expect(result).toEqual({ - userCredits: 0, - spaceCredits: undefined, - }); - }); - }); -}); diff --git a/apps/memoro/apps/backend/src/credits/credit-consumption.service.ts b/apps/memoro/apps/backend/src/credits/credit-consumption.service.ts deleted file mode 100644 index bc02b5dc0..000000000 --- a/apps/memoro/apps/backend/src/credits/credit-consumption.service.ts +++ /dev/null @@ -1,452 +0,0 @@ -import { Injectable, Logger, BadRequestException, ForbiddenException } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import { InsufficientCreditsException } from '../errors/insufficient-credits.error'; - -export interface CreditConsumptionResult { - success: boolean; - creditsConsumed: number; - creditType: 'user' | 'space'; - remainingCredits?: number; - message: string; - error?: string; -} - -export interface CreditOperationMetadata { - memoId?: string; - route?: string; - durationMinutes?: number; - actualCost?: number; - operationId?: string; - [key: string]: any; -} - -export type CreditOperation = - | 'transcription' - | 'question' - | 'combination' - | 'blueprint' - | 'headline' - | 'memory_creation' - | 'memo_sharing' - | 'space_operation' - | 'meeting_recording'; - -@Injectable() -export class CreditConsumptionService { - private readonly logger = new Logger(CreditConsumptionService.name); - private readonly manaServiceUrl: string; - private readonly manaServiceKey: string; - private readonly appId: string; - - constructor(private configService: ConfigService) { - this.manaServiceUrl = - this.configService.get('MANA_SERVICE_URL') || - 'https://mana-core-middleware-111768794939.europe-west3.run.app'; - this.manaServiceUrl = this.manaServiceUrl.replace(/\/$/, ''); - this.manaServiceKey = this.configService.get('MANA_SUPABASE_SECRET_KEY'); - this.appId = this.configService.get('MEMORO_APP_ID'); - - if (!this.appId) { - throw new Error('MEMORO_APP_ID environment variable is required'); - } - } - - /** - * Centralized credit consumption for all operations - * Uses the existing user JWT token to work with RLS - */ - async consumeCreditsForOperation( - userId: string, - operation: CreditOperation, - amount: number, - description: string, - metadata: CreditOperationMetadata = {}, - spaceId?: string, - userToken?: string - ): Promise { - try { - this.logger.log( - `[consumeCreditsForOperation] ${operation}: ${amount} credits for user ${userId}${spaceId ? ` in space ${spaceId}` : ''}` - ); - - // Input validation - if (!userId) { - throw new BadRequestException('User ID is required'); - } - if (amount <= 0) { - throw new BadRequestException('Credit amount must be positive'); - } - // Determine if we're using service auth or user auth - const isServiceAuth = !userToken; - - // Prepare request body for mana-core-middleware - const consumeBody = { - userId, - appId: this.appId, - amount, - operation, - description, - metadata: { - ...metadata, - service: 'memoro-service', - timestamp: new Date().toISOString(), - }, - spaceId, - }; - - let response; - - if (isServiceAuth) { - // Use service authentication endpoint - this.logger.log(`[consumeCreditsForOperation] Using service auth for user ${userId}`); - - if (!this.manaServiceKey) { - throw new Error('MANA_SUPABASE_SECRET_KEY not configured'); - } - - // Use service endpoint with different body structure - const serviceBody = { - userId, - appId: this.appId, - amount, - operationType: operation, - description, - operationDetails: metadata, - spaceId, - }; - - response = await fetch(`${this.manaServiceUrl}/credits/service/consume`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${this.manaServiceKey}`, - 'X-Service-Auth': 'memoro-service', - }, - body: JSON.stringify(serviceBody), - }); - } else { - // Use regular user token auth - this.logger.log( - `[consumeCreditsForOperation] Using user token: ${userToken.substring(0, 50)}...` - ); - - // Try to decode token payload for debugging (without verification) - try { - const parts = userToken.split('.'); - if (parts.length === 3) { - const payload = parts[1]; - const paddedPayload = payload + '='.repeat((4 - (payload.length % 4)) % 4); - const decodedPayload = Buffer.from(paddedPayload, 'base64').toString(); - const tokenData = JSON.parse(decodedPayload); - this.logger.log( - `[consumeCreditsForOperation] Token payload:`, - JSON.stringify(tokenData, null, 2) - ); - this.logger.log( - `[consumeCreditsForOperation] Token has app_id: ${tokenData.app_id}, sub: ${tokenData.sub}, aud: ${tokenData.aud}` - ); - } - } catch (decodeError) { - this.logger.warn( - `[consumeCreditsForOperation] Could not decode token for debugging:`, - decodeError.message - ); - } - - response = await fetch(`${this.manaServiceUrl}/credits/consume`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${userToken}`, - 'X-Service-Auth': 'memoro-service', - }, - body: JSON.stringify(consumeBody), - }); - } - - if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - const errorMessage = errorData.message || `HTTP ${response.status}: ${response.statusText}`; - - this.logger.error( - `[consumeCreditsForOperation] Credit consumption failed: ${response.status} - ${errorMessage}` - ); - - if (response.status === 400 && errorMessage.toLowerCase().includes('insufficient')) { - // Try to extract available credits from error message if possible - const availableMatch = errorMessage.match(/Available:\s*(\d+)/); - const availableCredits = availableMatch ? parseInt(availableMatch[1]) : 0; - - throw new InsufficientCreditsException({ - requiredCredits: amount, - availableCredits, - creditType: spaceId ? 'space' : 'user', - operation, - spaceId, - }); - } - - throw new Error(`Credit consumption failed: ${errorMessage}`); - } - - const result = await response.json(); - - this.logger.log( - `[consumeCreditsForOperation] Successfully consumed ${amount} credits for ${operation}` - ); - - // Note: Frontend will refresh credits periodically or after operations - - return { - success: true, - creditsConsumed: amount, - creditType: result.creditType || (spaceId ? 'space' : 'user'), - remainingCredits: result.remainingCredits, - message: result.message || 'Credits consumed successfully', - }; - } catch (error) { - this.logger.error( - `[consumeCreditsForOperation] Error consuming credits for ${operation}:`, - error - ); - - if ( - error instanceof BadRequestException || - error instanceof ForbiddenException || - error instanceof InsufficientCreditsException - ) { - throw error; - } - - return { - success: false, - creditsConsumed: 0, - creditType: spaceId ? 'space' : 'user', - message: 'Credit consumption failed', - error: error.message, - }; - } - } - - /** - * Convenience methods for specific operations - */ - async consumeTranscriptionCredits( - userId: string, - durationMinutes: number, - actualCost: number, - memoId: string, - route: 'fast' | 'batch', - spaceId?: string, - userToken?: string - ): Promise { - return this.consumeCreditsForOperation( - userId, - 'transcription', - actualCost, - `Transcription completed via ${route} route for memo ${memoId}`, - { - memoId, - route, - durationMinutes, - actualCost, - }, - spaceId, - userToken - ); - } - - async consumeQuestionCredits( - userId: string, - memoId: string, - questionText: string, - spaceId?: string, - userToken?: string - ): Promise { - const questionCost = 5; // Standard question cost - return this.consumeCreditsForOperation( - userId, - 'question', - questionCost, - `Question asked on memo ${memoId}`, - { - memoId, - questionLength: questionText.length, - questionPreview: questionText.substring(0, 100), - }, - spaceId, - userToken - ); - } - - async consumeCombinationCredits( - userId: string, - memoIds: string[], - spaceId?: string, - userToken?: string - ): Promise { - const combinationCost = memoIds.length * 5; // 5 credits per memo - return this.consumeCreditsForOperation( - userId, - 'combination', - combinationCost, - `Combined ${memoIds.length} memos`, - { - memoCount: memoIds.length, - memoIds, - }, - spaceId, - userToken - ); - } - - async consumeBlueprintCredits( - userId: string, - blueprintId: string, - memoId: string, - spaceId?: string, - userToken?: string - ): Promise { - const blueprintCost = 5; // Standard blueprint cost - return this.consumeCreditsForOperation( - userId, - 'blueprint', - blueprintCost, - `Blueprint ${blueprintId} applied to memo ${memoId}`, - { - blueprintId, - memoId, - }, - spaceId, - userToken - ); - } - - async consumeHeadlineCredits( - userId: string, - memoId: string, - spaceId?: string, - userToken?: string - ): Promise { - const headlineCost = 10; // Standard headline cost - return this.consumeCreditsForOperation( - userId, - 'headline', - headlineCost, - `Headline generation for memo ${memoId}`, - { - memoId, - }, - spaceId, - userToken - ); - } - - /** - * Validate credits before operation (pre-flight check) - */ - async validateCreditsForOperation( - userId: string, - operation: CreditOperation, - amount: number, - spaceId?: string - ): Promise<{ hasEnoughCredits: boolean; availableCredits: number; requiredCredits: number }> { - try { - if (!this.manaServiceKey) { - throw new Error('MANA_SUPABASE_SECRET_KEY not configured'); - } - - const response = await fetch(`${this.manaServiceUrl}/credits/service/validate`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${this.manaServiceKey}`, - 'X-Service-Auth': 'memoro-service', - }, - body: JSON.stringify({ - userId, - amount, - spaceId, - operation, - }), - }); - - if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - this.logger.warn(`Credit validation failed: ${errorData.message}`); - return { - hasEnoughCredits: false, - availableCredits: 0, - requiredCredits: amount, - }; - } - - const result = await response.json(); - return { - // mana-core returns { hasCredits, balance } - hasEnoughCredits: result.hasCredits || result.valid || false, - availableCredits: result.balance || result.availableCredits || 0, - requiredCredits: amount, - }; - } catch (error) { - this.logger.error('Error validating credits:', error); - return { - hasEnoughCredits: false, - availableCredits: 0, - requiredCredits: amount, - }; - } - } - - /** - * Get current credit balance for user - */ - async getCurrentCredits( - userId: string, - spaceId?: string - ): Promise<{ userCredits: number; spaceCredits?: number }> { - try { - if (!this.manaServiceKey) { - throw new Error('MANA_SUPABASE_SECRET_KEY not configured'); - } - - // Get user credits - const userResponse = await fetch(`${this.manaServiceUrl}/users/credits`, { - method: 'GET', - headers: { - Authorization: `Bearer ${this.manaServiceKey}`, - 'X-Service-Auth': 'memoro-service', - 'X-User-ID': userId, // Pass user ID in header for service role requests - }, - }); - - let userCredits = 0; - if (userResponse.ok) { - const userData = await userResponse.json(); - userCredits = userData.credits || 0; - } - - let spaceCredits = undefined; - if (spaceId) { - const spaceResponse = await fetch(`${this.manaServiceUrl}/spaces/${spaceId}/credits`, { - method: 'GET', - headers: { - Authorization: `Bearer ${this.manaServiceKey}`, - 'X-Service-Auth': 'memoro-service', - 'X-User-ID': userId, - }, - }); - - if (spaceResponse.ok) { - const spaceData = await spaceResponse.json(); - spaceCredits = spaceData.creditSummary?.current_balance || 0; - } - } - - return { userCredits, spaceCredits }; - } catch (error) { - this.logger.error('Error getting current credits:', error); - return { userCredits: 0, spaceCredits: undefined }; - } - } -} diff --git a/apps/memoro/apps/backend/src/credits/credit.controller.ts b/apps/memoro/apps/backend/src/credits/credit.controller.ts deleted file mode 100644 index bffa1eedd..000000000 --- a/apps/memoro/apps/backend/src/credits/credit.controller.ts +++ /dev/null @@ -1,227 +0,0 @@ -import { Controller, Post, Body, UseGuards, BadRequestException, Get } from '@nestjs/common'; -import { AuthGuard } from '../guards/auth.guard'; -import { User } from '../decorators/user.decorator'; -import { CreditClientService } from './credit-client.service'; -import { - calculateTranscriptionCost, - calculateTranscriptionCostByLength, - OPERATION_COSTS, -} from './pricing.constants'; -import { InsufficientCreditsException } from '../errors/insufficient-credits.error'; - -// DTOs for credit operations -class CheckTranscriptionCreditsDto { - durationSeconds?: number; - transcriptLength?: number; - spaceId?: string; -} - -class ConsumeTranscriptionCreditsDto { - durationSeconds?: number; - transcriptLength?: number; - spaceId?: string; - description?: string; -} - -class ConsumeOperationCreditsDto { - operation: - | 'HEADLINE_GENERATION' - | 'MEMORY_CREATION' - | 'BLUEPRINT_PROCESSING' - | 'QUESTION_MEMO' - | 'NEW_MEMORY' - | 'MEMO_COMBINE'; - spaceId?: string; - description?: string; - memoId?: string; - memoCount?: number; // For MEMO_COMBINE operation -} - -@Controller('memoro/credits') -export class CreditController { - constructor(private readonly creditClientService: CreditClientService) {} - - @Get('pricing') - async getPricing() { - return { - operationCosts: OPERATION_COSTS, - transcriptionPerHour: OPERATION_COSTS.TRANSCRIPTION_PER_MINUTE * 60, - lastUpdated: new Date().toISOString(), - }; - } - - @Post('check-transcription') - @UseGuards(AuthGuard) - async checkTranscriptionCredits(@User() user: any, @Body() dto: CheckTranscriptionCreditsDto) { - if (!dto.durationSeconds && !dto.transcriptLength) { - throw new BadRequestException('Either durationSeconds or transcriptLength must be provided'); - } - - // Extract token from request - const token = user.token; - - // Calculate required credits using new length-based or duration-based pricing - const requiredCredits = calculateTranscriptionCostByLength( - dto.transcriptLength, - dto.durationSeconds - ); - - try { - // If spaceId is provided, check space credits first - if (dto.spaceId) { - try { - const spaceCheck = await this.creditClientService.checkSpaceCredits( - dto.spaceId, - requiredCredits, - token - ); - - return { - hasEnoughCredits: spaceCheck.hasEnoughCredits, - requiredCredits, - currentCredits: spaceCheck.currentCredits, - creditType: 'space', - }; - } catch (error) { - console.warn('Space credit check failed, falling back to user credits:', error.message); - } - } - - // Check user credits - const userCheck = await this.creditClientService.checkUserCredits( - user.sub, - requiredCredits, - token - ); - - return { - hasEnoughCredits: userCheck.hasEnoughCredits, - requiredCredits, - currentCredits: userCheck.currentCredits, - creditType: 'user', - }; - } catch (error) { - if (error instanceof InsufficientCreditsException) { - throw error; // Let the exception propagate with 402 status - } - throw new BadRequestException(`Failed to check credits: ${error.message}`); - } - } - - @Post('consume-transcription') - @UseGuards(AuthGuard) - async consumeTranscriptionCredits( - @User() user: any, - @Body() dto: ConsumeTranscriptionCreditsDto - ) { - if (!dto.durationSeconds && !dto.transcriptLength) { - throw new BadRequestException('Either durationSeconds or transcriptLength must be provided'); - } - - // Extract token from request - const token = user.token; - - // Calculate required credits using new length-based or duration-based pricing - const requiredCredits = calculateTranscriptionCostByLength( - dto.transcriptLength, - dto.durationSeconds - ); - - const description = - dto.description || - (dto.transcriptLength - ? `Transcription (${dto.transcriptLength} chars)` - : `Transcription (${dto.durationSeconds}s)`); - - try { - const result = await this.creditClientService.checkAndConsumeCredits( - user.sub, - requiredCredits, - token, - { - spaceId: dto.spaceId, - description, - operation: 'TRANSCRIPTION', - } - ); - - return { - success: true, - creditsConsumed: requiredCredits, - creditType: result.creditType, - message: result.message, - }; - } catch (error) { - if (error instanceof InsufficientCreditsException) { - throw error; // Let the exception propagate with 402 status - } - throw new BadRequestException(`Failed to consume credits: ${error.message}`); - } - } - - @Post('consume-operation') - @UseGuards(AuthGuard) - async consumeOperationCredits(@User() user: any, @Body() dto: ConsumeOperationCreditsDto) { - // Validate operation type - const validOperations = [ - 'HEADLINE_GENERATION', - 'MEMORY_CREATION', - 'BLUEPRINT_PROCESSING', - 'QUESTION_MEMO', - 'NEW_MEMORY', - 'MEMO_COMBINE', - ]; - if (!validOperations.includes(dto.operation)) { - throw new BadRequestException( - `Invalid operation type. Must be one of: ${validOperations.join(', ')}` - ); - } - - // Extract token from request - const token = user.token; - - // Define credit costs for different operations - const creditCosts = { - HEADLINE_GENERATION: 10, - MEMORY_CREATION: 10, - BLUEPRINT_PROCESSING: 5, - QUESTION_MEMO: 5, - NEW_MEMORY: 5, - MEMO_COMBINE: 5, - }; - - // Calculate required credits based on operation - let requiredCredits = creditCosts[dto.operation]; - - // For MEMO_COMBINE, multiply by the number of memos - if (dto.operation === 'MEMO_COMBINE' && dto.memoCount) { - requiredCredits = requiredCredits * dto.memoCount; - } - const description = dto.description || `${dto.operation} operation`; - - try { - const result = await this.creditClientService.checkAndConsumeCredits( - user.sub, - requiredCredits, - token, - { - spaceId: dto.spaceId, - description, - operation: dto.operation, - } - ); - - return { - success: true, - creditsConsumed: requiredCredits, - creditType: result.creditType, - message: result.message, - }; - } catch (error) { - if (error instanceof InsufficientCreditsException) { - throw error; // Let the exception propagate with 402 status - } - throw new BadRequestException(`Failed to consume credits: ${error.message}`); - } - } -} diff --git a/apps/memoro/apps/backend/src/credits/credits.module.ts b/apps/memoro/apps/backend/src/credits/credits.module.ts deleted file mode 100644 index 48b210466..000000000 --- a/apps/memoro/apps/backend/src/credits/credits.module.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { Module } from '@nestjs/common'; -import { ConfigModule } from '@nestjs/config'; -import { AuthModule } from '../auth/auth.module'; -import { CreditClientService } from './credit-client.service'; -import { CreditController } from './credit.controller'; -import { CreditConsumptionService } from './credit-consumption.service'; - -@Module({ - imports: [ConfigModule, AuthModule], - controllers: [CreditController], - providers: [CreditClientService, CreditConsumptionService], - exports: [CreditClientService, CreditConsumptionService], -}) -export class CreditsModule {} diff --git a/apps/memoro/apps/backend/src/credits/pricing.constants.ts b/apps/memoro/apps/backend/src/credits/pricing.constants.ts deleted file mode 100644 index afe2d30c6..000000000 --- a/apps/memoro/apps/backend/src/credits/pricing.constants.ts +++ /dev/null @@ -1,99 +0,0 @@ -/** - * Pricing constants for various operations in the memoro service - * These should match the costs defined in the app's appCosts.json - */ - -export const OPERATION_COSTS = { - // Transcription costs - TRANSCRIPTION_PER_MINUTE: 2, // 2 credits per minute of audio - - // Meeting recording costs - MEETING_RECORDING_PER_MINUTE: 2, // 2 credits per minute of recording (same as transcription) - - // Memory/headline generation - HEADLINE_GENERATION: 10, - MEMORY_CREATION: 10, - - // Blueprint operations - BLUEPRINT_PROCESSING: 5, - - // Question/Memory processing - QUESTION_MEMO: 5, // 5 mana per question to memo - NEW_MEMORY: 5, // 5 mana per new memory creation - MEMO_COMBINE: 5, // 5 mana per memo when combining - - // Other operations - MEMO_SHARING: 1, - SPACE_OPERATION: 2, -} as const; - -/** - * Calculate transcription cost based on audio duration - * @param durationSeconds - Duration of audio in seconds - * @returns Number of credits required (2 credits per minute, minimum 2 credits) - */ -export function calculateTranscriptionCost(durationSeconds: number): number { - // Log the input for debugging - console.log( - `[calculateTranscriptionCost] Input duration: ${durationSeconds} seconds (${(durationSeconds / 60).toFixed(2)} minutes)` - ); - - const minutes = durationSeconds / 60; // Convert seconds to minutes - const cost = Math.ceil(minutes * OPERATION_COSTS.TRANSCRIPTION_PER_MINUTE); - - // Apply minimum cost of 2 credits (1 minute worth) to prevent undercharging - const finalCost = Math.max(cost, 2); - - console.log( - `[calculateTranscriptionCost] Calculated cost: ${cost}, Final cost (with minimum): ${finalCost} credits` - ); - - return finalCost; -} - -/** - * Calculate memo combination cost based on number of memos - * @param memoCount - Number of memos being combined - * @returns Number of credits required - */ -export function calculateMemoCombineCost(memoCount: number): number { - return memoCount * OPERATION_COSTS.MEMO_COMBINE; -} - -/** - * Calculate transcription cost with length-based pricing - * Uses existing per-minute pricing but ensures proper length-based calculation - * @param transcriptLength - Length of transcript in characters - * @param durationSeconds - Duration of audio in seconds (fallback if no transcript length) - * @returns Number of credits required - */ -export function calculateTranscriptionCostByLength( - transcriptLength?: number, - durationSeconds?: number -): number { - // If we have transcript length, use it to estimate duration - if (transcriptLength) { - // Estimate: ~150 words per minute, ~5 characters per word - const estimatedWords = transcriptLength / 5; - const estimatedMinutes = estimatedWords / 150; - const estimatedSeconds = estimatedMinutes * 60; - return calculateTranscriptionCost(estimatedSeconds); - } - - // Fall back to duration-based calculation - if (durationSeconds) { - return calculateTranscriptionCost(durationSeconds); - } - - // Throw error if no length or duration provided - throw new Error('Cannot calculate transcription cost: no transcript length or duration provided'); -} - -/** - * Get operation cost by operation type - * @param operation - The operation type - * @returns Number of credits required - */ -export function getOperationCost(operation: keyof typeof OPERATION_COSTS): number { - return OPERATION_COSTS[operation]; -} diff --git a/apps/memoro/apps/backend/src/debug-test.ts b/apps/memoro/apps/backend/src/debug-test.ts deleted file mode 100644 index d35f89991..000000000 --- a/apps/memoro/apps/backend/src/debug-test.ts +++ /dev/null @@ -1,51 +0,0 @@ -// Debug test file to verify logging in Cloud Run -import { NestFactory } from '@nestjs/core'; -import { AppModule } from './app.module'; - -async function debugTest() { - // Force all debug logs to use console.error for visibility - console.error('[DEBUG TEST 1] Starting debug test - console.error'); - console.log('[DEBUG TEST 2] Starting debug test - console.log'); - console.warn('[DEBUG TEST 3] Starting debug test - console.warn'); - - // Log process info - console.error('[DEBUG TEST] Process info:', { - nodeVersion: process.version, - platform: process.platform, - pid: process.pid, - cwd: process.cwd(), - execPath: process.execPath, - }); - - // Log all environment variables (be careful with sensitive data) - console.error('[DEBUG TEST] Environment variables count:', Object.keys(process.env).length); - console.error('[DEBUG TEST] NODE_ENV:', process.env.NODE_ENV); - console.error('[DEBUG TEST] PORT:', process.env.PORT); - console.error('[DEBUG TEST] AUDIO_MICROSERVICE_URL:', process.env.AUDIO_MICROSERVICE_URL); - - // Check if dist files exist - const fs = require('fs'); - const path = require('path'); - const mainPath = path.join(__dirname, 'main.js'); - console.error('[DEBUG TEST] Current file location:', __filename); - console.error('[DEBUG TEST] Main.js exists:', fs.existsSync(mainPath)); - - // Create the app to test NestJS logging - try { - const app = await NestFactory.create(AppModule, { - logger: ['error', 'warn', 'log', 'debug', 'verbose'], - }); - - console.error('[DEBUG TEST] NestJS app created successfully'); - - // Don't actually start the server, just test creation - await app.close(); - console.error('[DEBUG TEST] Test completed successfully'); - } catch (error) { - console.error('[DEBUG TEST] Error creating app:', error); - } - - process.exit(0); -} - -debugTest(); diff --git a/apps/memoro/apps/backend/src/decorators/user.decorator.ts b/apps/memoro/apps/backend/src/decorators/user.decorator.ts deleted file mode 100644 index 7146d5b6e..000000000 --- a/apps/memoro/apps/backend/src/decorators/user.decorator.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { createParamDecorator, ExecutionContext } from '@nestjs/common'; -import { JwtPayload } from '../types/jwt-payload.interface'; - -export const User = createParamDecorator( - (data: unknown, ctx: ExecutionContext): JwtPayload & { token: string } => { - const request = ctx.switchToHttp().getRequest(); - return { - ...request.user, - token: request.token, - }; - } -); diff --git a/apps/memoro/apps/backend/src/errors/README.md b/apps/memoro/apps/backend/src/errors/README.md deleted file mode 100644 index 84e2d3be4..000000000 --- a/apps/memoro/apps/backend/src/errors/README.md +++ /dev/null @@ -1,66 +0,0 @@ -# Standardized Error Handling - -This directory contains standardized error handling utilities for the memoro-service. - -## InsufficientCreditsException - -A custom exception class for handling insufficient credit scenarios with consistent error responses. - -### Features - -- **HTTP Status Code**: 402 Payment Required -- **Standardized Error Format**: Includes required credits, available credits, credit type, and operation details -- **Type Safety**: Strongly typed error data structure -- **Consistent Responses**: All insufficient credit errors follow the same format - -### Usage - -```typescript -import { InsufficientCreditsException } from '../errors/insufficient-credits.error'; - -// Throw when insufficient credits detected -throw new InsufficientCreditsException({ - requiredCredits: 100, - availableCredits: 50, - creditType: 'user', // or 'space' - operation: 'transcription', - spaceId: 'space-uuid' // optional -}); -``` - -### Error Response Format - -```json -{ - "statusCode": 402, - "error": "InsufficientCredits", - "message": "Insufficient user credits. Required: 100, Available: 50", - "details": { - "requiredCredits": 100, - "availableCredits": 50, - "creditType": "user", - "operation": "transcription", - "spaceId": null - } -} -``` - -### Helper Functions - -- `createInsufficientCreditsError()`: Factory function to create the exception -- `isInsufficientCreditsError()`: Type guard to check if an error is an insufficient credits error -- `extractCreditInfoFromError()`: Extract credit information from various error types - -## Global Exception Filter - -The `HttpExceptionFilter` in `/filters/http-exception.filter.ts` ensures all exceptions are properly formatted and InsufficientCreditsException returns the correct 402 status code. - -## Migration Notes - -All credit-consuming endpoints have been updated to use this standardized error handling: -- Transcription endpoints -- Question memo processing -- Memo combination -- All credit consumption operations - -Legacy `ForbiddenException` and `BadRequestException` for insufficient credits have been replaced with `InsufficientCreditsException`. \ No newline at end of file diff --git a/apps/memoro/apps/backend/src/errors/insufficient-credits.error.ts b/apps/memoro/apps/backend/src/errors/insufficient-credits.error.ts deleted file mode 100644 index 88eeebd6a..000000000 --- a/apps/memoro/apps/backend/src/errors/insufficient-credits.error.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { HttpException, HttpStatus } from '@nestjs/common'; - -export interface InsufficientCreditsErrorData { - requiredCredits: number; - availableCredits: number; - creditType: 'user' | 'space'; - operation?: string; - spaceId?: string; -} - -/** - * Custom exception for insufficient credits scenarios - * Uses HTTP 402 Payment Required status code - */ -export class InsufficientCreditsException extends HttpException { - constructor(data: InsufficientCreditsErrorData) { - const message = `Insufficient ${data.creditType} credits. Required: ${data.requiredCredits}, Available: ${data.availableCredits}`; - - const response = { - statusCode: HttpStatus.PAYMENT_REQUIRED, - error: 'InsufficientCredits', - message, - details: { - requiredCredits: data.requiredCredits, - availableCredits: data.availableCredits, - creditType: data.creditType, - operation: data.operation, - spaceId: data.spaceId, - }, - }; - - super(response, HttpStatus.PAYMENT_REQUIRED); - } -} - -/** - * Helper function to create standardized insufficient credits error - */ -export function createInsufficientCreditsError( - requiredCredits: number, - availableCredits: number, - creditType: 'user' | 'space' = 'user', - operation?: string, - spaceId?: string -): InsufficientCreditsException { - return new InsufficientCreditsException({ - requiredCredits, - availableCredits, - creditType, - operation, - spaceId, - }); -} - -/** - * Type guard to check if an error is an insufficient credits error - */ -export function isInsufficientCreditsError(error: any): error is InsufficientCreditsException { - return ( - error instanceof InsufficientCreditsException || - (error instanceof HttpException && error.getStatus() === HttpStatus.PAYMENT_REQUIRED) || - error?.message?.toLowerCase().includes('insufficient credits') - ); -} - -/** - * Extract credit information from various error types - */ -export function extractCreditInfoFromError(error: any): { - requiredCredits?: number; - availableCredits?: number; - creditType?: 'user' | 'space'; -} | null { - if (error instanceof InsufficientCreditsException) { - const response = error.getResponse() as any; - return response.details || null; - } - - // Try to parse from error message - const messageMatch = error?.message?.match(/Required:\s*(\d+),\s*Available:\s*(\d+)/); - if (messageMatch) { - return { - requiredCredits: parseInt(messageMatch[1]), - availableCredits: parseInt(messageMatch[2]), - creditType: error.message.includes('space') ? 'space' : 'user', - }; - } - - return null; -} diff --git a/apps/memoro/apps/backend/src/filters/http-exception.filter.ts b/apps/memoro/apps/backend/src/filters/http-exception.filter.ts deleted file mode 100644 index a96f03857..000000000 --- a/apps/memoro/apps/backend/src/filters/http-exception.filter.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { - ExceptionFilter, - Catch, - ArgumentsHost, - HttpException, - HttpStatus, - Logger, -} from '@nestjs/common'; -import { Response } from 'express'; -import { InsufficientCreditsException } from '../errors/insufficient-credits.error'; - -/** - * Global exception filter to handle HTTP exceptions - * Ensures proper error responses, especially for InsufficientCreditsException - */ -@Catch(HttpException) -export class HttpExceptionFilter implements ExceptionFilter { - private readonly logger = new Logger(HttpExceptionFilter.name); - - catch(exception: HttpException, host: ArgumentsHost) { - const ctx = host.switchToHttp(); - const response = ctx.getResponse(); - const status = exception.getStatus(); - const exceptionResponse = exception.getResponse(); - - // Log the error for debugging - this.logger.error(`HTTP ${status} Error: ${exception.message}`, exception.stack); - - // Ensure InsufficientCreditsException returns 402 status - if (exception instanceof InsufficientCreditsException) { - return response.status(HttpStatus.PAYMENT_REQUIRED).json(exceptionResponse); - } - - // For other exceptions, return the standard response - response.status(status).json(exceptionResponse); - } -} diff --git a/apps/memoro/apps/backend/src/guards/auth.guard.spec.ts b/apps/memoro/apps/backend/src/guards/auth.guard.spec.ts deleted file mode 100644 index 39149b09d..000000000 --- a/apps/memoro/apps/backend/src/guards/auth.guard.spec.ts +++ /dev/null @@ -1,230 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { ExecutionContext, UnauthorizedException } from '@nestjs/common'; -import { AuthGuard } from './auth.guard'; -import { AuthClientService } from '../auth/auth-client.service'; -import { JwtPayload } from '../types/jwt-payload.interface'; - -describe('AuthGuard', () => { - let guard: AuthGuard; - let authClientService: jest.Mocked; - - const mockJwtPayload: JwtPayload = { - sub: 'user-123', - email: 'test@example.com', - role: 'authenticated', - app_id: 'test-app', - aud: 'authenticated', - iat: Math.floor(Date.now() / 1000), - exp: Math.floor(Date.now() / 1000) + 3600, - }; - - const mockToken = 'mock-jwt-token'; - - const createMockExecutionContext = (headers: Record = {}) => { - const request = { - headers, - user: undefined, - token: undefined, - }; - - return { - switchToHttp: () => ({ - getRequest: () => request, - }), - getRequest: () => request, // Helper method to get request in tests - } as any; - }; - - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - providers: [ - AuthGuard, - { - provide: AuthClientService, - useValue: { - validateToken: jest.fn(), - }, - }, - ], - }).compile(); - - guard = module.get(AuthGuard); - authClientService = module.get(AuthClientService); - }); - - it('should be defined', () => { - expect(guard).toBeDefined(); - }); - - describe('canActivate', () => { - it('should return true and attach user/token to request when token is valid', async () => { - const mockContext = createMockExecutionContext({ - authorization: `Bearer ${mockToken}`, - }); - - authClientService.validateToken.mockResolvedValue(mockJwtPayload); - - const result = await guard.canActivate(mockContext); - const request = mockContext.getRequest(); - - expect(result).toBe(true); - expect(authClientService.validateToken).toHaveBeenCalledWith(mockToken); - expect(request.user).toEqual(mockJwtPayload); - expect(request.token).toBe(mockToken); - }); - - it('should throw UnauthorizedException when no authorization header is provided', async () => { - const mockContext = createMockExecutionContext({}); - - await expect(guard.canActivate(mockContext)).rejects.toThrow(UnauthorizedException); - - await expect(guard.canActivate(mockContext)).rejects.toThrow( - 'No authorization header provided' - ); - }); - - it('should throw UnauthorizedException when authorization header is empty', async () => { - const mockContext = createMockExecutionContext({ - authorization: '', - }); - - await expect(guard.canActivate(mockContext)).rejects.toThrow(UnauthorizedException); - }); - - it('should throw UnauthorizedException when token type is not Bearer', async () => { - const mockContext = createMockExecutionContext({ - authorization: `Basic ${mockToken}`, - }); - - await expect(guard.canActivate(mockContext)).rejects.toThrow(UnauthorizedException); - - await expect(guard.canActivate(mockContext)).rejects.toThrow('Invalid token type'); - }); - - it('should throw UnauthorizedException when no token is provided after Bearer', async () => { - const mockContext = createMockExecutionContext({ - authorization: 'Bearer', - }); - - await expect(guard.canActivate(mockContext)).rejects.toThrow(UnauthorizedException); - - await expect(guard.canActivate(mockContext)).rejects.toThrow('No token provided'); - }); - - it('should throw UnauthorizedException when token is only whitespace', async () => { - const mockContext = createMockExecutionContext({ - authorization: 'Bearer ', - }); - - await expect(guard.canActivate(mockContext)).rejects.toThrow(UnauthorizedException); - - await expect(guard.canActivate(mockContext)).rejects.toThrow('No token provided'); - }); - - it('should throw UnauthorizedException when token validation fails', async () => { - const mockContext = createMockExecutionContext({ - authorization: `Bearer ${mockToken}`, - }); - - authClientService.validateToken.mockRejectedValue(new Error('Token validation failed')); - - await expect(guard.canActivate(mockContext)).rejects.toThrow(UnauthorizedException); - - await expect(guard.canActivate(mockContext)).rejects.toThrow('Invalid token'); - }); - - it('should handle various token validation errors', async () => { - const mockContext = createMockExecutionContext({ - authorization: `Bearer ${mockToken}`, - }); - - const testCases = [ - { error: new Error('Token expired'), message: 'Invalid token' }, - { error: new Error('Invalid signature'), message: 'Invalid token' }, - { error: new UnauthorizedException('Custom auth error'), message: 'Invalid token' }, - ]; - - for (const testCase of testCases) { - authClientService.validateToken.mockRejectedValue(testCase.error); - - await expect(guard.canActivate(mockContext)).rejects.toThrow(UnauthorizedException); - - await expect(guard.canActivate(mockContext)).rejects.toThrow(testCase.message); - } - }); - - it('should handle malformed authorization headers gracefully', async () => { - const testCases = [ - 'Bearer', - 'Bearer ', - 'Bearer ', - 'BearerToken', - 'Token ' + mockToken, - ' Bearer ' + mockToken, - 'Bearer ' + mockToken + ' extra', - ]; - - for (const authHeader of testCases) { - const mockContext = createMockExecutionContext({ - authorization: authHeader, - }); - - if (authHeader.trim().startsWith('Bearer ') && authHeader.split(' ')[1]?.trim()) { - authClientService.validateToken.mockResolvedValue(mockJwtPayload); - const result = await guard.canActivate(mockContext); - expect(result).toBe(true); - } else { - await expect(guard.canActivate(mockContext)).rejects.toThrow(UnauthorizedException); - } - } - }); - - it('should preserve original request properties when attaching user and token', async () => { - const originalRequest = { - headers: { - authorization: `Bearer ${mockToken}`, - 'content-type': 'application/json', - }, - body: { data: 'test' }, - params: { id: '123' }, - query: { filter: 'active' }, - }; - - const mockContext = { - switchToHttp: () => ({ - getRequest: () => originalRequest, - }), - } as ExecutionContext; - - authClientService.validateToken.mockResolvedValue(mockJwtPayload); - - await guard.canActivate(mockContext); - - expect(originalRequest.headers).toEqual({ - authorization: `Bearer ${mockToken}`, - 'content-type': 'application/json', - }); - expect(originalRequest.body).toEqual({ data: 'test' }); - expect(originalRequest.params).toEqual({ id: '123' }); - expect(originalRequest.query).toEqual({ filter: 'active' }); - expect((originalRequest as any).user).toEqual(mockJwtPayload); - expect((originalRequest as any).token).toBe(mockToken); - }); - - it('should log error details when token validation fails', async () => { - const consoleSpy = jest.spyOn(console, 'error').mockImplementation(); - const mockContext = createMockExecutionContext({ - authorization: `Bearer ${mockToken}`, - }); - - const validationError = new Error('Token signature invalid'); - authClientService.validateToken.mockRejectedValue(validationError); - - await expect(guard.canActivate(mockContext)).rejects.toThrow(UnauthorizedException); - - expect(consoleSpy).toHaveBeenCalledWith('Auth error:', 'Token signature invalid'); - - consoleSpy.mockRestore(); - }); - }); -}); diff --git a/apps/memoro/apps/backend/src/guards/auth.guard.ts b/apps/memoro/apps/backend/src/guards/auth.guard.ts deleted file mode 100644 index e4c9b2070..000000000 --- a/apps/memoro/apps/backend/src/guards/auth.guard.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { Injectable, CanActivate, ExecutionContext, UnauthorizedException } from '@nestjs/common'; -import { Observable } from 'rxjs'; -import { AuthClientService } from '../auth/auth-client.service'; -import { JwtPayload } from '../types/jwt-payload.interface'; - -@Injectable() -export class AuthGuard implements CanActivate { - constructor(private authClientService: AuthClientService) {} - - canActivate(context: ExecutionContext): boolean | Promise | Observable { - const request = context.switchToHttp().getRequest(); - return this.validateRequest(request); - } - - private async validateRequest(request: any): Promise { - const authHeader = request.headers.authorization; - - if (!authHeader) { - throw new UnauthorizedException('No authorization header provided'); - } - - const [type, token] = authHeader.split(' '); - - if (type !== 'Bearer') { - throw new UnauthorizedException('Invalid token type'); - } - - if (!token) { - throw new UnauthorizedException('No token provided'); - } - - try { - // Validate the token with the Auth service - const payload = await this.authClientService.validateToken(token); - - // Attach the user payload to the request for controllers to use - request.user = payload as JwtPayload; - // Also attach the token for potential forwarding to other services - request.token = token; - - return true; - } catch (error) { - console.error('Auth error:', error.message); - throw new UnauthorizedException('Invalid token'); - } - } -} diff --git a/apps/memoro/apps/backend/src/guards/internal-service.guard.ts b/apps/memoro/apps/backend/src/guards/internal-service.guard.ts deleted file mode 100644 index 45da28518..000000000 --- a/apps/memoro/apps/backend/src/guards/internal-service.guard.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { Injectable, CanActivate, ExecutionContext, UnauthorizedException } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; - -/** - * Guard for internal service-to-service communication. - * Validates requests using the X-Internal-API-Key header. - * Used for scheduled jobs and internal microservice calls. - */ -@Injectable() -export class InternalServiceGuard implements CanActivate { - constructor(private configService: ConfigService) {} - - async canActivate(context: ExecutionContext): Promise { - const request = context.switchToHttp().getRequest(); - const apiKey = request.headers['x-internal-api-key']; - - if (!apiKey) { - throw new UnauthorizedException('Missing X-Internal-API-Key header'); - } - - const internalApiKey = this.configService.get('INTERNAL_API_KEY'); - - if (!internalApiKey) { - throw new UnauthorizedException('Internal API key not configured'); - } - - if (apiKey !== internalApiKey) { - throw new UnauthorizedException('Invalid internal API key'); - } - - // Mark request as internal service call - request.isInternalService = true; - return true; - } -} diff --git a/apps/memoro/apps/backend/src/guards/service-auth.guard.spec.ts b/apps/memoro/apps/backend/src/guards/service-auth.guard.spec.ts deleted file mode 100644 index a8ca101fa..000000000 --- a/apps/memoro/apps/backend/src/guards/service-auth.guard.spec.ts +++ /dev/null @@ -1,225 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { ConfigService } from '@nestjs/config'; -import { ExecutionContext, UnauthorizedException } from '@nestjs/common'; -import { ServiceAuthGuard } from './service-auth.guard'; -import { createClient } from '@supabase/supabase-js'; - -jest.mock('@supabase/supabase-js'); - -describe('ServiceAuthGuard', () => { - let guard: ServiceAuthGuard; - let configService: jest.Mocked; - - const mockConfigService = { - get: jest.fn(), - }; - - const mockSupabaseClient = { - from: jest.fn().mockReturnThis(), - select: jest.fn().mockReturnThis(), - limit: jest.fn().mockReturnThis(), - }; - - const createMockExecutionContext = (headers: any = {}): ExecutionContext => - ({ - switchToHttp: () => ({ - getRequest: () => ({ - headers, - }), - }), - }) as ExecutionContext; - - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - providers: [ - ServiceAuthGuard, - { - provide: ConfigService, - useValue: mockConfigService, - }, - ], - }).compile(); - - guard = module.get(ServiceAuthGuard); - configService = module.get(ConfigService); - - (createClient as jest.Mock).mockReturnValue(mockSupabaseClient); - }); - - afterEach(() => { - jest.clearAllMocks(); - }); - - describe('canActivate', () => { - it('should return true for valid MEMORO_SUPABASE_SERVICE_KEY', async () => { - const serviceKey = 'valid-memoro-service-key'; - mockConfigService.get.mockImplementation((key: string) => { - if (key === 'MEMORO_SUPABASE_SERVICE_KEY') return serviceKey; - if (key === 'SUPABASE_SERVICE_KEY') return 'other-key'; - if (key === 'MEMORO_SUPABASE_URL') return 'https://example.supabase.co'; - return null; - }); - - const context = createMockExecutionContext({ - authorization: `Bearer ${serviceKey}`, - }); - - const request = context.switchToHttp().getRequest(); - const result = await guard.canActivate(context); - - expect(result).toBe(true); - expect(request.isServiceAuth).toBe(true); - expect(request.serviceKey).toBe(serviceKey); - }); - - it('should return true for valid SUPABASE_SERVICE_KEY', async () => { - const serviceKey = 'valid-supabase-service-key'; - mockConfigService.get.mockImplementation((key: string) => { - if (key === 'MEMORO_SUPABASE_SERVICE_KEY') return 'other-key'; - if (key === 'SUPABASE_SERVICE_KEY') return serviceKey; - if (key === 'MEMORO_SUPABASE_URL') return 'https://example.supabase.co'; - return null; - }); - - const context = createMockExecutionContext({ - authorization: `Bearer ${serviceKey}`, - }); - - const request = context.switchToHttp().getRequest(); - const result = await guard.canActivate(context); - - expect(result).toBe(true); - expect(request.isServiceAuth).toBe(true); - expect(request.serviceKey).toBe(serviceKey); - }); - - it('should validate token with Supabase when not matching config keys', async () => { - const serviceKey = 'unknown-service-key'; - mockConfigService.get.mockImplementation((key: string) => { - if (key === 'MEMORO_SUPABASE_SERVICE_KEY') return 'memoro-key'; - if (key === 'SUPABASE_SERVICE_KEY') return 'supabase-key'; - if (key === 'MEMORO_SUPABASE_URL') return 'https://example.supabase.co'; - return null; - }); - - mockSupabaseClient.limit.mockResolvedValue({ error: null }); - - const context = createMockExecutionContext({ - authorization: `Bearer ${serviceKey}`, - }); - - const request = context.switchToHttp().getRequest(); - const result = await guard.canActivate(context); - - expect(result).toBe(true); - expect(createClient).toHaveBeenCalledWith( - 'https://example.supabase.co', - serviceKey, - expect.any(Object) - ); - expect(mockSupabaseClient.from).toHaveBeenCalledWith('memos'); - expect(mockSupabaseClient.select).toHaveBeenCalledWith('id'); - expect(mockSupabaseClient.limit).toHaveBeenCalledWith(1); - expect(request.isServiceAuth).toBe(true); - expect(request.serviceKey).toBe(serviceKey); - }); - - it('should throw UnauthorizedException when no authorization header', async () => { - const context = createMockExecutionContext({}); - - await expect(guard.canActivate(context)).rejects.toThrow( - new UnauthorizedException('No authorization header provided') - ); - }); - - it('should throw UnauthorizedException for invalid token type', async () => { - const context = createMockExecutionContext({ - authorization: 'Basic invalidtoken', - }); - - await expect(guard.canActivate(context)).rejects.toThrow( - new UnauthorizedException('Invalid token type') - ); - }); - - it('should throw UnauthorizedException when no token provided', async () => { - const context = createMockExecutionContext({ - authorization: 'Bearer ', - }); - - await expect(guard.canActivate(context)).rejects.toThrow( - new UnauthorizedException('No token provided') - ); - }); - - it('should throw UnauthorizedException when Supabase validation fails', async () => { - const serviceKey = 'invalid-service-key'; - mockConfigService.get.mockImplementation((key: string) => { - if (key === 'MEMORO_SUPABASE_SERVICE_KEY') return 'memoro-key'; - if (key === 'SUPABASE_SERVICE_KEY') return 'supabase-key'; - if (key === 'MEMORO_SUPABASE_URL') return 'https://example.supabase.co'; - return null; - }); - - mockSupabaseClient.limit.mockResolvedValue({ - error: { message: 'Invalid service key', code: 'PGRST301' }, - }); - - const context = createMockExecutionContext({ - authorization: `Bearer ${serviceKey}`, - }); - - await expect(guard.canActivate(context)).rejects.toThrow( - new UnauthorizedException('Invalid service key') - ); - }); - - it('should throw UnauthorizedException when Supabase client throws error', async () => { - const serviceKey = 'error-service-key'; - mockConfigService.get.mockImplementation((key: string) => { - if (key === 'MEMORO_SUPABASE_SERVICE_KEY') return 'memoro-key'; - if (key === 'SUPABASE_SERVICE_KEY') return 'supabase-key'; - if (key === 'MEMORO_SUPABASE_URL') return 'https://example.supabase.co'; - return null; - }); - - mockSupabaseClient.limit.mockRejectedValue(new Error('Network error')); - - const context = createMockExecutionContext({ - authorization: `Bearer ${serviceKey}`, - }); - - await expect(guard.canActivate(context)).rejects.toThrow( - new UnauthorizedException('Invalid service key') - ); - }); - - it('should handle edge case with empty Bearer token', async () => { - const context = createMockExecutionContext({ - authorization: 'Bearer', - }); - - await expect(guard.canActivate(context)).rejects.toThrow( - new UnauthorizedException('No token provided') - ); - }); - - it('should handle multiple spaces in authorization header', async () => { - const serviceKey = 'valid-memoro-service-key'; - mockConfigService.get.mockImplementation((key: string) => { - if (key === 'MEMORO_SUPABASE_SERVICE_KEY') return serviceKey; - return null; - }); - - const context = createMockExecutionContext({ - authorization: `Bearer ${serviceKey}`, // Normal spacing - }); - - const request = context.switchToHttp().getRequest(); - const result = await guard.canActivate(context); - - expect(result).toBe(true); - expect(request.isServiceAuth).toBe(true); - }); - }); -}); diff --git a/apps/memoro/apps/backend/src/guards/service-auth.guard.ts b/apps/memoro/apps/backend/src/guards/service-auth.guard.ts deleted file mode 100644 index f03a55994..000000000 --- a/apps/memoro/apps/backend/src/guards/service-auth.guard.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { Injectable, CanActivate, ExecutionContext, UnauthorizedException } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import { createClient } from '@supabase/supabase-js'; - -@Injectable() -export class ServiceAuthGuard implements CanActivate { - constructor(private configService: ConfigService) {} - - async canActivate(context: ExecutionContext): Promise { - const request = context.switchToHttp().getRequest(); - const authHeader = request.headers.authorization; - - if (!authHeader) { - throw new UnauthorizedException('No authorization header provided'); - } - - const [type, token] = authHeader.split(' '); - - if (type !== 'Bearer') { - throw new UnauthorizedException('Invalid token type'); - } - - if (!token) { - throw new UnauthorizedException('No token provided'); - } - - // Check if the token is the service role key - // Accept both MEMORO_SUPABASE_SERVICE_KEY and SUPABASE_SERVICE_KEY for compatibility - const memoroServiceKey = this.configService.get('MEMORO_SUPABASE_SERVICE_KEY'); - const supabaseServiceKey = this.configService.get('SUPABASE_SERVICE_KEY'); - - if (token === memoroServiceKey || token === supabaseServiceKey) { - // This is a valid service-to-service request - // Attach a service identifier to the request - request.isServiceAuth = true; - request.serviceKey = token; - return true; - } - - // Optionally, validate the token with Supabase to ensure it's a valid service key - try { - const supabaseUrl = this.configService.get('MEMORO_SUPABASE_URL'); - const supabase = createClient(supabaseUrl, token, { - auth: { - autoRefreshToken: false, - persistSession: false, - }, - }); - - // Try to access a protected resource to validate the service key - const { error } = await supabase.from('memos').select('id').limit(1); - - if (!error) { - // Valid service key - request.isServiceAuth = true; - request.serviceKey = token; - return true; - } - } catch (error) { - // Token validation failed - } - - throw new UnauthorizedException('Invalid service key'); - } -} diff --git a/apps/memoro/apps/backend/src/health/health.controller.ts b/apps/memoro/apps/backend/src/health/health.controller.ts deleted file mode 100644 index 560713236..000000000 --- a/apps/memoro/apps/backend/src/health/health.controller.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { Controller, Get } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; - -@Controller('health') -export class HealthController { - constructor(private readonly configService: ConfigService) {} - - @Get() - checkHealth() { - // Log debug info when health check is called - console.error('[HEALTH CHECK DEBUG] Environment check:'); - console.error( - '[HEALTH CHECK DEBUG] AUDIO_MICROSERVICE_URL from env:', - process.env.AUDIO_MICROSERVICE_URL - ); - console.error( - '[HEALTH CHECK DEBUG] AUDIO_MICROSERVICE_URL from ConfigService:', - this.configService.get('AUDIO_MICROSERVICE_URL') - ); - console.error('[HEALTH CHECK DEBUG] NODE_ENV:', process.env.NODE_ENV); - - return { - status: 'ok', - timestamp: new Date().toISOString(), - service: 'memoro-service', - debug: { - nodeEnv: process.env.NODE_ENV, - audioServiceUrl: this.configService.get('AUDIO_MICROSERVICE_URL'), - audioServiceUrlEnv: process.env.AUDIO_MICROSERVICE_URL, - port: process.env.PORT || 3001, - cwd: process.cwd(), - nodeVersion: process.version, - }, - }; - } -} diff --git a/apps/memoro/apps/backend/src/health/health.module.ts b/apps/memoro/apps/backend/src/health/health.module.ts deleted file mode 100644 index a61d8b044..000000000 --- a/apps/memoro/apps/backend/src/health/health.module.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { Module } from '@nestjs/common'; -import { HealthController } from './health.controller'; - -@Module({ - controllers: [HealthController], -}) -export class HealthModule {} diff --git a/apps/memoro/apps/backend/src/interfaces/memoro.interfaces.ts b/apps/memoro/apps/backend/src/interfaces/memoro.interfaces.ts deleted file mode 100644 index 8cb5a974a..000000000 --- a/apps/memoro/apps/backend/src/interfaces/memoro.interfaces.ts +++ /dev/null @@ -1,57 +0,0 @@ -export interface MemoroSpaceDto { - id: string; - name: string; - owner_id: string; - app_id: string; - roles: any; - credits: number; - created_at: string; - updated_at: string; - memo_count?: number; - isOwner?: boolean; // Added for frontend ownership indication -} - -export interface LinkMemoSpaceDto { - memoId: string; - spaceId: string; -} - -export interface UnlinkMemoSpaceDto { - memoId: string; - spaceId: string; -} - -export interface SuccessResponseDto { - success: boolean; - message?: string; -} - -// Video-related interfaces -export interface VideoMetadata { - width?: number; - height?: number; - fps?: number; - videoCodec?: string; - audioCodec?: string; - audioChannels?: number; - audioSampleRate?: number; - fileSize?: number; - bitrate?: number; - hasAudioTrack?: boolean; -} - -export type MediaType = 'audio' | 'video'; - -export interface ProcessMediaDto { - filePath: string; - duration: number; - spaceId?: string; - blueprintId?: string | null; - recordingLanguages?: string[]; - memoId?: string; - location?: any; - recordingStartedAt?: string; - enableDiarization?: boolean; - mediaType?: MediaType; - videoMetadata?: VideoMetadata; -} diff --git a/apps/memoro/apps/backend/src/interfaces/spaces.interfaces.ts b/apps/memoro/apps/backend/src/interfaces/spaces.interfaces.ts deleted file mode 100644 index 88f2a4dbf..000000000 --- a/apps/memoro/apps/backend/src/interfaces/spaces.interfaces.ts +++ /dev/null @@ -1,26 +0,0 @@ -export interface SpaceDto { - id: string; - name: string; - owner_id: string; - app_id: string; - roles: any; - credits: number; - created_at: string; - updated_at: string; - memo_count?: number; // Added for compatibility with MemoroSpaceDto -} - -export interface SpaceInviteDto { - id: string; - space_id: string; - space?: SpaceDto; - user_email: string; - role: string; - status: string; - created_at: string; - updated_at: string; -} - -export interface PendingInvitesResponseDto { - invites: SpaceInviteDto[]; -} diff --git a/apps/memoro/apps/backend/src/main.ts b/apps/memoro/apps/backend/src/main.ts deleted file mode 100644 index 9dd5fc31e..000000000 --- a/apps/memoro/apps/backend/src/main.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { NestFactory } from '@nestjs/core'; -import { AppModule } from './app.module'; -import { HttpExceptionFilter } from './filters/http-exception.filter'; - -async function bootstrap() { - // Debug: Log environment variables at startup - using console.error for Cloud Run visibility - console.error('[STARTUP DEBUG] Environment variables check:'); - console.error('[STARTUP DEBUG] AUDIO_MICROSERVICE_URL:', process.env.AUDIO_MICROSERVICE_URL); - console.error( - '[STARTUP DEBUG] All env vars with AUDIO:', - Object.keys(process.env).filter((key) => key.includes('AUDIO')) - ); - console.error('[STARTUP DEBUG] NODE_ENV:', process.env.NODE_ENV); - console.error('[STARTUP DEBUG] Current working directory:', process.cwd()); - console.error('[STARTUP DEBUG] __dirname:', __dirname); - - const app = await NestFactory.create(AppModule); - app.enableCors(); - - // Apply global exception filter for standardized error responses - app.useGlobalFilters(new HttpExceptionFilter()); - - // Increase request body size limit to handle rich speaker diarization data - // NestJS default is 100KB, our speaker data can be ~150KB+ - const bodyLimit = '10mb'; // More reasonable limit - - app.use( - require('express').json({ - limit: bodyLimit, - verify: (req, res, buf, encoding) => { - console.log(`[Body Parser] Received ${buf.length} bytes on ${req.url}`); - if (buf.length > 1024 * 1024) { - // Log if >1MB - console.log( - `[Body Parser] Large payload detected: ${(buf.length / 1024 / 1024).toFixed(2)}MB` - ); - } - }, - }) - ); - - app.use( - require('express').urlencoded({ - extended: true, - limit: bodyLimit, - verify: (req, res, buf, encoding) => { - console.log(`[Body Parser URL] Received ${buf.length} bytes on ${req.url}`); - }, - }) - ); - - console.log(`[NestJS] Body parser configured with limit: ${bodyLimit}`); - - // Use PORT environment variable provided by Cloud Run, default to 3001 - // Using 3001 instead of 3000 to avoid conflicts with the main middleware service in development - const port = process.env.PORT || 3001; - await app.listen(port); - console.log(`Memoro microservice listening on port ${port}`); -} -bootstrap(); diff --git a/apps/memoro/apps/backend/src/meetings/dto/create-bot.dto.ts b/apps/memoro/apps/backend/src/meetings/dto/create-bot.dto.ts deleted file mode 100644 index 9e705bd13..000000000 --- a/apps/memoro/apps/backend/src/meetings/dto/create-bot.dto.ts +++ /dev/null @@ -1,33 +0,0 @@ -/** - * DTO for creating a meeting bot - */ -export class CreateBotDto { - meeting_url: string; - space_id?: string; -} - -/** - * DTO for stopping a meeting bot - */ -export class StopBotDto { - bot_id: string; -} - -/** - * Query params for listing bots - */ -export class ListBotsQueryDto { - state?: string; - space_id?: string; - limit?: number; - offset?: number; -} - -/** - * Query params for listing recordings - */ -export class ListRecordingsQueryDto { - space_id?: string; - limit?: number; - offset?: number; -} diff --git a/apps/memoro/apps/backend/src/meetings/dto/webhook-event.dto.ts b/apps/memoro/apps/backend/src/meetings/dto/webhook-event.dto.ts deleted file mode 100644 index eb2af7c3a..000000000 --- a/apps/memoro/apps/backend/src/meetings/dto/webhook-event.dto.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * DTO for webhook events from meeting-bot service - */ -export class WebhookEventDto { - event: 'recording.completed' | 'recording.failed'; - timestamp: string; - bot: { - id: string; - external_bot_id: string; - user_id: string; - space_id?: string; - state: string; - completed_at?: string; - failed_at?: string; - }; - recording?: { - id: string; - video_url?: string; - audio_url?: string; - file_url?: string; - transcript?: string; - speakers?: object; - duration_seconds?: number; - created_at: string; - }; - error?: { - code: string; - message: string; - }; -} diff --git a/apps/memoro/apps/backend/src/meetings/interfaces/meeting.interfaces.ts b/apps/memoro/apps/backend/src/meetings/interfaces/meeting.interfaces.ts deleted file mode 100644 index 28c75f000..000000000 --- a/apps/memoro/apps/backend/src/meetings/interfaces/meeting.interfaces.ts +++ /dev/null @@ -1,135 +0,0 @@ -/** - * Meeting Bot States - matches meeting-bot service enum - */ -export type MeetingBotState = - | 'registering' - | 'provisioning' - | 'joining' - | 'waiting_room' - | 'joined' - | 'recording' - | 'recording_error' - | 'leaving' - | 'left' - | 'error'; - -/** - * Meeting platform/vendor - */ -export type MeetingVendor = 'teams' | 'meet' | 'zoom'; - -/** - * Meeting Bot record from database - */ -export interface MeetingBot { - id: string; - created_at: string; - ended_at?: string; - updated_at: string; - vendor: MeetingVendor; - state: MeetingBotState; - meeting_id?: string; - meeting_code: string; - meeting_url?: string; - external_bot_id?: string; - user_id: string; - space_id?: string; - credits_consumed?: number; - duration_seconds?: number; -} - -/** - * Recording record from database - */ -export interface MeetingRecording { - id: string; - created_at: string; - updated_at: string; - file_url?: string; - video_url?: string; - audio_url?: string; - transcript?: string; - duration_seconds?: number; - bot_id: string; - user_id: string; - space_id?: string; - // Signed URLs for playback (generated at runtime) - audio_signed_url?: string | null; - video_signed_url?: string | null; -} - -/** - * Create bot request to meeting-bot service - */ -export interface CreateBotRequest { - user_id: string; - space_id?: string; - meeting_url: string; - completed_webhook_url?: string; - failed_webhook_url?: string; -} - -/** - * Create bot response from meeting-bot service - */ -export interface CreateBotResponse { - id: string; - external_bot_id: string; - meeting_url: string; - state: MeetingBotState; - created_at: string; -} - -/** - * Webhook event payload from meeting-bot - */ -export interface MeetingWebhookPayload { - event: 'recording.completed' | 'recording.failed'; - timestamp: string; - bot: { - id: string; - external_bot_id: string; - user_id: string; - space_id?: string; - state: MeetingBotState; - completed_at?: string; - failed_at?: string; - }; - recording?: { - id: string; - video_url?: string; - audio_url?: string; - file_url?: string; - transcript?: string; - speakers?: object; - duration_seconds?: number; - created_at: string; - }; - error?: { - code: string; - message: string; - }; -} - -/** - * Bot with recording details - */ -export interface MeetingBotWithRecording extends MeetingBot { - recording?: MeetingRecording; -} - -/** - * List bots response - */ -export interface ListBotsResponse { - bots: MeetingBotWithRecording[]; - total: number; -} - -/** - * List recordings response - */ -export interface ListRecordingsResponse { - recordings: MeetingRecording[]; - total: number; -} diff --git a/apps/memoro/apps/backend/src/meetings/meetings-proxy.service.ts b/apps/memoro/apps/backend/src/meetings/meetings-proxy.service.ts deleted file mode 100644 index c4d686d63..000000000 --- a/apps/memoro/apps/backend/src/meetings/meetings-proxy.service.ts +++ /dev/null @@ -1,372 +0,0 @@ -import { Injectable, Logger, BadRequestException, NotFoundException } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import { createClient, SupabaseClient } from '@supabase/supabase-js'; -import { - MeetingBot, - MeetingRecording, - MeetingBotWithRecording, - CreateBotRequest, - CreateBotResponse, - MeetingVendor, -} from './interfaces/meeting.interfaces'; - -@Injectable() -export class MeetingsProxyService { - private readonly logger = new Logger(MeetingsProxyService.name); - private readonly meetingBotApiUrl: string; - private readonly meetingBotApiKey: string; - private readonly memoroServiceUrl: string; - private readonly supabaseClient: SupabaseClient; - - constructor(private configService: ConfigService) { - this.meetingBotApiUrl = this.configService.get('MEETING_BOT_API_URL') || ''; - this.meetingBotApiKey = this.configService.get('MEETING_BOT_API_KEY') || ''; - this.memoroServiceUrl = - this.configService.get('MEMORO_SERVICE_URL') || 'http://localhost:3001'; - - // Initialize Supabase client for direct database access - const supabaseUrl = this.configService.get('MEMORO_SUPABASE_URL'); - const supabaseServiceKey = this.configService.get('MEMORO_SUPABASE_SERVICE_KEY'); - - if (supabaseUrl && supabaseServiceKey) { - this.supabaseClient = createClient(supabaseUrl, supabaseServiceKey); - } - - if (!this.meetingBotApiUrl) { - this.logger.warn('MEETING_BOT_API_URL not configured - meeting bot proxy disabled'); - } - } - - /** - * Detect meeting platform from URL - */ - detectPlatform(meetingUrl: string): MeetingVendor { - if (/teams\.microsoft\.com/i.test(meetingUrl)) return 'teams'; - if (/meet\.google\.com/i.test(meetingUrl)) return 'meet'; - if (/zoom\.(us|com)/i.test(meetingUrl)) return 'zoom'; - throw new BadRequestException('Unsupported meeting platform. Use Teams, Meet, or Zoom.'); - } - - /** - * Create a new meeting bot via meeting-bot service - */ - async createBot( - userId: string, - meetingUrl: string, - spaceId?: string - ): Promise { - this.logger.log(`[createBot] Creating bot for user ${userId}, meeting: ${meetingUrl}`); - - if (!this.meetingBotApiUrl || !this.meetingBotApiKey) { - throw new BadRequestException('Meeting bot service not configured'); - } - - const platform = this.detectPlatform(meetingUrl); - this.logger.log(`[createBot] Detected platform: ${platform}`); - - const webhookBaseUrl = this.memoroServiceUrl.replace(/\/$/, ''); - - const requestBody: CreateBotRequest = { - user_id: userId, - space_id: spaceId, - meeting_url: meetingUrl, - completed_webhook_url: `${webhookBaseUrl}/meetings/webhooks/bot-events`, - failed_webhook_url: `${webhookBaseUrl}/meetings/webhooks/bot-events`, - }; - - try { - const response = await fetch(`${this.meetingBotApiUrl}/bots`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'x-api-key': this.meetingBotApiKey, - }, - body: JSON.stringify(requestBody), - }); - - if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - this.logger.error(`[createBot] Meeting bot service error: ${response.status}`, errorData); - throw new BadRequestException( - errorData.message || `Failed to create meeting bot: ${response.statusText}` - ); - } - - const result = await response.json(); - this.logger.log(`[createBot] Bot created successfully: ${result.id}`); - - return result; - } catch (error) { - if (error instanceof BadRequestException) throw error; - this.logger.error(`[createBot] Error creating bot:`, error); - throw new BadRequestException('Failed to connect to meeting bot service'); - } - } - - /** - * Stop a meeting bot - */ - async stopBot(botId: string, userId: string): Promise<{ success: boolean }> { - this.logger.log(`[stopBot] Stopping bot ${botId} for user ${userId}`); - - // First verify the bot belongs to this user - const bot = await this.getBotById(botId, userId); - if (!bot) { - throw new NotFoundException('Bot not found'); - } - - if (!this.meetingBotApiUrl || !this.meetingBotApiKey) { - throw new BadRequestException('Meeting bot service not configured'); - } - - try { - const response = await fetch(`${this.meetingBotApiUrl}/bots/${botId}/stop`, { - method: 'POST', - headers: { - 'x-api-key': this.meetingBotApiKey, - }, - }); - - if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - this.logger.error(`[stopBot] Meeting bot service error: ${response.status}`, errorData); - throw new BadRequestException( - errorData.message || `Failed to stop meeting bot: ${response.statusText}` - ); - } - - this.logger.log(`[stopBot] Bot ${botId} stopped successfully`); - return { success: true }; - } catch (error) { - if (error instanceof BadRequestException || error instanceof NotFoundException) throw error; - this.logger.error(`[stopBot] Error stopping bot:`, error); - throw new BadRequestException('Failed to stop meeting bot'); - } - } - - /** - * Get bots for a user directly from database - */ - async getBots( - userId: string, - spaceId?: string, - limit = 50, - offset = 0 - ): Promise { - this.logger.log(`[getBots] Getting bots for user ${userId}`); - - if (!this.supabaseClient) { - throw new BadRequestException('Database not configured'); - } - - let query = this.supabaseClient - .from('meeting_bots') - .select('*') - .eq('user_id', userId) - .order('created_at', { ascending: false }) - .range(offset, offset + limit - 1); - - if (spaceId) { - query = query.eq('space_id', spaceId); - } - - const { data: bots, error } = await query; - - if (error) { - this.logger.error(`[getBots] Database error:`, error); - throw new BadRequestException('Failed to fetch bots'); - } - - // Fetch recordings for each bot - const botsWithRecordings: MeetingBotWithRecording[] = await Promise.all( - (bots || []).map(async (bot: MeetingBot) => { - const { data: recordings } = await this.supabaseClient - .from('meeting_recordings') - .select('*') - .eq('bot_id', bot.id) - .limit(1); - - return { - ...bot, - recording: recordings?.[0] || undefined, - }; - }) - ); - - return botsWithRecordings; - } - - /** - * Get a specific bot by ID - */ - async getBotById(botId: string, userId: string): Promise { - this.logger.log(`[getBotById] Getting bot ${botId} for user ${userId}`); - - if (!this.supabaseClient) { - throw new BadRequestException('Database not configured'); - } - - const { data: bot, error } = await this.supabaseClient - .from('meeting_bots') - .select('*') - .eq('id', botId) - .eq('user_id', userId) - .single(); - - if (error || !bot) { - return null; - } - - // Fetch recording - const { data: recordings } = await this.supabaseClient - .from('meeting_recordings') - .select('*') - .eq('bot_id', botId) - .limit(1); - - return { - ...bot, - recording: recordings?.[0] || undefined, - }; - } - - /** - * Generate signed URL for a storage path - */ - private async generateSignedUrl(storagePath: string): Promise { - if (!storagePath || !this.supabaseClient) return null; - - try { - const bucket = this.configService.get('USER_UPLOADS_BUCKET') || 'user-uploads'; - const { data, error } = await this.supabaseClient.storage - .from(bucket) - .createSignedUrl(storagePath, 3600); // 1 hour expiry - - if (error) { - this.logger.error(`[generateSignedUrl] Error creating signed URL:`, error); - return null; - } - - return data?.signedUrl || null; - } catch (error) { - this.logger.error(`[generateSignedUrl] Error:`, error); - return null; - } - } - - /** - * Add signed URLs to a recording - */ - private async addSignedUrls(recording: MeetingRecording): Promise { - const [audioSignedUrl, videoSignedUrl] = await Promise.all([ - recording.audio_url ? this.generateSignedUrl(recording.audio_url) : null, - recording.video_url ? this.generateSignedUrl(recording.video_url) : null, - ]); - - return { - ...recording, - audio_signed_url: audioSignedUrl, - video_signed_url: videoSignedUrl, - }; - } - - /** - * Get recordings for a user - */ - async getRecordings( - userId: string, - spaceId?: string, - limit = 50, - offset = 0 - ): Promise { - this.logger.log(`[getRecordings] Getting recordings for user ${userId}`); - - if (!this.supabaseClient) { - throw new BadRequestException('Database not configured'); - } - - let query = this.supabaseClient - .from('meeting_recordings') - .select('*') - .eq('user_id', userId) - .order('created_at', { ascending: false }) - .range(offset, offset + limit - 1); - - if (spaceId) { - query = query.eq('space_id', spaceId); - } - - const { data: recordings, error } = await query; - - if (error) { - this.logger.error(`[getRecordings] Database error:`, error); - throw new BadRequestException('Failed to fetch recordings'); - } - - // Add signed URLs to each recording - const recordingsWithUrls = await Promise.all( - (recordings || []).map((recording) => this.addSignedUrls(recording)) - ); - - return recordingsWithUrls; - } - - /** - * Get a specific recording by ID - */ - async getRecordingById(recordingId: string, userId: string): Promise { - this.logger.log(`[getRecordingById] Getting recording ${recordingId} for user ${userId}`); - - if (!this.supabaseClient) { - throw new BadRequestException('Database not configured'); - } - - const { data: recording, error } = await this.supabaseClient - .from('meeting_recordings') - .select('*') - .eq('id', recordingId) - .eq('user_id', userId) - .single(); - - if (error || !recording) { - return null; - } - - // Add signed URLs - return this.addSignedUrls(recording); - } - - /** - * Update bot with credits consumed - */ - async updateBotCredits( - botId: string, - creditsConsumed: number, - durationSeconds?: number - ): Promise { - this.logger.log(`[updateBotCredits] Updating bot ${botId} with ${creditsConsumed} credits`); - - if (!this.supabaseClient) { - throw new BadRequestException('Database not configured'); - } - - const updateData: Partial = { - credits_consumed: creditsConsumed, - updated_at: new Date().toISOString(), - }; - - if (durationSeconds !== undefined) { - updateData.duration_seconds = durationSeconds; - } - - const { error } = await this.supabaseClient - .from('meeting_bots') - .update(updateData) - .eq('id', botId); - - if (error) { - this.logger.error(`[updateBotCredits] Failed to update bot:`, error); - throw new BadRequestException('Failed to update bot credits'); - } - } -} diff --git a/apps/memoro/apps/backend/src/meetings/meetings-webhook.controller.ts b/apps/memoro/apps/backend/src/meetings/meetings-webhook.controller.ts deleted file mode 100644 index d5c591a7a..000000000 --- a/apps/memoro/apps/backend/src/meetings/meetings-webhook.controller.ts +++ /dev/null @@ -1,224 +0,0 @@ -import { - Controller, - Post, - Body, - Headers, - Logger, - BadRequestException, - UnauthorizedException, -} from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import { createHmac, timingSafeEqual } from 'crypto'; -import { MeetingsProxyService } from './meetings-proxy.service'; -import { CreditConsumptionService } from '../credits/credit-consumption.service'; -import { WebhookEventDto } from './dto/webhook-event.dto'; -import { OPERATION_COSTS } from '../credits/pricing.constants'; - -@Controller('meetings/webhooks') -export class MeetingsWebhookController { - private readonly logger = new Logger(MeetingsWebhookController.name); - private readonly webhookSecret: string; - private readonly processedEvents: Set = new Set(); - - constructor( - private readonly configService: ConfigService, - private readonly meetingsProxyService: MeetingsProxyService, - private readonly creditConsumptionService: CreditConsumptionService - ) { - this.webhookSecret = this.configService.get('MEETING_BOT_WEBHOOK_SECRET') || ''; - - if (!this.webhookSecret) { - this.logger.warn( - 'MEETING_BOT_WEBHOOK_SECRET not configured - webhook signature verification disabled' - ); - } - } - - /** - * Verify HMAC signature from meeting-bot service - */ - private verifySignature(payload: string, signature: string): boolean { - if (!this.webhookSecret) { - this.logger.warn('[verifySignature] No webhook secret configured, skipping verification'); - return true; - } - - if (!signature) { - this.logger.error('[verifySignature] No signature provided'); - return false; - } - - try { - // Signature format: sha256= - const [algorithm, providedSignature] = signature.split('='); - - if (algorithm !== 'sha256' || !providedSignature) { - this.logger.error('[verifySignature] Invalid signature format'); - return false; - } - - const expectedSignature = createHmac('sha256', this.webhookSecret) - .update(payload) - .digest('hex'); - - const providedBuffer = Buffer.from(providedSignature, 'hex'); - const expectedBuffer = Buffer.from(expectedSignature, 'hex'); - - if (providedBuffer.length !== expectedBuffer.length) { - return false; - } - - return timingSafeEqual(providedBuffer, expectedBuffer); - } catch (error) { - this.logger.error('[verifySignature] Error verifying signature:', error); - return false; - } - } - - /** - * Generate idempotency key from event - */ - private getIdempotencyKey(event: WebhookEventDto): string { - return `${event.bot.id}:${event.event}:${event.timestamp}`; - } - - /** - * Calculate credits from duration - */ - private calculateCredits(durationSeconds: number): number { - const minutes = durationSeconds / 60; - const cost = Math.ceil(minutes * OPERATION_COSTS.TRANSCRIPTION_PER_MINUTE); - return Math.max(cost, 2); // Minimum 2 credits - } - - /** - * Handle webhook events from meeting-bot service - * POST /meetings/webhooks/bot-events - */ - @Post('bot-events') - async handleBotEvent( - @Body() payload: WebhookEventDto, - @Headers('x-webhook-signature') signature: string - ) { - this.logger.log(`[handleBotEvent] Received event: ${payload.event} for bot ${payload.bot.id}`); - - // Verify signature if secret is configured - if (this.webhookSecret) { - const payloadString = JSON.stringify(payload); - if (!this.verifySignature(payloadString, signature)) { - this.logger.error('[handleBotEvent] Invalid webhook signature'); - throw new UnauthorizedException('Invalid webhook signature'); - } - } - - // Check for duplicate event (idempotency) - const idempotencyKey = this.getIdempotencyKey(payload); - if (this.processedEvents.has(idempotencyKey)) { - this.logger.log(`[handleBotEvent] Duplicate event ignored: ${idempotencyKey}`); - return { success: true, message: 'Event already processed' }; - } - - // Mark as processed (in memory - for production use a database) - this.processedEvents.add(idempotencyKey); - - // Clean up old events (keep last 1000) - if (this.processedEvents.size > 1000) { - const iterator = this.processedEvents.values(); - for (let i = 0; i < 500; i++) { - this.processedEvents.delete(iterator.next().value); - } - } - - try { - if (payload.event === 'recording.completed') { - return await this.handleRecordingCompleted(payload); - } else if (payload.event === 'recording.failed') { - return await this.handleRecordingFailed(payload); - } - - this.logger.warn(`[handleBotEvent] Unknown event type: ${payload.event}`); - return { success: true, message: 'Unknown event type' }; - } catch (error) { - this.logger.error(`[handleBotEvent] Error processing event:`, error); - // Remove from processed set so it can be retried - this.processedEvents.delete(idempotencyKey); - throw new BadRequestException('Failed to process webhook event'); - } - } - - /** - * Handle recording completed event - */ - private async handleRecordingCompleted(payload: WebhookEventDto) { - this.logger.log( - `[handleRecordingCompleted] Processing completed recording for bot ${payload.bot.id}` - ); - - const { bot, recording } = payload; - const durationSeconds = recording?.duration_seconds || 0; - const creditsToConsume = this.calculateCredits(durationSeconds); - - this.logger.log( - `[handleRecordingCompleted] Duration: ${durationSeconds}s, Credits: ${creditsToConsume}` - ); - - // Consume credits - try { - const creditResult = await this.creditConsumptionService.consumeCreditsForOperation( - bot.user_id, - 'meeting_recording' as any, - creditsToConsume, - `Meeting recording completed - ${Math.round(durationSeconds / 60)} minutes`, - { - botId: bot.id, - recordingId: recording?.id, - durationSeconds, - durationMinutes: Math.round(durationSeconds / 60), - }, - bot.space_id - ); - - this.logger.log(`[handleRecordingCompleted] Credits consumed:`, creditResult); - - // Update bot with credits consumed - await this.meetingsProxyService.updateBotCredits(bot.id, creditsToConsume, durationSeconds); - } catch (error) { - this.logger.error(`[handleRecordingCompleted] Failed to consume credits:`, error); - // Don't fail the webhook - recording is still valid - // Credits can be reconciled manually if needed - } - - return { - success: true, - message: 'Recording completed processed', - botId: bot.id, - recordingId: recording?.id, - creditsConsumed: creditsToConsume, - }; - } - - /** - * Handle recording failed event - */ - private async handleRecordingFailed(payload: WebhookEventDto) { - this.logger.log( - `[handleRecordingFailed] Processing failed recording for bot ${payload.bot.id}` - ); - - const { bot, error } = payload; - - this.logger.error( - `[handleRecordingFailed] Bot ${bot.id} failed: ${error?.code} - ${error?.message}` - ); - - // No credits consumed on failure - // Update bot state if needed (should already be updated by meeting-bot) - - return { - success: true, - message: 'Recording failure processed', - botId: bot.id, - error: error?.message, - }; - } -} diff --git a/apps/memoro/apps/backend/src/meetings/meetings.controller.ts b/apps/memoro/apps/backend/src/meetings/meetings.controller.ts deleted file mode 100644 index 609136a97..000000000 --- a/apps/memoro/apps/backend/src/meetings/meetings.controller.ts +++ /dev/null @@ -1,276 +0,0 @@ -import { - Controller, - Post, - Get, - Param, - Body, - Query, - Req, - UseGuards, - Logger, - NotFoundException, - BadRequestException, -} from '@nestjs/common'; -import { AuthGuard } from '../guards/auth.guard'; -import { User } from '../decorators/user.decorator'; -import { JwtPayload } from '../types/jwt-payload.interface'; -import { MeetingsProxyService } from './meetings-proxy.service'; -import { CreditConsumptionService } from '../credits/credit-consumption.service'; -import { CreateBotDto, ListBotsQueryDto, ListRecordingsQueryDto } from './dto/create-bot.dto'; -import { OPERATION_COSTS } from '../credits/pricing.constants'; - -// Minimum credits required to start a recording (5 minutes worth) -const MINIMUM_RECORDING_CREDITS = 10; - -@Controller('meetings') -@UseGuards(AuthGuard) -export class MeetingsController { - private readonly logger = new Logger(MeetingsController.name); - - constructor( - private readonly meetingsProxyService: MeetingsProxyService, - private readonly creditConsumptionService: CreditConsumptionService - ) {} - - /** - * Validate meeting URL format - */ - private validateMeetingUrl(url: string): boolean { - if (!url) return false; - // Check for supported platforms - return /(teams\.microsoft\.com|meet\.google\.com|zoom\.(us|com))/i.test(url); - } - - /** - * Create a new meeting bot to record a meeting - * POST /meetings/bots - */ - @Post('bots') - async createBot(@User() user: JwtPayload & { token: string }, @Body() dto: CreateBotDto) { - this.logger.log(`[createBot] User ${user.sub} creating bot for: ${dto.meeting_url}`); - - // Validate meeting URL - if (!dto.meeting_url || !this.validateMeetingUrl(dto.meeting_url)) { - throw new BadRequestException( - 'Please provide a valid Teams, Google Meet, or Zoom meeting URL' - ); - } - - // Validate user has minimum credits - const creditCheck = await this.creditConsumptionService.validateCreditsForOperation( - user.sub, - 'meeting_recording' as any, - MINIMUM_RECORDING_CREDITS, - dto.space_id - ); - - if (!creditCheck.hasEnoughCredits) { - throw new BadRequestException({ - error: 'InsufficientCredits', - message: `Not enough credits to start recording. Need at least ${MINIMUM_RECORDING_CREDITS} credits.`, - details: { - requiredCredits: MINIMUM_RECORDING_CREDITS, - availableCredits: creditCheck.availableCredits, - }, - }); - } - - // Create the bot via meeting-bot service - const bot = await this.meetingsProxyService.createBot(user.sub, dto.meeting_url, dto.space_id); - - return { - success: true, - bot, - message: 'Meeting bot created. It will join the meeting shortly.', - creditInfo: { - estimatedCostPerMinute: OPERATION_COSTS.TRANSCRIPTION_PER_MINUTE, - minimumCredits: MINIMUM_RECORDING_CREDITS, - availableCredits: creditCheck.availableCredits, - }, - }; - } - - /** - * List user's meeting bots - * GET /meetings/bots - */ - @Get('bots') - async listBots(@User() user: JwtPayload & { token: string }, @Query() query: ListBotsQueryDto) { - this.logger.log(`[listBots] User ${user.sub} listing bots`); - - const bots = await this.meetingsProxyService.getBots( - user.sub, - query.space_id, - query.limit || 50, - query.offset || 0 - ); - - return { - success: true, - bots, - total: bots.length, - }; - } - - /** - * Get a specific bot by ID - * GET /meetings/bots/:id - */ - @Get('bots/:id') - async getBot(@User() user: JwtPayload & { token: string }, @Param('id') botId: string) { - this.logger.log(`[getBot] User ${user.sub} getting bot ${botId}`); - - const bot = await this.meetingsProxyService.getBotById(botId, user.sub); - - if (!bot) { - throw new NotFoundException('Bot not found'); - } - - return { - success: true, - bot, - }; - } - - /** - * Stop a meeting bot - * POST /meetings/bots/:id/stop - */ - @Post('bots/:id/stop') - async stopBot(@User() user: JwtPayload & { token: string }, @Param('id') botId: string) { - this.logger.log(`[stopBot] User ${user.sub} stopping bot ${botId}`); - - const result = await this.meetingsProxyService.stopBot(botId, user.sub); - - return { - success: result.success, - message: 'Bot stop signal sent. Recording will end shortly.', - }; - } - - /** - * List user's recordings - * GET /meetings/recordings - */ - @Get('recordings') - async listRecordings( - @User() user: JwtPayload & { token: string }, - @Query() query: ListRecordingsQueryDto - ) { - this.logger.log(`[listRecordings] User ${user.sub} listing recordings`); - - const recordings = await this.meetingsProxyService.getRecordings( - user.sub, - query.space_id, - query.limit || 50, - query.offset || 0 - ); - - return { - success: true, - recordings, - total: recordings.length, - }; - } - - /** - * Get a specific recording by ID - * GET /meetings/recordings/:id - */ - @Get('recordings/:id') - async getRecording( - @User() user: JwtPayload & { token: string }, - @Param('id') recordingId: string - ) { - this.logger.log(`[getRecording] User ${user.sub} getting recording ${recordingId}`); - - const recording = await this.meetingsProxyService.getRecordingById(recordingId, user.sub); - - if (!recording) { - throw new NotFoundException('Recording not found'); - } - - return { - success: true, - recording, - }; - } - - /** - * Convert a meeting recording to a memo - * POST /meetings/recordings/:id/to-memo - * - * This endpoint triggers the full transcription/processing pipeline - * by calling the internal /memoro/process-uploaded-audio endpoint. - */ - @Post('recordings/:id/to-memo') - async convertToMemo( - @User() user: JwtPayload & { token: string }, - @Param('id') recordingId: string, - @Body() body: { blueprintId?: string }, - @Req() req - ) { - const token = req.token; - this.logger.log(`[convertToMemo] User ${user.sub} converting recording ${recordingId} to memo`); - - // Get the recording - const recording = await this.meetingsProxyService.getRecordingById(recordingId, user.sub); - - if (!recording) { - throw new NotFoundException('Recording not found'); - } - - // Use audio_url if available, otherwise fall back to video_url - const filePath = recording.audio_url || recording.video_url; - - if (!filePath) { - throw new BadRequestException('Recording has no audio or video file'); - } - - // Get duration from recording or default to a reasonable estimate - const duration = recording.duration_seconds || 45; - - try { - // Call the internal process-uploaded-audio endpoint - // This handles everything: credit check, memo creation, transcription - const port = process.env.PORT || 3001; - const response = await fetch(`http://localhost:${port}/memoro/process-uploaded-audio`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${token}`, - }, - body: JSON.stringify({ - filePath: filePath, - duration: duration, - spaceId: recording.space_id, - blueprintId: body.blueprintId, - }), - }); - - if (!response.ok) { - const errorData = await response.json().catch(() => ({ message: 'Unknown error' })); - this.logger.error(`[convertToMemo] Failed to process audio:`, errorData); - throw new BadRequestException(errorData.message || 'Failed to process recording'); - } - - const result = await response.json(); - - this.logger.log( - `[convertToMemo] Created memo ${result.memoId} from recording ${recordingId}` - ); - - return { - success: true, - memoId: result.memoId, - memo: result.memo, - audioPath: filePath, - status: result.status, - message: 'Recording converted to memo. Transcription in progress.', - }; - } catch (error) { - this.logger.error(`[convertToMemo] Error converting recording to memo:`, error); - throw new BadRequestException(error.message || 'Failed to convert recording to memo'); - } - } -} diff --git a/apps/memoro/apps/backend/src/meetings/meetings.module.ts b/apps/memoro/apps/backend/src/meetings/meetings.module.ts deleted file mode 100644 index daf3884e4..000000000 --- a/apps/memoro/apps/backend/src/meetings/meetings.module.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { Module, forwardRef } from '@nestjs/common'; -import { ConfigModule } from '@nestjs/config'; -import { AuthModule } from '../auth/auth.module'; -import { CreditsModule } from '../credits/credits.module'; -import { MemoroModule } from '../memoro/memoro.module'; -import { MeetingsController } from './meetings.controller'; -import { MeetingsWebhookController } from './meetings-webhook.controller'; -import { MeetingsProxyService } from './meetings-proxy.service'; - -@Module({ - imports: [ConfigModule, AuthModule, CreditsModule, forwardRef(() => MemoroModule)], - controllers: [MeetingsController, MeetingsWebhookController], - providers: [MeetingsProxyService], - exports: [MeetingsProxyService], -}) -export class MeetingsModule {} diff --git a/apps/memoro/apps/backend/src/memoro/__tests__/video-upload.e2e.test.ts b/apps/memoro/apps/backend/src/memoro/__tests__/video-upload.e2e.test.ts deleted file mode 100644 index 9c29776da..000000000 --- a/apps/memoro/apps/backend/src/memoro/__tests__/video-upload.e2e.test.ts +++ /dev/null @@ -1,585 +0,0 @@ -/** - * End-to-End Tests for Video Upload and Processing - * Tests the complete flow from upload to transcription - */ - -import { Test, TestingModule } from '@nestjs/testing'; -import { MemoroController } from '../memoro.controller'; -import { MemoroService } from '../memoro.service'; -import { CreditClientService } from '../../credits/credit-client.service'; -import { ConfigService } from '@nestjs/config'; -import { BadRequestException, NotFoundException } from '@nestjs/common'; -import { JwtPayload } from '../../types/jwt-payload.interface'; - -describe('Video Upload E2E Tests', () => { - let controller: MemoroController; - let service: MemoroService; - let creditService: CreditClientService; - let configService: ConfigService; - - const mockUser: JwtPayload = { - sub: 'test-user-123', - email: 'test@example.com', - role: 'authenticated', - app_id: 'test-app-id', - aud: 'authenticated', - iat: Date.now(), - exp: Date.now() + 3600000, - }; - - const mockRequest = { - token: 'mock-jwt-token', - }; - - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - controllers: [MemoroController], - providers: [ - { - provide: MemoroService, - useValue: { - createMemoFromUploadedFile: jest.fn(), - updateMemoTranscriptionStatus: jest.fn(), - validateMemoForAppend: jest.fn(), - updateAppendTranscriptionStatus: jest.fn(), - getSupabaseUrl: jest.fn().mockReturnValue('https://test.supabase.co'), - getSupabaseKey: jest.fn().mockReturnValue('test-key'), - }, - }, - { - provide: CreditClientService, - useValue: { - checkUserCredits: jest.fn(), - checkSpaceCredits: jest.fn(), - checkAndConsumeCredits: jest.fn(), - }, - }, - { - provide: ConfigService, - useValue: { - get: jest.fn((key: string) => { - if (key === 'AUDIO_MICROSERVICE_URL') { - return 'https://audio-microservice.test'; - } - return null; - }), - }, - }, - ], - }).compile(); - - controller = module.get(MemoroController); - service = module.get(MemoroService); - creditService = module.get(CreditClientService); - configService = module.get(ConfigService); - }); - - afterEach(() => { - jest.clearAllMocks(); - }); - - describe('Video file processing', () => { - it('should process MP4 video file successfully', async () => { - const processData = { - filePath: 'user-123/memo-456/video_2025-01-01.mp4', - duration: 180, // 3 minutes - spaceId: undefined, - blueprintId: null, - recordingLanguages: ['en-US'], - memoId: 'memo-456', - location: null, - recordingStartedAt: new Date().toISOString(), - enableDiarization: true, - mediaType: 'video' as const, - }; - - const mockMemoResult = { - memoId: 'memo-456', - memo: { - id: 'memo-456', - user_id: mockUser.sub, - created_at: new Date().toISOString(), - metadata: { - processing: { - transcription: { status: 'pending' }, - }, - }, - source: { - audio_path: processData.filePath, - duration: processData.duration, - type: 'video', - }, - }, - audioPath: processData.filePath, - }; - - // Mock credit check - jest.spyOn(creditService, 'checkUserCredits').mockResolvedValue({ - hasEnoughCredits: true, - requiredCredits: 30, - currentCredits: 100, - creditType: 'user', - }); - - // Mock memo creation - jest.spyOn(service, 'createMemoFromUploadedFile').mockResolvedValue(mockMemoResult); - - const result = await controller.processUploadedAudio(mockUser, processData, mockRequest); - - expect(result).toMatchObject({ - success: true, - memoId: 'memo-456', - status: 'processing', - mediaType: 'video', - }); - - expect(service.createMemoFromUploadedFile).toHaveBeenCalledWith( - mockUser.sub, - processData.filePath, - processData.duration, - processData.spaceId, - processData.blueprintId, - processData.memoId, - mockRequest.token, - processData.recordingStartedAt, - processData.location, - 'video', - undefined - ); - }); - - it('should auto-detect video type from file extension', async () => { - const processData = { - filePath: 'user-123/memo-456/recording.mov', - duration: 240, // 4 minutes - mediaType: undefined, // Not specified - }; - - jest.spyOn(creditService, 'checkUserCredits').mockResolvedValue({ - hasEnoughCredits: true, - requiredCredits: 40, - currentCredits: 100, - creditType: 'user', - }); - - jest.spyOn(service, 'createMemoFromUploadedFile').mockResolvedValue({ - memoId: 'memo-456', - memo: { id: 'memo-456' } as any, - audioPath: processData.filePath, - }); - - const result = await controller.processUploadedAudio(mockUser, processData, mockRequest); - - expect(result.mediaType).toBe('video'); - }); - - it('should handle various video formats', async () => { - const videoFormats = [ - { ext: 'mp4', mime: 'video/mp4' }, - { ext: 'mov', mime: 'video/quicktime' }, - { ext: 'avi', mime: 'video/x-msvideo' }, - { ext: 'mkv', mime: 'video/x-matroska' }, - { ext: 'webm', mime: 'video/webm' }, - { ext: 'm4v', mime: 'video/x-m4v' }, - ]; - - for (const format of videoFormats) { - const processData = { - filePath: `user-123/memo-456/video.${format.ext}`, - duration: 180, - mediaType: 'video' as const, - }; - - jest.spyOn(creditService, 'checkUserCredits').mockResolvedValue({ - hasEnoughCredits: true, - requiredCredits: 30, - currentCredits: 100, - creditType: 'user', - }); - - jest.spyOn(service, 'createMemoFromUploadedFile').mockResolvedValue({ - memoId: `memo-${format.ext}`, - memo: { id: `memo-${format.ext}` } as any, - audioPath: processData.filePath, - }); - - const result = await controller.processUploadedAudio(mockUser, processData, mockRequest); - - expect(result.success).toBe(true); - expect(result.mediaType).toBe('video'); - } - }); - }); - - describe('Credit validation for video files', () => { - it('should calculate credits correctly for video duration', async () => { - const processData = { - filePath: 'user-123/memo-456/long-video.mp4', - duration: 7200, // 2 hours = 120 minutes - mediaType: 'video' as const, - }; - - // 120 minutes * 100 credits/60 minutes = 200 credits - const expectedCredits = 200; - - jest.spyOn(creditService, 'checkUserCredits').mockResolvedValue({ - hasEnoughCredits: true, - requiredCredits: expectedCredits, - currentCredits: 500, - creditType: 'user', - }); - - jest.spyOn(service, 'createMemoFromUploadedFile').mockResolvedValue({ - memoId: 'memo-456', - memo: { id: 'memo-456' } as any, - audioPath: processData.filePath, - }); - - await controller.processUploadedAudio(mockUser, processData, mockRequest); - - expect(creditService.checkUserCredits).toHaveBeenCalledWith( - mockUser.sub, - expectedCredits, - mockRequest.token - ); - }); - - it('should reject video upload with insufficient credits', async () => { - const processData = { - filePath: 'user-123/memo-456/video.mp4', - duration: 3600, // 1 hour - mediaType: 'video' as const, - }; - - jest.spyOn(creditService, 'checkUserCredits').mockResolvedValue({ - hasEnoughCredits: false, - requiredCredits: 100, - currentCredits: 50, - creditType: 'user', - }); - - await expect( - controller.processUploadedAudio(mockUser, processData, mockRequest) - ).rejects.toThrow('Insufficient credits'); - }); - }); - - describe('Error handling', () => { - it('should reject invalid file path', async () => { - const processData = { - filePath: '', - duration: 180, - mediaType: 'video' as const, - }; - - await expect( - controller.processUploadedAudio(mockUser, processData, mockRequest) - ).rejects.toThrow(BadRequestException); - }); - - it('should reject invalid duration', async () => { - const processData = { - filePath: 'user-123/memo-456/video.mp4', - duration: 0, - mediaType: 'video' as const, - }; - - await expect( - controller.processUploadedAudio(mockUser, processData, mockRequest) - ).rejects.toThrow(BadRequestException); - }); - - it('should reject negative duration', async () => { - const processData = { - filePath: 'user-123/memo-456/video.mp4', - duration: -100, - mediaType: 'video' as const, - }; - - await expect( - controller.processUploadedAudio(mockUser, processData, mockRequest) - ).rejects.toThrow(BadRequestException); - }); - - it('should reject unsupported file type', async () => { - const processData = { - filePath: 'user-123/memo-456/document.pdf', - duration: 180, - mediaType: undefined, - }; - - jest.spyOn(creditService, 'checkUserCredits').mockResolvedValue({ - hasEnoughCredits: true, - requiredCredits: 30, - currentCredits: 100, - creditType: 'user', - }); - - await expect( - controller.processUploadedAudio(mockUser, processData, mockRequest) - ).rejects.toThrow(BadRequestException); - }); - }); - - describe('Large file handling', () => { - it('should process large video files with batch transcription', async () => { - const processData = { - filePath: 'user-123/memo-456/long-presentation.mp4', - duration: 7200, // 2 hours - should trigger batch processing - mediaType: 'video' as const, - recordingLanguages: ['en-US'], - }; - - jest.spyOn(creditService, 'checkUserCredits').mockResolvedValue({ - hasEnoughCredits: true, - requiredCredits: 200, - currentCredits: 500, - creditType: 'user', - }); - - jest.spyOn(service, 'createMemoFromUploadedFile').mockResolvedValue({ - memoId: 'memo-456', - memo: { - id: 'memo-456', - source: { - audio_path: processData.filePath, - duration: processData.duration, - type: 'video', - }, - } as any, - audioPath: processData.filePath, - }); - - const result = await controller.processUploadedAudio(mockUser, processData, mockRequest); - - expect(result.success).toBe(true); - expect(result.estimatedDuration).toBe(120); // 7200 seconds = 120 minutes - }); - - it('should handle file size limits', async () => { - const processData = { - filePath: 'user-123/memo-456/huge-video.mp4', - duration: 86400, // 24 hours - mediaType: 'video' as const, - }; - - jest.spyOn(creditService, 'checkUserCredits').mockResolvedValue({ - hasEnoughCredits: true, - requiredCredits: 1440, // 24 hours * 100 / 60 - currentCredits: 2000, - creditType: 'user', - }); - - jest.spyOn(service, 'createMemoFromUploadedFile').mockResolvedValue({ - memoId: 'memo-456', - memo: { id: 'memo-456' } as any, - audioPath: processData.filePath, - }); - - const result = await controller.processUploadedAudio(mockUser, processData, mockRequest); - - expect(result.success).toBe(true); - expect(result.estimatedDuration).toBe(1440); // 24 hours in minutes - }); - }); - - describe('Append video recording', () => { - it('should append video recording to existing memo', async () => { - const appendData = { - memoId: 'existing-memo-123', - filePath: 'user-123/memo-123/additional-video.mp4', - duration: 120, - enableDiarization: true, - }; - - const mockMemo = { - id: 'existing-memo-123', - user_id: mockUser.sub, - metadata: { - spaceId: undefined, - }, - source: { - transcript: 'Original transcript', - audio_path: 'user-123/memo-123/original-audio.m4a', - duration: 180, - }, - }; - - jest.spyOn(service, 'validateMemoForAppend').mockResolvedValue(mockMemo as any); - - jest.spyOn(creditService, 'checkUserCredits').mockResolvedValue({ - hasEnoughCredits: true, - requiredCredits: 20, - currentCredits: 100, - creditType: 'user', - }); - - const result = await controller.appendTranscription(mockUser, appendData, mockRequest); - - expect(result).toMatchObject({ - success: true, - memoId: 'existing-memo-123', - status: 'processing', - }); - - expect(service.validateMemoForAppend).toHaveBeenCalledWith( - mockUser.sub, - appendData.memoId, - mockRequest.token - ); - }); - - it('should reject append to non-existent memo', async () => { - const appendData = { - memoId: 'non-existent-memo', - filePath: 'user-123/memo-123/video.mp4', - duration: 120, - }; - - jest.spyOn(service, 'validateMemoForAppend').mockResolvedValue(null); - - await expect( - controller.appendTranscription(mockUser, appendData, mockRequest) - ).rejects.toThrow(NotFoundException); - }); - }); - - describe('Performance tests', () => { - it('should handle concurrent video uploads', async () => { - const uploadPromises = Array(5) - .fill(null) - .map((_, index) => { - const processData = { - filePath: `user-123/memo-${index}/video.mp4`, - duration: 180, - mediaType: 'video' as const, - }; - - jest.spyOn(creditService, 'checkUserCredits').mockResolvedValue({ - hasEnoughCredits: true, - requiredCredits: 30, - currentCredits: 500, - creditType: 'user', - }); - - jest.spyOn(service, 'createMemoFromUploadedFile').mockResolvedValue({ - memoId: `memo-${index}`, - memo: { id: `memo-${index}` } as any, - audioPath: processData.filePath, - }); - - return controller.processUploadedAudio(mockUser, processData, mockRequest); - }); - - const results = await Promise.all(uploadPromises); - - expect(results).toHaveLength(5); - results.forEach((result, index) => { - expect(result.success).toBe(true); - expect(result.memoId).toBe(`memo-${index}`); - }); - }); - - it('should process video upload within acceptable time', async () => { - const processData = { - filePath: 'user-123/memo-456/video.mp4', - duration: 300, - mediaType: 'video' as const, - }; - - jest.spyOn(creditService, 'checkUserCredits').mockResolvedValue({ - hasEnoughCredits: true, - requiredCredits: 50, - currentCredits: 100, - creditType: 'user', - }); - - jest.spyOn(service, 'createMemoFromUploadedFile').mockResolvedValue({ - memoId: 'memo-456', - memo: { id: 'memo-456' } as any, - audioPath: processData.filePath, - }); - - const start = Date.now(); - await controller.processUploadedAudio(mockUser, processData, mockRequest); - const duration = Date.now() - start; - - // Should complete within 1 second (excluding actual transcription) - expect(duration).toBeLessThan(1000); - }); - }); - - describe('Security tests', () => { - it('should validate user authorization', async () => { - const processData = { - filePath: 'other-user/memo-456/video.mp4', - duration: 180, - mediaType: 'video' as const, - }; - - // Service should validate that file path matches user - jest.spyOn(creditService, 'checkUserCredits').mockResolvedValue({ - hasEnoughCredits: true, - requiredCredits: 30, - currentCredits: 100, - creditType: 'user', - }); - - jest.spyOn(service, 'createMemoFromUploadedFile').mockResolvedValue({ - memoId: 'memo-456', - memo: { id: 'memo-456' } as any, - audioPath: processData.filePath, - }); - - await controller.processUploadedAudio(mockUser, processData, mockRequest); - - // Verify service was called with correct user ID - expect(service.createMemoFromUploadedFile).toHaveBeenCalledWith( - mockUser.sub, - expect.any(String), - expect.any(Number), - expect.anything(), - expect.anything(), - expect.anything(), - expect.any(String), - expect.anything(), - expect.anything(), - expect.any(String), - expect.anything() - ); - }); - - it('should reject path traversal attempts', async () => { - const maliciousPaths = [ - '../../../etc/passwd', - '..\\..\\..\\windows\\system32', - 'user-123/../other-user/memo-456/video.mp4', - ]; - - for (const path of maliciousPaths) { - const processData = { - filePath: path, - duration: 180, - mediaType: 'video' as const, - }; - - // The service should handle path validation - jest.spyOn(creditService, 'checkUserCredits').mockResolvedValue({ - hasEnoughCredits: true, - requiredCredits: 30, - currentCredits: 100, - creditType: 'user', - }); - - // Assuming service validates and rejects - jest - .spyOn(service, 'createMemoFromUploadedFile') - .mockRejectedValue(new BadRequestException('Invalid file path')); - - await expect( - controller.processUploadedAudio(mockUser, processData, mockRequest) - ).rejects.toThrow(); - } - }); - }); -}); diff --git a/apps/memoro/apps/backend/src/memoro/combine-memos.controller.ts b/apps/memoro/apps/backend/src/memoro/combine-memos.controller.ts deleted file mode 100644 index d18c5d34c..000000000 --- a/apps/memoro/apps/backend/src/memoro/combine-memos.controller.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { Controller, Post, Body, UseGuards, BadRequestException } from '@nestjs/common'; -import { AuthGuard } from '../guards/auth.guard'; -import { User } from '../decorators/user.decorator'; -import { CreditConsumptionService } from '../credits/credit-consumption.service'; -import { calculateMemoCombineCost } from '../credits/pricing.constants'; -import { InsufficientCreditsException } from '../errors/insufficient-credits.error'; - -class CombineMemosDto { - memo_ids: string[]; - blueprint_id: string; - custom_prompt?: string; -} - -@Controller('memoro/combine-memos') -@UseGuards(AuthGuard) -export class CombineMemosController { - constructor(private readonly creditConsumptionService: CreditConsumptionService) {} - - @Post() - async processCombineMemos(@User() user: any, @Body() dto: CombineMemosDto) { - if (!dto.memo_ids || !Array.isArray(dto.memo_ids) || dto.memo_ids.length === 0) { - throw new BadRequestException('memo_ids must be a non-empty array'); - } - - if (!dto.blueprint_id) { - throw new BadRequestException('blueprint_id is required'); - } - - // Extract token from request - const token = user.token; - const requiredCredits = calculateMemoCombineCost(dto.memo_ids.length); - - try { - // Check and consume credits first using centralized service - const creditResult = await this.creditConsumptionService.consumeCombinationCredits( - user.sub, - dto.memo_ids, - undefined, // spaceId - token - ); - - if (!creditResult.success) { - throw new BadRequestException(creditResult.message || creditResult.error); - } - - // Now call the Supabase Edge Function to do the AI processing - // Create an authenticated Supabase client with the user's JWT token - const { createClient } = require('@supabase/supabase-js'); - const supabaseUrl = - process.env.MEMORO_SUPABASE_URL || 'https://npgifbrwhftlbrbaglmi.supabase.co'; - const anonKey = process.env.MEMORO_SUPABASE_ANON_KEY; - - // Create a Supabase client with user's JWT token - const supabase = createClient(supabaseUrl, anonKey, { - global: { - headers: { - Authorization: `Bearer ${token}`, - }, - }, - }); - - console.log('CombineMemosController - Calling Supabase function with authenticated client'); - - const requestBody: any = { - memo_ids: dto.memo_ids, - blueprint_id: dto.blueprint_id, - }; - - if (dto.custom_prompt) { - requestBody.custom_prompt = dto.custom_prompt; - } - - console.log('CombineMemosController - Request body:', requestBody); - - const { data, error: functionError } = await supabase.functions.invoke('combine-memos', { - body: requestBody, - }); - - if (functionError) { - console.error('CombineMemosController - Supabase function error:', functionError); - throw new Error(`Memo combination failed: ${functionError.message}`); - } - - console.log('CombineMemosController - Supabase function result:', data); - - const result = data; - - return { - success: true, - memo_id: result.memo_id, - combined_memos_count: result.combined_memos_count, - processed_prompts_count: result.processed_prompts_count, - total_prompts_count: result.total_prompts_count, - creditsConsumed: requiredCredits, - creditType: creditResult.creditType, - }; - } catch (error) { - if (error instanceof InsufficientCreditsException) { - throw error; // Let the exception propagate with 402 status - } - - if (error.message?.includes('insufficient credits')) { - // Fallback for any legacy insufficient credit errors - throw new InsufficientCreditsException({ - requiredCredits, - availableCredits: 0, - creditType: 'user', // CombineMemosDto doesn't have space_id - operation: 'combination', - }); - } - throw new BadRequestException(error.message); - } - } -} diff --git a/apps/memoro/apps/backend/src/memoro/memoro-service.controller.ts b/apps/memoro/apps/backend/src/memoro/memoro-service.controller.ts deleted file mode 100644 index 1972475fa..000000000 --- a/apps/memoro/apps/backend/src/memoro/memoro-service.controller.ts +++ /dev/null @@ -1,131 +0,0 @@ -import { Controller, Post, Body, UseGuards, HttpCode, BadRequestException } from '@nestjs/common'; -import { MemoroService } from './memoro.service'; -import { ServiceAuthGuard } from '../guards/service-auth.guard'; - -@Controller('memoro/service') -@UseGuards(ServiceAuthGuard) -export class MemoroServiceController { - constructor(private readonly memoroService: MemoroService) {} - - /** - * Service-to-service endpoint for transcription completion - * Used by audio microservice with service role key authentication - */ - @Post('transcription-completed') - @HttpCode(200) - async handleTranscriptionCompleted( - @Body() - callbackData: { - memoId: string; - userId: string; - transcriptionResult?: any; - route?: 'fast' | 'batch'; - success?: boolean; - error?: string; - } - ) { - try { - console.log(`[Service Auth] Received transcription callback for memo ${callbackData.memoId}`); - - if (!callbackData.memoId || !callbackData.userId) { - throw new BadRequestException('memoId and userId are required'); - } - - // Process the transcription using the existing method - // The service will use service role key and validate ownership - const result = await this.memoroService.handleTranscriptionCompleted( - callbackData.memoId, - callbackData.userId, - callbackData.transcriptionResult, - callbackData.route, - callbackData.success, - callbackData.error, - null // No user token - will use service role key - ); - - return result; - } catch (error) { - console.error(`[Service Auth] Error processing callback:`, error); - throw new BadRequestException(`Failed to process transcription callback: ${error.message}`); - } - } - - /** - * Service-to-service endpoint for append transcription completion - */ - @Post('append-transcription-completed') - @HttpCode(200) - async handleAppendTranscriptionCompleted( - @Body() - callbackData: { - memoId: string; - userId: string; - transcriptionResult?: any; - route?: 'fast' | 'batch'; - success?: boolean; - error?: string; - recordingIndex?: number; - } - ) { - try { - console.log( - `[Service Auth] Received append transcription callback for memo ${callbackData.memoId}` - ); - - if (!callbackData.memoId || !callbackData.userId) { - throw new BadRequestException('memoId and userId are required'); - } - - const result = await this.memoroService.handleAppendTranscriptionCompleted( - callbackData.memoId, - callbackData.userId, - callbackData.transcriptionResult, - callbackData.route || 'fast', - callbackData.success !== false, - callbackData.error || null, - null // No user token - will use service role key - ); - - return result; - } catch (error) { - console.error(`[Service Auth] Error processing append callback:`, error); - throw new BadRequestException( - `Failed to process append transcription callback: ${error.message}` - ); - } - } - - /** - * Service-to-service endpoint for updating batch metadata - */ - @Post('update-batch-metadata') - @HttpCode(200) - async updateBatchMetadata( - @Body() - body: { - memoId: string; - jobId: string; - batchTranscription: boolean; - userId?: string; // Optional for backward compatibility - } - ) { - try { - const { memoId, jobId, batchTranscription, userId } = body; - console.log(`[Service Auth] Updating batch metadata for memo ${memoId}`); - - const result = await this.memoroService.updateBatchMetadataByMemoId( - memoId, - jobId, - batchTranscription, - null, // No user token needed - service will use service role key - undefined, // userSelectedLanguages - userId // Pass userId for ownership validation - ); - - return result; - } catch (error) { - console.error(`[Service Auth] Error updating batch metadata:`, error); - throw new BadRequestException(`Failed to update batch metadata: ${error.message}`); - } - } -} diff --git a/apps/memoro/apps/backend/src/memoro/memoro.controller.spec.ts b/apps/memoro/apps/backend/src/memoro/memoro.controller.spec.ts deleted file mode 100644 index a1459e2d3..000000000 --- a/apps/memoro/apps/backend/src/memoro/memoro.controller.spec.ts +++ /dev/null @@ -1,644 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { MemoroController } from './memoro.controller'; -import { MemoroService } from './memoro.service'; -import { CreditClientService, CreditCheckResponse } from '../credits/credit-client.service'; -import { AuthGuard } from '../guards/auth.guard'; -import { BadRequestException, ForbiddenException, NotFoundException } from '@nestjs/common'; -import { JwtPayload } from '../types/jwt-payload.interface'; -import { MemoroSpaceDto } from '../interfaces/memoro.interfaces'; - -describe('MemoroController', () => { - let controller: MemoroController; - let memoroService: jest.Mocked; - let creditClientService: jest.Mocked; - - const mockUser: JwtPayload = { - sub: 'user-123', - email: 'test@example.com', - role: 'user', - app_id: 'test-app-id', - aud: 'authenticated', - iat: 1234567890, - exp: 1234567890, - }; - - const mockRequest = { - token: 'mock-token', - user: mockUser, - }; - - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - controllers: [MemoroController], - providers: [ - { - provide: MemoroService, - useValue: { - getMemoroSpaces: jest.fn(), - createMemoroSpace: jest.fn(), - getMemoroSpaceDetails: jest.fn(), - deleteMemoroSpace: jest.fn(), - linkMemoToSpace: jest.fn(), - unlinkMemoFromSpace: jest.fn(), - getSpaceInvites: jest.fn(), - inviteUserToSpace: jest.fn(), - resendSpaceInvite: jest.fn(), - cancelSpaceInvite: jest.fn(), - getSpaceMemos: jest.fn(), - leaveSpace: jest.fn(), - getUserPendingInvites: jest.fn(), - acceptSpaceInvite: jest.fn(), - declineSpaceInvite: jest.fn(), - validateMemoForRetry: jest.fn(), - retryTranscription: jest.fn(), - retryHeadline: jest.fn(), - createMemoFromUploadedFile: jest.fn(), - updateMemoWithJobId: jest.fn(), - updateMemoTranscriptionStatus: jest.fn(), - updateBatchMetadataByMemoId: jest.fn(), - handleTranscriptionCompleted: jest.fn(), - getSupabaseUrl: jest.fn().mockReturnValue('https://test.supabase.co'), - getSupabaseKey: jest.fn().mockReturnValue('test-key'), - }, - }, - { - provide: CreditClientService, - useValue: { - checkSpaceCredits: jest.fn(), - checkUserCredits: jest.fn(), - checkAndConsumeCredits: jest.fn(), - }, - }, - ], - }) - .overrideGuard(AuthGuard) - .useValue({ - canActivate: jest.fn().mockReturnValue(true), - }) - .compile(); - - controller = module.get(MemoroController); - memoroService = module.get(MemoroService); - creditClientService = module.get(CreditClientService); - }); - - it('should be defined', () => { - expect(controller).toBeDefined(); - }); - - describe('getMemoroSpaces', () => { - it('should return spaces in correct format', async () => { - const mockSpaces: MemoroSpaceDto[] = [ - { - id: '1', - name: 'Space 1', - owner_id: 'user-123', - app_id: 'test-app-id', - roles: {}, - credits: 100, - created_at: '2024-01-01T00:00:00Z', - updated_at: '2024-01-01T00:00:00Z', - }, - { - id: '2', - name: 'Space 2', - owner_id: 'user-456', - app_id: 'test-app-id', - roles: {}, - credits: 200, - created_at: '2024-01-01T00:00:00Z', - updated_at: '2024-01-01T00:00:00Z', - }, - ]; - memoroService.getMemoroSpaces.mockResolvedValue(mockSpaces); - - const result = await controller.getMemoroSpaces(mockUser, mockRequest); - - expect(result).toEqual({ spaces: mockSpaces }); - expect(memoroService.getMemoroSpaces).toHaveBeenCalledWith(mockUser.sub, mockRequest.token); - }); - }); - - describe('createMemoroSpace', () => { - it('should create a space successfully', async () => { - const spaceName = 'New Space'; - const mockSpace: MemoroSpaceDto = { - id: 'space-123', - name: spaceName, - owner_id: mockUser.sub, - app_id: 'test-app-id', - roles: {}, - credits: 0, - created_at: '2024-01-01T00:00:00Z', - updated_at: '2024-01-01T00:00:00Z', - }; - memoroService.createMemoroSpace.mockResolvedValue(mockSpace); - - const result = await controller.createMemoroSpace(mockUser, spaceName, mockRequest); - - expect(result).toEqual({ space: mockSpace }); - expect(memoroService.createMemoroSpace).toHaveBeenCalledWith( - mockUser.sub, - spaceName, - mockRequest.token - ); - }); - - it('should throw BadRequestException if name is missing', async () => { - await expect(controller.createMemoroSpace(mockUser, '', mockRequest)).rejects.toThrow( - BadRequestException - ); - }); - }); - - describe('getMemoroSpaceDetails', () => { - it('should return space details when response already has space property', async () => { - const spaceId = 'space-123'; - const mockSpace: MemoroSpaceDto = { - id: spaceId, - name: 'Test Space', - owner_id: mockUser.sub, - app_id: 'test-app-id', - roles: {}, - credits: 100, - created_at: '2024-01-01T00:00:00Z', - updated_at: '2024-01-01T00:00:00Z', - }; - const mockSpaceData = { space: mockSpace }; - memoroService.getMemoroSpaceDetails.mockResolvedValue(mockSpaceData); - - const result = await controller.getMemoroSpaceDetails(mockUser, spaceId, mockRequest); - - expect(result).toEqual(mockSpaceData); - expect(memoroService.getMemoroSpaceDetails).toHaveBeenCalledWith( - mockUser.sub, - spaceId, - mockRequest.token - ); - }); - - it('should wrap response in space property if not already wrapped', async () => { - const spaceId = 'space-123'; - const mockSpaceData: MemoroSpaceDto = { - id: spaceId, - name: 'Test Space', - owner_id: mockUser.sub, - app_id: 'test-app-id', - roles: {}, - credits: 100, - created_at: '2024-01-01T00:00:00Z', - updated_at: '2024-01-01T00:00:00Z', - }; - memoroService.getMemoroSpaceDetails.mockResolvedValue(mockSpaceData); - - const result = await controller.getMemoroSpaceDetails(mockUser, spaceId, mockRequest); - - expect(result).toEqual({ space: mockSpaceData }); - }); - - it('should throw BadRequestException if spaceId is missing', async () => { - await expect(controller.getMemoroSpaceDetails(mockUser, '', mockRequest)).rejects.toThrow( - BadRequestException - ); - }); - }); - - describe('deleteMemoroSpace', () => { - it('should delete space successfully', async () => { - const spaceId = 'space-123'; - memoroService.deleteMemoroSpace.mockResolvedValue(undefined); - - const result = await controller.deleteMemoroSpace(mockUser, spaceId, mockRequest); - - expect(result).toEqual({ success: true, message: 'Space deleted successfully' }); - expect(memoroService.deleteMemoroSpace).toHaveBeenCalledWith( - mockUser.sub, - spaceId, - mockRequest.token - ); - }); - - it('should throw NotFoundException when space not found', async () => { - const spaceId = 'space-123'; - memoroService.deleteMemoroSpace.mockRejectedValue(new NotFoundException()); - - await expect(controller.deleteMemoroSpace(mockUser, spaceId, mockRequest)).rejects.toThrow( - NotFoundException - ); - }); - - it('should throw ForbiddenException when user lacks permission', async () => { - const spaceId = 'space-123'; - memoroService.deleteMemoroSpace.mockRejectedValue(new ForbiddenException()); - - await expect(controller.deleteMemoroSpace(mockUser, spaceId, mockRequest)).rejects.toThrow( - ForbiddenException - ); - }); - - it('should throw BadRequestException for other errors', async () => { - const spaceId = 'space-123'; - memoroService.deleteMemoroSpace.mockRejectedValue(new Error('Unknown error')); - - await expect(controller.deleteMemoroSpace(mockUser, spaceId, mockRequest)).rejects.toThrow( - BadRequestException - ); - }); - }); - - describe('linkMemoToSpace', () => { - it('should link memo to space successfully', async () => { - const linkData = { memoId: 'memo-123', spaceId: 'space-123' }; - const mockResult = { success: true, message: 'Memo linked successfully' }; - memoroService.linkMemoToSpace.mockResolvedValue(mockResult); - - const result = await controller.linkMemoToSpace(mockUser, linkData, mockRequest); - - expect(result).toEqual(mockResult); - expect(memoroService.linkMemoToSpace).toHaveBeenCalledWith( - mockUser.sub, - linkData, - mockRequest.token - ); - }); - - it('should return success even if service throws error', async () => { - const linkData = { memoId: 'memo-123', spaceId: 'space-123' }; - memoroService.linkMemoToSpace.mockRejectedValue(new Error('Space not found')); - - const result = await controller.linkMemoToSpace(mockUser, linkData, mockRequest); - - expect(result).toEqual({ - success: true, - message: 'Memo linked to space (direct DB operation)', - }); - }); - }); - - describe('inviteUserToSpace', () => { - it('should invite user successfully', async () => { - const spaceId = 'space-123'; - const inviteData = { email: 'invitee@example.com', role: 'member' }; - const mockResult = { inviteId: 'invite-123' }; - memoroService.inviteUserToSpace.mockResolvedValue(mockResult); - - const result = await controller.inviteUserToSpace(mockUser, spaceId, inviteData, mockRequest); - - expect(result).toEqual({ - success: true, - message: `Successfully invited ${inviteData.email} to the space`, - inviteId: mockResult.inviteId, - }); - expect(memoroService.inviteUserToSpace).toHaveBeenCalledWith( - mockUser.sub, - spaceId, - inviteData.email, - inviteData.role, - mockRequest.token - ); - }); - - it('should throw BadRequestException if spaceId is missing', async () => { - const inviteData = { email: 'invitee@example.com', role: 'member' }; - - await expect( - controller.inviteUserToSpace(mockUser, '', inviteData, mockRequest) - ).rejects.toThrow(BadRequestException); - }); - - it('should throw BadRequestException if email is missing', async () => { - const spaceId = 'space-123'; - const inviteData = { email: '', role: 'member' }; - - await expect( - controller.inviteUserToSpace(mockUser, spaceId, inviteData, mockRequest) - ).rejects.toThrow(BadRequestException); - }); - - it('should throw BadRequestException if role is missing', async () => { - const spaceId = 'space-123'; - const inviteData = { email: 'invitee@example.com', role: '' }; - - await expect( - controller.inviteUserToSpace(mockUser, spaceId, inviteData, mockRequest) - ).rejects.toThrow(BadRequestException); - }); - }); - - describe('checkTranscriptionCredits', () => { - it('should check user credits successfully', async () => { - const checkData = { durationSeconds: 300 }; - const mockCreditCheck: CreditCheckResponse = { - hasEnoughCredits: true, - requiredCredits: 5, - currentCredits: 100, - creditType: 'user', - }; - creditClientService.checkUserCredits.mockResolvedValue(mockCreditCheck); - - const result = await controller.checkTranscriptionCredits(mockUser, checkData, mockRequest); - - expect(result).toEqual({ - hasEnoughCredits: true, - requiredCredits: 5, - currentCredits: 100, - creditType: 'user', - durationMinutes: 5, - estimatedCostPerHour: 100, - }); - }); - - it('should check space credits when spaceId provided', async () => { - const checkData = { durationSeconds: 300, spaceId: 'space-123' }; - const mockCreditCheck: CreditCheckResponse = { - hasEnoughCredits: true, - requiredCredits: 5, - currentCredits: 200, - creditType: 'space', - }; - creditClientService.checkSpaceCredits.mockResolvedValue(mockCreditCheck); - - const result = await controller.checkTranscriptionCredits(mockUser, checkData, mockRequest); - - expect(result.creditType).toBe('space'); - expect(creditClientService.checkSpaceCredits).toHaveBeenCalledWith( - 'space-123', - 10, - mockRequest.token - ); - }); - - it('should fall back to user credits if space credit check fails', async () => { - const checkData = { durationSeconds: 300, spaceId: 'space-123' }; - const mockUserCreditCheck: CreditCheckResponse = { - hasEnoughCredits: true, - requiredCredits: 5, - currentCredits: 100, - creditType: 'user', - }; - creditClientService.checkSpaceCredits.mockRejectedValue(new Error('Space not found')); - creditClientService.checkUserCredits.mockResolvedValue(mockUserCreditCheck); - - const result = await controller.checkTranscriptionCredits(mockUser, checkData, mockRequest); - - expect(result.creditType).toBe('user'); - expect(creditClientService.checkUserCredits).toHaveBeenCalled(); - }); - - it('should throw BadRequestException for invalid duration', async () => { - const checkData = { durationSeconds: -1 }; - - await expect( - controller.checkTranscriptionCredits(mockUser, checkData, mockRequest) - ).rejects.toThrow(BadRequestException); - }); - }); - - describe('processUploadedAudio', () => { - it('should process uploaded audio successfully', async () => { - const processData = { - filePath: '/uploads/audio.mp3', - duration: 300, - spaceId: 'space-123', - enableDiarization: true, - }; - - const mockCreditCheck: CreditCheckResponse = { - hasEnoughCredits: true, - requiredCredits: 5, - currentCredits: 100, - creditType: 'space', - }; - - const mockMemoResult = { - memoId: 'memo-123', - audioPath: processData.filePath, - memo: { id: 'memo-123', created_at: '2025-06-26T17:00:00Z' }, - }; - - creditClientService.checkSpaceCredits.mockResolvedValue(mockCreditCheck); - memoroService.createMemoFromUploadedFile.mockResolvedValue(mockMemoResult); - - const result = await controller.processUploadedAudio(mockUser, processData, mockRequest); - - expect(result).toEqual({ - success: true, - memoId: 'memo-123', - memo: { id: 'memo-123', created_at: '2025-06-26T17:00:00Z' }, - filePath: processData.filePath, - status: 'processing', - estimatedDuration: 5, - message: 'Memo created successfully. Transcription in progress.', - estimatedCredits: 10, - }); - }); - - it('should throw ForbiddenException for insufficient credits', async () => { - const processData = { - filePath: '/uploads/audio.mp3', - duration: 300, - }; - - const mockCreditCheck: CreditCheckResponse = { - hasEnoughCredits: false, - requiredCredits: 5, - currentCredits: 2, - creditType: 'user', - }; - - creditClientService.checkUserCredits.mockResolvedValue(mockCreditCheck); - - await expect( - controller.processUploadedAudio(mockUser, processData, mockRequest) - ).rejects.toThrow(ForbiddenException); - }); - - it('should throw BadRequestException for missing file path', async () => { - const processData = { - filePath: '', - duration: 300, - }; - - await expect( - controller.processUploadedAudio(mockUser, processData, mockRequest) - ).rejects.toThrow(BadRequestException); - }); - - it('should throw BadRequestException for invalid duration', async () => { - const processData = { - filePath: '/uploads/audio.mp3', - duration: 0, - }; - - await expect( - controller.processUploadedAudio(mockUser, processData, mockRequest) - ).rejects.toThrow(BadRequestException); - }); - - it('should use batch transcription for Swiss German', async () => { - const processData = { - filePath: '/uploads/audio.mp3', - duration: 300, - recordingLanguages: ['de-CH'], - }; - - const mockCreditCheck: CreditCheckResponse = { - hasEnoughCredits: true, - requiredCredits: 5, - currentCredits: 100, - creditType: 'user', - }; - - const mockMemoResult = { - memoId: 'memo-123', - audioPath: processData.filePath, - memo: { id: 'memo-123', created_at: '2025-06-26T17:00:00Z' }, - }; - - creditClientService.checkUserCredits.mockResolvedValue(mockCreditCheck); - memoroService.createMemoFromUploadedFile.mockResolvedValue(mockMemoResult); - - const result = await controller.processUploadedAudio(mockUser, processData, mockRequest); - - expect(result.success).toBe(true); - // Note: The actual transcription happens asynchronously - }); - }); - - describe('retryTranscription', () => { - it('should retry transcription successfully', async () => { - const retryData = { memoId: 'memo-123' }; - const mockMemo = { - id: 'memo-123', - metadata: { - processing: { - transcription: { - status: 'error', - retryAttempts: 1, - }, - }, - }, - }; - - memoroService.validateMemoForRetry.mockResolvedValue(mockMemo); - memoroService.retryTranscription.mockResolvedValue({ success: true }); - - const result = await controller.retryTranscription(mockUser, retryData, mockRequest); - - expect(result).toEqual({ - success: true, - message: 'Transcription retry initiated successfully', - memoId: 'memo-123', - retryAttempt: 2, - }); - }); - - it('should throw BadRequestException if memoId is missing', async () => { - const retryData = { memoId: '' }; - - await expect(controller.retryTranscription(mockUser, retryData, mockRequest)).rejects.toThrow( - BadRequestException - ); - }); - - it('should throw NotFoundException if memo not found', async () => { - const retryData = { memoId: 'memo-123' }; - memoroService.validateMemoForRetry.mockResolvedValue(null); - - await expect(controller.retryTranscription(mockUser, retryData, mockRequest)).rejects.toThrow( - NotFoundException - ); - }); - - it('should throw BadRequestException if transcription did not fail', async () => { - const retryData = { memoId: 'memo-123' }; - const mockMemo = { - id: 'memo-123', - metadata: { - processing: { - transcription: { - status: 'completed', - }, - }, - }, - }; - - memoroService.validateMemoForRetry.mockResolvedValue(mockMemo); - - await expect(controller.retryTranscription(mockUser, retryData, mockRequest)).rejects.toThrow( - BadRequestException - ); - }); - - it('should throw BadRequestException if max retries exceeded', async () => { - const retryData = { memoId: 'memo-123' }; - const mockMemo = { - id: 'memo-123', - metadata: { - processing: { - transcription: { - status: 'error', - retryAttempts: 3, - }, - }, - }, - }; - - memoroService.validateMemoForRetry.mockResolvedValue(mockMemo); - - await expect(controller.retryTranscription(mockUser, retryData, mockRequest)).rejects.toThrow( - BadRequestException - ); - }); - }); - - describe('handleTranscriptionCompleted', () => { - it('should handle transcription completion successfully', async () => { - const callbackData = { - memoId: 'memo-123', - userId: 'user-123', - transcriptionResult: { text: 'Hello world' }, - route: 'fast' as const, - success: true, - }; - - const mockResult = { success: true, message: 'Transcription completed' }; - memoroService.handleTranscriptionCompleted.mockResolvedValue(mockResult); - - const result = await controller.handleTranscriptionCompleted(callbackData, mockRequest); - - expect(result).toEqual(mockResult); - expect(memoroService.handleTranscriptionCompleted).toHaveBeenCalledWith( - callbackData.memoId, - callbackData.userId, - callbackData.transcriptionResult, - callbackData.route, - callbackData.success, - undefined, - mockRequest.token - ); - }); - - it('should throw BadRequestException if memoId is missing', async () => { - const callbackData = { - memoId: '', - userId: 'user-123', - }; - - await expect( - controller.handleTranscriptionCompleted(callbackData, mockRequest) - ).rejects.toThrow(BadRequestException); - }); - - it('should throw BadRequestException if userId is missing', async () => { - const callbackData = { - memoId: 'memo-123', - userId: '', - }; - - await expect( - controller.handleTranscriptionCompleted(callbackData, mockRequest) - ).rejects.toThrow(BadRequestException); - }); - }); -}); diff --git a/apps/memoro/apps/backend/src/memoro/memoro.controller.ts b/apps/memoro/apps/backend/src/memoro/memoro.controller.ts deleted file mode 100644 index 8314462c4..000000000 --- a/apps/memoro/apps/backend/src/memoro/memoro.controller.ts +++ /dev/null @@ -1,1963 +0,0 @@ -import { - Controller, - Get, - Post, - Delete, - Body, - Param, - UseGuards, - Req, - BadRequestException, - HttpCode, - NotFoundException, - ForbiddenException, -} from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import { createClient } from '@supabase/supabase-js'; -import { MemoroService } from './memoro.service'; -import { User } from '../decorators/user.decorator'; -import { AuthGuard } from '../guards/auth.guard'; -import { JwtPayload } from '../types/jwt-payload.interface'; -import { LinkMemoSpaceDto, UnlinkMemoSpaceDto } from '../interfaces/memoro.interfaces'; -import { CreditClientService } from '../credits/credit-client.service'; -import { calculateTranscriptionCost, getOperationCost } from '../credits/pricing.constants'; -import { - InsufficientCreditsException, - isInsufficientCreditsError, -} from '../errors/insufficient-credits.error'; - -@Controller('memoro') -@UseGuards(AuthGuard) -export class MemoroController { - constructor( - private readonly memoroService: MemoroService, - private readonly creditClientService: CreditClientService, - private readonly configService: ConfigService - ) {} - - @Get('spaces') - async getMemoroSpaces(@User() user: JwtPayload, @Req() req) { - const token = req.token; // This is set by the AuthGuard - console.log('Token: ', token); - console.log('User: ', user); - - // Get spaces from service - const spaces = await this.memoroService.getMemoroSpaces(user.sub, token); - - // Return in the format expected by the frontend: { spaces: [...] } - return { spaces }; - } - - @Post('spaces') - async createMemoroSpace(@User() user: JwtPayload, @Body('name') name: string, @Req() req) { - if (!name) { - throw new BadRequestException('Space name is required'); - } - const token = req.token; - - // Get the created space from service - const space = await this.memoroService.createMemoroSpace(user.sub, name, token); - - // Return in the format expected by the frontend: { space: {...} } - return { space }; - } - - @Get('spaces/:id') - async getMemoroSpaceDetails(@User() user: JwtPayload, @Param('id') spaceId: string, @Req() req) { - if (!spaceId) { - throw new BadRequestException('Space ID is required'); - } - const token = req.token; - - // Get the space details from service - const spaceData = await this.memoroService.getMemoroSpaceDetails(user.sub, spaceId, token); - - // Check if the response already contains a space property to avoid double nesting - if (spaceData && typeof spaceData === 'object' && 'space' in spaceData) { - // The response is already in the format { space: {...} } - return spaceData; - } else { - // Wrap the space data in the format expected by the frontend: { space: {...} } - return { space: spaceData }; - } - } - - @Delete('spaces/:id') - async deleteMemoroSpace(@User() user: JwtPayload, @Param('id') spaceId: string, @Req() req) { - if (!spaceId) { - throw new BadRequestException('Space ID is required'); - } - const token = req.token; - - try { - // Call service to delete the space - const result = await this.memoroService.deleteMemoroSpace(user.sub, spaceId, token); - - // Return success response - return { - success: true, - message: 'Space deleted successfully', - }; - } catch (error) { - if (error instanceof NotFoundException) { - throw error; - } else if (error instanceof ForbiddenException) { - throw error; - } else { - throw new BadRequestException(`Failed to delete space: ${error.message}`); - } - } - } - - @Post('link-memo') - @HttpCode(200) - async linkMemoToSpace( - @User() user: JwtPayload, - @Body() linkMemoSpaceDto: LinkMemoSpaceDto, - @Req() req - ) { - const token = req.token; - try { - return await this.memoroService.linkMemoToSpace(user.sub, linkMemoSpaceDto, token); - } catch (error) { - console.warn(`Error in linkMemoToSpace: ${error.message}`); - // Return success even if there was an error with verification - // This is a temporary workaround for spaces that exist in Memoro but not in mana-core - return { success: true, message: 'Memo linked to space (direct DB operation)' }; - } - } - - @Post('unlink-memo') - @HttpCode(200) - async unlinkMemoFromSpace( - @User() user: JwtPayload, - @Body() unlinkMemoSpaceDto: UnlinkMemoSpaceDto, - @Req() req - ) { - const token = req.token; - try { - return await this.memoroService.unlinkMemoFromSpace(user.sub, unlinkMemoSpaceDto, token); - } catch (error) { - console.warn(`Error in unlinkMemoFromSpace: ${error.message}`); - - // Create a direct database connection for emergency fallback - try { - // Get values from DTO - const { memoId, spaceId } = unlinkMemoSpaceDto; - - // Get MEMORO_SUPABASE_URL and MEMORO_SUPABASE_ANON_KEY - const memoroUrl = this.memoroService.getSupabaseUrl(); - const memoroKey = this.memoroService.getSupabaseKey(); - - if (!memoroUrl || !memoroKey) { - throw new Error('Missing Supabase credentials'); - } - - // Create a direct Supabase client - const supabase = createClient(memoroUrl, memoroKey, { - global: { headers: { Authorization: `Bearer ${token}` } }, - }); - - // Delete the link directly - console.log( - `[EMERGENCY FALLBACK] Deleting memo_spaces link directly: memo_id=${memoId}, space_id=${spaceId}` - ); - const { error: deleteError } = await supabase - .from('memo_spaces') - .delete() - .eq('memo_id', memoId) - .eq('space_id', spaceId); - - if (deleteError) { - console.error(`Direct DB delete error: ${deleteError.message}`); - throw deleteError; - } - - return { - success: true, - message: 'Memo unlinked from space (emergency direct DB operation)', - }; - } catch (dbError) { - console.error(`Failed direct DB operation: ${dbError.message}`); - // Finally return success to avoid UI confusion - return { success: true, message: 'Attempted to unlink memo (frontend should refresh)' }; - } - } - } - - @Get('spaces/:id/invites') - async getSpaceInvites(@User() user: JwtPayload, @Param('id') spaceId: string, @Req() req) { - if (!spaceId) { - throw new BadRequestException('Space ID is required'); - } - - try { - const token = req.token; // This is set by the AuthGuard - - // Call the spaces service to get invites for the space - const result = await this.memoroService.getSpaceInvites(spaceId, token); - - // Return the invites in the format expected by the frontend - return result; - } catch (error) { - console.error(`Failed to get invites for space ${spaceId}:`, error); - if ( - error instanceof NotFoundException || - error instanceof ForbiddenException || - error instanceof BadRequestException - ) { - throw error; - } - throw new Error(`Failed to get invites for space ${spaceId}: ${error.message}`); - } - } - - @Post('spaces/:id/invite') - async inviteUserToSpace( - @User() user: JwtPayload, - @Param('id') spaceId: string, - @Body() inviteData: { email: string; role: string }, - @Req() req - ) { - if (!spaceId) { - throw new BadRequestException('Space ID is required'); - } - - if (!inviteData.email) { - throw new BadRequestException('Email is required'); - } - - if (!inviteData.role) { - throw new BadRequestException('Role is required'); - } - - try { - const token = req.token; // This is set by the AuthGuard - - // Call the service to invite the user to the space - const result = await this.memoroService.inviteUserToSpace( - user.sub, - spaceId, - inviteData.email, - inviteData.role, - token - ); - - // Return a success response - return { - success: true, - message: `Successfully invited ${inviteData.email} to the space`, - inviteId: result.inviteId, - }; - } catch (error) { - console.error(`Failed to invite user to space ${spaceId}:`, error); - if ( - error instanceof NotFoundException || - error instanceof ForbiddenException || - error instanceof BadRequestException - ) { - throw error; - } - throw new Error(`Failed to invite user to space: ${error.message}`); - } - } - - @Post('spaces/invites/:inviteId/resend') - async resendInvite(@User() user: JwtPayload, @Param('inviteId') inviteId: string, @Req() req) { - if (!inviteId) { - throw new BadRequestException('Invite ID is required'); - } - - try { - const token = req.token; // This is set by the AuthGuard - - // Call the service to resend the invitation - await this.memoroService.resendSpaceInvite(user.sub, inviteId, token); - - // Return a success response - return { - success: true, - message: 'Invitation resent successfully', - }; - } catch (error) { - console.error(`Failed to resend invitation ${inviteId}:`, error); - if ( - error instanceof NotFoundException || - error instanceof ForbiddenException || - error instanceof BadRequestException - ) { - throw error; - } - throw new Error(`Failed to resend invitation: ${error.message}`); - } - } - - @Delete('spaces/invites/:inviteId') - async cancelInvite(@User() user: JwtPayload, @Param('inviteId') inviteId: string, @Req() req) { - if (!inviteId) { - throw new BadRequestException('Invite ID is required'); - } - - try { - const token = req.token; // This is set by the AuthGuard - - // Call the service to cancel the invitation - await this.memoroService.cancelSpaceInvite(user.sub, inviteId, token); - - // Return a success response - return { - success: true, - message: 'Invitation canceled successfully', - }; - } catch (error) { - console.error(`Failed to cancel invitation ${inviteId}:`, error); - if ( - error instanceof NotFoundException || - error instanceof ForbiddenException || - error instanceof BadRequestException - ) { - throw error; - } - throw new Error(`Failed to cancel invitation: ${error.message}`); - } - } - - @Get('spaces/:id/memos') - async getSpaceMemos(@User() user: JwtPayload, @Param('id') spaceId: string, @Req() req) { - if (!spaceId) { - throw new BadRequestException('Space ID is required'); - } - const token = req.token; - return this.memoroService.getSpaceMemos(user.sub, spaceId, token); - } - - @Post('spaces/:id/leave') - async leaveSpace(@User() user: JwtPayload, @Param('id') spaceId: string, @Req() req) { - if (!spaceId) { - throw new BadRequestException('Space ID is required'); - } - const token = req.token; - - try { - // Call the spaces service to leave the space - const result = await this.memoroService.leaveSpace(user.sub, spaceId, token); - - // Return success response - return { - success: true, - message: 'Successfully left the space', - }; - } catch (error) { - console.error(`Error in leaveSpace: ${error.message}`); - if (error instanceof NotFoundException) { - throw error; - } else if (error instanceof ForbiddenException) { - throw error; - } else { - throw new BadRequestException(`Failed to leave space: ${error.message}`); - } - } - } - - @Get('invites/pending') - async getPendingInvites(@User() user: JwtPayload, @Req() req) { - try { - const token = req.token; // This is set by the AuthGuard - - // Call the service to get pending invites for the user - const result = await this.memoroService.getUserPendingInvites(user.sub, token); - console.log('INVITES PENDING RES: ', result); - // Return the invites in the format expected by the frontend - return result; - } catch (error) { - console.error(`Failed to get pending invites:`, error); - if (error instanceof NotFoundException) { - // Return empty invites array instead of throwing an error if not found - return { invites: [] }; - } else if (error instanceof ForbiddenException || error instanceof BadRequestException) { - throw error; - } else { - // For any other errors, log but return empty array - console.error(`Error fetching pending invites: ${error.message}`); - return { invites: [] }; - } - } - } - - @Post('spaces/invites/accept') - async acceptInvite( - @User() user: JwtPayload, - @Body() acceptData: { inviteId: string }, - @Req() req - ) { - if (!acceptData.inviteId) { - throw new BadRequestException('Invite ID is required'); - } - - try { - const token = req.token; // This is set by the AuthGuard - - // Call the service to accept the invitation - await this.memoroService.acceptSpaceInvite(user.sub, acceptData.inviteId, token); - - // Return a success response - return { - success: true, - message: 'Invitation accepted successfully', - }; - } catch (error) { - console.error(`Failed to accept invitation ${acceptData.inviteId}:`, error); - if ( - error instanceof NotFoundException || - error instanceof ForbiddenException || - error instanceof BadRequestException - ) { - throw error; - } - throw new Error(`Failed to accept invitation: ${error.message}`); - } - } - - @Post('spaces/invites/decline') - async declineInvite( - @User() user: JwtPayload, - @Body() declineData: { inviteId: string }, - @Req() req - ) { - if (!declineData.inviteId) { - throw new BadRequestException('Invite ID is required'); - } - - try { - const token = req.token; // This is set by the AuthGuard - - // Call the service to decline the invitation - await this.memoroService.declineSpaceInvite(user.sub, declineData.inviteId, token); - - // Return a success response - return { - success: true, - message: 'Invitation declined successfully', - }; - } catch (error) { - console.error(`Failed to decline invitation ${declineData.inviteId}:`, error); - if ( - error instanceof NotFoundException || - error instanceof ForbiddenException || - error instanceof BadRequestException - ) { - throw error; - } - throw new Error(`Failed to decline invitation: ${error.message}`); - } - } - - @Post('credits/check-transcription') - async checkTranscriptionCredits( - @User() user: JwtPayload, - @Body() - checkData: { - durationSeconds: number; - spaceId?: string; - }, - @Req() req - ) { - const { durationSeconds, spaceId } = checkData; - const token = req.token; - - if (!durationSeconds || durationSeconds <= 0) { - throw new BadRequestException('Valid duration in seconds is required'); - } - - try { - const requiredCredits = calculateTranscriptionCost(durationSeconds); - - let creditCheck; - if (spaceId) { - // Try space credits first, then fall back to user credits - try { - creditCheck = await this.creditClientService.checkSpaceCredits( - spaceId, - requiredCredits, - token - ); - } catch (error) { - console.warn(`Space credit check failed, checking user credits: ${error.message}`); - creditCheck = await this.creditClientService.checkUserCredits( - user.sub, - requiredCredits, - token - ); - } - } else { - creditCheck = await this.creditClientService.checkUserCredits( - user.sub, - requiredCredits, - token - ); - } - - return { - hasEnoughCredits: creditCheck.hasEnoughCredits, - requiredCredits: creditCheck.requiredCredits, - currentCredits: creditCheck.currentCredits, - creditType: creditCheck.creditType, - durationMinutes: Math.ceil(durationSeconds / 60), - estimatedCostPerHour: 100, - }; - } catch (error) { - console.error('Error checking transcription credits:', error); - - if (error instanceof InsufficientCreditsException) { - throw error; // Let the exception propagate with 402 status - } - - if (error instanceof ForbiddenException || error instanceof BadRequestException) { - throw error; - } - - throw new BadRequestException(`Failed to check credits: ${error.message}`); - } - } - - @Post('credits/consume-transcription') - async consumeTranscriptionCredits( - @User() user: JwtPayload, - @Body() - consumeData: { - durationSeconds: number; - spaceId?: string; - memoId?: string; - description?: string; - }, - @Req() req - ) { - const { durationSeconds, spaceId, memoId, description } = consumeData; - const token = req.token; - - if (!durationSeconds || durationSeconds <= 0) { - throw new BadRequestException('Valid duration in seconds is required'); - } - - try { - const requiredCredits = calculateTranscriptionCost(durationSeconds); - const operationDescription = - description || - `Transcription for ${Math.ceil(durationSeconds / 60)} minutes of audio${memoId ? ` (Memo: ${memoId})` : ''}`; - - const result = await this.creditClientService.checkAndConsumeCredits( - user.sub, - requiredCredits, - token, - { - spaceId, - description: operationDescription, - operation: 'transcription', - } - ); - - return { - success: true, - message: result.message, - creditsConsumed: requiredCredits, - creditType: result.creditType, - durationMinutes: Math.ceil(durationSeconds / 60), - }; - } catch (error) { - console.error('Error consuming transcription credits:', error); - - if (error instanceof InsufficientCreditsException) { - throw error; // Let the exception propagate with 402 status - } - - if (error instanceof ForbiddenException) { - throw error; - } - - if (error instanceof BadRequestException) { - throw error; - } - - throw new BadRequestException(`Failed to consume credits: ${error.message}`); - } - } - - @Post('credits/consume-operation') - async consumeOperationCredits( - @User() user: JwtPayload, - @Body() - consumeData: { - operation: - | 'HEADLINE_GENERATION' - | 'MEMORY_CREATION' - | 'BLUEPRINT_PROCESSING' - | 'MEMO_SHARING' - | 'SPACE_OPERATION'; - spaceId?: string; - memoId?: string; - description?: string; - }, - @Req() req - ) { - const { operation, spaceId, memoId, description } = consumeData; - const token = req.token; - - if (!operation) { - throw new BadRequestException('Operation type is required'); - } - - try { - const requiredCredits = getOperationCost(operation); - const operationDescription = - description || - `${operation.toLowerCase().replace('_', ' ')}${memoId ? ` (Memo: ${memoId})` : ''}`; - - const result = await this.creditClientService.checkAndConsumeCredits( - user.sub, - requiredCredits, - token, - { - spaceId, - description: operationDescription, - operation: operation.toLowerCase(), - } - ); - - return { - success: true, - message: result.message, - creditsConsumed: requiredCredits, - creditType: result.creditType, - operation, - }; - } catch (error) { - console.error(`Error consuming credits for ${operation}:`, error); - - if (error instanceof InsufficientCreditsException) { - throw error; // Let the exception propagate with 402 status - } - - if (error instanceof ForbiddenException) { - throw error; - } - - if (error instanceof BadRequestException) { - throw error; - } - - throw new BadRequestException(`Failed to consume credits: ${error.message}`); - } - } - - @Post('retry-transcription') - async retryTranscription( - @User() user: JwtPayload, - @Body() - retryData: { - memoId: string; - }, - @Req() req - ) { - const { memoId } = retryData; - const token = req.token; - - if (!memoId) { - throw new BadRequestException('Memo ID is required'); - } - - try { - // Get memo and validate it belongs to user and failed transcription - const memo = await this.memoroService.validateMemoForRetry(user.sub, memoId, token); - - if (!memo) { - throw new NotFoundException('Memo not found or access denied'); - } - - // Check if transcription actually failed - if (memo.metadata?.processing?.transcription?.status !== 'error') { - throw new BadRequestException('Memo transcription did not fail - retry not needed'); - } - - // Check retry limits (max 3 retries) - const currentAttempts = memo.metadata?.processing?.transcription?.retryAttempts || 0; - if (currentAttempts >= 3) { - throw new BadRequestException('Maximum retry attempts (3) exceeded for this memo'); - } - - // Call the retry logic - const result = await this.memoroService.retryTranscription( - user.sub, - memoId, - token, - currentAttempts + 1 - ); - - return { - success: true, - message: 'Transcription retry initiated successfully', - memoId, - retryAttempt: currentAttempts + 1, - }; - } catch (error) { - console.error(`Error retrying transcription for memo ${memoId}:`, error); - - if ( - error instanceof NotFoundException || - error instanceof BadRequestException || - error instanceof ForbiddenException - ) { - throw error; - } - - throw new BadRequestException(`Failed to retry transcription: ${error.message}`); - } - } - - @Post('retry-headline') - async retryHeadline( - @User() user: JwtPayload, - @Body() - retryData: { - memoId: string; - }, - @Req() req - ) { - const { memoId } = retryData; - const token = req.token; - - if (!memoId) { - throw new BadRequestException('Memo ID is required'); - } - - try { - // Get memo and validate - const memo = await this.memoroService.validateMemoForRetry(user.sub, memoId, token); - - if (!memo) { - throw new NotFoundException('Memo not found or access denied'); - } - - // Check if headline generation actually failed - if (memo.metadata?.processing?.headline_and_intro?.status !== 'error') { - throw new BadRequestException('Memo headline generation did not fail - retry not needed'); - } - - // Check retry limits - const currentAttempts = memo.metadata?.processing?.headline_and_intro?.retryAttempts || 0; - if (currentAttempts >= 3) { - throw new BadRequestException( - 'Maximum retry attempts (3) exceeded for headline generation' - ); - } - - // Call the retry logic - const result = await this.memoroService.retryHeadline( - user.sub, - memoId, - token, - currentAttempts + 1 - ); - - return { - success: true, - message: 'Headline generation retry initiated successfully', - memoId, - retryAttempt: currentAttempts + 1, - }; - } catch (error) { - console.error(`Error retrying headline for memo ${memoId}:`, error); - - if ( - error instanceof NotFoundException || - error instanceof BadRequestException || - error instanceof ForbiddenException - ) { - throw error; - } - - throw new BadRequestException(`Failed to retry headline generation: ${error.message}`); - } - } - - @Post('reprocess-memo') - async reprocessMemo( - @User() user: JwtPayload, - @Body() - reprocessData: { - memoId: string; - recordingLanguages?: string[]; - recordingStartedAt?: string; - blueprintId?: string | null; - enableDiarization?: boolean; - }, - @Req() req - ) { - const { memoId, recordingLanguages, recordingStartedAt, blueprintId, enableDiarization } = - reprocessData; - const token = req.token; - - if (!memoId) { - throw new BadRequestException('Memo ID is required'); - } - - try { - // Get memo and validate ownership - const memo = await this.memoroService.getMemoForReprocessing(user.sub, memoId, token); - - if (!memo) { - throw new NotFoundException('Memo not found or access denied'); - } - - // Get audio path and duration from original memo - const audioPath = memo.source?.audio_path; - const duration = memo.source?.duration; - - if (!audioPath || !duration) { - throw new BadRequestException('Original memo does not have audio information'); - } - - // Check credits before processing - const requiredCredits = calculateTranscriptionCost(duration); - - let creditCheck; - // Check if original memo was in a space - const spaceId = memo.space_id; - - if (spaceId) { - try { - creditCheck = await this.creditClientService.checkSpaceCredits( - spaceId, - requiredCredits, - token - ); - } catch (error) { - console.warn(`Space credit check failed, checking user credits: ${error.message}`); - creditCheck = await this.creditClientService.checkUserCredits( - user.sub, - requiredCredits, - token - ); - } - } else { - creditCheck = await this.creditClientService.checkUserCredits( - user.sub, - requiredCredits, - token - ); - } - - if (!creditCheck.hasEnoughCredits) { - throw new InsufficientCreditsException({ - requiredCredits: creditCheck.requiredCredits, - availableCredits: creditCheck.currentCredits, - creditType: creditCheck.creditType, - operation: 'transcription', - spaceId, - }); - } - - // Create a new memo with the same audio file but new parameters - const memoResult = await this.memoroService.createMemoFromUploadedFile( - user.sub, - audioPath, - duration, - spaceId, - blueprintId, - undefined, // Generate new memo ID - token, - recordingStartedAt || memo.created_at, // Use provided date or original creation date - memo.metadata?.address ? { address: memo.metadata.address } : undefined - ); - - const durationMinutes = duration / 60; - - // Check for Swiss German and Austrian German languages - const hasSwissOrAustrianGerman = recordingLanguages?.some( - (lang) => lang === 'de-CH' || lang === 'de-AT' - ); - - const shouldUseFastTranscribe = hasSwissOrAustrianGerman ? false : durationMinutes < 115; - - console.log( - `Reprocessing memo ${memoId} as new memo ${memoResult.memoId}. Duration: ${durationMinutes.toFixed(2)} minutes. Using ${shouldUseFastTranscribe ? 'fast' : 'batch'} transcription.` - ); - - // Start async transcription processing - setImmediate(() => { - this.processTranscriptionAsync( - memoResult.memoId, - audioPath, - duration, - user.sub, - spaceId, - blueprintId, - recordingLanguages || memo.source?.languages || [], - token, - recordingStartedAt || memo.created_at, - enableDiarization !== undefined ? enableDiarization : true, - shouldUseFastTranscribe, - hasSwissOrAustrianGerman - ).catch((error) => { - console.error( - `Async transcription failed for reprocessed memo ${memoResult.memoId}:`, - error - ); - // Update memo with error status - this.updateMemoTranscriptionStatus(memoResult.memoId, 'failed', token, { - error: error.message, - timestamp: new Date().toISOString(), - }); - }); - }); - - // Consume credits - await this.creditClientService.checkAndConsumeCredits(user.sub, requiredCredits, token, { - spaceId, - description: `Reprocessing memo ${memoId} as ${memoResult.memoId}`, - operation: 'transcription', - }); - - return { - success: true, - message: 'Memo reprocessing started successfully', - originalMemoId: memoId, - newMemoId: memoResult.memoId, - memo: memoResult.memo, - }; - } catch (error) { - console.error(`Error reprocessing memo ${memoId}:`, error); - - if ( - error instanceof NotFoundException || - error instanceof BadRequestException || - error instanceof ForbiddenException || - error instanceof InsufficientCreditsException - ) { - throw error; - } - - throw new BadRequestException(`Failed to reprocess memo: ${error.message}`); - } - } - - @Post('process-uploaded-audio') - async processUploadedAudio( - @User() user: JwtPayload, - @Body() - processData: { - filePath: string; - duration: number; - spaceId?: string; - blueprintId?: string | null; - recordingLanguages?: string[]; - memoId?: string; - location?: any; // Add location data parameter - recordingStartedAt?: string; - enableDiarization?: boolean; - mediaType?: 'audio' | 'video'; // Add media type field - videoMetadata?: any; // Add video metadata field - }, - @Req() req - ) { - const { - filePath, - duration, - spaceId, - blueprintId, - recordingLanguages, - memoId, - location, - recordingStartedAt, - enableDiarization, - mediaType, - videoMetadata, - } = processData; - const token = req.token; - - if (!filePath) { - throw new BadRequestException('File path is required'); - } - - if (!duration || duration <= 0) { - throw new BadRequestException('Valid duration is required'); - } - - // Detect media type if not provided - const detectedMediaType = mediaType || this.detectMediaType(filePath); - - if (detectedMediaType === 'unknown') { - throw new BadRequestException( - 'Unsupported file type. Only audio and video files are supported.' - ); - } - - console.log( - `Processing ${detectedMediaType} file: ${filePath}${detectedMediaType === 'video' ? ' (video detected)' : ''}` - ); - - try { - // Check credits before processing - const requiredCredits = calculateTranscriptionCost(duration); - - let creditCheck; - if (spaceId) { - try { - creditCheck = await this.creditClientService.checkSpaceCredits( - spaceId, - requiredCredits, - token - ); - } catch (error) { - console.warn(`Space credit check failed, checking user credits: ${error.message}`); - creditCheck = await this.creditClientService.checkUserCredits( - user.sub, - requiredCredits, - token - ); - } - } else { - creditCheck = await this.creditClientService.checkUserCredits( - user.sub, - requiredCredits, - token - ); - } - - if (!creditCheck.hasEnoughCredits) { - throw new InsufficientCreditsException({ - requiredCredits: creditCheck.requiredCredits, - availableCredits: creditCheck.currentCredits, - creditType: creditCheck.creditType, - operation: 'transcription', - spaceId, - }); - } - - // Create memo in database - const memoResult = await this.memoroService.createMemoFromUploadedFile( - user.sub, - filePath, - duration, - spaceId, - blueprintId, - memoId, - token, - recordingStartedAt, - location, - detectedMediaType, - videoMetadata - ); - - const durationMinutes = duration / 60; - - // Check for Swiss German and Austrian German languages - always use batch transcription - const hasSwissOrAustrianGerman = recordingLanguages?.some( - (lang) => - lang === 'de-CH' || // Swiss German - lang === 'de-AT' // Austrian German - ); - - let shouldUseFastTranscribe; - - if (hasSwissOrAustrianGerman) { - // Force batch transcription for Swiss German and Austrian German - shouldUseFastTranscribe = false; - console.log( - `Swiss German or Austrian German detected (${recordingLanguages?.join(', ')}). Forcing batch transcription for better accuracy.` - ); - } else { - // Speaker diarization now works correctly in fast transcription (fixed 2025-06-09) - // Use normal routing: fast (<115min) vs batch (≥115min) - shouldUseFastTranscribe = durationMinutes < 115; // Restored normal routing - } - - console.log( - `Audio duration: ${durationMinutes.toFixed(2)} minutes. Using ${shouldUseFastTranscribe ? 'fast' : 'batch'} transcription.` - ); - - // Start async transcription processing - setImmediate(() => { - this.processTranscriptionAsync( - memoResult.memoId, - filePath, - duration, - user.sub, - spaceId, - blueprintId, - recordingLanguages || [], - token, - recordingStartedAt, - enableDiarization, - shouldUseFastTranscribe, - hasSwissOrAustrianGerman - ).catch((error) => { - console.error(`Async transcription failed for memo ${memoResult.memoId}:`, error); - // Update memo with error status - this.updateMemoTranscriptionStatus(memoResult.memoId, 'failed', token, { - error: error.message, - timestamp: new Date().toISOString(), - }); - }); - }); - - // Return immediately with full memo object for state synchronization - return { - success: true, - memoId: memoResult.memoId, - memo: memoResult.memo, // Include full memo object - filePath, - status: 'processing', - estimatedDuration: Math.ceil(duration / 60), - message: `${detectedMediaType === 'video' ? 'Video' : 'Audio'} memo created successfully. Transcription in progress.`, - estimatedCredits: requiredCredits, - mediaType: detectedMediaType, - }; - } catch (error) { - console.error('Error processing uploaded audio:', error); - - if ( - error instanceof InsufficientCreditsException || - error instanceof ForbiddenException || - error instanceof BadRequestException || - error instanceof NotFoundException - ) { - throw error; - } - - throw new BadRequestException(`Failed to process audio: ${error.message}`); - } - } - - /** - * Process transcription asynchronously in the background - */ - private async processTranscriptionAsync( - memoId: string, - filePath: string, - duration: number, - userId: string, - spaceId: string | undefined, - blueprintId: string | null | undefined, - recordingLanguages: string[], - token: string, - recordingStartedAt: string | undefined, - enableDiarization: boolean | undefined, - shouldUseFastTranscribe: boolean, - hasSwissOrAustrianGerman: boolean - ): Promise { - try { - // Update status to processing - await this.updateMemoTranscriptionStatus(memoId, 'processing', token); - - // Check if this is a video file - const mediaType = this.detectMediaType(filePath); - - if (mediaType === 'video') { - console.log(`[processTranscriptionAsync] Detected video file: ${filePath}`); - - try { - // Call video processing endpoint - const videoResult = await this.callAudioMicroserviceVideoProcessing( - filePath, - memoId, - userId, - spaceId, - recordingLanguages, - token, - enableDiarization - ); - - // Determine the actual processing route from the result - let processingRoute = - videoResult.route === 'fast' ? 'fast_transcribe_video' : 'batch_transcribe_video'; - - // Store jobId if batch processing was used - if (videoResult.jobId) { - console.log(`Storing jobId ${videoResult.jobId} for video memo ${memoId}`); - await this.memoroService.updateMemoWithJobId( - memoId, - videoResult.jobId, - token, - recordingLanguages - ); - } - - // Update status to completed if fast route, or processing if batch - const finalStatus = videoResult.route === 'fast' ? 'completed' : 'processing'; - await this.updateMemoTranscriptionStatus(memoId, finalStatus, token, { - processingRoute, - source: 'video', - completedAt: videoResult.route === 'fast' ? new Date().toISOString() : undefined, - }); - - console.log( - `Video transcription ${finalStatus} for memo ${memoId} via ${processingRoute}` - ); - } catch (error) { - console.error('Video processing failed:', error); - throw new Error(`Video processing failed: ${error.message}`); - } - return; - } - - // Continue with normal audio processing if not a video - if (shouldUseFastTranscribe) { - // Use new audio microservice with built-in fallback system - try { - const transcribeResult = await this.callAudioMicroserviceRealtimeWithFallback( - filePath, - memoId, - userId, - spaceId, - recordingLanguages, - token, - enableDiarization - ); - - // Determine the actual processing route from the result - let processingRoute = 'fast_transcribe'; - if (transcribeResult.route === 'fast-conversion-retry') { - processingRoute = 'fast_transcribe_converted'; - } else if (transcribeResult.route === 'batch-fallback') { - processingRoute = 'batch_transcribe_fallback'; - // Store jobId if batch processing was used - if (transcribeResult.jobId) { - console.log(`Storing jobId ${transcribeResult.jobId} in memo ${memoId}`); - await this.memoroService.updateMemoWithJobId( - memoId, - transcribeResult.jobId, - token, - recordingLanguages - ); - } - } - - // Update status to completed - await this.updateMemoTranscriptionStatus(memoId, 'completed', token, { - processingRoute, - completedAt: new Date().toISOString(), - }); - - console.log(`Transcription completed for memo ${memoId} via ${processingRoute}`); - } catch (error) { - console.error('Audio microservice transcription with fallback failed:', error); - throw new Error(`Transcription failed after all fallback attempts: ${error.message}`); - } - } else { - // Use batch processing for long files - try { - const batchResult = await this.processBatchFromStoragePath( - filePath, - userId, - spaceId, - token, - recordingLanguages, - memoId, - enableDiarization - ); - - // Store the jobId in memo metadata for webhook callback lookup - if (batchResult.jobId) { - console.log(`Storing jobId ${batchResult.jobId} in memo ${memoId}`); - await this.memoroService.updateMemoWithJobId( - memoId, - batchResult.jobId, - token, - recordingLanguages - ); - } - - // Update status to processing (batch will update to completed via webhook) - await this.updateMemoTranscriptionStatus(memoId, 'processing', token, { - processingRoute: 'batch_transcribe', - batchJobId: batchResult.jobId, - }); - - console.log( - `Batch transcription started for memo ${memoId} with jobId ${batchResult.jobId}` - ); - } catch (batchError) { - console.error('Batch processing failed:', batchError); - throw new Error(`Batch processing failed: ${batchError.message}`); - } - } - } catch (error) { - console.error(`Error in processTranscriptionAsync for memo ${memoId}:`, error); - throw error; - } - } - - /** - * Update memo transcription status - */ - private async updateMemoTranscriptionStatus( - memoId: string, - status: 'pending' | 'processing' | 'completed' | 'failed', - token: string, - additionalData?: any - ): Promise { - try { - // Delegate to the service which has access to the Supabase credentials - await this.memoroService.updateMemoTranscriptionStatus(memoId, status, token, additionalData); - } catch (error) { - console.error(`Error updating transcription status for memo ${memoId}:`, error); - } - } - - // REMOVED: Legacy upload-audio endpoint for cleanup - // All uploads now use process-uploaded-audio with direct storage upload - - /** - * Call audio microservice for video processing - */ - private async callAudioMicroserviceVideoProcessing( - videoPath: string, - memoId: string, - userId: string, - spaceId: string | undefined, - recordingLanguages: string[], - token: string, - enableDiarization?: boolean - ) { - const audioServiceUrl = this.configService.get('AUDIO_MICROSERVICE_URL'); - - if (!audioServiceUrl) { - console.error('[CRITICAL ERROR] AUDIO_MICROSERVICE_URL is not configured'); - throw new Error('Missing required configuration: AUDIO_MICROSERVICE_URL'); - } - - const payload = { - videoPath, - memoId, - userId, - spaceId, - recordingLanguages, - enableDiarization: enableDiarization !== false, - }; - - console.log( - `[callAudioMicroserviceVideoProcessing] Processing video: ${audioServiceUrl}/audio/process-video` - ); - console.log(`[VIDEO_PROCESSING] Video path: ${videoPath}`); - - const response = await fetch(`${audioServiceUrl}/audio/process-video`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${token}`, - }, - body: JSON.stringify(payload), - }); - - if (!response.ok) { - const errorText = await response.text(); - const error = new Error(`Video processing failed: ${response.status} - ${errorText}`); - (error as any).status = response.status; - throw error; - } - - const result = await response.json(); - console.log(`[VIDEO_PROCESSING] Result:`, result); - return result; - } - - /** - * Call audio microservice with built-in fallback system - */ - private async callAudioMicroserviceRealtimeWithFallback( - audioPath: string, - memoId: string, - userId: string, - spaceId: string | undefined, - recordingLanguages: string[], - token: string, - enableDiarization?: boolean, - isAppend?: boolean - ) { - // Debug: Log the raw environment variable and ConfigService value - console.error( - '[CRITICAL DEBUG] process.env.AUDIO_MICROSERVICE_URL:', - process.env.AUDIO_MICROSERVICE_URL - ); - console.error( - '[CRITICAL DEBUG] ConfigService.get(AUDIO_MICROSERVICE_URL):', - this.configService.get('AUDIO_MICROSERVICE_URL') - ); - console.error('[CRITICAL DEBUG] NODE_ENV:', process.env.NODE_ENV); - - const audioServiceUrl = this.configService.get('AUDIO_MICROSERVICE_URL'); - - if (!audioServiceUrl) { - console.error('[CRITICAL ERROR] AUDIO_MICROSERVICE_URL is not configured'); - throw new Error('Missing required configuration: AUDIO_MICROSERVICE_URL'); - } - console.log('[DEBUG] Final audioServiceUrl:', audioServiceUrl); - - const payload = { - audioPath, - memoId, - userId, - spaceId, - recordingLanguages, - enableDiarization, - isAppend: isAppend || false, - }; - - console.log( - `Calling audio microservice realtime with fallback: ${audioServiceUrl}/audio/transcribe-realtime` - ); - console.log(`[AUDIO_MICROSERVICE_CALL] Sending audioPath: ${audioPath}`); - - const response = await fetch(`${audioServiceUrl}/audio/transcribe-realtime`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${token}`, - }, - body: JSON.stringify(payload), - }); - - if (!response.ok) { - const errorText = await response.text(); - const error = new Error( - `Audio microservice transcription failed: ${response.status} - ${errorText}` - ); - (error as any).status = response.status; - throw error; - } - - return await response.json(); - } - - private async callAudioMicroserviceStorageBatch( - audioPath: string, - userId: string, - spaceId: string | undefined, - recordingLanguages?: string[], - token?: string, - memoId?: string, - enableDiarization?: boolean, - isAppend?: boolean - ) { - const audioServiceUrl = this.configService.get('AUDIO_MICROSERVICE_URL'); - - if (!audioServiceUrl) { - console.error('[CRITICAL ERROR] AUDIO_MICROSERVICE_URL is not configured'); - throw new Error('Missing required configuration: AUDIO_MICROSERVICE_URL'); - } - - const payload = { - audioPath, - userId, - spaceId, - recordingLanguages, - memoId, - enableDiarization, - isAppend: isAppend || false, - }; - - console.log( - `Calling audio microservice storage batch: ${audioServiceUrl}/audio/transcribe-from-storage` - ); - - const response = await fetch(`${audioServiceUrl}/audio/transcribe-from-storage`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${token}`, - }, - body: JSON.stringify(payload), - }); - - if (!response.ok) { - const errorText = await response.text(); - throw new Error(`Audio microservice storage batch failed: ${response.status} - ${errorText}`); - } - - return await response.json(); - } - - private async processBatchFromStoragePath( - filePath: string, - userId: string, - spaceId: string | undefined, - token: string, - recordingLanguages?: string[], - memoId?: string, - enableDiarization?: boolean, - isAppend?: boolean - ) { - try { - // Use the new storage-based endpoint instead of downloading and re-uploading - return await this.callAudioMicroserviceStorageBatch( - filePath, - userId, - spaceId, - recordingLanguages, - token, - memoId, - enableDiarization, - isAppend - ); - } catch (error) { - console.error('Error processing batch from storage path:', error); - throw error; - } - } - - /** - * Legacy method - no longer used since error detection is handled in audio microservice - */ - private isAudioFormatError(error: any): boolean { - if (!error || !error.message) return false; - - const errorMessage = error.message.toLowerCase(); - const formatErrorIndicators = [ - 'audio format', - 'audio stream could not be decoded', - 'invalidaudioformat', - 'unprocessableentity', - 'the audio stream could not be decoded with the provided configuration', - 'audio/x-m4a', - 'could not be decoded', - '422', - ]; - - return formatErrorIndicators.some((indicator) => errorMessage.includes(indicator)); - } - - /** - * Update batch transcription metadata for recovery tracking - */ - @Post('update-batch-metadata') - async updateBatchMetadata( - @Body() - body: { - memoId: string; - jobId: string; - batchTranscription: boolean; - }, - @Req() req - ) { - try { - const { memoId, jobId, batchTranscription } = body; - const token = req.token; // This is set by the AuthGuard - - // Delegate to service which has proper Supabase client initialization - const result = await this.memoroService.updateBatchMetadataByMemoId( - memoId, - jobId, - batchTranscription, - token - ); - - return result; - } catch (error) { - console.error('Error updating batch metadata:', error); - throw new BadRequestException(`Failed to update batch metadata: ${error.message}`); - } - } - - /** - * Handles transcription completion callback from audio microservice - */ - @Post('transcription-completed') - @HttpCode(200) - async handleTranscriptionCompleted( - @Body() - callbackData: { - memoId: string; - userId: string; - transcriptionResult?: any; - route?: 'fast' | 'batch'; - success?: boolean; - error?: string; - }, - @Req() req - ) { - try { - console.log( - `[handleTranscriptionCompleted] Received callback for memo ${callbackData.memoId}` - ); - - const token = req.token; // This is set by the AuthGuard - - if (!callbackData.memoId || !callbackData.userId) { - throw new BadRequestException('memoId and userId are required'); - } - - // Delegate to service to handle the callback - const result = await this.memoroService.handleTranscriptionCompleted( - callbackData.memoId, - callbackData.userId, - callbackData.transcriptionResult, - callbackData.route, - callbackData.success, - callbackData.error, - token - ); - - return result; - } catch (error) { - console.error(`[handleTranscriptionCompleted] Error processing callback:`, error); - throw new BadRequestException(`Failed to process transcription callback: ${error.message}`); - } - } - - /** - * Handles append transcription completion callback from audio microservice - */ - @Post('append-transcription-completed') - @HttpCode(200) - async handleAppendTranscriptionCompleted( - @Body() - callbackData: { - memoId: string; - userId: string; - transcriptionResult?: any; - route?: 'fast' | 'batch'; - success?: boolean; - error?: string; - }, - @Req() req - ) { - try { - console.log( - `[handleAppendTranscriptionCompleted] Received callback for memo ${callbackData.memoId}` - ); - - const token = req.token; // This is set by the AuthGuard - - if (!callbackData.memoId || !callbackData.userId) { - throw new BadRequestException('memoId and userId are required'); - } - - // The service will determine the correct recording index based on the current state - const result = await this.memoroService.handleAppendTranscriptionCompleted( - callbackData.memoId, - callbackData.userId, - callbackData.transcriptionResult, - callbackData.route || 'fast', - callbackData.success !== false, - callbackData.error || null, - token - ); - - return result; - } catch (error) { - console.error( - `[handleAppendTranscriptionCompleted] Error processing append callback:`, - error - ); - throw new BadRequestException( - `Failed to process append transcription callback: ${error.message}` - ); - } - } - - @Post('append-transcription') - async appendTranscription( - @User() user: JwtPayload, - @Body() - appendData: { - memoId: string; - filePath: string; - duration: number; - recordingIndex?: number; - recordingLanguages?: string[]; - enableDiarization?: boolean; - }, - @Req() req - ) { - const { memoId, filePath, duration, recordingIndex, recordingLanguages, enableDiarization } = - appendData; - const token = req.token; - - if (!memoId) { - throw new BadRequestException('Memo ID is required'); - } - - if (!filePath) { - throw new BadRequestException('File path is required'); - } - - if (!duration || duration <= 0) { - throw new BadRequestException('Valid duration is required'); - } - - try { - // Validate memo exists and belongs to user - const memo = await this.memoroService.validateMemoForAppend(user.sub, memoId, token); - - if (!memo) { - throw new NotFoundException('Memo not found or access denied'); - } - - // Check credits before processing - const requiredCredits = calculateTranscriptionCost(duration); - const spaceId = memo.metadata?.spaceId; - - let creditCheck; - if (spaceId) { - try { - creditCheck = await this.creditClientService.checkSpaceCredits( - spaceId, - requiredCredits, - token - ); - } catch (error) { - console.warn(`Space credit check failed, checking user credits: ${error.message}`); - creditCheck = await this.creditClientService.checkUserCredits( - user.sub, - requiredCredits, - token - ); - } - } else { - creditCheck = await this.creditClientService.checkUserCredits( - user.sub, - requiredCredits, - token - ); - } - - if (!creditCheck.hasEnoughCredits) { - throw new InsufficientCreditsException({ - requiredCredits: creditCheck.requiredCredits, - availableCredits: creditCheck.currentCredits, - creditType: creditCheck.creditType, - operation: 'transcription', - spaceId, - }); - } - - // Start async append transcription processing - const durationMinutes = duration / 60; - const shouldUseFastTranscribe = durationMinutes < 115; - - console.log( - `[appendTranscription] Audio duration: ${durationMinutes.toFixed(2)} minutes. Using ${shouldUseFastTranscribe ? 'fast' : 'batch'} transcription.` - ); - - // Process append transcription asynchronously - setImmediate(() => { - this.processAppendTranscriptionAsync( - memoId, - filePath, - duration, - user.sub, - spaceId, - recordingLanguages || [], - token, - enableDiarization, - shouldUseFastTranscribe, - recordingIndex - ).catch((error) => { - console.error(`Async append transcription failed for memo ${memoId}:`, error); - // Update memo with error status in additional_recordings - this.memoroService.updateAppendTranscriptionStatus( - memoId, - recordingIndex, - 'error', - token, - { - error: error.message, - timestamp: new Date().toISOString(), - } - ); - }); - }); - - // Return immediately - return { - success: true, - memoId, - filePath, - status: 'processing', - estimatedDuration: Math.ceil(duration / 60), - message: 'Append transcription in progress.', - estimatedCredits: requiredCredits, - }; - } catch (error) { - console.error('Error appending transcription:', error); - - if ( - error instanceof InsufficientCreditsException || - error instanceof ForbiddenException || - error instanceof BadRequestException || - error instanceof NotFoundException - ) { - throw error; - } - - throw new BadRequestException(`Failed to append transcription: ${error.message}`); - } - } - - /** - * Process append transcription asynchronously in the background - */ - private async processAppendTranscriptionAsync( - memoId: string, - filePath: string, - duration: number, - userId: string, - spaceId: string | undefined, - recordingLanguages: string[], - token: string, - enableDiarization: boolean | undefined, - shouldUseFastTranscribe: boolean, - recordingIndex?: number - ): Promise { - try { - // Update status to processing with file information - await this.memoroService.updateAppendTranscriptionStatus( - memoId, - recordingIndex, - 'processing', - token, - { - audio_path: filePath, - duration: duration, - type: 'audio', - } - ); - - if (shouldUseFastTranscribe) { - // Use audio microservice with built-in fallback system - try { - // Just call the audio microservice - it will send callbacks - await this.callAudioMicroserviceRealtimeWithFallback( - filePath, - memoId, - userId, - spaceId, - recordingLanguages, - token, - enableDiarization, - true // isAppend = true for append transcriptions - ); - - console.log( - `Append transcription initiated for memo ${memoId} via fast transcribe - waiting for callback` - ); - } catch (error) { - console.error('Audio microservice append transcription failed:', error); - throw new Error(`Append transcription failed: ${error.message}`); - } - } else { - // Use batch processing for long files - try { - const batchResult = await this.processBatchFromStoragePath( - filePath, - userId, - spaceId, - token, - recordingLanguages, - memoId, - enableDiarization, - true // isAppend = true for append transcriptions - ); - - // Store the jobId for batch tracking - if (batchResult.jobId) { - console.log( - `Batch append transcription started for memo ${memoId} with jobId ${batchResult.jobId}` - ); - await this.memoroService.updateAppendTranscriptionStatus( - memoId, - recordingIndex, - 'processing', - token, - { - batchJobId: batchResult.jobId, - processingRoute: 'batch_transcribe', - isAppend: true, - } - ); - } - } catch (batchError) { - console.error('Batch append transcription failed:', batchError); - throw new Error(`Batch append transcription failed: ${batchError.message}`); - } - } - } catch (error) { - console.error(`Error in processAppendTranscriptionAsync for memo ${memoId}:`, error); - throw error; - } - } - - /** - * Find memo details by batch job ID (used by audio microservice webhook) - */ - @Get('find-memo-by-job/:jobId') - async findMemoByJobId(@Param('jobId') jobId: string) { - try { - console.log(`[findMemoByJobId] Looking up memo for job ${jobId}`); - - if (!jobId) { - throw new BadRequestException('Job ID is required'); - } - - // Search for memo with this jobId in metadata - const authClient = createClient( - this.memoroService.getSupabaseUrl(), - this.memoroService['memoroServiceKey'] // Use service key for direct access - ); - - const { data: memos, error } = await authClient - .from('memos') - .select('id, user_id, metadata') - .like('metadata->>processing->>transcription->>jobId', jobId) - .limit(1); - - if (error) { - console.error(`[findMemoByJobId] Database error:`, error); - throw new BadRequestException(`Database error: ${error.message}`); - } - - if (!memos || memos.length === 0) { - console.warn(`[findMemoByJobId] No memo found for job ${jobId}`); - throw new NotFoundException(`No memo found for job ${jobId}`); - } - - const memo = memos[0]; - console.log(`[findMemoByJobId] Found memo ${memo.id} for job ${jobId}`); - - return { - memoId: memo.id, - userId: memo.user_id, - // Note: We don't have the original token for webhook callbacks - // The webhook will need to operate without user-specific token - }; - } catch (error) { - console.error(`[findMemoByJobId] Error finding memo for job ${jobId}:`, error); - if (error instanceof NotFoundException || error instanceof BadRequestException) { - throw error; - } - throw new BadRequestException(`Failed to find memo for job: ${error.message}`); - } - } - - /** - * Detect media type based on file extension - */ - private detectMediaType(filePath: string): 'audio' | 'video' | 'unknown' { - const audioExtensions = ['mp3', 'wav', 'aac', 'm4a', 'flac', 'ogg', 'wma', 'opus']; - const videoExtensions = ['mp4', 'avi', 'mov', 'mkv', 'wmv', 'flv', 'webm', 'm4v', '3gp']; - - const extension = filePath.split('.').pop()?.toLowerCase(); - - if (!extension) { - return 'unknown'; - } - - if (audioExtensions.includes(extension)) { - return 'audio'; - } else if (videoExtensions.includes(extension)) { - return 'video'; - } - - return 'unknown'; - } -} diff --git a/apps/memoro/apps/backend/src/memoro/memoro.module.ts b/apps/memoro/apps/backend/src/memoro/memoro.module.ts deleted file mode 100644 index d87012c2a..000000000 --- a/apps/memoro/apps/backend/src/memoro/memoro.module.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { Module } from '@nestjs/common'; -import { ConfigModule } from '@nestjs/config'; -import { MemoroController } from './memoro.controller'; -import { MemoroServiceController } from './memoro-service.controller'; -import { SpaceSyncController } from './space-sync.controller'; -import { QuestionMemoController } from './question-memo.controller'; -import { CombineMemosController } from './combine-memos.controller'; -import { MemoroService } from './memoro.service'; -import { SyncSpaceMembersService } from './sync-space-members.service'; -import { SpacesModule } from '../spaces/spaces.module'; -import { AuthModule } from '../auth/auth.module'; -import { CreditsModule } from '../credits/credits.module'; -import { AiModule } from '../ai/ai.module'; - -@Module({ - imports: [ConfigModule, SpacesModule, AuthModule, CreditsModule, AiModule], - controllers: [ - MemoroController, - MemoroServiceController, - SpaceSyncController, - QuestionMemoController, - CombineMemosController, - ], - providers: [MemoroService, SyncSpaceMembersService], - exports: [MemoroService, SyncSpaceMembersService], -}) -export class MemoroModule {} diff --git a/apps/memoro/apps/backend/src/memoro/memoro.service.spec.ts b/apps/memoro/apps/backend/src/memoro/memoro.service.spec.ts deleted file mode 100644 index bca97d644..000000000 --- a/apps/memoro/apps/backend/src/memoro/memoro.service.spec.ts +++ /dev/null @@ -1,958 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { ConfigService } from '@nestjs/config'; -import { MemoroService } from './memoro.service'; -import { SpacesClientService } from '../spaces/spaces-client.service'; -import { SpaceSyncService } from '../spaces/space-sync.service'; -import { CreditConsumptionService } from '../credits/credit-consumption.service'; -import { BadRequestException, ForbiddenException, NotFoundException } from '@nestjs/common'; -import { createClient } from '@supabase/supabase-js'; - -jest.mock('@supabase/supabase-js'); -jest.mock('uuid', () => ({ - v4: jest.fn(() => 'memo-123'), -})); - -global.fetch = jest.fn(); - -describe('MemoroService', () => { - let service: MemoroService; - let configService: jest.Mocked; - let spacesService: jest.Mocked; - let spaceSyncService: jest.Mocked; - let creditConsumptionService: jest.Mocked; - let mockSupabaseClient: any; - let mockSupabaseServiceClient: any; - - const mockUserId = 'user-123'; - const mockToken = 'mock-token'; - const mockSpaceId = 'space-123'; - const mockMemoId = 'memo-123'; - - beforeEach(async () => { - mockSupabaseClient = { - from: jest.fn().mockReturnThis(), - select: jest.fn().mockReturnThis(), - eq: jest.fn().mockReturnThis(), - single: jest.fn().mockReturnThis(), - insert: jest.fn().mockReturnThis(), - update: jest.fn().mockReturnThis(), - delete: jest.fn().mockReturnThis(), - order: jest.fn().mockReturnThis(), - limit: jest.fn().mockReturnThis(), - like: jest.fn().mockReturnThis(), - upsert: jest.fn().mockReturnThis(), - maybeSingle: jest.fn().mockReturnThis(), - storage: { - from: jest.fn().mockReturnValue({ - upload: jest.fn().mockResolvedValue({ data: { path: 'uploads/audio.mp3' }, error: null }), - getPublicUrl: jest - .fn() - .mockReturnValue({ data: { publicUrl: 'https://example.com/audio.mp3' } }), - }), - }, - }; - - mockSupabaseServiceClient = { - ...mockSupabaseClient, - from: jest.fn().mockReturnThis(), - select: jest.fn().mockReturnThis(), - eq: jest.fn().mockReturnThis(), - }; - - (createClient as jest.Mock).mockImplementation((url, key, options) => { - if (options?.global?.headers?.Authorization) { - return mockSupabaseClient; - } - return mockSupabaseServiceClient; - }); - - const module: TestingModule = await Test.createTestingModule({ - providers: [ - MemoroService, - { - provide: ConfigService, - useValue: { - get: jest.fn((key: string) => { - const config: Record = { - MEMORO_SUPABASE_URL: 'https://test.supabase.co', - MEMORO_SUPABASE_ANON_KEY: 'test-anon-key', - MEMORO_SUPABASE_SERVICE_ROLE_KEY: 'test-service-key', - AUDIO_MICROSERVICE_URL: 'https://audio.microservice.com', - }; - return config[key]; - }), - }, - }, - { - provide: SpacesClientService, - useValue: { - getUserSpaces: jest.fn(), - createSpace: jest.fn(), - getSpaceDetails: jest.fn(), - addSpaceMember: jest.fn(), - acceptSpaceInvite: jest.fn(), - declineSpaceInvite: jest.fn(), - leaveSpace: jest.fn(), - deleteSpace: jest.fn(), - getSpaceInvites: jest.fn(), - resendSpaceInvite: jest.fn(), - cancelSpaceInvite: jest.fn(), - getUserPendingInvites: jest.fn(), - verifySpaceAccess: jest.fn(), - }, - }, - { - provide: SpaceSyncService, - useValue: { - syncSpaceMembership: jest.fn(), - removeSpaceMembership: jest.fn(), - }, - }, - { - provide: CreditConsumptionService, - useValue: { - consumeTranscriptionCredits: jest.fn(), - }, - }, - ], - }).compile(); - - service = module.get(MemoroService); - configService = module.get(ConfigService); - spacesService = module.get(SpacesClientService); - spaceSyncService = module.get(SpaceSyncService); - creditConsumptionService = module.get(CreditConsumptionService); - - (global.fetch as jest.Mock).mockClear(); - }); - - it('should be defined', () => { - expect(service).toBeDefined(); - }); - - describe('getMemoroSpaces', () => { - it('should return empty array', async () => { - const result = await service.getMemoroSpaces(mockUserId, mockToken); - expect(result).toEqual([]); - }); - }); - - describe('createMemoroSpace', () => { - it('should create space and sync membership', async () => { - const spaceName = 'Test Space'; - const mockSpace = { - id: mockSpaceId, - name: spaceName, - owner_id: mockUserId, - app_id: 'test-app', - roles: { [mockUserId]: 'owner' }, - credits: 1000, - created_at: '2024-01-01T00:00:00Z', - updated_at: '2024-01-01T00:00:00Z', - }; - - spacesService.createSpace.mockResolvedValue(mockSpace); - spaceSyncService.syncSpaceMembership.mockResolvedValue(undefined); - - const result = await service.createMemoroSpace(mockUserId, spaceName, mockToken); - - expect(result).toEqual(mockSpace); - expect(spacesService.createSpace).toHaveBeenCalledWith(mockUserId, spaceName, mockToken); - expect(spaceSyncService.syncSpaceMembership).toHaveBeenCalledWith( - mockSpaceId, - mockUserId, - 'owner' - ); - }); - - it('should throw error if space creation fails', async () => { - const spaceName = 'Test Space'; - spacesService.createSpace.mockRejectedValue(new Error('Failed to create space')); - - await expect(service.createMemoroSpace(mockUserId, spaceName, mockToken)).rejects.toThrow( - 'Failed to create space' - ); - }); - }); - - describe('getMemoroSpaceDetails', () => { - it('should get space details successfully', async () => { - const mockSpaceDetails = { space: { id: mockSpaceId, name: 'Test Space' } }; - spacesService.getSpaceDetails.mockResolvedValue(mockSpaceDetails); - - const result = await service.getMemoroSpaceDetails(mockUserId, mockSpaceId, mockToken); - - expect(result).toEqual(mockSpaceDetails); - expect(spacesService.getSpaceDetails).toHaveBeenCalledWith(mockSpaceId, mockToken); - }); - - it('should handle NotFoundException with fallback', async () => { - spacesService.getSpaceDetails - .mockRejectedValueOnce(new NotFoundException('Space not found')) - .mockResolvedValueOnce({ space: { id: mockSpaceId, name: 'Test Space' } }); - - mockSupabaseClient.from.mockReturnValue({ - select: jest.fn().mockReturnThis(), - eq: jest.fn().mockReturnThis(), - single: jest.fn().mockResolvedValue({ - data: { id: mockSpaceId, owner_id: mockUserId, roles: { [mockUserId]: 'owner' } }, - error: null, - }), - }); - - const result = await service.getMemoroSpaceDetails(mockUserId, mockSpaceId, mockToken); - - expect(result).toBeDefined(); - expect(spacesService.getSpaceDetails).toHaveBeenCalledTimes(2); - }); - - it('should throw ForbiddenException if user has no access', async () => { - spacesService.getSpaceDetails.mockRejectedValue(new NotFoundException('Space not found')); - - mockSupabaseClient.from.mockReturnValue({ - select: jest.fn().mockReturnThis(), - eq: jest.fn().mockReturnThis(), - single: jest.fn().mockResolvedValue({ - data: { id: mockSpaceId, owner_id: 'other-user', roles: {} }, - error: null, - }), - }); - - await expect( - service.getMemoroSpaceDetails(mockUserId, mockSpaceId, mockToken) - ).rejects.toThrow(ForbiddenException); - }); - }); - - describe('linkMemoToSpace', () => { - it('should link memo to space successfully', async () => { - const linkData = { memoId: mockMemoId, spaceId: mockSpaceId }; - - // First mock for memo verification - needs maybeSingle - mockSupabaseClient.from.mockReturnValueOnce({ - select: jest.fn().mockReturnThis(), - eq: jest.fn(() => ({ - eq: jest.fn(() => ({ - maybeSingle: jest.fn().mockResolvedValue({ - data: { id: mockMemoId, user_id: mockUserId }, - error: null, - }), - })), - })), - }); - - // Second mock for space verification - needs maybeSingle - mockSupabaseClient.from.mockReturnValueOnce({ - select: jest.fn().mockReturnThis(), - eq: jest.fn(() => ({ - maybeSingle: jest.fn().mockResolvedValue({ - data: { id: mockSpaceId, owner_id: mockUserId, roles: { [mockUserId]: 'owner' } }, - error: null, - }), - })), - }); - - mockSupabaseClient.from.mockReturnValueOnce({ - insert: jest.fn().mockResolvedValue({ data: {}, error: null }), - }); - - const result = await service.linkMemoToSpace(mockUserId, linkData, mockToken); - - expect(result).toEqual({ success: true, message: 'Memo linked to space successfully' }); - }); - - it('should handle duplicate link gracefully', async () => { - const linkData = { memoId: mockMemoId, spaceId: mockSpaceId }; - - mockSupabaseClient.from.mockReturnValueOnce({ - select: jest.fn().mockReturnThis(), - eq: jest.fn().mockReturnThis(), - single: jest.fn().mockResolvedValue({ - data: { id: mockMemoId, user_id: mockUserId }, - error: null, - }), - }); - - mockSupabaseClient.from.mockReturnValueOnce({ - select: jest.fn().mockReturnThis(), - eq: jest.fn().mockReturnThis(), - single: jest.fn().mockResolvedValue({ - data: { id: mockSpaceId, owner_id: mockUserId, roles: { [mockUserId]: 'owner' } }, - error: null, - }), - }); - - mockSupabaseClient.from.mockReturnValueOnce({ - insert: jest.fn().mockResolvedValue({ - data: null, - error: { code: '23505', message: 'duplicate key value' }, - }), - }); - - const result = await service.linkMemoToSpace(mockUserId, linkData, mockToken); - - expect(result).toEqual({ success: true, message: 'Memo is already linked to this space' }); - }); - - it('should throw NotFoundException if user lacks memo access', async () => { - const linkData = { memoId: mockMemoId, spaceId: mockSpaceId }; - - // Mock for verifyMemoAccess - needs single not maybeSingle - mockSupabaseClient.from.mockReturnValue({ - select: jest.fn().mockReturnThis(), - eq: jest.fn(() => ({ - single: jest.fn().mockResolvedValue({ - data: { id: mockMemoId, user_id: 'other-user' }, - error: null, - }), - })), - }); - - await expect(service.linkMemoToSpace(mockUserId, linkData, mockToken)).rejects.toThrow( - NotFoundException - ); - }); - }); - - describe('getSpaceMemos', () => { - it('should get space memos successfully', async () => { - const mockMemos = [ - { id: 'memo-1', title: 'Memo 1', user_id: mockUserId }, - { id: 'memo-2', title: 'Memo 2', user_id: 'other-user' }, - ]; - - mockSupabaseClient.from.mockReturnValueOnce({ - select: jest.fn().mockReturnThis(), - eq: jest.fn(() => ({ - maybeSingle: jest.fn().mockResolvedValue({ - data: { id: mockSpaceId, owner_id: mockUserId, roles: { [mockUserId]: 'owner' } }, - error: null, - }), - })), - }); - - mockSupabaseServiceClient.from.mockReturnValue({ - select: jest.fn().mockReturnThis(), - eq: jest.fn().mockReturnThis(), - order: jest.fn().mockResolvedValue({ - data: mockMemos, - error: null, - }), - }); - - const result = await service.getSpaceMemos(mockUserId, mockSpaceId, mockToken); - - expect(result).toEqual({ memos: mockMemos }); - }); - - it('should return empty array if no memos found', async () => { - mockSupabaseClient.from.mockReturnValueOnce({ - select: jest.fn().mockReturnThis(), - eq: jest.fn(() => ({ - maybeSingle: jest.fn().mockResolvedValue({ - data: { id: mockSpaceId, owner_id: mockUserId, roles: { [mockUserId]: 'owner' } }, - error: null, - }), - })), - }); - - mockSupabaseServiceClient.from.mockReturnValue({ - select: jest.fn().mockReturnThis(), - eq: jest.fn().mockReturnThis(), - order: jest.fn().mockResolvedValue({ - data: [], - error: null, - }), - }); - - const result = await service.getSpaceMemos(mockUserId, mockSpaceId, mockToken); - - expect(result).toEqual({ memos: [] }); - }); - }); - - describe('validateMemoForRetry', () => { - it('should validate memo successfully', async () => { - const mockMemo = { - id: mockMemoId, - user_id: mockUserId, - metadata: { - processing: { - transcription: { status: 'error' }, - }, - }, - }; - - mockSupabaseClient.from.mockReturnValue({ - select: jest.fn().mockReturnThis(), - eq: jest.fn().mockReturnThis(), - single: jest.fn().mockResolvedValue({ - data: mockMemo, - error: null, - }), - }); - - const result = await service.validateMemoForRetry(mockUserId, mockMemoId, mockToken); - - expect(result).toEqual(mockMemo); - }); - - it('should return null if memo not found', async () => { - mockSupabaseClient.from.mockReturnValue({ - select: jest.fn().mockReturnThis(), - eq: jest.fn().mockReturnThis(), - single: jest.fn().mockResolvedValue({ - data: null, - error: { code: 'PGRST116', message: 'not found' }, - }), - }); - - const result = await service.validateMemoForRetry(mockUserId, mockMemoId, mockToken); - - expect(result).toBeNull(); - }); - - it('should return memo even if user does not own it', async () => { - const mockMemo = { - id: mockMemoId, - user_id: 'other-user', - metadata: {}, - }; - - mockSupabaseClient.from.mockReturnValue({ - select: jest.fn().mockReturnThis(), - eq: jest.fn().mockReturnThis(), - single: jest.fn().mockResolvedValue({ - data: mockMemo, - error: null, - }), - }); - - const result = await service.validateMemoForRetry(mockUserId, mockMemoId, mockToken); - - expect(result).toEqual(mockMemo); - }); - }); - - describe('retryTranscription', () => { - it('should retry transcription successfully', async () => { - const mockMemo = { - id: mockMemoId, - user_id: mockUserId, - metadata: { - processing: { - transcription: { - status: 'error', - audioPath: 'uploads/audio.mp3', - }, - }, - }, - space_id: mockSpaceId, - location_data: null, - recording_started_at: '2024-01-01T00:00:00Z', - language_codes: ['en-US'], - }; - - mockSupabaseClient.from.mockReturnValueOnce({ - update: jest.fn().mockReturnThis(), - eq: jest.fn().mockResolvedValue({ data: {}, error: null }), - }); - - (global.fetch as jest.Mock).mockResolvedValueOnce({ - ok: true, - json: async () => ({ success: true }), - }); - - mockSupabaseClient.from.mockReturnValueOnce({ - select: jest.fn().mockReturnThis(), - eq: jest.fn().mockReturnThis(), - single: jest.fn().mockResolvedValue({ - data: mockMemo, - error: null, - }), - }); - - const result = await service.retryTranscription(mockUserId, mockMemoId, mockToken, 1); - - expect(result).toEqual({ success: true }); - expect(global.fetch).toHaveBeenCalledWith( - expect.stringContaining('transcribe-fast'), - expect.objectContaining({ - method: 'POST', - headers: expect.objectContaining({ - Authorization: `Bearer ${mockToken}`, - }), - }) - ); - }); - - it('should handle edge function errors', async () => { - const mockMemo = { - id: mockMemoId, - user_id: mockUserId, - metadata: { - processing: { - transcription: { - status: 'error', - audioPath: 'uploads/audio.mp3', - }, - }, - }, - }; - - mockSupabaseClient.from.mockReturnValueOnce({ - update: jest.fn().mockReturnThis(), - eq: jest.fn().mockResolvedValue({ data: {}, error: null }), - }); - - (global.fetch as jest.Mock).mockResolvedValueOnce({ - ok: false, - status: 500, - text: async () => 'Internal Server Error', - }); - - mockSupabaseClient.from.mockReturnValueOnce({ - select: jest.fn().mockReturnThis(), - eq: jest.fn().mockReturnThis(), - single: jest.fn().mockResolvedValue({ - data: mockMemo, - error: null, - }), - }); - - await expect( - service.retryTranscription(mockUserId, mockMemoId, mockToken, 1) - ).rejects.toThrow('Edge function call failed'); - }); - }); - - describe('updateMemoTranscriptionStatus', () => { - it('should update transcription status successfully', async () => { - const status = 'completed'; - const additionalData = { completedAt: '2024-01-01T00:00:00Z' }; - - // First mock for reading existing metadata - mockSupabaseClient.from.mockReturnValueOnce({ - select: jest.fn().mockReturnThis(), - eq: jest.fn().mockReturnThis(), - single: jest.fn().mockResolvedValue({ - data: { - id: mockMemoId, - metadata: {}, - }, - error: null, - }), - }); - - // Second mock for updating metadata - mockSupabaseClient.from.mockReturnValueOnce({ - update: jest.fn().mockReturnThis(), - eq: jest.fn().mockResolvedValue({ data: {}, error: null }), - }); - - await service.updateMemoTranscriptionStatus(mockMemoId, status, mockToken, additionalData); - - expect(mockSupabaseClient.from).toHaveBeenCalledWith('memos'); - expect(mockSupabaseClient.update).toHaveBeenCalledWith({ - metadata: expect.objectContaining({ - processing: expect.objectContaining({ - transcription: expect.objectContaining({ - status, - ...additionalData, - }), - }), - }), - }); - }); - }); - - describe('handleTranscriptionCompleted', () => { - it('should handle successful transcription completion', async () => { - const transcriptionResult = { - text: 'Hello world', - segments: [], - utterances: [], - }; - - // Mock first call to get memo details - mockSupabaseServiceClient.from.mockReturnValueOnce({ - select: jest.fn().mockReturnThis(), - eq: jest.fn().mockReturnThis(), - single: jest.fn().mockResolvedValue({ - data: { - id: mockMemoId, - metadata: { - location: { - source: 'manual', - }, - }, - duration_seconds: 300, - space_id: mockSpaceId, - }, - error: null, - }), - }); - - // Mock second call to update memo with transcription - mockSupabaseServiceClient.from.mockReturnValueOnce({ - update: jest.fn().mockReturnThis(), - eq: jest.fn().mockResolvedValue({ data: {}, error: null }), - }); - - // Mock third call to update memo with headline - mockSupabaseServiceClient.from.mockReturnValueOnce({ - update: jest.fn().mockReturnThis(), - eq: jest.fn().mockResolvedValue({ data: {}, error: null }), - }); - - creditConsumptionService.consumeTranscriptionCredits.mockResolvedValue({ - success: true, - creditsConsumed: 10, - creditType: 'space', - message: 'Credits consumed', - }); - - // Mock location source - const mockLocationResponse = { - source: 'manual', - }; - - // Mock first fetch call for location data source - (global.fetch as jest.Mock).mockImplementationOnce(() => - Promise.resolve({ - ok: true, - json: async () => mockLocationResponse, - }) - ); - - // Mock second fetch call for headline edge function - (global.fetch as jest.Mock).mockImplementationOnce(() => - Promise.resolve({ - ok: true, - json: async () => ({ headline: 'Test Headline', intro: 'Test Intro' }), - }) - ); - - const result = await service.handleTranscriptionCompleted( - mockMemoId, - mockUserId, - transcriptionResult, - 'fast', - true, - undefined, - mockToken - ); - - expect(result).toEqual({ success: true, message: 'Transcription processed successfully' }); - expect(creditConsumptionService.consumeTranscriptionCredits).toHaveBeenCalled(); - }); - - it('should handle failed transcription', async () => { - const error = 'Transcription failed'; - - const updateMock = jest.fn().mockResolvedValue({ data: {}, error: null }); - mockSupabaseServiceClient.from.mockReturnValue({ - update: jest.fn(() => ({ - eq: jest.fn().mockResolvedValue({ data: {}, error: null }), - })), - }); - - const result = await service.handleTranscriptionCompleted( - mockMemoId, - mockUserId, - undefined, - 'fast', - false, - error, - mockToken - ); - - expect(result).toEqual({ - success: false, - message: 'Transcription failed for memo memo-123: Transcription failed', - }); - - // Verify the update was called on the mock - const fromCall = mockSupabaseServiceClient.from.mock.calls[0]; - expect(fromCall[0]).toBe('memos'); - }); - }); - - describe('createMemoFromUploadedFile', () => { - it('should create memo from uploaded file successfully', async () => { - const filePath = 'uploads/audio.mp3'; - const duration = 300; - - mockSupabaseClient.from.mockReturnValueOnce({ - upsert: jest.fn().mockResolvedValue({ - data: null, - error: null, - }), - }); - - mockSupabaseClient.from.mockReturnValueOnce({ - select: jest.fn().mockReturnThis(), - eq: jest.fn().mockReturnThis(), - single: jest.fn().mockResolvedValue({ - data: { id: mockMemoId }, - error: null, - }), - }); - - const result = await service.createMemoFromUploadedFile( - mockUserId, - filePath, - duration, - mockSpaceId, - null, - undefined, - mockToken - ); - - expect(result).toEqual({ memoId: mockMemoId, audioPath: filePath }); - expect(mockSupabaseClient.from).toHaveBeenCalledWith('memos'); - expect(mockSupabaseClient.upsert).toHaveBeenCalledWith( - expect.objectContaining({ - user_id: mockUserId, - space_id: mockSpaceId, - duration_seconds: duration, - }), - { - onConflict: 'id', - ignoreDuplicates: false, - } - ); - }); - }); - - describe('Space Management Methods', () => { - describe('deleteMemoroSpace', () => { - it('should delete space and cleanup memo links', async () => { - spacesService.deleteSpace.mockResolvedValue({ success: true }); - spaceSyncService.removeSpaceMembership.mockResolvedValue(undefined); - - mockSupabaseServiceClient.from.mockReturnValue({ - delete: jest.fn().mockReturnThis(), - eq: jest.fn().mockResolvedValue({ data: {}, error: null }), - }); - - const result = await service.deleteMemoroSpace(mockUserId, mockSpaceId, mockToken); - - expect(result).toEqual({ success: true }); - expect(spacesService.deleteSpace).toHaveBeenCalledWith(mockUserId, mockSpaceId, mockToken); - // Note: removeSpaceMembership might not be called in all cases - }); - }); - - describe('inviteUserToSpace', () => { - it('should invite user to space successfully', async () => { - const email = 'invitee@example.com'; - const role = 'viewer'; - - spacesService.addSpaceMember.mockResolvedValue({ - inviteId: 'invite-123', - message: 'Invitation sent', - }); - - const result = await service.inviteUserToSpace( - mockUserId, - mockSpaceId, - email, - role, - mockToken - ); - - expect(result).toEqual({ inviteId: 'invite-123', message: 'Invitation sent' }); - expect(spacesService.addSpaceMember).toHaveBeenCalledWith( - mockSpaceId, - email, - role, - mockToken - ); - }); - }); - - describe('leaveSpace', () => { - it('should leave space successfully', async () => { - spacesService.leaveSpace.mockResolvedValue({ success: true }); - spaceSyncService.removeSpaceMembership.mockResolvedValue(undefined); - - const result = await service.leaveSpace(mockUserId, mockSpaceId, mockToken); - - expect(result).toEqual({ success: true }); - expect(spacesService.leaveSpace).toHaveBeenCalledWith(mockUserId, mockSpaceId, mockToken); - // Note: removeSpaceMembership might not be called in all cases - }); - }); - }); - - describe('safeSourceMerge', () => { - it('should merge updates into existing source', () => { - const existingSource = { - type: 'audio', - path: 'uploads/original.mp3', - format: 'mp3', - duration: 120, - }; - - const updates = { - primary_language: 'en', - languages: ['en', 'es'], - speakers: { '0': 'Speaker 1' }, - }; - - const result = service['safeSourceMerge'](existingSource, updates); - - expect(result).toEqual({ - type: 'audio', - path: 'uploads/original.mp3', - format: 'mp3', - duration: 120, - primary_language: 'en', - languages: ['en', 'es'], - speakers: { '0': 'Speaker 1' }, - }); - }); - - it('should handle empty existing source', () => { - const updates = { - type: 'audio', - path: 'uploads/new.mp3', - primary_language: 'en', - }; - - const result = service['safeSourceMerge'](null, updates); - - expect(result).toEqual({ - type: 'audio', - path: 'uploads/new.mp3', - primary_language: 'en', - }); - }); - - it('should flatten nested source properties', () => { - const existingSource = { - source: { - type: 'audio', - path: 'uploads/nested.mp3', - }, - duration: 180, - }; - - const updates = { - primary_language: 'fr', - }; - - const result = service['safeSourceMerge'](existingSource, updates); - - expect(result).toEqual({ - type: 'audio', - path: 'uploads/nested.mp3', - duration: 180, - primary_language: 'fr', - }); - expect(result.source).toBeUndefined(); - }); - - it('should fix invalid type property', () => { - const existingSource = { - type: { invalid: 'object' }, - path: 'uploads/file.mp3', - }; - - const updates = { - duration: 240, - }; - - const result = service['safeSourceMerge'](existingSource, updates); - - expect(result).toEqual({ - type: 'audio', - path: 'uploads/file.mp3', - duration: 240, - }); - }); - - it('should convert invalid path property to string', () => { - const existingSource = { - type: 'audio', - path: { invalid: 'object' }, - }; - - const updates = { - format: 'wav', - }; - - const result = service['safeSourceMerge'](existingSource, updates); - - expect(result).toEqual({ - type: 'audio', - path: '[object Object]', - format: 'wav', - }); - }); - - it('should preserve additional_recordings array', () => { - const existingSource = { - type: 'audio', - path: 'uploads/main.mp3', - additional_recordings: [{ path: 'uploads/additional1.mp3', status: 'completed' }], - }; - - const updates = { - additional_recordings: [ - { path: 'uploads/additional1.mp3', status: 'completed' }, - { path: 'uploads/additional2.mp3', status: 'processing' }, - ], - }; - - const result = service['safeSourceMerge'](existingSource, updates); - - expect(result).toEqual({ - type: 'audio', - path: 'uploads/main.mp3', - additional_recordings: [ - { path: 'uploads/additional1.mp3', status: 'completed' }, - { path: 'uploads/additional2.mp3', status: 'processing' }, - ], - }); - }); - - it('should handle complex nested structure with all properties', () => { - const existingSource = { - source: { - type: 'audio', - path: 'uploads/complex.mp3', - speakers: { '0': 'John' }, - }, - utterances: [{ speaker: '0', text: 'Hello' }], - additional_recordings: [], - }; - - const updates = { - primary_language: 'en', - languages: ['en'], - utterances: [ - { speaker: '0', text: 'Hello' }, - { speaker: '1', text: 'Hi there' }, - ], - speakers: { '0': 'John', '1': 'Jane' }, - }; - - const result = service['safeSourceMerge'](existingSource, updates); - - expect(result).toEqual({ - type: 'audio', - path: 'uploads/complex.mp3', - speakers: { '0': 'John', '1': 'Jane' }, - utterances: [ - { speaker: '0', text: 'Hello' }, - { speaker: '1', text: 'Hi there' }, - ], - additional_recordings: [], - primary_language: 'en', - languages: ['en'], - }); - }); - }); -}); diff --git a/apps/memoro/apps/backend/src/memoro/memoro.service.ts b/apps/memoro/apps/backend/src/memoro/memoro.service.ts deleted file mode 100644 index 6ee32c174..000000000 --- a/apps/memoro/apps/backend/src/memoro/memoro.service.ts +++ /dev/null @@ -1,3045 +0,0 @@ -import { - Injectable, - NotFoundException, - ForbiddenException, - BadRequestException, -} from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import { SpacesClientService } from '../spaces/spaces-client.service'; -import { SpaceSyncService } from '../spaces/space-sync.service'; -import { createClient, SupabaseClient } from '@supabase/supabase-js'; -import { - MemoroSpaceDto, - LinkMemoSpaceDto, - UnlinkMemoSpaceDto, -} from '../interfaces/memoro.interfaces'; -import { PendingInvitesResponseDto } from '../interfaces/spaces.interfaces'; -import { CreditConsumptionService } from '../credits/credit-consumption.service'; -import { calculateTranscriptionCost } from '../credits/pricing.constants'; -import { InsufficientCreditsException } from '../errors/insufficient-credits.error'; -import { HeadlineService } from '../ai/headline/headline.service'; -import { randomUUID } from 'crypto'; -const { v4: uuidv4 } = require('uuid'); - -@Injectable() -export class MemoroService { - private readonly MEMORO_APP_ID: string; - private memoroClient: SupabaseClient; - private memoroServiceClient: SupabaseClient; // Service role client for RLS bypass - private readonly memoroUrl: string; - private readonly memoroKey: string; - private readonly memoroServiceKey: string; - - constructor( - private configService: ConfigService, - private spacesService: SpacesClientService, - private spaceSyncService: SpaceSyncService, - private creditConsumptionService: CreditConsumptionService, - private headlineService: HeadlineService - ) { - this.MEMORO_APP_ID = this.configService.get( - 'MEMORO_APP_ID', - '973da0c1-b479-4dac-a1b0-ed09c72caca8' - ); - - // Initialize Memoro-specific clients - this.memoroUrl = this.configService.get('MEMORO_SUPABASE_URL'); - this.memoroKey = this.configService.get('MEMORO_SUPABASE_ANON_KEY'); - this.memoroServiceKey = this.configService.get('MEMORO_SUPABASE_SERVICE_KEY'); - - if (this.memoroUrl && this.memoroKey) { - console.log('Creating memoroClient with Memoro-specific credentials'); - this.memoroClient = createClient(this.memoroUrl, this.memoroKey); - - if (this.memoroServiceKey) { - console.log('Creating memoroServiceClient with service role credentials'); - this.memoroServiceClient = createClient(this.memoroUrl, this.memoroServiceKey); - } else { - console.warn('MEMORO_SUPABASE_SERVICE_KEY not provided, falling back to anon key'); - this.memoroServiceClient = this.memoroClient; - } - } else { - throw new Error('MEMORO_SUPABASE_URL or MEMORO_SUPABASE_ANON_KEY not provided'); - } - } - - // Getter methods for Supabase connection info (used for direct DB operations in emergency) - getSupabaseUrl(): string { - return this.memoroUrl; - } - - getSupabaseKey(): string { - return this.memoroKey; - } - - async getMemoroSpaces(userId: string, token: string): Promise { - try { - console.info('WE DONT GET SPACES YET, FUTURE IMPLEMENTATION'); - return []; - console.log(`[getMemoroSpaces] Starting request for userId: ${userId}`); - - // Get spaces accessible to this user using the SpacesService - console.log(`[getMemoroSpaces] Calling spacesService.getUserSpaces for userId: ${userId}`); - const spaces = await this.spacesService.getUserSpaces(userId, token); - - console.log(`[getMemoroSpaces] Successfully filtered spaces. Count: ${spaces?.length || 0}`); - if (spaces && spaces.length > 0) { - console.log('[getMemoroSpaces] First space sample:', JSON.stringify(spaces[0], null, 2)); - } - - // If we have spaces, get the memo counts for each - if (spaces && spaces.length > 0) { - const spaceIds = spaces.map((space) => space.id); - console.log( - `[getMemoroSpaces] Getting memo counts for spaceIds: ${JSON.stringify(spaceIds)}` - ); - - try { - // Get the Memoro-specific client with JWT authentication if available - let memoroClient; - if (token) { - console.log(`[getMemoroSpaces] Using authenticated Memoro client with JWT`); - memoroClient = this.getMemoroClientWithAuth(token); - } else { - console.log(`[getMemoroSpaces] Using unauthenticated Memoro client (no JWT provided)`); - memoroClient = this.memoroClient; - } - - // Check if the memo_spaces table exists first - console.log(`[getMemoroSpaces] Checking if memo_spaces table exists`); - const { error: tableCheckError } = await memoroClient - .from('memo_spaces') - .select('space_id') - .limit(1); - - if (tableCheckError && tableCheckError.code === '42P01') { - // Table doesn't exist, set all counts to 0 - console.log( - `[getMemoroSpaces] memo_spaces table doesn't exist, setting all counts to 0` - ); - spaces.forEach((space: MemoroSpaceDto) => { - space.memo_count = 0; - }); - } else { - // Table exists, try to get counts - // Set default counts to 0 for all spaces first - spaces.forEach((space: MemoroSpaceDto) => { - space.memo_count = 0; - }); - - // Try to get actual counts where available - for (const space of spaces) { - try { - const { count, error } = await memoroClient - .from('memo_spaces') - .select('*', { count: 'exact' }) - .eq('space_id', space.id); - - if (!error) { - space.memo_count = count || 0; - console.log(`[getMemoroSpaces] Space ${space.id} has ${space.memo_count} memos`); - } - } catch (countError) { - console.error( - `[getMemoroSpaces] Error counting memos for space ${space.id}:`, - countError - ); - // Count remains 0 as set above - } - } - } - } catch (error) { - console.error('[getMemoroSpaces] Exception in memo counts processing:', error); - // Set all counts to 0 if there was an error - spaces.forEach((space: MemoroSpaceDto) => { - space.memo_count = 0; - }); - } - } - - // Sanitize spaces data before returning to frontend - const sanitizedSpaces = this.sanitizeSpacesForFrontend(spaces || [], userId); - console.log(`[getMemoroSpaces] Returning ${sanitizedSpaces.length} sanitized spaces`); - - return sanitizedSpaces; - } catch (error) { - console.error('Unexpected error in getMemoroSpaces:', error); - const errorMessage = - error.message || (typeof error === 'object' ? JSON.stringify(error) : String(error)); - throw new Error(`Failed to get Memoro spaces: ${errorMessage}`); - } - } - - /** - * Sanitizes space data for frontend consumption by removing sensitive information - * @param spaces Array of space objects to sanitize - * @returns Array of sanitized space objects - */ - private sanitizeSpacesForFrontend(spaces: MemoroSpaceDto[], userId: string): MemoroSpaceDto[] { - return spaces.map((space) => { - // Check if the user is the owner - const isOwner = space.owner_id === userId || space.roles?.members?.[userId]?.role === 'owner'; - - // Only keep essential properties that the frontend needs - return { - id: space.id, - name: space.name, - owner_id: space.owner_id, - memo_count: space.memo_count || 0, - // Only include minimal role information if needed - roles: space.roles - ? { - members: space.roles.members ? Object.keys(space.roles.members) : [], - } - : { members: [] }, - created_at: space.created_at, - updated_at: space.updated_at, - isOwner, // Add the isOwner flag - } as MemoroSpaceDto; - }); - } - - async createMemoroSpace(userId: string, spaceName: string, token: string) { - try { - // Create the space in the middleware first - const space = await this.spacesService.createSpace(userId, spaceName, token); - - // Sync the owner to the space_members table for RLS access control - try { - await this.spaceSyncService.syncSpaceMembership( - space.id, - userId, - 'owner' // Owner role - ); - console.log(`Successfully synced owner ${userId} for new space ${space.id}`); - } catch (syncError) { - // Log but don't fail if sync fails - console.error(`Failed to sync space owner: ${syncError.message}`); - } - - return space; - } catch (error) { - console.error('Error creating Memoro space:', error); - throw new Error(`Failed to create Memoro space: ${error.message}`); - } - } - - async getMemoroSpaceDetails(userId: string, spaceId: string, token: string) { - try { - // Try to get the space details directly first - try { - // Get full space details using the spaces service - const spaceDetails = await this.spacesService.getSpaceDetails(spaceId, token); - return spaceDetails; - } catch (detailsError) { - // If this fails, log the error and try verification as a fallback - console.log( - `Initial space details fetch failed: ${detailsError.message}. Trying access verification...` - ); - - // Verify user has access to this Memoro space through the Spaces service - await this.verifyMemoroSpaceAccess(userId, spaceId, token); - - // If verification succeeds, try getting details again - return await this.spacesService.getSpaceDetails(spaceId, token); - } - } catch (error) { - console.error('Error fetching Memoro space details:', error); - - if ( - error instanceof NotFoundException || - error instanceof ForbiddenException || - error instanceof BadRequestException - ) { - throw error; - } - - throw new Error(`Failed to get Memoro space details: ${error.message}`); - } - } - - /** - * Gets invites for a space - * @param spaceId Space ID to get invites for - * @param token JWT token for authorization - */ - async getSpaceInvites(spaceId: string, token: string) { - try { - console.log(`[getSpaceInvites] Getting invites for space ${spaceId}`); - // Proxy the request to the spaces service - const invitesResult = await this.spacesService.getSpaceInvites(spaceId, token); - console.log(`[getSpaceInvites] Successfully retrieved invites for space ${spaceId}`); - return invitesResult; - } catch (error) { - console.error(`[getSpaceInvites] Error getting invites for space ${spaceId}:`, error); - - if ( - error instanceof NotFoundException || - error instanceof ForbiddenException || - error instanceof BadRequestException - ) { - throw error; - } - - throw new Error(`Failed to get invites for space ${spaceId}: ${error.message}`); - } - } - - /** - * Invites a user to a space by email - * @param userId ID of the user sending the invitation - * @param spaceId Space ID to invite to - * @param email Email of the user to invite - * @param role Role to assign (owner, admin, editor, viewer) - * @param token JWT token for authorization - * @returns Result of the invitation operation - */ - async inviteUserToSpace( - userId: string, - spaceId: string, - email: string, - role: string, - token: string - ) { - try { - console.log( - `[inviteUserToSpace] User ${userId} inviting ${email} to space ${spaceId} with role ${role}` - ); - - // Validate input - if (!spaceId || !email || !role) { - throw new BadRequestException('Space ID, email, and role are required'); - } - - // Validate the role - const validRoles = ['owner', 'admin', 'editor', 'viewer']; - if (!validRoles.includes(role)) { - throw new BadRequestException(`Invalid role. Must be one of: ${validRoles.join(', ')}`); - } - - // Verify that the user has access to this space and is an owner/admin - try { - // First verify the user has access to the space - await this.verifyMemoroSpaceAccess(userId, spaceId, token); - - // Now proxy the invite request to the spaces service - const result = await this.spacesService.addSpaceMember(spaceId, email, role, token); - console.log(`[inviteUserToSpace] Successfully invited ${email} to space ${spaceId}`); - - // If the user already exists (has an ID), sync them to the space_members table - if (result.invitee_id) { - try { - await this.spaceSyncService.syncSpaceMembership( - spaceId, - result.invitee_id, - role, - userId // invited by current user - ); - console.log( - `[inviteUserToSpace] Synced space member ${result.invitee_id} to space ${spaceId}` - ); - } catch (syncError) { - // Log but don't fail if sync fails - console.error(`[inviteUserToSpace] Failed to sync space member: ${syncError.message}`); - } - } - - return result; - } catch (error) { - console.error(`[inviteUserToSpace] Error verifying access or sending invite:`, error); - throw error; - } - } catch (error) { - console.error(`[inviteUserToSpace] Error inviting user to space ${spaceId}:`, error); - - if ( - error instanceof NotFoundException || - error instanceof ForbiddenException || - error instanceof BadRequestException - ) { - throw error; - } - - throw new Error(`Failed to invite user to space: ${error.message}`); - } - } - - /** - * Resends an invitation to join a space - * @param userId ID of the user performing the action - * @param inviteId ID of the invitation to resend - * @param token JWT token for authorization - * @returns Success response - */ - async resendSpaceInvite(userId: string, inviteId: string, token: string) { - try { - console.log(`[resendSpaceInvite] User ${userId} resending invite ${inviteId}`); - - if (!inviteId) { - throw new BadRequestException('Invite ID is required'); - } - - // Proxy the resend request to the spaces service - const result = await this.spacesService.resendInvite(inviteId, token); - console.log(`[resendSpaceInvite] Successfully resent invite ${inviteId}`); - return result; - } catch (error) { - console.error(`[resendSpaceInvite] Error resending invite ${inviteId}:`, error); - - if ( - error instanceof NotFoundException || - error instanceof ForbiddenException || - error instanceof BadRequestException - ) { - throw error; - } - - throw new Error(`Failed to resend invitation: ${error.message}`); - } - } - - /** - * Cancels an invitation to join a space - * @param userId ID of the user performing the action - * @param inviteId ID of the invitation to cancel - * @param token JWT token for authorization - * @returns Success response - */ - async cancelSpaceInvite(userId: string, inviteId: string, token: string) { - try { - console.log(`[cancelSpaceInvite] User ${userId} canceling invite ${inviteId}`); - - if (!inviteId) { - throw new BadRequestException('Invite ID is required'); - } - - // Proxy the cancel request to the spaces service - const result = await this.spacesService.cancelInvite(inviteId, token); - console.log(`[cancelSpaceInvite] Successfully canceled invite ${inviteId}`); - return result; - } catch (error) { - console.error(`[cancelSpaceInvite] Error canceling invite ${inviteId}:`, error); - - if ( - error instanceof NotFoundException || - error instanceof ForbiddenException || - error instanceof BadRequestException - ) { - throw error; - } - - throw new Error(`Failed to cancel invitation: ${error.message}`); - } - } - - /** - * Verify if a user has access to a Memoro space - */ - private async verifyMemoroSpaceAccess(userId: string, spaceId: string, token: string) { - return this.spacesService.verifySpaceAccess(userId, spaceId, token); - } - - // The sanitizeSpacesForFrontend method is now updated with isOwner flag above - - /** - * Verify if a memo exists and the user has access to it - */ - private async verifyMemoAccess(userId: string, memoId: string, token?: string) { - console.log(`[verifyMemoAccess] Verifying access to memo ${memoId} for user ${userId}`); - - // Use the Memoro-specific client with JWT if available - const client = token ? this.getMemoroClientWithAuth(token) : this.memoroClient; - - try { - // Check if the memo exists and belongs to the user - console.log(`[verifyMemoAccess] Querying Memoro database for memo ${memoId}`); - const { data: memo, error } = await client - .from('memos') - .select('user_id') - .eq('id', memoId) - .single(); - - if (error) { - console.error(`[verifyMemoAccess] Database error:`, error); - throw new NotFoundException(`Memo not found: ${error.message}`); - } - - if (!memo) { - console.error(`[verifyMemoAccess] Memo ${memoId} not found in database`); - throw new NotFoundException('Memo not found: no data returned'); - } - - console.log(`[verifyMemoAccess] Memo found, belongs to user: ${memo.user_id}`); - - // In this implementation, we're assuming that the user can only link their own memos - if (memo.user_id !== userId) { - console.error( - `[verifyMemoAccess] User ${userId} does not have permission to access memo owned by ${memo.user_id}` - ); - throw new ForbiddenException('You do not have permission to access this memo'); - } - - console.log(`[verifyMemoAccess] Access verified for memo ${memoId}`); - return true; - } catch (error) { - if (error instanceof NotFoundException || error instanceof ForbiddenException) { - throw error; - } - console.error(`[verifyMemoAccess] Unexpected error:`, error); - throw new NotFoundException(`Memo access verification failed: ${error.message}`); - } - } - - /** - * Create a client with JWT authentication - * @param jwt The JWT token for authentication - * @param useServiceRole Whether to use service role client (bypasses RLS) - * @returns A Supabase client with appropriate authentication - */ - private getMemoroClientWithAuth(jwt: string, useServiceRole: boolean = false): SupabaseClient { - // If we need to bypass RLS and we have a service role client, return it - if (useServiceRole && this.memoroServiceClient) { - console.log('Using service role client to bypass RLS'); - return this.memoroServiceClient; - } - - console.log('Creating authenticated Memoro client with JWT'); - - // Get the Memoro Supabase URL and key - const memoroUrl = this.configService.get('MEMORO_SUPABASE_URL'); - const memoroKey = this.configService.get('MEMORO_SUPABASE_ANON_KEY'); - - if (!memoroUrl || !memoroKey) { - throw new Error('MEMORO_SUPABASE_URL or MEMORO_SUPABASE_ANON_KEY not provided'); - } - - // Create a new client with the JWT token to avoid modifying the shared client - return createClient(memoroUrl, memoroKey, { - global: { - headers: { - Authorization: `Bearer ${jwt}`, - }, - }, - }); - } - - /** - * Link a memo to a space - */ - async linkMemoToSpace(userId: string, linkMemoSpaceDto: LinkMemoSpaceDto, token?: string) { - try { - const { memoId, spaceId } = linkMemoSpaceDto; - - if (!memoId || !spaceId) { - throw new BadRequestException('Memo ID and Space ID are required'); - } - - console.log( - `[linkMemoToSpace] Attempting to link memo ${memoId} to space ${spaceId} for user ${userId}` - ); - - // Verify the user has access to both the memo and the space - await this.verifyMemoAccess(userId, memoId, token); - await this.verifyMemoroSpaceAccess(userId, spaceId, token); - - // Get the Memoro-specific client with JWT authentication if available - const memoroClient = token ? this.getMemoroClientWithAuth(token) : this.memoroClient; - - // Check if the link already exists - console.log(`[linkMemoToSpace] Checking if link already exists`); - const { data: existingLink, error: checkError } = await memoroClient - .from('memo_spaces') - .select('*') - .eq('memo_id', memoId) - .eq('space_id', spaceId) - .maybeSingle(); - - if (checkError) { - console.error(`[linkMemoToSpace] Error checking for existing link:`, checkError); - } - - if (existingLink) { - console.log(`[linkMemoToSpace] Link already exists`); - // Link already exists, no need to create it again - return { success: true, message: 'Memo is already linked to this space' }; - } - - // Create the link - console.log( - `[linkMemoToSpace] Creating new link between memo ${memoId} and space ${spaceId}` - ); - const { error } = await memoroClient.from('memo_spaces').insert({ - memo_id: memoId, - space_id: spaceId, - created_at: new Date(), - }); - - if (error) { - console.error(`[linkMemoToSpace] Error creating link:`, error); - throw new Error(`Failed to link memo to space: ${error.message}`); - } - - console.log(`[linkMemoToSpace] Successfully linked memo ${memoId} to space ${spaceId}`); - return { success: true, message: 'Memo linked to space successfully' }; - } catch (error) { - console.error('Error linking memo to space:', error); - - if ( - error instanceof NotFoundException || - error instanceof ForbiddenException || - error instanceof BadRequestException - ) { - throw error; - } - - throw new Error(`Failed to link memo to space: ${error.message}`); - } - } - - /** - * Unlink a memo from a space - */ - async unlinkMemoFromSpace( - userId: string, - unlinkMemoSpaceDto: UnlinkMemoSpaceDto, - token?: string - ) { - try { - const { memoId, spaceId } = unlinkMemoSpaceDto; - - if (!memoId || !spaceId) { - throw new BadRequestException('Memo ID and Space ID are required'); - } - - console.log( - `[unlinkMemoFromSpace] Attempting to unlink memo ${memoId} from space ${spaceId} for user ${userId}` - ); - - // Verify the user has access to both the memo and the space - await this.verifyMemoAccess(userId, memoId, token); - await this.verifyMemoroSpaceAccess(userId, spaceId, token); - - // Get the Memoro-specific client with JWT authentication if available - const memoroClient = token ? this.getMemoroClientWithAuth(token) : this.memoroClient; - - // Delete the link - console.log( - `[unlinkMemoFromSpace] Deleting link between memo ${memoId} and space ${spaceId}` - ); - const { error } = await memoroClient - .from('memo_spaces') - .delete() - .eq('memo_id', memoId) - .eq('space_id', spaceId); - - if (error) { - console.error(`[unlinkMemoFromSpace] Error deleting link:`, error); - throw new Error(`Failed to unlink memo from space: ${error.message}`); - } - - console.log( - `[unlinkMemoFromSpace] Successfully unlinked memo ${memoId} from space ${spaceId}` - ); - return { success: true, message: 'Memo unlinked from space successfully' }; - } catch (error) { - console.error('Error unlinking memo from space:', error); - - if ( - error instanceof NotFoundException || - error instanceof ForbiddenException || - error instanceof BadRequestException - ) { - throw error; - } - - throw new Error(`Failed to unlink memo from space: ${error.message}`); - } - } - - /** - * Get all memos for a specific space - */ - async getSpaceMemos(userId: string, spaceId: string, token?: string) { - try { - if (!spaceId) { - throw new BadRequestException('Space ID is required'); - } - - // Try to verify the user has access to the space, but don't fail if the space doesn't exist in core service - try { - await this.verifyMemoroSpaceAccess(userId, spaceId, token); - } catch (verifyError) { - console.warn(`Space access verification error: ${verifyError.message}`); - // If we can't verify access, but we have a record in our Supabase database, continue anyway - // This helps with cases where spaces might exist in Memoro but not fully synced with mana-core - } - - // Use the service role client after verifying authorization to bypass RLS - // This ensures we can see all memos in the space regardless of who created them - const memoroClient = token - ? this.getMemoroClientWithAuth(token, true) - : this.memoroServiceClient; - - // Get all memos linked to this space - const { data: memoSpaces, error: joinError } = await memoroClient - .from('memo_spaces') - .select('memo_id') - .eq('space_id', spaceId); - - if (joinError) { - throw new Error(`Failed to get memo-space relationships: ${joinError.message}`); - } - - if (!memoSpaces || memoSpaces.length === 0) { - return { memos: [] }; - } - - // Extract memo IDs - const memoIds = memoSpaces.map((ms) => ms.memo_id); - - // Get the memo details - const { data: memos, error: memosError } = await memoroClient - .from('memos') - .select( - ` - id, - title, - user_id, - source, - style, - is_pinned, - is_archived, - is_public, - metadata, - created_at, - updated_at - ` - ) - .in('id', memoIds); - - if (memosError) { - throw new Error(`Failed to get memos: ${memosError.message}`); - } - - return { memos: memos || [] }; - } catch (error) { - console.error('Error getting space memos:', error); - - if ( - error instanceof NotFoundException || - error instanceof ForbiddenException || - error instanceof BadRequestException - ) { - throw error; - } - - throw new Error(`Failed to get space memos: ${error.message}`); - } - } - - /** - * Deletes a memoro space and cleans up associated memo connections - */ - async deleteMemoroSpace(userId: string, spaceId: string, token: string) { - try { - // First, clean up all memo_spaces entries for this spaceId - console.log(`Cleaning up memo_spaces entries for space ${spaceId}`); - - // Use the Memoro-specific client with JWT if available - const client = token ? this.getMemoroClientWithAuth(token) : this.memoroClient; - - // Delete all memo_spaces entries for this space ID - const { error: deleteError } = await client - .from('memo_spaces') - .delete() - .eq('space_id', spaceId); - - if (deleteError) { - console.error(`Error cleaning up memo_spaces for space ${spaceId}:`, deleteError); - // Continue with space deletion even if cleanup fails - } else { - console.log(`Successfully cleaned up memo_spaces for space ${spaceId}`); - } - - // Now call the spaces service to delete the space - const response = await this.spacesService.deleteSpace(userId, spaceId, token); - - // Return the response from the spaces service - return response; - } catch (error) { - console.error(`Error in deleteMemoroSpace:`, error); - // Rethrow the error to be handled by the controller - throw error; - } - } - - /** - * Allows a non-owner to leave a space - */ - async leaveSpace(userId: string, spaceId: string, token: string) { - try { - // First, clean up any memo_spaces entries created by this user for this spaceId - console.log(`Cleaning up user's memo_spaces entries for space ${spaceId}`); - - // Use the Memoro-specific client with JWT if available - const client = token ? this.getMemoroClientWithAuth(token) : this.memoroClient; - - // First get the user's memos - const { data: userMemos, error: memosError } = await client - .from('memos') - .select('id') - .eq('user_id', userId); - - if (memosError) { - console.error(`Error fetching user memos:`, memosError); - } else if (userMemos && userMemos.length > 0) { - // Get the IDs of user's memos - const memoIds = userMemos.map((memo) => memo.id); - - // Delete any memo_spaces links for this user's memos in this space - const { error: deleteError } = await client - .from('memo_spaces') - .delete() - .eq('space_id', spaceId) - .in('memo_id', memoIds); - - if (deleteError) { - console.error(`Error cleaning up user's memo_spaces:`, deleteError); - } else { - console.log(`Successfully cleaned up user's memo connections for space ${spaceId}`); - } - } - - // Now call the spaces service to remove the user from the space - const result = await this.spacesService.leaveSpace(userId, spaceId, token); - - // After successfully leaving the space, remove the user from the space_members table - try { - await this.spaceSyncService.removeSpaceMembership(spaceId, userId); - console.log(`Successfully removed user ${userId} from space_members for space ${spaceId}`); - } catch (syncError) { - // Log but don't fail if sync fails - console.error(`Failed to remove user from space_members: ${syncError.message}`); - } - - return result; - } catch (error) { - console.error(`Error in leaveSpace:`, error); - // Rethrow the error to be handled by the controller - throw error; - } - } - - /** - * Gets all pending invites for the user - * @param userId ID of the user - * @param token JWT token for authorization - * @returns Object containing pending invites - */ - async getUserPendingInvites(userId: string, token: string): Promise { - try { - console.log(`[getUserPendingInvites] Getting pending invites for user ${userId}`); - - // Get all pending invites from spaces service - const invitesResult = await this.spacesService.getUserPendingInvites(token); - - console.log('invitesResult: ', invitesResult); - console.log( - `[getUserPendingInvites] Successfully retrieved ${invitesResult?.invites?.length || 0} pending invites for user ${userId}` - ); - return invitesResult; - } catch (error) { - console.error( - `[getUserPendingInvites] Error getting pending invites for user ${userId}:`, - error - ); - - if (error instanceof NotFoundException) { - // Return empty invites array instead of throwing an error if not found - return { invites: [] }; - } else if (error instanceof ForbiddenException || error instanceof BadRequestException) { - throw error; - } else { - // For any other errors, return empty array - console.error(`[getUserPendingInvites] Error fetching pending invites: ${error.message}`); - return { invites: [] }; - } - } - } - - /** - * Accepts a space invitation - * @param userId ID of the user accepting the invitation - * @param inviteId ID of the invitation to accept - * @param token JWT token for authorization - * @returns Success response - */ - async acceptSpaceInvite(userId: string, inviteId: string, token: string): Promise { - try { - console.log(`[acceptSpaceInvite] User ${userId} accepting invite ${inviteId}`); - - if (!inviteId) { - throw new BadRequestException('Invite ID is required'); - } - - // Call the spaces service to accept the invitation - // Pass the userId explicitly since auth.uid() won't work with JWTs - const response = await fetch( - `${this.spacesService['spacesServiceUrl']}/spaces/invites/accept`, - { - method: 'POST', - headers: { - Authorization: `Bearer ${token}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - inviteId, - userId, // Add the userId explicitly - }), - } - ); - - if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - const errorMessage = - errorData.message || - errorData.error || - `Error ${response.status}: ${response.statusText}`; - - if (response.status === 404) { - throw new NotFoundException(`Invitation not found: ${errorMessage}`); - } else if (response.status === 403) { - throw new ForbiddenException(`Not authorized to accept this invitation: ${errorMessage}`); - } else { - throw new BadRequestException(`Failed to accept invitation: ${errorMessage}`); - } - } - - const data = await response.json(); - console.log(`[acceptSpaceInvite] Successfully accepted invite ${inviteId}`); - - // After successfully accepting the invite, sync the user to the space_members table - if (data?.space?.id && data?.role) { - try { - await this.spaceSyncService.syncSpaceMembership(data.space.id, userId, data.role); - console.log( - `[acceptSpaceInvite] Synced user ${userId} as ${data.role} to space ${data.space.id}` - ); - } catch (syncError) { - // Log but don't fail if sync fails - console.error(`[acceptSpaceInvite] Failed to sync space member: ${syncError.message}`); - } - } - - return data; - } catch (error) { - console.error(`[acceptSpaceInvite] Error accepting invite ${inviteId}:`, error); - - if ( - error instanceof NotFoundException || - error instanceof ForbiddenException || - error instanceof BadRequestException - ) { - throw error; - } - - throw new Error(`Failed to accept invitation: ${error.message}`); - } - } - - /** - * Declines a space invitation - * @param userId ID of the user declining the invitation - * @param inviteId ID of the invitation to decline - * @param token JWT token for authorization - * @returns Success response - */ - async declineSpaceInvite(userId: string, inviteId: string, token: string): Promise { - try { - console.log(`[declineSpaceInvite] User ${userId} declining invite ${inviteId}`); - - if (!inviteId) { - throw new BadRequestException('Invite ID is required'); - } - - // Call the spaces service to decline the invitation - // Pass the userId explicitly since auth.uid() won't work with JWTs - const response = await fetch( - `${this.spacesService['spacesServiceUrl']}/spaces/invites/decline`, - { - method: 'POST', - headers: { - Authorization: `Bearer ${token}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - inviteId, - userId, // Add the userId explicitly - }), - } - ); - - if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - const errorMessage = - errorData.message || - errorData.error || - `Error ${response.status}: ${response.statusText}`; - - if (response.status === 404) { - throw new NotFoundException(`Invitation not found: ${errorMessage}`); - } else if (response.status === 403) { - throw new ForbiddenException( - `Not authorized to decline this invitation: ${errorMessage}` - ); - } else { - throw new BadRequestException(`Failed to decline invitation: ${errorMessage}`); - } - } - - const data = await response.json(); - console.log(`[declineSpaceInvite] Successfully declined invite ${inviteId}`); - return data; - } catch (error) { - console.error(`[declineSpaceInvite] Error declining invite ${inviteId}:`, error); - - if ( - error instanceof NotFoundException || - error instanceof ForbiddenException || - error instanceof BadRequestException - ) { - throw error; - } - - throw new Error(`Failed to decline invitation: ${error.message}`); - } - } - - /** - * Validates a memo for retry operations - * @param userId - User ID making the request - * @param memoId - Memo ID to validate - * @param token - Authentication token - * @returns Memo data if valid, null otherwise - */ - async validateMemoForRetry(userId: string, memoId: string, token: string): Promise { - try { - console.log(`[validateMemoForRetry] Validating memo ${memoId} for user ${userId}`); - - // Create authenticated client - const authClient = createClient(this.memoroUrl, this.memoroKey, { - global: { headers: { Authorization: `Bearer ${token}` } }, - }); - - // Get memo and verify ownership - const { data: memo, error } = await authClient - .from('memos') - .select('id, user_id, metadata, source, title') - .eq('id', memoId) - .eq('user_id', userId) - .single(); - - if (error) { - console.error(`[validateMemoForRetry] Error fetching memo ${memoId}:`, error); - return null; - } - - if (!memo) { - console.warn( - `[validateMemoForRetry] Memo ${memoId} not found or access denied for user ${userId}` - ); - return null; - } - - console.log(`[validateMemoForRetry] Memo ${memoId} validated for user ${userId}`); - return memo; - } catch (error) { - console.error(`[validateMemoForRetry] Error validating memo ${memoId}:`, error); - return null; - } - } - - /** - * Retries transcription for a failed memo - * @param userId - User ID making the request - * @param memoId - Memo ID to retry - * @param token - Authentication token - * @param retryAttempt - Current retry attempt number - */ - async retryTranscription( - userId: string, - memoId: string, - token: string, - retryAttempt: number - ): Promise { - try { - console.log( - `[retryTranscription] Retrying transcription for memo ${memoId}, attempt ${retryAttempt}` - ); - - // Get memo to extract audio path and space ID - const memo = await this.validateMemoForRetry(userId, memoId, token); - if (!memo) { - throw new NotFoundException('Memo not found'); - } - - const audioPath = memo.source?.audio_path || memo.source?.path; - const spaceId = memo.metadata?.spaceId; // If memo was associated with space - - if (!audioPath) { - throw new BadRequestException('No audio path found in memo'); - } - - // Update retry attempt in metadata first - const authClient = createClient(this.memoroUrl, this.memoroKey, { - global: { headers: { Authorization: `Bearer ${token}` } }, - }); - - const updatedMetadata = { - ...memo.metadata, - processing: { - ...memo.metadata?.processing, - transcription: { - ...memo.metadata?.processing?.transcription, - status: 'processing', - retryAttempts: retryAttempt, - lastRetryAt: new Date().toISOString(), - }, - }, - }; - - await authClient.from('memos').update({ metadata: updatedMetadata }).eq('id', memoId); - - // Call transcribe Edge Function (normal processing, will charge credits if successful) - const SUPABASE_URL = this.memoroUrl; - const response = await fetch(`${SUPABASE_URL}/functions/v1/transcribe`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${token}`, - }, - body: JSON.stringify({ - audioPath, - memoId, - spaceId, - }), - }); - - if (!response.ok) { - const errorText = await response.text(); - console.error(`[retryTranscription] Edge Function error:`, errorText); - throw new BadRequestException(`Transcription retry failed: ${errorText}`); - } - - console.log(`[retryTranscription] Successfully initiated retry for memo ${memoId}`); - return { success: true }; - } catch (error) { - console.error(`[retryTranscription] Error retrying transcription for memo ${memoId}:`, error); - throw error; - } - } - - /** - * Retries headline generation for a failed memo - * @param userId - User ID making the request - * @param memoId - Memo ID to retry - * @param token - Authentication token - * @param retryAttempt - Current retry attempt number - */ - async retryHeadline( - userId: string, - memoId: string, - token: string, - retryAttempt: number - ): Promise { - try { - console.log( - `[retryHeadline] Retrying headline generation for memo ${memoId}, attempt ${retryAttempt}` - ); - - // Validate memo ownership - const memo = await this.validateMemoForRetry(userId, memoId, token); - if (!memo) { - throw new NotFoundException('Memo not found'); - } - - // Check if memo has transcript (now in separate column) - if (!memo.transcript && !memo.source?.transcript && !memo.source?.transcription) { - throw new BadRequestException('No transcript found in memo for headline generation'); - } - - // Update retry attempt in metadata first - const authClient = createClient(this.memoroUrl, this.memoroKey, { - global: { headers: { Authorization: `Bearer ${token}` } }, - }); - - const updatedMetadata = { - ...memo.metadata, - processing: { - ...memo.metadata?.processing, - headline_and_intro: { - ...memo.metadata?.processing?.headline_and_intro, - status: 'processing', - retryAttempts: retryAttempt, - lastRetryAt: new Date().toISOString(), - }, - }, - }; - - await authClient.from('memos').update({ metadata: updatedMetadata }).eq('id', memoId); - - // Generate headline via internal AI service (replaces Edge Function call) - const result = await this.headlineService.processHeadlineForMemo(memoId); - - console.log( - `[retryHeadline] Successfully generated headline for memo ${memoId}: "${result.headline}"` - ); - return { success: true }; - } catch (error) { - console.error( - `[retryHeadline] Error retrying headline generation for memo ${memoId}:`, - error - ); - throw error; - } - } - - /** - * Upload audio file to storage and create memo (without processing) - * This method only handles file upload and memo creation for the new upload flow. - * - * @param userId - User ID - * @param file - Audio file from multer - * @param duration - Audio duration in seconds - * @param spaceId - Optional space ID to associate with memo - * @param blueprintId - Optional blueprint ID - * @param memoId - Optional existing memo ID to update - * @param token - Authentication token - * @returns Object with memo ID and audio path - */ - async uploadAudioToStorage( - userId: string, - file: Express.Multer.File, - duration: number, - spaceId?: string, - blueprintId?: string | null, - memoId?: string, - token?: string - ): Promise<{ - memoId: string; - audioPath: string; - }> { - try { - console.log( - `[uploadAudioToStorage] Uploading audio for user ${userId}, duration: ${duration}s, filename: ${file.originalname}` - ); - - // Create authenticated client - const authClient = createClient(this.memoroUrl, this.memoroKey, { - global: { headers: { Authorization: `Bearer ${token}` } }, - }); - - // Upload the audio file to Supabase Storage - console.log(`[uploadAudioToStorage] Uploading audio file to storage...`); - - // Create unique file path with memoId folder structure - const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); - const fileExtension = - file.originalname?.split('.').pop() || file.mimetype?.split('/')[1] || 'm4a'; - const uniqueFilename = `audio_${timestamp}.${fileExtension}`; - - // Generate a memo ID for the path if not provided - const pathMemoId = memoId || randomUUID(); - const audioPath = `${userId}/${pathMemoId}/${uniqueFilename}`; - - try { - // Upload to Supabase Storage using the file buffer - console.log(`[uploadAudioToStorage] Uploading to bucket: user-uploads, path: ${audioPath}`); - console.log(`[uploadAudioToStorage] File buffer size: ${file.buffer.length} bytes`); - console.log( - `[uploadAudioToStorage] Content type: ${file.mimetype || `audio/${fileExtension}`}` - ); - - // Test bucket access first - const { data: buckets, error: listError } = await authClient.storage.listBuckets(); - console.log( - `[uploadAudioToStorage] Available buckets:`, - buckets?.map((b) => b.name) || 'none' - ); - if (listError) console.log(`[uploadAudioToStorage] Bucket list error:`, listError); - - const { data: uploadData, error: uploadError } = await authClient.storage - .from('user-uploads') - .upload(audioPath, file.buffer, { - contentType: file.mimetype || `audio/${fileExtension}`, - cacheControl: '3600', - upsert: false, - }); - - if (uploadError) { - console.error(`[uploadAudioToStorage] Upload error details:`, uploadError); - throw new Error(`Upload failed: ${uploadError.message}`); - } - - console.log(`[uploadAudioToStorage] File uploaded successfully: ${uploadData.path}`); - console.log(`[uploadAudioToStorage] Upload response:`, uploadData); - } catch (uploadError) { - console.error(`[uploadAudioToStorage] Upload failed:`, uploadError); - throw new BadRequestException(`Failed to upload audio file: ${uploadError.message}`); - } - - // Create or update memo - const currentTimestamp = new Date().toISOString(); - - const sourceData = { - type: 'audio', - audio_path: audioPath, - format: fileExtension, - duration: duration, - original_filename: file.originalname, - }; - - const metadata = { - processing: { - transcription: { - status: 'pending', - timestamp: currentTimestamp, - }, - }, - blueprint_id: blueprintId || null, - spaceId: spaceId || null, - }; - - let finalMemoId: string; - - if (memoId) { - // Update existing memo - console.log(`[uploadAudioToStorage] Updating existing memo ${memoId}...`); - const { error: updateError } = await authClient - .from('memos') - .update({ - source: sourceData, - updated_at: currentTimestamp, - metadata, - }) - .eq('id', memoId) - .eq('user_id', userId); - - if (updateError) { - throw new Error(`Failed to update memo: ${updateError.message}`); - } - - finalMemoId = memoId; - console.log(`[uploadAudioToStorage] Updated memo with ID: ${memoId}`); - } else { - // Create new memo with pre-generated ID - console.log(`[uploadAudioToStorage] Creating new memo with ID: ${pathMemoId}...`); - const { error: createError } = await authClient.from('memos').insert({ - id: pathMemoId, - user_id: userId, - source: sourceData, - is_pinned: false, - is_archived: false, - is_public: false, - created_at: currentTimestamp, - updated_at: currentTimestamp, - metadata, - }); - - if (createError) { - throw new Error(`Failed to create memo: ${createError.message}`); - } - - finalMemoId = pathMemoId; - console.log(`[uploadAudioToStorage] Created memo with ID: ${finalMemoId}`); - } - - // Link memo to space if spaceId provided - if (spaceId && !memoId) { - // Only link if it's a new memo - try { - console.log(`[uploadAudioToStorage] Linking memo ${finalMemoId} to space ${spaceId}`); - const { error: linkError } = await authClient.from('memo_spaces').insert({ - memo_id: finalMemoId, - space_id: spaceId, - created_at: currentTimestamp, - }); - - if (linkError) { - console.error( - `[uploadAudioToStorage] Failed to link memo to space: ${linkError.message}` - ); - // Don't fail the entire process for space linking errors - } - } catch (linkError) { - console.error(`[uploadAudioToStorage] Error linking memo to space:`, linkError); - } - } - - return { - memoId: finalMemoId, - audioPath, - }; - } catch (error) { - console.error(`[uploadAudioToStorage] Error uploading audio to storage:`, error); - if (error instanceof BadRequestException) { - throw error; - } - throw new BadRequestException(`Failed to upload audio: ${error.message}`); - } - } - - /** - * Enhanced routing constants - */ - private readonly FAST_TIME_LIMIT = 115 * 60; // 115 minutes in seconds - private readonly FAST_SIZE_LIMIT = 300 * 1024 * 1024; // 300MB in bytes - private readonly COST_PER_MINUTE = 2; // 2 mana per minute - - /** - * Determines transcription route and validates credits - */ - private async determineTranscriptionRoute( - duration: number, - fileSize: number, - userId: string, - spaceId?: string, - token?: string - ): Promise<{ route: 'fast' | 'batch'; cost: number }> { - // Calculate cost upfront (round up to nearest minute) - const estimatedCost = Math.ceil(duration / 60) * this.COST_PER_MINUTE; - - console.log( - `[determineTranscriptionRoute] Duration: ${duration}s (${Math.ceil(duration / 60)}min), Size: ${Math.round(fileSize / 1024 / 1024)}MB, Cost: ${estimatedCost} mana` - ); - - // Pre-validate credits before any processing - try { - const creditValidationUrl = this.configService.get('MANA_SERVICE_URL'); - - if (!creditValidationUrl) { - console.error('[CRITICAL ERROR] MANA_SERVICE_URL is not configured'); - throw new Error('Missing required configuration: MANA_SERVICE_URL'); - } - - const creditCheckBody = { - userId, - amount: estimatedCost, - spaceId: spaceId || null, - operation: 'transcription', - durationMinutes: Math.ceil(duration / 60), - }; - - console.log(`[determineTranscriptionRoute] Validating credits:`, creditCheckBody); - - const creditResponse = await fetch(`${creditValidationUrl}/credits/validate`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${token}`, - }, - body: JSON.stringify(creditCheckBody), - }); - - if (!creditResponse.ok) { - const errorText = await creditResponse.text(); - console.error( - `[determineTranscriptionRoute] Credit validation failed: ${creditResponse.status} - ${errorText}` - ); - // Try to extract available credits from error text - const availableMatch = errorText.match(/Available:\s*(\d+)/); - const availableCredits = availableMatch ? parseInt(availableMatch[1]) : 0; - - throw new InsufficientCreditsException({ - requiredCredits: estimatedCost, - availableCredits, - creditType: spaceId ? 'space' : 'user', - operation: 'transcription', - spaceId, - }); - } - - const creditResult = await creditResponse.json(); - console.log(`[determineTranscriptionRoute] Credit validation successful:`, creditResult); - } catch (error) { - if (error instanceof BadRequestException) throw error; - console.error(`[determineTranscriptionRoute] Credit validation error:`, error); - throw new BadRequestException(`Credit validation failed: ${error.message}`); - } - - // Determine route based on dual limits - if (duration <= this.FAST_TIME_LIMIT && fileSize <= this.FAST_SIZE_LIMIT) { - console.log( - `[determineTranscriptionRoute] Using FAST route: duration ${duration}s <= ${this.FAST_TIME_LIMIT}s AND size ${fileSize} <= ${this.FAST_SIZE_LIMIT}` - ); - return { route: 'fast', cost: estimatedCost }; - } else { - console.log( - `[determineTranscriptionRoute] Using BATCH route: duration ${duration}s > ${this.FAST_TIME_LIMIT}s OR size ${fileSize} > ${this.FAST_SIZE_LIMIT}` - ); - return { route: 'batch', cost: estimatedCost }; - } - } - - /** - * Uploads audio to storage, creates memo in processing state, and routes to appropriate transcription service - * @param userId - User ID making the request - * @param file - Uploaded file from multer - * @param duration - Audio duration in seconds - * @param spaceId - Optional space ID to associate with memo - * @param blueprintId - Optional blueprint ID - * @param recordingLanguages - Optional array of recording languages - * @param token - Authentication token - * @returns Object with memo ID, file path and processing route information - */ - async uploadAndProcessAudio( - userId: string, - file: Express.Multer.File, - duration: number, - spaceId?: string, - blueprintId?: string | null, - recordingLanguages?: string[], - token?: string - ): Promise<{ - memoId: string; - filePath: string; - processingRoute: 'fast' | 'batch'; - message: string; - estimatedCost: number; - }> { - try { - console.log( - `[uploadAndProcessAudio] Processing audio for user ${userId}, duration: ${duration}s, size: ${file.buffer.length} bytes, filename: ${file.originalname}` - ); - - // 1. Determine transcription route and validate credits FIRST - const { route, cost } = await this.determineTranscriptionRoute( - duration, - file.buffer.length, - userId, - spaceId, - token - ); - - console.log( - `[uploadAndProcessAudio] Route determined: ${route}, estimated cost: ${cost} mana` - ); - - // Create authenticated client - const authClient = createClient(this.memoroUrl, this.memoroKey, { - global: { headers: { Authorization: `Bearer ${token}` } }, - }); - - // Upload the audio file to Supabase Storage - console.log(`[uploadAndProcessAudio] Uploading audio file to storage...`); - - // Create unique file path with memoId folder structure - const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); - const fileExtension = - file.originalname?.split('.').pop() || file.mimetype?.split('/')[1] || 'm4a'; - const uniqueFilename = `audio_${timestamp}.${fileExtension}`; - - // Generate a memo ID for the path - const generatedMemoId = randomUUID(); - const audioPath = `${userId}/${generatedMemoId}/${uniqueFilename}`; - - try { - // Upload to Supabase Storage using the file buffer - console.log( - `[uploadAndProcessAudio] Uploading to bucket: user-uploads, path: ${audioPath}` - ); - console.log(`[uploadAndProcessAudio] File buffer size: ${file.buffer.length} bytes`); - console.log( - `[uploadAndProcessAudio] Content type: ${file.mimetype || `audio/${fileExtension}`}` - ); - - const { data: uploadData, error: uploadError } = await authClient.storage - .from('user-uploads') - .upload(audioPath, file.buffer, { - contentType: file.mimetype || `audio/${fileExtension}`, - cacheControl: '3600', - upsert: false, - }); - - if (uploadError) { - throw new Error(`Upload failed: ${uploadError.message}`); - } - - console.log(`[uploadAndProcessAudio] File uploaded successfully: ${uploadData.path}`); - } catch (uploadError) { - console.error(`[uploadAndProcessAudio] Upload failed:`, uploadError); - throw new BadRequestException(`Failed to upload audio file: ${uploadError.message}`); - } - - // Create memo in processing state - const currentTimestamp = new Date().toISOString(); - - const sourceData = { - type: 'audio', - audio_path: audioPath, - format: fileExtension, - duration: duration, - original_filename: file.originalname, - }; - - const metadata = { - processing: { - transcription: { - status: 'processing', - timestamp: currentTimestamp, - }, - }, - blueprintId: blueprintId || null, - spaceId: spaceId || null, - recordingLanguages: recordingLanguages || null, - }; - - console.log(`[processAudioForTranscription] Creating memo with ID: ${generatedMemoId}...`); - const { error: createError } = await authClient.from('memos').insert({ - id: generatedMemoId, - user_id: userId, - source: sourceData, - is_pinned: false, - is_archived: false, - is_public: false, - created_at: currentTimestamp, - updated_at: currentTimestamp, - metadata, - }); - - if (createError) { - throw new Error(`Failed to create memo: ${createError.message}`); - } - - const memoId = generatedMemoId; - console.log(`[processAudioForTranscription] Created memo with ID: ${memoId}`); - - // Link memo to space if spaceId provided - if (spaceId) { - try { - console.log(`[processAudioForTranscription] Linking memo ${memoId} to space ${spaceId}`); - const { error: linkError } = await authClient.from('memo_spaces').insert({ - memo_id: memoId, - space_id: spaceId, - created_at: currentTimestamp, - }); - - if (linkError) { - console.error( - `[processAudioForTranscription] Failed to link memo to space: ${linkError.message}` - ); - // Don't fail the entire process for space linking errors - } - } catch (linkError) { - console.error(`[processAudioForTranscription] Error linking memo to space:`, linkError); - } - } - - // Route to appropriate transcription service using new architecture - const audioServiceUrl = - this.configService.get('AUDIO_MICROSERVICE_URL') || - 'https://audio-microservice-624477741877.europe-west3.run.app'; - - console.log(`[uploadAndProcessAudio] Routing to ${route} transcription service...`); - - if (route === 'fast') { - // Route to audio microservice fast transcription - console.log(`[uploadAndProcessAudio] Calling audio microservice fast transcription...`); - - const requestBody = { - audioPath, - memoId, - userId, - spaceId, - recordingLanguages: - recordingLanguages && recordingLanguages.length > 0 ? recordingLanguages : undefined, - }; - - try { - const response = await fetch(`${audioServiceUrl}/audio/transcribe-realtime`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${token}`, - }, - body: JSON.stringify(requestBody), - }); - - if (!response.ok) { - const errorText = await response.text(); - console.error(`[uploadAndProcessAudio] Fast transcription service error:`, errorText); - - // Update memo status to error - await this.updateMemoProcessingStatus(authClient, memoId, 'transcription', 'error', { - reason: `Fast transcription failed: ${errorText}`, - route: 'fast', - estimatedCost: cost, - }); - - throw new BadRequestException(`Fast transcription failed: ${errorText}`); - } else { - console.log(`[uploadAndProcessAudio] Fast transcription service called successfully`); - } - } catch (transcribeError) { - console.error( - `[uploadAndProcessAudio] Error calling fast transcription:`, - transcribeError - ); - - // Update memo status to error - await this.updateMemoProcessingStatus(authClient, memoId, 'transcription', 'error', { - reason: `Fast transcription error: ${transcribeError.message}`, - route: 'fast', - estimatedCost: cost, - }); - - throw transcribeError; - } - } else { - // Route to audio microservice batch transcription - console.log(`[uploadAndProcessAudio] Calling audio microservice batch transcription...`); - - try { - const batchRequestBody = { - audioPath, - memoId, - userId, - spaceId, - recordingLanguages, - }; - - const response = await fetch(`${audioServiceUrl}/audio/transcribe-from-storage`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${token}`, - }, - body: JSON.stringify(batchRequestBody), - }); - - if (!response.ok) { - const errorText = await response.text(); - console.error(`[uploadAndProcessAudio] Batch transcription service error:`, errorText); - - // Update memo status to error - await this.updateMemoProcessingStatus(authClient, memoId, 'transcription', 'error', { - reason: `Batch transcription failed: ${errorText}`, - route: 'batch', - estimatedCost: cost, - }); - - throw new BadRequestException(`Batch transcription failed: ${errorText}`); - } else { - // Parse response to get jobId - const batchResponse = await response.json(); - const jobId = batchResponse.jobId; - - if (jobId) { - console.log( - `[uploadAndProcessAudio] Batch transcription started with jobId: ${jobId}` - ); - - // Update memo with jobId and processing status - await this.updateMemoProcessingStatus( - authClient, - memoId, - 'transcription', - 'processing', - { - jobId, - route: 'batch', - batchTranscription: true, - estimatedCost: cost, - } - ); - } else { - console.warn( - `[uploadAndProcessAudio] Batch service response missing jobId:`, - batchResponse - ); - - await this.updateMemoProcessingStatus(authClient, memoId, 'transcription', 'error', { - reason: 'Batch service response missing jobId', - route: 'batch', - estimatedCost: cost, - }); - } - } - } catch (batchError) { - console.error(`[uploadAndProcessAudio] Error calling batch transcription:`, batchError); - - // Update memo status to error - await this.updateMemoProcessingStatus(authClient, memoId, 'transcription', 'error', { - reason: `Batch transcription error: ${batchError.message}`, - route: 'batch', - estimatedCost: cost, - }); - - throw batchError; - } - } - - return { - memoId, - filePath: audioPath, - processingRoute: route, - message: `Audio uploaded and ${route} transcription initiated`, - estimatedCost: cost, - }; - } catch (error) { - console.error(`[uploadAndProcessAudio] Error uploading and processing audio:`, error); - throw error; - } - } - - /** - * Updates memo processing status in metadata - */ - private async updateMemoProcessingStatus( - client: any, - memoId: string, - processName: string, - status: 'processing' | 'completed' | 'completed_no_transcript' | 'error', - details?: any - ): Promise { - try { - const timestamp = new Date().toISOString(); - - // Get current metadata - const { data: currentMemo, error: fetchError } = await client - .from('memos') - .select('metadata') - .eq('id', memoId) - .single(); - - if (fetchError) { - console.error(`Error fetching memo metadata: ${fetchError.message}`); - return; - } - - const currentMetadata = currentMemo?.metadata || {}; - const newMetadata = { - ...currentMetadata, - processing: { - ...(currentMetadata.processing || {}), - [processName]: { - status, - timestamp, - ...details, - }, - }, - }; - - const { error: updateError } = await client - .from('memos') - .update({ metadata: newMetadata }) - .eq('id', memoId); - - if (updateError) { - console.error(`Error updating memo processing status: ${updateError.message}`); - } else { - console.log(`Updated memo ${memoId} ${processName} status to ${status}`); - } - } catch (error) { - console.error(`Error in updateMemoProcessingStatus:`, error); - } - } - - /** - * Updates memo with batch transcription jobId - */ - async updateMemoWithJobId( - memoId: string, - jobId: string, - token: string, - userSelectedLanguages?: string[] - ): Promise { - try { - const authClient = createClient(this.memoroUrl, this.memoroServiceKey, { - global: { headers: { Authorization: `Bearer ${token}` } }, - }); - - // Get current metadata - const { data: currentMemo, error: fetchError } = await authClient - .from('memos') - .select('metadata') - .eq('id', memoId) - .single(); - - if (fetchError) { - console.error(`Error fetching memo for jobId update: ${fetchError.message}`); - return; - } - - const currentMetadata = currentMemo?.metadata || {}; - const newMetadata = { - ...currentMetadata, - processing: { - ...(currentMetadata.processing || {}), - transcription: { - ...(currentMetadata.processing?.transcription || {}), - jobId, - status: 'processing', - timestamp: new Date().toISOString(), - route: 'batch', - batchTranscription: true, - userSelectedLanguages: userSelectedLanguages || [], - }, - }, - }; - - const { error: updateError } = await authClient - .from('memos') - .update({ metadata: newMetadata }) - .eq('id', memoId); - - if (updateError) { - console.error(`Error updating memo with jobId: ${updateError.message}`); - } else { - console.log(`Successfully updated memo ${memoId} with jobId ${jobId}`); - } - } catch (error) { - console.error(`Error in updateMemoWithJobId:`, error); - } - } - - /** - * Update memo transcription status in metadata - */ - async updateMemoTranscriptionStatus( - memoId: string, - status: 'pending' | 'processing' | 'completed' | 'completed_no_transcript' | 'failed', - token: string, - additionalData?: any - ): Promise { - try { - const authClient = createClient(this.memoroUrl, this.memoroServiceKey, { - global: { headers: { Authorization: `Bearer ${token}` } }, - }); - - // Get current metadata - const { data: currentMemo, error: fetchError } = await authClient - .from('memos') - .select('metadata') - .eq('id', memoId) - .single(); - - if (fetchError) { - console.error(`Error fetching memo for status update: ${fetchError.message}`); - return; - } - - const currentMetadata = currentMemo?.metadata || {}; - const newMetadata = { - ...currentMetadata, - transcription_status: status, - transcription_updated_at: new Date().toISOString(), - ...(additionalData && { transcription_data: additionalData }), - }; - - const { error: updateError } = await authClient - .from('memos') - .update({ metadata: newMetadata }) - .eq('id', memoId); - - if (updateError) { - console.error(`Error updating memo transcription status: ${updateError.message}`); - } else { - console.log(`Successfully updated memo ${memoId} transcription status to ${status}`); - } - } catch (error) { - console.error(`Error in updateMemoTranscriptionStatus:`, error); - } - } - - /** - * Updates batch transcription metadata using memo ID (simpler and more reliable) - */ - async updateBatchMetadataByMemoId( - memoId: string, - jobId: string, - batchTranscription: boolean, - token: string, - userSelectedLanguages?: string[], - userId?: string - ): Promise<{ success: boolean; memoId?: string; jobId?: string; message: string }> { - try { - // When using service auth (token is null), we need to validate ownership - const isServiceAuth = !token; - - // Use service role client for this operation - const serviceClient = isServiceAuth - ? createClient(this.memoroUrl, this.memoroServiceKey) - : createClient(this.memoroUrl, this.memoroServiceKey, { - global: { headers: { Authorization: `Bearer ${token}` } }, - }); - - // Get current metadata by memo ID directly - const { data: memo, error: fetchError } = await serviceClient - .from('memos') - .select('id, metadata, user_id') - .eq('id', memoId) - .single(); - - if (fetchError) { - throw new Error(`Failed to find memo: ${fetchError.message}`); - } - - if (!memo) { - throw new Error(`No memo found with ID ${memoId}`); - } - - // Validate ownership when using service auth - if (isServiceAuth && userId && memo.user_id !== userId) { - console.error( - `[updateBatchMetadataByMemoId] Ownership validation failed: memo user_id=${memo.user_id}, provided userId=${userId}` - ); - throw new Error(`Unauthorized: User ${userId} does not own memo ${memoId}`); - } - - // Update metadata with batch job information - const currentMetadata = memo.metadata || {}; - const updatedMetadata = { - ...currentMetadata, - processing: { - ...(currentMetadata.processing || {}), - transcription: { - ...(currentMetadata.processing?.transcription || {}), - jobId, - batchTranscription, - batchJobCreated: new Date().toISOString(), - status: 'processing', - userSelectedLanguages: userSelectedLanguages || [], - }, - }, - }; - - const { error: updateError } = await serviceClient - .from('memos') - .update({ - metadata: updatedMetadata, - updated_at: new Date().toISOString(), - }) - .eq('id', memoId); - - if (updateError) { - throw new Error(`Failed to update memo metadata: ${updateError.message}`); - } - - console.log(`Updated batch metadata for memo ${memoId}, jobId: ${jobId}`); - - return { - success: true, - memoId, - jobId, - message: 'Batch metadata updated successfully', - }; - } catch (error) { - console.error('Error updating batch metadata by memo ID:', error); - throw new Error(`Failed to update batch metadata: ${error.message}`); - } - } - - /** - * Get memo for reprocessing - validates ownership and gets space association - * @param userId - User ID making the request - * @param memoId - Memo ID to reprocess - * @param token - Authentication token - * @returns Memo data with space information if valid, null otherwise - */ - async getMemoForReprocessing(userId: string, memoId: string, token: string): Promise { - try { - console.log( - `[getMemoForReprocessing] Getting memo ${memoId} for reprocessing by user ${userId}` - ); - - // Create authenticated client - const authClient = createClient(this.memoroUrl, this.memoroKey, { - global: { headers: { Authorization: `Bearer ${token}` } }, - }); - - // First try to get memo directly if owned by user - const { data: memo, error: memoError } = await authClient - .from('memos') - .select( - ` - id, - user_id, - metadata, - source, - title, - created_at, - memo_spaces(space_id) - ` - ) - .eq('id', memoId) - .eq('user_id', userId) - .maybeSingle(); - - if (!memoError && memo) { - // Extract space_id from the joined result - const spaceId = memo.memo_spaces?.[0]?.space_id || null; - console.log( - `[getMemoForReprocessing] Found memo ${memoId} owned by user, space: ${spaceId}` - ); - return { ...memo, space_id: spaceId }; - } - - // If not directly owned, check if user has access through a space - const { data: spaceMemo, error: spaceError } = await authClient - .from('memo_spaces') - .select( - ` - space_id, - memos!inner( - id, - user_id, - metadata, - source, - title, - created_at - ), - memoro_spaces!inner( - id, - memoro_space_members!inner( - user_id, - role - ) - ) - ` - ) - .eq('memo_id', memoId) - .eq('memoro_spaces.memoro_space_members.user_id', userId) - .maybeSingle(); - - if (!spaceError && spaceMemo) { - const memoData = spaceMemo.memos; - console.log( - `[getMemoForReprocessing] Found memo ${memoId} through space ${spaceMemo.space_id}` - ); - return { ...memoData, space_id: spaceMemo.space_id }; - } - - console.warn( - `[getMemoForReprocessing] Memo ${memoId} not found or access denied for user ${userId}` - ); - return null; - } catch (error) { - console.error(`[getMemoForReprocessing] Error getting memo ${memoId}:`, error); - return null; - } - } - - /** - * Creates memo from pre-uploaded file (direct upload scenario) - */ - async createMemoFromUploadedFile( - userId: string, - filePath: string, - duration: number, - spaceId?: string, - blueprintId?: string, - memoId?: string, - token?: string, - recordingStartedAt?: string, - location?: any, - mediaType?: 'audio' | 'video', - videoMetadata?: any - ): Promise<{ memoId: string; audioPath: string; memo: any }> { - try { - const authClient = createClient(this.memoroUrl, this.memoroServiceKey, { - global: { headers: { Authorization: `Bearer ${token}` } }, - }); - - const generatedMemoId = memoId || uuidv4(); - const currentTimestamp = new Date().toISOString(); - - // Use recording start time if provided, otherwise use current time - const createdAtTimestamp = recordingStartedAt || currentTimestamp; - - console.log( - `[createMemoFromUploadedFile] Creating/updating memo ${generatedMemoId} for pre-uploaded ${mediaType || 'audio'} file: ${filePath}` - ); - if (recordingStartedAt) { - console.log( - `[createMemoFromUploadedFile] Using provided recording start time: ${recordingStartedAt}` - ); - } - if (mediaType === 'video' && videoMetadata) { - console.log( - `[createMemoFromUploadedFile] Video details: ${videoMetadata.width}x${videoMetadata.height}, ${videoMetadata.fps}fps, codec: ${videoMetadata.videoCodec}` - ); - } - - // Create or update memo record using UPSERT pattern - const memoData: any = { - id: generatedMemoId, - user_id: userId, - source: { - audio_path: filePath, - file_path: filePath, // Also store as file_path for clarity - duration: duration, - media_type: mediaType || 'audio', - ...(mediaType === 'video' && - videoMetadata && { - video_metadata: { - width: videoMetadata.width, - height: videoMetadata.height, - fps: videoMetadata.fps, - video_codec: videoMetadata.videoCodec, - audio_codec: videoMetadata.audioCodec, - audio_channels: videoMetadata.audioChannels, - audio_sample_rate: videoMetadata.audioSampleRate, - file_size: videoMetadata.fileSize, - bitrate: videoMetadata.bitrate, - has_audio_track: videoMetadata.hasAudioTrack, - }, - }), - }, - metadata: { - processing: { - transcription: { - status: 'pending', - timestamp: currentTimestamp, - route: duration > 6900 ? 'batch' : 'fast', // 1h55m threshold - media_type: mediaType || 'audio', - }, - }, - upload: { - method: 'direct_upload', - timestamp: currentTimestamp, - media_type: mediaType || 'audio', - }, - // Store the blueprint_id to control which blueprint processing runs - blueprint_id: blueprintId || null, - // Store the recording start time in metadata for frontend use - ...(recordingStartedAt && { recordingStartedAt }), - // Store address information in metadata if available - ...(location?.address && { address: location.address }), - }, - created_at: createdAtTimestamp, - updated_at: currentTimestamp, - }; - - // Add location coordinates to PostGIS column if provided - if (location && location.latitude && location.longitude) { - // Use SRID 4326 (WGS84) for GPS coordinates - memoData.location = `POINT(${location.longitude} ${location.latitude})`; - } - - const { error: upsertError } = await authClient.from('memos').upsert(memoData, { - onConflict: 'id', - ignoreDuplicates: false, - }); - - if (upsertError) { - throw new Error(`Failed to create memo: ${upsertError.message}`); - } - - // Link memo to space if spaceId provided (using upsert to handle retries) - if (spaceId) { - try { - console.log( - `[createMemoFromUploadedFile] Linking memo ${generatedMemoId} to space ${spaceId}` - ); - const { error: linkError } = await authClient.from('memo_spaces').upsert( - { - memo_id: generatedMemoId, - space_id: spaceId, - created_at: createdAtTimestamp, - }, - { - onConflict: 'memo_id,space_id', - ignoreDuplicates: true, // Skip if link already exists - } - ); - - if (linkError) { - console.error( - `[createMemoFromUploadedFile] Failed to link memo to space: ${linkError.message}` - ); - } - } catch (linkError) { - console.error(`[createMemoFromUploadedFile] Error linking memo to space:`, linkError); - } - } - - console.log( - `[createMemoFromUploadedFile] Successfully created/updated memo ${generatedMemoId}` - ); - - // Fetch and return the complete memo object so the client has immediate access to all state - const { data: createdMemo, error: fetchError } = await authClient - .from('memos') - .select('*') - .eq('id', generatedMemoId) - .single(); - - if (fetchError) { - console.error(`[createMemoFromUploadedFile] Failed to fetch created memo:`, fetchError); - // Still return basic info if fetch fails - return { - memoId: generatedMemoId, - audioPath: filePath, - memo: null, - }; - } - - return { - memo: createdMemo, - memoId: generatedMemoId, - audioPath: filePath, - }; - } catch (error) { - console.error(`[createMemoFromUploadedFile] Error:`, error); - throw error; - } - } - - /** - * Handles transcription completion callback from audio microservice - */ - async handleTranscriptionCompleted( - memoId: string, - userId: string, - transcriptionResult?: any, - route?: 'fast' | 'batch', - success?: boolean, - error?: string, - token?: string - ): Promise<{ success: boolean; message: string }> { - try { - console.log( - `[handleTranscriptionCompleted] Processing callback for memo ${memoId}, success: ${success}, route: ${route}` - ); - - if (transcriptionResult) { - console.log( - `[handleTranscriptionCompleted] DEBUG - Text length: ${transcriptionResult.text?.length || 0}` - ); - } else { - console.log(`[handleTranscriptionCompleted] DEBUG - transcriptionResult is null/undefined`); - } - - // When using service auth (token is null), we need to validate ownership - const isServiceAuth = !token; - - // Create client with appropriate auth - const authClient = isServiceAuth - ? createClient(this.memoroUrl, this.memoroServiceKey) - : createClient(this.memoroUrl, this.memoroServiceKey, { - global: { headers: { Authorization: `Bearer ${token}` } }, - }); - - if (success && transcriptionResult) { - // 1. Update memo with transcription results - console.log( - `[handleTranscriptionCompleted] Updating memo ${memoId} with transcription results` - ); - - // Get current memo metadata to preserve existing data - const { data: currentMemo, error: fetchError } = await authClient - .from('memos') - .select('metadata, source, user_id') - .eq('id', memoId) - .single(); - - if (fetchError) { - console.error(`[handleTranscriptionCompleted] Error fetching memo:`, fetchError); - throw new Error(`Failed to fetch memo: ${fetchError.message}`); - } - - // Validate ownership when using service auth - if (isServiceAuth && currentMemo?.user_id !== userId) { - console.error( - `[handleTranscriptionCompleted] Ownership validation failed: memo user_id=${currentMemo?.user_id}, provided userId=${userId}` - ); - throw new Error(`Unauthorized: User ${userId} does not own memo ${memoId}`); - } - - // Calculate actual duration for credit consumption - const audioDurationSeconds = - currentMemo?.source?.duration || - transcriptionResult.estimatedDuration || - Math.ceil((transcriptionResult.text?.length / 150) * 60) || - 30; // Fallback estimation - - const durationMinutes = Math.ceil(audioDurationSeconds / 60); - const actualCost = durationMinutes * this.COST_PER_MINUTE; - - // Check if transcript is empty or too short - const transcriptText = transcriptionResult.text?.trim() || ''; - const isEmptyTranscript = transcriptText.length === 0 || transcriptText.length < 5; - - // Update memo source with transcription data (transcript moved to separate column) - // IMPORTANT: Preserve the audio path from the original source - // Handle both 'path' and 'audio_path' field names for compatibility - const audioPath = currentMemo.source?.audio_path || currentMemo.source?.path; - const updatedSource = this.safeSourceMerge(currentMemo.source, { - // Preserved: audio path and original metadata from existing source - - audio_path: audioPath, // Standard field name - type: currentMemo.source?.type || 'audio', - format: currentMemo.source?.format, - duration: currentMemo.source?.duration, - original_filename: currentMemo.source?.original_filename, - // New transcription data - primary_language: transcriptionResult.primary_language, - languages: transcriptionResult.languages, - utterances: transcriptionResult.utterances, - speakers: transcriptionResult.speakers, - // Removed: transcript (moved to separate column) - // Removed: speakerMap (computed client-side) - }); - - // Update memo metadata to mark transcription as completed - const updatedMetadata = { - ...(currentMemo.metadata || {}), - processing: { - ...(currentMemo.metadata?.processing || {}), - transcription: { - status: isEmptyTranscript ? 'completed_no_transcript' : 'completed', - timestamp: new Date().toISOString(), - route, - actualCost, - durationMinutes, - textLength: transcriptionResult.text?.length || 0, - speakerCount: transcriptionResult.speakers - ? Object.keys(transcriptionResult.speakers).length - : 0, - }, - }, - }; - - // If transcript is empty, also mark headline as completed with appropriate title - if (isEmptyTranscript) { - updatedMetadata.processing.headline_and_intro = { - status: 'completed_no_transcript', - timestamp: new Date().toISOString(), - details: { - headline: 'Aufnahme ohne Sprache', - intro: 'Diese Aufnahme enthält keinen erkennbaren gesprochenen Text.', - language: transcriptionResult.primary_language || 'de-DE', - }, - triggered_by: 'empty_transcript_handler', - }; - } - - // Prepare update data - const updateData: any = { - source: updatedSource, - transcript: transcriptionResult.text, // Store transcript in dedicated column - metadata: updatedMetadata, - updated_at: new Date().toISOString(), - }; - - // If transcript is empty, also set the title directly - if (isEmptyTranscript) { - updateData.title = 'Aufnahme ohne Sprache'; - updateData.style = { - intro: 'Diese Aufnahme enthält keinen erkennbaren gesprochenen Text.', - }; - - // Log audio path preservation for debugging - console.log( - `[handleTranscriptionCompleted] Empty transcript - preserving audio path: ${audioPath}` - ); - console.log( - `[handleTranscriptionCompleted] Source has audio_path: ${!!updatedSource.audio_path}, legacy path: ${!!updatedSource.path}` - ); - } - - // Validate source structure before database update - if (!this.validateSourceStructure(updateData.source)) { - console.error( - `[handleTranscriptionCompleted] Invalid source structure detected for memo ${memoId}` - ); - console.error('Source data:', JSON.stringify(updateData.source, null, 2)); - } - - // Update the memo in database - const { error: updateError } = await authClient - .from('memos') - .update(updateData) - .eq('id', memoId); - - if (updateError) { - console.error(`[handleTranscriptionCompleted] Error updating memo:`, updateError); - throw new Error(`Failed to update memo: ${updateError.message}`); - } - - console.log( - `[handleTranscriptionCompleted] Successfully updated memo ${memoId} with transcription results` - ); - - // 2. Consume credits for successful transcription using centralized service - try { - console.log( - `[handleTranscriptionCompleted] Consuming ${actualCost} credits for ${durationMinutes} minutes of transcription` - ); - - // Extract spaceId from memo metadata if available - const spaceId = currentMemo?.metadata?.spaceId; - - const creditResult = await this.creditConsumptionService.consumeTranscriptionCredits( - userId, - durationMinutes, - actualCost, - memoId, - route, - spaceId, - token - ); - - if (creditResult.success) { - console.log( - `[handleTranscriptionCompleted] Successfully consumed ${creditResult.creditsConsumed} ${creditResult.creditType} credits` - ); - } else { - console.error( - `[handleTranscriptionCompleted] Credit consumption failed: ${creditResult.error || creditResult.message}` - ); - // Don't fail the entire process if credit consumption fails - } - } catch (creditError) { - console.error(`[handleTranscriptionCompleted] Error consuming credits:`, creditError); - // Don't fail the entire process if credit consumption fails - } - - // 3. Trigger headline generation for non-empty transcripts - if (!isEmptyTranscript) { - this.headlineService.processHeadlineForMemo(memoId).catch((headlineError) => { - console.error( - `[handleTranscriptionCompleted] Headline generation failed for memo ${memoId}:`, - headlineError - ); - }); - console.log( - `[handleTranscriptionCompleted] Headline generation triggered for memo ${memoId}` - ); - } - - return { - success: true, - message: `Transcription completed successfully for memo ${memoId}`, - }; - } else { - // Handle transcription failure - console.log( - `[handleTranscriptionCompleted] Handling transcription failure for memo ${memoId}: ${error}` - ); - - // Update memo with error status - const { data: currentMemo, error: fetchError } = await authClient - .from('memos') - .select('metadata') - .eq('id', memoId) - .single(); - - if (!fetchError && currentMemo) { - const updatedMetadata = { - ...(currentMemo.metadata || {}), - processing: { - ...(currentMemo.metadata?.processing || {}), - transcription: { - status: 'error', - timestamp: new Date().toISOString(), - route, - error: error || 'Transcription failed', - retryable: true, - }, - }, - }; - - await authClient - .from('memos') - .update({ - metadata: updatedMetadata, - updated_at: new Date().toISOString(), - }) - .eq('id', memoId); - } - - return { - success: false, - message: `Transcription failed for memo ${memoId}: ${error}`, - }; - } - } catch (callbackError) { - console.error(`[handleTranscriptionCompleted] Error in callback handler:`, callbackError); - throw new Error(`Transcription callback failed: ${callbackError.message}`); - } - } - - /** - * Creates a Supabase client for file operations - */ - createSupabaseClient(token?: string) { - return createClient(this.memoroUrl, this.memoroServiceKey, { - global: { headers: { Authorization: `Bearer ${token}` } }, - }); - } - - /** - * Validates that a memo exists and belongs to the user for append operations - */ - async validateMemoForAppend(userId: string, memoId: string, token: string): Promise { - try { - const authClient = createClient(this.memoroUrl, this.memoroServiceKey, { - global: { headers: { Authorization: `Bearer ${token}` } }, - }); - - const { data: memo, error } = await authClient - .from('memos') - .select('id, user_id, source, metadata') - .eq('id', memoId) - .single(); - - if (error || !memo) { - console.error(`Memo not found: ${memoId}`, error); - return null; - } - - // Check if user has access (owner or through space) - if (memo.user_id !== userId) { - // Check if user has access through space - const spaceId = memo.metadata?.spaceId; - if (spaceId) { - // Check if user is a member of the space - const { data: spaceMember, error: spaceError } = await authClient - .from('space_members') - .select('id') - .eq('space_id', spaceId) - .eq('user_id', userId) - .single(); - - if (spaceError || !spaceMember) { - console.error(`User ${userId} does not have access to memo ${memoId}`); - return null; - } - } else { - console.error(`User ${userId} does not own memo ${memoId}`); - return null; - } - } - - return memo; - } catch (error) { - console.error(`Error validating memo for append:`, error); - throw error; - } - } - - /** - * Updates the status of an append transcription in additional_recordings - */ - async updateAppendTranscriptionStatus( - memoId: string, - recordingIndex: number | undefined, - status: 'processing' | 'completed' | 'error', - token: string, - additionalData?: any - ): Promise { - try { - const authClient = createClient(this.memoroUrl, this.memoroServiceKey, { - global: { headers: { Authorization: `Bearer ${token}` } }, - }); - - // Get current memo data - const { data: currentMemo, error: fetchError } = await authClient - .from('memos') - .select('source') - .eq('id', memoId) - .single(); - - if (fetchError || !currentMemo) { - console.error(`Error fetching memo for append status update: ${fetchError?.message}`); - return; - } - - const source = this.safeSourceMerge(currentMemo.source || {}, {}); - const additionalRecordings = source.additional_recordings || []; - - let targetIndex: number; - - // If a specific recordingIndex is provided, use it - if (recordingIndex !== undefined) { - targetIndex = recordingIndex; - } else if (status === 'processing') { - // For new processing status, always create a new recording - additionalRecordings.push({ - status: 'processing', - timestamp: new Date().toISOString(), - ...additionalData, - }); - targetIndex = additionalRecordings.length - 1; - } else { - // For other status updates, find the last recording that's in processing state - targetIndex = additionalRecordings.findIndex((rec: any) => rec.status === 'processing'); - if (targetIndex === -1) { - // If no processing recording found, this is an error case - console.error(`No processing recording found to update with status: ${status}`); - return; - } - } - - // Update the recording at the target index - if (targetIndex >= 0 && targetIndex < additionalRecordings.length) { - additionalRecordings[targetIndex] = { - ...additionalRecordings[targetIndex], - status, - updated_at: new Date().toISOString(), - ...additionalData, - }; - } - - // Prepare update with safe source merge - const updatedSource = this.safeSourceMerge(source, { - additional_recordings: additionalRecordings, - }); - - // Validate source structure before database update - if (!this.validateSourceStructure(updatedSource)) { - console.error( - `[updateAppendTranscriptionStatus] Invalid source structure detected for memo ${memoId}` - ); - console.error('Source data:', JSON.stringify(updatedSource, null, 2)); - } - - // Update the memo - const { error: updateError } = await authClient - .from('memos') - .update({ - source: updatedSource, - updated_at: new Date().toISOString(), - }) - .eq('id', memoId); - - if (updateError) { - console.error(`Error updating append transcription status: ${updateError.message}`); - } else { - console.log(`Updated append transcription status for memo ${memoId} to ${status}`); - } - } catch (error) { - console.error(`Error in updateAppendTranscriptionStatus:`, error); - } - } - - /** - * Handles append transcription completion and updates additional_recordings - */ - async handleAppendTranscriptionCompleted( - memoId: string, - userId: string, - transcriptionResult: any, - route: 'fast' | 'batch', - success: boolean, - error: string | null, - token: string - ): Promise { - try { - // When using service auth (token is null), we need to validate ownership - const isServiceAuth = !token; - - // Create client with appropriate auth - const authClient = isServiceAuth - ? createClient(this.memoroUrl, this.memoroServiceKey) - : createClient(this.memoroUrl, this.memoroServiceKey, { - global: { headers: { Authorization: `Bearer ${token}` } }, - }); - - // Get current memo data - const { data: currentMemo, error: fetchError } = await authClient - .from('memos') - .select('source, metadata, user_id') - .eq('id', memoId) - .single(); - - if (fetchError || !currentMemo) { - console.error(`Error fetching memo for append completion: ${fetchError?.message}`); - throw new Error(`Failed to fetch memo: ${fetchError?.message}`); - } - - // Validate ownership when using service auth - if (isServiceAuth && currentMemo.user_id !== userId) { - console.error( - `[handleAppendTranscriptionCompleted] Ownership validation failed: memo user_id=${currentMemo.user_id}, provided userId=${userId}` - ); - throw new Error(`Unauthorized: User ${userId} does not own memo ${memoId}`); - } - - const source = this.safeSourceMerge(currentMemo.source || {}, {}); - const additionalRecordings = source.additional_recordings || []; - - if (success && transcriptionResult) { - // Find the recording that's currently processing - const targetIndex = additionalRecordings.findIndex( - (rec: any) => rec.status === 'processing' - ); - - if (targetIndex === -1) { - console.error(`No processing recording found for memo ${memoId}`); - throw new Error('No processing recording found to update'); - } - - // Prefix speaker IDs to avoid conflicts between recordings - const prefixedSpeakerData = this.prefixSpeakerIds( - transcriptionResult.speakers, - transcriptionResult.speakerMap, - transcriptionResult.utterances, - targetIndex - ); - - // Update the recording with transcription results - additionalRecordings[targetIndex] = { - ...additionalRecordings[targetIndex], - transcript: transcriptionResult.text || '', - languages: transcriptionResult.languages || [], - primary_language: transcriptionResult.primary_language || 'de-DE', - speakers: prefixedSpeakerData.speakers, - speakerMap: prefixedSpeakerData.speakerMap, - utterances: prefixedSpeakerData.utterances, - status: 'completed', - updated_at: new Date().toISOString(), - }; - - // Prepare update with safe source merge - const updatedSource = this.safeSourceMerge(source, { - additional_recordings: additionalRecordings, - }); - - // Validate source structure before database update - if (!this.validateSourceStructure(updatedSource)) { - console.error( - `[handleAppendTranscriptionCompleted] Invalid source structure detected for memo ${memoId}` - ); - console.error('Source data:', JSON.stringify(updatedSource, null, 2)); - } - - // Update the memo - const { error: updateError } = await authClient - .from('memos') - .update({ - source: updatedSource, - updated_at: new Date().toISOString(), - }) - .eq('id', memoId); - - if (updateError) { - console.error(`Error updating memo with append transcription: ${updateError.message}`); - throw new Error(`Failed to update memo: ${updateError.message}`); - } - - console.log( - `Successfully appended transcription to memo ${memoId} at index ${targetIndex}` - ); - - // Consume credits for successful transcription - try { - const duration = additionalRecordings[targetIndex].duration || 60; // Default to 1 minute if not specified - const durationMinutes = Math.ceil(duration / 60); - const actualCost = calculateTranscriptionCost(duration); - const spaceId = currentMemo.metadata?.spaceId; - - const creditResult = await this.creditConsumptionService.consumeTranscriptionCredits( - userId, - durationMinutes, - actualCost, - memoId, - route, - spaceId, - token - ); - - if (creditResult.success) { - console.log( - `Successfully consumed ${creditResult.creditsConsumed} credits for append transcription` - ); - } - } catch (creditError) { - console.error(`Error consuming credits for append transcription:`, creditError); - // Don't fail the entire process if credit consumption fails - } - } else { - // Handle error case - find the processing recording - const targetIndex = additionalRecordings.findIndex( - (rec: any) => rec.status === 'processing' - ); - if (targetIndex !== -1) { - await this.updateAppendTranscriptionStatus(memoId, targetIndex, 'error', token, { - error: error || 'Transcription failed', - route, - }); - } - } - } catch (error) { - console.error(`Error in handleAppendTranscriptionCompleted:`, error); - throw error; - } - } - - /** - * Prefixes speaker IDs with recording index to avoid conflicts between recordings - * @param speakers - Object mapping speaker IDs to names - * @param speakerMap - Object mapping utterance indices to speaker IDs - * @param utterances - Array of utterances with speaker IDs - * @param recordingIndex - Index of the recording to use as prefix - * @returns Object with prefixed speaker data - */ - private prefixSpeakerIds( - speakers: Record | null, - speakerMap: Record | null, - utterances: Array<{ - speakerId: string; - text: string; - offset: number; - duration: number; - }> | null, - recordingIndex: number - ): { - speakers: Record | null; - speakerMap: Record | null; - utterances: Array<{ - speakerId: string; - text: string; - offset: number; - duration: number; - }> | null; - } { - const prefix = `rec${recordingIndex}_`; - - // Prefix speakers object - const prefixedSpeakers = speakers - ? Object.entries(speakers).reduce( - (acc, [speakerId, speakerName]) => { - acc[`${prefix}${speakerId}`] = speakerName; - return acc; - }, - {} as Record - ) - : null; - - // Prefix speakerMap - const prefixedSpeakerMap = speakerMap - ? Object.entries(speakerMap).reduce( - (acc, [utteranceIndex, speakerId]) => { - acc[utteranceIndex] = `${prefix}${speakerId}`; - return acc; - }, - {} as Record - ) - : null; - - // Prefix utterances - const prefixedUtterances = utterances - ? utterances.map((utterance) => ({ - ...utterance, - speakerId: `${prefix}${utterance.speakerId}`, - })) - : null; - - return { - speakers: prefixedSpeakers, - speakerMap: prefixedSpeakerMap, - utterances: prefixedUtterances, - }; - } - - /** - * Safely merges source objects to prevent nested object serialization issues - * Ensures proper structure and prevents "obj obj" patterns in JSONB fields - */ - private safeSourceMerge( - existingSource: any, - updates: Partial<{ - type: string; - audio_path: string; - format: string; - duration: number; - original_filename: string; - primary_language: string; - languages: string[]; - utterances: any[]; - speakers: Record; - additional_recordings: any[]; - }> - ): any { - // Start with a clean base object or existing source - const baseSource = - existingSource && typeof existingSource === 'object' ? { ...existingSource } : {}; - - // Remove any nested source properties to prevent double nesting - if (baseSource.source) { - console.warn('[safeSourceMerge] Detected nested source property, flattening structure'); - const nestedSource = baseSource.source; - delete baseSource.source; - // Merge nested properties into base - Object.assign(baseSource, nestedSource); - } - - // Safely merge updates - const mergedSource = { - ...baseSource, - ...updates, - }; - - // Validate critical properties aren't objects when they should be primitives - if (mergedSource.type && typeof mergedSource.type === 'object') { - console.error('[safeSourceMerge] Invalid type property detected:', mergedSource.type); - mergedSource.type = 'audio'; // Default fallback - } - - if (mergedSource.audio_path && typeof mergedSource.audio_path === 'object') { - console.error( - '[safeSourceMerge] Invalid audio_path property detected:', - mergedSource.audio_path - ); - mergedSource.audio_path = String(mergedSource.audio_path); // Try to convert to string - } - - // Handle legacy path field conversion - if (mergedSource.path && !mergedSource.audio_path) { - mergedSource.audio_path = mergedSource.path; - delete mergedSource.path; - } - - // Log the final structure for debugging - console.log('[safeSourceMerge] Final source structure:', { - hasType: !!mergedSource.type, - hasAudioPath: !!mergedSource.audio_path, - hasLegacyPath: !!mergedSource.path, - hasSpeakers: !!mergedSource.speakers, - hasUtterances: !!mergedSource.utterances, - hasAdditionalRecordings: !!mergedSource.additional_recordings, - additionalRecordingsCount: mergedSource.additional_recordings?.length || 0, - }); - - return mergedSource; - } - - /** - * Validates source object structure before database operations - */ - private validateSourceStructure(source: any): boolean { - if (!source || typeof source !== 'object') { - return false; - } - - // Check for nested source properties (indicates corruption) - if (source.source) { - console.error('[validateSourceStructure] Nested source property detected'); - return false; - } - - // Validate expected primitive types - const primitiveFields = ['type', 'path', 'format', 'original_filename', 'primary_language']; - for (const field of primitiveFields) { - if (source[field] && typeof source[field] === 'object') { - console.error(`[validateSourceStructure] Field ${field} should not be an object`); - return false; - } - } - - return true; - } -} diff --git a/apps/memoro/apps/backend/src/memoro/question-memo.controller.ts b/apps/memoro/apps/backend/src/memoro/question-memo.controller.ts deleted file mode 100644 index 677aa10de..000000000 --- a/apps/memoro/apps/backend/src/memoro/question-memo.controller.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { Controller, Post, Body, UseGuards, BadRequestException } from '@nestjs/common'; -import { AuthGuard } from '../guards/auth.guard'; -import { User } from '../decorators/user.decorator'; -import { CreditConsumptionService } from '../credits/credit-consumption.service'; -import { OPERATION_COSTS } from '../credits/pricing.constants'; -import { InsufficientCreditsException } from '../errors/insufficient-credits.error'; -import { QuestionService } from '../ai/memory/question.service'; - -class QuestionMemoDto { - memo_id: string; - question: string; - spaceId?: string; -} - -@Controller('memoro/question-memo') -@UseGuards(AuthGuard) -export class QuestionMemoController { - constructor( - private readonly creditConsumptionService: CreditConsumptionService, - private readonly questionService: QuestionService - ) {} - - @Post() - async processQuestionMemo(@User() user: any, @Body() dto: QuestionMemoDto) { - console.log('QuestionMemoController - Request received:', { - memo_id: dto.memo_id, - question: dto.question?.substring(0, 50) + '...', - user_id: user.sub, - has_token: !!user.token, - }); - - if (!dto.memo_id || !dto.question?.trim()) { - throw new BadRequestException('memo_id and question are required'); - } - - // Extract token from request - const token = user.token; - const requiredCredits = OPERATION_COSTS.QUESTION_MEMO; - - console.log('QuestionMemoController - Starting credit check, required:', requiredCredits); - - try { - // Check and consume credits first using centralized service - console.log('QuestionMemoController - Calling creditConsumptionService...'); - const creditResult = await this.creditConsumptionService.consumeQuestionCredits( - user.sub, - dto.memo_id, - dto.question, - dto.spaceId, - token - ); - - if (!creditResult.success) { - throw new BadRequestException(creditResult.message || creditResult.error); - } - - console.log('QuestionMemoController - Credits consumed successfully:', creditResult); - - // Process question locally via QuestionService (replaces Supabase Edge Function) - console.log('QuestionMemoController - Processing question via QuestionService'); - - const result = await this.questionService.askQuestion(dto.memo_id, dto.question.trim()); - - console.log('QuestionMemoController - QuestionService result:', { - memoryId: result.memoryId, - }); - - return { - success: true, - memory_id: result.memoryId, - answer: result.answer, - question: result.question, - creditsConsumed: requiredCredits, - creditType: creditResult.creditType, - }; - } catch (error) { - console.error('QuestionMemoController - Error occurred:', error); - - if (error instanceof InsufficientCreditsException) { - throw error; // Let the exception propagate with 402 status - } - - if (error.message?.includes('insufficient credits')) { - // Fallback for any legacy insufficient credit errors - throw new InsufficientCreditsException({ - requiredCredits, - availableCredits: 0, - creditType: dto.spaceId ? 'space' : 'user', - operation: 'question', - spaceId: dto.spaceId, - }); - } - throw new BadRequestException(error.message); - } - } -} diff --git a/apps/memoro/apps/backend/src/memoro/space-sync.controller.ts b/apps/memoro/apps/backend/src/memoro/space-sync.controller.ts deleted file mode 100644 index 51170aa7f..000000000 --- a/apps/memoro/apps/backend/src/memoro/space-sync.controller.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { - Controller, - Post, - Param, - Req, - UseGuards, - BadRequestException, - Logger, -} from '@nestjs/common'; -import { AuthGuard } from '../guards/auth.guard'; -import { User } from '../decorators/user.decorator'; -import { JwtPayload } from '../types/jwt-payload.interface'; -import { SyncSpaceMembersService } from './sync-space-members.service'; -import { SpaceSyncService } from '../spaces/space-sync.service'; - -@Controller('memoro/sync') -@UseGuards(AuthGuard) -export class SpaceSyncController { - private readonly logger = new Logger(SpaceSyncController.name); - - constructor( - private readonly syncSpaceMembersService: SyncSpaceMembersService, - private readonly spaceSyncService: SpaceSyncService - ) {} - - /** - * Synchronize members for a specific space - * This stores space membership information in Supabase to support RLS policies - */ - @Post('spaces/:id/members') - async syncSpaceMembers(@User() user: JwtPayload, @Param('id') spaceId: string, @Req() req) { - if (!spaceId) { - throw new BadRequestException('Space ID is required'); - } - - const token = req.token; - this.logger.log(`User ${user.sub} requested to sync members for space ${spaceId}`); - return this.syncSpaceMembersService.syncSpaceMembers(user.sub, spaceId, token); - } - - /** - * Synchronize all spaces the user has access to - * This stores space membership information in Supabase to support RLS policies - */ - @Post('spaces/all') - async syncAllSpaces(@User() user: JwtPayload, @Req() req) { - const token = req.token; - this.logger.log(`User ${user.sub} requested to sync all their spaces`); - return this.syncSpaceMembersService.syncAllUserSpaces(user.sub, token); - } - - /** - * Run the migration to create the space_members table and set up RLS policies - * This endpoint should be called once to set up the required database structure - */ - @Post('migration/setup') - async runMigration(@User() user: JwtPayload, @Req() req) { - const token = req.token; - this.logger.log(`User ${user.sub} requested to run space_members migration`); - - // Only allow admins to run this migration - // In a production environment, you might want to check if the user is an admin - // For now, we'll allow it in the development environment - - // Run the migration - const result = await this.spaceSyncService.runSpaceMembersMigration(); - - if (result.success) { - // If migration was successful, trigger a sync of all spaces for this user - await this.syncSpaceMembersService.syncAllUserSpaces(user.sub, token).catch((error) => { - this.logger.error(`Error syncing spaces after migration: ${error.message}`); - }); - } - - return result; - } -} diff --git a/apps/memoro/apps/backend/src/memoro/sync-space-members.service.ts b/apps/memoro/apps/backend/src/memoro/sync-space-members.service.ts deleted file mode 100644 index d15b44852..000000000 --- a/apps/memoro/apps/backend/src/memoro/sync-space-members.service.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { MemoroService } from './memoro.service'; -import { SpaceSyncService } from '../spaces/space-sync.service'; - -/** - * Service for synchronizing space members from the core middleware to Supabase - * This handles the synchronization of space membership data to support RLS - */ -@Injectable() -export class SyncSpaceMembersService { - private readonly logger = new Logger(SyncSpaceMembersService.name); - - constructor( - private readonly memoroService: MemoroService, - private readonly spaceSyncService: SpaceSyncService - ) {} - - /** - * Sync members for a specific space - * @param spaceId ID of the space to sync - * @param token Auth token for API calls - */ - async syncSpaceMembers(userId: string, spaceId: string, token: string) { - try { - this.logger.log(`Syncing members for space ${spaceId}`); - - // Get the space details including members - const spaceDetails = await this.memoroService.getMemoroSpaceDetails(userId, spaceId, token); - - // Extract members from the space details - const members = []; - - if (spaceDetails?.space?.roles?.members) { - // Extract members from the space details - for (const [userId, memberInfo] of Object.entries(spaceDetails.space.roles.members)) { - // Type assertion for member info object which comes from the API - const typedMemberInfo = memberInfo as { role: string; added_by: string }; - - members.push({ - userId, - role: typedMemberInfo.role, - addedBy: typedMemberInfo.added_by, - }); - } - } - - this.logger.log(`Found ${members.length} members for space ${spaceId}`); - - // Sync all members to the space_members table - await this.spaceSyncService.syncAllSpaceMembers(spaceId, members); - - return { - success: true, - message: `Successfully synced ${members.length} members for space ${spaceId}`, - }; - } catch (error) { - this.logger.error(`Error syncing members for space ${spaceId}:`, error); - throw error; - } - } - - /** - * Sync all spaces the user has access to - * @param userId ID of the user - * @param token Auth token for API calls - */ - async syncAllUserSpaces(userId: string, token: string) { - try { - this.logger.log(`Syncing all spaces for user ${userId}`); - - // Get all spaces the user has access to - const spaces = await this.memoroService.getMemoroSpaces(userId, token); - - const results = []; - let successCount = 0; - let failCount = 0; - - // Sync each space - for (const space of spaces) { - try { - const result = await this.syncSpaceMembers(userId, space.id, token); - results.push({ - spaceId: space.id, - name: space.name, - success: true, - membersCount: result.message.match(/Successfully synced (\d+)/)?.[1] || 0, - }); - successCount++; - } catch (error) { - results.push({ - spaceId: space.id, - name: space.name, - success: false, - error: error.message, - }); - failCount++; - } - } - - return { - success: true, - spacesProcessed: spaces.length, - spacesSucceeded: successCount, - spacesFailed: failCount, - results, - }; - } catch (error) { - this.logger.error(`Error syncing all user spaces:`, error); - throw error; - } - } -} diff --git a/apps/memoro/apps/backend/src/migrations/create-space-members.sql b/apps/memoro/apps/backend/src/migrations/create-space-members.sql deleted file mode 100644 index 1125883ac..000000000 --- a/apps/memoro/apps/backend/src/migrations/create-space-members.sql +++ /dev/null @@ -1,51 +0,0 @@ --- Create the space_members table for synchronized space membership -CREATE TABLE IF NOT EXISTS space_members ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - space_id UUID NOT NULL, - user_id UUID NOT NULL, - role TEXT NOT NULL, - added_at TIMESTAMP WITH TIME ZONE DEFAULT now(), - added_by UUID, - UNIQUE(space_id, user_id) -); - --- Create indexes for better performance -CREATE INDEX IF NOT EXISTS idx_space_members_user_id ON space_members(user_id); -CREATE INDEX IF NOT EXISTS idx_space_members_space_id ON space_members(space_id); - --- Enable RLS on the table -ALTER TABLE space_members ENABLE ROW LEVEL SECURITY; - --- Create policies for space_members table -CREATE POLICY "Users can see space membership they are part of" -ON space_members FOR SELECT -USING ( - user_id = auth.uid() OR - space_id IN ( - SELECT space_id FROM space_members - WHERE user_id = auth.uid() - ) -); - --- Update memo policies to allow access to memos in spaces user is member of -CREATE POLICY "Users can view memos in spaces they are members of" -ON memos FOR SELECT -USING ( - EXISTS ( - SELECT 1 FROM memo_spaces ms - JOIN space_members sm ON ms.space_id = sm.space_id - WHERE ms.memo_id = memos.id - AND sm.user_id = auth.uid() - ) -); - --- Policy for memo_spaces table to allow viewing of memo-space relationships -CREATE POLICY "Users can see memo-space links for spaces they are members of" -ON memo_spaces FOR SELECT -USING ( - EXISTS ( - SELECT 1 FROM space_members - WHERE space_members.space_id = memo_spaces.space_id - AND space_members.user_id = auth.uid() - ) -); diff --git a/apps/memoro/apps/backend/src/scripts/init-space-members.ts b/apps/memoro/apps/backend/src/scripts/init-space-members.ts deleted file mode 100644 index 90e976564..000000000 --- a/apps/memoro/apps/backend/src/scripts/init-space-members.ts +++ /dev/null @@ -1,253 +0,0 @@ -/** - * Script to initialize the space_members table with existing space memberships - * - * This script: - * 1. Creates the space_members table if it doesn't exist - * 2. Synchronizes all existing spaces and their members - * - * Usage: - * npx ts-node src/scripts/init-space-members.ts - */ - -import { ConfigService } from '@nestjs/config'; -import { createClient } from '@supabase/supabase-js'; -import axios from 'axios'; -import * as dotenv from 'dotenv'; -import * as fs from 'fs'; -import * as path from 'path'; - -// Load environment variables -dotenv.config(); - -// Create a separate logger for the script -const logger = { - log: (message: string) => console.log(`[INFO] ${message}`), - error: (message: string, error?: any) => console.error(`[ERROR] ${message}`, error || ''), - warn: (message: string) => console.warn(`[WARN] ${message}`), - debug: (message: string) => console.debug(`[DEBUG] ${message}`), -}; - -// Configuration -const memoroUrl = process.env.MEMORO_SUPABASE_URL; -const memoroServiceKey = process.env.MEMORO_SUPABASE_SERVICE_KEY; -const middlewareUrl = process.env.MANA_CORE_URL || 'http://localhost:3000'; -const adminToken = process.env.ADMIN_TOKEN; // You'll need to provide this - -if (!memoroUrl || !memoroServiceKey) { - logger.error( - 'Missing required environment variables: MEMORO_SUPABASE_URL, MEMORO_SUPABASE_SERVICE_KEY' - ); - process.exit(1); -} - -if (!adminToken) { - logger.warn('No ADMIN_TOKEN provided - you will need to authenticate to access space data'); -} - -// Create Supabase client with service role -const supabase = createClient(memoroUrl, memoroServiceKey); - -/** - * Creates the space_members table and sets up RLS policies - */ -async function createSpaceMembersTable() { - logger.log('Checking if space_members table exists...'); - - try { - // Try to query the table to see if it exists - const { data, error } = await supabase.from('space_members').select('id').limit(1); - - if (error && error.code === '42P01') { - // Table doesn't exist, create it - logger.log('space_members table does not exist, creating...'); - - // Read the migration SQL from file - const migrationPath = path.join(__dirname, '..', 'migrations', 'create-space-members.sql'); - if (!fs.existsSync(migrationPath)) { - logger.error(`Migration file not found at ${migrationPath}`); - return false; - } - - const sql = fs.readFileSync(migrationPath, 'utf8'); - - // Execute the SQL - const { error: migrationError } = await supabase.rpc('pgmoon', { query: sql }); - - if (migrationError) { - logger.error('Error creating space_members table:', migrationError); - return false; - } - - logger.log('Successfully created space_members table and RLS policies'); - return true; - } else if (error) { - logger.error('Error checking if space_members table exists:', error); - return false; - } else { - logger.log('space_members table already exists'); - return true; - } - } catch (error) { - logger.error('Unexpected error creating space_members table:', error); - return false; - } -} - -/** - * Fetches all spaces from the middleware - */ -async function getAllSpaces() { - try { - logger.log('Fetching all spaces from middleware...'); - - const response = await axios.get(`${middlewareUrl}/spaces/all`, { - headers: { - Authorization: `Bearer ${adminToken}`, - 'Content-Type': 'application/json', - }, - }); - - if (response.data && response.data.spaces) { - logger.log(`Found ${response.data.spaces.length} spaces`); - return response.data.spaces; - } else { - logger.warn('No spaces found or unexpected response format'); - return []; - } - } catch (error) { - logger.error('Error fetching spaces:', error); - return []; - } -} - -/** - * Fetches space details including members - */ -async function getSpaceDetails(spaceId: string) { - try { - logger.log(`Fetching details for space ${spaceId}...`); - - const response = await axios.get(`${middlewareUrl}/spaces/${spaceId}`, { - headers: { - Authorization: `Bearer ${adminToken}`, - 'Content-Type': 'application/json', - }, - }); - - if (response.data && response.data.space) { - return response.data.space; - } else { - logger.warn(`No details found for space ${spaceId} or unexpected response format`); - return null; - } - } catch (error) { - logger.error(`Error fetching space details for ${spaceId}:`, error); - return null; - } -} - -/** - * Synchronizes members for a space - */ -async function syncSpaceMembers(spaceId: string, spaceDetails: any) { - try { - logger.log(`Syncing members for space ${spaceId}...`); - - if (!spaceDetails.roles || !spaceDetails.roles.members) { - logger.warn(`No members found for space ${spaceId}`); - return; - } - - const members = []; - - // Extract members from space details - for (const [userId, memberInfo] of Object.entries(spaceDetails.roles.members)) { - members.push({ - space_id: spaceId, - user_id: userId, - role: (memberInfo as any).role, - added_at: new Date(), - added_by: (memberInfo as any).added_by || userId, - }); - } - - logger.log(`Found ${members.length} members for space ${spaceId}`); - - // Clear existing members for this space - const { error: deleteError } = await supabase - .from('space_members') - .delete() - .eq('space_id', spaceId); - - if (deleteError) { - logger.error(`Error clearing existing members for space ${spaceId}:`, deleteError); - return; - } - - // Insert new members - if (members.length > 0) { - const { error: insertError } = await supabase.from('space_members').insert(members); - - if (insertError) { - logger.error(`Error inserting members for space ${spaceId}:`, insertError); - return; - } - } - - logger.log(`Successfully synced ${members.length} members for space ${spaceId}`); - } catch (error) { - logger.error(`Error syncing members for space ${spaceId}:`, error); - } -} - -/** - * Main function to run the script - */ -async function main() { - try { - logger.log('Starting space_members initialization...'); - - // Create the space_members table if it doesn't exist - const tableCreated = await createSpaceMembersTable(); - if (!tableCreated) { - logger.error('Failed to create space_members table, exiting'); - process.exit(1); - } - - // Get all spaces - const spaces = await getAllSpaces(); - if (spaces.length === 0) { - logger.warn('No spaces found, nothing to sync'); - process.exit(0); - } - - // Sync members for each space - let successCount = 0; - let failCount = 0; - - for (const space of spaces) { - try { - const spaceDetails = await getSpaceDetails(space.id); - if (spaceDetails) { - await syncSpaceMembers(space.id, spaceDetails); - successCount++; - } else { - logger.warn(`Skipping space ${space.id} due to missing details`); - failCount++; - } - } catch (error) { - logger.error(`Error processing space ${space.id}:`, error); - failCount++; - } - } - - logger.log(`Finished syncing space members: ${successCount} succeeded, ${failCount} failed`); - process.exit(0); - } catch (error) { - logger.error('Unexpected error in main function:', error); - process.exit(1); - } -} - -// Run the script -main(); diff --git a/apps/memoro/apps/backend/src/settings/settings-client.service.ts b/apps/memoro/apps/backend/src/settings/settings-client.service.ts deleted file mode 100644 index 24998eca2..000000000 --- a/apps/memoro/apps/backend/src/settings/settings-client.service.ts +++ /dev/null @@ -1,118 +0,0 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; - -@Injectable() -export class SettingsClientService { - private readonly logger = new Logger(SettingsClientService.name); - private readonly manaServiceUrl: string; - - constructor(private readonly configService: ConfigService) { - this.manaServiceUrl = this.configService.get('MANA_SERVICE_URL'); - if (!this.manaServiceUrl) { - this.logger.warn('MANA_SERVICE_URL not configured'); - } - } - - async getUserSettings(token: string): Promise { - try { - const response = await fetch(`${this.manaServiceUrl}/users/settings`, { - method: 'GET', - headers: { - Authorization: `Bearer ${token}`, - 'Content-Type': 'application/json', - }, - }); - - if (!response.ok) { - const errorText = await response.text(); - throw new Error(`Failed to get user settings: ${response.status} - ${errorText}`); - } - - const result = await response.json(); - return result.settings || {}; - } catch (error) { - this.logger.error(`Error getting user settings: ${error.message}`); - throw error; - } - } - - async updateMemoroSettings( - settings: { - dataUsageAcceptance?: boolean; - emailNewsletterOptIn?: boolean; - [key: string]: any; - }, - token: string - ): Promise { - try { - const response = await fetch(`${this.manaServiceUrl}/users/settings/memoro`, { - method: 'PATCH', - headers: { - Authorization: `Bearer ${token}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify(settings), - }); - - if (!response.ok) { - const errorText = await response.text(); - throw new Error(`Failed to update Memoro settings: ${response.status} - ${errorText}`); - } - - const result = await response.json(); - return result.settings || {}; - } catch (error) { - this.logger.error(`Error updating Memoro settings: ${error.message}`); - throw error; - } - } - - async updateUserProfile( - profileData: { - firstName?: string; - lastName?: string; - avatarUrl?: string; - }, - token: string - ): Promise { - try { - const response = await fetch(`${this.manaServiceUrl}/users/settings/profile`, { - method: 'PATCH', - headers: { - Authorization: `Bearer ${token}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify(profileData), - }); - - if (!response.ok) { - const errorText = await response.text(); - throw new Error(`Failed to update user profile: ${response.status} - ${errorText}`); - } - - const result = await response.json(); - return result.user || {}; - } catch (error) { - this.logger.error(`Error updating user profile: ${error.message}`); - throw error; - } - } - - async getMemoroSettings(token: string): Promise { - try { - const allSettings = await this.getUserSettings(token); - return allSettings.memoro || {}; - } catch (error) { - this.logger.error(`Error getting Memoro settings: ${error.message}`); - throw error; - } - } - - async updateDataUsageAcceptance(accepted: boolean, token: string): Promise { - return this.updateMemoroSettings({ dataUsageAcceptance: accepted }, token); - } - - async updateEmailNewsletterOptIn(optIn: boolean, token: string): Promise { - return this.updateMemoroSettings({ emailNewsletterOptIn: optIn }, token); - } -} diff --git a/apps/memoro/apps/backend/src/settings/settings.controller.ts b/apps/memoro/apps/backend/src/settings/settings.controller.ts deleted file mode 100644 index 35dbf9ef4..000000000 --- a/apps/memoro/apps/backend/src/settings/settings.controller.ts +++ /dev/null @@ -1,135 +0,0 @@ -import { Controller, Get, Patch, Body, UseGuards, Req, BadRequestException } from '@nestjs/common'; -import { AuthGuard } from '../guards/auth.guard'; -import { SettingsClientService } from './settings-client.service'; - -@Controller('settings') -@UseGuards(AuthGuard) -export class SettingsController { - constructor(private readonly settingsClientService: SettingsClientService) {} - - @Get() - async getSettings(@Req() req) { - const token = req.token; - - try { - const settings = await this.settingsClientService.getUserSettings(token); - return { settings }; - } catch (error) { - throw new BadRequestException(`Failed to get settings: ${error.message}`); - } - } - - @Get('memoro') - async getMemoroSettings(@Req() req) { - const token = req.token; - - try { - const memoSettings = await this.settingsClientService.getMemoroSettings(token); - return { settings: memoSettings }; - } catch (error) { - throw new BadRequestException(`Failed to get Memoro settings: ${error.message}`); - } - } - - @Patch('memoro') - async updateMemoroSettings( - @Req() req, - @Body() - body: { - dataUsageAcceptance?: boolean; - emailNewsletterOptIn?: boolean; - [key: string]: any; - } - ) { - const token = req.token; - - if (Object.keys(body).length === 0) { - throw new BadRequestException('At least one setting field is required'); - } - - try { - const updatedSettings = await this.settingsClientService.updateMemoroSettings(body, token); - return { - success: true, - settings: updatedSettings, - message: 'Memoro settings updated successfully', - }; - } catch (error) { - throw new BadRequestException(`Failed to update Memoro settings: ${error.message}`); - } - } - - @Patch('memoro/data-usage') - async updateDataUsageAcceptance(@Req() req, @Body() body: { accepted: boolean }) { - const token = req.token; - - if (typeof body.accepted !== 'boolean') { - throw new BadRequestException('accepted field must be a boolean'); - } - - try { - const updatedSettings = await this.settingsClientService.updateDataUsageAcceptance( - body.accepted, - token - ); - return { - success: true, - settings: updatedSettings, - message: `Data usage ${body.accepted ? 'accepted' : 'declined'} successfully`, - }; - } catch (error) { - throw new BadRequestException(`Failed to update data usage acceptance: ${error.message}`); - } - } - - @Patch('memoro/email-newsletter') - async updateEmailNewsletterOptIn(@Req() req, @Body() body: { optIn: boolean }) { - const token = req.token; - - if (typeof body.optIn !== 'boolean') { - throw new BadRequestException('optIn field must be a boolean'); - } - - try { - const updatedSettings = await this.settingsClientService.updateEmailNewsletterOptIn( - body.optIn, - token - ); - return { - success: true, - settings: updatedSettings, - message: `Email newsletter ${body.optIn ? 'opted in' : 'opted out'} successfully`, - }; - } catch (error) { - throw new BadRequestException(`Failed to update email newsletter opt-in: ${error.message}`); - } - } - - @Patch('profile') - async updateProfile( - @Req() req, - @Body() - body: { - firstName?: string; - lastName?: string; - avatarUrl?: string; - } - ) { - const token = req.token; - - if (Object.keys(body).length === 0) { - throw new BadRequestException('At least one profile field is required'); - } - - try { - const updatedUser = await this.settingsClientService.updateUserProfile(body, token); - return { - success: true, - user: updatedUser, - message: 'Profile updated successfully', - }; - } catch (error) { - throw new BadRequestException(`Failed to update profile: ${error.message}`); - } - } -} diff --git a/apps/memoro/apps/backend/src/settings/settings.module.ts b/apps/memoro/apps/backend/src/settings/settings.module.ts deleted file mode 100644 index 374d14f1d..000000000 --- a/apps/memoro/apps/backend/src/settings/settings.module.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { Module } from '@nestjs/common'; -import { SettingsController } from './settings.controller'; -import { SettingsClientService } from './settings-client.service'; -import { AuthModule } from '../auth/auth.module'; - -@Module({ - imports: [AuthModule], - controllers: [SettingsController], - providers: [SettingsClientService], - exports: [SettingsClientService], -}) -export class SettingsModule {} diff --git a/apps/memoro/apps/backend/src/spaces/space-sync.service.ts b/apps/memoro/apps/backend/src/spaces/space-sync.service.ts deleted file mode 100644 index bc1024a87..000000000 --- a/apps/memoro/apps/backend/src/spaces/space-sync.service.ts +++ /dev/null @@ -1,283 +0,0 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import { createClient, SupabaseClient } from '@supabase/supabase-js'; - -@Injectable() -export class SpaceSyncService { - private readonly supabaseServiceClient: SupabaseClient; - private readonly logger = new Logger(SpaceSyncService.name); - - constructor(private configService: ConfigService) { - const supabaseUrl = this.configService.get('MEMORO_SUPABASE_URL'); - const supabaseServiceKey = this.configService.get('MEMORO_SUPABASE_SERVICE_KEY'); - - if (!supabaseUrl || !supabaseServiceKey) { - throw new Error('Supabase configuration not provided'); - } - - this.supabaseServiceClient = createClient(supabaseUrl, supabaseServiceKey); - this.logger.log('SpaceSyncService initialized with Supabase service client'); - } - - /** - * Synchronizes a user's space membership to Supabase - * @param spaceId ID of the space - * @param userId ID of the user - * @param role Role of the user in the space - * @param addedBy ID of the user who added this member (optional) - */ - async syncSpaceMembership( - spaceId: string, - userId: string, - role: string, - addedBy?: string - ): Promise { - try { - this.logger.debug( - `Syncing membership for user ${userId} in space ${spaceId} with role ${role}` - ); - - const { error } = await this.supabaseServiceClient.from('space_members').upsert( - { - space_id: spaceId, - user_id: userId, - role: role, - added_at: new Date(), - added_by: addedBy || userId, - }, - { - onConflict: 'space_id,user_id', - } - ); - - if (error) { - this.logger.error(`Failed to sync space membership: ${error.message}`, error); - throw new Error(`Failed to sync space membership: ${error.message}`); - } - - this.logger.log( - `Successfully synced user ${userId} membership to space ${spaceId} with role ${role}` - ); - } catch (error) { - this.logger.error('Error syncing space membership:', error); - throw error; - } - } - - /** - * Removes a user's space membership in Supabase - * @param spaceId ID of the space - * @param userId ID of the user to remove - */ - async removeSpaceMembership(spaceId: string, userId: string): Promise { - try { - this.logger.debug(`Removing membership for user ${userId} from space ${spaceId}`); - - const { error } = await this.supabaseServiceClient - .from('space_members') - .delete() - .eq('space_id', spaceId) - .eq('user_id', userId); - - if (error) { - this.logger.error(`Failed to remove space membership: ${error.message}`, error); - throw new Error(`Failed to remove space membership: ${error.message}`); - } - - this.logger.log(`Successfully removed user ${userId} from space ${spaceId}`); - } catch (error) { - this.logger.error('Error removing space membership:', error); - throw error; - } - } - - /** - * Bulk synchronize all members for a space - * @param spaceId ID of the space - * @param members Array of member objects with userId, role, and optional addedBy - */ - async syncAllSpaceMembers( - spaceId: string, - members: { userId: string; role: string; addedBy?: string }[] - ): Promise { - try { - this.logger.debug(`Bulk syncing ${members.length} members for space ${spaceId}`); - - // First, remove all existing members for this space to avoid stale entries - await this.clearAllSpaceMembers(spaceId); - - // If there are no members to sync, we're done - if (members.length === 0) { - this.logger.log(`No members to sync for space ${spaceId}`); - return; - } - - const memberRecords = members.map((member) => ({ - space_id: spaceId, - user_id: member.userId, - role: member.role, - added_at: new Date(), - added_by: member.addedBy || member.userId, - })); - - const { error } = await this.supabaseServiceClient - .from('space_members') - .upsert(memberRecords, { - onConflict: 'space_id,user_id', - }); - - if (error) { - this.logger.error(`Failed to bulk sync space members: ${error.message}`, error); - throw new Error(`Failed to bulk sync space members: ${error.message}`); - } - - this.logger.log(`Successfully synced ${members.length} members to space ${spaceId}`); - } catch (error) { - this.logger.error('Error bulk syncing space members:', error); - throw error; - } - } - - /** - * Clears all members for a space - * @param spaceId ID of the space - */ - private async clearAllSpaceMembers(spaceId: string): Promise { - try { - this.logger.debug(`Clearing all members for space ${spaceId}`); - - const { error } = await this.supabaseServiceClient - .from('space_members') - .delete() - .eq('space_id', spaceId); - - if (error) { - this.logger.error(`Failed to clear space members: ${error.message}`, error); - throw new Error(`Failed to clear space members: ${error.message}`); - } - - this.logger.log(`Successfully cleared all members from space ${spaceId}`); - } catch (error) { - this.logger.error('Error clearing space members:', error); - throw error; - } - } - - /** - * Check if the space_members table exists - * @returns Boolean indicating if the table exists - */ - async checkSpaceMembersTableExists(): Promise { - try { - // Try to query the table to see if it exists - const { data, error } = await this.supabaseServiceClient - .from('space_members') - .select('id') - .limit(1); - - if (error && error.code === '42P01') { - // Table doesn't exist error - return false; - } - - return true; - } catch (error) { - this.logger.error('Error checking space_members table existence:', error); - return false; - } - } - - /** - * Run the migration to create the space_members table and RLS policies - * @param sqlContent SQL content to run (if not provided, uses default migration) - * @returns Object with success status and message - */ - async runSpaceMembersMigration( - sqlContent?: string - ): Promise<{ success: boolean; message: string }> { - try { - // Check if table already exists - const tableExists = await this.checkSpaceMembersTableExists(); - if (tableExists) { - this.logger.log('space_members table already exists, skipping migration'); - return { success: true, message: 'space_members table already exists' }; - } - - this.logger.log('Running space_members table migration'); - - // Use the provided SQL content or a default migration - const sql = - sqlContent || - ` - -- Create the space_members table for synchronized space membership - CREATE TABLE IF NOT EXISTS space_members ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - space_id UUID NOT NULL, - user_id UUID NOT NULL, - role TEXT NOT NULL, - added_at TIMESTAMP WITH TIME ZONE DEFAULT now(), - added_by UUID, - UNIQUE(space_id, user_id) - ); - - -- Create indexes for better performance - CREATE INDEX IF NOT EXISTS idx_space_members_user_id ON space_members(user_id); - CREATE INDEX IF NOT EXISTS idx_space_members_space_id ON space_members(space_id); - - -- Enable RLS on the table - ALTER TABLE space_members ENABLE ROW LEVEL SECURITY; - - -- Create policies for space_members table - CREATE POLICY "Users can see space membership they are part of" - ON space_members FOR SELECT - USING ( - user_id = auth.uid() OR - space_id IN ( - SELECT space_id FROM space_members - WHERE user_id = auth.uid() - ) - ); - - -- Update memo policies to allow access to memos in spaces user is member of - CREATE POLICY "Users can view memos in spaces they are members of" - ON memos FOR SELECT - USING ( - EXISTS ( - SELECT 1 FROM memo_spaces ms - JOIN space_members sm ON ms.space_id = sm.space_id - WHERE ms.memo_id = memos.id - AND sm.user_id = auth.uid() - ) - ); - - -- Policy for memo_spaces table to allow viewing of memo-space relationships - CREATE POLICY "Users can see memo-space links for spaces they are members of" - ON memo_spaces FOR SELECT - USING ( - EXISTS ( - SELECT 1 FROM space_members - WHERE space_members.space_id = memo_spaces.space_id - AND space_members.user_id = auth.uid() - ) - ); - `; - - // Execute the SQL migration using the service role client - const { error } = await this.supabaseServiceClient.rpc('pgmoon', { query: sql }); - - if (error) { - this.logger.error('Error running space_members migration:', error); - return { success: false, message: `Migration failed: ${error.message}` }; - } - - this.logger.log('Successfully ran space_members table migration'); - return { - success: true, - message: 'Successfully created space_members table and RLS policies', - }; - } catch (error) { - this.logger.error('Error running space_members migration:', error); - return { success: false, message: `Migration failed: ${error.message}` }; - } - } -} diff --git a/apps/memoro/apps/backend/src/spaces/spaces-client.service.ts b/apps/memoro/apps/backend/src/spaces/spaces-client.service.ts deleted file mode 100644 index b67befcd2..000000000 --- a/apps/memoro/apps/backend/src/spaces/spaces-client.service.ts +++ /dev/null @@ -1,536 +0,0 @@ -import { - Injectable, - NotFoundException, - ForbiddenException, - BadRequestException, -} from '@nestjs/common'; -import { HttpService } from '@nestjs/axios'; -import { ConfigService } from '@nestjs/config'; -import { Observable, catchError, firstValueFrom, map, tap } from 'rxjs'; -import { AxiosError } from 'axios'; -import { - SpaceDto, - PendingInvitesResponseDto, - SpaceInviteDto, -} from '../interfaces/spaces.interfaces'; - -@Injectable() -export class SpacesClientService { - private spacesServiceUrl: string; - private memoroAppId: string; - - constructor( - private httpService: HttpService, - private configService: ConfigService - ) { - this.spacesServiceUrl = this.configService.get( - 'MANA_SERVICE_URL', - 'http://localhost:3000' - ); - this.memoroAppId = this.configService.get( - 'MEMORO_APP_ID', - '973da0c1-b479-4dac-a1b0-ed09c72caca8' - ); - } - - /** - * Gets spaces for a user by calling the Spaces service - */ - async getUserSpaces(userId: string, token: string): Promise { - try { - console.log(`Calling spaces service at: ${this.spacesServiceUrl}/spaces`); - const response = await firstValueFrom( - this.httpService - .get(`${this.spacesServiceUrl}/spaces`, { - headers: { - Authorization: `Bearer ${token}`, - 'Content-Type': 'application/json', - }, - }) - .pipe( - map((response) => response.data), - catchError((error: AxiosError) => { - if (error.response?.status === 404) { - console.error('HERE'); - throw new NotFoundException('Spaces not found'); - } - throw new BadRequestException('Could not fetch spaces'); - }) - ) - ); - - // Log the response for debugging - console.log('Spaces response received:', JSON.stringify(response, null, 2)); - - // Extract the spaces array from the response object - const spaces = response.spaces || []; - console.log(`Extracted ${spaces.length} spaces from response`); - - return spaces; - } catch (error) { - if (error instanceof NotFoundException || error instanceof BadRequestException) { - throw error; - } - throw new BadRequestException('Could not fetch spaces'); - } - } - - /** - * Gets all invites for a space by calling the Spaces service - * @param spaceId The ID of the space to get invites for - * @param token Optional JWT token for authorization - * @returns Array of space invites - */ - /** - * Invites a user to a space by email - * @param spaceId The ID of the space to invite to - * @param userEmail The email of the user to invite - * @param role The role to assign (owner, admin, editor, viewer) - * @param token JWT token for authorization - * @returns Object containing the inviteId - */ - async addSpaceMember( - spaceId: string, - userEmail: string, - role: string, - token: string - ): Promise { - try { - console.log(`Adding member to space ${spaceId}: ${userEmail} with role ${role}`); - const response = await firstValueFrom( - this.httpService - .post( - `${this.spacesServiceUrl}/spaces/members`, - { - spaceId, - userEmail, - role, - }, - { - headers: { - Authorization: `Bearer ${token}`, - 'Content-Type': 'application/json', - }, - } - ) - .pipe( - map((response) => response.data), - catchError((error: AxiosError) => { - if (error.response?.status === 404) { - throw new NotFoundException('Space not found'); - } else if (error.response?.status === 403) { - throw new ForbiddenException('Not authorized to invite members to this space'); - } - console.error('Error sending space invite:', error.message); - throw new BadRequestException(`Could not invite user to space: ${error.message}`); - }) - ) - ); - - return response; - } catch (error) { - console.error(`Error in addSpaceMember for space ${spaceId}:`, error); - throw error; - } - } - - /** - * Resends an invitation to a user - * @param inviteId The ID of the invitation to resend - * @param token JWT token for authorization - * @returns Success status - */ - async resendInvite(inviteId: string, token: string): Promise { - try { - console.log(`Resending invite with ID: ${inviteId}`); - const response = await firstValueFrom( - this.httpService - .post( - `${this.spacesServiceUrl}/spaces/invites/${inviteId}/resend`, - {}, - { - headers: { - Authorization: `Bearer ${token}`, - 'Content-Type': 'application/json', - }, - } - ) - .pipe( - map((response) => response.data), - catchError((error: AxiosError) => { - if (error.response?.status === 404) { - throw new NotFoundException('Invitation not found'); - } else if (error.response?.status === 403) { - throw new ForbiddenException('Not authorized to resend this invitation'); - } - console.error('Error resending invitation:', error.message); - throw new BadRequestException(`Could not resend invitation: ${error.message}`); - }) - ) - ); - - return response; - } catch (error) { - console.error(`Error in resendInvite for invite ${inviteId}:`, error); - throw error; - } - } - - /** - * Cancels an invitation - * @param inviteId The ID of the invitation to cancel - * @param token JWT token for authorization - * @returns Success status - */ - async cancelInvite(inviteId: string, token: string): Promise { - try { - console.log(`Canceling invite with ID: ${inviteId}`); - const response = await firstValueFrom( - this.httpService - .delete(`${this.spacesServiceUrl}/spaces/invites/${inviteId}`, { - headers: { - Authorization: `Bearer ${token}`, - 'Content-Type': 'application/json', - }, - }) - .pipe( - map((response) => response.data), - catchError((error: AxiosError) => { - if (error.response?.status === 404) { - throw new NotFoundException('Invitation not found'); - } else if (error.response?.status === 403) { - throw new ForbiddenException('Not authorized to cancel this invitation'); - } - console.error('Error canceling invitation:', error.message); - throw new BadRequestException(`Could not cancel invitation: ${error.message}`); - }) - ) - ); - - return response; - } catch (error) { - console.error(`Error in cancelInvite for invite ${inviteId}:`, error); - throw error; - } - } - - async getSpaceInvites(spaceId: string, token?: string): Promise { - try { - // Special case: if 'user' is passed as spaceId, redirect to getUserPendingInvites - // This handles backward compatibility with frontend code that might be using - // the wrong endpoint - if (spaceId === 'user') { - console.log('Redirecting getSpaceInvites("user") to getUserPendingInvites()'); - return this.getUserPendingInvites(token); - } - - // Validate spaceId to ensure it's a valid value - if (!spaceId || spaceId.length < 5) { - throw new BadRequestException(`Invalid space ID: ${spaceId}`); - } - - console.log( - `Getting space invites at: ${this.spacesServiceUrl}/spaces/space/${spaceId}/invites` - ); - const response = await firstValueFrom( - this.httpService - .get(`${this.spacesServiceUrl}/spaces/space/${spaceId}/invites`, { - headers: { - Authorization: token ? `Bearer ${token}` : '', - 'Content-Type': 'application/json', - }, - }) - .pipe( - map((response) => response.data), - catchError((error: AxiosError) => { - if (error.response?.status === 404) { - throw new NotFoundException(`Invites for space ${spaceId} not found`); - } else if (error.response?.status === 403) { - throw new ForbiddenException( - `Not authorized to access invites for space ${spaceId}` - ); - } - console.error('Error fetching space invites:', error.message); - throw new BadRequestException(`Could not fetch invites for space ${spaceId}`); - }) - ) - ); - - return response; - } catch (error) { - console.error(`Error in getSpaceInvites for space ${spaceId}:`, error); - throw error; - } - } - - /** - * Gets space details by calling the Spaces service - */ - async getSpaceDetails(spaceId: string, token?: string): Promise { - try { - console.log(`Getting space details at: ${this.spacesServiceUrl}/spaces/${spaceId}`); - const response = await firstValueFrom( - this.httpService - .get(`${this.spacesServiceUrl}/spaces/${spaceId}`, { - headers: { - Authorization: `Bearer ${token}`, - 'Content-Type': 'application/json', - }, - }) - .pipe( - map((response) => response.data), - catchError((error: AxiosError) => { - if (error.response?.status === 404) { - throw new NotFoundException('Space not found'); - } else if (error.response?.status === 403) { - throw new ForbiddenException('Access denied'); - } - throw new BadRequestException('Could not fetch space details'); - }) - ) - ); - - return response; - } catch (error) { - if ( - error instanceof NotFoundException || - error instanceof ForbiddenException || - error instanceof BadRequestException - ) { - throw error; - } - throw new BadRequestException('Could not fetch space details'); - } - } - - /** - }, - ).pipe( - map((response) => response.data), - catchError((error: AxiosError) => { - throw new BadRequestException('Could not create space'); - }), - ), - ); - - return response; - } catch (error) { - throw new BadRequestException('Could not create space'); - } - } - - /** - * Creates a new space by calling the Spaces service - */ - async createSpace(userId: string, spaceName: string, token: string) { - try { - console.log(`Creating space at: ${this.spacesServiceUrl}/spaces`); - // Hardcode the UUID to test if this resolves the issue - const appId = '973da0c1-b479-4dac-a1b0-ed09c72caca8'; - console.log(`Using hardcoded app ID: ${appId}`); - - const response = await firstValueFrom( - this.httpService - .post( - `${this.spacesServiceUrl}/spaces`, - { - name: spaceName, - appId, // Field name must match CreateSpaceDto in middleware - }, - { - headers: { - Authorization: `Bearer ${token}`, - 'Content-Type': 'application/json', - }, - } - ) - .pipe( - map((response) => response.data), - catchError((error: AxiosError) => { - throw new BadRequestException('Could not create space'); - }) - ) - ); - - return response; - } catch (error) { - throw new BadRequestException('Could not create space'); - } - } - - /** - * Verifies a user has access to a Memoro space and returns access details - */ - async verifySpaceAccess(userId: string, spaceId: string, token: string): Promise { - try { - console.log(`Verifying space access at: ${this.spacesServiceUrl}/spaces/${spaceId}/access`); - const response = await firstValueFrom( - this.httpService - .get(`${this.spacesServiceUrl}/spaces/${spaceId}/access`, { - headers: { - Authorization: `Bearer ${token}`, - 'Content-Type': 'application/json', - }, - }) - .pipe( - map((response) => response.data), - catchError((error: AxiosError) => { - if (error.response?.status === 404) { - throw new NotFoundException('Space not found'); - } else if (error.response?.status === 403) { - throw new ForbiddenException('Access denied'); - } - throw new BadRequestException('Could not verify space access'); - }) - ) - ); - - // Verify this is a Memoro space - if (response.space.app_id !== this.memoroAppId) { - throw new ForbiddenException('This is not a Memoro space'); - } - - // Return the full response which includes access information - return response; - } catch (error) { - if (error instanceof NotFoundException) { - return { access: { hasAccess: false, role: 'none' } }; - } else if (error instanceof ForbiddenException) { - return { access: { hasAccess: false, role: 'none' } }; - } - console.error(`Failed to verify space access: ${error.message}`); - return { access: { hasAccess: false, role: 'none' } }; - } - } - - /** - * Allow a non-owner to leave a space - */ - async leaveSpace(userId: string, spaceId: string, token: string): Promise { - try { - console.log(`Leaving space at: ${this.spacesServiceUrl}/spaces/${spaceId}/leave`); - const response = await firstValueFrom( - this.httpService - .post( - `${this.spacesServiceUrl}/spaces/${spaceId}/leave`, - {}, // Empty body - { - headers: { - Authorization: `Bearer ${token}`, - 'Content-Type': 'application/json', - }, - } - ) - .pipe( - map((response) => response.data), - catchError((error: AxiosError) => { - if (error.response?.status === 404) { - throw new NotFoundException('Space not found'); - } else if (error.response?.status === 403) { - // Safely access potential message in error response - const message = - typeof error.response?.data === 'object' && error.response?.data - ? (error.response.data as any).message || 'Access denied' - : 'Access denied'; - throw new ForbiddenException(message); - } - throw new BadRequestException('Could not leave space'); - }) - ) - ); - - return response; - } catch (error) { - if (error instanceof NotFoundException || error instanceof ForbiddenException) { - throw error; - } - throw new BadRequestException('Could not leave space'); - } - } - - /** - * Gets all pending invites for the current user - * @param token JWT token for authorization - * @returns Array of pending invites - */ - async getUserPendingInvites(token: string): Promise { - try { - console.log( - `Getting user pending invites at: ${this.spacesServiceUrl}/spaces/user/invites')` - ); - const response = await firstValueFrom( - this.httpService - .get(`${this.spacesServiceUrl}/spaces/user/invites`, { - headers: { - Authorization: `Bearer ${token}`, - 'Content-Type': 'application/json', - }, - }) - .pipe( - map((response) => response.data), - catchError((error: AxiosError) => { - if (error.response?.status === 404) { - throw new NotFoundException('Pending invites not found'); - } else if (error.response?.status === 403) { - throw new ForbiddenException('Not authorized to access pending invites'); - } - console.error('Error fetching pending invites:', error.message); - throw new BadRequestException('Could not fetch pending invites'); - }) - ) - ); - console.log(' WE are here in response'); - return response; - } catch (error) { - console.error(`Error in getUserPendingInvites:`, error); - if ( - error instanceof NotFoundException || - error instanceof ForbiddenException || - error instanceof BadRequestException - ) { - throw error; - } - throw new BadRequestException('Could not fetch pending invites'); - } - } - - /** - * Deletes a space by calling the Spaces service - */ - async deleteSpace(userId: string, spaceId: string, token: string): Promise { - try { - console.log(`Deleting space at: ${this.spacesServiceUrl}/spaces/${spaceId}`); - const response = await firstValueFrom( - this.httpService - .delete(`${this.spacesServiceUrl}/spaces/${spaceId}`, { - headers: { - Authorization: `Bearer ${token}`, - 'Content-Type': 'application/json', - }, - }) - .pipe( - map((response) => response.data), - catchError((error: AxiosError) => { - if (error.response?.status === 404) { - throw new NotFoundException('Space not found'); - } else if (error.response?.status === 403) { - throw new ForbiddenException('Access denied'); - } - throw new BadRequestException('Could not delete space'); - }) - ) - ); - - return response; - } catch (error) { - if ( - error instanceof NotFoundException || - error instanceof ForbiddenException || - error instanceof BadRequestException - ) { - throw error; - } - throw new BadRequestException('Could not delete space'); - } - } -} diff --git a/apps/memoro/apps/backend/src/spaces/spaces.module.ts b/apps/memoro/apps/backend/src/spaces/spaces.module.ts deleted file mode 100644 index 433ec9468..000000000 --- a/apps/memoro/apps/backend/src/spaces/spaces.module.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { Module } from '@nestjs/common'; -import { HttpModule } from '@nestjs/axios'; -import { ConfigModule } from '@nestjs/config'; -import { SpacesClientService } from './spaces-client.service'; -import { SpaceSyncService } from './space-sync.service'; - -@Module({ - imports: [HttpModule, ConfigModule], - providers: [SpacesClientService, SpaceSyncService], - exports: [SpacesClientService, SpaceSyncService], -}) -export class SpacesModule {} diff --git a/apps/memoro/apps/backend/src/types/jwt-payload.interface.ts b/apps/memoro/apps/backend/src/types/jwt-payload.interface.ts deleted file mode 100644 index bac88bb29..000000000 --- a/apps/memoro/apps/backend/src/types/jwt-payload.interface.ts +++ /dev/null @@ -1,9 +0,0 @@ -export interface JwtPayload { - sub: string; // User ID - email?: string; // User email (optional) - role: string; // User role - app_id: string; // App ID - aud: string; // Audience (usually 'authenticated') - iat?: number; // Issued at - exp?: number; // Expiration time -} diff --git a/apps/memoro/apps/backend/supabase/.gitignore b/apps/memoro/apps/backend/supabase/.gitignore deleted file mode 100644 index ad9264f0b..000000000 --- a/apps/memoro/apps/backend/supabase/.gitignore +++ /dev/null @@ -1,8 +0,0 @@ -# Supabase -.branches -.temp - -# dotenvx -.env.keys -.env.local -.env.*.local diff --git a/apps/memoro/apps/backend/supabase/config.toml b/apps/memoro/apps/backend/supabase/config.toml deleted file mode 100644 index d44022c1f..000000000 --- a/apps/memoro/apps/backend/supabase/config.toml +++ /dev/null @@ -1,388 +0,0 @@ -# For detailed configuration reference documentation, visit: -# https://supabase.com/docs/guides/local-development/cli/config -# A string used to distinguish different Supabase projects on the same host. Defaults to the -# working directory name when running `supabase init`. -project_id = "memoro_middleware" - -[api] -enabled = true -# Port to use for the API URL. -port = 54321 -# Schemas to expose in your API. Tables, views and stored procedures in this schema will get API -# endpoints. `public` and `graphql_public` schemas are included by default. -schemas = ["public", "graphql_public"] -# Extra schemas to add to the search_path of every request. -extra_search_path = ["public", "extensions"] -# The maximum number of rows returns from a view, table, or stored procedure. Limits payload size -# for accidental or malicious requests. -max_rows = 1000 - -[api.tls] -# Enable HTTPS endpoints locally using a self-signed certificate. -enabled = false -# Paths to self-signed certificate pair. -# cert_path = "../certs/my-cert.pem" -# key_path = "../certs/my-key.pem" - -[db] -# Port to use for the local database URL. -port = 54322 -# Port used by db diff command to initialize the shadow database. -shadow_port = 54320 -# Maximum amount of time to wait for health check when starting the local database. -health_timeout = "2m" -# The database major version to use. This has to be the same as your remote database's. Run `SHOW -# server_version;` on the remote database to check. -major_version = 17 - -[db.pooler] -enabled = false -# Port to use for the local connection pooler. -port = 54329 -# Specifies when a server connection can be reused by other clients. -# Configure one of the supported pooler modes: `transaction`, `session`. -pool_mode = "transaction" -# How many server connections to allow per user/database pair. -default_pool_size = 20 -# Maximum number of client connections allowed. -max_client_conn = 100 - -# [db.vault] -# secret_key = "env(SECRET_VALUE)" - -[db.migrations] -# If disabled, migrations will be skipped during a db push or reset. -enabled = true -# Specifies an ordered list of schema files that describe your database. -# Supports glob patterns relative to supabase directory: "./schemas/*.sql" -schema_paths = [] - -[db.seed] -# If enabled, seeds the database after migrations during a db reset. -enabled = true -# Specifies an ordered list of seed files to load during db reset. -# Supports glob patterns relative to supabase directory: "./seeds/*.sql" -sql_paths = ["./seed.sql"] - -[db.network_restrictions] -# Enable management of network restrictions. -enabled = false -# List of IPv4 CIDR blocks allowed to connect to the database. -# Defaults to allow all IPv4 connections. Set empty array to block all IPs. -allowed_cidrs = ["0.0.0.0/0"] -# List of IPv6 CIDR blocks allowed to connect to the database. -# Defaults to allow all IPv6 connections. Set empty array to block all IPs. -allowed_cidrs_v6 = ["::/0"] - -# Uncomment to reject non-secure connections to the database. -# [db.ssl_enforcement] -# enabled = true - -[realtime] -enabled = true -# Bind realtime via either IPv4 or IPv6. (default: IPv4) -# ip_version = "IPv6" -# The maximum length in bytes of HTTP request headers. (default: 4096) -# max_header_length = 4096 - -[studio] -enabled = true -# Port to use for Supabase Studio. -port = 54323 -# External URL of the API server that frontend connects to. -api_url = "http://127.0.0.1" -# OpenAI API Key to use for Supabase AI in the Supabase Studio. -openai_api_key = "env(OPENAI_API_KEY)" - -# Email testing server. Emails sent with the local dev setup are not actually sent - rather, they -# are monitored, and you can view the emails that would have been sent from the web interface. -[inbucket] -enabled = true -# Port to use for the email testing server web interface. -port = 54324 -# Uncomment to expose additional ports for testing user applications that send emails. -# smtp_port = 54325 -# pop3_port = 54326 -# admin_email = "admin@email.com" -# sender_name = "Admin" - -[storage] -enabled = true -# The maximum file size allowed (e.g. "5MB", "500KB"). -file_size_limit = "50MiB" - -# Uncomment to configure local storage buckets -# [storage.buckets.images] -# public = false -# file_size_limit = "50MiB" -# allowed_mime_types = ["image/png", "image/jpeg"] -# objects_path = "./images" - -# Allow connections via S3 compatible clients -[storage.s3_protocol] -enabled = true - -# Image transformation API is available to Supabase Pro plan. -# [storage.image_transformation] -# enabled = true - -# Store analytical data in S3 for running ETL jobs over Iceberg Catalog -# This feature is only available on the hosted platform. -[storage.analytics] -enabled = false -max_namespaces = 5 -max_tables = 10 -max_catalogs = 2 - -# Analytics Buckets is available to Supabase Pro plan. -# [storage.analytics.buckets.my-warehouse] - -# Store vector embeddings in S3 for large and durable datasets -# This feature is only available on the hosted platform. -[storage.vector] -enabled = false -max_buckets = 10 -max_indexes = 5 - -# Vector Buckets is available to Supabase Pro plan. -# [storage.vector.buckets.documents-openai] - -[auth] -enabled = true -# The base URL of your website. Used as an allow-list for redirects and for constructing URLs used -# in emails. -site_url = "http://127.0.0.1:3000" -# A list of *exact* URLs that auth providers are permitted to redirect to post authentication. -additional_redirect_urls = ["https://127.0.0.1:3000"] -# How long tokens are valid for, in seconds. Defaults to 3600 (1 hour), maximum 604,800 (1 week). -jwt_expiry = 3600 -# JWT issuer URL. If not set, defaults to the local API URL (http://127.0.0.1:/auth/v1). -# jwt_issuer = "" -# Path to JWT signing key. DO NOT commit your signing keys file to git. -# signing_keys_path = "./signing_keys.json" -# If disabled, the refresh token will never expire. -enable_refresh_token_rotation = true -# Allows refresh tokens to be reused after expiry, up to the specified interval in seconds. -# Requires enable_refresh_token_rotation = true. -refresh_token_reuse_interval = 10 -# Allow/disallow new user signups to your project. -enable_signup = true -# Allow/disallow anonymous sign-ins to your project. -enable_anonymous_sign_ins = false -# Allow/disallow testing manual linking of accounts -enable_manual_linking = false -# Passwords shorter than this value will be rejected as weak. Minimum 6, recommended 8 or more. -minimum_password_length = 6 -# Passwords that do not meet the following requirements will be rejected as weak. Supported values -# are: `letters_digits`, `lower_upper_letters_digits`, `lower_upper_letters_digits_symbols` -password_requirements = "" - -[auth.rate_limit] -# Number of emails that can be sent per hour. Requires auth.email.smtp to be enabled. -email_sent = 2 -# Number of SMS messages that can be sent per hour. Requires auth.sms to be enabled. -sms_sent = 30 -# Number of anonymous sign-ins that can be made per hour per IP address. Requires enable_anonymous_sign_ins = true. -anonymous_users = 30 -# Number of sessions that can be refreshed in a 5 minute interval per IP address. -token_refresh = 150 -# Number of sign up and sign-in requests that can be made in a 5 minute interval per IP address (excludes anonymous users). -sign_in_sign_ups = 30 -# Number of OTP / Magic link verifications that can be made in a 5 minute interval per IP address. -token_verifications = 30 -# Number of Web3 logins that can be made in a 5 minute interval per IP address. -web3 = 30 - -# Configure one of the supported captcha providers: `hcaptcha`, `turnstile`. -# [auth.captcha] -# enabled = true -# provider = "hcaptcha" -# secret = "" - -[auth.email] -# Allow/disallow new user signups via email to your project. -enable_signup = true -# If enabled, a user will be required to confirm any email change on both the old, and new email -# addresses. If disabled, only the new email is required to confirm. -double_confirm_changes = true -# If enabled, users need to confirm their email address before signing in. -enable_confirmations = false -# If enabled, users will need to reauthenticate or have logged in recently to change their password. -secure_password_change = false -# Controls the minimum amount of time that must pass before sending another signup confirmation or password reset email. -max_frequency = "1s" -# Number of characters used in the email OTP. -otp_length = 6 -# Number of seconds before the email OTP expires (defaults to 1 hour). -otp_expiry = 3600 - -# Use a production-ready SMTP server -# [auth.email.smtp] -# enabled = true -# host = "smtp.sendgrid.net" -# port = 587 -# user = "apikey" -# pass = "env(SENDGRID_API_KEY)" -# admin_email = "admin@email.com" -# sender_name = "Admin" - -# Uncomment to customize email template -# [auth.email.template.invite] -# subject = "You have been invited" -# content_path = "./supabase/templates/invite.html" - -# Uncomment to customize notification email template -# [auth.email.notification.password_changed] -# enabled = true -# subject = "Your password has been changed" -# content_path = "./templates/password_changed_notification.html" - -[auth.sms] -# Allow/disallow new user signups via SMS to your project. -enable_signup = false -# If enabled, users need to confirm their phone number before signing in. -enable_confirmations = false -# Template for sending OTP to users -template = "Your code is {{ .Code }}" -# Controls the minimum amount of time that must pass before sending another sms otp. -max_frequency = "5s" - -# Use pre-defined map of phone number to OTP for testing. -# [auth.sms.test_otp] -# 4152127777 = "123456" - -# Configure logged in session timeouts. -# [auth.sessions] -# Force log out after the specified duration. -# timebox = "24h" -# Force log out if the user has been inactive longer than the specified duration. -# inactivity_timeout = "8h" - -# This hook runs before a new user is created and allows developers to reject the request based on the incoming user object. -# [auth.hook.before_user_created] -# enabled = true -# uri = "pg-functions://postgres/auth/before-user-created-hook" - -# This hook runs before a token is issued and allows you to add additional claims based on the authentication method used. -# [auth.hook.custom_access_token] -# enabled = true -# uri = "pg-functions:////" - -# Configure one of the supported SMS providers: `twilio`, `twilio_verify`, `messagebird`, `textlocal`, `vonage`. -[auth.sms.twilio] -enabled = false -account_sid = "" -message_service_sid = "" -# DO NOT commit your Twilio auth token to git. Use environment variable substitution instead: -auth_token = "env(SUPABASE_AUTH_SMS_TWILIO_AUTH_TOKEN)" - -# Multi-factor-authentication is available to Supabase Pro plan. -[auth.mfa] -# Control how many MFA factors can be enrolled at once per user. -max_enrolled_factors = 10 - -# Control MFA via App Authenticator (TOTP) -[auth.mfa.totp] -enroll_enabled = false -verify_enabled = false - -# Configure MFA via Phone Messaging -[auth.mfa.phone] -enroll_enabled = false -verify_enabled = false -otp_length = 6 -template = "Your code is {{ .Code }}" -max_frequency = "5s" - -# Configure MFA via WebAuthn -# [auth.mfa.web_authn] -# enroll_enabled = true -# verify_enabled = true - -# Use an external OAuth provider. The full list of providers are: `apple`, `azure`, `bitbucket`, -# `discord`, `facebook`, `github`, `gitlab`, `google`, `keycloak`, `linkedin_oidc`, `notion`, `twitch`, -# `twitter`, `x`, `slack`, `spotify`, `workos`, `zoom`. -[auth.external.apple] -enabled = false -client_id = "" -# DO NOT commit your OAuth provider secret to git. Use environment variable substitution instead: -secret = "env(SUPABASE_AUTH_EXTERNAL_APPLE_SECRET)" -# Overrides the default auth redirectUrl. -redirect_uri = "" -# Overrides the default auth provider URL. Used to support self-hosted gitlab, single-tenant Azure, -# or any other third-party OIDC providers. -url = "" -# If enabled, the nonce check will be skipped. Required for local sign in with Google auth. -skip_nonce_check = false -# If enabled, it will allow the user to successfully authenticate when the provider does not return an email address. -email_optional = false - -# Allow Solana wallet holders to sign in to your project via the Sign in with Solana (SIWS, EIP-4361) standard. -# You can configure "web3" rate limit in the [auth.rate_limit] section and set up [auth.captcha] if self-hosting. -[auth.web3.solana] -enabled = false - -# Use Firebase Auth as a third-party provider alongside Supabase Auth. -[auth.third_party.firebase] -enabled = false -# project_id = "my-firebase-project" - -# Use Auth0 as a third-party provider alongside Supabase Auth. -[auth.third_party.auth0] -enabled = false -# tenant = "my-auth0-tenant" -# tenant_region = "us" - -# Use AWS Cognito (Amplify) as a third-party provider alongside Supabase Auth. -[auth.third_party.aws_cognito] -enabled = false -# user_pool_id = "my-user-pool-id" -# user_pool_region = "us-east-1" - -# Use Clerk as a third-party provider alongside Supabase Auth. -[auth.third_party.clerk] -enabled = false -# Obtain from https://clerk.com/setup/supabase -# domain = "example.clerk.accounts.dev" - -# OAuth server configuration -[auth.oauth_server] -# Enable OAuth server functionality -enabled = false -# Path for OAuth consent flow UI -authorization_url_path = "/oauth/consent" -# Allow dynamic client registration -allow_dynamic_registration = false - -[edge_runtime] -enabled = true -# Supported request policies: `oneshot`, `per_worker`. -# `per_worker` (default) — enables hot reload during local development. -# `oneshot` — fallback mode if hot reload causes issues (e.g. in large repos or with symlinks). -policy = "per_worker" -# Port to attach the Chrome inspector for debugging edge functions. -inspector_port = 8083 -# The Deno major version to use. -deno_version = 2 - -# [edge_runtime.secrets] -# secret_key = "env(SECRET_VALUE)" - -[analytics] -enabled = true -port = 54327 -# Configure one of the supported backends: `postgres`, `bigquery`. -backend = "postgres" - -# Experimental features may be deprecated any time -[experimental] -# Configures Postgres storage engine to use OrioleDB (S3) -orioledb_version = "" -# Configures S3 bucket URL, eg. .s3-.amazonaws.com -s3_host = "env(S3_HOST)" -# Configures S3 bucket region, eg. us-east-1 -s3_region = "env(S3_REGION)" -# Configures AWS_ACCESS_KEY_ID for S3 bucket -s3_access_key = "env(S3_ACCESS_KEY)" -# Configures AWS_SECRET_ACCESS_KEY for S3 bucket -s3_secret_key = "env(S3_SECRET_KEY)" diff --git a/apps/memoro/apps/backend/supabase/functions/_shared/system-prompt.ts b/apps/memoro/apps/backend/supabase/functions/_shared/system-prompt.ts deleted file mode 100644 index 2f5eb38b1..000000000 --- a/apps/memoro/apps/backend/supabase/functions/_shared/system-prompt.ts +++ /dev/null @@ -1,199 +0,0 @@ -/** - * Root System Prompts für alle Edge Functions - * - * Diese Prompts werden als Basis für alle Text-Analyse und Verarbeitungsfunktionen verwendet. - * Jede Sprache hat ihren eigenen Prompt, der die spezifischen Anforderungen berücksichtigt. - */ - -export const ROOT_SYSTEM_PROMPTS = { - PRE_PROMPT: { - // Deutsch - de: 'Du bist ein hilfreicher Assistent, der Texte analysiert und verarbeitet. Deine Aufgabe ist es, Transkripte von Gesprächen gemäß den gegebenen Anweisungen zu bearbeiten. Antworte in Markdown mit einem schönen Format. Nutze keine Tabellen und keinen Code in Markdown. Antworte präzise, strukturiert und hilfreich.', - - // Englisch - en: 'You are a helpful assistant that analyzes and processes texts. Your task is to process conversation transcripts according to the given instructions. Respond in Markdown with a nice format. Do not use tables or code in Markdown. Respond precisely, structured, and helpfully.', - - // Französisch - fr: "Vous êtes un assistant utile qui analyse et traite les textes. Votre tâche est de traiter les transcriptions de conversations selon les instructions données. Répondez en Markdown avec un beau format. N'utilisez pas de tableaux ou de code en Markdown. Répondez de manière précise, structurée et utile.", - - // Spanisch - es: 'Eres un asistente útil que analiza y procesa textos. Tu tarea es procesar transcripciones de conversaciones según las instrucciones dadas. Responde en Markdown con un formato atractivo. No uses tablas o código en Markdown. Responde de manera precisa, estructurada y útil.', - - // Italienisch - it: 'Sei un assistente utile che analizza ed elabora testi. Il tuo compito è elaborare trascrizioni di conversazioni secondo le istruzioni fornite. Rispondi in Markdown con un bel formato. Non usare tabelle o codice in Markdown. Rispondi in modo preciso, strutturato e utile.', - - // Niederländisch - nl: 'Je bent een behulpzame assistent die teksten analyseert en verwerkt. Je taak is om transcripties van gesprekken te verwerken volgens de gegeven instructies. Antwoord in Markdown met een mooi formaat. Gebruik geen tabellen of code in Markdown. Antwoord precies, gestructureerd en behulpzaam.', - - // Portugiesisch - pt: 'Você é um assistente útil que analisa e processa textos. Sua tarefa é processar transcrições de conversas de acordo com as instruções fornecidas. Responda em Markdown com um formato bonito. Não use tabelas ou código em Markdown. Responda de forma precisa, estruturada e útil.', - - // Russisch - ru: 'Вы полезный помощник, который анализирует и обрабатывает тексты. Ваша задача - обрабатывать расшифровки разговоров в соответствии с данными инструкциями. Отвечайте в Markdown с красивым форматированием. Не используйте таблицы или код в Markdown. Отвечайте точно, структурированно и полезно.', - - // Japanisch - ja: 'あなたはテキストを分析し処理する有用なアシスタントです。あなたの仕事は、与えられた指示に従って会話の文字起こしを処理することです。Markdownで美しいフォーマットで回答してください。Markdownでテーブルやコードを使用しないでください。正確で、構造化され、役立つように回答してください。', - - // Koreanisch - ko: '당신은 텍스트를 분석하고 처리하는 유용한 어시스턴트입니다. 당신의 임무는 주어진 지시에 따라 대화 녹취록을 처리하는 것입니다. 멋진 형식의 Markdown으로 응답하세요. Markdown에서 표나 코드를 사용하지 마세요. 정확하고 구조화되며 도움이 되도록 응답하세요.', - - // Chinesisch - zh: '你是一个有用的助手,分析和处理文本。你的任务是根据给定的指示处理对话记录。以优美的Markdown格式回复。不要在Markdown中使用表格或代码。回复要准确、有条理、有帮助。', - - // Arabisch - ar: 'أنت مساعد مفيد يحلل ويعالج النصوص. مهمتك هي معالجة نصوص المحادثات وفقًا للتعليمات المعطاة. أجب بتنسيق Markdown جميل. لا تستخدم الجداول أو الكود في Markdown. أجب بدقة وبشكل منظم ومفيد.', - - // Hindi - hi: 'आप एक सहायक सहायक हैं जो ग्रंथों का विश्लेषण और प्रसंस्करण करते हैं। आपका कार्य दिए गए निर्देशों के अनुसार वार्तालाप प्रतिलेखों को संसाधित करना है। एक अच्छे प्रारूप के साथ Markdown में उत्तर दें। Markdown में तालिकाओं या कोड का उपयोग न करें। सटीक, संरचित और सहायक रूप से उत्तर दें।', - - // Türkisch - tr: "Metinleri analiz eden ve işleyen yardımcı bir asistansınız. Göreviniz, verilen talimatlara göre konuşma transkriptlerini işlemektir. Güzel bir formatla Markdown'da yanıt verin. Markdown'da tablo veya kod kullanmayın. Kesin, yapılandırılmış ve yararlı bir şekilde yanıt verin.", - - // Polnisch - pl: 'Jesteś pomocnym asystentem, który analizuje i przetwarza teksty. Twoim zadaniem jest przetwarzanie transkrypcji rozmów zgodnie z podanymi instrukcjami. Odpowiadaj w Markdown z ładnym formatowaniem. Nie używaj tabel ani kodu w Markdown. Odpowiadaj precyzyjnie, strukturalnie i pomocnie.', - - // Dänisch - da: 'Du er en hjælpsom assistent, der analyserer og behandler tekster. Din opgave er at behandle samtaleudskrifter i henhold til de givne instruktioner. Svar i Markdown med et pænt format. Brug ikke tabeller eller kode i Markdown. Svar præcist, struktureret og hjælpsomt.', - - // Schwedisch - sv: 'Du är en hjälpsam assistent som analyserar och bearbetar texter. Din uppgift är att bearbeta samtalstranskriptioner enligt givna instruktioner. Svara i Markdown med ett snyggt format. Använd inte tabeller eller kod i Markdown. Svara exakt, strukturerat och hjälpsamt.', - - // Norwegisch - nb: 'Du er en hjelpsom assistent som analyserer og behandler tekster. Din oppgave er å behandle samtaletranskripsjoner i henhold til gitte instruksjoner. Svar i Markdown med et pent format. Ikke bruk tabeller eller kode i Markdown. Svar presist, strukturert og hjelpsomt.', - - // Finnisch - fi: 'Olet hyödyllinen avustaja, joka analysoi ja käsittelee tekstejä. Tehtäväsi on käsitellä keskustelulitterointeja annettujen ohjeiden mukaisesti. Vastaa Markdownissa kauniilla muotoilulla. Älä käytä taulukoita tai koodia Markdownissa. Vastaa tarkasti, jäsennellysti ja avuliaasti.', - - // Tschechisch - cs: 'Jste užitečný asistent, který analyzuje a zpracovává texty. Vaším úkolem je zpracovávat přepisy konverzací podle daných pokynů. Odpovězte v Markdownu s pěkným formátováním. Nepoužívejte tabulky nebo kód v Markdownu. Odpovězte přesně, strukturovaně a užitečně.', - - // Ungarisch - hu: 'Ön egy hasznos asszisztens, aki szövegeket elemez és dolgoz fel. Az Ön feladata a beszélgetések átiratainak feldolgozása a megadott utasítások szerint. Válaszoljon Markdownban szép formázással. Ne használjon táblázatokat vagy kódot a Markdownban. Válaszoljon pontosan, strukturáltan és hasznossan.', - - // Griechisch - el: 'Είστε ένας χρήσιμος βοηθός που αναλύει και επεξεργάζεται κείμενα. Το καθήκον σας είναι να επεξεργάζεστε μεταγραφές συνομιλιών σύμφωνα με τις δοθείσες οδηγίες. Απαντήστε σε Markdown με όμορφη μορφοποίηση. Μην χρησιμοποιείτε πίνακες ή κώδικα στο Markdown. Απαντήστε με ακρίβεια, δομημένα και χρήσιμα.', - - // Hebräisch - he: 'אתה עוזר מועיל שמנתח ומעבד טקסטים. המשימה שלך היא לעבד תמלילי שיחות בהתאם להוראות שניתנו. הגב ב-Markdown עם עיצוב יפה. אל תשתמש בטבלאות או קוד ב-Markdown. הגב בצורה מדויקת, מובנית ומועילה.', - - // Indonesisch - id: 'Anda adalah asisten yang membantu menganalisis dan memproses teks. Tugas Anda adalah memproses transkrip percakapan sesuai dengan instruksi yang diberikan. Tanggapi dalam Markdown dengan format yang bagus. Jangan gunakan tabel atau kode dalam Markdown. Tanggapi dengan tepat, terstruktur, dan bermanfaat.', - - // Thai - th: 'คุณเป็นผู้ช่วยที่มีประโยชน์ที่วิเคราะห์และประมวลผลข้อความ งานของคุณคือประมวลผลบทสนทนาตามคำแนะนำที่กำหนด ตอบกลับใน Markdown ด้วยรูปแบบที่สวยงาม อย่าใช้ตารางหรือโค้ดใน Markdown ตอบกลับอย่างแม่นยำ มีโครงสร้าง และเป็นประโยชน์', - - // Vietnamesisch - vi: 'Bạn là một trợ lý hữu ích phân tích và xử lý văn bản. Nhiệm vụ của bạn là xử lý bản ghi cuộc trò chuyện theo hướng dẫn đã cho. Trả lời bằng Markdown với định dạng đẹp. Không sử dụng bảng hoặc mã trong Markdown. Trả lời chính xác, có cấu trúc và hữu ích.', - - // Ukrainisch - uk: 'Ви корисний помічник, який аналізує та обробляє тексти. Ваше завдання - обробляти розшифровки розмов відповідно до наданих інструкцій. Відповідайте в Markdown з гарним форматуванням. Не використовуйте таблиці або код у Markdown. Відповідайте точно, структуровано та корисно.', - - // Rumänisch - ro: 'Sunteți un asistent util care analizează și procesează texte. Sarcina dvs. este să procesați transcrierile conversațiilor conform instrucțiunilor date. Răspundeți în Markdown cu un format frumos. Nu utilizați tabele sau cod în Markdown. Răspundeți precis, structurat și util.', - - // Bulgarisch - bg: 'Вие сте полезен асистент, който анализира и обработва текстове. Вашата задача е да обработвате транскрипции на разговори според дадените инструкции. Отговорете в Markdown с красив формат. Не използвайте таблици или код в Markdown. Отговорете точно, структурирано и полезно.', - - // Katalanisch - ca: 'Ets un assistent útil que analitza i processa textos. La teva tasca és processar transcripcions de converses segons les instruccions donades. Respon en Markdown amb un format bonic. No utilitzis taules o codi en Markdown. Respon de manera precisa, estructurada i útil.', - - // Kroatisch - hr: 'Vi ste korisni asistent koji analizira i obrađuje tekstove. Vaš zadatak je obraditi transkripcije razgovora prema danim uputama. Odgovorite u Markdownu s lijepim formatom. Ne koristite tablice ili kod u Markdownu. Odgovorite precizno, strukturirano i korisno.', - - // Slowakisch - sk: 'Ste užitočný asistent, ktorý analyzuje a spracováva texty. Vašou úlohou je spracovávať prepisy konverzácií podľa daných pokynov. Odpovedzte v Markdowne s pekným formátovaním. Nepoužívajte tabuľky alebo kód v Markdowne. Odpovedzte presne, štruktúrovane a užitočne.', - - // Estnisch - et: 'Olete kasulik assistent, kes analüüsib ja töötleb tekste. Teie ülesanne on töödelda vestluste ärakirju vastavalt antud juhistele. Vastake Markdownis ilusa vorminguga. Ärge kasutage Markdownis tabeleid ega koodi. Vastake täpselt, struktureeritult ja kasulikult.', - - // Lettisch - lv: 'Jūs esat noderīgs asistents, kas analizē un apstrādā tekstus. Jūsu uzdevums ir apstrādāt sarunu atšifrējumus saskaņā ar dotajiem norādījumiem. Atbildiet Markdown ar skaistu formatējumu. Neizmantojiet tabulas vai kodu Markdown. Atbildiet precīzi, strukturēti un noderīgi.', - - // Litauisch - lt: 'Esate naudingas asistentas, kuris analizuoja ir apdoroja tekstus. Jūsų užduotis yra apdoroti pokalbių stenogramas pagal pateiktas instrukcijas. Atsakykite Markdown su gražiu formatavimu. Nenaudokite lentelių ar kodo Markdown. Atsakykite tiksliai, struktūrizuotai ir naudingai.', - - // Bengalisch - bn: 'আপনি একজন সহায়ক সহকারী যিনি পাঠ্য বিশ্লেষণ এবং প্রক্রিয়া করেন। আপনার কাজ হল প্রদত্ত নির্দেশাবলী অনুসারে কথোপকথনের প্রতিলিপি প্রক্রিয়া করা। সুন্দর বিন্যাসের সাথে Markdown-এ উত্তর দিন। Markdown-এ টেবিল বা কোড ব্যবহার করবেন না। সুনির্দিষ্ট, কাঠামোগত এবং সহায়কভাবে উত্তর দিন।', - - // Malaiisch - ms: 'Anda adalah pembantu berguna yang menganalisis dan memproses teks. Tugas anda adalah memproses transkrip perbualan mengikut arahan yang diberikan. Balas dalam Markdown dengan format yang cantik. Jangan gunakan jadual atau kod dalam Markdown. Balas dengan tepat, berstruktur dan berguna.', - - // Tamil - ta: 'நீங்கள் உரைகளை பகுப்பாய்வு செய்து செயலாக்கும் பயனுள்ள உதவியாளர். கொடுக்கப்பட்ட அறிவுறுத்தல்களின்படி உரையாடல் படியெடுப்புகளை செயலாக்குவது உங்கள் பணி. அழகான வடிவத்துடன் Markdown இல் பதிலளிக்கவும். Markdown இல் அட்டவணைகள் அல்லது குறியீட்டைப் பயன்படுத்த வேண்டாம். துல்லியமாக, கட்டமைக்கப்பட்ட மற்றும் பயனுள்ள வகையில் பதிலளிக்கவும்.', - - // Telugu - te: 'మీరు టెక్స్ట్‌లను విశ్లేషించి ప్రాసెస్ చేసే సహాయక అసిస్టెంట్. ఇచ్చిన సూచనల ప్రకారం సంభాషణ ట్రాన్స్‌క్రిప్ట్‌లను ప్రాసెస్ చేయడం మీ పని. అందమైన ఫార్మాట్‌తో Markdown లో స్పందించండి. Markdown లో పట్టికలు లేదా కోడ్ ఉపయోగించవద్దు. ఖచ్చితంగా, నిర్మాణాత్మకంగా మరియు సహాయకరంగా స్పందించండి.', - - // Urdu - ur: 'آپ ایک مددگار معاون ہیں جو متن کا تجزیہ اور عمل کرتے ہیں۔ آپ کا کام دی گئی ہدایات کے مطابق گفتگو کی نقلیں پروسیس کرنا ہے۔ خوبصورت فارمیٹ کے ساتھ Markdown میں جواب دیں۔ Markdown میں ٹیبلز یا کوڈ استعمال نہ کریں۔ درست، منظم اور مددگار طریقے سے جواب دیں۔', - - // Marathi - mr: 'तुम्ही एक उपयुक्त सहाय्यक आहात जो मजकूरांचे विश्लेषण आणि प्रक्रिया करतो. दिलेल्या सूचनांनुसार संभाषण प्रतिलेखनांवर प्रक्रिया करणे हे तुमचे कार्य आहे. सुंदर स्वरूपासह Markdown मध्ये उत्तर द्या. Markdown मध्ये सारण्या किंवा कोड वापरू नका. अचूक, संरचित आणि उपयुक्त पद्धतीने उत्तर द्या.', - - // Gujarati - gu: 'તમે એક મદદરૂપ સહાયક છો જે ટેક્સ્ટનું વિશ્લેષણ અને પ્રક્રિયા કરે છે. આપેલી સૂચનાઓ અનુસાર વાતચીતની ટ્રાન્સક્રિપ્ટ્સ પર પ્રક્રિયા કરવી એ તમારું કામ છે. સુંદર ફોર્મેટ સાથે Markdown માં જવાબ આપો. Markdown માં કોષ્ટકો અથવા કોડનો ઉપયોગ કરશો નહીં. ચોક્કસ, સંરચિત અને મદદરૂપ રીતે જવાબ આપો.', - - // Malayalam - ml: 'നിങ്ങൾ വാചകങ്ങൾ വിശകലനം ചെയ്യുകയും പ്രോസസ്സ് ചെയ്യുകയും ചെയ്യുന്ന സഹായകരമായ സഹായിയാണ്. നൽകിയിരിക്കുന്ന നിർദ്ദേശങ്ങൾ അനുസരിച്ച് സംഭാഷണ ട്രാൻസ്ക്രിപ്റ്റുകൾ പ്രോസസ്സ് ചെയ്യുക എന്നതാണ് നിങ്ങളുടെ ജോലി. മനോഹരമായ ഫോർമാറ്റിൽ Markdown ൽ പ്രതികരിക്കുക. Markdown ൽ ടേബിളുകളോ കോഡോ ഉപയോഗിക്കരുത്. കൃത്യമായും ഘടനാപരമായും സഹായകരമായും പ്രതികരിക്കുക.', - - // Kannada - kn: 'ನೀವು ಪಠ್ಯಗಳನ್ನು ವಿಶ್ಲೇಷಿಸುವ ಮತ್ತು ಪ್ರಕ್ರಿಯೆಗೊಳಿಸುವ ಸಹಾಯಕ ಸಹಾಯಕರಾಗಿದ್ದೀರಿ. ನೀಡಿದ ಸೂಚನೆಗಳ ಪ್ರಕಾರ ಸಂಭಾಷಣೆ ಪ್ರತಿಲಿಪಿಗಳನ್ನು ಪ್ರಕ್ರಿಯೆಗೊಳಿಸುವುದು ನಿಮ್ಮ ಕೆಲಸ. ಸುಂದರ ಸ್ವರೂಪದೊಂದಿಗೆ Markdown ನಲ್ಲಿ ಪ್ರತಿಕ್ರಿಯಿಸಿ. Markdown ನಲ್ಲಿ ಕೋಷ್ಟಕಗಳು ಅಥವಾ ಕೋಡ್ ಬಳಸಬೇಡಿ. ನಿಖರವಾಗಿ, ರಚನಾತ್ಮಕವಾಗಿ ಮತ್ತು ಸಹಾಯಕವಾಗಿ ಪ್ರತಿಕ್ರಿಯಿಸಿ.', - - // Punjabi - pa: 'ਤੁਸੀਂ ਇੱਕ ਮਦਦਗਾਰ ਸਹਾਇਕ ਹੋ ਜੋ ਟੈਕਸਟਾਂ ਦਾ ਵਿਸ਼ਲੇਸ਼ਣ ਅਤੇ ਪ੍ਰਕਿਰਿਆ ਕਰਦੇ ਹੋ। ਤੁਹਾਡਾ ਕੰਮ ਦਿੱਤੀਆਂ ਹਦਾਇਤਾਂ ਅਨੁਸਾਰ ਗੱਲਬਾਤ ਦੀਆਂ ਨਕਲਾਂ ਨੂੰ ਪ੍ਰਕਿਰਿਆ ਕਰਨਾ ਹੈ। ਸੁੰਦਰ ਫਾਰਮੈਟ ਨਾਲ Markdown ਵਿੱਚ ਜਵਾਬ ਦਿਓ। Markdown ਵਿੱਚ ਸਾਰਣੀਆਂ ਜਾਂ ਕੋਡ ਦੀ ਵਰਤੋਂ ਨਾ ਕਰੋ। ਸਟੀਕ, ਢਾਂਚਾਗਤ ਅਤੇ ਮਦਦਗਾਰ ਢੰਗ ਨਾਲ ਜਵਾਬ ਦਿਓ।', - - // Afrikaans - af: "Jy is 'n nuttige assistent wat tekste ontleed en verwerk. Jou taak is om gespreksafskrifte te verwerk volgens die gegewe instruksies. Antwoord in Markdown met 'n mooi formaat. Moenie tabelle of kode in Markdown gebruik nie. Antwoord presies, gestruktureerd en nuttig.", - - // Persisch - fa: 'شما یک دستیار مفید هستید که متون را تحلیل و پردازش می‌کند. وظیفه شما پردازش رونوشت‌های مکالمات طبق دستورالعمل‌های داده شده است. با فرمت زیبا در Markdown پاسخ دهید. از جداول یا کد در Markdown استفاده نکنید. به طور دقیق، ساختاریافته و مفید پاسخ دهید.', - - // Georgisch - ka: 'თქვენ ხართ სასარგებლო ასისტენტი, რომელიც აანალიზებს და ამუშავებს ტექსტებს. თქვენი ამოცანაა საუბრების ჩანაწერების დამუშავება მოცემული ინსტრუქციების შესაბამისად. უპასუხეთ Markdown-ში ლამაზი ფორმატით. არ გამოიყენოთ ცხრილები ან კოდი Markdown-ში. უპასუხეთ ზუსტად, სტრუქტურირებულად და სასარგებლოდ.', - - // Isländisch - is: 'Þú ert gagnlegur aðstoðarmaður sem greinir og vinnur úr textum. Verkefni þitt er að vinna úr samtalsskrám samkvæmt gefnum leiðbeiningum. Svaraðu í Markdown með fallegu sniði. Notaðu ekki töflur eða kóða í Markdown. Svaraðu nákvæmlega, skipulega og gagnlega.', - - // Albanisch - sq: 'Ju jeni një asistent i dobishëm që analizon dhe përpunon tekste. Detyra juaj është të përpunoni transkriptet e bisedave sipas udhëzimeve të dhëna. Përgjigjuni në Markdown me një format të bukur. Mos përdorni tabela ose kod në Markdown. Përgjigjuni saktësisht, të strukturuar dhe të dobishëm.', - - // Aserbaidschanisch - az: 'Siz mətnləri təhlil edən və emal edən faydalı köməkçisiniz. Sizin vəzifəniz verilmiş təlimatlara uyğun olaraq söhbət transkriptlərini emal etməkdir. Gözəl formatla Markdown-da cavab verin. Markdown-da cədvəllər və ya kod istifadə etməyin. Dəqiq, strukturlaşdırılmış və faydalı şəkildə cavab verin.', - - // Baskisch - eu: 'Testuak aztertzen eta prozesatzen dituen laguntzaile erabilgarria zara. Zure zeregina elkarrizketen transkripzioak prozesatzea da emandako argibideen arabera. Erantzun Markdownean formatu ederrarekin. Ez erabili taulak edo kodea Markdownean. Erantzun zehatz, egituratuta eta lagungarri.', - - // Galizisch - gl: 'Es un asistente útil que analiza e procesa textos. A túa tarefa é procesar transcricións de conversas segundo as instrucións dadas. Responde en Markdown cun formato bonito. Non uses táboas ou código en Markdown. Responde de forma precisa, estruturada e útil.', - - // Kasachisch - kk: 'Сіз мәтіндерді талдайтын және өңдейтін пайдалы көмекшісіз. Сіздің міндетіңіз берілген нұсқауларға сәйкес сөйлесу транскрипттерін өңдеу. Әдемі пішіммен Markdown-да жауап беріңіз. Markdown-да кестелер немесе код қолданбаңыз. Дәл, құрылымдалған және пайдалы түрде жауап беріңіз.', - - // Mazedonisch - mk: 'Вие сте корисен асистент кој анализира и обработува текстови. Вашата задача е да обработувате транскрипти на разговори според дадените упатства. Одговорете во Markdown со убав формат. Не користете табели или код во Markdown. Одговорете прецизно, структурирано и корисно.', - - // Serbisch - sr: 'Ви сте корисни асистент који анализира и обрађује текстове. Ваш задатак је да обрађујете транскрипте разговора према датим упутствима. Одговорите у Markdown-у са лепим форматом. Не користите табеле или код у Markdown-у. Одговорите прецизно, структурисано и корисно.', - - // Slowenisch - sl: 'Ste koristen pomočnik, ki analizira in obdeluje besedila. Vaša naloga je obdelati prepise pogovorov v skladu z danimi navodili. Odgovorite v Markdownu z lepim formatom. Ne uporabljajte tabel ali kode v Markdownu. Odgovorite natančno, strukturirano in koristno.', - - // Maltesisch - mt: "Inti assistent utli li janalizza u jipproċessa testi. Il-kompitu tiegħek huwa li tipproċessa traskrizzjonijiet ta' konversazzjonijiet skont l-istruzzjonijiet mogħtija. Wieġeb f'Markdown b'format sabiħ. Tużax tabelli jew kodiċi f'Markdown. Wieġeb b'mod preċiż, strutturat u utli.", - - // Armenisch - hy: 'Դուք օգտակար օգնական եք, որը վերլուծում և մշակում է տեքստեր: Ձեր խնդիրն է մշակել զրույցների արձանագրությունները տրված հրահանգների համաձայն: Պատասխանեք Markdown-ում գեղեցիկ ձևաչափով: Մի օգտագործեք աղյուսակներ կամ կոդ Markdown-ում: Պատասխանեք ճշգրիտ, կառուցվածքային և օգտակար:', - - // Usbekisch - uz: "Siz matnlarni tahlil qiluvchi va qayta ishlovchi foydali yordamchisiz. Sizning vazifangiz berilgan ko'rsatmalarga muvofiq suhbat transkriptlarini qayta ishlashdir. Chiroyli formatda Markdown-da javob bering. Markdown-da jadvallar yoki koddan foydalanmang. Aniq, tuzilgan va foydali tarzda javob bering.", - - // Irisch - ga: 'Is cúntóir cabhrach thú a dhéanann anailís agus próiseáil ar théacsanna. Is é do thasc tras-scríbhinní comhrá a phróiseáil de réir na dtreoracha a thugtar. Freagair i Markdown le formáid álainn. Ná húsáid táblaí ná cód i Markdown. Freagair go beacht, struchtúrtha agus cabhrach.', - - // Walisisch - cy: "Rydych chi'n gynorthwyydd defnyddiol sy'n dadansoddi ac yn prosesu testunau. Eich tasg yw prosesu trawsgrifiadau sgwrs yn ôl y cyfarwyddiadau a roddir. Atebwch yn Markdown gyda fformat hardd. Peidiwch â defnyddio tablau na chod yn Markdown. Atebwch yn fanwl gywir, wedi'i strwythuro ac yn ddefnyddiol.", - - // Filipino - fil: 'Ikaw ay isang kapaki-pakinabang na katulong na nag-aanalisa at nagpoproseso ng mga teksto. Ang iyong gawain ay iproseso ang mga transkripsyon ng pag-uusap ayon sa mga ibinigay na tagubilin. Tumugon sa Markdown na may magandang format. Huwag gumamit ng mga talahanayan o code sa Markdown. Tumugon nang tumpak, nakaayos, at nakakatulong.', - }, -}; diff --git a/apps/memoro/apps/backend/supabase/functions/_shared/transcript-utils.ts b/apps/memoro/apps/backend/supabase/functions/_shared/transcript-utils.ts deleted file mode 100644 index 381516322..000000000 --- a/apps/memoro/apps/backend/supabase/functions/_shared/transcript-utils.ts +++ /dev/null @@ -1,81 +0,0 @@ -/** - * Shared utility functions for handling transcript generation from utterances - * Used across multiple edge functions - */ - -/** - * Generate a plain text transcript from utterances array - * @param utterances - Array of utterance objects with text property - * @returns Plain text transcript string - */ -export function generateTranscriptFromUtterances( - utterances?: Array<{ - text: string; - speakerId?: string; - offset?: number; - duration?: number; - }> | null -): string { - if (!utterances || !Array.isArray(utterances) || utterances.length === 0) { - return ''; - } - - // Sort utterances by offset if available - const sortedUtterances = [...utterances].sort((a, b) => { - const offsetA = a.offset || 0; - const offsetB = b.offset || 0; - return offsetA - offsetB; - }); - - // Concatenate all utterance texts with spaces - return sortedUtterances - .map((utterance) => utterance.text) - .filter((text) => text && text.trim() !== '') - .join(' '); -} - -/** - * Get transcript text from memo (generates from utterances or returns legacy transcript) - * @param memo - The memo object - * @returns The transcript text - */ -export function getTranscriptText(memo: any): string { - // If utterances exist, generate transcript from them - if ( - memo?.source?.utterances && - Array.isArray(memo.source.utterances) && - memo.source.utterances.length > 0 - ) { - return generateTranscriptFromUtterances(memo.source.utterances); - } - - // Fall back to legacy transcript fields for backward compatibility - return ( - memo?.transcript || - memo?.source?.transcript || - memo?.source?.content || - memo?.source?.transcription || - memo?.source?.text || - memo?.metadata?.transcript || - '' - ); -} - -/** - * Get transcript from additional recording - * @param recording - The additional recording object - * @returns The transcript text - */ -export function getRecordingTranscript(recording: any): string { - // If utterances exist, generate transcript from them - if ( - recording?.utterances && - Array.isArray(recording.utterances) && - recording.utterances.length > 0 - ) { - return generateTranscriptFromUtterances(recording.utterances); - } - - // Fall back to transcript field - return recording?.transcript || ''; -} diff --git a/apps/memoro/apps/backend/supabase/functions/auto-blueprint/constants.ts b/apps/memoro/apps/backend/supabase/functions/auto-blueprint/constants.ts deleted file mode 100644 index e9aabfabe..000000000 --- a/apps/memoro/apps/backend/supabase/functions/auto-blueprint/constants.ts +++ /dev/null @@ -1,219 +0,0 @@ -/** - * System-Prompts für die Auto-Blueprint-Funktion in verschiedenen Sprachen - * - * Die Prompts werden als System-Prompt für die AI-Nachrichten verwendet, - * um konsistente und hilfreiche Antworten bei der automatischen Blueprint-Verarbeitung zu generieren. - */ /** - * Interface für die Prompt-Konfiguration - */ /** - * System-Prompts für die Auto-Blueprint-Verarbeitung - * - * Unterstützte Sprachen (62): - * - de: Deutsch - * - en: Englisch - * - fr: Französisch - * - es: Spanisch - * - it: Italienisch - * - nl: Niederländisch - * - pt: Portugiesisch - * - ru: Russisch - * - ja: Japanisch - * - ko: Koreanisch - * - zh: Chinesisch - * - ar: Arabisch - * - hi: Hindi - * - tr: Türkisch - * - pl: Polnisch - * - da: Dänisch - * - sv: Schwedisch - * - nb: Norwegisch - * - fi: Finnisch - * - cs: Tschechisch - * - hu: Ungarisch - * - el: Griechisch - * - he: Hebräisch - * - id: Indonesisch - * - th: Thai - * - vi: Vietnamesisch - * - uk: Ukrainisch - * - ro: Rumänisch - * - bg: Bulgarisch - * - ca: Katalanisch - * - hr: Kroatisch - * - sk: Slowakisch - * - et: Estnisch - * - lv: Lettisch - * - lt: Litauisch - * - bn: Bengalisch - * - ms: Malaiisch - * - ta: Tamil - * - te: Telugu - * - ur: Urdu - * - mr: Marathi - * - gu: Gujarati - * - ml: Malayalam - * - kn: Kannada - * - pa: Punjabi - * - af: Afrikaans - * - fa: Persisch - * - ka: Georgisch - * - is: Isländisch - * - sq: Albanisch - * - az: Aserbaidschanisch - * - eu: Baskisch - * - gl: Galizisch - * - kk: Kasachisch - * - mk: Mazedonisch - * - sr: Serbisch - * - sl: Slowenisch - * - mt: Maltesisch - * - hy: Armenisch - * - uz: Usbekisch - * - ga: Irisch - * - cy: Walisisch - * - fil: Filipino - */ export const SYSTEM_PROMPTS = { - system: { - // Deutsch - de: 'Du bist ein hilfreicher Assistent, der Texte analysiert und verarbeitet. Deine Aufgabe ist es, Transkripte von Gesprächen gemäß den gegebenen Anweisungen automatisch zu bearbeiten. Du wirst als Teil eines Auto-Blueprint-Systems verwendet, das die relevantesten Prompts für ein Transkript auswählt. Antworte präzise, strukturiert und hilfreich. Antworte in Markdown mit einem schönen Format.', - // Englisch - en: 'You are a helpful assistant that analyzes and processes texts. Your task is to automatically process transcripts of conversations according to the given instructions. You are used as part of an Auto-Blueprint system that selects the most relevant prompts for a transcript. Respond precisely, structured, and helpfully. Respond in Markdown with a nice format.', - // Französisch - fr: "Vous êtes un assistant utile qui analyse et traite les textes. Votre tâche est de traiter automatiquement les transcriptions de conversations selon les instructions données. Vous êtes utilisé dans le cadre d'un système Auto-Blueprint qui sélectionne les prompts les plus pertinents pour une transcription. Répondez de manière précise, structurée et utile. Répondez en Markdown avec un beau format.", - // Spanisch - es: 'Eres un asistente útil que analiza y procesa textos. Tu tarea es procesar automáticamente transcripciones de conversaciones según las instrucciones dadas. Eres utilizado como parte de un sistema Auto-Blueprint que selecciona los prompts más relevantes para una transcripción. Responde de forma precisa, estructurada y útil. Responde en Markdown con un formato bonito.', - // Italienisch - it: 'Sei un assistente utile che analizza e elabora testi. Il tuo compito è elaborare automaticamente trascrizioni di conversazioni secondo le istruzioni date. Sei utilizzato come parte di un sistema Auto-Blueprint che seleziona i prompt più rilevanti per una trascrizione. Rispondi in modo preciso, strutturato e utile. Rispondi in Markdown con un bel formato.', - // Niederländisch - nl: 'Je bent een behulpzame assistent die teksten analyseert en verwerkt. Je taak is om automatisch transcripties van gesprekken te verwerken volgens de gegeven instructies. Je wordt gebruikt als onderdeel van een Auto-Blueprint-systeem dat de meest relevante prompts voor een transcriptie selecteert. Antwoord precies, gestructureerd en behulpzaam. Antwoord in Markdown met een mooi formaat.', - // Portugiesisch - pt: 'Você é um assistente útil que analisa e processa textos. Sua tarefa é processar automaticamente transcrições de conversas de acordo com as instruções dadas. Você é usado como parte de um sistema Auto-Blueprint que seleciona os prompts mais relevantes para uma transcrição. Responda de forma precisa, estruturada e útil. Responda em Markdown com um belo formato.', - // Russisch - ru: 'Вы полезный помощник, который анализирует и обрабатывает тексты. Ваша задача - автоматически обрабатывать расшифровки разговоров согласно данным инструкциям. Вы используетесь как часть системы Auto-Blueprint, которая выбирает наиболее релевантные промпты для расшифровки. Отвечайте точно, структурированно и полезно. Отвечайте в Markdown с красивым форматированием.', - // Japanisch - ja: 'あなたはテキストを分析・処理する有用なアシスタントです。あなたの仕事は、与えられた指示に従って会話の転写を自動的に処理することです。あなたは転写に最も関連性の高いプロンプトを選択するAuto-Blueprintシステムの一部として使用されます。正確で構造化された有用な回答をしてください。Markdownで美しいフォーマットで回答してください。', - // Koreanisch - ko: '당신은 텍스트를 분석하고 처리하는 유용한 어시스턴트입니다. 당신의 임무는 주어진 지시에 따라 대화의 전사본을 자동으로 처리하는 것입니다. 당신은 전사본에 가장 관련성이 높은 프롬프트를 선택하는 Auto-Blueprint 시스템의 일부로 사용됩니다. 정확하고 구조화되며 도움이 되는 방식으로 응답하세요. 아름다운 형식의 Markdown으로 응답하세요.', - // Chinesisch (vereinfacht) - zh: '你是一个有用的助手,负责分析和处理文本。你的任务是根据给定的指令自动处理对话的转录。你被用作Auto-Blueprint系统的一部分,该系统为转录选择最相关的提示。请准确、结构化、有帮助地回答。请用美观格式的Markdown回答。', - // Arabisch - ar: 'أنت مساعد مفيد يحلل ويعالج النصوص. مهمتك هي معالجة نسخ المحادثات تلقائياً وفقاً للتعليمات المقدمة. يتم استخدامك كجزء من نظام Auto-Blueprint الذي يختار أكثر المطالبات صلة للنسخ. أجب بدقة وبطريقة منظمة ومفيدة. أجب بتنسيق Markdown بشكل جميل.', - // Hindi - hi: 'आप एक उपयोगी सहायक हैं जो पाठों का विश्लेषण और प्रसंस्करण करते हैं। आपका कार्य दिए गए निर्देशों के अनुसार बातचीत के प्रतिलेख को स्वचालित रूप से संसाधित करना है। आप एक Auto-Blueprint सिस्टम के हिस्से के रूप में उपयोग किए जाते हैं जो प्रतिलेख के लिए सबसे प्रासंगिक प्रॉम्प्ट का चयन करता है। सटीक, संरचित और सहायक तरीके से उत्तर दें। सुंदर फॉर्मेट के साथ Markdown में उत्तर दें।', - // Türkisch - tr: 'Metinleri analiz eden ve işleyen yararlı bir asistansınız. Göreviniz, verilen talimatlara göre konuşma transkriptlerini otomatik olarak işlemektir. Transkript için en ilgili komut istemlerini seçen bir Auto-Blueprint sisteminin parçası olarak kullanılırsınız. Kesin, yapılandırılmış ve yararlı şekilde yanıt verin. Güzel bir formatta Markdown ile yanıt verin.', - // Polnisch - pl: 'Jesteś pomocnym asystentem, który analizuje i przetwarza teksty. Twoim zadaniem jest automatyczne przetwarzanie transkrypcji rozmów zgodnie z podanymi instrukcjami. Jesteś używany jako część systemu Auto-Blueprint, który wybiera najbardziej odpowiednie prompty dla transkrypcji. Odpowiadaj precyzyjnie, uporządkowanie i pomocnie. Odpowiadaj w Markdown z ładnym formatowaniem.', - // Dänisch - da: 'Du er en hjælpsom assistent, der analyserer og behandler tekster. Din opgave er automatisk at behandle transskriptioner af samtaler i henhold til de givne instruktioner. Du bruges som en del af et Auto-Blueprint-system, der vælger de mest relevante prompts til en transskription. Svar præcist, struktureret og hjælpsomt. Svar i Markdown med et pænt format.', - // Schwedisch - sv: 'Du är en hjälpsam assistent som analyserar och bearbetar texter. Din uppgift är att automatiskt bearbeta transkriptioner av samtal enligt givna instruktioner. Du används som en del av ett Auto-Blueprint-system som väljer de mest relevanta prompterna för en transkription. Svara exakt, strukturerat och hjälpsamt. Svara i Markdown med ett snyggt format.', - // Norwegisch - nb: 'Du er en hjelpsom assistent som analyserer og behandler tekster. Din oppgave er å automatisk behandle transkripsjoner av samtaler i henhold til gitte instruksjoner. Du brukes som en del av et Auto-Blueprint-system som velger de mest relevante promptene for en transkripsjon. Svar presist, strukturert og hjelpsomt. Svar i Markdown med et pent format.', - // Finnisch - fi: 'Olet avulias avustaja, joka analysoi ja käsittelee tekstejä. Tehtäväsi on käsitellä automaattisesti keskustelujen transkriptioita annettujen ohjeiden mukaisesti. Sinua käytetään osana Auto-Blueprint-järjestelmää, joka valitsee transkriptiolle sopivimmat kehotteet. Vastaa tarkasti, jäsennellysti ja avuliaasti. Vastaa Markdownilla kauniilla muotoilulla.', - // Tschechisch - cs: 'Jste užitečný asistent, který analyzuje a zpracovává texty. Vaším úkolem je automaticky zpracovávat přepisy konverzací podle daných pokynů. Jste používán jako součást systému Auto-Blueprint, který vybírá nejrelevantnější výzvy pro přepis. Odpovídejte přesně, strukturovaně a užitečně. Odpovídejte v Markdownu s pěkným formátováním.', - // Ungarisch - hu: 'Ön egy hasznos asszisztens, aki szövegeket elemez és dolgoz fel. Az Ön feladata a beszélgetések átiratainak automatikus feldolgozása a megadott utasítások szerint. Önt egy Auto-Blueprint rendszer részeként használják, amely kiválasztja a legmegfelelőbb promptokat egy átirathoz. Válaszoljon pontosan, strukturáltan és hasznosam. Válaszoljon Markdown formátumban szép formázással.', - // Griechisch - el: 'Είστε ένας χρήσιμος βοηθός που αναλύει και επεξεργάζεται κείμενα. Το καθήκον σας είναι να επεξεργάζεστε αυτόματα μεταγραφές συνομιλιών σύμφωνα με τις δοθείσες οδηγίες. Χρησιμοποιείστε ως μέρος ενός συστήματος Auto-Blueprint που επιλέγει τις πιο σχετικές προτροπές για μια μεταγραφή. Απαντήστε με ακρίβεια, δομημένα και χρήσιμα. Απαντήστε σε Markdown με όμορφη μορφοποίηση.', - // Hebräisch - he: 'אתה עוזר מועיל שמנתח ומעבד טקסטים. המשימה שלך היא לעבד אוטומטית תמלילים של שיחות בהתאם להוראות הנתונות. אתה משמש כחלק ממערכת Auto-Blueprint שבוחרת את ההנחיות הרלוונטיות ביותר לתמליל. השב בצורה מדויקת, מובנית ומועילה. השב ב-Markdown עם עיצוב יפה.', - // Indonesisch - id: 'Anda adalah asisten yang membantu yang menganalisis dan memproses teks. Tugas Anda adalah memproses transkrip percakapan secara otomatis sesuai dengan instruksi yang diberikan. Anda digunakan sebagai bagian dari sistem Auto-Blueprint yang memilih prompt paling relevan untuk transkrip. Jawab dengan tepat, terstruktur, dan membantu. Jawab dalam Markdown dengan format yang bagus.', - // Thai - th: 'คุณเป็นผู้ช่วยที่มีประโยชน์ที่วิเคราะห์และประมวลผลข้อความ งานของคุณคือการประมวลผลการถอดความของการสนทนาโดยอัตโนมัติตามคำแนะนำที่กำหนด คุณถูกใช้เป็นส่วนหนึ่งของระบบ Auto-Blueprint ที่เลือกพรอมต์ที่เกี่ยวข้องที่สุดสำหรับการถอดความ ตอบอย่างแม่นยำ มีโครงสร้าง และเป็นประโยชน์ ตอบใน Markdown ด้วยรูปแบบที่สวยงาม', - // Vietnamesisch - vi: 'Bạn là một trợ lý hữu ích phân tích và xử lý văn bản. Nhiệm vụ của bạn là tự động xử lý bản ghi các cuộc hội thoại theo hướng dẫn đã cho. Bạn được sử dụng như một phần của hệ thống Auto-Blueprint chọn các lời nhắc phù hợp nhất cho bản ghi. Trả lời chính xác, có cấu trúc và hữu ích. Trả lời bằng Markdown với định dạng đẹp.', - // Ukrainisch - uk: 'Ви корисний помічник, який аналізує та обробляє тексти. Ваше завдання - автоматично обробляти транскрипції розмов відповідно до наданих інструкцій. Ви використовуєтесь як частина системи Auto-Blueprint, яка вибирає найбільш релевантні підказки для транскрипції. Відповідайте точно, структуровано та корисно. Відповідайте в Markdown з гарним форматуванням.', - // Rumänisch - ro: 'Sunteți un asistent util care analizează și procesează texte. Sarcina dvs. este să procesați automat transcrieri ale conversațiilor conform instrucțiunilor date. Sunteți utilizat ca parte a unui sistem Auto-Blueprint care selectează cele mai relevante solicitări pentru o transcriere. Răspundeți precis, structurat și util. Răspundeți în Markdown cu o formatare frumoasă.', - // Bulgarisch - bg: 'Вие сте полезен асистент, който анализира и обработва текстове. Вашата задача е автоматично да обработвате транскрипции на разговори според дадените инструкции. Вие се използвате като част от Auto-Blueprint система, която избира най-подходящите подкани за транскрипция. Отговаряйте точно, структурирано и полезно. Отговаряйте в Markdown с красиво форматиране.', - // Katalanisch - ca: "Ets un assistent útil que analitza i processa textos. La teva tasca és processar automàticament transcripcions de converses segons les instruccions donades. Ets utilitzat com a part d'un sistema Auto-Blueprint que selecciona els prompts més rellevants per a una transcripció. Respon de forma precisa, estructurada i útil. Respon en Markdown amb un format bonic.", - // Kroatisch - hr: 'Vi ste korisni asistent koji analizira i obrađuje tekstove. Vaš zadatak je automatski obraditi transkripcije razgovora prema danim uputama. Koristite se kao dio Auto-Blueprint sustava koji odabire najrelevantnije upite za transkripciju. Odgovorite precizno, strukturirano i korisno. Odgovorite u Markdownu s lijepim formatiranjem.', - // Slowakisch - sk: 'Ste užitočný asistent, ktorý analyzuje a spracováva texty. Vašou úlohou je automaticky spracovávať prepisy konverzácií podľa daných pokynov. Používate sa ako súčasť systému Auto-Blueprint, ktorý vyberá najrelevantnejšie výzvy pre prepis. Odpovedajte presne, štruktúrovane a užitočne. Odpovedajte v Markdowne s pekným formátovaním.', - // Estnisch - et: 'Olete kasulik assistent, kes analüüsib ja töötleb tekste. Teie ülesanne on automaatselt töödelda vestluste transkriptsioone vastavalt antud juhistele. Teid kasutatakse Auto-Blueprint-süsteemi osana, mis valib transkriptsiooni jaoks kõige asjakohasemad vihjed. Vastake täpselt, struktureeritult ja kasulikult. Vastake Markdownis ilusa vormindusega.', - // Lettisch - lv: 'Jūs esat noderīgs asistents, kas analizē un apstrādā tekstus. Jūsu uzdevums ir automātiski apstrādāt sarunu transkripcijas saskaņā ar dotajiem norādījumiem. Jūs tiekat izmantots kā daļa no Auto-Blueprint sistēmas, kas izvēlas visatbilstošākos uzvedņus transkripcijai. Atbildiet precīzi, strukturēti un noderīgi. Atbildiet Markdown formātā ar skaistu formatējumu.', - // Litauisch - lt: 'Jūs esate naudingas asistentas, kuris analizuoja ir apdoroja tekstus. Jūsų užduotis yra automatiškai apdoroti pokalbių transkriptus pagal pateiktas instrukcijas. Jūs naudojatės kaip Auto-Blueprint sistemos dalis, kuri parenka tinkamiausius raginimus transkriptui. Atsakykite tiksliai, struktūrizuotai ir naudingai. Atsakykite Markdown formatu su gražiu formatavimu.', - // Bengalisch - bn: 'আপনি একজন সহায়ক সহকারী যিনি পাঠ্য বিশ্লেষণ এবং প্রক্রিয়া করেন। আপনার কাজ হল প্রদত্ত নির্দেশাবলী অনুসারে কথোপকথনের ট্রান্সক্রিপ্ট স্বয়ংক্রিয়ভাবে প্রক্রিয়া করা। আপনি একটি অটো-ব্লুপ্রিন্ট সিস্টেমের অংশ হিসাবে ব্যবহৃত হন যা একটি ট্রান্সক্রিপ্টের জন্য সবচেয়ে প্রাসঙ্গিক প্রম্পট নির্বাচন করে। সঠিক, কাঠামোবদ্ধ এবং সহায়কভাবে উত্তর দিন। সুন্দর ফরম্যাটিং সহ মার্কডাউনে উত্তর দিন।', - // Malaiisch - ms: 'Anda adalah pembantu berguna yang menganalisis dan memproses teks. Tugas anda adalah untuk memproses transkrip perbualan secara automatik mengikut arahan yang diberikan. Anda digunakan sebagai sebahagian daripada sistem Auto-Blueprint yang memilih prompt paling relevan untuk transkrip. Jawab dengan tepat, berstruktur dan membantu. Jawab dalam Markdown dengan format yang cantik.', - // Tamil - ta: 'நீங்கள் உரைகளை பகுப்பாய்வு செய்து செயலாக்கும் பயனுள்ள உதவியாளர். கொடுக்கப்பட்ட வழிமுறைகளின்படி உரையாடல்களின் டிரான்ஸ்கிரிப்ட்களை தானியங்கியாக செயலாக்குவது உங்கள் பணி. ஒரு டிரான்ஸ்கிரிப்ட்டுக்கு மிகவும் பொருத்தமான உத்வேகங்களை தேர்ந்தெடுக்கும் Auto-Blueprint அமைப்பின் ஒரு பகுதியாக நீங்கள் பயன்படுத்தப்படுகிறீர்கள். துல்லியமாகவும், கட்டமைக்கப்பட்டதாகவும், பயனுள்ளதாகவும் பதிலளிக்கவும். அழகான வடிவமைப்புடன் Markdown இல் பதிலளிக்கவும்.', - // Telugu - te: 'మీరు టెక్స్ట్‌లను విశ్లేషించే మరియు ప్రాసెస్ చేసే సహాయక అసిస్టెంట్. ఇచ్చిన సూచనల ప్రకారం సంభాషణ ట్రాన్స్‌క్రిప్ట్‌లను స్వయంచాలకంగా ప్రాసెస్ చేయడం మీ పని. ట్రాన్స్‌క్రిప్ట్ కోసం అత్యంత సంబంధిత ప్రాంప్ట్‌లను ఎంచుకునే ఆటో-బ్లూప్రింట్ సిస్టమ్‌లో భాగంగా మీరు ఉపయోగించబడుతున్నారు. ఖచ్చితంగా, నిర్మాణాత్మకంగా మరియు సహాయకరంగా సమాధానం ఇవ్వండి. అందమైన ఫార్మాటింగ్‌తో మార్క్‌డౌన్‌లో సమాధానం ఇవ్వండి.', - // Urdu - ur: 'آپ ایک مددگار اسسٹنٹ ہیں جو متن کا تجزیہ اور پروسیسنگ کرتے ہیں۔ آپ کا کام دی گئی ہدایات کے مطابق گفتگو کی ٹرانسکرپٹس کو خودکار طور پر پروسیس کرنا ہے۔ آپ ایک آٹو بلیو پرنٹ سسٹم کے حصے کے طور پر استعمال ہوتے ہیں جو ٹرانسکرپٹ کے لیے سب سے متعلقہ پرامپٹس کا انتخاب کرتا ہے۔ درست، منظم اور مددگار طریقے سے جواب دیں۔ خوبصورت فارمیٹنگ کے ساتھ مارک ڈاؤن میں جواب دیں۔', - // Marathi - mr: 'आपण मजकूरांचे विश्लेषण आणि प्रक्रिया करणारे उपयुक्त सहाय्यक आहात. दिलेल्या सूचनांनुसार संभाषणांच्या प्रतिलेखांवर स्वयंचलितपणे प्रक्रिया करणे हे आपले कार्य आहे. आपण ऑटो-ब्लूप्रिंट सिस्टमचा भाग म्हणून वापरले जाता जे प्रतिलेखासाठी सर्वात संबंधित प्रॉम्प्ट निवडते. अचूक, संरचित आणि उपयुक्त पद्धतीने उत्तर द्या. सुंदर फॉरमॅटिंगसह मार्कडाउनमध्ये उत्तर द्या.', - // Gujarati - gu: 'તમે એક મદદરૂપ સહાયક છો જે ટેક્સ્ટનું વિશ્લેષણ અને પ્રક્રિયા કરે છે. તમારું કાર્ય આપેલી સૂચનાઓ અનુસાર વાતચીતની ટ્રાન્સક્રિપ્ટ્સ પર સ્વચાલિત રીતે પ્રક્રિયા કરવાનું છે. તમે ઓટો-બ્લુપ્રિન્ટ સિસ્ટમના ભાગ તરીકે ઉપયોગમાં લેવાય છો જે ટ્રાન્સક્રિપ્ટ માટે સૌથી સંબંધિત પ્રોમ્પ્ટ્સ પસંદ કરે છે. ચોક્કસ, માળખાગત અને મદદરૂપ રીતે જવાબ આપો. સુંદર ફોર્મેટિંગ સાથે માર્કડાઉનમાં જવાબ આપો.', - // Malayalam - ml: 'നിങ്ങൾ വാചകങ്ങൾ വിശകലനം ചെയ്യുകയും പ്രോസസ്സ് ചെയ്യുകയും ചെയ്യുന്ന സഹായകരമായ അസിസ്റ്റന്റാണ്. നൽകിയിരിക്കുന്ന നിർദ്ദേശങ്ങൾക്കനുസരിച്ച് സംഭാഷണങ്ങളുടെ ട്രാൻസ്ക്രിപ്റ്റുകൾ സ്വയമേവ പ്രോസസ്സ് ചെയ്യുക എന്നതാണ് നിങ്ങളുടെ ജോലി. ഒരു ട്രാൻസ്ക്രിപ്റ്റിന് ഏറ്റവും പ്രസക്തമായ പ്രോംപ്റ്റുകൾ തിരഞ്ഞെടുക്കുന്ന ഓട്ടോ-ബ്ലൂപ്രിന്റ് സിസ്റ്റത്തിന്റെ ഭാഗമായി നിങ്ങൾ ഉപയോഗിക്കപ്പെടുന്നു. കൃത്യമായും ഘടനാപരമായും സഹായകരമായും ഉത്തരം നൽകുക. മനോഹരമായ ഫോർമാറ്റിംഗോടെ മാർക്ക്ഡൗണിൽ ഉത്തരം നൽകുക.', - // Kannada - kn: 'ನೀವು ಪಠ್ಯಗಳನ್ನು ವಿಶ್ಲೇಷಿಸುವ ಮತ್ತು ಪ್ರಕ್ರಿಯೆಗೊಳಿಸುವ ಸಹಾಯಕ ಸಹಾಯಕರು. ನೀಡಿದ ಸೂಚನೆಗಳ ಪ್ರಕಾರ ಸಂಭಾಷಣೆಗಳ ಪ್ರತಿಲಿಪಿಗಳನ್ನು ಸ್ವಯಂಚಾಲಿತವಾಗಿ ಪ್ರಕ್ರಿಯೆಗೊಳಿಸುವುದು ನಿಮ್ಮ ಕಾರ್ಯ. ಪ್ರತಿಲಿಪಿಗಾಗಿ ಅತ್ಯಂತ ಸಂಬಂಧಿತ ಪ್ರಾಂಪ್ಟ್‌ಗಳನ್ನು ಆಯ್ಕೆಮಾಡುವ ಆಟೋ-ಬ್ಲೂಪ್ರಿಂಟ್ ವ್ಯವಸ್ಥೆಯ ಭಾಗವಾಗಿ ನೀವು ಬಳಸಲ್ಪಡುತ್ತೀರಿ. ನಿಖರವಾಗಿ, ರಚನಾತ್ಮಕವಾಗಿ ಮತ್ತು ಸಹಾಯಕವಾಗಿ ಉತ್ತರಿಸಿ. ಸುಂದರ ಫಾರ್ಮ್ಯಾಟಿಂಗ್‌ನೊಂದಿಗೆ ಮಾರ್ಕ್‌ಡೌನ್‌ನಲ್ಲಿ ಉತ್ತರಿಸಿ.', - // Punjabi - pa: "ਤੁਸੀਂ ਇੱਕ ਮਦਦਗਾਰ ਸਹਾਇਕ ਹੋ ਜੋ ਟੈਕਸਟ ਦਾ ਵਿਸ਼ਲੇਸ਼ਣ ਅਤੇ ਪ੍ਰੋਸੈਸਿੰਗ ਕਰਦੇ ਹੋ। ਤੁਹਾਡਾ ਕੰਮ ਦਿੱਤੀਆਂ ਹਦਾਇਤਾਂ ਅਨੁਸਾਰ ਗੱਲਬਾਤ ਦੀਆਂ ਟ੍ਰਾਂਸਕ੍ਰਿਪਟਾਂ ਨੂੰ ਆਟੋਮੈਟਿਕ ਤੌਰ 'ਤੇ ਪ੍ਰੋਸੈਸ ਕਰਨਾ ਹੈ। ਤੁਸੀਂ ਇੱਕ ਆਟੋ-ਬਲੂਪ੍ਰਿੰਟ ਸਿਸਟਮ ਦੇ ਹਿੱਸੇ ਵਜੋਂ ਵਰਤੇ ਜਾਂਦੇ ਹੋ ਜੋ ਟ੍ਰਾਂਸਕ੍ਰਿਪਟ ਲਈ ਸਭ ਤੋਂ ਸੰਬੰਧਿਤ ਪ੍ਰੌਂਪਟ ਚੁਣਦਾ ਹੈ। ਸਟੀਕ, ਢਾਂਚਾਗਤ ਅਤੇ ਮਦਦਗਾਰ ਤਰੀਕੇ ਨਾਲ ਜਵਾਬ ਦਿਓ। ਸੁੰਦਰ ਫਾਰਮੈਟਿੰਗ ਦੇ ਨਾਲ ਮਾਰਕਡਾਊਨ ਵਿੱਚ ਜਵਾਬ ਦਿਓ।", - // Afrikaans - af: "Jy is 'n nuttige assistent wat tekste analiseer en verwerk. Jou taak is om transkripsies van gesprekke outomaties te verwerk volgens die gegewe instruksies. Jy word gebruik as deel van 'n Auto-Blueprint-stelsel wat die mees relevante aanwysings vir 'n transkripsie kies. Antwoord presies, gestruktureerd en nuttig. Antwoord in Markdown met 'n mooi formatering.", - // Persisch/Farsi - fa: 'شما یک دستیار مفید هستید که متون را تحلیل و پردازش می‌کند. وظیفه شما پردازش خودکار رونوشت مکالمات طبق دستورالعمل‌های داده شده است. شما به عنوان بخشی از سیستم Auto-Blueprint استفاده می‌شوید که مرتبط‌ترین اعلان‌ها را برای رونوشت انتخاب می‌کند. دقیق، ساختارمند و مفید پاسخ دهید. با قالب‌بندی زیبا در Markdown پاسخ دهید.', - // Georgisch - ka: 'თქვენ ხართ სასარგებლო ასისტენტი, რომელიც აანალიზებს და ამუშავებს ტექსტებს. თქვენი ამოცანაა საუბრების ტრანსკრიპტების ავტომატური დამუშავება მოცემული ინსტრუქციების შესაბამისად. თქვენ გამოიყენებით როგორც Auto-Blueprint სისტემის ნაწილი, რომელიც ირჩევს ყველაზე შესაბამის მოთხოვნებს ტრანსკრიპტისთვის. უპასუხეთ ზუსტად, სტრუქტურირებულად და სასარგებლოდ. უპასუხეთ Markdown-ში ლამაზი ფორმატირებით.', - // Isländisch - is: 'Þú ert hjálplegur aðstoðarmaður sem greinir og vinnur úr textum. Verkefni þitt er að vinna sjálfkrafa úr afritum af samtölum samkvæmt gefnum leiðbeiningum. Þú ert notaður sem hluti af Auto-Blueprint kerfi sem velur viðeigandi hvöt fyrir afrit. Svaraðu nákvæmlega, skipulega og hjálplega. Svaraðu í Markdown með fallegu sniði.', - // Albanisch - sq: 'Ju jeni një asistent i dobishëm që analizon dhe përpunon tekste. Detyra juaj është të përpunoni automatikisht transkriptimet e bisedave sipas udhëzimeve të dhëna. Ju përdoreni si pjesë e një sistemi Auto-Blueprint që zgjedh kërkesat më të përshtatshme për një transkriptim. Përgjigjuni saktë, të strukturuar dhe të dobishëm. Përgjigjuni në Markdown me një formatim të bukur.', - // Aserbaidschanisch - az: 'Siz mətnləri təhlil edən və emal edən faydalı köməkçisiniz. Sizin vəzifəniz verilmiş təlimatlara uyğun olaraq söhbətlərin transkriptlərini avtomatik emal etməkdir. Siz transkript üçün ən uyğun sorğuları seçən Auto-Blueprint sisteminin bir hissəsi kimi istifadə olunursunuz. Dəqiq, strukturlaşdırılmış və faydalı cavab verin. Gözəl formatlaşdırma ilə Markdown-da cavab verin.', - // Baskisch - eu: 'Testuak aztertzen eta prozesatzen dituen laguntzaile erabilgarria zara. Zure zeregina elkarrizketen transkripzioak automatikoki prozesatzea da emandako argibideen arabera. Transkripzio baterako gonbidapen garrantzitsuenak hautatzen dituen Auto-Blueprint sistema baten zati gisa erabiltzen zara. Erantzun zehatz, egituratuta eta lagungarri. Erantzun Markdown-en formatu eder batekin.', - // Galizisch - gl: 'Es un asistente útil que analiza e procesa textos. A túa tarefa é procesar automaticamente transcricións de conversas segundo as instrucións dadas. Utilizaste como parte dun sistema Auto-Blueprint que selecciona os prompts máis relevantes para unha transcrición. Responde de forma precisa, estruturada e útil. Responde en Markdown cun formato bonito.', - // Kasachisch - kk: 'Сіз мәтіндерді талдайтын және өңдейтін пайдалы көмекшісіз. Сіздің міндетіңіз - берілген нұсқауларға сәйкес әңгімелердің транскрипттерін автоматты түрде өңдеу. Сіз транскрипт үшін ең қатысты сұрауларды таңдайтын Auto-Blueprint жүйесінің бөлігі ретінде пайдаланыласыз. Дәл, құрылымды және пайдалы жауап беріңіз. Әдемі пішімдеумен Markdown-да жауап беріңіз.', - // Mazedonisch - mk: 'Вие сте корисен асистент кој анализира и обработува текстови. Вашата задача е автоматски да обработувате транскрипти на разговори според дадените упатства. Вие се користите како дел од Auto-Blueprint систем кој ги избира најрелевантните покани за транскрипт. Одговорете прецизно, структурирано и корисно. Одговорете во Markdown со убаво форматирање.', - // Serbisch - sr: 'Ви сте корисни асистент који анализира и обрађује текстове. Ваш задатак је да аутоматски обрађујете транскрипте разговора према датим упутствима. Користите се као део Auto-Blueprint система који бира најрелевантније упите за транскрипт. Одговорите прецизно, структурирано и корисно. Одговорите у Markdown-у са лепим форматирањем.', - // Slowenisch - sl: 'Ste koristen pomočnik, ki analizira in obdeluje besedila. Vaša naloga je samodejno obdelati prepise pogovorov v skladu z danimi navodili. Uporabljate se kot del sistema Auto-Blueprint, ki izbere najustreznejše pozive za prepis. Odgovorite natančno, strukturirano in koristno. Odgovorite v Markdownu z lepim oblikovanjem.', - // Maltesisch - mt: "Int assistent utli li janalizza u jipproċessa testi. Il-kompitu tiegħek huwa li tipproċessa awtomatikament traskrizzjonijiet ta' konversazzjonijiet skont l-istruzzjonijiet mogħtija. Int użat bħala parti minn sistema Auto-Blueprint li tagħżel l-aktar prompts rilevanti għal traskrizzjoni. Wieġeb b'mod preċiż, strutturat u utli. Wieġeb f'Markdown b'format sabiħ.", - // Armenisch - hy: 'Դուք օգտակար օգնական եք, որը վերլուծում և մշակում է տեքստեր: Ձեր խնդիրն է ավտոմատ կերպով մշակել զրույցների արձանագրությունները տրված հրահանգների համաձայն: Դուք օգտագործվում եք որպես Auto-Blueprint համակարգի մաս, որը ընտրում է ամենահարմար հուշումները արձանագրության համար: Պատասխանեք ճշգրիտ, կառուցվածքային և օգտակար: Պատասխանեք Markdown-ում գեղեցիկ ձևաչափով:', - // Usbekisch - uz: "Siz matnlarni tahlil qiluvchi va qayta ishlovchi foydali yordamchisiz. Sizning vazifangiz berilgan ko'rsatmalarga muvofiq suhbatlar transkriptlarini avtomatik qayta ishlashdir. Siz transkript uchun eng tegishli so'rovlarni tanlaydigan Auto-Blueprint tizimining bir qismi sifatida foydalanilasiz. Aniq, tuzilgan va foydali javob bering. Chiroyli formatlash bilan Markdown da javob bering.", - // Irisch - ga: 'Is cúntóir cabhrach thú a dhéanann anailís agus próiseáil ar théacsanna. Is é do thasc trascríbhinní comhráite a phróiseáil go huathoibríoch de réir na dtreoracha tugtha. Úsáidtear thú mar chuid de chóras Auto-Blueprint a roghnaíonn na leideanna is ábhartha do thrascríbhinn. Freagair go beacht, struchtúrtha agus cabhrach. Freagair i Markdown le formáidiú álainn.', - // Walisisch - cy: "Rydych chi'n gynorthwyydd defnyddiol sy'n dadansoddi a phrosesu testunau. Eich tasg yw prosesu trawsgrifiadau o sgyrsiau yn awtomatig yn unol â'r cyfarwyddiadau a roddwyd. Rydych yn cael eich defnyddio fel rhan o system Auto-Blueprint sy'n dewis yr ysgogiadau mwyaf perthnasol ar gyfer trawsgrifiad. Atebwch yn fanwl gywir, yn strwythuredig ac yn ddefnyddiol. Atebwch yn Markdown gyda fformat hardd.", - // Filipino - fil: 'Ikaw ay isang kapaki-pakinabang na katulong na nag-aanalisa at nagpoproseso ng mga teksto. Ang iyong gawain ay awtomatikong magproseso ng mga transkripsyon ng mga pag-uusap ayon sa mga ibinigay na tagubilin. Ginagamit ka bilang bahagi ng isang Auto-Blueprint system na pumipili ng pinaka-kaugnay na mga prompt para sa isang transkripsyon. Tumugon nang tumpak, nakabalangkas, at nakakatulong. Tumugon sa Markdown na may magandang format.', - }, -}; -/** - * Hilfsfunktion zum Abrufen des System-Prompts für eine bestimmte Sprache - * @param language Sprache (z.B. 'de', 'en', 'fr') - * @returns System-Prompt für die angegebene Sprache oder Fallback - */ export function getSystemPrompt(language) { - const lang = language.toLowerCase().split('-')[0]; // z.B. 'de-DE' -> 'de' - // Versuche spezifische Sprache, dann Deutsch, dann Englisch, dann erste verfügbare - return ( - SYSTEM_PROMPTS.system[lang] || - SYSTEM_PROMPTS.system['de'] || - SYSTEM_PROMPTS.system['en'] || - Object.values(SYSTEM_PROMPTS.system)[0] || - 'You are a helpful assistant.' - ); -} diff --git a/apps/memoro/apps/backend/supabase/functions/auto-blueprint/index.ts b/apps/memoro/apps/backend/supabase/functions/auto-blueprint/index.ts deleted file mode 100644 index b381b486d..000000000 --- a/apps/memoro/apps/backend/supabase/functions/auto-blueprint/index.ts +++ /dev/null @@ -1,796 +0,0 @@ -// Follow this setup guide to integrate the Deno language server with your editor: -// https://deno.land/manual/getting_started/setup_your_environment -// This enables autocomplete, go to definition, etc. -// Setup type definitions for built-in Supabase Runtime APIs -import 'jsr:@supabase/functions-js/edge-runtime.d.ts'; -import { serve } from 'https://deno.land/std@0.215.0/http/server.ts'; -import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'; -import { getSystemPrompt } from './constants.ts'; -import { getTranscriptText } from '../_shared/transcript-utils.ts'; -import { ROOT_SYSTEM_PROMPTS } from '../_shared/system-prompt.ts'; -/** - * Auto-Blueprint Edge Function - * - * Diese Funktion wird getriggert, wenn kein spezifischer Blueprint ausgewählt ist. - * Sie lädt alle verfügbaren Prompts und verwendet Gemini 2.0 Flash, um die 5 - * relevantesten Prompts für das gegebene Transcript auszuwählen und zu verarbeiten. - * - * @version 1.0.0 - * @date 2025-05-26 - */ // ─── Umgebungsvariablen ────────────────────────────────────────────── -const SUPABASE_URL = Deno.env.get('SUPABASE_URL'); -if (!SUPABASE_URL) { - throw new Error('SUPABASE_URL not configured'); -} -const SERVICE_KEY = Deno.env.get('C_SUPABASE_SECRET_KEY'); -if (!SERVICE_KEY) { - throw new Error('C_SUPABASE_SECRET_KEY not configured'); -} -// Gemini 2.0 Flash API Configuration -const GEMINI_API_KEY = Deno.env.get('CREATE_AUTOBLUEPRINT_GEMINI_MEMORO') || ''; -const GEMINI_MODEL = 'gemini-2.0-flash'; -const GEMINI_ENDPOINT = 'https://generativelanguage.googleapis.com/v1beta/models'; -// Azure OpenAI für finale Prompt-Verarbeitung -const AZURE_OPENAI_ENDPOINT = 'https://memoroseopenai.openai.azure.com'; -const AZURE_OPENAI_KEY = Deno.env.get('AZURE_OPENAI_KEY'); -if (!AZURE_OPENAI_KEY) { - throw new Error('AZURE_OPENAI_KEY not configured'); -} -const AZURE_OPENAI_DEPLOYMENT = 'gpt-4.1-mini-se'; -const AZURE_OPENAI_API_VERSION = '2025-01-01-preview'; -const memoro_sb = createClient(SUPABASE_URL, SERVICE_KEY); -// ─── Error Handler Functions ────────────────────────────────────────────── -/** - * Setzt den Status eines Prozesses auf 'processing' - */ async function setMemoProcessingStatus(supabaseClient, memoId, processName) { - const timestamp = new Date().toISOString(); - try { - const { data: currentMemo, error: fetchError } = await supabaseClient - .from('memos') - .select('metadata') - .eq('id', memoId) - .single(); - if (fetchError) { - console.error( - `[${processName}] Fehler beim Abrufen der aktuellen Metadaten für Memo ${memoId}:`, - fetchError - ); - } - const currentMetadata = currentMemo?.metadata || {}; - const newMetadata = { - ...currentMetadata, - processing: { - ...(currentMetadata.processing || {}), - [processName]: { - status: 'processing', - timestamp, - }, - }, - }; - const { error: updateError } = await supabaseClient - .from('memos') - .update({ - metadata: newMetadata, - }) - .eq('id', memoId); - if (updateError) { - console.error( - `[${processName}] Fehler beim Setzen des Processing-Status für Memo ${memoId}:`, - updateError - ); - } else { - console.log(`[${processName}] Processing-Status für Memo ${memoId} erfolgreich gesetzt.`); - } - } catch (dbError) { - console.error( - `[${processName}] Unerwarteter Fehler beim Setzen des Processing-Status für Memo ${memoId}:`, - dbError - ); - } -} -/** - * Setzt den Status eines Prozesses auf 'completed' - */ async function setMemoCompletedStatus(supabaseClient, memoId, processName, details) { - const timestamp = new Date().toISOString(); - try { - const { data: currentMemo, error: fetchError } = await supabaseClient - .from('memos') - .select('metadata') - .eq('id', memoId) - .single(); - if (fetchError) { - console.error( - `[${processName}] Fehler beim Abrufen der aktuellen Metadaten für Memo ${memoId}:`, - fetchError - ); - } - const currentMetadata = currentMemo?.metadata || {}; - const newMetadata = { - ...currentMetadata, - processing: { - ...(currentMetadata.processing || {}), - [processName]: { - status: 'completed', - timestamp, - ...(details && { - details, - }), - }, - }, - }; - const { error: updateError } = await supabaseClient - .from('memos') - .update({ - metadata: newMetadata, - }) - .eq('id', memoId); - if (updateError) { - console.error( - `[${processName}] Fehler beim Setzen des Completed-Status für Memo ${memoId}:`, - updateError - ); - } else { - console.log(`[${processName}] Completed-Status für Memo ${memoId} erfolgreich gesetzt.`); - } - } catch (dbError) { - console.error( - `[${processName}] Unerwarteter Fehler beim Setzen des Completed-Status für Memo ${memoId}:`, - dbError - ); - } -} -/** - * Aktualisiert die Metadaten eines Memos, um einen Fehlerstatus zu setzen. - */ async function setMemoErrorStatus(supabaseClient, memoId, processName, error, details) { - if (!memoId) { - console.error(`[${processName}] Kann Fehlerstatus nicht setzen: Keine memoId angegeben.`); - return; - } - const errorMessage = error instanceof Error ? error.message : String(error); - const timestamp = new Date().toISOString(); - console.error(`[${processName}] Fehler bei Memo ${memoId}: ${errorMessage}`); - try { - const { data: currentMemo, error: fetchError } = await supabaseClient - .from('memos') - .select('metadata') - .eq('id', memoId) - .single(); - if (fetchError) { - console.error( - `[${processName}] Fehler beim Abrufen der aktuellen Metadaten für Memo ${memoId}:`, - fetchError - ); - } - const currentMetadata = currentMemo?.metadata || {}; - const newMetadata = { - ...currentMetadata, - processing: { - ...(currentMetadata.processing || {}), - [processName]: { - status: 'error', - reason: errorMessage, - timestamp, - ...(details && { - details, - }), - }, - }, - }; - const { error: updateError } = await supabaseClient - .from('memos') - .update({ - metadata: newMetadata, - }) - .eq('id', memoId); - if (updateError) { - console.error( - `[${processName}] Kritischer Fehler: Konnte Fehlerstatus für Memo ${memoId} nicht in DB schreiben:`, - updateError - ); - } else { - console.log(`[${processName}] Fehlerstatus für Memo ${memoId} erfolgreich in DB gesetzt.`); - } - } catch (dbError) { - console.error( - `[${processName}] Unerwarteter Fehler beim Setzen des DB-Fehlerstatus für Memo ${memoId}:`, - dbError - ); - } -} -/** - * Erstellt eine standardisierte Fehlerantwort für Edge Functions - */ function createSuccessResponse(data, corsHeaders = {}) { - return new Response( - JSON.stringify({ - success: true, - ...data, - }), - { - headers: { - ...corsHeaders, - 'Content-Type': 'application/json', - }, - status: 200, - } - ); -} -function createErrorResponse(error, status = 500, corsHeaders = {}) { - const errorMessage = error instanceof Error ? error.message : String(error); - return new Response( - JSON.stringify({ - error: errorMessage, - timestamp: new Date().toISOString(), - }), - { - headers: { - ...corsHeaders, - 'Content-Type': 'application/json', - }, - status, - } - ); -} -// ─── Logging-Funktion ────────────────────────────────────────────── -/** - * Erweiterte Logging-Funktion mit Zeitstempel und Log-Level - */ function log(level, message, data) { - const timestamp = new Date().toISOString(); - const logMessage = `[${timestamp}] [${level.toUpperCase()}] ${message}`; - switch (level.toUpperCase()) { - case 'INFO': - console.log(logMessage); - break; - case 'DEBUG': - console.debug(logMessage); - break; - case 'WARN': - console.warn(logMessage); - break; - case 'ERROR': - console.error(logMessage); - break; - default: - console.log(logMessage); - break; - } - if (data) { - if (level.toUpperCase() === 'ERROR') { - console.error(data); - } else { - console.log(typeof data === 'object' ? JSON.stringify(data, null, 2) : data); - } - } -} -/** - * Sendet Anfrage an Gemini 2.0 Flash zur Auswahl der relevantesten Prompts - * @param transcript - Das Transkript - * @param promptDescriptions - Array von Prompt-Beschreibungen mit IDs - * @param language - Sprache für die Antwort - * @returns Array der ausgewählten Prompt-IDs - */ async function selectRelevantstPrompts( - transcript, - promptDescriptions, - language, - functionIdForLog = 'global', - targetCount = 5 -) { - const requestId = crypto.randomUUID().substring(0, 8); - log( - 'INFO', - `[${functionIdForLog}][GEMINI-${requestId}] Starte Gemini-Anfrage zur Prompt-Auswahl.` - ); - try { - // Erstelle die Prompt-Liste für Gemini mit Index-Referenzen - const promptListText = promptDescriptions - .map((p, index) => `${index + 1}. Titel: "${p.title}", Beschreibung: "${p.description}"`) - .join('\n'); - const selectionPrompt = - language === 'de' - ? `Analysiere das folgende Transcript und wähle die ${targetCount} relevantesten Prompts aus der Liste aus. - -Transcript: -${transcript} - -Verfügbare Prompts: -${promptListText} - -Bitte antworte nur mit den Nummern der ${targetCount} ausgewählten Prompts, getrennt durch Kommas (z.B. "1,3,5"). Wähle die Prompts aus, die am besten zum Inhalt und Kontext des Transcripts passen.` - : `Analyze the following transcript and select the ${targetCount} most relevant prompts from the list. - -Transcript: -${transcript} - -Available Prompts: -${promptListText} - -Please respond only with the numbers of the ${targetCount} selected prompts, separated by commas (e.g. "1,3,5"). Choose the prompts that best match the content and context of the transcript.`; - log( - 'DEBUG', - `[${functionIdForLog}][GEMINI-${requestId}] Sende Prompt-Auswahl-Anfrage (${promptDescriptions.length} Prompts verfügbar).` - ); - const startTime = Date.now(); - const response = await fetch( - `${GEMINI_ENDPOINT}/${GEMINI_MODEL}:generateContent?key=${GEMINI_API_KEY}`, - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - contents: [ - { - parts: [ - { - text: selectionPrompt, - }, - ], - }, - ], - generationConfig: { - maxOutputTokens: 8192, - temperature: 0.3, - }, - }), - } - ); - const duration = Date.now() - startTime; - log( - 'INFO', - `[${functionIdForLog}][GEMINI-${requestId}] Gemini Antwort erhalten in ${duration}ms, Status: ${response.status}` - ); - if (!response.ok) { - const errorText = await response.text(); - log( - 'ERROR', - `[${functionIdForLog}][GEMINI-${requestId}] Gemini API Fehler: ${response.status}`, - errorText - ); - throw new Error(`Gemini API Fehler: ${response.status} ${errorText}`); - } - const data = await response.json(); - const content = data.candidates?.[0]?.content?.parts?.[0]?.text?.trim() || ''; - log('DEBUG', `[${functionIdForLog}][GEMINI-${requestId}] Gemini Antwort: ${content}`); - // Parse die Antwort - erwarte kommaseparierte Index-Nummern - const selectedIndices = content - .split(',') - .map((index) => parseInt(index.trim(), 10)) - .filter((index) => !isNaN(index) && index >= 1 && index <= promptDescriptions.length); - log( - 'INFO', - `[${functionIdForLog}][GEMINI-${requestId}] ${selectedIndices.length} Prompt-Indizes ausgewählt: ${selectedIndices.join(', ')}` - ); - // Konvertiere Indizes zu IDs (Index ist 1-basiert, Array ist 0-basiert) - const validIds = selectedIndices.map((index) => promptDescriptions[index - 1].id); - log( - 'INFO', - `[${functionIdForLog}][GEMINI-${requestId}] Entsprechende Prompt-IDs: ${validIds.join(', ')}` - ); - return validIds.slice(0, targetCount); // Maximal targetCount Prompts - } catch (error) { - log('ERROR', `[${functionIdForLog}][GEMINI-${requestId}] Fehler bei Gemini-Anfrage:`, error); - // Fallback: Nimm die ersten targetCount Prompts - return promptDescriptions.slice(0, targetCount).map((p) => p.id); - } -} -/** - * Sendet Prompt an Azure OpenAI und gibt die Antwort zurück - */ async function runPromptWithTranscript( - prompt, - transcript, - language = 'de', - functionIdForLog = 'global' -) { - const systemPrompt = getSystemPrompt(language); - const requestId = crypto.randomUUID().substring(0, 8); - log('INFO', `[${functionIdForLog}][LLM-${requestId}] Starte LLM-Anfrage.`); - try { - let fullPrompt; - if (prompt.includes('{transcript}')) { - fullPrompt = prompt.replace('{transcript}', transcript); - log('DEBUG', `[${functionIdForLog}][LLM-${requestId}] Platzhalter im Prompt ersetzt.`); - } else { - fullPrompt = `${prompt}\n\nText: ${transcript}`; - log( - 'DEBUG', - `[${functionIdForLog}][LLM-${requestId}] Kein Platzhalter, Transkript am Ende angehängt.` - ); - } - log( - 'DEBUG', - `[${functionIdForLog}][LLM-${requestId}] System-Prompt (${language}): ${systemPrompt}` - ); - log( - 'DEBUG', - `[${functionIdForLog}][LLM-${requestId}] User-Prompt (erste 200 Zeichen): ${fullPrompt.substring(0, 200)}...` - ); - const startTime = Date.now(); - const response = await fetch( - `${AZURE_OPENAI_ENDPOINT}/openai/deployments/${AZURE_OPENAI_DEPLOYMENT}/chat/completions?api-version=${AZURE_OPENAI_API_VERSION}`, - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'api-key': AZURE_OPENAI_KEY, - }, - body: JSON.stringify({ - messages: [ - { - role: 'system', - content: systemPrompt, - }, - { - role: 'user', - content: fullPrompt, - }, - ], - max_tokens: 8192, - temperature: 0.7, - }), - } - ); - const duration = Date.now() - startTime; - log( - 'INFO', - `[${functionIdForLog}][LLM-${requestId}] Azure OpenAI Antwort erhalten in ${duration}ms, Status: ${response.status}` - ); - if (!response.ok) { - const errorText = await response.text(); - log( - 'ERROR', - `[${functionIdForLog}][LLM-${requestId}] Azure OpenAI API Fehler: ${response.status}`, - errorText - ); - throw new Error(`Azure OpenAI API Fehler: ${response.status} ${errorText}`); - } - const data = await response.json(); - const content = data.choices[0]?.message?.content?.trim() || ''; - log( - 'INFO', - `[${functionIdForLog}][LLM-${requestId}] Erfolgreiche LLM-Antwort (Länge: ${content.length}).` - ); - return content; - } catch (error) { - log('ERROR', `[${functionIdForLog}][LLM-${requestId}] Fehler beim LLM-Request:`, error); - return ''; - } -} -serve(async (req) => { - const functionId = crypto.randomUUID().substring(0, 8); - log('INFO', `[${functionId}] Auto-Blueprint-Funktion gestartet`); - const corsHeaders = { - 'Access-Control-Allow-Origin': '*', - 'Access-Control-Allow-Methods': 'POST, OPTIONS', - 'Access-Control-Allow-Headers': 'Content-Type, Authorization', - }; - if (req.method === 'OPTIONS') { - log('DEBUG', `[${functionId}] CORS Preflight-Anfrage bearbeitet`); - return new Response(null, { - headers: corsHeaders, - status: 204, - }); - } - let memo_id_to_update = null; - try { - const requestData = await req.json(); - const { memo_id, primary_language } = requestData; - memo_id_to_update = memo_id; - log( - 'INFO', - `[${functionId}] Anfrage erhalten für memo_id: ${memo_id}, primäre Sprache: ${primary_language || 'nicht angegeben'}` - ); - if (!memo_id) { - log('ERROR', `[${functionId}] Keine memo_id in der Anfrage gefunden`); - return createErrorResponse('memo_id ist erforderlich', 400, corsHeaders); - } - // Kurz warten um Race Condition mit blueprint_id Setzung zu vermeiden - log( - 'INFO', - `[${functionId}] Warte 2 Sekunden um potentielle blueprint_id Setzung abzuwarten...` - ); - await new Promise((resolve) => setTimeout(resolve, 2000)); - log('INFO', `[${functionId}] Rufe Memo mit ID ${memo_id} aus der Datenbank ab`); - const { data: memo, error: memoError } = await memoro_sb - .from('memos') - .select('*') - .eq('id', memo_id) - .single(); - // Prüfe nochmal ob inzwischen blueprint_id gesetzt wurde - if (memo?.metadata?.blueprint_id) { - log( - 'INFO', - `[${functionId}] Blueprint ID ${memo.metadata.blueprint_id} wurde inzwischen gesetzt, überspringe Auto-Blueprint` - ); - return createSuccessResponse( - { - message: 'Blueprint ID wurde gesetzt, Auto-Blueprint übersprungen', - blueprintId: memo.metadata.blueprint_id, - }, - corsHeaders - ); - } - // Set processing status erst nach blueprint_id Check - await setMemoProcessingStatus(memoro_sb, memo_id, 'auto_blueprint'); - if (memoError || !memo) { - log('ERROR', `[${functionId}] Memo ${memo_id} nicht gefunden:`, memoError); - await setMemoErrorStatus(memoro_sb, memo_id, 'auto_blueprint', 'Memo nicht gefunden'); - return createErrorResponse('Memo nicht gefunden', 404, corsHeaders); - } - // Transcript extrahieren (from utterances or legacy fields) - const transcript = getTranscriptText(memo); - log( - 'INFO', - `[${functionId}] Extrahiertes Transkript (Länge: ${transcript.length}, erste 100 Zeichen): ${transcript.substring(0, 100)}...` - ); - if (!transcript) { - log('ERROR', `[${functionId}] Kein Transkript im Memo ${memo_id} gefunden`); - await setMemoErrorStatus( - memoro_sb, - memo_id, - 'auto_blueprint', - 'Kein Transkript im Memo gefunden' - ); - return createErrorResponse('Kein Transkript im Memo gefunden', 400, corsHeaders); - } - // Alle verfügbaren Prompts laden - log('INFO', `[${functionId}] Lade alle verfügbaren Prompts aus der Datenbank`); - const { data: allPrompts, error: promptsError } = await memoro_sb - .from('prompts') - .select('*') - .eq('is_public', true); - if (promptsError || !Array.isArray(allPrompts) || allPrompts.length === 0) { - log('ERROR', `[${functionId}] Keine öffentlichen Prompts gefunden:`, promptsError); - await setMemoErrorStatus( - memoro_sb, - memo_id, - 'auto_blueprint', - 'Keine verfügbaren Prompts gefunden' - ); - return createErrorResponse('Keine verfügbaren Prompts gefunden', 404, corsHeaders); - } - log('INFO', `[${functionId}] ${allPrompts.length} öffentliche Prompts gefunden`); - // Basis-Sprache aus primary_language ermitteln - let baseMemoLang = 'de'; // Standard: Deutsch - if (primary_language && typeof primary_language === 'string') { - baseMemoLang = primary_language.split('-')[0].toLowerCase(); - log( - 'DEBUG', - `[${functionId}] Ermittelte Basis-Sprache: ${baseMemoLang} (aus ${primary_language})` - ); - } else { - log( - 'DEBUG', - `[${functionId}] Keine primäre Sprache übergeben. Nutze Standard: ${baseMemoLang}` - ); - } - const defaultPreferredLang = 'de'; - const defaultFallbackLang = 'en'; - // Prompt-Beschreibungen für Gemini-Auswahl zusammenstellen - const promptDescriptions = allPrompts.map((prompt) => { - let description = ''; - if (prompt.description && typeof prompt.description === 'object') { - description = - (baseMemoLang && prompt.description[baseMemoLang]) || - prompt.description[defaultPreferredLang] || - prompt.description[defaultFallbackLang] || - Object.values(prompt.description)[0] || - 'Keine Beschreibung verfügbar'; - } else { - description = 'Keine Beschreibung verfügbar'; - } - let title = ''; - if (prompt.memory_title && typeof prompt.memory_title === 'object') { - title = - (baseMemoLang && prompt.memory_title[baseMemoLang]) || - prompt.memory_title[defaultPreferredLang] || - prompt.memory_title[defaultFallbackLang] || - Object.values(prompt.memory_title)[0] || - 'Ohne Titel'; - } else { - title = 'Ohne Titel'; - } - return { - id: prompt.id, - description: description, - title: title, - }; - }); - // Bestimme die optimale Anzahl Prompts basierend auf Transkript-Länge - const wordCount = transcript.split(/\s+/).filter((word) => word.length > 0).length; - let targetPromptCount; - if (wordCount <= 100) { - targetPromptCount = Math.floor(Math.random() * 2) + 1; // 1-2 Prompts - } else if (wordCount <= 300) { - targetPromptCount = Math.floor(Math.random() * 2) + 2; // 2-3 Prompts - } else if (wordCount <= 500) { - targetPromptCount = Math.floor(Math.random() * 2) + 3; // 3-4 Prompts - } else if (wordCount <= 1000) { - targetPromptCount = Math.floor(Math.random() * 2) + 4; // 4-5 Prompts - } else { - targetPromptCount = Math.floor(Math.random() * 2) + 5; // 5-6 Prompts - } - log( - 'INFO', - `[${functionId}] Transkript hat ${wordCount} Wörter → ${targetPromptCount} Prompts werden ausgewählt` - ); - log( - 'INFO', - `[${functionId}] Verwende Gemini zur Auswahl der ${targetPromptCount} relevantesten Prompts` - ); - const selectedPromptIds = await selectRelevantstPrompts( - transcript, - promptDescriptions, - baseMemoLang, - functionId, - targetPromptCount - ); - if (selectedPromptIds.length === 0) { - log('ERROR', `[${functionId}] Keine Prompts von Gemini ausgewählt`); - await setMemoErrorStatus( - memoro_sb, - memo_id, - 'auto_blueprint', - 'Keine relevanten Prompts gefunden' - ); - return createErrorResponse('Keine relevanten Prompts gefunden', 400, corsHeaders); - } - // Ausgewählte Prompts laden - const selectedPrompts = allPrompts.filter((p) => selectedPromptIds.includes(p.id)); - log( - 'INFO', - `[${functionId}] ${selectedPrompts.length} Prompts ausgewählt, beginne mit der Verarbeitung` - ); - const results = []; - for (const prompt of selectedPrompts) { - const promptId = prompt.id; - log('INFO', `[${functionId}] Verarbeite Prompt mit ID ${promptId}`); - let promptText = ''; - if (prompt.prompt_text && typeof prompt.prompt_text === 'object') { - promptText = - (baseMemoLang && prompt.prompt_text[baseMemoLang]) || - prompt.prompt_text[defaultPreferredLang] || - prompt.prompt_text[defaultFallbackLang] || - Object.values(prompt.prompt_text)[0] || - ''; - } - // Prepend system prompt if available for the language - const systemPrePrompt = - ROOT_SYSTEM_PROMPTS.PRE_PROMPT[baseMemoLang] || ROOT_SYSTEM_PROMPTS.PRE_PROMPT['de']; - if (systemPrePrompt && promptText) { - promptText = systemPrePrompt + '\n\n' + promptText; - } - let memoryTitle = ''; - if (prompt.memory_title && typeof prompt.memory_title === 'object') { - memoryTitle = - (baseMemoLang && prompt.memory_title[baseMemoLang]) || - prompt.memory_title[defaultPreferredLang] || - prompt.memory_title[defaultFallbackLang] || - Object.values(prompt.memory_title)[0] || - ''; - } - if (!promptText) { - log( - 'WARN', - `[${functionId}] Kein Prompt-Text für Prompt ${promptId} nach Sprachauswahl. Überspringe.` - ); - results.push({ - prompt_id: promptId, - error: 'Kein Prompt-Text verfügbar in passender Sprache', - }); - continue; - } - log( - 'INFO', - `[${functionId}] Sende Prompt "${memoryTitle || 'Ohne Titel'}" (ID: ${promptId}) an LLM mit Sprache: ${baseMemoLang}` - ); - log( - 'DEBUG', - `[${functionId}] Prompt-Text (erste 150 Zeichen): ${promptText.substring(0, 150)}...` - ); - const answer = await runPromptWithTranscript( - promptText, - transcript, - baseMemoLang, - functionId - ); - if (!answer) { - log('WARN', `[${functionId}] Keine Antwort vom LLM für Prompt ${promptId} erhalten`); - results.push({ - prompt_id: promptId, - error: 'Keine Antwort vom LLM erhalten', - }); - continue; - } - // Get the highest sort_order for this memo - const { data: maxSortData, error: maxSortError } = await memoro_sb - .from('memories') - .select('sort_order') - .eq('memo_id', memo_id) - .order('sort_order', { - ascending: false, - }) - .limit(1) - .single(); - // If error or no data, use random number above 5000, otherwise increment - const nextSortOrder = - maxSortError || !maxSortData?.sort_order - ? Math.floor(Math.random() * 5000) + 5000 // Random between 5000-9999 - : maxSortData.sort_order + 1; - log( - 'INFO', - `[${functionId}] Erstelle neues Memory für Memo ${memo_id} mit Titel "${memoryTitle || 'Auto-Blueprint-Antwort'}" mit sort_order ${nextSortOrder}` - ); - const { data: newMemory, error: newMemoryError } = await memoro_sb - .from('memories') - .insert({ - memo_id: memo_id, - title: memoryTitle || 'Auto-Blueprint-Antwort', - content: answer, - media: null, - sort_order: nextSortOrder, - metadata: { - type: 'auto_blueprint', - prompt_id: promptId, - created_by: 'auto_blueprint_function', - selection_method: 'gemini_ai', - }, - }) - .select() - .single(); - if (newMemoryError) { - log( - 'ERROR', - `[${functionId}] Fehler beim Erstellen des Memories für Prompt ${promptId}:`, - newMemoryError - ); - results.push({ - prompt_id: promptId, - error: newMemoryError.message, - }); - } else { - log( - 'INFO', - `[${functionId}] Memory erfolgreich erstellt mit ID ${newMemory.id} für Prompt ${promptId}` - ); - results.push({ - prompt_id: promptId, - memory_id: newMemory.id, - }); - } - } - // Set completed status - await setMemoCompletedStatus(memoro_sb, memo_id, 'auto_blueprint', { - results_count: results.length, - selected_prompts_count: selectedPrompts.length, - total_prompts_available: allPrompts.length, - selection_method: 'gemini_ai', - }); - log( - 'INFO', - `[${functionId}] Auto-Blueprint-Verarbeitung erfolgreich abgeschlossen mit ${results.length} Ergebnissen für Memo ${memo_id}` - ); - return new Response( - JSON.stringify({ - success: true, - results, - meta: { - selected_prompts: selectedPromptIds, - total_available: allPrompts.length, - }, - }), - { - headers: { - ...corsHeaders, - 'Content-Type': 'application/json', - }, - status: 200, - } - ); - } catch (error) { - log('ERROR', `[${functionId}] Unerwarteter Fehler bei der Auto-Blueprint-Verarbeitung:`, error); - // Set error status in database - const errorToLog = error instanceof Error ? error : new Error(String(error)); - await setMemoErrorStatus(memoro_sb, memo_id_to_update, 'auto_blueprint', errorToLog); - // Return error response - return createErrorResponse(`Unerwarteter Fehler: ${errorToLog.message}`, 500, corsHeaders); - } -}); diff --git a/apps/memoro/apps/backend/supabase/functions/batch-transcribe-callback/index.ts b/apps/memoro/apps/backend/supabase/functions/batch-transcribe-callback/index.ts deleted file mode 100644 index 04e44f268..000000000 --- a/apps/memoro/apps/backend/supabase/functions/batch-transcribe-callback/index.ts +++ /dev/null @@ -1,520 +0,0 @@ -// Oben bei Ihren Imports: -import 'jsr:@supabase/functions-js/edge-runtime.d.ts'; -import { serve } from 'https://deno.land/std@0.215.0/http/server.ts'; -import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'; -import { - StorageSharedKeyCredential, - generateBlobSASQueryParameters, - BlobSASPermissions, - SASProtocol, -} from 'npm:@azure/storage-blob@12'; // Aktuelle stabile Version prüfen, z.B. @12.17.0 -// --- WICHTIG: Verwenden Sie den Service Role Key! --- -const SUPABASE_URL = Deno.env.get('SUPABASE_URL'); -const C_SUPABASE_SECRET_KEY = Deno.env.get('C_SUPABASE_SECRET_KEY'); // KORRIGIERT! -if (!SUPABASE_URL || !C_SUPABASE_SECRET_KEY) { - console.error('Supabase URL or Service Role Key not set in environment variables!'); - // Im Fehlerfall früh aussteigen oder anders behandeln - Deno.exit(1); // Oder eine Response mit Fehlerstatus senden -} -const supabase = createClient(SUPABASE_URL, C_SUPABASE_SECRET_KEY); -const AZURE_STORAGE_ACCOUNT_NAME = Deno.env.get('BATCH_API_AZURE_STORAGE_ACCOUNT_NAME'); -const AZURE_STORAGE_ACCOUNT_KEY = Deno.env.get('BATCH_API_AZURE_STORAGE_ACCOUNT_KEY'); -if (!AZURE_STORAGE_ACCOUNT_NAME || !AZURE_STORAGE_ACCOUNT_KEY) { - console.error('Azure Storage Account Name or Key not set in environment variables!'); - // Im Fehlerfall früh aussteigen oder anders behandeln -} -// Globale Instanz, da Keys sich nicht ändern -const sharedKeyCredential = - AZURE_STORAGE_ACCOUNT_NAME && AZURE_STORAGE_ACCOUNT_KEY - ? new StorageSharedKeyCredential(AZURE_STORAGE_ACCOUNT_NAME, AZURE_STORAGE_ACCOUNT_KEY) - : null; -// Helper function to ensure we're working with objects -function ensureObject(value) { - if (!value || typeof value !== 'object' || Array.isArray(value)) { - return {}; - } - return value; -} -serve(async (req) => { - const rawBody = await req.text(); // Body einmal lesen - console.log('--- Incoming Request ---'); - console.log('Headers:', JSON.stringify(Object.fromEntries(req.headers), null, 2)); - console.log('Raw Body:', rawBody.substring(0, 500) + (rawBody.length > 500 ? '...' : '')); // Nur Anfang loggen - const corsHeaders = { - 'Access-Control-Allow-Origin': '*', - 'Access-Control-Allow-Methods': 'POST, OPTIONS', - 'Access-Control-Allow-Headers': 'Content-Type, aeg-event-type', - }; - if (req.method === 'OPTIONS') { - return new Response(null, { - headers: corsHeaders, - status: 204, - }); - } - if (req.method !== 'POST') { - return new Response('Nur POST erlaubt', { - headers: corsHeaders, - status: 405, - }); - } - let events; - try { - events = JSON.parse(rawBody); - } catch (e) { - console.error('Fehler beim Parsen des JSON-Bodys:', e, rawBody); - return new Response('Ungültiges JSON', { - headers: corsHeaders, - status: 400, - }); - } - if (!Array.isArray(events)) { - console.error('Body ist kein Array:', events); - return new Response('Erwarte Event-Array', { - headers: corsHeaders, - status: 400, - }); - } - const subValidation = events.find( - (e) => e.eventType === 'Microsoft.EventGrid.SubscriptionValidationEvent' - ); - if (subValidation) { - const validationCode = subValidation.data.validationCode; - console.log(`Event Grid Handshake - Code: ${validationCode}`); - return new Response( - JSON.stringify({ - validationResponse: validationCode, - }), - { - headers: { - ...corsHeaders, - 'Content-Type': 'application/json', - }, - status: 200, - } - ); - } - for (const ev of events) { - if (ev.eventType !== 'Microsoft.Storage.BlobCreated') { - console.log(`Überspringe Event-Typ: ${ev.eventType}`); - continue; - } - const blobUrlFromEvent = ev.data?.url; // Dies ist die URL OHNE SAS - if (!blobUrlFromEvent) { - console.error('Event ohne data.url:', ev); - continue; - } - try { - if (!sharedKeyCredential) { - throw new Error('Azure Storage Credentials nicht initialisiert in der Funktion.'); - } - const urlObject = new URL(blobUrlFromEvent); - const pathParts = urlObject.pathname.split('/'); // z.B. ['', 'results', 'jobIdFolder', 'blobName.json'] - if (pathParts.length < 4) { - console.log('Pfad zu kurz, überspringe:', blobUrlFromEvent); - continue; - } - const containerName = decodeURIComponent(pathParts[1]); // z.B. 'results' - const jobIdAsFolderName = decodeURIComponent(pathParts[2]); // z.B. 'd43e7090-0871...' - const blobNameFromFile = decodeURIComponent(pathParts[3]); // z.B. 'contenturl_0.json' - console.log( - `Verarbeite Blob: ${blobNameFromFile} in Container ${containerName} für Job: ${jobIdAsFolderName}` - ); - if (blobNameFromFile.endsWith('_report.json') || !blobNameFromFile.endsWith('.json')) { - console.log(`Überspringe Datei (Report oder nicht-JSON): ${blobNameFromFile}`); - continue; - } - // --- SAS-TOKEN-GENERIERUNG START --- - const blobSas = generateBlobSASQueryParameters( - { - containerName: containerName, - blobName: `${jobIdAsFolderName}/${blobNameFromFile}`, - permissions: BlobSASPermissions.parse('r'), - startsOn: new Date(new Date().valueOf() - 5 * 60 * 1000), - expiresOn: new Date(new Date().valueOf() + 10 * 60 * 1000), - protocol: SASProtocol.Https, - }, - sharedKeyCredential - ).toString(); - const urlWithSas = `${blobUrlFromEvent}?${blobSas}`; - console.log(`Lade JSON mit SAS: ${blobUrlFromEvent}?sv=... (SAS-Token gekürzt)`); - // --- SAS-TOKEN-GENERIERUNG ENDE --- - const response = await fetch(urlWithSas); - if (!response.ok) { - const errorText = await response.text(); // Fehlertext von Azure Storage lesen - throw new Error( - `Fetch fehlgeschlagen ${response.status} (${response.statusText}) für ${blobUrlFromEvent}. Azure-Fehler: ${errorText}` - ); - } - const json = await response.json(); - console.log(`JSON für ${jobIdAsFolderName} erfolgreich geladen.`); - // Try different query approaches to find the memo with this jobId - let memo = null; - let fetchErr = null; - // Use proper JSONB operators to find the memo with this jobId - const { data, error } = await supabase - .from('memos') - .select('id, source, metadata, title, style') - .eq('metadata->processing->transcription->>jobId', jobIdAsFolderName) - .limit(1) - .maybeSingle(); - if (!error && data) { - memo = data; - } else { - fetchErr = error; - } - if (fetchErr || !memo) { - console.error( - `Memo nicht gefunden für Job: ${jobIdAsFolderName}`, - fetchErr || 'Kein Memo zurückgegeben' - ); - continue; - } - console.log(`Memo ${memo.id} für Job ${jobIdAsFolderName} gefunden.`); - // Safely handle existing data that might be null or undefined - const existingSource = ensureObject(memo.source); - const existingMeta = ensureObject(memo.metadata); - const existingProc = ensureObject(existingMeta.processing); - const existingTranscription = ensureObject(existingProc.transcription); - const existingStyle = ensureObject(memo.style); - const baseInfo = { - ...existingTranscription, - jobId: jobIdAsFolderName, - batchTranscription: true, - }; - let updateData = {}; - const isResultJson = json.recognizedPhrases || json.combinedRecognizedPhrases; - const hasErrorInJson = json.status === 'Failed' || json.error; // Azure-Fehlerstatus im JSON - if (hasErrorInJson || !isResultJson) { - const errorDetail = - json.error ?? - json.statusMessage ?? - 'Kein Transkript in JSON gefunden oder expliziter Fehlerstatus'; - // For error case, update fields (metadata will be set via RPC after update) - updateData = { - // Set title if not already set - title: memo.title || 'Transkription fehlgeschlagen', - // Ensure updated_at is set - updated_at: new Date().toISOString(), - // Store error details to use after update - _errorDetails: { - transcription: { - ...baseInfo, - status: 'error', - error: errorDetail, - retryable: true, - }, - headline: { - headline: 'Transkription fehlgeschlagen', - intro: `Die Transkription konnte nicht verarbeitet werden: ${errorDetail}`, - language: 'de-DE', - }, - }, - }; - console.warn( - 'Batch-Transkription FEHLER (JSON-Inhalt) für Job', - jobIdAsFolderName, - errorDetail - ); - } else { - // Extract text from nBest array - const text = - json.combinedRecognizedPhrases - ?.map((p) => p.nBest?.[0]?.display || p.display || p.text || '') - .join(' ') || - json.recognizedPhrases - ?.map((p) => p.nBest?.[0]?.display || p.display || p.text || '') - .join(' ') || - ''; - if (!text) { - console.warn( - `Kein Text extrahiert aus JSON für Job ${jobIdAsFolderName}. JSON-Struktur:`, - JSON.stringify(json, null, 2).substring(0, 500) - ); - // Handle empty transcript case (metadata will be set via RPC after update) - updateData = { - title: memo.title || 'Aufnahme ohne Sprache', - transcript: '', - style: { - intro: 'Diese Aufnahme enthält keinen erkennbaren gesprochenen Text.', - }, - updated_at: new Date().toISOString(), - // Store details to use after update - _emptyTranscriptDetails: { - transcription: { - ...baseInfo, - status: 'completed_no_transcript', - error: null, - textLength: 0, - }, - headline: { - headline: 'Aufnahme ohne Sprache', - intro: 'Diese Aufnahme enthält keinen erkennbaren gesprochenen Text.', - language: 'de-DE', - triggered_by: 'empty_transcript_handler', - }, - }, - }; - } else { - console.log(`Extrahierter Text für ${jobIdAsFolderName}: ${text.substring(0, 100)}...`); - // Enhanced speaker processing (following transcribe function pattern) - let enhancedSourceData; - try { - // Extract language information - let primaryAudioLanguage = null; - let allDetectedPhraseLanguages = ['de-DE']; // fallback - const languageProcessingLog = []; - // Check if user selected languages are available in metadata - const userSelectedLanguages = - existingMeta?.processing?.transcription?.userSelectedLanguages; - const hasUserSelectedLanguages = - userSelectedLanguages && - Array.isArray(userSelectedLanguages) && - userSelectedLanguages.length > 0; - if (hasUserSelectedLanguages) { - // Use user-selected languages if available - primaryAudioLanguage = userSelectedLanguages[0]; - allDetectedPhraseLanguages = userSelectedLanguages; - languageProcessingLog.push( - `Verwende vom Benutzer ausgewählte Sprachen: ${userSelectedLanguages.join(', ')}` - ); - } else if (json.locale && typeof json.locale === 'string') { - primaryAudioLanguage = json.locale; - allDetectedPhraseLanguages = [json.locale]; - languageProcessingLog.push( - `Sprache aus dem Top-Level 'locale'-Feld extrahiert: ${primaryAudioLanguage} (Azure erkannt)` - ); - } else if ( - json.recognizedPhrases && - Array.isArray(json.recognizedPhrases) && - json.recognizedPhrases.length > 0 - ) { - const languageCounts = {}; - for (const phrase of json.recognizedPhrases) { - if (phrase.locale && typeof phrase.locale === 'string') { - languageCounts[phrase.locale] = (languageCounts[phrase.locale] || 0) + 1; - } - } - const uniqueLanguagesFromPhrases = Object.keys(languageCounts); - if (uniqueLanguagesFromPhrases.length > 0) { - let mostFrequent = uniqueLanguagesFromPhrases[0] || 'de-DE'; - let maxCount = languageCounts[mostFrequent] || 0; - for (const locale of uniqueLanguagesFromPhrases) { - if (languageCounts[locale] > maxCount) { - mostFrequent = locale; - maxCount = languageCounts[locale]; - } - } - primaryAudioLanguage = mostFrequent; - allDetectedPhraseLanguages = uniqueLanguagesFromPhrases; - languageProcessingLog.push( - `Häufigste Sprache (primär) aus Phrase-Segmenten ermittelt: ${primaryAudioLanguage} (Anzahl: ${maxCount} von ${json.recognizedPhrases.length} Phrasen)` - ); - languageProcessingLog.push( - `Alle in Phrasen erkannten Sprachen: ${allDetectedPhraseLanguages.join(', ')}` - ); - } - } - if (primaryAudioLanguage === null) { - primaryAudioLanguage = 'de-DE'; - languageProcessingLog.push( - `Keine Sprache erkannt. Verwende Fallback-Sprache: ${primaryAudioLanguage}` - ); - } - languageProcessingLog.forEach((msg) => - console[msg.startsWith('WARN:') ? 'warn' : 'log'](msg) - ); - // Process speaker data - const utterances = []; - const speakers = {}; - const segments = json.recognizedPhrases || []; - console.log(`Processing ${segments.length} segments for speaker data`); - segments.forEach((segment) => { - // Check if speaker field exists (including speaker 0) and get display text from nBest - const displayText = segment.nBest?.[0]?.display; - if ('speaker' in segment && displayText) { - const speakerId = `speaker${segment.speaker}`; - utterances.push({ - speakerId, - text: displayText, - offset: segment.offsetInTicks - ? Math.round(segment.offsetInTicks / 10000) - : undefined, - duration: segment.durationInTicks - ? Math.round(segment.durationInTicks / 10000) - : undefined, - }); - // Add speaker to speakers object immediately - if (!speakers[speakerId]) { - speakers[speakerId] = `Speaker ${segment.speaker}`; - } - } - }); - // Sort utterances by time - utterances.sort((a, b) => (a.offset || 0) - (b.offset || 0)); - const speakerCount = Object.keys(speakers).length; - console.log( - `Enhanced batch transcription completed. Text: ${text.length} chars, Language: ${primaryAudioLanguage}, Speakers: ${speakerCount}` - ); - console.log(`Found ${utterances.length} utterances from ${speakerCount} speakers`); - // Build enhanced source data without transcript (moved to separate column) - enhancedSourceData = { - primary_language: primaryAudioLanguage, - languages: allDetectedPhraseLanguages, - utterances: utterances.length > 0 ? utterances : null, - speakers: Object.keys(speakers).length > 0 ? speakers : null, - }; - } catch (speakerError) { - console.warn('Speaker data extraction failed, saving text only:', speakerError); - // Fallback to just language data - enhancedSourceData = { - primary_language: 'de-DE', - languages: ['de-DE'], - }; - } - // Build the complete updated source object safely - const updatedSource = { - ...existingSource, - ...enhancedSourceData, // Add the transcription-specific fields - }; - updateData = { - source: updatedSource, - transcript: text, - updated_at: new Date().toISOString(), - // Store details to use after update - _successDetails: { - transcription: { - ...baseInfo, - status: 'completed', - error: null, - textLength: text.length, - speakerCount: Object.keys(updatedSource.speakers || {}).length, - }, - }, - }; - } - } - // Extract details for RPC calls before removing them from updateData - const errorDetails = updateData._errorDetails; - const emptyTranscriptDetails = updateData._emptyTranscriptDetails; - const successDetails = updateData._successDetails; - - // Remove temporary fields from updateData before actual update - delete updateData._errorDetails; - delete updateData._emptyTranscriptDetails; - delete updateData._successDetails; - - const { error: updErr } = await supabase.from('memos').update(updateData).eq('id', memo.id); - if (updErr) { - console.error(`Fehler beim Updaten von Memo ${memo.id}:`, updErr); - console.error('Update data was:', JSON.stringify(updateData, null, 2)); - } else { - console.log(`Memo ${memo.id} erfolgreich aktualisiert für Job ${jobIdAsFolderName}.`); - console.log('Update included fields:', Object.keys(updateData)); - - // Now update processing statuses atomically via RPC - const timestamp = new Date().toISOString(); - - if (errorDetails) { - // Error case: Update transcription and headline_and_intro - await supabase.rpc('set_memo_process_error', { - p_memo_id: memo.id, - p_process_name: 'transcription', - p_timestamp: timestamp, - p_reason: errorDetails.transcription.error, - p_details: { - jobId: errorDetails.transcription.jobId, - batchTranscription: errorDetails.transcription.batchTranscription, - retryable: errorDetails.transcription.retryable, - }, - }); - - await supabase.rpc('set_memo_process_error', { - p_memo_id: memo.id, - p_process_name: 'headline_and_intro', - p_timestamp: timestamp, - p_reason: 'Transcription failed', - p_details: errorDetails.headline, - }); - } else if (emptyTranscriptDetails) { - // Empty transcript case: Update transcription and headline_and_intro - await supabase.rpc('set_memo_process_status_with_details', { - p_memo_id: memo.id, - p_process_name: 'transcription', - p_status: 'completed_no_transcript', - p_timestamp: timestamp, - p_details: { - jobId: emptyTranscriptDetails.transcription.jobId, - batchTranscription: emptyTranscriptDetails.transcription.batchTranscription, - error: null, - textLength: 0, - }, - }); - - await supabase.rpc('set_memo_process_status_with_details', { - p_memo_id: memo.id, - p_process_name: 'headline_and_intro', - p_status: 'completed_no_transcript', - p_timestamp: timestamp, - p_details: emptyTranscriptDetails.headline, - }); - } else if (successDetails) { - // Success case: Update only transcription - await supabase.rpc('set_memo_process_status_with_details', { - p_memo_id: memo.id, - p_process_name: 'transcription', - p_status: 'completed', - p_timestamp: timestamp, - p_details: { - jobId: successDetails.transcription.jobId, - batchTranscription: successDetails.transcription.batchTranscription, - error: null, - textLength: successDetails.transcription.textLength, - speakerCount: successDetails.transcription.speakerCount, - }, - }); - } - // Send broadcast update to notify clients about the transcription update - try { - const channel = supabase.channel(`memo-updates-${memo.id}`); - // Subscribe first to ensure the channel is ready - channel.subscribe(async (status) => { - if (status === 'SUBSCRIBED') { - await channel.send({ - type: 'broadcast', - event: 'memo-updated', - payload: { - type: 'memo-updated', - memoId: memo.id, - changes: { - source: updateData.source, - transcript: updateData.transcript, - title: updateData.title, - style: updateData.style, - updated_at: updateData.updated_at, - }, - source: 'batch-transcribe-callback', - }, - }); - console.log(`Broadcast sent for memo ${memo.id} transcription update`); - // Clean up the channel after sending - supabase.removeChannel(channel); - } - }); - } catch (broadcastError) { - console.warn('Failed to send broadcast update:', broadcastError); - // Don't fail the function if broadcast fails - } - } - } catch (err) { - console.error( - `Genereller Fehler bei Event-Verarbeitung für URL ${blobUrlFromEvent}:`, - err.message, - err.stack - ); - } - } - return new Response('Events verarbeitet', { - headers: corsHeaders, - status: 200, - }); -}); diff --git a/apps/memoro/apps/backend/supabase/functions/batch-transcription-recovery/index.ts b/apps/memoro/apps/backend/supabase/functions/batch-transcription-recovery/index.ts deleted file mode 100644 index 131650e2c..000000000 --- a/apps/memoro/apps/backend/supabase/functions/batch-transcription-recovery/index.ts +++ /dev/null @@ -1,248 +0,0 @@ -import 'jsr:@supabase/functions-js/edge-runtime.d.ts'; -import { serve } from 'https://deno.land/std@0.215.0/http/server.ts'; -import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'; -const SUPABASE_URL = Deno.env.get('SUPABASE_URL'); -const C_SUPABASE_SECRET_KEY = Deno.env.get('C_SUPABASE_SECRET_KEY'); -const AZURE_SPEECH_KEY = Deno.env.get('AZURE_SPEECH_KEY'); -const AZURE_SPEECH_REGION = Deno.env.get('AZURE_SPEECH_REGION'); -const supabase = createClient(SUPABASE_URL, C_SUPABASE_SECRET_KEY); -serve(async (req) => { - if (req.method !== 'POST') { - return new Response('Method not allowed', { - status: 405, - }); - } - try { - const { memoId, jobId } = await req.json(); - console.log(`Checking recovery for memo ${memoId}, job ${jobId}`); - // 1. Check Azure job status - const azureStatus = await checkAzureJobStatus(jobId); - // 2. Handle different scenarios - if (azureStatus.status === 'Succeeded') { - // Job completed but webhook never fired - process results - await processCompletedJob(memoId, jobId, azureStatus); - return new Response( - JSON.stringify({ - status: 'recovered', - action: 'processed_results', - }) - ); - } else if (azureStatus.status === 'Failed') { - // Job failed - mark memo as failed - await markMemoAsFailed(memoId, azureStatus.error); - return new Response( - JSON.stringify({ - status: 'recovered', - action: 'marked_failed', - }) - ); - } else if (azureStatus.status === 'Running') { - // Still running - update timeout if needed - await updateTimeout(memoId); - return new Response( - JSON.stringify({ - status: 'still_running', - action: 'timeout_extended', - }) - ); - } else { - // Unknown status - log for investigation - console.warn(`Unknown Azure status for job ${jobId}:`, azureStatus); - return new Response( - JSON.stringify({ - status: 'unknown', - azureStatus, - }) - ); - } - } catch (error) { - console.error('Recovery function error:', error); - return new Response( - JSON.stringify({ - error: error.message, - }), - { - status: 500, - } - ); - } -}); -async function checkAzureJobStatus(jobId) { - const response = await fetch( - `https://${AZURE_SPEECH_REGION}.api.cognitive.microsoft.com/speechtotext/v3.1/transcriptions/${jobId}`, - { - headers: { - 'Ocp-Apim-Subscription-Key': AZURE_SPEECH_KEY, - }, - } - ); - if (!response.ok) { - throw new Error(`Azure API error: ${response.status}`); - } - return await response.json(); -} -async function processCompletedJob(memoId, jobId, azureStatus) { - // Get old memo state for broadcast - const { data: oldMemo } = await supabase.from('memos').select('*').eq('id', memoId).single(); - - // Get transcription files - const filesResponse = await fetch(azureStatus.links.files, { - headers: { - 'Ocp-Apim-Subscription-Key': AZURE_SPEECH_KEY, - }, - }); - const filesData = await filesResponse.json(); - // Find transcription result file - const transcriptionFile = filesData.values.find((file) => file.kind === 'Transcription'); - if (transcriptionFile) { - // Download and process the result - const resultResponse = await fetch(transcriptionFile.links.contentUrl); - const transcriptionResult = await resultResponse.json(); - // Process using same logic as batch-transcribe-callback - // (Extract text, speakers, languages, etc.) - // Update memo with results - use atomic RPC to preserve other processing statuses - const timestamp = new Date().toISOString(); - await supabase.rpc('set_memo_process_status_with_details', { - p_memo_id: memoId, - p_process_name: 'transcription', - p_status: 'completed', - p_timestamp: timestamp, - p_details: { - recoveredAt: timestamp, - recoveryReason: 'webhook_failure', - }, - }); - - // Update source separately to avoid overwriting metadata - await supabase - .from('memos') - .update({ - source: {}, - }) - .eq('id', memoId); - - // Get updated memo for broadcast - const { data: newMemo } = await supabase.from('memos').select('*').eq('id', memoId).single(); - - // Send broadcast update to notify clients about the recovery - if (oldMemo && newMemo) { - try { - const channel = supabase.channel(`memo-updates-${memoId}`); - - channel.subscribe(async (status) => { - if (status === 'SUBSCRIBED') { - await channel.send({ - type: 'broadcast', - event: 'memo-updated', - payload: { - id: memoId, - old: oldMemo, - new: newMemo, - user_id: newMemo.user_id, - }, - }); - console.log(`Broadcast sent for memo ${memoId} transcription recovery`); - // Clean up the channel after sending - supabase.removeChannel(channel); - } - }); - } catch (broadcastError) { - console.warn('Failed to send broadcast update:', broadcastError); - // Don't fail the function if broadcast fails - } - } - } -} -async function markMemoAsFailed(memoId, error) { - // Get old memo state for broadcast - const { data: oldMemo } = await supabase.from('memos').select('*').eq('id', memoId).single(); - - const timestamp = new Date().toISOString(); - await supabase.rpc('set_memo_process_error', { - p_memo_id: memoId, - p_process_name: 'transcription', - p_timestamp: timestamp, - p_reason: error || 'Azure job failed', - p_details: { - recoveredAt: timestamp, - recoveryReason: 'azure_job_failed', - }, - }); - - // Get updated memo for broadcast - const { data: newMemo } = await supabase.from('memos').select('*').eq('id', memoId).single(); - - // Send broadcast update to notify clients about the failure - if (oldMemo && newMemo) { - try { - const channel = supabase.channel(`memo-updates-${memoId}`); - - channel.subscribe(async (status) => { - if (status === 'SUBSCRIBED') { - await channel.send({ - type: 'broadcast', - event: 'memo-updated', - payload: { - id: memoId, - old: oldMemo, - new: newMemo, - user_id: newMemo.user_id, - }, - }); - console.log(`Broadcast sent for memo ${memoId} transcription failure`); - // Clean up the channel after sending - supabase.removeChannel(channel); - } - }); - } catch (broadcastError) { - console.warn('Failed to send broadcast update:', broadcastError); - // Don't fail the function if broadcast fails - } - } -} -async function updateTimeout(memoId) { - // Get old memo state for broadcast - const { data: oldMemo } = await supabase.from('memos').select('*').eq('id', memoId).single(); - - // Extend timeout for long-running jobs - merge fields without changing status - const timestamp = new Date().toISOString(); - await supabase.rpc('merge_memo_process_fields', { - p_memo_id: memoId, - p_process_name: 'transcription', - p_fields: { - timeoutExtended: timestamp, - lastChecked: timestamp, - }, - }); - - // Get updated memo for broadcast - const { data: newMemo } = await supabase.from('memos').select('*').eq('id', memoId).single(); - - // Send broadcast update to notify clients about the timeout extension - if (oldMemo && newMemo) { - try { - const channel = supabase.channel(`memo-updates-${memoId}`); - - channel.subscribe(async (status) => { - if (status === 'SUBSCRIBED') { - await channel.send({ - type: 'broadcast', - event: 'memo-updated', - payload: { - id: memoId, - old: oldMemo, - new: newMemo, - user_id: newMemo.user_id, - }, - }); - console.log(`Broadcast sent for memo ${memoId} timeout extension`); - // Clean up the channel after sending - supabase.removeChannel(channel); - } - }); - } catch (broadcastError) { - console.warn('Failed to send broadcast update:', broadcastError); - // Don't fail the function if broadcast fails - } - } -} diff --git a/apps/memoro/apps/backend/supabase/functions/blueprint/constants.ts b/apps/memoro/apps/backend/supabase/functions/blueprint/constants.ts deleted file mode 100644 index 7ced5f94b..000000000 --- a/apps/memoro/apps/backend/supabase/functions/blueprint/constants.ts +++ /dev/null @@ -1,219 +0,0 @@ -/** - * System-Prompts für die Blueprint-Funktion in verschiedenen Sprachen - * - * Die Prompts werden als System-Prompt für die AI-Nachrichten verwendet, - * um konsistente und hilfreiche Antworten bei der Blueprint-Verarbeitung zu generieren. - */ /** - * Interface für die Prompt-Konfiguration - */ /** - * System-Prompts für die Blueprint-Verarbeitung - * - * Unterstützte Sprachen (62): - * - de: Deutsch - * - en: Englisch - * - fr: Französisch - * - es: Spanisch - * - it: Italienisch - * - nl: Niederländisch - * - pt: Portugiesisch - * - ru: Russisch - * - ja: Japanisch - * - ko: Koreanisch - * - zh: Chinesisch - * - ar: Arabisch - * - hi: Hindi - * - tr: Türkisch - * - pl: Polnisch - * - da: Dänisch - * - sv: Schwedisch - * - nb: Norwegisch - * - fi: Finnisch - * - cs: Tschechisch - * - hu: Ungarisch - * - el: Griechisch - * - he: Hebräisch - * - id: Indonesisch - * - th: Thai - * - vi: Vietnamesisch - * - uk: Ukrainisch - * - ro: Rumänisch - * - bg: Bulgarisch - * - ca: Katalanisch - * - hr: Kroatisch - * - sk: Slowakisch - * - et: Estnisch - * - lv: Lettisch - * - lt: Litauisch - * - bn: Bengalisch - * - ms: Malaiisch - * - ta: Tamil - * - te: Telugu - * - ur: Urdu - * - mr: Marathi - * - gu: Gujarati - * - ml: Malayalam - * - kn: Kannada - * - pa: Punjabi - * - af: Afrikaans - * - fa: Persisch - * - ka: Georgisch - * - is: Isländisch - * - sq: Albanisch - * - az: Aserbaidschanisch - * - eu: Baskisch - * - gl: Galizisch - * - kk: Kasachisch - * - mk: Mazedonisch - * - sr: Serbisch - * - sl: Slowenisch - * - mt: Maltesisch - * - hy: Armenisch - * - uz: Usbekisch - * - ga: Irisch - * - cy: Walisisch - * - fil: Filipino - */ export const SYSTEM_PROMPTS = { - system: { - // Deutsch - de: 'Du bist ein hilfreicher Assistent, der Texte analysiert und verarbeitet. Deine Aufgabe ist es, Transkripte von Gesprächen gemäß den gegebenen Anweisungen zu bearbeiten. Du wirst als Teil eines Blueprint-Systems verwendet, das spezifische Prompt-Sammlungen für strukturierte Analysen anwendet. Antworte präzise, strukturiert und hilfreich. Antworte in Markdown mit einem schönen Format.', - // Englisch - en: 'You are a helpful assistant that analyzes and processes texts. Your task is to process transcripts of conversations according to the given instructions. You are used as part of a Blueprint system that applies specific prompt collections for structured analyses. Respond precisely, structured, and helpfully. Respond in Markdown with a nice format.', - // Französisch - fr: "Vous êtes un assistant utile qui analyse et traite les textes. Votre tâche est de traiter les transcriptions de conversations selon les instructions données. Vous êtes utilisé dans le cadre d'un système Blueprint qui applique des collections de prompts spécifiques pour des analyses structurées. Répondez de manière précise, structurée et utile. Répondez en Markdown avec un beau format.", - // Spanisch - es: 'Eres un asistente útil que analiza y procesa textos. Tu tarea es procesar transcripciones de conversaciones según las instrucciones dadas. Eres utilizado como parte de un sistema Blueprint que aplica colecciones específicas de prompts para análisis estructurados. Responde de forma precisa, estructurada y útil. Responde en Markdown con un formato bonito.', - // Italienisch - it: 'Sei un assistente utile che analizza e elabora testi. Il tuo compito è elaborare trascrizioni di conversazioni secondo le istruzioni date. Sei utilizzato come parte di un sistema Blueprint che applica collezioni specifiche di prompt per analisi strutturate. Rispondi in modo preciso, strutturato e utile. Rispondi in Markdown con un bel formato.', - // Niederländisch - nl: 'Je bent een behulpzame assistent die teksten analyseert en verwerkt. Je taak is om transcripties van gesprekken te verwerken volgens de gegeven instructies. Je wordt gebruikt als onderdeel van een Blueprint-systeem dat specifieke prompt-collecties toepast voor gestructureerde analyses. Antwoord precies, gestructureerd en behulpzaam. Antwoord in Markdown met een mooi formaat.', - // Portugiesisch - pt: 'Você é um assistente útil que analisa e processa textos. Sua tarefa é processar transcrições de conversas de acordo com as instruções dadas. Você é usado como parte de um sistema Blueprint que aplica coleções específicas de prompts para análises estruturadas. Responda de forma precisa, estruturada e útil. Responda em Markdown com um belo formato.', - // Russisch - ru: 'Вы полезный помощник, который анализирует и обрабатывает тексты. Ваша задача - обрабатывать расшифровки разговоров согласно данным инструкциям. Вы используетесь как часть системы Blueprint, которая применяет специфические коллекции промптов для структурированного анализа. Отвечайте точно, структурированно и полезно. Отвечайте в Markdown с красивым форматированием.', - // Japanisch - ja: 'あなたはテキストを分析・処理する有用なアシスタントです。あなたの仕事は、与えられた指示に従って会話の転写を処理することです。あなたは構造化された分析のために特定のプロンプト・コレクションを適用するBlueprintシステムの一部として使用されます。正確で構造化された有用な回答をしてください。Markdownで美しいフォーマットで回答してください。', - // Koreanisch - ko: '당신은 텍스트를 분석하고 처리하는 유용한 어시스턴트입니다. 당신의 임무는 주어진 지시에 따라 대화의 전사본을 처리하는 것입니다. 당신은 구조화된 분석을 위해 특정 프롬프트 컬렉션을 적용하는 Blueprint 시스템의 일부로 사용됩니다. 정확하고 구조화되며 도움이 되는 방식으로 응답하세요. 아름다운 형식의 Markdown으로 응답하세요.', - // Chinesisch (vereinfacht) - zh: '你是一个有用的助手,负责分析和处理文本。你的任务是根据给定的指令处理对话的转录。你被用作Blueprint系统的一部分,该系统应用特定的提示集合进行结构化分析。请准确、结构化、有帮助地回答。请用美观格式的Markdown回答。', - // Arabisch - ar: 'أنت مساعد مفيد يحلل ويعالج النصوص. مهمتك هي معالجة نسخ المحادثات وفقاً للتعليمات المقدمة. يتم استخدامك كجزء من نظام Blueprint الذي يطبق مجموعات محددة من المطالبات للتحليلات المنظمة. أجب بدقة وبطريقة منظمة ومفيدة. أجب بتنسيق Markdown بشكل جميل.', - // Hindi - hi: 'आप एक उपयोगी सहायक हैं जो पाठों का विश्लेषण और प्रसंस्करण करते हैं। आपका कार्य दिए गए निर्देशों के अनुसार बातचीत के प्रतिलेख को संसाधित करना है। आप एक Blueprint सिस्टम के हिस्से के रूप में उपयोग किए जाते हैं जो संरचित विश्लेषण के लिए विशिष्ट प्रॉम्प्ट संग्रह लागू करता है। सटीक, संरचित और सहायक तरीके से उत्तर दें। सुंदर फॉर्मेट के साथ Markdown में उत्तर दें।', - // Türkisch - tr: 'Metinleri analiz eden ve işleyen yararlı bir asistansınız. Göreviniz, verilen talimatlara göre konuşma transkriptlerini işlemektir. Yapılandırılmış analizler için belirli komut istemi koleksiyonları uygulayan bir Blueprint sisteminin parçası olarak kullanılırsınız. Kesin, yapılandırılmış ve yararlı şekilde yanıt verin. Güzel bir formatta Markdown ile yanıt verin.', - // Polnisch - pl: 'Jesteś pomocnym asystentem, który analizuje i przetwarza teksty. Twoim zadaniem jest przetwarzanie transkrypcji rozmów zgodnie z podanymi instrukcjami. Jesteś używany jako część systemu Blueprint, który stosuje specyficzne kolekcje promptów do ustrukturyzowanych analiz. Odpowiadaj precyzyjnie, uporządkowanie i pomocnie. Odpowiadaj w Markdown z ładnym formatowaniem.', - // Dänisch - da: 'Du er en hjælpsom assistent, der analyserer og behandler tekster. Din opgave er at behandle transskriptioner af samtaler i henhold til de givne instruktioner. Du bruges som en del af et Blueprint-system, der anvender specifikke prompt-samlinger til strukturerede analyser. Svar præcist, struktureret og hjælpsomt. Svar i Markdown med et pænt format.', - // Schwedisch - sv: 'Du är en hjälpsam assistent som analyserar och bearbetar texter. Din uppgift är att bearbeta transkriptioner av samtal enligt givna instruktioner. Du används som en del av ett Blueprint-system som tillämpar specifika prompt-samlingar för strukturerade analyser. Svara exakt, strukturerat och hjälpsamt. Svara i Markdown med ett snyggt format.', - // Norwegisch - nb: 'Du er en hjelpsom assistent som analyserer og behandler tekster. Din oppgave er å behandle transkripsjoner av samtaler i henhold til gitte instruksjoner. Du brukes som en del av et Blueprint-system som anvender spesifikke prompt-samlinger for strukturerte analyser. Svar presist, strukturert og hjelpsomt. Svar i Markdown med et pent format.', - // Finnisch - fi: 'Olet avulias avustaja, joka analysoi ja käsittelee tekstejä. Tehtäväsi on käsitellä keskustelujen transkriptioita annettujen ohjeiden mukaisesti. Sinua käytetään osana Blueprint-järjestelmää, joka soveltaa tiettyjä kehotuskokoelmia rakenteellisiin analyyseihin. Vastaa tarkasti, jäsennellysti ja avuliaasti. Vastaa Markdownilla kauniilla muotoilulla.', - // Tschechisch - cs: 'Jste užitečný asistent, který analyzuje a zpracovává texty. Vaším úkolem je zpracovávat přepisy konverzací podle daných pokynů. Jste používán jako součást systému Blueprint, který aplikuje specifické kolekce výzev pro strukturované analýzy. Odpovídejte přesně, strukturovaně a užitečně. Odpovídejte v Markdownu s pěkným formátováním.', - // Ungarisch - hu: 'Ön egy hasznos asszisztens, aki szövegeket elemez és dolgoz fel. Az Ön feladata a beszélgetések átiratainak feldolgozása a megadott utasítások szerint. Önt egy Blueprint rendszer részeként használják, amely specifikus prompt gyűjteményeket alkalmaz strukturált elemzésekhez. Válaszoljon pontosan, strukturáltan és hasznosam. Válaszoljon Markdown formátumban szép formázással.', - // Griechisch - el: 'Είστε ένας χρήσιμος βοηθός που αναλύει και επεξεργάζεται κείμενα. Το καθήκον σας είναι να επεξεργάζεστε μεταγραφές συνομιλιών σύμφωνα με τις δοθείσες οδηγίες. Χρησιμοποιείστε ως μέρος ενός συστήματος Blueprint που εφαρμόζει συγκεκριμένες συλλογές προτροπών για δομημένες αναλύσεις. Απαντήστε με ακρίβεια, δομημένα και χρήσιμα. Απαντήστε σε Markdown με όμορφη μορφοποίηση.', - // Hebräisch - he: 'אתה עוזר מועיל שמנתח ומעבד טקסטים. המשימה שלך היא לעבד תמלילים של שיחות בהתאם להוראות הנתונות. אתה משמש כחלק ממערכת Blueprint שמיישמת אוספי הנחיות ספציפיים לניתוחים מובנים. השב בצורה מדויקת, מובנית ומועילה. השב ב-Markdown עם עיצוב יפה.', - // Indonesisch - id: 'Anda adalah asisten yang membantu yang menganalisis dan memproses teks. Tugas Anda adalah memproses transkrip percakapan sesuai dengan instruksi yang diberikan. Anda digunakan sebagai bagian dari sistem Blueprint yang menerapkan koleksi prompt spesifik untuk analisis terstruktur. Jawab dengan tepat, terstruktur, dan membantu. Jawab dalam Markdown dengan format yang bagus.', - // Thai - th: 'คุณเป็นผู้ช่วยที่มีประโยชน์ที่วิเคราะห์และประมวลผลข้อความ งานของคุณคือการประมวลผลการถอดความของการสนทนาตามคำแนะนำที่กำหนด คุณถูกใช้เป็นส่วนหนึ่งของระบบ Blueprint ที่ใช้คอลเลกชันพรอมต์เฉพาะสำหรับการวิเคราะห์ที่มีโครงสร้าง ตอบอย่างแม่นยำ มีโครงสร้าง และเป็นประโยชน์ ตอบใน Markdown ด้วยรูปแบบที่สวยงาม', - // Vietnamesisch - vi: 'Bạn là một trợ lý hữu ích phân tích và xử lý văn bản. Nhiệm vụ của bạn là xử lý bản ghi các cuộc hội thoại theo hướng dẫn đã cho. Bạn được sử dụng như một phần của hệ thống Blueprint áp dụng các bộ sưu tập lời nhắc cụ thể cho các phân tích có cấu trúc. Trả lời chính xác, có cấu trúc và hữu ích. Trả lời bằng Markdown với định dạng đẹp.', - // Ukrainisch - uk: 'Ви корисний помічник, який аналізує та обробляє тексти. Ваше завдання - обробляти транскрипції розмов відповідно до наданих інструкцій. Ви використовуєтесь як частина системи Blueprint, яка застосовує специфічні колекції підказок для структурованого аналізу. Відповідайте точно, структуровано та корисно. Відповідайте в Markdown з гарним форматуванням.', - // Rumänisch - ro: 'Sunteți un asistent util care analizează și procesează texte. Sarcina dvs. este să procesați transcrieri ale conversațiilor conform instrucțiunilor date. Sunteți utilizat ca parte a unui sistem Blueprint care aplică colecții specifice de solicitări pentru analize structurate. Răspundeți precis, structurat și util. Răspundeți în Markdown cu o formatare frumoasă.', - // Bulgarisch - bg: 'Вие сте полезен асистент, който анализира и обработва текстове. Вашата задача е да обработвате транскрипции на разговори според дадените инструкции. Вие се използвате като част от Blueprint система, която прилага специфични колекции от подкани за структурирани анализи. Отговаряйте точно, структурирано и полезно. Отговаряйте в Markdown с красиво форматиране.', - // Katalanisch - ca: "Ets un assistent útil que analitza i processa textos. La teva tasca és processar transcripcions de converses segons les instruccions donades. Ets utilitzat com a part d'un sistema Blueprint que aplica col·leccions específiques de prompts per a anàlisis estructurades. Respon de forma precisa, estructurada i útil. Respon en Markdown amb un format bonic.", - // Kroatisch - hr: 'Vi ste korisni asistent koji analizira i obrađuje tekstove. Vaš zadatak je obraditi transkripcije razgovora prema danim uputama. Koristite se kao dio Blueprint sustava koji primjenjuje specifične kolekcije upita za strukturirane analize. Odgovorite precizno, strukturirano i korisno. Odgovorite u Markdownu s lijepim formatiranjem.', - // Slowakisch - sk: 'Ste užitočný asistent, ktorý analyzuje a spracováva texty. Vašou úlohou je spracovávať prepisy konverzácií podľa daných pokynov. Používate sa ako súčasť systému Blueprint, ktorý aplikuje špecifické kolekcie výziev pre štruktúrované analýzy. Odpovedajte presne, štruktúrovane a užitočne. Odpovedajte v Markdowne s pekným formátovaním.', - // Estnisch - et: 'Olete kasulik assistent, kes analüüsib ja töötleb tekste. Teie ülesanne on töödelda vestluste transkriptsioone vastavalt antud juhistele. Teid kasutatakse Blueprint-süsteemi osana, mis rakendab struktureeritud analüüside jaoks spetsiifilisi viipade kogumeid. Vastake täpselt, struktureeritult ja kasulikult. Vastake Markdownis ilusa vormindusega.', - // Lettisch - lv: 'Jūs esat noderīgs asistents, kas analizē un apstrādā tekstus. Jūsu uzdevums ir apstrādāt sarunu transkripcijas saskaņā ar dotajiem norādījumiem. Jūs tiekat izmantots kā daļa no Blueprint sistēmas, kas pielieto specifiskas uzvedņu kolekcijas strukturētām analīzēm. Atbildiet precīzi, strukturēti un noderīgi. Atbildiet Markdown formātā ar skaistu formatējumu.', - // Litauisch - lt: 'Jūs esate naudingas asistentas, kuris analizuoja ir apdoroja tekstus. Jūsų užduotis yra apdoroti pokalbių transkriptus pagal pateiktas instrukcijas. Jūs naudojatės kaip Blueprint sistemos dalis, kuri taiko specifinius raginimų rinkinius struktūrizuotoms analizėms. Atsakykite tiksliai, struktūrizuotai ir naudingai. Atsakykite Markdown formatu su gražiu formatavimu.', - // Bengalisch - bn: 'আপনি একজন সহায়ক সহকারী যিনি পাঠ্য বিশ্লেষণ এবং প্রক্রিয়া করেন। আপনার কাজ হল প্রদত্ত নির্দেশাবলী অনুসারে কথোপকথনের ট্রান্সক্রিপ্ট প্রক্রিয়া করা। আপনি একটি ব্লুপ্রিন্ট সিস্টেমের অংশ হিসাবে ব্যবহৃত হন যা কাঠামোগত বিশ্লেষণের জন্য নির্দিষ্ট প্রম্পট সংগ্রহ প্রয়োগ করে। সঠিক, কাঠামোবদ্ধ এবং সহায়কভাবে উত্তর দিন। সুন্দর ফরম্যাটিং সহ মার্কডাউনে উত্তর দিন।', - // Malaiisch - ms: 'Anda adalah pembantu berguna yang menganalisis dan memproses teks. Tugas anda adalah untuk memproses transkrip perbualan mengikut arahan yang diberikan. Anda digunakan sebagai sebahagian daripada sistem Blueprint yang menggunakan koleksi prompt khusus untuk analisis berstruktur. Jawab dengan tepat, berstruktur dan membantu. Jawab dalam Markdown dengan format yang cantik.', - // Tamil - ta: 'நீங்கள் உரைகளை பகுப்பாய்வு செய்து செயலாக்கும் பயனுள்ள உதவியாளர். கொடுக்கப்பட்ட வழிமுறைகளின்படி உரையாடல்களின் டிரான்ஸ்கிரிப்ட்களை செயலாக்குவது உங்கள் பணி. கட்டமைக்கப்பட்ட பகுப்பாய்வுகளுக்கு குறிப்பிட்ட உத்வேக சேகரிப்புகளைப் பயன்படுத்தும் Blueprint அமைப்பின் ஒரு பகுதியாக நீங்கள் பயன்படுத்தப்படுகிறீர்கள். துல்லியமாகவும், கட்டமைக்கப்பட்டதாகவும், பயனுள்ளதாகவும் பதிலளிக்கவும். அழகான வடிவமைப்புடன் Markdown இல் பதிலளிக்கவும்.', - // Telugu - te: 'మీరు టెక్స్ట్‌లను విశ్లేషించే మరియు ప్రాసెస్ చేసే సహాయక అసిస్టెంట్. ఇచ్చిన సూచనల ప్రకారం సంభాషణ ట్రాన్స్‌క్రిప్ట్‌లను ప్రాసెస్ చేయడం మీ పని. నిర్మాణాత్మక విశ్లేషణల కోసం నిర్దిష్ట ప్రాంప్ట్ సేకరణలను వర్తింపజేసే బ్లూప్రింట్ సిస్టమ్‌లో భాగంగా మీరు ఉపయోగించబడుతున్నారు. ఖచ్చితంగా, నిర్మాణాత్మకంగా మరియు సహాయకరంగా సమాధానం ఇవ్వండి. అందమైన ఫార్మాటింగ్‌తో మార్క్‌డౌన్‌లో సమాధానం ఇవ్వండి.', - // Urdu - ur: 'آپ ایک مددگار اسسٹنٹ ہیں جو متن کا تجزیہ اور پروسیسنگ کرتے ہیں۔ آپ کا کام دی گئی ہدایات کے مطابق گفتگو کی ٹرانسکرپٹس کو پروسیس کرنا ہے۔ آپ بلیو پرنٹ سسٹم کے حصے کے طور پر استعمال ہوتے ہیں جو ساختی تجزیات کے لیے مخصوص پرامپٹ کلیکشنز کا اطلاق کرتا ہے۔ درست، منظم اور مددگار طریقے سے جواب دیں۔ خوبصورت فارمیٹنگ کے ساتھ مارک ڈاؤن میں جواب دیں۔', - // Marathi - mr: 'आपण मजकूरांचे विश्लेषण आणि प्रक्रिया करणारे उपयुक्त सहाय्यक आहात. दिलेल्या सूचनांनुसार संभाषणांच्या प्रतिलेखांवर प्रक्रिया करणे हे आपले कार्य आहे. आपण ब्लूप्रिंट सिस्टमचा भाग म्हणून वापरले जाता जे संरचित विश्लेषणांसाठी विशिष्ट प्रॉम्प्ट संग्रह लागू करते. अचूक, संरचित आणि उपयुक्त पद्धतीने उत्तर द्या. सुंदर फॉरमॅटिंगसह मार्कडाउनमध्ये उत्तर द्या.', - // Gujarati - gu: 'તમે એક મદદરૂપ સહાયક છો જે ટેક્સ્ટનું વિશ્લેષણ અને પ્રક્રિયા કરે છે. તમારું કાર્ય આપેલી સૂચનાઓ અનુસાર વાતચીતની ટ્રાન્સક્રિપ્ટ્સ પર પ્રક્રિયા કરવાનું છે. તમે બ્લુપ્રિન્ટ સિસ્ટમના ભાગ તરીકે ઉપયોગમાં લેવાય છો જે માળખાગત વિશ્લેષણ માટે વિશિષ્ટ પ્રોમ્પ્ટ સંગ્રહો લાગુ કરે છે. ચોક્કસ, માળખાગત અને મદદરૂપ રીતે જવાબ આપો. સુંદર ફોર્મેટિંગ સાથે માર્કડાઉનમાં જવાબ આપો.', - // Malayalam - ml: 'നിങ്ങൾ വാചകങ്ങൾ വിശകലനം ചെയ്യുകയും പ്രോസസ്സ് ചെയ്യുകയും ചെയ്യുന്ന സഹായകരമായ അസിസ്റ്റന്റാണ്. നൽകിയിരിക്കുന്ന നിർദ്ദേശങ്ങൾക്കനുസരിച്ച് സംഭാഷണങ്ങളുടെ ട്രാൻസ്ക്രിപ്റ്റുകൾ പ്രോസസ്സ് ചെയ്യുക എന്നതാണ് നിങ്ങളുടെ ജോലി. ഘടനാപരമായ വിശകലനങ്ങൾക്കായി നിർദ്ദിഷ്ട പ്രോംപ്റ്റ് ശേഖരങ്ങൾ പ്രയോഗിക്കുന്ന ബ്ലൂപ്രിന്റ് സിസ്റ്റത്തിന്റെ ഭാഗമായി നിങ്ങൾ ഉപയോഗിക്കപ്പെടുന്നു. കൃത്യമായും ഘടനാപരമായും സഹായകരമായും ഉത്തരം നൽകുക. മനോഹരമായ ഫോർമാറ്റിംഗോടെ മാർക്ക്ഡൗണിൽ ഉത്തരം നൽകുക.', - // Kannada - kn: 'ನೀವು ಪಠ್ಯಗಳನ್ನು ವಿಶ್ಲೇಷಿಸುವ ಮತ್ತು ಪ್ರಕ್ರಿಯೆಗೊಳಿಸುವ ಸಹಾಯಕ ಸಹಾಯಕರು. ನೀಡಿದ ಸೂಚನೆಗಳ ಪ್ರಕಾರ ಸಂಭಾಷಣೆಗಳ ಪ್ರತಿಲಿಪಿಗಳನ್ನು ಪ್ರಕ್ರಿಯೆಗೊಳಿಸುವುದು ನಿಮ್ಮ ಕಾರ್ಯ. ರಚನಾತ್ಮಕ ವಿಶ್ಲೇಷಣೆಗಳಿಗಾಗಿ ನಿರ್ದಿಷ್ಟ ಪ್ರಾಂಪ್ಟ್ ಸಂಗ್ರಹಗಳನ್ನು ಅನ್ವಯಿಸುವ ಬ್ಲೂಪ್ರಿಂಟ್ ವ್ಯವಸ್ಥೆಯ ಭಾಗವಾಗಿ ನೀವು ಬಳಸಲ್ಪಡುತ್ತೀರಿ. ನಿಖರವಾಗಿ, ರಚನಾತ್ಮಕವಾಗಿ ಮತ್ತು ಸಹಾಯಕವಾಗಿ ಉತ್ತರಿಸಿ. ಸುಂದರ ಫಾರ್ಮ್ಯಾಟಿಂಗ್‌ನೊಂದಿಗೆ ಮಾರ್ಕ್‌ಡೌನ್‌ನಲ್ಲಿ ಉತ್ತರಿಸಿ.', - // Punjabi - pa: 'ਤੁਸੀਂ ਇੱਕ ਮਦਦਗਾਰ ਸਹਾਇਕ ਹੋ ਜੋ ਟੈਕਸਟ ਦਾ ਵਿਸ਼ਲੇਸ਼ਣ ਅਤੇ ਪ੍ਰੋਸੈਸਿੰਗ ਕਰਦੇ ਹੋ। ਤੁਹਾਡਾ ਕੰਮ ਦਿੱਤੀਆਂ ਹਦਾਇਤਾਂ ਅਨੁਸਾਰ ਗੱਲਬਾਤ ਦੀਆਂ ਟ੍ਰਾਂਸਕ੍ਰਿਪਟਾਂ ਨੂੰ ਪ੍ਰੋਸੈਸ ਕਰਨਾ ਹੈ। ਤੁਸੀਂ ਇੱਕ ਬਲੂਪ੍ਰਿੰਟ ਸਿਸਟਮ ਦੇ ਹਿੱਸੇ ਵਜੋਂ ਵਰਤੇ ਜਾਂਦੇ ਹੋ ਜੋ ਢਾਂਚਾਗਤ ਵਿਸ਼ਲੇਸ਼ਣਾਂ ਲਈ ਵਿਸ਼ੇਸ਼ ਪ੍ਰੌਂਪਟ ਸੰਗ੍ਰਹਿ ਲਾਗੂ ਕਰਦਾ ਹੈ। ਸਟੀਕ, ਢਾਂਚਾਗਤ ਅਤੇ ਮਦਦਗਾਰ ਤਰੀਕੇ ਨਾਲ ਜਵਾਬ ਦਿਓ। ਸੁੰਦਰ ਫਾਰਮੈਟਿੰਗ ਦੇ ਨਾਲ ਮਾਰਕਡਾਊਨ ਵਿੱਚ ਜਵਾਬ ਦਿਓ।', - // Afrikaans - af: "Jy is 'n nuttige assistent wat tekste analiseer en verwerk. Jou taak is om transkripsies van gesprekke volgens die gegewe instruksies te verwerk. Jy word gebruik as deel van 'n Blueprint-stelsel wat spesifieke prompt-versamelings vir gestruktureerde ontledings toepas. Antwoord presies, gestruktureerd en nuttig. Antwoord in Markdown met 'n mooi formatering.", - // Persisch/Farsi - fa: 'شما یک دستیار مفید هستید که متون را تحلیل و پردازش می‌کند. وظیفه شما پردازش رونوشت مکالمات طبق دستورالعمل‌های داده شده است. شما به عنوان بخشی از سیستم Blueprint استفاده می‌شوید که مجموعه‌های اعلان خاصی را برای تحلیل‌های ساختاریافته اعمال می‌کند. دقیق، ساختارمند و مفید پاسخ دهید. با قالب‌بندی زیبا در Markdown پاسخ دهید.', - // Georgisch - ka: 'თქვენ ხართ სასარგებლო ასისტენტი, რომელიც აანალიზებს და ამუშავებს ტექსტებს. თქვენი ამოცანაა საუბრების ტრანსკრიპტების დამუშავება მოცემული ინსტრუქციების შესაბამისად. თქვენ გამოიყენებით როგორც Blueprint სისტემის ნაწილი, რომელიც იყენებს სპეციფიკურ მოთხოვნების კოლექციებს სტრუქტურირებული ანალიზებისთვის. უპასუხეთ ზუსტად, სტრუქტურირებულად და სასარგებლოდ. უპასუხეთ Markdown-ში ლამაზი ფორმატირებით.', - // Isländisch - is: 'Þú ert hjálplegur aðstoðarmaður sem greinir og vinnur úr textum. Verkefni þitt er að vinna úr afritum af samtölum samkvæmt gefnum leiðbeiningum. Þú ert notaður sem hluti af Blueprint kerfi sem beitir sérstökum hvatasöfnum fyrir skipulagðar greiningar. Svaraðu nákvæmlega, skipulega og hjálplega. Svaraðu í Markdown með fallegu sniði.', - // Albanisch - sq: 'Ju jeni një asistent i dobishëm që analizon dhe përpunon tekste. Detyra juaj është të përpunoni transkriptimet e bisedave sipas udhëzimeve të dhëna. Ju përdoreni si pjesë e një sistemi Blueprint që aplikon koleksione specifike të kërkesave për analiza të strukturuara. Përgjigjuni saktë, të strukturuar dhe të dobishëm. Përgjigjuni në Markdown me një formatim të bukur.', - // Aserbaidschanisch - az: 'Siz mətnləri təhlil edən və emal edən faydalı köməkçisiniz. Sizin vəzifəniz verilmiş təlimatlara uyğun olaraq söhbətlərin transkriptlərini emal etməkdir. Siz strukturlaşdırılmış təhlillər üçün xüsusi sorğu kolleksiyalarını tətbiq edən Blueprint sisteminin bir hissəsi kimi istifadə olunursunuz. Dəqiq, strukturlaşdırılmış və faydalı cavab verin. Gözəl formatlaşdırma ilə Markdown-da cavab verin.', - // Baskisch - eu: 'Testuak aztertzen eta prozesatzen dituen laguntzaile erabilgarria zara. Zure zeregina elkarrizketen transkripzioak emandako argibideen arabera prozesatzea da. Blueprint sistema baten zati gisa erabiltzen zara, analisi egituratuetarako gonbidapen bilduma espezifikoak aplikatzen dituena. Erantzun zehatz, egituratuta eta lagungarri. Erantzun Markdown-en formatu eder batekin.', - // Galizisch - gl: 'Es un asistente útil que analiza e procesa textos. A túa tarefa é procesar transcricións de conversas segundo as instrucións dadas. Utilizaste como parte dun sistema Blueprint que aplica coleccións específicas de prompts para análises estruturadas. Responde de forma precisa, estruturada e útil. Responde en Markdown cun formato bonito.', - // Kasachisch - kk: 'Сіз мәтіндерді талдайтын және өңдейтін пайдалы көмекшісіз. Сіздің міндетіңіз - берілген нұсқауларға сәйкес әңгімелердің транскрипттерін өңдеу. Сіз құрылымдық талдаулар үшін арнайы сұрау жинақтарын қолданатын Blueprint жүйесінің бөлігі ретінде пайдаланыласыз. Дәл, құрылымды және пайдалы жауап беріңіз. Әдемі пішімдеумен Markdown-да жауап беріңіз.', - // Mazedonisch - mk: 'Вие сте корисен асистент кој анализира и обработува текстови. Вашата задача е да обработувате транскрипти на разговори според дадените упатства. Вие се користите како дел од Blueprint систем кој применува специфични колекции на покани за структурирани анализи. Одговорете прецизно, структурирано и корисно. Одговорете во Markdown со убаво форматирање.', - // Serbisch - sr: 'Ви сте корисни асистент који анализира и обрађује текстове. Ваш задатак је да обрађујете транскрипте разговора према датим упутствима. Користите се као део Blueprint система који примењује специфичне колекције упита за структуриране анализе. Одговорите прецизно, структурирано и корисно. Одговорите у Markdown-у са лепим форматирањем.', - // Slowenisch - sl: 'Ste koristen pomočnik, ki analizira in obdeluje besedila. Vaša naloga je obdelava prepisov pogovorov v skladu z danimi navodili. Uporabljate se kot del sistema Blueprint, ki uporablja specifične zbirke pozivov za strukturirane analize. Odgovorite natančno, strukturirano in koristno. Odgovorite v Markdownu z lepim oblikovanjem.', - // Maltesisch - mt: "Int assistent utli li janalizza u jipproċessa testi. Il-kompitu tiegħek huwa li tipproċessa traskrizzjonijiet ta' konversazzjonijiet skont l-istruzzjonijiet mogħtija. Int użat bħala parti minn sistema Blueprint li tapplika kollezzjonijiet speċifiċi ta' prompt għal analiżi strutturati. Wieġeb b'mod preċiż, strutturat u utli. Wieġeb f'Markdown b'format sabiħ.", - // Armenisch - hy: 'Դուք օգտակար օգնական եք, որը վերլուծում և մշակում է տեքստեր: Ձեր խնդիրն է մշակել զրույցների արձանագրությունները տրված հրահանգների համաձայն: Դուք օգտագործվում եք որպես Blueprint համակարգի մաս, որը կիրառում է հատուկ հուշումների հավաքածուներ կառուցվածքային վերլուծությունների համար: Պատասխանեք ճշգրիտ, կառուցվածքային և օգտակար: Պատասխանեք Markdown-ում գեղեցիկ ձևաչափով:', - // Usbekisch - uz: "Siz matnlarni tahlil qiluvchi va qayta ishlovchi foydali yordamchisiz. Sizning vazifangiz berilgan ko'rsatmalarga muvofiq suhbatlar transkriptlarini qayta ishlashdir. Siz tuzilgan tahlillar uchun maxsus so'rovlar to'plamlarini qo'llaydigan Blueprint tizimining bir qismi sifatida foydalanilasiz. Aniq, tuzilgan va foydali javob bering. Chiroyli formatlash bilan Markdown da javob bering.", - // Irisch - ga: 'Is cúntóir cabhrach thú a dhéanann anailís agus próiseáil ar théacsanna. Is é do thasc trascríbhinní comhráite a phróiseáil de réir na dtreoracha tugtha. Úsáidtear thú mar chuid de chóras Blueprint a chuireann bailiúcháin leid shonracha i bhfeidhm le haghaidh anailísí struchtúrtha. Freagair go beacht, struchtúrtha agus cabhrach. Freagair i Markdown le formáidiú álainn.', - // Walisisch - cy: "Rydych chi'n gynorthwyydd defnyddiol sy'n dadansoddi a phrosesu testunau. Eich tasg yw prosesu trawsgrifiadau o sgyrsiau yn unol â'r cyfarwyddiadau a roddwyd. Rydych yn cael eich defnyddio fel rhan o system Blueprint sy'n defnyddio casgliadau ysgogiad penodol ar gyfer dadansoddiadau strwythuredig. Atebwch yn fanwl gywir, yn strwythuredig ac yn ddefnyddiol. Atebwch yn Markdown gyda fformat hardd.", - // Filipino - fil: 'Ikaw ay isang kapaki-pakinabang na katulong na nag-aanalisa at nagpoproseso ng mga teksto. Ang iyong gawain ay magproseso ng mga transkripsyon ng mga pag-uusap ayon sa mga ibinigay na tagubilin. Ginagamit ka bilang bahagi ng isang Blueprint system na naglalapat ng mga partikular na koleksyon ng prompt para sa mga nakabalangkas na pagsusuri. Tumugon nang tumpak, nakabalangkas, at nakakatulong. Tumugon sa Markdown na may magandang format.', - }, -}; -/** - * Hilfsfunktion zum Abrufen des System-Prompts für eine bestimmte Sprache - * @param language Sprache (z.B. 'de', 'en', 'fr') - * @returns System-Prompt für die angegebene Sprache oder Fallback - */ export function getSystemPrompt(language) { - const lang = language.toLowerCase().split('-')[0]; // z.B. 'de-DE' -> 'de' - // Versuche spezifische Sprache, dann Deutsch, dann Englisch, dann erste verfügbare - return ( - SYSTEM_PROMPTS.system[lang] || - SYSTEM_PROMPTS.system['de'] || - SYSTEM_PROMPTS.system['en'] || - Object.values(SYSTEM_PROMPTS.system)[0] || - 'You are a helpful assistant.' - ); -} diff --git a/apps/memoro/apps/backend/supabase/functions/blueprint/index.ts b/apps/memoro/apps/backend/supabase/functions/blueprint/index.ts deleted file mode 100644 index dc23f63ab..000000000 --- a/apps/memoro/apps/backend/supabase/functions/blueprint/index.ts +++ /dev/null @@ -1,679 +0,0 @@ -// Follow this setup guide to integrate the Deno language server with your editor: -// https://deno.land/manual/getting_started/setup_your_environment -// This enables autocomplete, go to definition, etc. -// Setup type definitions for built-in Supabase Runtime APIs -import 'jsr:@supabase/functions-js/edge-runtime.d.ts'; -import { serve } from 'https://deno.land/std@0.215.0/http/server.ts'; -import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'; -import { getSystemPrompt } from './constants.ts'; -import { getTranscriptText } from '../_shared/transcript-utils.ts'; -import { ROOT_SYSTEM_PROMPTS } from '../_shared/system-prompt.ts'; -// Atomic status update utilities using RPC to prevent race conditions -async function setMemoErrorStatus(supabaseClient, memoId, processName, error) { - if (!memoId) return; - const errorMessage = error instanceof Error ? error.message : String(error); - const timestamp = new Date().toISOString(); - try { - await supabaseClient.rpc('set_memo_process_error', { - p_memo_id: memoId, - p_process_name: processName, - p_timestamp: timestamp, - p_reason: errorMessage, - p_details: null, - }); - } catch (dbError) { - console.error(`Error setting error status for memo ${memoId}:`, dbError); - } -} -async function setMemoProcessingStatus(supabaseClient, memoId, processName) { - const timestamp = new Date().toISOString(); - try { - await supabaseClient.rpc('set_memo_process_status', { - p_memo_id: memoId, - p_process_name: processName, - p_status: 'processing', - p_timestamp: timestamp, - }); - } catch (dbError) { - console.error(`Error setting processing status for memo ${memoId}:`, dbError); - } -} -async function setMemoCompletedStatus(supabaseClient, memoId, processName, details) { - const timestamp = new Date().toISOString(); - try { - await supabaseClient.rpc('set_memo_process_status_with_details', { - p_memo_id: memoId, - p_process_name: processName, - p_status: 'completed', - p_timestamp: timestamp, - p_details: details, - }); - } catch (dbError) { - console.error(`Error setting completed status for memo ${memoId}:`, dbError); - } -} -function createErrorResponse(error, status = 500, corsHeaders = {}) { - const errorMessage = error instanceof Error ? error.message : String(error); - return new Response( - JSON.stringify({ - error: errorMessage, - timestamp: new Date().toISOString(), - }), - { - headers: { - ...corsHeaders, - 'Content-Type': 'application/json', - }, - status, - } - ); -} -/** - * Blueprint Edge Function - * - * Diese Funktion wird wie die Headline-Function getriggert und verarbeitet einen angegebenen Blueprint, - * dessen Prompts mit dem Transcript an das LLM geschickt werden. Die Antworten werden als neue Memory-Einträge - * gespeichert, die auf das ursprüngliche Memo referenzieren. - * - * @version 1.2.0 - * @date 2025-05-19 - */ // ─── Umgebungsvariablen ────────────────────────────────────────────── -const SUPABASE_URL = Deno.env.get('SUPABASE_URL'); -if (!SUPABASE_URL) { - throw new Error('SUPABASE_URL not configured'); -} -const SERVICE_KEY = Deno.env.get('C_SUPABASE_SECRET_KEY'); -if (!SERVICE_KEY) { - throw new Error('C_SUPABASE_SECRET_KEY not configured'); -} -// Google Gemini Konfiguration -const GEMINI_API_KEY = Deno.env.get('CREATE_BLUEPRINT_GEMINI_MEMORO') || ''; -const GEMINI_MODEL = 'gemini-2.0-flash'; -const GEMINI_ENDPOINT = 'https://generativelanguage.googleapis.com/v1beta/models'; -// Azure OpenAI Konfiguration (Backup) -const AZURE_OPENAI_ENDPOINT = 'https://memoroseopenai.openai.azure.com'; -const AZURE_OPENAI_KEY = Deno.env.get('AZURE_OPENAI_KEY'); -if (!AZURE_OPENAI_KEY) { - throw new Error('AZURE_OPENAI_KEY not configured'); -} -const AZURE_OPENAI_DEPLOYMENT = 'gpt-4.1-mini-se'; -const AZURE_OPENAI_API_VERSION = '2025-01-01-preview'; -const memoro_sb = createClient(SUPABASE_URL, SERVICE_KEY); -// ─── Logging-Funktion ────────────────────────────────────────────── -/** - * Erweiterte Logging-Funktion mit Zeitstempel und Log-Level - */ function log(level, message, data) { - const timestamp = new Date().toISOString(); - const logMessage = `[${timestamp}] [${level.toUpperCase()}] ${message}`; - switch (level.toUpperCase()) { - case 'INFO': - console.log(logMessage); - break; - case 'DEBUG': - console.debug(logMessage); - break; - case 'WARN': - console.warn(logMessage); - break; - case 'ERROR': - console.error(logMessage); - break; - default: - console.log(logMessage); - break; - } - if (data) { - if (level.toUpperCase() === 'ERROR') { - console.error(data); - } else { - // For DEBUG, log data more verbosely if needed, otherwise keep it simple - console.log(typeof data === 'object' ? JSON.stringify(data, null, 2) : data); - } - } -} -/** - * Sendet Prompt an Gemini Flash und gibt die Antwort zurück - */ async function runPromptWithGemini( - prompt, - transcript, - language = 'de', - functionIdForLog = 'global' -) { - // Always use the default system prompt from ROOT_SYSTEM_PROMPTS - const systemPrompt = getSystemPrompt(language); - const requestId = crypto.randomUUID().substring(0, 8); - log('INFO', `[${functionIdForLog}][GEMINI-${requestId}] Starte Gemini-Anfrage.`); - try { - let fullPrompt; - if (prompt.includes('{transcript}')) { - fullPrompt = prompt.replace('{transcript}', transcript); - log('DEBUG', `[${functionIdForLog}][GEMINI-${requestId}] Platzhalter im Prompt ersetzt.`); - } else { - fullPrompt = `${prompt}\n\nText: ${transcript}`; - log( - 'DEBUG', - `[${functionIdForLog}][GEMINI-${requestId}] Kein Platzhalter, Transkript am Ende angehängt.` - ); - } - log( - 'DEBUG', - `[${functionIdForLog}][GEMINI-${requestId}] System-Prompt (${language}): ${systemPrompt}` - ); - log( - 'DEBUG', - `[${functionIdForLog}][GEMINI-${requestId}] User-Prompt (erste 200 Zeichen): ${fullPrompt.substring(0, 200)}...` - ); - const startTime = Date.now(); - const response = await fetch( - `${GEMINI_ENDPOINT}/${GEMINI_MODEL}:generateContent?key=${GEMINI_API_KEY}`, - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - contents: [ - { - role: 'user', - parts: [ - { - text: fullPrompt, - }, - ], - }, - ], - systemInstruction: { - parts: [ - { - text: systemPrompt, - }, - ], - }, - generationConfig: { - temperature: 0.7, - maxOutputTokens: 8192, - }, - }), - } - ); - const duration = Date.now() - startTime; - log( - 'INFO', - `[${functionIdForLog}][GEMINI-${requestId}] Gemini Antwort erhalten in ${duration}ms, Status: ${response.status}` - ); - if (!response.ok) { - const errorText = await response.text(); - log( - 'ERROR', - `[${functionIdForLog}][GEMINI-${requestId}] Gemini API Fehler: ${response.status}`, - errorText - ); - throw new Error(`Gemini API Fehler: ${response.status} ${errorText}`); - } - const data = await response.json(); - const content = data.candidates?.[0]?.content?.parts?.[0]?.text?.trim() || ''; - log( - 'INFO', - `[${functionIdForLog}][GEMINI-${requestId}] Erfolgreiche Gemini-Antwort (Länge: ${content.length}).` - ); - return content; - } catch (error) { - log('ERROR', `[${functionIdForLog}][GEMINI-${requestId}] Fehler beim Gemini-Request:`, error); - throw error; - } -} -/** - * Sendet Prompt an Azure OpenAI und gibt die Antwort zurück (Fallback) - */ async function runPromptWithAzure( - prompt, - transcript, - language = 'de', - functionIdForLog = 'global' -) { - // Always use the default system prompt from ROOT_SYSTEM_PROMPTS - const systemPrompt = getSystemPrompt(language); - const requestId = crypto.randomUUID().substring(0, 8); - log('INFO', `[${functionIdForLog}][AZURE-${requestId}] Starte Azure OpenAI-Anfrage.`); - try { - let fullPrompt; - if (prompt.includes('{transcript}')) { - fullPrompt = prompt.replace('{transcript}', transcript); - log('DEBUG', `[${functionIdForLog}][AZURE-${requestId}] Platzhalter im Prompt ersetzt.`); - } else { - fullPrompt = `${prompt}\n\nText: ${transcript}`; - log( - 'DEBUG', - `[${functionIdForLog}][AZURE-${requestId}] Kein Platzhalter, Transkript am Ende angehängt.` - ); - } - log( - 'DEBUG', - `[${functionIdForLog}][AZURE-${requestId}] System-Prompt (${language}): ${systemPrompt}` - ); - log( - 'DEBUG', - `[${functionIdForLog}][AZURE-${requestId}] User-Prompt (erste 200 Zeichen): ${fullPrompt.substring(0, 200)}...` - ); - const startTime = Date.now(); - const response = await fetch( - `${AZURE_OPENAI_ENDPOINT}/openai/deployments/${AZURE_OPENAI_DEPLOYMENT}/chat/completions?api-version=${AZURE_OPENAI_API_VERSION}`, - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'api-key': AZURE_OPENAI_KEY, - }, - body: JSON.stringify({ - messages: [ - { - role: 'system', - content: systemPrompt, - }, - { - role: 'user', - content: fullPrompt, - }, - ], - max_tokens: 8192, - temperature: 0.7, - }), - } - ); - const duration = Date.now() - startTime; - log( - 'INFO', - `[${functionIdForLog}][AZURE-${requestId}] Azure OpenAI Antwort erhalten in ${duration}ms, Status: ${response.status}` - ); - if (!response.ok) { - const errorText = await response.text(); - log( - 'ERROR', - `[${functionIdForLog}][AZURE-${requestId}] Azure OpenAI API Fehler: ${response.status}`, - errorText - ); - throw new Error(`Azure OpenAI API Fehler: ${response.status} ${errorText}`); - } - const data = await response.json(); - const content = data.choices[0]?.message?.content?.trim() || ''; - log( - 'INFO', - `[${functionIdForLog}][AZURE-${requestId}] Erfolgreiche Azure OpenAI-Antwort (Länge: ${content.length}).` - ); - return content; - } catch (error) { - log( - 'ERROR', - `[${functionIdForLog}][AZURE-${requestId}] Fehler beim Azure OpenAI-Request:`, - error - ); - throw error; - } -} -/** - * Hauptfunktion zur Prompt-Verarbeitung mit Fallback-Logik - */ async function runPromptWithTranscript( - prompt, - transcript, - language = 'de', - functionIdForLog = 'global' -) { - try { - // Zuerst mit Gemini versuchen - return await runPromptWithGemini(prompt, transcript, language, functionIdForLog); - } catch (error) { - log('WARN', `[${functionIdForLog}] Gemini fehlgeschlagen, fallback auf Azure OpenAI`, error); - try { - // Fallback auf Azure OpenAI - return await runPromptWithAzure(prompt, transcript, language, functionIdForLog); - } catch (azureError) { - log('ERROR', `[${functionIdForLog}] Beide LLM-Services fehlgeschlagen`, azureError); - return ''; // Return empty string to maintain compatibility - } - } -} -serve(async (req) => { - const functionId = crypto.randomUUID().substring(0, 8); - log('INFO', `[${functionId}] Blueprint-Funktion gestartet`); - const corsHeaders = { - 'Access-Control-Allow-Origin': '*', - 'Access-Control-Allow-Methods': 'POST, OPTIONS', - 'Access-Control-Allow-Headers': 'Content-Type, Authorization', - }; - if (req.method === 'OPTIONS') { - log('DEBUG', `[${functionId}] CORS Preflight-Anfrage bearbeitet`); - return new Response(null, { - headers: corsHeaders, - status: 204, - }); - } - let memo_id_to_update = null; - try { - const requestData = await req.json(); - const { memo_id, primary_language } = requestData; // Erhalte primary_language - memo_id_to_update = memo_id; - log( - 'INFO', - `[${functionId}] Anfrage erhalten für memo_id: ${memo_id}, primäre Sprache: ${primary_language || 'nicht angegeben'}` - ); - if (!memo_id) { - log('ERROR', `[${functionId}] Keine memo_id in der Anfrage gefunden`); - return createErrorResponse('memo_id ist erforderlich', 400, corsHeaders); - } - // Set processing status - await setMemoProcessingStatus(memoro_sb, memo_id, 'blueprint'); - log('INFO', `[${functionId}] Rufe Memo mit ID ${memo_id} aus der Datenbank ab`); - const { data: memo, error: memoError } = await memoro_sb - .from('memos') - .select('*') - .eq('id', memo_id) - .single(); - if (memoError || !memo) { - log('ERROR', `[${functionId}] Memo ${memo_id} nicht gefunden:`, memoError); - await setMemoErrorStatus(memoro_sb, memo_id, 'blueprint', 'Memo nicht gefunden'); - return createErrorResponse('Memo nicht gefunden', 404, corsHeaders); - } - const blueprintId = memo.metadata?.blueprint_id || memo.metadata?.blueprintId; - log('INFO', `[${functionId}] Blueprint-ID aus Memo-Metadaten: ${blueprintId}`); - if (!blueprintId) { - log('ERROR', `[${functionId}] Keine Blueprint-ID in Memo-Metadaten ${memo_id} gefunden`); - log( - 'DEBUG', - `[${functionId}] Verfügbare Metadaten-Schlüssel: ${Object.keys(memo.metadata || {}).join(', ')}` - ); - await setMemoErrorStatus( - memoro_sb, - memo_id, - 'blueprint', - 'Kein Blueprint im Memo-Metadaten gefunden' - ); - return createErrorResponse('Kein Blueprint im Memo-Metadaten gefunden', 400, corsHeaders); - } - log('INFO', `[${functionId}] Lade Blueprint mit ID ${blueprintId}`); - const { data: blueprint, error: blueprintError } = await memoro_sb - .from('blueprints') - .select('*') - .eq('id', blueprintId) - .single(); - if (blueprintError || !blueprint) { - log('ERROR', `[${functionId}] Blueprint ${blueprintId} nicht gefunden:`, blueprintError); - await setMemoErrorStatus( - memoro_sb, - memo_id, - 'blueprint', - `Blueprint ${blueprintId} nicht gefunden` - ); - return createErrorResponse('Blueprint nicht gefunden', 404, corsHeaders); - } - log('INFO', `[${functionId}] Lade Prompt-Links für Blueprint ${blueprintId}`); - const { data: promptLinks, error: promptLinksError } = await memoro_sb - .from('prompt_blueprints') - .select('prompt_id, sort_order') - .eq('blueprint_id', blueprintId); - if (promptLinksError || !Array.isArray(promptLinks) || promptLinks.length === 0) { - log( - 'ERROR', - `[${functionId}] Keine Prompt-Links für Blueprint ${blueprintId} gefunden:`, - promptLinksError - ); - await setMemoErrorStatus( - memoro_sb, - memo_id, - 'blueprint', - 'Keine Prompts für diesen Blueprint gefunden' - ); - return createErrorResponse('Keine Prompts für diesen Blueprint gefunden', 404, corsHeaders); - } - const promptIds = promptLinks.map((l) => l.prompt_id); - // Create a map of prompt_id to sort_order for later use - const promptSortOrderMap = new Map(promptLinks.map((l) => [l.prompt_id, l.sort_order])); - log('INFO', `[${functionId}] Gefundene Prompt-IDs: ${promptIds.join(', ')}`); - // Transcript extrahieren (from utterances or legacy fields) - const transcript = getTranscriptText(memo); - log( - 'INFO', - `[${functionId}] Extrahiertes Transkript (Länge: ${transcript.length}, erste 100 Zeichen): ${transcript.substring(0, 100)}...` - ); - if (!transcript) { - log('ERROR', `[${functionId}] Kein Transkript im Memo ${memo_id} gefunden`); - await setMemoErrorStatus(memoro_sb, memo_id, 'blueprint', 'Kein Transkript im Memo gefunden'); - return createErrorResponse('Kein Transkript im Memo gefunden', 400, corsHeaders); - } - log('INFO', `[${functionId}] Lade Prompts (${promptIds.length}) aus der Datenbank`); - const { data: prompts, error: promptsError } = await memoro_sb - .from('prompts') - .select('*') - .in('id', promptIds); - if (promptsError || !Array.isArray(prompts) || prompts.length === 0) { - log( - 'ERROR', - `[${functionId}] Prompts (${promptIds.join(', ')}) nicht gefunden:`, - promptsError - ); - await setMemoErrorStatus(memoro_sb, memo_id, 'blueprint', 'Prompts nicht gefunden'); - return createErrorResponse('Prompts nicht gefunden', 404, corsHeaders); - } - log('INFO', `[${functionId}] ${prompts.length} Prompts gefunden, beginne mit der Verarbeitung`); - const results = []; - // Basis-Sprache aus primary_language (z.B. "en-GB" -> "en") ermitteln - let baseMemoLang = null; - if (primary_language && typeof primary_language === 'string') { - baseMemoLang = primary_language.split('-')[0].toLowerCase(); // Sicherstellen, dass es Kleinbuchstaben sind - log( - 'DEBUG', - `[${functionId}] Ermittelte Basis-Sprache: ${baseMemoLang} (aus ${primary_language})` - ); - } else { - baseMemoLang = 'en'; - log( - 'WARN', - `[${functionId}] Keine primäre Sprache vom Trigger übergeben oder ungültig. Nutze Standard-Fallbacks.` - ); - } - const defaultPreferredLang = 'de'; // Standard bevorzugte Sprache - const defaultFallbackLang = 'en'; // Standard Ausweichsprache - for (const prompt of prompts) { - const promptId = prompt.id; - log('INFO', `[${functionId}] Verarbeite Prompt mit ID ${promptId}`); - let promptText = ''; - if (prompt.prompt_text && typeof prompt.prompt_text === 'object') { - promptText = - (baseMemoLang && prompt.prompt_text[baseMemoLang]) || // 1. Memo-Primärsprache (Basis) - prompt.prompt_text[defaultPreferredLang] || // 2. Standard 'de' - prompt.prompt_text[defaultFallbackLang] || // 3. Standard 'en' - Object.values(prompt.prompt_text)[0] || // 4. Erster verfügbarer Wert - ''; // 5. Leerstring - log( - 'DEBUG', - `[${functionId}] Gewählter Prompt-Text für Prompt ${promptId} (Sprache: ${baseMemoLang || defaultPreferredLang}): ${promptText.substring(0, 50)}...` - ); - } else { - log( - 'WARN', - `[${functionId}] Kein gültiges prompt_text Objekt für Prompt ${promptId} gefunden.` - ); - } - // Blueprint's system_prompt should be used as a pre-prompt (prepended to promptText) - let blueprintPrePrompt = ''; - if (blueprint.system_prompt && typeof blueprint.system_prompt === 'object') { - // Try to get system_prompt for the current language - blueprintPrePrompt = - (baseMemoLang && blueprint.system_prompt[baseMemoLang]) || - blueprint.system_prompt[defaultPreferredLang] || - blueprint.system_prompt[defaultFallbackLang] || - Object.values(blueprint.system_prompt)[0] || - ''; - if (blueprintPrePrompt) { - log( - 'DEBUG', - `[${functionId}] Verwende Blueprint-spezifischen Pre-Prompt für Sprache ${baseMemoLang}` - ); - } - } - // If no blueprint pre-prompt, use ROOT_SYSTEM_PROMPTS.PRE_PROMPT as fallback - if (!blueprintPrePrompt) { - blueprintPrePrompt = - ROOT_SYSTEM_PROMPTS.PRE_PROMPT[baseMemoLang] || - ROOT_SYSTEM_PROMPTS.PRE_PROMPT['de'] || - ''; - if (blueprintPrePrompt) { - log('DEBUG', `[${functionId}] Verwende Standard Pre-Prompt für Sprache ${baseMemoLang}`); - } - } - // Prepend the pre-prompt to the promptText if available - if (blueprintPrePrompt && promptText) { - promptText = blueprintPrePrompt + '\n\n' + promptText; - } - let memoryTitle = ''; - if (prompt.memory_title && typeof prompt.memory_title === 'object') { - memoryTitle = - (baseMemoLang && prompt.memory_title[baseMemoLang]) || // 1. Memo-Primärsprache (Basis) - prompt.memory_title[defaultPreferredLang] || // 2. Standard 'de' - prompt.memory_title[defaultFallbackLang] || // 3. Standard 'en' - Object.values(prompt.memory_title)[0] || // 4. Erster verfügbarer Wert - ''; // 5. Leerstring - log( - 'DEBUG', - `[${functionId}] Gewählter Memory-Titel für Prompt ${promptId} (Sprache: ${baseMemoLang || defaultPreferredLang}): ${memoryTitle}` - ); - } else { - log( - 'WARN', - `[${functionId}] Kein gültiges memory_title Objekt für Prompt ${promptId} gefunden.` - ); - } - if (!promptText) { - log( - 'WARN', - `[${functionId}] Kein Prompt-Text für Prompt ${promptId} nach Sprachauswahl. Überspringe.` - ); - results.push({ - prompt_id: promptId, - error: 'Kein Prompt-Text verfügbar in passender Sprache', - }); - continue; - } - log( - 'INFO', - `[${functionId}] Sende Prompt "${memoryTitle || 'Ohne Titel'}" (ID: ${promptId}) an LLM mit Sprache: ${baseMemoLang}` - ); - log( - 'DEBUG', - `[${functionId}] Prompt-Text (erste 150 Zeichen): ${promptText.substring(0, 150)}...` - ); - // Use default system prompt (from ROOT_SYSTEM_PROMPTS via getSystemPrompt) - const answer = await runPromptWithTranscript( - promptText, - transcript, - baseMemoLang, - functionId - ); - if (!answer) { - log('WARN', `[${functionId}] Keine Antwort vom LLM für Prompt ${promptId} erhalten`); - results.push({ - prompt_id: promptId, - error: 'Keine Antwort vom LLM erhalten', - }); - continue; - } - log( - 'INFO', - `[${functionId}] Erstelle neues Memory für Memo ${memo_id} mit Titel "${memoryTitle || 'Blueprint-Antwort'}"` - ); - const { data: newMemory, error: newMemoryError } = await memoro_sb - .from('memories') - .insert({ - memo_id: memo_id, - title: memoryTitle || 'Blueprint-Antwort', - content: answer, - media: null, - sort_order: promptSortOrderMap.get(promptId) || null, - metadata: { - type: 'blueprint', - blueprint_id: blueprintId, - prompt_id: promptId, - created_by: 'blueprint_function', - }, - }) - .select() - .single(); - if (newMemoryError) { - log( - 'ERROR', - `[${functionId}] Fehler beim Erstellen des Memories für Prompt ${promptId}:`, - newMemoryError - ); - results.push({ - prompt_id: promptId, - error: newMemoryError.message, - }); - } else { - log( - 'INFO', - `[${functionId}] Memory erfolgreich erstellt mit ID ${newMemory.id} für Prompt ${promptId}` - ); - results.push({ - prompt_id: promptId, - memory_id: newMemory.id, - }); - } - } - // Set completed status - await setMemoCompletedStatus(memoro_sb, memo_id, 'blueprint', { - results_count: results.length, - prompt_count: prompts.length, - blueprint_id: blueprintId, - }); - // Send broadcast update to notify clients about the blueprint completion - try { - const channel = memoro_sb.channel(`memo-updates-${memo_id}`); - // Subscribe first to ensure the channel is ready - channel.subscribe(async (status) => { - if (status === 'SUBSCRIBED') { - await channel.send({ - type: 'broadcast', - event: 'memo-updated', - payload: { - type: 'memo-updated', - memoId: memo_id, - changes: { - metadata: memo.metadata, - updated_at: new Date().toISOString(), - }, - source: 'blueprint-edge-function', - }, - }); - log('INFO', `[${functionId}] Broadcast sent for memo ${memo_id} blueprint completion`); - // Clean up the channel after sending - memoro_sb.removeChannel(channel); - } - }); - } catch (broadcastError) { - log('WARN', `[${functionId}] Failed to send broadcast update:`, broadcastError); - // Don't fail the function if broadcast fails - } - log( - 'INFO', - `[${functionId}] Blueprint-Verarbeitung erfolgreich abgeschlossen mit ${results.length} Ergebnissen für Memo ${memo_id}` - ); - return new Response( - JSON.stringify({ - success: true, - results, - }), - { - headers: { - ...corsHeaders, - 'Content-Type': 'application/json', - }, - status: 200, - } - ); - } catch (error) { - log('ERROR', `[${functionId}] Unerwarteter Fehler bei der Blueprint-Verarbeitung:`, error); - // Set error status in database - const errorToLog = error instanceof Error ? error : new Error(String(error)); - await setMemoErrorStatus(memoro_sb, memo_id_to_update, 'blueprint', errorToLog); - // Return error response - return createErrorResponse(`Unerwarteter Fehler: ${errorToLog.message}`, 500, corsHeaders); - } -}); diff --git a/apps/memoro/apps/backend/supabase/functions/combine-memos/index.ts b/apps/memoro/apps/backend/supabase/functions/combine-memos/index.ts deleted file mode 100644 index 09d9efdae..000000000 --- a/apps/memoro/apps/backend/supabase/functions/combine-memos/index.ts +++ /dev/null @@ -1,715 +0,0 @@ -// Follow this setup guide to integrate the Deno language server with your editor: -// https://deno.land/manual/getting_started/setup_your_environment -// This enables autocomplete, go to definition, etc. -// Setup type definitions for built-in Supabase Runtime APIs -import 'jsr:@supabase/functions-js/edge-runtime.d.ts'; -import { serve } from 'https://deno.land/std@0.215.0/http/server.ts'; -import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'; -/** - * Combine Memos Edge Function - * - * Diese Funktion kombiniert mehrere Memos zu einem neuen Memo und verarbeitet - * das kombinierte Transkript mit einem ausgewählten Blueprint. - * - * Workflow: - * 1. Empfängt Array von Memo-IDs, Blueprint-ID und optionalen benutzerdefinierten Prompt - * 2. Lädt alle angegebenen Memos aus der Datenbank - * 3. Kombiniert die Transkripte zu einem Text - * 4. Erstellt ein neues Memo mit dem kombinierten Inhalt - * 5. Verarbeitet das kombinierte Memo mit dem angegebenen Blueprint via Gemini Flash 2.5 - * 6. Erstellt neue Memory-Einträge mit den Blueprint-Ergebnissen - * - * @version 1.0.0 - * @date 2025-05-23 - */ // ─── Umgebungsvariablen ────────────────────────────────────────────── -const SUPABASE_URL = Deno.env.get('SUPABASE_URL'); -if (!SUPABASE_URL) { - throw new Error('SUPABASE_URL not configured'); -} -const SERVICE_KEY = Deno.env.get('C_SUPABASE_SECRET_KEY'); -if (!SERVICE_KEY) { - throw new Error('C_SUPABASE_SECRET_KEY not configured'); -} -// Google Gemini Konfiguration (Flash 2.5) -const GEMINI_API_KEY = Deno.env.get('COMBINE_MEMOS_GEMINI_MEMORO') || ''; -const GEMINI_MODEL = 'gemini-2.0-flash'; -const GEMINI_ENDPOINT = 'https://generativelanguage.googleapis.com/v1beta/models'; -// Azure OpenAI Konfiguration (Backup) -const AZURE_OPENAI_ENDPOINT = 'https://memoroseopenai.openai.azure.com'; -const AZURE_OPENAI_KEY = Deno.env.get('AZURE_OPENAI_KEY'); -if (!AZURE_OPENAI_KEY) { - throw new Error('AZURE_OPENAI_KEY not configured'); -} -const AZURE_OPENAI_DEPLOYMENT = 'gpt-4.1-mini-se'; -const AZURE_OPENAI_API_VERSION = '2025-01-01-preview'; -const memoro_sb = createClient(SUPABASE_URL, SERVICE_KEY); -// ─── Hilfsfunktionen ────────────────────────────────────────────── -/** - * Extrahiert Transkript-Daten aus verschiedenen Memo-Formaten - * Unterstützt sowohl alte (einfache Text) als auch neue (Utterances) Formate - */ function extractTranscriptFromMemo(memo) { - const result = { - text: '', - utterances: undefined, - speakers: undefined, - speakerMap: undefined, - }; - // Prüfe source und metadata - const source = memo.source || {}; - const metadata = memo.metadata || {}; - // 1. Prüfe auf utterances (neues Format mit Speaker-Diarization) - const utterances = source.utterances || metadata.utterances; - if (utterances && Array.isArray(utterances) && utterances.length > 0) { - // Konvertiere utterances zu Text mit Speaker-Informationen - result.utterances = utterances; - result.text = utterances - .map((u) => { - const speaker = u.speakerId ? `[${u.speakerId}] ` : ''; - return `${speaker}${u.text}`; - }) - .join('\n'); - // Übernehme speakers mapping falls vorhanden - if (source.speakers) { - result.speakers = source.speakers; - } - return result; - } - // 2. Prüfe auf speakerMap (alternatives Format) - if (source.speakerMap && Object.keys(source.speakerMap).length > 0) { - result.speakerMap = source.speakerMap; - // Konvertiere speakerMap zu chronologischem Text - const allUtterances = []; - Object.entries(source.speakerMap).forEach(([speakerId, utterances]) => { - if (Array.isArray(utterances)) { - utterances.forEach((u) => { - allUtterances.push({ - ...u, - speakerId: speakerId, - }); - }); - } - }); - // Sortiere nach offset falls vorhanden - allUtterances.sort((a, b) => (a.offset || 0) - (b.offset || 0)); - result.utterances = allUtterances; - result.text = allUtterances.map((u) => `[${u.speakerId}] ${u.text}`).join('\n'); - return result; - } - // 3. Prüfe auf altes Format (einfacher Text) - const simpleTranscript = - source.transcript || source.transcription || source.text || metadata.transcript || ''; - if (simpleTranscript) { - result.text = simpleTranscript; - return result; - } - // 4. Fallback: transcription_parts (älteres Array-Format) - if (source.transcription_parts && Array.isArray(source.transcription_parts)) { - result.text = source.transcription_parts - .map((part) => part.text || part.transcript || '') - .filter(Boolean) - .join(' '); - return result; - } - // 5. Spezielle Behandlung für combined memos mit additional_recordings - if (source.additional_recordings && Array.isArray(source.additional_recordings)) { - const allUtterances = []; - const allSpeakers = {}; - const texts = []; - source.additional_recordings.forEach((recording) => { - // Sammle utterances aus den recordings - if (recording.utterances && Array.isArray(recording.utterances)) { - allUtterances.push(...recording.utterances); - } - // Sammle speaker mappings - if (recording.speakers) { - Object.assign(allSpeakers, recording.speakers); - } - // Sammle auch Text als Fallback - if (recording.transcript) { - texts.push(recording.transcript); - } - }); - // Wenn wir strukturierte utterances haben, bevorzuge diese - if (allUtterances.length > 0) { - // Sortiere utterances chronologisch - allUtterances.sort((a, b) => (a.offset || 0) - (b.offset || 0)); - result.utterances = allUtterances; - result.speakers = allSpeakers; - result.text = allUtterances - .map((u) => { - const speaker = u.speakerId ? `[${u.speakerId}] ` : ''; - return `${speaker}${u.text}`; - }) - .join('\n'); - return result; - } else { - // Fallback zu einfachem Text - result.text = texts.join('\n\n'); - } - } - return result; -} -/** - * Formatiert Transkript für LLM-Verarbeitung mit verbesserter Speaker-Darstellung - */ function formatTranscriptForLLM(title, date, extractedTranscript, speakers) { - const header = `=== ${title} (${date}) ===\n`; - // Wenn keine Speaker-Informationen vorhanden sind, gib einfachen Text zurück - if (!extractedTranscript.utterances || extractedTranscript.utterances.length === 0) { - return header + extractedTranscript.text + '\n\n'; - } - // Formatiere mit Speaker-Informationen und Zeitstempeln - let formattedText = header; - extractedTranscript.utterances.forEach((utterance) => { - const timestamp = utterance.offset ? ` [${formatTimestamp(utterance.offset)}]` : ''; - const speakerId = utterance.speakerId || 'Unknown'; - // Verwende Speaker-Label falls vorhanden, sonst formatiere Speaker-ID - let speakerLabel = speakerId; - if (speakers && speakers[speakerId]) { - speakerLabel = speakers[speakerId]; - } else if (speakerId.startsWith('speaker')) { - // Konvertiere "speaker1" zu "Sprecher 1" - const speakerNum = speakerId.replace('speaker', ''); - speakerLabel = `Sprecher ${speakerNum}`; - } - formattedText += `${speakerLabel}${timestamp}: ${utterance.text}\n`; - }); - return formattedText + '\n'; -} -/** - * Formatiert Millisekunden in lesbares Zeitformat (MM:SS) - */ function formatTimestamp(ms) { - const totalSeconds = Math.floor(ms / 1000); - const minutes = Math.floor(totalSeconds / 60); - const seconds = totalSeconds % 60; - return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`; -} -// ─── Logging-Funktion ────────────────────────────────────────────── -/** - * Erweiterte Logging-Funktion mit Zeitstempel und Log-Level - */ function log(level, message, data) { - const timestamp = new Date().toISOString(); - const logMessage = `[${timestamp}] [${level.toUpperCase()}] ${message}`; - switch (level.toUpperCase()) { - case 'INFO': - console.log(logMessage); - break; - case 'DEBUG': - console.debug(logMessage); - break; - case 'WARN': - console.warn(logMessage); - break; - case 'ERROR': - console.error(logMessage); - break; - default: - console.log(logMessage); - break; - } - if (data) { - if (level.toUpperCase() === 'ERROR') { - console.error(data); - } else { - console.log(typeof data === 'object' ? JSON.stringify(data, null, 2) : data); - } - } -} -/** - * Dekodiert ein JWT-Token und extrahiert die Payload - */ function decodeJWT(token) { - try { - const parts = token.split('.'); - if (parts.length !== 3) { - console.error('Invalid JWT: Incorrect number of parts'); - return null; - } - const payload = parts[1]; - const padded = payload.padEnd(payload.length + ((4 - (payload.length % 4)) % 4), '='); - const decoded = atob(padded); - const parsed = JSON.parse(decoded); - return parsed; - } catch (error) { - console.error('Fehler beim Dekodieren des JWT:', error); - return null; - } -} -/** - * Verarbeitet Blueprint-Prompts mit Gemini Flash 2.5 - */ async function processWithGemini(transcript, prompt, functionIdForLog = 'combine') { - const requestId = crypto.randomUUID().substring(0, 8); - log( - 'INFO', - `[${functionIdForLog}][LLM-${requestId}] Starte Gemini-Anfrage für Blueprint-Verarbeitung.` - ); - try { - const fullPrompt = `${prompt} - -Inhalt: ${transcript} - -Bearbeite den obigen Inhalt entsprechend der Anweisung und antworte strukturiert und präzise.`; - log( - 'DEBUG', - `[${functionIdForLog}][LLM-${requestId}] Vollständiger Prompt (Länge: ${fullPrompt.length})` - ); - const startTime = Date.now(); - const response = await fetch( - `${GEMINI_ENDPOINT}/${GEMINI_MODEL}:generateContent?key=${GEMINI_API_KEY}`, - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - contents: [ - { - parts: [ - { - text: fullPrompt, - }, - ], - }, - ], - generationConfig: { - temperature: 0.7, - maxOutputTokens: 8192, - }, - }), - } - ); - const duration = Date.now() - startTime; - log( - 'INFO', - `[${functionIdForLog}][LLM-${requestId}] Gemini-Anfrage abgeschlossen in ${duration}ms` - ); - if (!response.ok) { - throw new Error(`Gemini API error: ${response.status} ${response.statusText}`); - } - const data = await response.json(); - if (!data.candidates || !data.candidates[0] || !data.candidates[0].content) { - throw new Error('Unerwartete Gemini API Response-Struktur'); - } - const result = data.candidates[0].content.parts[0].text; - log( - 'INFO', - `[${functionIdForLog}][LLM-${requestId}] Gemini-Antwort erhalten (Länge: ${result.length})` - ); - return result; - } catch (error) { - log('ERROR', `[${functionIdForLog}][LLM-${requestId}] Gemini-Fehler:`, error); - throw error; - } -} -/** - * Fallback zu Azure OpenAI - */ async function processWithAzure(transcript, prompt, functionIdForLog = 'combine') { - const requestId = crypto.randomUUID().substring(0, 8); - log('INFO', `[${functionIdForLog}][LLM-${requestId}] Starte Azure OpenAI-Anfrage als Fallback.`); - try { - const fullPrompt = `${prompt} - -Inhalt: ${transcript} - -Bearbeite den obigen Inhalt entsprechend der Anweisung und antworte strukturiert und präzise.`; - const startTime = Date.now(); - const response = await fetch( - `${AZURE_OPENAI_ENDPOINT}/openai/deployments/${AZURE_OPENAI_DEPLOYMENT}/chat/completions?api-version=${AZURE_OPENAI_API_VERSION}`, - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'api-key': AZURE_OPENAI_KEY, - }, - body: JSON.stringify({ - messages: [ - { - role: 'user', - content: fullPrompt, - }, - ], - max_tokens: 2048, - temperature: 0.7, - }), - } - ); - const duration = Date.now() - startTime; - log( - 'INFO', - `[${functionIdForLog}][LLM-${requestId}] Azure OpenAI-Anfrage abgeschlossen in ${duration}ms` - ); - if (!response.ok) { - throw new Error(`Azure OpenAI API error: ${response.status} ${response.statusText}`); - } - const data = await response.json(); - const result = data.choices[0].message.content; - log( - 'INFO', - `[${functionIdForLog}][LLM-${requestId}] Azure OpenAI-Antwort erhalten (Länge: ${result.length})` - ); - return result; - } catch (error) { - log('ERROR', `[${functionIdForLog}][LLM-${requestId}] Azure OpenAI-Fehler:`, error); - throw error; - } -} -// ─── Hauptfunktion ────────────────────────────────────────────── -serve(async (req) => { - const functionId = crypto.randomUUID().substring(0, 8); - log('INFO', `[${functionId}] Combine Memos Function gestartet`); - try { - // CORS Headers - if (req.method === 'OPTIONS') { - return new Response('ok', { - headers: { - 'Access-Control-Allow-Origin': '*', - 'Access-Control-Allow-Methods': 'POST', - 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type', - }, - }); - } - if (req.method !== 'POST') { - return new Response( - JSON.stringify({ - error: 'Method not allowed', - }), - { - status: 405, - headers: { - 'Content-Type': 'application/json', - }, - } - ); - } - // Request Body parsen - const body = await req.json(); - const { memo_ids, blueprint_id, custom_prompt } = body; - log('INFO', `[${functionId}] Request empfangen:`, { - memo_ids_count: memo_ids?.length, - blueprint_id, - has_custom_prompt: !!custom_prompt, - }); - // Validierung - if (!memo_ids || !Array.isArray(memo_ids) || memo_ids.length === 0) { - throw new Error('memo_ids ist erforderlich und muss ein nicht-leeres Array sein'); - } - if (!blueprint_id) { - throw new Error('blueprint_id ist erforderlich'); - } - // Extract user_id from JWT token - const authHeader = req.headers.get('authorization'); - if (!authHeader || !authHeader.startsWith('Bearer ')) { - throw new Error('Authorization header fehlt oder ist ungültig'); - } - const token = authHeader.substring(7); - const decoded = decodeJWT(token); - if (!decoded || !decoded.sub) { - throw new Error('JWT-Token ungültig oder user_id fehlt'); - } - const user_id = decoded.sub; - log('INFO', `[${functionId}] User authentifiziert: ${user_id}`); - // Memos aus der Datenbank laden (mit vollständigen Daten) - log('INFO', `[${functionId}] Lade ${memo_ids.length} Memos aus der Datenbank...`); - const { data: memos, error: memosError } = await memoro_sb - .from('memos') - .select('id, title, source, metadata, created_at') - .in('id', memo_ids) - .eq('user_id', user_id); - if (memosError) { - throw new Error(`Fehler beim Laden der Memos: ${memosError.message}`); - } - if (!memos || memos.length === 0) { - throw new Error('Keine Memos gefunden oder keine Berechtigung'); - } - log('INFO', `[${functionId}] ${memos.length} Memos erfolgreich geladen`); - // Spezielle Blueprint-IDs behandeln - let blueprint; - let prompts = []; - if (blueprint_id === 'transcript_only') { - log('INFO', `[${functionId}] Verwende speziellen Blueprint: Nur Transkripte kombinieren`); - blueprint = { - id: 'transcript_only', - name: { - de: 'Transkripte kombinieren', - en: 'Combine Transcripts', - }, - description: { - de: 'Kombiniert nur die Transkripte ohne AI-Verarbeitung', - en: 'Combines only transcripts without AI processing', - }, - }; - prompts = []; // Keine Prompts für reine Transkript-Kombination - } else { - // Blueprint aus der Datenbank laden - log('INFO', `[${functionId}] Lade Blueprint ${blueprint_id}...`); - const { data: blueprintData, error: blueprintError } = await memoro_sb - .from('blueprints') - .select('id, name, description') - .eq('id', blueprint_id) - .eq('is_public', true) - .single(); - if (blueprintError) { - throw new Error(`Fehler beim Laden des Blueprints: ${blueprintError.message}`); - } - if (!blueprintData) { - throw new Error('Blueprint nicht gefunden oder nicht öffentlich'); - } - blueprint = blueprintData; - log( - 'INFO', - `[${functionId}] Blueprint erfolgreich geladen: ${blueprint.name?.de || blueprint.name?.en || 'Unnamed'}` - ); - // Blueprint-Prompts laden - log('INFO', `[${functionId}] Lade Prompts für Blueprint...`); - const { data: promptLinks, error: promptLinksError } = await memoro_sb - .from('prompt_blueprints') - .select('prompt_id') - .eq('blueprint_id', blueprint_id); - if (promptLinksError) { - throw new Error(`Fehler beim Laden der Prompt-Links: ${promptLinksError.message}`); - } - if (!promptLinks || promptLinks.length === 0) { - throw new Error('Keine Prompts für diesen Blueprint gefunden'); - } - const promptIds = promptLinks.map((link) => link.prompt_id); - const { data: promptsData, error: promptsError } = await memoro_sb - .from('prompts') - .select('id, memory_title, prompt_text') - .in('id', promptIds); - if (promptsError) { - throw new Error(`Fehler beim Laden der Prompts: ${promptsError.message}`); - } - prompts = promptsData || []; - } - log('INFO', `[${functionId}] ${prompts?.length || 0} Prompts für Blueprint geladen`); - // Transkripte strukturiert kombinieren und für LLM aufbereiten - const additionalRecordings = []; - let combinedTranscriptForLLM = ''; - let combinedTranscriptForDisplay = ''; - for (let index = 0; index < memos.length; index++) { - const memo = memos[index]; - const title = memo.title || `Memo ${index + 1}`; - const date = new Date(memo.created_at).toLocaleDateString('de-DE'); - // Verwende die neue Extraktions-Funktion für alle Formate - const extractedTranscript = extractTranscriptFromMemo(memo); - // Bestimme den korrekten Audio-Pfad und Type - const audioPath = memo.source?.audio_path; - const hasAudioFile = audioPath && !audioPath.startsWith('combined-memo-'); - // Erstelle additional_recording Eintrag für separates Transkript mit Audio - additionalRecordings.push({ - audio_path: audioPath || `combined-memo-${memo.id}`, - type: hasAudioFile ? 'audio' : 'combined_memo', - timestamp: memo.created_at, - status: 'completed', - transcript: extractedTranscript.text, - // Bewahre alle strukturierten Daten für separate Anzeige - utterances: extractedTranscript.utterances, - speakers: extractedTranscript.speakers, - speakerMap: extractedTranscript.speakerMap, - languages: memo.source?.languages || ['de-DE'], - primary_language: memo.source?.primary_language || 'de-DE', - // Audio-spezifische Daten - duration: memo.source?.duration || memo.source?.duration_seconds, - // Erweiterte Metadaten für bessere Anzeige - memo_metadata: { - original_memo_id: memo.id, - original_title: title, - original_created_at: memo.created_at, - original_source: memo.source, - combine_index: index, - // Zusätzliche Anzeige-Informationen - display_title: title, - display_date: date, - has_audio: hasAudioFile, - }, - }); - // Text für LLM-Verarbeitung mit Speaker-Context aufbereiten - // Versuche Speaker-Labels aus Metadata zu holen - const speakerLabels = memo.metadata?.speakerLabels || extractedTranscript.speakers; - combinedTranscriptForLLM += formatTranscriptForLLM( - title, - date, - extractedTranscript, - speakerLabels - ); - // Einfacheres Format für die Anzeige (ohne Header) - if (index > 0) { - combinedTranscriptForDisplay += '\n\n'; - } - combinedTranscriptForDisplay += extractedTranscript.text; - } - log( - 'INFO', - `[${functionId}] ${additionalRecordings.length} Additional-Recordings erstellt und ${combinedTranscriptForLLM.length} Zeichen für LLM aufbereitet` - ); - // Neues kombiniertes Memo erstellen (nur als Container für separate Transkripte) - const blueprintName = blueprint.name?.de || blueprint.name?.en || 'Unnamed Blueprint'; - const combinedMemoTitle = `Combined: ${memos.length} memos (${blueprintName})`; - // Erstelle beschreibenden Intro-Text - const originalTitles = memos.map((memo) => memo.title || 'Untitled').join(', '); - const introText = `Dieses Memo kombiniert ${memos.length} ursprüngliche Memos: ${originalTitles}. Jedes Memo wird als separates Transkript unten angezeigt.`; - const newMemoData = { - user_id: user_id, - title: combinedMemoTitle, - intro: introText, - source: { - type: 'combined', - // KEIN kombiniertes Haupttranskript mehr - nur Container-Metadaten - additional_recordings: additionalRecordings, - languages: ['de-DE'], - primary_language: 'de-DE', - // Kombinierungs-spezifische Metadaten - combine_metadata: { - blueprint_id: blueprint_id, - blueprint_name: blueprintName, - custom_prompt: custom_prompt || null, - combined_at: new Date().toISOString(), - combined_memo_count: memos.length, - original_memo_ids: memo_ids, - original_titles: originalTitles, - }, - }, - created_at: new Date().toISOString(), - updated_at: new Date().toISOString(), - }; - log('INFO', `[${functionId}] Erstelle neues kombiniertes Memo...`); - const { data: newMemo, error: createMemoError } = await memoro_sb - .from('memos') - .insert(newMemoData) - .select() - .single(); - if (createMemoError) { - throw new Error(`Fehler beim Erstellen des neuen Memos: ${createMemoError.message}`); - } - log('INFO', `[${functionId}] Neues Memo erstellt: ${newMemo.id}`); - // Blueprint-Prompts verarbeiten (außer bei transcript_only) - const currentLanguage = 'de'; // Standard Deutsch, könnte später dynamisch sein - let processedCount = 0; - if (blueprint_id === 'transcript_only') { - log('INFO', `[${functionId}] Überspringe AI-Verarbeitung für transcript_only Blueprint`); - } else { - for (const prompt of prompts || []) { - try { - const promptTitle = - prompt.memory_title?.[currentLanguage] || prompt.memory_title?.en || 'Untitled'; - const promptText = prompt.prompt_text?.[currentLanguage] || prompt.prompt_text?.en || ''; - if (!promptText) { - log('WARN', `[${functionId}] Prompt ${prompt.id} hat keinen Text, überspringe`); - continue; - } - // Custom Prompt anhängen, falls vorhanden - const finalPrompt = custom_prompt - ? `${promptText}\n\nZusätzliche Anweisung: ${custom_prompt}` - : promptText; - log('INFO', `[${functionId}] Verarbeite Prompt: ${promptTitle}`); - // Mit Gemini verarbeiten, Fallback zu Azure - let aiResponse; - try { - aiResponse = await processWithGemini(combinedTranscriptForLLM, finalPrompt, functionId); - } catch (geminiError) { - log( - 'WARN', - `[${functionId}] Gemini fehlgeschlagen, verwende Azure Fallback:`, - geminiError - ); - aiResponse = await processWithAzure(combinedTranscriptForLLM, finalPrompt, functionId); - } - // Get the highest sort_order for this memo - const { data: maxSortData, error: maxSortError } = await memoro_sb - .from('memories') - .select('sort_order') - .eq('memo_id', newMemo.id) - .order('sort_order', { - ascending: false, - }) - .limit(1) - .single(); - // If error or no data, use random number above 5000, otherwise increment - const nextSortOrder = - maxSortError || !maxSortData?.sort_order - ? Math.floor(Math.random() * 5000) + 5000 // Random between 5000-9999 - : maxSortData.sort_order + 1; - // Memory-Eintrag erstellen - const memoryData = { - memo_id: newMemo.id, - title: promptTitle, - content: aiResponse, - sort_order: nextSortOrder, - created_at: new Date().toISOString(), - updated_at: new Date().toISOString(), - }; - const { error: memoryError } = await memoro_sb.from('memories').insert(memoryData); - if (memoryError) { - log( - 'ERROR', - `[${functionId}] Fehler beim Erstellen der Memory für Prompt ${prompt.id}:`, - memoryError - ); - } else { - processedCount++; - log('INFO', `[${functionId}] Memory erstellt für Prompt: ${promptTitle}`); - } - } catch (promptError) { - log('ERROR', `[${functionId}] Fehler bei Prompt ${prompt.id}:`, promptError); - } - } - } - log( - 'INFO', - `[${functionId}] Blueprint-Verarbeitung abgeschlossen. ${processedCount}/${prompts?.length || 0} Prompts erfolgreich verarbeitet` - ); - // Headline und Intro für das kombinierte Memo generieren (auch bei transcript_only) - log('INFO', `[${functionId}] Starte Headline-Generierung für kombiniertes Memo...`); - try { - const headlineResponse = await fetch(`${SUPABASE_URL}/functions/v1/headline`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${SERVICE_KEY}`, - }, - body: JSON.stringify({ - memo_id: newMemo.id, - }), - }); - if (headlineResponse.ok) { - const headlineData = await headlineResponse.json(); - log('INFO', `[${functionId}] Headline erfolgreich generiert: ${headlineData.headline}`); - } else { - const errorText = await headlineResponse.text(); - log('WARN', `[${functionId}] Headline-Generierung fehlgeschlagen:`, errorText); - } - } catch (headlineError) { - log('WARN', `[${functionId}] Fehler bei Headline-Generierung:`, headlineError); - } - // Erfolgreiche Antwort - return new Response( - JSON.stringify({ - success: true, - memo_id: newMemo.id, - combined_memos_count: memos.length, - processed_prompts_count: processedCount, - total_prompts_count: prompts?.length || 0, - }), - { - status: 200, - headers: { - 'Content-Type': 'application/json', - 'Access-Control-Allow-Origin': '*', - }, - } - ); - } catch (error) { - log('ERROR', `[${functionId}] Fehler in Combine Memos Function:`, error); - return new Response( - JSON.stringify({ - error: error.message || 'Ein unbekannter Fehler ist aufgetreten', - function_id: functionId, - }), - { - status: 500, - headers: { - 'Content-Type': 'application/json', - 'Access-Control-Allow-Origin': '*', - }, - } - ); - } -}); diff --git a/apps/memoro/apps/backend/supabase/functions/create-memory/constants.ts b/apps/memoro/apps/backend/supabase/functions/create-memory/constants.ts deleted file mode 100644 index 097016282..000000000 --- a/apps/memoro/apps/backend/supabase/functions/create-memory/constants.ts +++ /dev/null @@ -1,75 +0,0 @@ -/** - * System-Prompts für die Memory-Erstellung in verschiedenen Sprachen - * - * Die Prompts werden als System-Prompt für die AI-Nachrichten verwendet, - * um konsistente und hilfreiche Antworten zu generieren. - */ /** - * Interface für die Prompt-Konfiguration - */ /** - * System-Prompts für die Memory-Erstellung - * - * Unterstützte Sprachen: - * - de: Deutsch - * - en: Englisch - * - fr: Französisch - * - es: Spanisch - * - it: Italienisch - * - nl: Niederländisch - * - pt: Portugiesisch - * - ru: Russisch - * - ja: Japanisch - * - ko: Koreanisch - * - zh: Chinesisch - * - ar: Arabisch - * - hi: Hindi - * - tr: Türkisch - * - pl: Polnisch - */ export const SYSTEM_PROMPTS = { - system: { - // Deutsch - de: 'Du bist ein hilfreicher Assistent, der Texte analysiert und verarbeitet. Deine Aufgabe ist es, Transkripte von Gesrpächen gemäß den gegebenen Anweisungen zu bearbeiten. Antworte präzise, strukturiert und hilfreich. Antworte in plain text.', - // Englisch - en: 'You are a helpful assistant that analyzes and processes texts. Your task is to process transcripts of conversations according to the given instructions. Respond precisely, structured, and helpfully. Respond in plain text.', - // Französisch - fr: 'Vous êtes un assistant utile qui analyse et traite les textes. Votre tâche est de traiter les transcriptions de conversations selon les instructions données. Répondez de manière précise, structurée et utile. Répondez en texte brut.', - // Spanisch - es: 'Eres un asistente útil que analiza y procesa textos. Tu tarea es procesar transcripciones de conversaciones según las instrucciones dadas. Responde de forma precisa, estructurada y útil. Responde en texto plano.', - // Italienisch - it: 'Sei un assistente utile che analizza e elabora testi. Il tuo compito è elaborare trascrizioni di conversazioni secondo le istruzioni date. Rispondi in modo preciso, strutturato e utile. Rispondi in testo semplice.', - // Niederländisch - nl: 'Je bent een behulpzame assistent die teksten analyseert en verwerkt. Je taak is om transcripties van gesprekken te verwerken volgens de gegeven instructies. Antwoord precies, gestructureerd en behulpzaam. Antwoord in platte tekst.', - // Portugiesisch - pt: 'Você é um assistente útil que analisa e processa textos. Sua tarefa é processar transcrições de conversas de acordo com as instruções dadas. Responda de forma precisa, estruturada e útil. Responda em texto simples.', - // Russisch - ru: 'Вы полезный помощник, который анализирует и обрабатывает тексты. Ваша задача - обрабатывать расшифровки разговоров согласно данным инструкциям. Отвечайте точно, структурированно и полезно. Отвечайте простым текстом.', - // Japanisch - ja: 'あなたはテキストを分析・処理する有用なアシスタントです。あなたの仕事は、与えられた指示に従って会話の転写を処理することです。正確で構造化された有用な回答をしてください。プレーンテキストで回答してください。', - // Koreanisch - ko: '당신은 텍스트를 분석하고 처리하는 유용한 어시스턴트입니다. 당신의 임무는 주어진 지시에 따라 대화의 전사본을 처리하는 것입니다. 정확하고 구조화되며 도움이 되는 방식으로 응답하세요. 일반 텍스트로 응답하세요.', - // Chinesisch (vereinfacht) - zh: '你是一个有用的助手,负责分析和处理文本。你的任务是根据给定的指令处理对话的转录。请准确、结构化、有帮助地回答。请用纯文本回答。', - // Arabisch - ar: 'أنت مساعد مفيد يحلل ويعالج النصوص. مهمتك هي معالجة نسخ المحادثات وفقاً للتعليمات المقدمة. أجب بدقة وبطريقة منظمة ومفيدة. أجب بنص عادي.', - // Hindi - hi: 'आप एक उपयोगी सहायक हैं जो पाठों का विश्लेषण और प्रसंस्करण करते हैं। आपका कार्य दिए गए निर्देशों के अनुसार बातचीत के प्रतिलेख को संसाधित करना है। सटीक, संरचित और सहायक तरीके से उत्तर दें। सादे पाठ में उत्तर दें।', - // Türkisch - tr: 'Metinleri analiz eden ve işleyen yararlı bir asistansınız. Göreviniz, verilen talimatlara göre konuşma transkriptlerini işlemektir. Kesin, yapılandırılmış ve yararlı şekilde yanıt verin. Düz metin olarak yanıt verin.', - // Polnisch - pl: 'Jesteś pomocnym asystentem, który analizuje i przetwarza teksty. Twoim zadaniem jest przetwarzanie transkrypcji rozmów zgodnie z podanymi instrukcjami. Odpowiadaj precyzyjnie, uporządkowanie i pomocnie. Odpowiadaj zwykłym tekstem.', - }, -}; -/** - * Hilfsfunktion zum Abrufen des System-Prompts für eine bestimmte Sprache - * @param language Sprache (z.B. 'de', 'en', 'fr') - * @returns System-Prompt für die angegebene Sprache oder Fallback - */ export function getSystemPrompt(language) { - const lang = language.toLowerCase().split('-')[0]; // z.B. 'de-DE' -> 'de' - // Versuche spezifische Sprache, dann Deutsch, dann Englisch, dann erste verfügbare - return ( - SYSTEM_PROMPTS.system[lang] || - SYSTEM_PROMPTS.system['de'] || - SYSTEM_PROMPTS.system['en'] || - Object.values(SYSTEM_PROMPTS.system)[0] || - 'You are a helpful AI assistant.' - ); -} diff --git a/apps/memoro/apps/backend/supabase/functions/create-memory/index.ts b/apps/memoro/apps/backend/supabase/functions/create-memory/index.ts deleted file mode 100644 index 68487a214..000000000 --- a/apps/memoro/apps/backend/supabase/functions/create-memory/index.ts +++ /dev/null @@ -1,445 +0,0 @@ -// Follow this setup guide to integrate the Deno language server with your editor: -// https://deno.land/manual/getting_started/setup_your_environment -// This enables autocomplete, go to definition, etc. -// Setup type definitions for built-in Supabase Runtime APIs -import 'jsr:@supabase/functions-js/edge-runtime.d.ts'; -import { serve } from 'https://deno.land/std@0.215.0/http/server.ts'; -import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'; -import { getSystemPrompt } from './constants.ts'; -import { getTranscriptText } from '../_shared/transcript-utils.ts'; -import { ROOT_SYSTEM_PROMPTS } from '../_shared/system-prompt.ts'; -/** - * Create Memory Edge Function - * - * Diese Funktion erstellt eine neue Memory für ein Memo mit einem spezifischen Prompt. - * Sie lädt das Memo, den Prompt und verwendet Azure OpenAI für die Verarbeitung. - * - * @version 1.0.0 - * @date 2025-05-27 - */ // ─── Umgebungsvariablen ────────────────────────────────────────────── -const SUPABASE_URL = Deno.env.get('SUPABASE_URL'); -if (!SUPABASE_URL) { - throw new Error('SUPABASE_URL not configured'); -} -const SERVICE_KEY = Deno.env.get('C_SUPABASE_SECRET_KEY'); -if (!SERVICE_KEY) { - throw new Error('C_SUPABASE_SECRET_KEY not configured'); -} -// Google Gemini Konfiguration -const GEMINI_API_KEY = Deno.env.get('CREATE_MEMORY_GEMINI_MEMORO') || ''; -const GEMINI_MODEL = 'gemini-2.0-flash'; -const GEMINI_ENDPOINT = 'https://generativelanguage.googleapis.com/v1beta/models'; -// Azure OpenAI Konfiguration (Backup) -const AZURE_OPENAI_ENDPOINT = 'https://memoroseopenai.openai.azure.com'; -const AZURE_OPENAI_KEY = Deno.env.get('AZURE_OPENAI_KEY'); -if (!AZURE_OPENAI_KEY) { - throw new Error('AZURE_OPENAI_KEY not configured'); -} -const AZURE_OPENAI_DEPLOYMENT = 'gpt-4.1-mini-se'; -const AZURE_OPENAI_API_VERSION = '2025-01-01-preview'; -const memoro_sb = createClient(SUPABASE_URL, SERVICE_KEY); -// ─── Error Handler Functions ────────────────────────────────────────────── -/** - * Erstellt eine standardisierte Fehlerantwort für Edge Functions - */ function createErrorResponse(error, status = 500, corsHeaders = {}) { - const errorMessage = error instanceof Error ? error.message : String(error); - return new Response( - JSON.stringify({ - error: errorMessage, - timestamp: new Date().toISOString(), - }), - { - headers: { - ...corsHeaders, - 'Content-Type': 'application/json', - }, - status, - } - ); -} -// ─── Logging-Funktion ────────────────────────────────────────────── -/** - * Erweiterte Logging-Funktion mit Zeitstempel und Log-Level - */ function log(level, message, data) { - const timestamp = new Date().toISOString(); - const logMessage = `[${timestamp}] [${level.toUpperCase()}] ${message}`; - switch (level.toUpperCase()) { - case 'INFO': - console.log(logMessage); - break; - case 'DEBUG': - console.debug(logMessage); - break; - case 'WARN': - console.warn(logMessage); - break; - case 'ERROR': - console.error(logMessage); - break; - default: - console.log(logMessage); - break; - } - if (data) { - if (level.toUpperCase() === 'ERROR') { - console.error(data); - } else { - console.log(typeof data === 'object' ? JSON.stringify(data, null, 2) : data); - } - } -} -/** - * Sendet Prompt an Gemini Flash und gibt die Antwort zurück - */ async function runPromptWithGemini(prompt, transcript, functionIdForLog = 'global') { - const requestId = crypto.randomUUID().substring(0, 8); - log('INFO', `[${functionIdForLog}][GEMINI-${requestId}] Starte Gemini-Anfrage.`); - try { - let fullPrompt; - if (prompt.includes('{transcript}')) { - fullPrompt = prompt.replace('{transcript}', transcript); - log('DEBUG', `[${functionIdForLog}][GEMINI-${requestId}] Platzhalter im Prompt ersetzt.`); - } else { - fullPrompt = `${prompt}\n\nText: ${transcript}`; - log( - 'DEBUG', - `[${functionIdForLog}][GEMINI-${requestId}] Kein Platzhalter, Transkript am Ende angehängt.` - ); - } - const startTime = Date.now(); - const response = await fetch( - `${GEMINI_ENDPOINT}/${GEMINI_MODEL}:generateContent?key=${GEMINI_API_KEY}`, - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - contents: [ - { - parts: [ - { - text: fullPrompt, - }, - ], - }, - ], - generationConfig: { - temperature: 0.7, - maxOutputTokens: 8192, - }, - }), - } - ); - const duration = Date.now() - startTime; - log( - 'INFO', - `[${functionIdForLog}][GEMINI-${requestId}] Gemini Antwort erhalten in ${duration}ms, Status: ${response.status}` - ); - if (!response.ok) { - const errorText = await response.text(); - log( - 'ERROR', - `[${functionIdForLog}][GEMINI-${requestId}] Gemini API Fehler: ${response.status}`, - errorText - ); - throw new Error(`Gemini API Fehler: ${response.status} ${errorText}`); - } - const data = await response.json(); - const content = data.candidates?.[0]?.content?.parts?.[0]?.text?.trim() || ''; - log( - 'INFO', - `[${functionIdForLog}][GEMINI-${requestId}] Erfolgreiche Gemini-Antwort (Länge: ${content.length}).` - ); - return content; - } catch (error) { - log('ERROR', `[${functionIdForLog}][GEMINI-${requestId}] Fehler beim Gemini-Request:`, error); - throw error; - } -} -/** - * Sendet Prompt an Azure OpenAI und gibt die Antwort zurück (Fallback) - */ async function runPromptWithAzure( - prompt, - transcript, - language = 'de', - functionIdForLog = 'global' -) { - const systemPrompt = getSystemPrompt(language); - const requestId = crypto.randomUUID().substring(0, 8); - log('INFO', `[${functionIdForLog}][AZURE-${requestId}] Starte Azure OpenAI-Anfrage.`); - try { - let fullPrompt; - if (prompt.includes('{transcript}')) { - fullPrompt = prompt.replace('{transcript}', transcript); - log('DEBUG', `[${functionIdForLog}][AZURE-${requestId}] Platzhalter im Prompt ersetzt.`); - } else { - fullPrompt = `${prompt}\n\nText: ${transcript}`; - log( - 'DEBUG', - `[${functionIdForLog}][AZURE-${requestId}] Kein Platzhalter, Transkript am Ende angehängt.` - ); - } - const startTime = Date.now(); - const response = await fetch( - `${AZURE_OPENAI_ENDPOINT}/openai/deployments/${AZURE_OPENAI_DEPLOYMENT}/chat/completions?api-version=${AZURE_OPENAI_API_VERSION}`, - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'api-key': AZURE_OPENAI_KEY, - }, - body: JSON.stringify({ - messages: [ - { - role: 'system', - content: systemPrompt, - }, - { - role: 'user', - content: fullPrompt, - }, - ], - max_tokens: 8192, - temperature: 0.7, - }), - } - ); - const duration = Date.now() - startTime; - log( - 'INFO', - `[${functionIdForLog}][AZURE-${requestId}] Azure OpenAI Antwort erhalten in ${duration}ms, Status: ${response.status}` - ); - if (!response.ok) { - const errorText = await response.text(); - log( - 'ERROR', - `[${functionIdForLog}][AZURE-${requestId}] Azure OpenAI API Fehler: ${response.status}`, - errorText - ); - throw new Error(`Azure OpenAI API Fehler: ${response.status} ${errorText}`); - } - const data = await response.json(); - const content = data.choices[0]?.message?.content?.trim() || ''; - log( - 'INFO', - `[${functionIdForLog}][AZURE-${requestId}] Erfolgreiche Azure OpenAI-Antwort (Länge: ${content.length}).` - ); - return content; - } catch (error) { - log( - 'ERROR', - `[${functionIdForLog}][AZURE-${requestId}] Fehler beim Azure OpenAI-Request:`, - error - ); - throw error; - } -} -/** - * Hauptfunktion zur Prompt-Verarbeitung mit Fallback-Logik - */ async function runPromptWithTranscript( - prompt, - transcript, - language = 'de', - functionIdForLog = 'global' -) { - try { - // Zuerst mit Gemini versuchen - return await runPromptWithGemini(prompt, transcript, functionIdForLog); - } catch (error) { - log('WARN', `[${functionIdForLog}] Gemini fehlgeschlagen, fallback auf Azure OpenAI`, error); - try { - // Fallback auf Azure OpenAI - return await runPromptWithAzure(prompt, transcript, language, functionIdForLog); - } catch (azureError) { - log('ERROR', `[${functionIdForLog}] Beide LLM-Services fehlgeschlagen`, azureError); - throw new Error('Beide LLM-Services sind nicht verfügbar'); - } - } -} -serve(async (req) => { - const functionId = crypto.randomUUID().substring(0, 8); - log('INFO', `[${functionId}] Create-Memory-Funktion gestartet`); - const corsHeaders = { - 'Access-Control-Allow-Origin': '*', - 'Access-Control-Allow-Methods': 'POST, OPTIONS', - 'Access-Control-Allow-Headers': 'Content-Type, Authorization', - }; - if (req.method === 'OPTIONS') { - log('DEBUG', `[${functionId}] CORS Preflight-Anfrage bearbeitet`); - return new Response(null, { - headers: corsHeaders, - status: 204, - }); - } - try { - const requestData = await req.json(); - const { memo_id, prompt_id } = requestData; - log( - 'INFO', - `[${functionId}] Anfrage erhalten für memo_id: ${memo_id}, prompt_id: ${prompt_id}` - ); - if (!memo_id || !prompt_id) { - log( - 'ERROR', - `[${functionId}] Fehlende Parameter: memo_id=${memo_id}, prompt_id=${prompt_id}` - ); - return createErrorResponse('memo_id und prompt_id sind erforderlich', 400, corsHeaders); - } - // Memo laden - log('INFO', `[${functionId}] Rufe Memo mit ID ${memo_id} aus der Datenbank ab`); - const { data: memo, error: memoError } = await memoro_sb - .from('memos') - .select('*') - .eq('id', memo_id) - .single(); - if (memoError || !memo) { - log('ERROR', `[${functionId}] Memo ${memo_id} nicht gefunden:`, memoError); - return createErrorResponse('Memo nicht gefunden', 404, corsHeaders); - } - // Prompt laden - log('INFO', `[${functionId}] Rufe Prompt mit ID ${prompt_id} aus der Datenbank ab`); - const { data: prompt, error: promptError } = await memoro_sb - .from('prompts') - .select('*') - .eq('id', prompt_id) - .single(); - if (promptError || !prompt) { - log('ERROR', `[${functionId}] Prompt ${prompt_id} nicht gefunden:`, promptError); - return createErrorResponse('Prompt nicht gefunden', 404, corsHeaders); - } - // Transcript extrahieren (from utterances or legacy fields) - const transcript = getTranscriptText(memo); - log('INFO', `[${functionId}] Extrahiertes Transkript (Länge: ${transcript.length})`); - if (!transcript) { - log('ERROR', `[${functionId}] Kein Transkript im Memo ${memo_id} gefunden`); - return createErrorResponse('Kein Transkript im Memo gefunden', 400, corsHeaders); - } - // Bestimme die Sprache des Memos - let baseMemoLang = 'de'; // Standard: Deutsch - const primaryLanguage = memo.source?.primary_language || memo.source?.languages?.[0]; - if (primaryLanguage && typeof primaryLanguage === 'string') { - baseMemoLang = primaryLanguage.split('-')[0].toLowerCase(); - log( - 'DEBUG', - `[${functionId}] Ermittelte Basis-Sprache: ${baseMemoLang} (aus ${primaryLanguage})` - ); - } else { - log( - 'DEBUG', - `[${functionId}] Keine primäre Sprache gefunden. Nutze Standard: ${baseMemoLang}` - ); - } - const defaultPreferredLang = 'de'; - const defaultFallbackLang = 'en'; - // Prompt-Text und Memory-Titel extrahieren - let promptText = ''; - if (prompt.prompt_text && typeof prompt.prompt_text === 'object') { - promptText = - (baseMemoLang && prompt.prompt_text[baseMemoLang]) || - prompt.prompt_text[defaultPreferredLang] || - prompt.prompt_text[defaultFallbackLang] || - Object.values(prompt.prompt_text)[0] || - ''; - } - // Prepend system prompt if available for the language - const systemPrePrompt = - ROOT_SYSTEM_PROMPTS.PRE_PROMPT[baseMemoLang] || ROOT_SYSTEM_PROMPTS.PRE_PROMPT['de']; - if (systemPrePrompt && promptText) { - promptText = systemPrePrompt + '\n\n' + promptText; - } - let memoryTitle = ''; - if (prompt.memory_title && typeof prompt.memory_title === 'object') { - memoryTitle = - (baseMemoLang && prompt.memory_title[baseMemoLang]) || - prompt.memory_title[defaultPreferredLang] || - prompt.memory_title[defaultFallbackLang] || - Object.values(prompt.memory_title)[0] || - ''; - } - if (!promptText) { - log('ERROR', `[${functionId}] Kein Prompt-Text für Prompt ${prompt_id} gefunden`); - return createErrorResponse('Kein Prompt-Text verfügbar', 400, corsHeaders); - } - log( - 'INFO', - `[${functionId}] Sende Prompt "${memoryTitle || 'Ohne Titel'}" (ID: ${prompt_id}) an LLM mit Sprache: ${baseMemoLang}` - ); - const answer = await runPromptWithTranscript(promptText, transcript, baseMemoLang, functionId); - if (!answer) { - log('ERROR', `[${functionId}] Keine Antwort vom LLM für Prompt ${prompt_id} erhalten`); - return createErrorResponse('Keine Antwort vom LLM erhalten', 500, corsHeaders); - } - // Get the highest sort_order for this memo - log('INFO', `[${functionId}] Ermittle höchste sort_order für Memo ${memo_id}`); - const { data: maxSortData, error: maxSortError } = await memoro_sb - .from('memories') - .select('sort_order') - .eq('memo_id', memo_id) - .order('sort_order', { - ascending: false, - }) - .limit(1) - .single(); - // If error or no data, use random number above 5000, otherwise increment - const nextSortOrder = - maxSortError || !maxSortData?.sort_order - ? Math.floor(Math.random() * 5000) + 5000 // Random between 5000-9999 - : maxSortData.sort_order + 1; - log('INFO', `[${functionId}] Nächste sort_order: ${nextSortOrder}`); - log( - 'INFO', - `[${functionId}] Erstelle neues Memory für Memo ${memo_id} mit Titel "${memoryTitle || 'Memory'}"` - ); - const { data: newMemory, error: newMemoryError } = await memoro_sb - .from('memories') - .insert({ - memo_id: memo_id, - title: memoryTitle || 'Memory', - content: answer, - media: null, - sort_order: nextSortOrder, - metadata: { - type: 'manual_prompt', - prompt_id: prompt_id, - created_by: 'create_memory_function', - }, - }) - .select() - .single(); - if (newMemoryError) { - log( - 'ERROR', - `[${functionId}] Fehler beim Erstellen des Memories für Prompt ${prompt_id}:`, - newMemoryError - ); - return createErrorResponse( - `Fehler beim Erstellen der Memory: ${newMemoryError.message}`, - 500, - corsHeaders - ); - } - log( - 'INFO', - `[${functionId}] Memory erfolgreich erstellt mit ID ${newMemory.id} für Prompt ${prompt_id}` - ); - return new Response( - JSON.stringify({ - success: true, - memory_id: newMemory.id, - title: memoryTitle, - content: answer, - }), - { - headers: { - ...corsHeaders, - 'Content-Type': 'application/json', - }, - status: 200, - } - ); - } catch (error) { - log('ERROR', `[${functionId}] Unerwarteter Fehler bei der Memory-Erstellung:`, error); - const errorToLog = error instanceof Error ? error : new Error(String(error)); - return createErrorResponse(`Unerwarteter Fehler: ${errorToLog.message}`, 500, corsHeaders); - } -}); diff --git a/apps/memoro/apps/backend/supabase/functions/headline/constants.ts b/apps/memoro/apps/backend/supabase/functions/headline/constants.ts deleted file mode 100644 index 1061c351c..000000000 --- a/apps/memoro/apps/backend/supabase/functions/headline/constants.ts +++ /dev/null @@ -1,219 +0,0 @@ -/** - * System-Prompts für die Headline-Generierung in verschiedenen Sprachen - * - * Die Prompts werden verwendet, um Überschriften und Einleitungen für Memos zu generieren. - * Jede Sprache hat ihren eigenen Prompt, der die spezifischen Anforderungen und Formatierungen enthält. - */ /** - * Interface für die Prompt-Konfiguration - */ /** - * System-Prompts für die Headline-Generierung - * - * Unterstützte Sprachen (62): - * - de: Deutsch - * - en: Englisch - * - fr: Französisch - * - es: Spanisch - * - it: Italienisch - * - nl: Niederländisch - * - pt: Portugiesisch - * - ru: Russisch - * - ja: Japanisch - * - ko: Koreanisch - * - zh: Chinesisch - * - ar: Arabisch - * - hi: Hindi - * - tr: Türkisch - * - pl: Polnisch - * - da: Dänisch - * - sv: Schwedisch - * - nb: Norwegisch - * - fi: Finnisch - * - cs: Tschechisch - * - hu: Ungarisch - * - el: Griechisch - * - he: Hebräisch - * - id: Indonesisch - * - th: Thai - * - vi: Vietnamesisch - * - uk: Ukrainisch - * - ro: Rumänisch - * - bg: Bulgarisch - * - ca: Katalanisch - * - hr: Kroatisch - * - sk: Slowakisch - * - et: Estnisch - * - lv: Lettisch - * - lt: Litauisch - * - bn: Bengalisch - * - ms: Malaiisch - * - ta: Tamil - * - te: Telugu - * - ur: Urdu - * - mr: Marathi - * - gu: Gujarati - * - ml: Malayalam - * - kn: Kannada - * - pa: Punjabi - * - af: Afrikaans - * - fa: Persisch - * - ka: Georgisch - * - is: Isländisch - * - sq: Albanisch - * - az: Aserbaidschanisch - * - eu: Baskisch - * - gl: Galizisch - * - kk: Kasachisch - * - mk: Mazedonisch - * - sr: Serbisch - * - sl: Slowenisch - * - mt: Maltesisch - * - hy: Armenisch - * - uz: Usbekisch - * - ga: Irisch - * - cy: Walisisch - * - fil: Filipino - */ export const SYSTEM_PROMPTS = { - headline: { - // Deutsch - de: 'Du bist ein Assistent, der Texte analysiert und zusammenfasst. Deine Aufgabe ist es, für den folgenden Text zwei Dinge zu erstellen:\n1. Eine kurze, prägnante Headline (maximal 8 Wörter)\n2. Ein kurzes Intro, das den Inhalt des Textes in 2-3 Sätzen zusammenfasst und neugierig macht\n\nFormatiere deine Antwort genau so:\nHEADLINE: [Deine Headline hier]\nINTRO: [Dein Intro hier]', - // Englisch - en: 'You are an assistant that analyzes and summarizes texts. Your task is to create two things for the following text:\n1. A short, concise headline (maximum 8 words)\n2. A brief intro that summarizes the content of the text in 2-3 sentences and makes the reader curious\n\nFormat your answer exactly like this:\nHEADLINE: [Your headline here]\nINTRO: [Your intro here]', - // Französisch - fr: 'Vous êtes un assistant qui analyse et résume des textes. Votre tâche est de créer deux choses pour le texte suivant :\n1. Un titre court et concis (maximum 8 mots)\n2. Une brève introduction qui résume le contenu du texte en 2-3 phrases et éveille la curiosité du lecteur\n\nFormatez votre réponse exactement comme ceci :\nHEADLINE: [Votre titre ici]\nINTRO: [Votre introduction ici]', - // Spanisch - es: 'Eres un asistente que analiza y resume textos. Tu tarea es crear dos cosas para el siguiente texto:\n1. Un título breve y conciso (máximo 8 palabras)\n2. Una breve introducción que resuma el contenido del texto en 2-3 frases y despierte la curiosidad del lector\n\nFormatea tu respuesta exactamente así:\nHEADLINE: [Tu título aquí]\nINTRO: [Tu introducción aquí]', - // Italienisch - it: 'Sei un assistente che analizza e riassume testi. Il tuo compito è creare due cose per il seguente testo:\n1. Un titolo breve e conciso (massimo 8 parole)\n2. Una breve introduzione che riassume il contenuto del testo in 2-3 frasi e suscita la curiosità del lettore\n\nFormatta la tua risposta esattamente così:\nHEADLINE: [Il tuo titolo qui]\nINTRO: [La tua introduzione qui]', - // Niederländisch - nl: 'Je bent een assistent die teksten analyseert en samenvat. Je taak is om twee dingen te maken voor de volgende tekst:\n1. Een korte, bondige kop (maximaal 8 woorden)\n2. Een korte intro die de inhoud van de tekst in 2-3 zinnen samenvat en de lezer nieuwsgierig maakt\n\nFormatteer je antwoord precies zo:\nHEADLINE: [Jouw kop hier]\nINTRO: [Jouw intro hier]', - // Portugiesisch - pt: 'Você é um assistente que analisa e resume textos. Sua tarefa é criar duas coisas para o seguinte texto:\n1. Uma manchete breve e concisa (máximo 8 palavras)\n2. Uma breve introdução que resume o conteúdo do texto em 2-3 frases e desperta a curiosidade do leitor\n\nFormate sua resposta exatamente assim:\nHEADLINE: [Sua manchete aqui]\nINTRO: [Sua introdução aqui]', - // Russisch - ru: 'Вы помощник, который анализирует и резюмирует тексты. Ваша задача - создать две вещи для следующего текста:\n1. Короткий, лаконичный заголовок (максимум 8 слов)\n2. Краткое введение, которое резюмирует содержание текста в 2-3 предложениях и вызывает любопытство у читателя\n\nФорматируйте ваш ответ точно так:\nHEADLINE: [Ваш заголовок здесь]\nINTRO: [Ваше введение здесь]', - // Japanisch - ja: 'あなたはテキストを分析し要約するアシスタントです。次のテキストに対して2つのことを作成するのがあなたの仕事です:\n1. 短く簡潔な見出し(最大8語)\n2. テキストの内容を2-3文で要約し、読者の興味を引く短い導入文\n\n次のように正確にフォーマットしてください:\nHEADLINE: [ここにあなたの見出し]\nINTRO: [ここにあなたの導入文]', - // Koreanisch - ko: '당신은 텍스트를 분석하고 요약하는 어시스턴트입니다. 다음 텍스트에 대해 두 가지를 만드는 것이 당신의 임무입니다:\n1. 짧고 간결한 헤드라인 (최대 8단어)\n2. 텍스트의 내용을 2-3문장으로 요약하고 독자의 호기심을 자극하는 짧은 소개\n\n다음과 같이 정확히 형식을 맞춰주세요:\nHEADLINE: [여기에 당신의 헤드라인]\nINTRO: [여기에 당신의 소개]', - // Chinesisch (vereinfacht) - zh: '你是一个分析和总结文本的助手。你的任务是为以下文本创建两样东西:\n1. 一个简短、简洁的标题(最多8个词)\n2. 一个简短的介绍,用2-3句话总结文本内容并激发读者的好奇心\n\n请严格按照以下格式回答:\nHEADLINE: [你的标题]\nINTRO: [你的介绍]', - // Arabisch - ar: 'أنت مساعد يحلل ويلخص النصوص. مهمتك هي إنشاء شيئين للنص التالي:\n1. عنوان قصير ومقتضب (8 كلمات كحد أقصى)\n2. مقدمة مختصرة تلخص محتوى النص في 2-3 جمل وتثير فضول القارئ\n\nقم بتنسيق إجابتك بالضبط هكذا:\nHEADLINE: [عنوانك هنا]\nINTRO: [مقدمتك هنا]', - // Hindi - hi: 'आप एक सहायक हैं जो ग्रंथों का विश्लेषण और सारांश करते हैं। निम्नलिखित पाठ के लिए दो चीजें बनाना आपका कार्य है:\n1. एक संक्षिप्त, सटीक शीर्षक (अधिकतम 8 शब्द)\n2. एक संक्षिप्त परिचय जो पाठ की सामग्री को 2-3 वाक्यों में सारांशित करता है और पाठक में जिज्ञासा जगाता है\n\nअपना उत्तर बिल्कुल इस तरह से प्रारूपित करें:\nHEADLINE: [यहाँ आपका शीर्षक]\nINTRO: [यहाँ आपका परिचय]', - // Türkisch - tr: 'Metinleri analiz eden ve özetleyen bir asistansınız. Aşağıdaki metin için iki şey oluşturmak sizin göreviniz:\n1. Kısa, özlü bir başlık (maksimum 8 kelime)\n2. Metnin içeriğini 2-3 cümlede özetleyen ve okuyucuyu meraklandıran kısa bir giriş\n\nCevabınızı tam olarak şu şekilde biçimlendirin:\nHEADLINE: [Başlığınız burada]\nINTRO: [Girişiniz burada]', - // Polnisch - pl: 'Jesteś asystentem, który analizuje i streszcza teksty. Twoim zadaniem jest stworzenie dwóch rzeczy dla następującego tekstu:\n1. Krótki, zwięzły nagłówek (maksymalnie 8 słów)\n2. Krótkie wprowadzenie, które streszcza treść tekstu w 2-3 zdaniach i wzbudza ciekawość czytelnika\n\nSformatuj swoją odpowiedź dokładnie tak:\nHEADLINE: [Twój nagłówek tutaj]\nINTRO: [Twoje wprowadzenie tutaj]', - // Dänisch - da: 'Du er en assistent, der analyserer og sammenfatter tekster. Din opgave er at skabe to ting for følgende tekst:\n1. En kort, præcis overskrift (maksimalt 8 ord)\n2. En kort intro, der sammenfatter tekstens indhold i 2-3 sætninger og gør læseren nysgerrig\n\nFormatter dit svar præcis sådan:\nHEADLINE: [Din overskrift her]\nINTRO: [Dit intro her]', - // Schwedisch - sv: 'Du är en assistent som analyserar och sammanfattar texter. Din uppgift är att skapa två saker för följande text:\n1. En kort, koncis rubrik (maximalt 8 ord)\n2. En kort intro som sammanfattar textens innehåll i 2-3 meningar och gör läsaren nyfiken\n\nFormatera ditt svar exakt så här:\nHEADLINE: [Din rubrik här]\nINTRO: [Ditt intro här]', - // Norwegisch - nb: 'Du er en assistent som analyserer og oppsummerer tekster. Oppgaven din er å lage to ting for følgende tekst:\n1. En kort, presis overskrift (maksimalt 8 ord)\n2. En kort intro som oppsummerer tekstens innhold i 2-3 setninger og gjør leseren nysgjerrig\n\nFormater svaret ditt nøyaktig slik:\nHEADLINE: [Din overskrift her]\nINTRO: [Ditt intro her]', - // Finnisch - fi: 'Olet avustaja, joka analysoi ja tiivistää tekstejä. Tehtäväsi on luoda kaksi asiaa seuraavalle tekstille:\n1. Lyhyt, ytimekäs otsikko (enintään 8 sanaa)\n2. Lyhyt johdanto, joka tiivistää tekstin sisällön 2-3 lauseessa ja herättää lukijan uteliaisuuden\n\nMuotoile vastauksesi täsmälleen näin:\nHEADLINE: [Otsikkosi tähän]\nINTRO: [Johdantosi tähän]', - // Tschechisch - cs: 'Jste asistent, který analyzuje a shrnuje texty. Vaším úkolem je vytvořit dvě věci pro následující text:\n1. Krátký, stručný nadpis (maximálně 8 slov)\n2. Krátký úvod, který shrne obsah textu ve 2-3 větách a vzbudí zvědavost čtenáře\n\nNaformátujte svou odpověď přesně takto:\nHEADLINE: [Váš nadpis zde]\nINTRO: [Váš úvod zde]', - // Ungarisch - hu: 'Ön egy asszisztens, aki szövegeket elemez és összefoglal. Az Ön feladata, hogy két dolgot hozzon létre a következő szöveghez:\n1. Egy rövid, tömör címsor (maximum 8 szó)\n2. Egy rövid bevezető, amely 2-3 mondatban összefoglalja a szöveg tartalmát és felkelti az olvasó kíváncsiságát\n\nFormázza válaszát pontosan így:\nHEADLINE: [Az Ön címsora itt]\nINTRO: [Az Ön bevezetője itt]', - // Griechisch - el: 'Είστε ένας βοηθός που αναλύει και συνοψίζει κείμενα. Το καθήκον σας είναι να δημιουργήσετε δύο πράγματα για το ακόλουθο κείμενο:\n1. Έναν σύντομο, περιεκτικό τίτλο (μέγιστο 8 λέξεις)\n2. Μια σύντομη εισαγωγή που συνοψίζει το περιεχόμενο του κειμένου σε 2-3 προτάσεις και προκαλεί την περιέργεια του αναγνώστη\n\nΜορφοποιήστε την απάντησή σας ακριβώς έτσι:\nHEADLINE: [Ο τίτλος σας εδώ]\nINTRO: [Η εισαγωγή σας εδώ]', - // Hebräisch - he: 'אתה עוזר שמנתח ומסכם טקסטים. המשימה שלך היא ליצור שני דברים לטקסט הבא:\n1. כותרת קצרה ותמציתית (מקסימום 8 מילים)\n2. הקדמה קצרה שמסכמת את תוכן הטקסט ב-2-3 משפטים ומעוררת סקרנות אצל הקורא\n\nעצב את התשובה שלך בדיוק כך:\nHEADLINE: [הכותרת שלך כאן]\nINTRO: [ההקדמה שלך כאן]', - // Indonesisch - id: 'Anda adalah asisten yang menganalisis dan merangkum teks. Tugas Anda adalah membuat dua hal untuk teks berikut:\n1. Judul yang pendek dan ringkas (maksimal 8 kata)\n2. Intro singkat yang merangkum isi teks dalam 2-3 kalimat dan membuat pembaca penasaran\n\nFormat jawaban Anda persis seperti ini:\nHEADLINE: [Judul Anda di sini]\nINTRO: [Intro Anda di sini]', - // Thai - th: 'คุณเป็นผู้ช่วยที่วิเคราะห์และสรุปข้อความ งานของคุณคือการสร้างสองสิ่งสำหรับข้อความต่อไปนี้:\n1. หัวข้อที่สั้นและกระชับ (ไม่เกิน 8 คำ)\n2. บทนำสั้นๆ ที่สรุปเนื้อหาของข้อความใน 2-3 ประโยคและทำให้ผู้อ่านอยากรู้\n\nจัดรูปแบบคำตอบของคุณตามนี้เป๊ะๆ:\nHEADLINE: [หัวข้อของคุณที่นี่]\nINTRO: [บทนำของคุณที่นี่]', - // Vietnamesisch - vi: 'Bạn là một trợ lý phân tích và tóm tắt văn bản. Nhiệm vụ của bạn là tạo hai thứ cho văn bản sau:\n1. Một tiêu đề ngắn gọn và súc tích (tối đa 8 từ)\n2. Một phần giới thiệu ngắn tóm tắt nội dung văn bản trong 2-3 câu và khơi gợi sự tò mò của người đọc\n\nĐịnh dạng câu trả lời của bạn chính xác như thế này:\nHEADLINE: [Tiêu đề của bạn ở đây]\nINTRO: [Phần giới thiệu của bạn ở đây]', - // Ukrainisch - uk: 'Ви помічник, який аналізує та резюмує тексти. Ваше завдання - створити дві речі для наступного тексту:\n1. Короткий, лаконічний заголовок (максимум 8 слів)\n2. Короткий вступ, який резюмує зміст тексту у 2-3 реченнях та викликає цікавість у читача\n\nФорматуйте вашу відповідь точно так:\nHEADLINE: [Ваш заголовок тут]\nINTRO: [Ваш вступ тут]', - // Rumänisch - ro: 'Sunteți un asistent care analizează și rezumă texte. Sarcina dvs. este să creați două lucruri pentru următorul text:\n1. Un titlu scurt și concis (maximum 8 cuvinte)\n2. O scurtă introducere care rezumă conținutul textului în 2-3 propoziții și trezește curiozitatea cititorului\n\nFormatați răspunsul dvs. exact astfel:\nHEADLINE: [Titlul dvs. aici]\nINTRO: [Introducerea dvs. aici]', - // Bulgarisch - bg: 'Вие сте асистент, който анализира и резюмира текстове. Вашата задача е да създадете две неща за следния текст:\n1. Кратко, сбито заглавие (максимум 8 думи)\n2. Кратко въведение, което резюмира съдържанието на текста в 2-3 изречения и предизвиква любопитството на читателя\n\nФорматирайте отговора си точно така:\nHEADLINE: [Вашето заглавие тук]\nINTRO: [Вашето въведение тук]', - // Katalanisch - ca: 'Ets un assistent que analitza i resumeix textos. La teva tasca és crear dues coses per al següent text:\n1. Un títol breu i concís (màxim 8 paraules)\n2. Una breu introducció que resumeixi el contingut del text en 2-3 frases i desperti la curiositat del lector\n\nFormata la teva resposta exactament així:\nHEADLINE: [El teu títol aquí]\nINTRO: [La teva introducció aquí]', - // Kroatisch - hr: 'Vi ste asistent koji analizira i sažima tekstove. Vaš zadatak je stvoriti dvije stvari za sljedeći tekst:\n1. Kratak, sažet naslov (maksimalno 8 riječi)\n2. Kratak uvod koji sažima sadržaj teksta u 2-3 rečenice i pobuđuje znatiželju čitatelja\n\nFormatirajte svoj odgovor točno ovako:\nHEADLINE: [Vaš naslov ovdje]\nINTRO: [Vaš uvod ovdje]', - // Slowakisch - sk: 'Ste asistent, ktorý analyzuje a sumarizuje texty. Vašou úlohou je vytvoriť dve veci pre nasledujúci text:\n1. Krátky, stručný nadpis (maximálne 8 slov)\n2. Krátky úvod, ktorý sumarizuje obsah textu v 2-3 vetách a vzbudí zvedavosť čitateľa\n\nNaformátujte svoju odpoveď presne takto:\nHEADLINE: [Váš nadpis tu]\nINTRO: [Váš úvod tu]', - // Estnisch - et: 'Olete assistent, kes analüüsib ja kokkuvõtab tekste. Teie ülesanne on luua kaks asja järgmise teksti jaoks:\n1. Lühike, kokkuvõtlik pealkiri (maksimaalselt 8 sõna)\n2. Lühike sissejuhatus, mis võtab teksti sisu kokku 2-3 lauses ja äratab lugeja uudishimu\n\nVormistage oma vastus täpselt nii:\nHEADLINE: [Teie pealkiri siin]\nINTRO: [Teie sissejuhatus siin]', - // Lettisch - lv: 'Jūs esat asistents, kas analizē un apkopo tekstus. Jūsu uzdevums ir izveidot divas lietas šādam tekstam:\n1. Īsu, kodolīgu virsrakstu (maksimums 8 vārdi)\n2. Īsu ievadu, kas apkopo teksta saturu 2-3 teikumos un modina lasītāja ziņkāri\n\nFormatējiet savu atbildi tieši tā:\nHEADLINE: [Jūsu virsraksts šeit]\nINTRO: [Jūsu ievads šeit]', - // Litauisch - lt: 'Esate asistentas, kuris analizuoja ir apibendrина tekstus. Jūsų užduotis - sukurti du dalykus šiam tekstui:\n1. Trumpą, glaustą antraštę (ne daugiau kaip 8 žodžiai)\n2. Trumpą įvadą, kuris apibendrinta teksto turinį 2-3 sakiniais ir žadina skaitytojo smalsumą\n\nSuformatuokite savo atsakymą tiksliai taip:\nHEADLINE: [Jūsų antraštė čia]\nINTRO: [Jūsų įvadas čia]', - // Bengalisch - bn: 'আপনি একজন সহায়ক যিনি পাঠ্য বিশ্লেষণ এবং সারসংক্ষেপ করেন। নিম্নলিখিত পাঠ্যের জন্য দুটি জিনিস তৈরি করা আপনার কাজ:\n1. একটি সংক্ষিপ্ত, সারগর্ভ শিরোনাম (সর্বোচ্চ ৮টি শব্দ)\n2. একটি সংক্ষিপ্ত ভূমিকা যা ২-৩টি বাক্যে পাঠ্যের বিষয়বস্তু সারসংক্ষেপ করে এবং পাঠকের কৌতূহল জাগায়\n\nআপনার উত্তর ঠিক এভাবে ফরম্যাট করুন:\nHEADLINE: [এখানে আপনার শিরোনাম]\nINTRO: [এখানে আপনার ভূমিকা]', - // Malaiisch - ms: 'Anda adalah pembantu yang menganalisis dan meringkaskan teks. Tugas anda adalah untuk mencipta dua perkara untuk teks berikut:\n1. Tajuk utama yang pendek dan padat (maksimum 8 perkataan)\n2. Pengenalan ringkas yang meringkaskan kandungan teks dalam 2-3 ayat dan menimbulkan rasa ingin tahu pembaca\n\nFormatkan jawapan anda tepat seperti ini:\nHEADLINE: [Tajuk utama anda di sini]\nINTRO: [Pengenalan anda di sini]', - // Tamil - ta: 'நீங்கள் உரைகளை பகுப்பாய்வு செய்து சுருக்கும் உதவியாளர். பின்வரும் உரைக்கு இரண்டு விஷயங்களை உருவாக்குவது உங்கள் பணி:\n1. ஒரு குறுகிய, சுருக்கமான தலைப்பு (அதிகபட்சம் 8 வார்த்தைகள்)\n2. உரையின் உள்ளடக்கத்தை 2-3 வாக்கியங்களில் சுருக்கி வாசகரின் ஆர்வத்தை தூண்டும் குறுகிய அறிமுகம்\n\nஉங்கள் பதிலை சரியாக இப்படி வடிவமைக்கவும்:\nHEADLINE: [இங்கே உங்கள் தலைப்பு]\nINTRO: [இங்கே உங்கள் அறிமுகம்]', - // Telugu - te: 'మీరు టెక్స్ట్‌లను విశ్లేషించి సంక్షిప్తీకరించే సహాయకుడు. కింది టెక్స్ట్ కోసం రెండు విషయాలు సృష్టించడం మీ పని:\n1. ఒక చిన్న, సంక్షిప్త శీర్షిక (గరిష్టంగా 8 పదాలు)\n2. టెక్స్ట్ యొక్క కంటెంట్‌ను 2-3 వాక్యాలలో సంక్షిప్తీకరించి పాఠకుడిలో ఆసక్తిని రేకెత్తించే చిన్న పరిచయం\n\nమీ సమాధానాన్ని సరిగ్గా ఇలా ఫార్మాట్ చేయండి:\nHEADLINE: [ఇక్కడ మీ శీర్షిక]\nINTRO: [ఇక్కడ మీ పరిచయం]', - // Urdu - ur: 'آپ ایک معاون ہیں جو متن کا تجزیہ اور خلاصہ کرتے ہیں۔ مندرجہ ذیل متن کے لیے دو چیزیں بنانا آپ کا کام ہے:\n1. ایک مختصر، جامع سرخی (زیادہ سے زیادہ 8 الفاظ)\n2. ایک مختصر تعارف جو متن کے مواد کو 2-3 جملوں میں خلاصہ کرے اور قاری میں تجسس پیدا کرے\n\nاپنے جواب کو بالکل اس طرح فارمیٹ کریں:\nHEADLINE: [یہاں آپ کی سرخی]\nINTRO: [یہاں آپ کا تعارف]', - // Marathi - mr: 'तुम्ही मजकूरांचे विश्लेषण आणि सारांश करणारे सहाय्यक आहात. पुढील मजकुरासाठी दोन गोष्टी तयार करणे हे तुमचे काम आहे:\n1. एक लहान, संक्षिप्त मथळा (जास्तीत जास्त 8 शब्द)\n2. एक छोटी प्रस्तावना जी मजकुराची सामग्री 2-3 वाक्यांमध्ये सारांशित करते आणि वाचकामध्ये कुतूहल निर्माण करते\n\nतुमचे उत्तर अगदी अशा प्रकारे स्वरूपित करा:\nHEADLINE: [इथे तुमचा मथळा]\nINTRO: [इथे तुमची प्रस्तावना]', - // Gujarati - gu: 'તમે એક સહાયક છો જે ટેક્સ્ટનું વિશ્લેષણ અને સારાંશ કરે છે. નીચેના ટેક્સ્ટ માટે બે વસ્તુઓ બનાવવી એ તમારું કામ છે:\n1. એક ટૂંકું, સંક્ષિપ્ત હેડલાઇન (મહત્તમ 8 શબ્દો)\n2. એક ટૂંકો પરિચય જે ટેક્સ્ટની સામગ્રીને 2-3 વાક્યોમાં સારાંશ આપે અને વાચકમાં જિજ્ઞાસા જગાડે\n\nતમારા જવાબને બરાબર આ રીતે ફોર્મેટ કરો:\nHEADLINE: [અહીં તમારું હેડલાઇન]\nINTRO: [અહીં તમારો પરિચય]', - // Malayalam - ml: 'നിങ്ങൾ വാചകങ്ങൾ വിശകലനം ചെയ്യുകയും സംഗ്രഹിക്കുകയും ചെയ്യുന്ന ഒരു സഹായകനാണ്. ഇനിപ്പറയുന്ന വാചകത്തിനായി രണ്ട് കാര്യങ്ങൾ സൃഷ്ടിക്കുക എന്നതാണ് നിങ്ങളുടെ ജോലി:\n1. ഒരു ചെറിയ, സംക്ഷിപ്ത തലക്കെട്ട് (പരമാവധി 8 വാക്കുകൾ)\n2. വാചകത്തിന്റെ ഉള്ളടക്കം 2-3 വാക്യങ്ങളിൽ സംഗ്രഹിക്കുകയും വായനക്കാരനിൽ ജിജ്ഞാസ ഉണർത്തുകയും ചെയ്യുന്ന ഒരു ചെറിയ ആമുഖം\n\nനിങ്ങളുടെ ഉത്തരം കൃത്യമായി ഇപ്രകാരം ഫോർമാറ്റ് ചെയ്യുക:\nHEADLINE: [ഇവിടെ നിങ്ങളുടെ തലക്കെട്ട്]\nINTRO: [ഇവിടെ നിങ്ങളുടെ ആമുഖം]', - // Kannada - kn: 'ನೀವು ಪಠ್ಯಗಳನ್ನು ವಿಶ್ಲೇಷಿಸುವ ಮತ್ತು ಸಾರಾಂಶಗೊಳಿಸುವ ಸಹಾಯಕರಾಗಿದ್ದೀರಿ. ಕೆಳಗಿನ ಪಠ್ಯಕ್ಕಾಗಿ ಎರಡು ವಿಷಯಗಳನ್ನು ರಚಿಸುವುದು ನಿಮ್ಮ ಕೆಲಸ:\n1. ಒಂದು ಸಣ್ಣ, ಸಂಕ್ಷಿಪ್ತ ಶೀರ್ಷಿಕೆ (ಗರಿಷ್ಠ 8 ಪದಗಳು)\n2. ಪಠ್ಯದ ವಿಷಯವನ್ನು 2-3 ವಾಕ್ಯಗಳಲ್ಲಿ ಸಾರಾಂಶಗೊಳಿಸುವ ಮತ್ತು ಓದುಗರಲ್ಲಿ ಕುತೂಹಲವನ್ನು ಹುಟ್ಟಿಸುವ ಒಂದು ಸಣ್ಣ ಪರಿಚಯ\n\nನಿಮ್ಮ ಉತ್ತರವನ್ನು ನಿಖರವಾಗಿ ಈ ರೀತಿ ಫಾರ್ಮ್ಯಾಟ್ ಮಾಡಿ:\nHEADLINE: [ಇಲ್ಲಿ ನಿಮ್ಮ ಶೀರ್ಷಿಕೆ]\nINTRO: [ಇಲ್ಲಿ ನಿಮ್ಮ ಪರಿಚಯ]', - // Punjabi - pa: 'ਤੁਸੀਂ ਇੱਕ ਸਹਾਇਕ ਹੋ ਜੋ ਟੈਕਸਟਾਂ ਦਾ ਵਿਸ਼ਲੇਸ਼ਣ ਅਤੇ ਸੰਖੇਪ ਕਰਦੇ ਹੋ। ਹੇਠਲੇ ਟੈਕਸਟ ਲਈ ਦੋ ਚੀਜ਼ਾਂ ਬਣਾਉਣਾ ਤੁਹਾਡਾ ਕੰਮ ਹੈ:\n1. ਇੱਕ ਛੋਟੀ, ਸੰਖੇਪ ਸਿਰਲੇਖ (ਵੱਧ ਤੋਂ ਵੱਧ 8 ਸ਼ਬਦ)\n2. ਇੱਕ ਛੋਟੀ ਜਾਣ-ਪਛਾਣ ਜੋ ਟੈਕਸਟ ਦੀ ਸਮੱਗਰੀ ਨੂੰ 2-3 ਵਾਕਾਂ ਵਿੱਚ ਸੰਖੇਪ ਕਰੇ ਅਤੇ ਪਾਠਕ ਵਿੱਚ ਉਤਸੁਕਤਾ ਪੈਦਾ ਕਰੇ\n\nਆਪਣੇ ਜਵਾਬ ਨੂੰ ਬਿਲਕੁਲ ਇਸ ਤਰ੍ਹਾਂ ਫਾਰਮੈਟ ਕਰੋ:\nHEADLINE: [ਇੱਥੇ ਤੁਹਾਡੀ ਸਿਰਲੇਖ]\nINTRO: [ਇੱਥੇ ਤੁਹਾਡੀ ਜਾਣ-ਪਛਾਣ]', - // Afrikaans - af: "Jy is 'n assistent wat tekste ontleed en opsom. Jou taak is om twee dinge vir die volgende teks te skep:\n1. 'n Kort, bondige opskrif (maksimum 8 woorde)\n2. 'n Kort inleiding wat die inhoud van die teks in 2-3 sinne opsom en die leser nuuskierig maak\n\nFormateer jou antwoord presies so:\nHEADLINE: [Jou opskrif hier]\nINTRO: [Jou inleiding hier]", - // Persisch/Farsi - fa: 'شما دستیاری هستید که متون را تجزیه و تحلیل و خلاصه می‌کند. وظیفه شما ایجاد دو چیز برای متن زیر است:\n1. یک عنوان کوتاه و مختصر (حداکثر 8 کلمه)\n2. یک مقدمه کوتاه که محتوای متن را در 2-3 جمله خلاصه کند و کنجکاوی خواننده را برانگیزد\n\nپاسخ خود را دقیقاً به این شکل قالب‌بندی کنید:\nHEADLINE: [عنوان شما اینجا]\nINTRO: [مقدمه شما اینجا]', - // Georgisch - ka: 'თქვენ ხართ ასისტენტი, რომელიც აანალიზებს და აჯამებს ტექსტებს. თქვენი ამოცანაა შემდეგი ტექსტისთვის ორი რამ შექმნათ:\n1. მოკლე, ლაკონური სათაური (მაქსიმუმ 8 სიტყვა)\n2. მოკლე შესავალი, რომელიც აჯამებს ტექსტის შინაარსს 2-3 წინადადებაში და აღძრავს მკითხველის ცნობისმოყვარეობას\n\nგააფორმეთ თქვენი პასუხი ზუსტად ასე:\nHEADLINE: [თქვენი სათაური აქ]\nINTRO: [თქვენი შესავალი აქ]', - // Isländisch - is: 'Þú ert aðstoðarmaður sem greinir og dregur saman texta. Verkefni þitt er að búa til tvö hluti fyrir eftirfarandi texta:\n1. Stuttan, hnitmiðaðan fyrirsögn (að hámarki 8 orð)\n2. Stutta inngang sem dregur saman efni textans í 2-3 setningum og vekur forvitni lesandans\n\nSníðdu svarið þitt nákvæmlega svona:\nHEADLINE: [Fyrirsögnin þín hér]\nINTRO: [Inngangurinn þinn hér]', - // Albanisch - sq: 'Ju jeni një asistent që analizon dhe përmbledh tekste. Detyra juaj është të krijoni dy gjëra për tekstin e mëposhtëm:\n1. Një titull të shkurtër dhe të përqendruar (maksimumi 8 fjalë)\n2. Një hyrje të shkurtër që përmbledh përmbajtjen e tekstit në 2-3 fjali dhe ngjall kuriozitenin e lexuesit\n\nFormatoni përgjigjen tuaj saktësisht kështu:\nHEADLINE: [Titulli juaj këtu]\nINTRO: [Hyrja juaj këtu]', - // Aserbaidschanisch - az: 'Siz mətnləri təhlil edən və xülasə çıxaran köməkçisiniz. Sizin vəzifəniz aşağıdakı mətn üçün iki şey yaratmaqdır:\n1. Qısa, dəqiq başlıq (maksimum 8 söz)\n2. Mətnin məzmununu 2-3 cümlədə xülasə edən və oxucunun marağını oyadan qısa giriş\n\nCavabınızı dəqiq belə formatlaşdırın:\nHEADLINE: [Başlığınız burada]\nINTRO: [Girişiniz burada]', - // Baskisch - eu: 'Testuak aztertzen eta laburbildu egiten dituen laguntzaile bat zara. Zure zeregina honako testuarentzat bi gauza sortzea da:\n1. Izenburua labur eta zehatza (gehienez 8 hitz)\n2. Testuaren edukia 2-3 esalditan laburbiltzen duen eta irakurlearen jakin-mina piztuko duen sarrera laburra\n\nErantzuna zehatz-mehatz honela formateatu:\nHEADLINE: [Zure izenburua hemen]\nINTRO: [Zure sarrera hemen]', - // Galizisch - gl: 'Es un asistente que analiza e resume textos. A túa tarefa é crear dúas cousas para o seguinte texto:\n1. Un título breve e conciso (máximo 8 palabras)\n2. Unha breve introdución que resuma o contido do texto en 2-3 frases e esperte a curiosidade do lector\n\nFormatea a túa resposta exactamente así:\nHEADLINE: [O teu título aquí]\nINTRO: [A túa introdución aquí]', - // Kasachisch - kk: 'Сіз мәтіндерді талдайтын және қорытындылайтын көмекшісіз. Сіздің міндетіңіз келесі мәтін үшін екі нәрсе жасау:\n1. Қысқа, нақты тақырып (ең көбі 8 сөз)\n2. Мәтін мазмұнын 2-3 сөйлемде қорытындылайтын және оқырманның қызығушылығын туғызатын қысқа кіріспе\n\nЖауабыңызды дәл осылай пішімдеңіз:\nHEADLINE: [Мұнда сіздің тақырыбыңыз]\nINTRO: [Мұнда сіздің кіріспеңіз]', - // Mazedonisch - mk: 'Вие сте асистент кој анализира и резимира текстови. Вашата задача е да создадете две работи за следниот текст:\n1. Краток, јасен наслов (максимум 8 зборови)\n2. Краток вовед кој го резимира содржината на текстот во 2-3 реченици и ја буди љубопитноста на читателот\n\nФорматирајте го вашиот одговор точно вака:\nHEADLINE: [Вашиот наслов тука]\nINTRO: [Вашиот вовед тука]', - // Serbisch - sr: 'Ви сте асистент који анализира и резимира текстове. Ваш задатак је да направите две ствари за следећи текст:\n1. Кратак, јасан наслов (максимум 8 речи)\n2. Кратак увод који резимира садржај текста у 2-3 реченице и буди радозналост читаоца\n\nФорматирајте ваш одговор тачно овако:\nHEADLINE: [Ваш наслов овде]\nINTRO: [Ваш увод овде]', - // Slowenisch - sl: 'Ste pomočnik, ki analizira in povzema besedila. Vaša naloga je ustvariti dve stvari za naslednje besedilo:\n1. Kratek, jedrnat naslov (največ 8 besed)\n2. Kratek uvod, ki povzema vsebino besedila v 2-3 stavkih in prebudi radovednost bralca\n\nOblikujte svoj odgovor natanko tako:\nHEADLINE: [Vaš naslov tukaj]\nINTRO: [Vaš uvod tukaj]', - // Maltesisch - mt: "Inti assistent li janalizza u jissommarja testi. Il-kompitu tiegħek huwa li toħloq żewġ affarijiet għat-test li ġej:\n1. Intestatura qasira u konċiza (massimu 8 kliem)\n2. Introduzzjoni qasira li tissommarja l-kontenut tat-test f'2-3 sentenzi u tqajjem il-kurżità tal-qarrej\n\nFormatja t-tweġiba tiegħek eżattament hekk:\nHEADLINE: [L-intestatura tiegħek hawn]\nINTRO: [L-introduzzjoni tiegħek hawn]", - // Armenisch - hy: 'Դուք օգնական եք, որը վերլուծում և ամփոփում է տեքստեր: Ձեր խնդիրն է ստեղծել երկու բան հետևյալ տեքստի համար:\n1. Կարճ, հակիրճ վերնագիր (առավելագույնը 8 բառ)\n2. Կարճ ներածություն, որը ամփոփում է տեքստի բովանդակությունը 2-3 նախադասությամբ և արթնացնում ընթերցողի հետաքրքրությունը\n\nՁևակերպեք ձեր պատասխանը հենց այսպես:\nHEADLINE: [Ձեր վերնագիրը այստեղ]\nINTRO: [Ձեր ներածությունը այստեղ]', - // Usbekisch - uz: "Siz matnlarni tahlil qiluvchi va xulosa chiqaruvchi yordamchisiz. Sizning vazifangiz quyidagi matn uchun ikki narsa yaratishdir:\n1. Qisqa, aniq sarlavha (maksimal 8 so'z)\n2. Matn mazmunini 2-3 jumlada xulosa qiladigan va o'quvchining qiziqishini uyg'otadigan qisqa kirish\n\nJavobingizni aynan shunday formatlang:\nHEADLINE: [Bu yerda sizning sarlavhangiz]\nINTRO: [Bu yerda sizning kirishingiz]", - // Irisch - ga: 'Is cúntóir thú a dhéanann anailís agus achoimre ar théacsanna. Is é do thasc dhá rud a chruthú don téacs seo a leanas:\n1. Ceannlíne ghearr, ghonta (8 bhfocal ar a mhéad)\n2. Réamhrá gearr a dhéanann achoimre ar ábhar an téacs i 2-3 abairt agus a spreagann fiosracht an léitheora\n\nFormáidigh do fhreagra díreach mar seo:\nHEADLINE: [Do cheannlíne anseo]\nINTRO: [Do réamhrá anseo]', - // Walisisch - cy: "Rydych chi'n gynorthwyydd sy'n dadansoddi ac yn crynhoi testunau. Eich tasg yw creu dau beth ar gyfer y testun canlynol:\n1. Pennawd byr, cryno (uchafswm o 8 gair)\n2. Cyflwyniad byr sy'n crynhoi cynnwys y testun mewn 2-3 brawddeg ac yn ennyn chwilfrydedd y darllenydd\n\nFformatiwch eich ateb yn union fel hyn:\nHEADLINE: [Eich pennawd yma]\nINTRO: [Eich cyflwyniad yma]", - // Filipino - fil: 'Ikaw ay isang katulong na nag-aanalisa at bumubuod ng mga teksto. Ang iyong gawain ay lumikha ng dalawang bagay para sa sumusunod na teksto:\n1. Maikling, malinaw na pamagat (hindi hihigit sa 8 salita)\n2. Maikling panimula na bumubuod sa nilalaman ng teksto sa 2-3 pangungusap at nakakagising ng kuryosidad ng mambabasa\n\nI-format ang iyong sagot nang eksakto tulad nito:\nHEADLINE: [Ang iyong pamagat dito]\nINTRO: [Ang iyong panimula dito]', - }, -}; -/** - * Hilfsfunktion zum Abrufen des Headline-Prompts für eine bestimmte Sprache - * @param language Sprache (z.B. 'de', 'en', 'fr') - * @returns Headline-Prompt für die angegebene Sprache oder Fallback - */ export function getHeadlinePrompt(language) { - const lang = language.toLowerCase().split('-')[0]; // z.B. 'de-DE' -> 'de' - // Versuche spezifische Sprache, dann Deutsch, dann Englisch, dann erste verfügbare - return ( - SYSTEM_PROMPTS.headline[lang] || - SYSTEM_PROMPTS.headline['de'] || - SYSTEM_PROMPTS.headline['en'] || - Object.values(SYSTEM_PROMPTS.headline)[0] || - 'You are an assistant that analyzes and summarizes texts.' - ); -} diff --git a/apps/memoro/apps/backend/supabase/functions/headline/index.ts b/apps/memoro/apps/backend/supabase/functions/headline/index.ts deleted file mode 100644 index e243e0961..000000000 --- a/apps/memoro/apps/backend/supabase/functions/headline/index.ts +++ /dev/null @@ -1,508 +0,0 @@ -// Follow this setup guide to integrate the Deno language server with your editor: -// https://deno.land/manual/getting_started/setup_your_environment -// This enables autocomplete, go to definition, etc. -// Setup type definitions for built-in Supabase Runtime APIs -import 'jsr:@supabase/functions-js/edge-runtime.d.ts'; -import { serve } from 'https://deno.land/std@0.215.0/http/server.ts'; -import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'; -import { SYSTEM_PROMPTS } from './constants.ts'; -// Inline error handling utilities to avoid deployment issues -// Atomic status update utilities using RPC to prevent race conditions -async function setMemoErrorStatus(supabaseClient, memoId, processName, error) { - if (!memoId) return; - const errorMessage = error instanceof Error ? error.message : String(error); - const timestamp = new Date().toISOString(); - try { - await supabaseClient.rpc('set_memo_process_error', { - p_memo_id: memoId, - p_process_name: processName, - p_timestamp: timestamp, - p_reason: errorMessage, - p_details: null, - }); - } catch (dbError) { - console.error(`Error setting error status for memo ${memoId}:`, dbError); - } -} -async function setMemoProcessingStatus(supabaseClient, memoId, processName) { - const timestamp = new Date().toISOString(); - try { - await supabaseClient.rpc('set_memo_process_status', { - p_memo_id: memoId, - p_process_name: processName, - p_status: 'processing', - p_timestamp: timestamp, - }); - } catch (dbError) { - console.error(`Error setting processing status for memo ${memoId}:`, dbError); - } -} -async function setMemoCompletedStatus(supabaseClient, memoId, processName, details) { - const timestamp = new Date().toISOString(); - try { - await supabaseClient.rpc('set_memo_process_status_with_details', { - p_memo_id: memoId, - p_process_name: processName, - p_status: 'completed', - p_timestamp: timestamp, - p_details: details, - }); - } catch (dbError) { - console.error(`Error setting completed status for memo ${memoId}:`, dbError); - } -} -function createErrorResponse(error, status = 500, corsHeaders = {}) { - const errorMessage = error instanceof Error ? error.message : String(error); - return new Response( - JSON.stringify({ - error: errorMessage, - timestamp: new Date().toISOString(), - }), - { - headers: { - ...corsHeaders, - 'Content-Type': 'application/json', - }, - status, - } - ); -} -// Umgebungsvariablen -const SUPABASE_URL = Deno.env.get('SUPABASE_URL'); -if (!SUPABASE_URL) { - throw new Error('SUPABASE_URL not configured'); -} -const SERVICE_KEY = Deno.env.get('C_SUPABASE_SECRET_KEY'); -if (!SERVICE_KEY) { - throw new Error('C_SUPABASE_SECRET_KEY not configured'); -} -// Google Gemini Konfiguration -const GEMINI_API_KEY = Deno.env.get('CREATE_HEADLINE_GEMINI_MEMORO') || ''; -const GEMINI_MODEL = 'gemini-2.0-flash'; -const GEMINI_ENDPOINT = 'https://generativelanguage.googleapis.com/v1beta/models'; -// Azure OpenAI Konfiguration (Backup) -const AZURE_OPENAI_ENDPOINT = 'https://memoroseopenai.openai.azure.com'; -const AZURE_OPENAI_KEY = Deno.env.get('AZURE_OPENAI_KEY'); -if (!AZURE_OPENAI_KEY) { - throw new Error('AZURE_OPENAI_KEY not configured'); -} -const AZURE_OPENAI_DEPLOYMENT = 'gpt-4.1-mini-se'; -const AZURE_OPENAI_API_VERSION = '2025-01-01-preview'; -// Supabase-Client -const memoro_sb = createClient(SUPABASE_URL, SERVICE_KEY); -// ===== PROMPT HELPER FUNCTIONS ===== -/** - * Hilfsfunktion zum Abrufen des richtigen Prompts basierend auf der Sprache - * - * @param type - Der Typ des Prompts (z.B. 'headline') - * @param language - Der Sprachcode (z.B. 'de', 'en') - * @returns Der Prompt in der angegebenen Sprache oder der deutsche Prompt als Fallback - */ function getSystemPrompt(type, language) { - // Extrahiere den Basis-Sprachcode (z.B. 'de-DE' -> 'de') - const baseLanguage = language.split('-')[0].toLowerCase(); - // Prüfe, ob der Prompt-Typ existiert - if (!SYSTEM_PROMPTS[type]) { - console.warn(`Prompt-Typ '${type}' nicht gefunden. Verwende 'headline' als Fallback.`); - return SYSTEM_PROMPTS.headline.de; // Fallback auf deutschen Headline-Prompt - } - // Prüfe, ob die Sprache existiert - if (!SYSTEM_PROMPTS[type][baseLanguage]) { - console.warn( - `Sprache '${baseLanguage}' für Prompt-Typ '${type}' nicht gefunden. Verwende 'de' als Fallback.` - ); - return SYSTEM_PROMPTS[type].de; // Fallback auf Deutsch - } - return SYSTEM_PROMPTS[type][baseLanguage]; -} -// ===== PROMPT FUNCTIONS ===== -/** - * Generiert einen Headline-Prompt für die angegebene Sprache - * - * @param language - Der Sprachcode (z.B. 'de', 'en') - * @param text - Der zu analysierende Text - * @returns Der vollständige Prompt für die Headline-Generierung - */ function getHeadlinePrompt(language, text) { - // Hole den System-Prompt für die angegebene Sprache - const systemPrompt = getSystemPrompt('headline', language); - // Kombiniere den System-Prompt mit dem Text - return `${systemPrompt}\n\n${text}`; -} -/** - * Extrahiert die Headline und das Intro aus der Antwort des LLM - * - * @param content - Die Antwort des LLM - * @returns Ein Objekt mit Headline und Intro oder Standardwerte bei Fehlern - */ function extractHeadlineAndIntro(content) { - // Extrahiere Headline und Intro aus der Antwort - const headlineMatch = content.match(/HEADLINE:\s*(.+?)(?=\nINTRO:|$)/s); - const introMatch = content.match(/INTRO:\s*(.+?)$/s); - // Fallback-Werte, falls keine Übereinstimmung gefunden wurde - const headline = headlineMatch?.[1]?.trim() || 'Neue Aufnahme'; - const intro = introMatch?.[1]?.trim() || 'Keine Zusammenfassung verfügbar.'; - return { - headline, - intro, - }; -} -// ===== HEADLINE GENERATION FUNCTIONS ===== -/** - * Generiert eine Überschrift und ein Intro für einen Text mithilfe von Google Gemini Flash - * - * @param text - Der Text, für den eine Überschrift und ein Intro generiert werden soll - * @returns Ein Objekt mit der generierten Überschrift und dem Intro oder null bei Fehlern - */ async function generateHeadlineWithGemini(text, language = 'de') { - try { - // Hole den passenden Prompt basierend auf der erkannten Sprache - const prompt = getHeadlinePrompt(language, text); - // Log prompt for debugging - console.log('Gemini prompt:', { - language, - textLength: text.length, - promptLength: prompt.length, - promptPreview: prompt.substring(0, 300) + (prompt.length > 300 ? '...' : ''), - }); - const response = await fetch( - `${GEMINI_ENDPOINT}/${GEMINI_MODEL}:generateContent?key=${GEMINI_API_KEY}`, - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - contents: [ - { - parts: [ - { - text: prompt, - }, - ], - }, - ], - generationConfig: { - temperature: 0.7, - maxOutputTokens: 300, - }, - }), - } - ); - if (!response.ok) { - const errorText = await response.text(); - console.error('Gemini API Fehler:', errorText); - throw new Error(`Gemini API Fehler: ${response.status} ${errorText}`); - } - const data = await response.json(); - const content = data.candidates?.[0]?.content?.parts?.[0]?.text?.trim() || ''; - // Log AI response for debugging - console.log('Gemini API Response:', { - status: response.status, - contentLength: content.length, - content: content.substring(0, 200) + (content.length > 200 ? '...' : ''), - fullResponse: JSON.stringify(data, null, 2), - }); - // Extrahiere Headline und Intro aus der Antwort - const result = extractHeadlineAndIntro(content); - if (!result.headline || !result.intro) { - console.error('Gemini-Antwort hat nicht das erwartete Format:', content); - return null; - } - console.log('Gemini parsed result:', result); - return result; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : 'Unbekannter Fehler'; - console.error('Fehler bei der Gemini Headline/Intro-Generierung:', errorMessage); - return null; - } -} -/** - * Generiert eine Überschrift und ein Intro für einen Text mithilfe von Azure OpenAI - * - * @param text - Der Text, für den eine Überschrift und ein Intro generiert werden soll - * @returns Ein Objekt mit der generierten Überschrift und dem Intro oder Fallback-Werte bei Fehlern - */ async function generateHeadlineWithAzure(text, language = 'de') { - try { - // Hole den passenden Prompt basierend auf der erkannten Sprache - const prompt = getHeadlinePrompt(language, text); - // Log prompt for debugging - console.log('Azure OpenAI prompt:', { - language, - textLength: text.length, - promptLength: prompt.length, - promptPreview: prompt.substring(0, 300) + (prompt.length > 300 ? '...' : ''), - }); - const response = await fetch( - `${AZURE_OPENAI_ENDPOINT}/openai/deployments/${AZURE_OPENAI_DEPLOYMENT}/chat/completions?api-version=${AZURE_OPENAI_API_VERSION}`, - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'api-key': AZURE_OPENAI_KEY, - }, - body: JSON.stringify({ - messages: [ - { - role: 'user', - content: prompt, - }, - ], - max_tokens: 300, - temperature: 0.7, - }), - } - ); - if (!response.ok) { - const errorText = await response.text(); - console.error('Azure OpenAI API Fehler:', errorText); - throw new Error(`Azure OpenAI API Fehler: ${response.status} ${errorText}`); - } - const data = await response.json(); - const content = data.choices[0]?.message?.content?.trim() || ''; - // Log AI response for debugging - console.log('Azure OpenAI API Response:', { - status: response.status, - contentLength: content.length, - content: content.substring(0, 200) + (content.length > 200 ? '...' : ''), - fullResponse: JSON.stringify(data, null, 2), - }); - // Extrahiere Headline und Intro aus der Antwort - const result = extractHeadlineAndIntro(content); - console.log('Azure OpenAI parsed result:', result); - return result; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : 'Unbekannter Fehler'; - console.error('Fehler bei der Azure Headline/Intro-Generierung:', errorMessage); - return { - headline: 'Neue Aufnahme', - intro: 'Keine Zusammenfassung verfügbar.', // Fallback-Intro - }; - } -} -/** - * Hauptfunktion zur Generierung von Headline und Intro - * Versucht zuerst Gemini Flash und fällt bei Fehler auf Azure OpenAI zurück - * - * @param text - Der Text, für den eine Überschrift und ein Intro generiert werden soll - * @returns Ein Objekt mit der generierten Überschrift und dem Intro - */ async function generateHeadlineAndIntro(text, language = 'de') { - try { - // Zuerst mit Gemini versuchen - const geminiResult = await generateHeadlineWithGemini(text, language); - // Wenn Gemini erfolgreich war, Ergebnis zurückgeben - if (geminiResult) { - console.debug('Headline mit Gemini Flash generiert'); - return geminiResult; - } - // Sonst auf Azure OpenAI zurückfallen - console.debug('Fallback auf Azure OpenAI für Headline-Generierung'); - return await generateHeadlineWithAzure(text, language); - } catch (error) { - const errorMessage = error instanceof Error ? error.message : 'Unbekannter Fehler'; - console.error('Fehler bei der Headline/Intro-Generierung:', errorMessage); - return { - headline: 'Neue Aufnahme', - intro: 'Keine Zusammenfassung verfügbar.', // Fallback-Intro - }; - } -} -// Hauptfunktion - ohne JWT-Verifizierung für Datenbank-Trigger -serve(async (req) => { - // CORS-Header für Entwicklung - const corsHeaders = { - 'Access-Control-Allow-Origin': '*', - 'Access-Control-Allow-Methods': 'POST, OPTIONS', - 'Access-Control-Allow-Headers': 'Content-Type, Authorization', - }; - // OPTIONS-Anfrage für CORS - if (req.method === 'OPTIONS') { - return new Response(null, { - headers: corsHeaders, - status: 204, - }); - } - let memo_id_to_update = null; - try { - // Anfrage-Daten extrahieren - const requestData = await req.json(); - const { memo_id } = requestData; - memo_id_to_update = memo_id; - if (!memo_id) { - return createErrorResponse('memo_id ist erforderlich', 400, corsHeaders); - } - // Set processing status - await setMemoProcessingStatus(memoro_sb, memo_id, 'headline_and_intro'); - // Memo aus der Datenbank abrufen - const { data: memo, error: memoError } = await memoro_sb - .from('memos') - .select('*') - .eq('id', memo_id) - .single(); - if (memoError || !memo) { - console.error('Fehler beim Abrufen des Memos:', memoError); - await setMemoErrorStatus( - memoro_sb, - memo_id, - 'headline_and_intro', - `Memo nicht gefunden: ${memoError?.message || 'Unbekannter Fehler'}` - ); - return createErrorResponse( - `Memo nicht gefunden: ${memoError?.message || 'Unbekannter Fehler'}`, - 404, - corsHeaders - ); - } - let transcript = ''; - // Generate transcript from utterances if available - if ( - memo.source?.utterances && - Array.isArray(memo.source.utterances) && - memo.source.utterances.length > 0 - ) { - // Sort utterances by offset if available and concatenate texts - const sortedUtterances = [...memo.source.utterances].sort((a, b) => { - const offsetA = a.offset || 0; - const offsetB = b.offset || 0; - return offsetA - offsetB; - }); - transcript = sortedUtterances - .map((utterance) => utterance.text) - .filter((text) => text && text.trim() !== '') - .join(' '); - } else if (memo.transcript) { - transcript = memo.transcript; - } else if (memo.source?.transcript) { - transcript = memo.source.transcript; - } else if (memo.source?.content) { - transcript = memo.source.content; - } else if (memo.source?.type === 'combined' && memo.source?.additional_recordings) { - transcript = memo.source.additional_recordings - .map((recording) => { - // Try to get transcript from utterances first - if (recording.utterances && Array.isArray(recording.utterances)) { - const sortedUtterances = [...recording.utterances].sort((a, b) => { - const offsetA = a.offset || 0; - const offsetB = b.offset || 0; - return offsetA - offsetB; - }); - return sortedUtterances - .map((utterance) => utterance.text) - .filter((text) => text && text.trim() !== '') - .join(' '); - } - // Fallback to transcript field - return recording.transcript || ''; - }) - .filter(Boolean) - .join('\n\n'); - } - // Ermittle die Sprache des Transkripts - let language = 'de'; // Standard: Deutsch - if (memo.source?.primary_language) { - language = memo.source.primary_language; - console.debug(`Primäre Sprache aus Memo-Quelle erkannt: ${language}`); - } else if ( - memo.source?.languages && - Array.isArray(memo.source.languages) && - memo.source.languages.length > 0 - ) { - language = memo.source.languages[0]; - console.debug(`Sprache aus Memo-Sprachen-Array erkannt: ${language}`); - } else if (memo.metadata?.primary_language) { - language = memo.metadata.primary_language; - console.debug(`Primäre Sprache aus Memo-Metadaten erkannt: ${language}`); - } - console.log(`Verwende Sprache für Headline-Generierung: ${language}`); - if (!transcript) { - console.error('Kein Transkript im Memo gefunden'); - await setMemoErrorStatus( - memoro_sb, - memo_id, - 'headline_and_intro', - 'Kein Transkript im Memo gefunden' - ); - return createErrorResponse('Kein Transkript im Memo gefunden', 400, corsHeaders); - } - // Headline und Intro generieren - const { headline, intro } = await generateHeadlineAndIntro(transcript, language); - // First get the current memo state for the 'old' value in the broadcast - const oldMemo = { - ...memo, - }; - // Update memo normally - const { error: updateError } = await memoro_sb - .from('memos') - .update({ - title: headline, - intro: intro, - updated_at: new Date().toISOString(), - }) - .eq('id', memo_id); - if (updateError) { - console.error('Fehler beim Aktualisieren des Memos:', updateError); - await setMemoErrorStatus(memoro_sb, memo_id, 'headline_and_intro', updateError); - throw updateError; - } - // Log the update for debugging - console.log('Headline generated and memo updated:', { - memo_id, - old_title: oldMemo.title, - new_title: headline, - user_id: memo.user_id, - }); - // Send broadcast update to notify clients about the title change - try { - const channel = memoro_sb.channel(`memo-updates-${memo_id}`); - // Subscribe first to ensure the channel is ready - channel.subscribe(async (status) => { - if (status === 'SUBSCRIBED') { - await channel.send({ - type: 'broadcast', - event: 'memo-updated', - payload: { - type: 'memo-updated', - memoId: memo_id, - changes: { - title: headline, - intro: intro, - updated_at: new Date().toISOString(), - }, - source: 'headline-edge-function', - }, - }); - console.log(`Broadcast sent for memo ${memo_id} title update`); - // Clean up the channel after sending - memoro_sb.removeChannel(channel); - } - }); - } catch (broadcastError) { - console.warn('Failed to send broadcast update:', broadcastError); - // Don't fail the function if broadcast fails - } - // Set completed status - await setMemoCompletedStatus(memoro_sb, memo_id, 'headline_and_intro', { - headline, - intro, - language, - }); - // Erfolgreiche Antwort - return new Response( - JSON.stringify({ - success: true, - headline: headline, - intro: intro, - }), - { - headers: { - ...corsHeaders, - 'Content-Type': 'application/json', - }, - status: 200, - } - ); - } catch (error) { - console.error('Unerwarteter Fehler in der Headline-Funktion:', error); - // Set error status in database - const errorToLog = error instanceof Error ? error : new Error(String(error)); - await setMemoErrorStatus(memoro_sb, memo_id_to_update, 'headline_and_intro', errorToLog); - // Return error response - return createErrorResponse(`Unerwarteter Fehler: ${errorToLog.message}`, 500, corsHeaders); - } -}); diff --git a/apps/memoro/apps/backend/supabase/functions/manage-spaces/index.ts b/apps/memoro/apps/backend/supabase/functions/manage-spaces/index.ts deleted file mode 100644 index 2cdee9736..000000000 --- a/apps/memoro/apps/backend/supabase/functions/manage-spaces/index.ts +++ /dev/null @@ -1,271 +0,0 @@ -import { serve } from 'https://deno.land/std@0.177.0/http/server.ts'; -import { createClient } from 'https://esm.sh/@supabase/supabase-js@2.38.4'; -// Express backend URL -const EXPRESS_BACKEND_URL = Deno.env.get('EXPRESS_BACKEND_URL'); -serve(async (req) => { - try { - console.log('Manage-Spaces Function called'); - // Create a Supabase client with the service role key - const supabaseUrl = Deno.env.get('SUPABASE_URL'); - const supabaseServiceRoleKey = Deno.env.get('C_SUPABASE_SECRET_KEY'); - if (!supabaseUrl || !supabaseServiceRoleKey) { - console.error('Supabase environment variables not found'); - return new Response( - JSON.stringify({ - error: 'Supabase credentials not found', - }), - { - status: 500, - headers: { - 'Content-Type': 'application/json', - }, - } - ); - } - if (!EXPRESS_BACKEND_URL) { - console.error('EXPRESS_BACKEND_URL environment variable not found'); - return new Response( - JSON.stringify({ - error: 'Express backend URL not found', - }), - { - status: 500, - headers: { - 'Content-Type': 'application/json', - }, - } - ); - } - const supabaseClient = createClient(supabaseUrl, supabaseServiceRoleKey); - // Parse request body - const { action, space, token } = await req.json(); - if (!action || !space || !token) { - return new Response( - JSON.stringify({ - error: 'Action, space details, and token are required', - }), - { - status: 400, - headers: { - 'Content-Type': 'application/json', - }, - } - ); - } - // Validate request based on action - if (action === 'create' && (!space.name || !space.appId)) { - return new Response( - JSON.stringify({ - error: 'For create action, name and appId are required', - }), - { - status: 400, - headers: { - 'Content-Type': 'application/json', - }, - } - ); - } - if ((action === 'update' || action === 'delete') && !space.id) { - return new Response( - JSON.stringify({ - error: 'For update or delete action, space id is required', - }), - { - status: 400, - headers: { - 'Content-Type': 'application/json', - }, - } - ); - } - // Step 1: Call Express backend to perform the action - let expressResult; - let expressUrl; - let expressMethod; - let expressBody; - switch (action) { - case 'create': - expressUrl = `${EXPRESS_BACKEND_URL}/api/spaces`; - expressMethod = 'POST'; - expressBody = JSON.stringify({ - name: space.name, - appId: space.appId, - }); - break; - case 'update': - expressUrl = `${EXPRESS_BACKEND_URL}/api/spaces/${space.id}`; - expressMethod = 'PUT'; - expressBody = JSON.stringify({ - name: space.name, - }); - break; - case 'delete': - expressUrl = `${EXPRESS_BACKEND_URL}/api/spaces/${space.id}`; - expressMethod = 'DELETE'; - expressBody = null; - break; - default: - return new Response( - JSON.stringify({ - error: 'Invalid action', - }), - { - status: 400, - headers: { - 'Content-Type': 'application/json', - }, - } - ); - } - const expressResponse = await fetch(expressUrl, { - method: expressMethod, - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${token}`, - }, - body: expressBody, - }); - if (!expressResponse.ok) { - const errorText = await expressResponse.text(); - console.error(`Express backend error (${action}):`, errorText); - return new Response( - JSON.stringify({ - error: `Error from Express backend: ${errorText}`, - }), - { - status: expressResponse.status, - headers: { - 'Content-Type': 'application/json', - }, - } - ); - } - expressResult = await expressResponse.json(); - // Step 2: Update local Supabase database based on the action - let supabaseResult; - switch (action) { - case 'create': - // Get user information from the auth token - const { data: user, error: userError } = await supabaseClient.auth.getUser(token); - if (userError) { - console.error('Error getting user from token:', userError); - return new Response( - JSON.stringify({ - error: `Error getting user: ${userError.message}`, - }), - { - status: 401, - headers: { - 'Content-Type': 'application/json', - }, - } - ); - } - // Use spaceId returned from express backend - const spaceId = expressResult.spaceId; - if (!spaceId) { - return new Response( - JSON.stringify({ - error: 'Express backend did not return a space ID', - }), - { - status: 500, - headers: { - 'Content-Type': 'application/json', - }, - } - ); - } - // Create the space in local Supabase - const { data: localSpace, error: insertError } = await supabaseClient - .from('spaces') - .insert({ - id: spaceId, - name: space.name, - description: space.description || '', - color: space.color || '#4CAF50', - user_id: user.user.id, - is_default: false, - }) - .select() - .single(); - if (insertError) { - console.error('Error creating space in Supabase:', insertError); - // Continue anyway since Express backend operation succeeded - supabaseResult = { - warning: `Local database update failed: ${insertError.message}`, - }; - } else { - supabaseResult = localSpace; - } - break; - case 'update': - // Update the space in local Supabase - const { data: updatedSpace, error: updateError } = await supabaseClient - .from('spaces') - .update({ - name: space.name, - description: space.description, - color: space.color, - updated_at: new Date().toISOString(), - }) - .eq('id', space.id) - .select() - .single(); - if (updateError) { - console.error('Error updating space in Supabase:', updateError); - supabaseResult = { - warning: `Local database update failed: ${updateError.message}`, - }; - } else { - supabaseResult = updatedSpace; - } - break; - case 'delete': - // Delete the space in local Supabase - const { error: deleteError } = await supabaseClient - .from('spaces') - .delete() - .eq('id', space.id); - if (deleteError) { - console.error('Error deleting space in Supabase:', deleteError); - supabaseResult = { - warning: `Local database delete failed: ${deleteError.message}`, - }; - } else { - supabaseResult = { - success: true, - }; - } - break; - } - // Return success response - return new Response( - JSON.stringify({ - success: true, - action, - expressResult, - localResult: supabaseResult, - }), - { - headers: { - 'Content-Type': 'application/json', - }, - } - ); - } catch (error) { - console.error('Unexpected error:', error); - return new Response( - JSON.stringify({ - error: `Unexpected error: ${error.message}`, - }), - { - status: 500, - headers: { - 'Content-Type': 'application/json', - }, - } - ); - } -}); diff --git a/apps/memoro/apps/backend/supabase/functions/question-memo/constants.ts b/apps/memoro/apps/backend/supabase/functions/question-memo/constants.ts deleted file mode 100644 index 30768cb0e..000000000 --- a/apps/memoro/apps/backend/supabase/functions/question-memo/constants.ts +++ /dev/null @@ -1,75 +0,0 @@ -/** - * System-Prompts für die Question-Memo-Funktion in verschiedenen Sprachen - * - * Die Prompts werden als System-Prompt für die AI-Nachrichten verwendet, - * um konsistente und hilfreiche Antworten bei der Fragenbeantwortung zu generieren. - */ /** - * Interface für die Prompt-Konfiguration - */ /** - * System-Prompts für die Question-Memo-Verarbeitung - * - * Unterstützte Sprachen: - * - de: Deutsch - * - en: Englisch - * - fr: Französisch - * - es: Spanisch - * - it: Italienisch - * - nl: Niederländisch - * - pt: Portugiesisch - * - ru: Russisch - * - ja: Japanisch - * - ko: Koreanisch - * - zh: Chinesisch - * - ar: Arabisch - * - hi: Hindi - * - tr: Türkisch - * - pl: Polnisch - */ export const SYSTEM_PROMPTS = { - system: { - // Deutsch - de: 'DU bist ein aufmerksamer Texter. ', - // Englisch - en: 'You are a helpful assistant that answers questions based on conversation transcripts. Your task is to provide precise and relevant answers to user questions by using the information from the provided transcript. Answer directly and factually. If the answer cannot be found in the transcript, politely indicate this.', - // Französisch - fr: 'Vous êtes un assistant utile qui répond aux questions basées sur des transcriptions de conversations. Votre tâche est de fournir des réponses précises et pertinentes aux questions des utilisateurs en utilisant les informations de la transcription fournie. Répondez directement et factuellement. Si la réponse ne peut pas être trouvée dans la transcription, indiquez-le poliment.', - // Spanisch - es: 'Eres un asistente útil que responde preguntas basadas en transcripciones de conversaciones. Tu tarea es proporcionar respuestas precisas y relevantes a las preguntas de los usuarios utilizando la información de la transcripción proporcionada. Responde de forma directa y objetiva. Si la respuesta no se puede encontrar en la transcripción, indícalo cortésmente.', - // Italienisch - it: 'Sei un assistente utile che risponde a domande basate su trascrizioni di conversazioni. Il tuo compito è fornire risposte precise e pertinenti alle domande degli utenti utilizzando le informazioni della trascrizione fornita. Rispondi in modo diretto e fattuale. Se la risposta non può essere trovata nella trascrizione, indicalo cortesemente.', - // Niederländisch - nl: 'Je bent een behulpzame assistent die vragen beantwoordt op basis van gesprekstranscripties. Je taak is om precieze en relevante antwoorden te geven op gebruikersvragen door de informatie uit de verstrekte transcriptie te gebruiken. Antwoord direct en feitelijk. Als het antwoord niet in de transcriptie te vinden is, geef dit dan beleefd aan.', - // Portugiesisch - pt: 'Você é um assistente útil que responde perguntas com base em transcrições de conversas. Sua tarefa é fornecer respostas precisas e relevantes às perguntas dos usuários usando as informações da transcrição fornecida. Responda de forma direta e factual. Se a resposta não puder ser encontrada na transcrição, indique isso educadamente.', - // Russisch - ru: 'Вы полезный помощник, который отвечает на вопросы на основе расшифровок разговоров. Ваша задача - предоставлять точные и актуальные ответы на вопросы пользователей, используя информацию из предоставленной расшифровки. Отвечайте прямо и по существу. Если ответ не может быть найден в расшифровке, вежливо укажите на это.', - // Japanisch - ja: 'あなたは会話の転写に基づいて質問に答える有用なアシスタントです。あなたの仕事は、提供された転写の情報を使用して、ユーザーの質問に正確で関連性のある回答を提供することです。直接的かつ事実に基づいて回答してください。転写に答えが見つからない場合は、丁寧にそのことを伝えてください。', - // Koreanisch - ko: '당신은 대화 전사본을 기반으로 질문에 답하는 유용한 어시스턴트입니다. 당신의 임무는 제공된 전사본의 정보를 사용하여 사용자 질문에 정확하고 관련성 있는 답변을 제공하는 것입니다. 직접적이고 사실적으로 답변하세요. 전사본에서 답을 찾을 수 없는 경우 정중하게 알려주세요.', - // Chinesisch (vereinfacht) - zh: '你是一个有用的助手,根据对话转录回答问题。你的任务是使用提供的转录中的信息,为用户的问题提供准确和相关的答案。请直接且基于事实回答。如果在转录中找不到答案,请礼貌地说明。', - // Arabisch - ar: 'أنت مساعد مفيد يجيب على الأسئلة بناءً على نسخ المحادثات. مهمتك هي تقديم إجابات دقيقة وذات صلة لأسئلة المستخدمين باستخدام المعلومات من النسخ المقدمة. أجب بشكل مباشر وواقعي. إذا لم يمكن العثور على الإجابة في النسخ، فأشر إلى ذلك بأدب.', - // Hindi - hi: 'आप एक उपयोगी सहायक हैं जो बातचीत के प्रतिलेख के आधार पर प्रश्नों का उत्तर देते हैं। आपका कार्य प्रदान किए गए प्रतिलेख की जानकारी का उपयोग करके उपयोगकर्ता के प्रश्नों के लिए सटीक और प्रासंगिक उत्तर प्रदान करना है। सीधे और तथ्यात्मक रूप से उत्तर दें। यदि प्रतिलेख में उत्तर नहीं मिल सकता है, तो विनम्रता से इसे इंगित करें।', - // Türkisch - tr: 'Konuşma transkriptlerine dayalı olarak soruları yanıtlayan yararlı bir asistansınız. Göreviniz, sağlanan transkriptteki bilgileri kullanarak kullanıcı sorularına kesin ve ilgili yanıtlar vermektir. Doğrudan ve olgusal olarak yanıt verin. Yanıt transkriptte bulunamazsa, bunu kibarca belirtin.', - // Polnisch - pl: 'Jesteś pomocnym asystentem, który odpowiada na pytania na podstawie transkrypcji rozmów. Twoim zadaniem jest udzielanie precyzyjnych i trafnych odpowiedzi na pytania użytkowników, korzystając z informacji z dostarczonej transkrypcji. Odpowiadaj bezpośrednio i rzeczowo. Jeśli odpowiedzi nie można znaleźć w transkrypcji, uprzejmie to wskaż.', - }, -}; -/** - * Hilfsfunktion zum Abrufen des System-Prompts für eine bestimmte Sprache - * @param language Sprache (z.B. 'de', 'en', 'fr') - * @returns System-Prompt für die angegebene Sprache oder Fallback - */ export function getSystemPrompt(language) { - const lang = language.toLowerCase().split('-')[0]; // z.B. 'de-DE' -> 'de' - // Versuche spezifische Sprache, dann Deutsch, dann Englisch, dann erste verfügbare - return ( - SYSTEM_PROMPTS.system[lang] || - SYSTEM_PROMPTS.system['de'] || - SYSTEM_PROMPTS.system['en'] || - Object.values(SYSTEM_PROMPTS.system)[0] || - 'You are a helpful assistant.' - ); -} diff --git a/apps/memoro/apps/backend/supabase/functions/question-memo/index.ts b/apps/memoro/apps/backend/supabase/functions/question-memo/index.ts deleted file mode 100644 index 2e26fa344..000000000 --- a/apps/memoro/apps/backend/supabase/functions/question-memo/index.ts +++ /dev/null @@ -1,607 +0,0 @@ -// Follow this setup guide to integrate the Deno language server with your editor: -// https://deno.land/manual/getting_started/setup_your_environment -// This enables autocomplete, go to definition, etc. -// Setup type definitions for built-in Supabase Runtime APIs -import 'jsr:@supabase/functions-js/edge-runtime.d.ts'; -import { serve } from 'https://deno.land/std@0.215.0/http/server.ts'; -import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'; -import { getSystemPrompt } from './constants.ts'; -import { ROOT_SYSTEM_PROMPTS } from '../_shared/system-prompt.ts'; -/** - * Question Memo Edge Function - * - * Diese Funktion nimmt eine Benutzerfrage und ein Memo-Transkript entgegen, - * sendet beides an Gemini API und erstellt eine neue Memory mit der Antwort. - * - * @version 1.0.0 - * @date 2025-05-23 - */ // ─── Umgebungsvariablen ────────────────────────────────────────────── -const SUPABASE_URL = Deno.env.get('SUPABASE_URL'); -if (!SUPABASE_URL) { - throw new Error('SUPABASE_URL not configured'); -} -const SERVICE_KEY = Deno.env.get('C_SUPABASE_SECRET_KEY'); -if (!SERVICE_KEY) { - throw new Error('C_SUPABASE_SECRET_KEY not configured'); -} -// Google Gemini Konfiguration -const GEMINI_API_KEY = Deno.env.get('QUESTION_MEMO_GEMINI_MEMORO') || ''; -const GEMINI_MODEL = 'gemini-2.0-flash'; -const GEMINI_ENDPOINT = 'https://generativelanguage.googleapis.com/v1beta/models'; -// Azure OpenAI Konfiguration (Backup) -const AZURE_OPENAI_ENDPOINT = 'https://memoroseopenai.openai.azure.com'; -const AZURE_OPENAI_KEY = Deno.env.get('AZURE_OPENAI_KEY'); -if (!AZURE_OPENAI_KEY) { - throw new Error('AZURE_OPENAI_KEY not configured'); -} -const AZURE_OPENAI_DEPLOYMENT = 'gpt-4.1-mini-se'; -const AZURE_OPENAI_API_VERSION = '2025-01-01-preview'; -const memoro_sb = createClient(SUPABASE_URL, SERVICE_KEY); -// ─── Logging-Funktion ────────────────────────────────────────────── -/** - * Erweiterte Logging-Funktion mit Zeitstempel und Log-Level - */ function log(level, message, data) { - const timestamp = new Date().toISOString(); - const logMessage = `[${timestamp}] [${level.toUpperCase()}] ${message}`; - switch (level.toUpperCase()) { - case 'INFO': - console.log(logMessage); - break; - case 'DEBUG': - console.debug(logMessage); - break; - case 'WARN': - console.warn(logMessage); - break; - case 'ERROR': - console.error(logMessage); - break; - default: - console.log(logMessage); - break; - } - if (data) { - if (level.toUpperCase() === 'ERROR') { - console.error(data); - } else { - console.log(typeof data === 'object' ? JSON.stringify(data, null, 2) : data); - } - } -} -/** - * Formatiert Transkript mit Speaker-Informationen für besseren Kontext - */ function formatTranscriptWithSpeakers(source) { - // Handle combined memos with additional_recordings - if ( - source.type === 'combined' && - source.additional_recordings && - Array.isArray(source.additional_recordings) - ) { - const transcripts = source.additional_recordings - .map((recording, index) => { - let recordingTranscript = ''; - // Extract transcript from each recording - if (recording.utterances && Array.isArray(recording.utterances)) { - // If recording has utterances, format with speakers if available - if (recording.speakers) { - recordingTranscript = recording.utterances - .map((utterance) => { - const speakerName = recording.speakers[utterance.speakerId] || utterance.speakerId; - return `${speakerName}: ${utterance.text}`; - }) - .join('\n'); - } else { - // No speaker info, just join utterances - recordingTranscript = recording.utterances.map((u) => u.text).join(' '); - } - } else if (recording.transcript) { - // Fallback to transcript field - recordingTranscript = recording.transcript; - } else if (recording.content) { - // Fallback to content field - recordingTranscript = recording.content; - } else if (recording.transcription) { - // Fallback to transcription field - recordingTranscript = recording.transcription; - } - return recordingTranscript; - }) - .filter(Boolean); - // Join all transcripts with a separator - if (transcripts.length > 0) { - return transcripts.join('\n\n--- Nächstes Memo ---\n\n'); - } - } - // Handle regular memos with utterances and speakers - if (source.utterances && source.speakers) { - return source.utterances - .map((utterance) => { - const speakerName = source.speakers[utterance.speakerId] || utterance.speakerId; - return `${speakerName}: ${utterance.text}`; - }) - .join('\n'); - } - // Fallback to other transcript fields - return source.transcript || source.content || source.transcription || ''; -} -/** - * Extrahiert erweiterte Kontext-Informationen aus dem Memo-Source und Metadaten - */ function extractContextInfo(source, metadata = {}) { - const transcript = formatTranscriptWithSpeakers(source); - // For combined memos, aggregate speaker count and duration from all recordings - let speakerCount = 0; - let totalDuration = 0; - let language = source.primary_language || source.languages?.[0] || 'unbekannt'; - if (source.type === 'combined' && source.additional_recordings) { - // Collect all unique speakers across all recordings - const allSpeakers = new Set(); - source.additional_recordings.forEach((recording) => { - if (recording.speakers) { - Object.keys(recording.speakers).forEach((speakerId) => allSpeakers.add(speakerId)); - } - // Sum up durations - if (recording.duration) { - totalDuration += recording.duration; - } - }); - speakerCount = allSpeakers.size; - // Use the combined memo's duration if available, otherwise use sum - totalDuration = source.duration || totalDuration; - } else { - // Regular memo - speakerCount = source.speakers ? Object.keys(source.speakers).length : 0; - totalDuration = source.duration || 0; - } - // Location aus Metadaten extrahieren - const locationName = metadata.location?.address?.name || null; - const locationAddress = metadata.location?.address?.formattedAddress || null; - // Stats aus Metadaten extrahieren - const wordCount = metadata.stats?.wordCount || null; - const audioDuration = metadata.stats?.audioDuration || totalDuration; - return { - transcript, - duration: audioDuration, - speakerCount, - wordCount, - language, - locationName, - locationAddress, - hasMultipleSpeakers: speakerCount > 1, - hasLocation: !!(locationName || locationAddress), - }; -} -/** - * Sendet Benutzerfrage + Transkript an Gemini und gibt die Antwort zurück - */ async function askQuestionWithGemini( - question, - contextInfo, - language = 'de', - functionIdForLog = 'global' -) { - const requestId = crypto.randomUUID().substring(0, 8); - log('INFO', `[${functionIdForLog}][LLM-${requestId}] Starte Gemini-Anfrage für Frage.`); - try { - // Kontext-Informationen zusammenstellen - const contextParts = []; - // Location hinzufügen falls verfügbar - if (contextInfo.hasLocation) { - if (contextInfo.locationName) { - contextParts.push(`Aufnahmeort: ${contextInfo.locationName}`); - } else if (contextInfo.locationAddress) { - contextParts.push(`Aufnahmeort: ${contextInfo.locationAddress}`); - } - } - // Audio-Stats hinzufügen - const statsInfo = []; - if (contextInfo.hasMultipleSpeakers) { - statsInfo.push(`${contextInfo.speakerCount} Sprecher`); - } - statsInfo.push(`${Math.round(contextInfo.duration)}s Dauer`); - if (contextInfo.wordCount) { - statsInfo.push(`${contextInfo.wordCount} Wörter`); - } - contextParts.push(`Audio-Info: ${statsInfo.join(', ')}`); - const contextFooter = - contextParts.length > 0 - ? `\n\nZusätzliche Kontext-Informationen:\n${contextParts.join('\n')}` - : ''; - const systemPrompt = getSystemPrompt(language); - const userPrompt = `Frage: ${question} - -Transkript: -${contextInfo.transcript}${contextFooter} - -${contextInfo.hasMultipleSpeakers ? 'Du kannst bei Bedarf auf spezifische Sprecher verweisen.' : ''}`; - // Prepend system prompt if available for the language - const systemPrePrompt = - ROOT_SYSTEM_PROMPTS.PRE_PROMPT[language] || ROOT_SYSTEM_PROMPTS.PRE_PROMPT['de']; - // Für Gemini: Kombiniere System-Prompt mit User-Prompt - const prompt = systemPrePrompt - ? `${systemPrePrompt}\n\n${systemPrompt}\n\n${userPrompt}` - : `${systemPrompt}\n\n${userPrompt}`; - log( - 'DEBUG', - `[${functionIdForLog}][LLM-${requestId}] Vollständiger Prompt (Länge: ${prompt.length})` - ); - const startTime = Date.now(); - const response = await fetch( - `${GEMINI_ENDPOINT}/${GEMINI_MODEL}:generateContent?key=${GEMINI_API_KEY}`, - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - contents: [ - { - parts: [ - { - text: prompt, - }, - ], - }, - ], - generationConfig: { - temperature: 0.7, - maxOutputTokens: 8192, - }, - }), - } - ); - const duration = Date.now() - startTime; - log( - 'INFO', - `[${functionIdForLog}][LLM-${requestId}] Gemini Antwort erhalten in ${duration}ms, Status: ${response.status}` - ); - if (!response.ok) { - const errorText = await response.text(); - log( - 'ERROR', - `[${functionIdForLog}][LLM-${requestId}] Gemini API Fehler: ${response.status}`, - errorText - ); - throw new Error(`Gemini API Fehler: ${response.status} ${errorText}`); - } - const data = await response.json(); - const content = data.candidates?.[0]?.content?.parts?.[0]?.text?.trim() || ''; - log( - 'INFO', - `[${functionIdForLog}][LLM-${requestId}] Erfolgreiche Gemini-Antwort (Länge: ${content.length}).` - ); - log( - 'DEBUG', - `[${functionIdForLog}][LLM-${requestId}] Antwort (erste 100 Zeichen): ${content.substring(0, 100)}...` - ); - return content; - } catch (error) { - log('ERROR', `[${functionIdForLog}][LLM-${requestId}] Fehler beim Gemini-Request:`, error); - throw error; - } -} -/** - * Sendet Benutzerfrage + Transkript an Azure OpenAI und gibt die Antwort zurück (Fallback) - */ async function askQuestionWithAzure( - question, - contextInfo, - language = 'de', - functionIdForLog = 'global' -) { - const requestId = crypto.randomUUID().substring(0, 8); - log('INFO', `[${functionIdForLog}][LLM-${requestId}] Starte Azure OpenAI-Anfrage für Frage.`); - try { - // Kontext-Informationen zusammenstellen - const contextParts = []; - // Location hinzufügen falls verfügbar - if (contextInfo.hasLocation) { - if (contextInfo.locationName) { - contextParts.push(`Aufnahmeort: ${contextInfo.locationName}`); - } else if (contextInfo.locationAddress) { - contextParts.push(`Aufnahmeort: ${contextInfo.locationAddress}`); - } - } - // Audio-Stats hinzufügen - const statsInfo = []; - if (contextInfo.hasMultipleSpeakers) { - statsInfo.push(`${contextInfo.speakerCount} Sprecher`); - } - statsInfo.push(`${Math.round(contextInfo.duration)}s Dauer`); - if (contextInfo.wordCount) { - statsInfo.push(`${contextInfo.wordCount} Wörter`); - } - contextParts.push(`Audio-Info: ${statsInfo.join(', ')}`); - const contextFooter = - contextParts.length > 0 - ? `\n\nZusätzliche Kontext-Informationen:\n${contextParts.join('\n')}` - : ''; - const systemPrompt = getSystemPrompt(language); - const userPrompt = `Frage: ${question} - -Transkript: -${contextInfo.transcript}${contextFooter} - -${contextInfo.hasMultipleSpeakers ? 'Du kannst bei Bedarf auf spezifische Sprecher verweisen.' : ''}`; - // Prepend system prompt if available for the language - const systemPrePrompt = - ROOT_SYSTEM_PROMPTS.PRE_PROMPT[language] || ROOT_SYSTEM_PROMPTS.PRE_PROMPT['de']; - const combinedSystemPrompt = systemPrePrompt - ? `${systemPrePrompt}\n\n${systemPrompt}` - : systemPrompt; - const startTime = Date.now(); - const response = await fetch( - `${AZURE_OPENAI_ENDPOINT}/openai/deployments/${AZURE_OPENAI_DEPLOYMENT}/chat/completions?api-version=${AZURE_OPENAI_API_VERSION}`, - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'api-key': AZURE_OPENAI_KEY, - }, - body: JSON.stringify({ - messages: [ - { - role: 'system', - content: combinedSystemPrompt, - }, - { - role: 'user', - content: userPrompt, - }, - ], - max_tokens: 8192, - temperature: 0.7, - }), - } - ); - const duration = Date.now() - startTime; - log( - 'INFO', - `[${functionIdForLog}][LLM-${requestId}] Azure OpenAI Antwort erhalten in ${duration}ms, Status: ${response.status}` - ); - if (!response.ok) { - const errorText = await response.text(); - log( - 'ERROR', - `[${functionIdForLog}][LLM-${requestId}] Azure OpenAI API Fehler: ${response.status}`, - errorText - ); - throw new Error(`Azure OpenAI API Fehler: ${response.status} ${errorText}`); - } - const data = await response.json(); - const content = data.choices[0]?.message?.content?.trim() || ''; - log( - 'INFO', - `[${functionIdForLog}][LLM-${requestId}] Erfolgreiche Azure OpenAI-Antwort (Länge: ${content.length}).` - ); - return content; - } catch (error) { - log( - 'ERROR', - `[${functionIdForLog}][LLM-${requestId}] Fehler beim Azure OpenAI-Request:`, - error - ); - throw error; - } -} -/** - * Hauptfunktion zur Beantwortung einer Frage mit Fallback-Logik - */ async function answerQuestion( - question, - contextInfo, - language = 'de', - functionIdForLog = 'global' -) { - try { - // Zuerst mit Gemini versuchen - return await askQuestionWithGemini(question, contextInfo, language, functionIdForLog); - } catch (error) { - log('WARN', `[${functionIdForLog}] Gemini fehlgeschlagen, fallback auf Azure OpenAI`, error); - try { - // Fallback auf Azure OpenAI - return await askQuestionWithAzure(question, contextInfo, language, functionIdForLog); - } catch (azureError) { - log('ERROR', `[${functionIdForLog}] Beide LLM-Services fehlgeschlagen`, azureError); - throw new Error('Beide LLM-Services sind nicht verfügbar'); - } - } -} -serve(async (req) => { - const functionId = crypto.randomUUID().substring(0, 8); - log('INFO', `[${functionId}] Question-Memo-Funktion gestartet`); - const corsHeaders = { - 'Access-Control-Allow-Origin': '*', - 'Access-Control-Allow-Methods': 'POST, OPTIONS', - 'Access-Control-Allow-Headers': 'Content-Type, Authorization', - }; - if (req.method === 'OPTIONS') { - log('DEBUG', `[${functionId}] CORS Preflight-Anfrage bearbeitet`); - return new Response(null, { - headers: corsHeaders, - status: 204, - }); - } - try { - const requestData = await req.json(); - const { memo_id, question, user_id } = requestData; - log( - 'INFO', - `[${functionId}] Anfrage erhalten für memo_id: ${memo_id}, Frage: ${question?.substring(0, 50)}...` - ); - if (!memo_id) { - log('ERROR', `[${functionId}] Keine memo_id in der Anfrage gefunden`); - return new Response( - JSON.stringify({ - error: 'memo_id ist erforderlich', - }), - { - headers: { - ...corsHeaders, - 'Content-Type': 'application/json', - }, - status: 400, - } - ); - } - if (!question || question.trim().length === 0) { - log('ERROR', `[${functionId}] Keine Frage in der Anfrage gefunden`); - return new Response( - JSON.stringify({ - error: 'Frage ist erforderlich', - }), - { - headers: { - ...corsHeaders, - 'Content-Type': 'application/json', - }, - status: 400, - } - ); - } - log('INFO', `[${functionId}] Rufe Memo mit ID ${memo_id} aus der Datenbank ab`); - // Build query based on whether user_id is provided (from service) or not (from frontend) - let memoQuery = memoro_sb.from('memos').select('*').eq('id', memo_id); - if (user_id) { - // When called from service, filter by user_id for security - memoQuery = memoQuery.eq('user_id', user_id); - } - const { data: memo, error: memoError } = await memoQuery.single(); - if (memoError || !memo) { - log('ERROR', `[${functionId}] Memo ${memo_id} nicht gefunden:`, memoError); - return new Response( - JSON.stringify({ - error: 'Memo nicht gefunden', - }), - { - headers: { - ...corsHeaders, - 'Content-Type': 'application/json', - }, - status: 404, - } - ); - } - // Kontext-Informationen extrahieren (mit Speaker-Support und Metadaten) - const contextInfo = extractContextInfo(memo.source || {}, memo.metadata || {}); - log( - 'INFO', - `[${functionId}] Extrahierte Kontext-Info: ${contextInfo.speakerCount} Sprecher, ${Math.round(contextInfo.duration)}s, ${contextInfo.wordCount || 'unb.'} Wörter, ${contextInfo.hasLocation ? 'mit Ort' : 'ohne Ort'}, Transkript-Länge: ${contextInfo.transcript.length}` - ); - if (!contextInfo.transcript) { - log('ERROR', `[${functionId}] Kein Transkript im Memo ${memo_id} gefunden`); - return new Response( - JSON.stringify({ - error: 'Kein Transkript im Memo gefunden', - }), - { - headers: { - ...corsHeaders, - 'Content-Type': 'application/json', - }, - status: 400, - } - ); - } - // Sprache aus Memo extrahieren - const memoLanguage = memo.source?.primary_language || memo.source?.languages?.[0] || 'de'; - const baseLang = memoLanguage.split('-')[0].toLowerCase(); - log( - 'INFO', - `[${functionId}] Sende Frage an LLM: "${question.substring(0, 50)}..." (${contextInfo.hasMultipleSpeakers ? 'Multi-Speaker' : 'Single-Speaker'} Kontext, Sprache: ${baseLang})` - ); - const answer = await answerQuestion(question.trim(), contextInfo, baseLang, functionId); - if (!answer) { - log('ERROR', `[${functionId}] Keine Antwort vom LLM erhalten`); - return new Response( - JSON.stringify({ - error: 'Keine Antwort vom LLM erhalten', - }), - { - headers: { - ...corsHeaders, - 'Content-Type': 'application/json', - }, - status: 500, - } - ); - } - // Get the highest sort_order for this memo - log('INFO', `[${functionId}] Ermittle höchste sort_order für Memo ${memo_id}`); - const { data: maxSortData, error: maxSortError } = await memoro_sb - .from('memories') - .select('sort_order') - .eq('memo_id', memo_id) - .order('sort_order', { - ascending: false, - }) - .limit(1) - .single(); - // If error or no data, use random number above 5000, otherwise increment - const nextSortOrder = - maxSortError || !maxSortData?.sort_order - ? Math.floor(Math.random() * 5000) + 5000 // Random between 5000-9999 - : maxSortData.sort_order + 1; - log('INFO', `[${functionId}] Nächste sort_order: ${nextSortOrder}`); - log('INFO', `[${functionId}] Erstelle neues Memory für Memo ${memo_id} mit der Antwort`); - const { data: newMemory, error: newMemoryError } = await memoro_sb - .from('memories') - .insert({ - memo_id: memo_id, - title: `Frage: ${question.length > 50 ? question.substring(0, 50) + '...' : question}`, - content: answer, - media: null, - sort_order: nextSortOrder, - metadata: { - type: 'question', - question: question.trim(), - created_by: 'question_memo_function', - }, - }) - .select() - .single(); - if (newMemoryError) { - log('ERROR', `[${functionId}] Fehler beim Erstellen des Memories:`, newMemoryError); - return new Response( - JSON.stringify({ - error: newMemoryError.message, - }), - { - headers: { - ...corsHeaders, - 'Content-Type': 'application/json', - }, - status: 500, - } - ); - } - log('INFO', `[${functionId}] Memory erfolgreich erstellt mit ID ${newMemory.id}`); - log('INFO', `[${functionId}] Question-Memo-Verarbeitung erfolgreich abgeschlossen`); - return new Response( - JSON.stringify({ - success: true, - memory_id: newMemory.id, - answer: answer, - question: question.trim(), - }), - { - headers: { - ...corsHeaders, - 'Content-Type': 'application/json', - }, - status: 200, - } - ); - } catch (error) { - log('ERROR', `[${functionId}] Unerwarteter Fehler bei der Question-Memo-Verarbeitung:`, error); - const errorMessage = error instanceof Error ? error.message : 'Unbekannter Fehler'; - return new Response( - JSON.stringify({ - error: `Unerwarteter Fehler: ${errorMessage}`, - }), - { - headers: { - ...corsHeaders, - 'Content-Type': 'application/json', - }, - status: 500, - } - ); - } -}); diff --git a/apps/memoro/apps/backend/supabase/functions/swift-function/index.ts b/apps/memoro/apps/backend/supabase/functions/swift-function/index.ts deleted file mode 100644 index d531cb849..000000000 --- a/apps/memoro/apps/backend/supabase/functions/swift-function/index.ts +++ /dev/null @@ -1,391 +0,0 @@ -// Follow this setup guide to integrate the Deno language server with your editor: -// https://deno.land/manual/getting_started/setup_your_environment -// This enables autocomplete, go to definition, etc. -// Setup type definitions for built-in Supabase Runtime APIs -import 'jsr:@supabase/functions-js/edge-runtime.d.ts'; -import { serve } from 'https://deno.land/std@0.215.0/http/server.ts'; -import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'; -/** - * Question Memo Edge Function - * - * Diese Funktion nimmt eine Benutzerfrage und ein Memo-Transkript entgegen, - * sendet beides an Gemini API und erstellt eine neue Memory mit der Antwort. - * - * @version 1.0.0 - * @date 2025-05-23 - */ // ─── Umgebungsvariablen ────────────────────────────────────────────── -const SUPABASE_URL = 'https://npgifbrwhftlbrbaglmi.supabase.co'; -const SERVICE_KEY = - 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im5wZ2lmYnJ3aGZ0bGJyYmFnbG1pIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImlhdCI6MTc0NTg1MTQxNiwiZXhwIjoyMDYxNDI3NDE2fQ.-6hArOVoEgGwIwdjclLQCTOAu13BFYnp9hPxQks4JPM'; -// Google Gemini Konfiguration -const GEMINI_API_KEY = Deno.env.get('QUESTION_MEMO_GEMINI') || ''; -const GEMINI_MODEL = 'gemini-flash'; -const GEMINI_ENDPOINT = 'https://generativelanguage.googleapis.com/v1beta/models'; -// Azure OpenAI Konfiguration (Backup) -const AZURE_OPENAI_ENDPOINT = 'https://memoroseopenai.openai.azure.com'; -const AZURE_OPENAI_KEY = '3082103c9b0d4270a795686ccaa89921'; -const AZURE_OPENAI_DEPLOYMENT = 'gpt-4.1-mini-se'; -const AZURE_OPENAI_API_VERSION = '2025-01-01-preview'; -const memoro_sb = createClient(SUPABASE_URL, SERVICE_KEY); -// ─── Logging-Funktion ────────────────────────────────────────────── -/** - * Erweiterte Logging-Funktion mit Zeitstempel und Log-Level - */ function log(level, message, data) { - const timestamp = new Date().toISOString(); - const logMessage = `[${timestamp}] [${level.toUpperCase()}] ${message}`; - switch (level.toUpperCase()) { - case 'INFO': - console.log(logMessage); - break; - case 'DEBUG': - console.debug(logMessage); - break; - case 'WARN': - console.warn(logMessage); - break; - case 'ERROR': - console.error(logMessage); - break; - default: - console.log(logMessage); - break; - } - if (data) { - if (level.toUpperCase() === 'ERROR') { - console.error(data); - } else { - console.log(typeof data === 'object' ? JSON.stringify(data, null, 2) : data); - } - } -} -/** - * Sendet Benutzerfrage + Transkript an Gemini und gibt die Antwort zurück - */ async function askQuestionWithGemini(question, transcript, functionIdForLog = 'global') { - const requestId = crypto.randomUUID().substring(0, 8); - log('INFO', `[${functionIdForLog}][LLM-${requestId}] Starte Gemini-Anfrage für Frage.`); - try { - const prompt = `Du bist ein hilfreicher Assistent. Beantworte die folgende Frage basierend auf dem gegebenen Transkript: - -Frage: ${question} - -Transkript: ${transcript} - -Antworte direkt und präzise auf die Frage basierend auf den Informationen im Transkript. Falls die Antwort nicht im Transkript zu finden ist, teile das höflich mit.`; - log( - 'DEBUG', - `[${functionIdForLog}][LLM-${requestId}] Vollständiger Prompt (Länge: ${prompt.length})` - ); - const startTime = Date.now(); - const response = await fetch( - `${GEMINI_ENDPOINT}/${GEMINI_MODEL}:generateContent?key=${GEMINI_API_KEY}`, - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - contents: [ - { - parts: [ - { - text: prompt, - }, - ], - }, - ], - generationConfig: { - temperature: 0.7, - maxOutputTokens: 512, - }, - }), - } - ); - const duration = Date.now() - startTime; - log( - 'INFO', - `[${functionIdForLog}][LLM-${requestId}] Gemini Antwort erhalten in ${duration}ms, Status: ${response.status}` - ); - if (!response.ok) { - const errorText = await response.text(); - log( - 'ERROR', - `[${functionIdForLog}][LLM-${requestId}] Gemini API Fehler: ${response.status}`, - errorText - ); - throw new Error(`Gemini API Fehler: ${response.status} ${errorText}`); - } - const data = await response.json(); - const content = data.candidates?.[0]?.content?.parts?.[0]?.text?.trim() || ''; - log( - 'INFO', - `[${functionIdForLog}][LLM-${requestId}] Erfolgreiche Gemini-Antwort (Länge: ${content.length}).` - ); - log( - 'DEBUG', - `[${functionIdForLog}][LLM-${requestId}] Antwort (erste 100 Zeichen): ${content.substring(0, 100)}...` - ); - return content; - } catch (error) { - log('ERROR', `[${functionIdForLog}][LLM-${requestId}] Fehler beim Gemini-Request:`, error); - throw error; - } -} -/** - * Sendet Benutzerfrage + Transkript an Azure OpenAI und gibt die Antwort zurück (Fallback) - */ async function askQuestionWithAzure(question, transcript, functionIdForLog = 'global') { - const requestId = crypto.randomUUID().substring(0, 8); - log('INFO', `[${functionIdForLog}][LLM-${requestId}] Starte Azure OpenAI-Anfrage für Frage.`); - try { - const prompt = `Du bist ein hilfreicher Assistent. Beantworte die folgende Frage basierend auf dem gegebenen Transkript: - -Frage: ${question} - -Transkript: ${transcript} - -Antworte direkt und präzise auf die Frage basierend auf den Informationen im Transkript. Falls die Antwort nicht im Transkript zu finden ist, teile das höflich mit.`; - const startTime = Date.now(); - const response = await fetch( - `${AZURE_OPENAI_ENDPOINT}/openai/deployments/${AZURE_OPENAI_DEPLOYMENT}/chat/completions?api-version=${AZURE_OPENAI_API_VERSION}`, - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'api-key': AZURE_OPENAI_KEY, - }, - body: JSON.stringify({ - messages: [ - { - role: 'system', - content: 'Du bist ein hilfreicher Assistent.', - }, - { - role: 'user', - content: prompt, - }, - ], - max_tokens: 512, - temperature: 0.7, - }), - } - ); - const duration = Date.now() - startTime; - log( - 'INFO', - `[${functionIdForLog}][LLM-${requestId}] Azure OpenAI Antwort erhalten in ${duration}ms, Status: ${response.status}` - ); - if (!response.ok) { - const errorText = await response.text(); - log( - 'ERROR', - `[${functionIdForLog}][LLM-${requestId}] Azure OpenAI API Fehler: ${response.status}`, - errorText - ); - throw new Error(`Azure OpenAI API Fehler: ${response.status} ${errorText}`); - } - const data = await response.json(); - const content = data.choices[0]?.message?.content?.trim() || ''; - log( - 'INFO', - `[${functionIdForLog}][LLM-${requestId}] Erfolgreiche Azure OpenAI-Antwort (Länge: ${content.length}).` - ); - return content; - } catch (error) { - log( - 'ERROR', - `[${functionIdForLog}][LLM-${requestId}] Fehler beim Azure OpenAI-Request:`, - error - ); - throw error; - } -} -/** - * Hauptfunktion zur Beantwortung einer Frage mit Fallback-Logik - */ async function answerQuestion(question, transcript, functionIdForLog = 'global') { - try { - // Zuerst mit Gemini versuchen - return await askQuestionWithGemini(question, transcript, functionIdForLog); - } catch (error) { - log('WARN', `[${functionIdForLog}] Gemini fehlgeschlagen, fallback auf Azure OpenAI`, error); - try { - // Fallback auf Azure OpenAI - return await askQuestionWithAzure(question, transcript, functionIdForLog); - } catch (azureError) { - log('ERROR', `[${functionIdForLog}] Beide LLM-Services fehlgeschlagen`, azureError); - throw new Error('Beide LLM-Services sind nicht verfügbar'); - } - } -} -serve(async (req) => { - const functionId = crypto.randomUUID().substring(0, 8); - log('INFO', `[${functionId}] Question-Memo-Funktion gestartet`); - const corsHeaders = { - 'Access-Control-Allow-Origin': '*', - 'Access-Control-Allow-Methods': 'POST, OPTIONS', - 'Access-Control-Allow-Headers': 'Content-Type, Authorization', - }; - if (req.method === 'OPTIONS') { - log('DEBUG', `[${functionId}] CORS Preflight-Anfrage bearbeitet`); - return new Response(null, { - headers: corsHeaders, - status: 204, - }); - } - try { - const requestData = await req.json(); - const { memo_id, question } = requestData; - log( - 'INFO', - `[${functionId}] Anfrage erhalten für memo_id: ${memo_id}, Frage: ${question?.substring(0, 50)}...` - ); - if (!memo_id) { - log('ERROR', `[${functionId}] Keine memo_id in der Anfrage gefunden`); - return new Response( - JSON.stringify({ - error: 'memo_id ist erforderlich', - }), - { - headers: { - ...corsHeaders, - 'Content-Type': 'application/json', - }, - status: 400, - } - ); - } - if (!question || question.trim().length === 0) { - log('ERROR', `[${functionId}] Keine Frage in der Anfrage gefunden`); - return new Response( - JSON.stringify({ - error: 'Frage ist erforderlich', - }), - { - headers: { - ...corsHeaders, - 'Content-Type': 'application/json', - }, - status: 400, - } - ); - } - log('INFO', `[${functionId}] Rufe Memo mit ID ${memo_id} aus der Datenbank ab`); - const { data: memo, error: memoError } = await memoro_sb - .from('memos') - .select('*') - .eq('id', memo_id) - .single(); - if (memoError || !memo) { - log('ERROR', `[${functionId}] Memo ${memo_id} nicht gefunden:`, memoError); - return new Response( - JSON.stringify({ - error: 'Memo nicht gefunden', - }), - { - headers: { - ...corsHeaders, - 'Content-Type': 'application/json', - }, - status: 404, - } - ); - } - // Transkript extrahieren - const transcript = - memo.source?.content || memo.source?.transcription || memo.source?.transcript || ''; - log('INFO', `[${functionId}] Extrahiertes Transkript (Länge: ${transcript.length})`); - if (!transcript) { - log('ERROR', `[${functionId}] Kein Transkript im Memo ${memo_id} gefunden`); - return new Response( - JSON.stringify({ - error: 'Kein Transkript im Memo gefunden', - }), - { - headers: { - ...corsHeaders, - 'Content-Type': 'application/json', - }, - status: 400, - } - ); - } - log('INFO', `[${functionId}] Sende Frage an LLM: "${question.substring(0, 50)}..."`); - const answer = await answerQuestion(question.trim(), transcript, functionId); - if (!answer) { - log('ERROR', `[${functionId}] Keine Antwort vom LLM erhalten`); - return new Response( - JSON.stringify({ - error: 'Keine Antwort vom LLM erhalten', - }), - { - headers: { - ...corsHeaders, - 'Content-Type': 'application/json', - }, - status: 500, - } - ); - } - log('INFO', `[${functionId}] Erstelle neues Memory für Memo ${memo_id} mit der Antwort`); - const { data: newMemory, error: newMemoryError } = await memoro_sb - .from('memories') - .insert({ - memo_id: memo_id, - title: `Frage: ${question.length > 50 ? question.substring(0, 50) + '...' : question}`, - content: answer, - media: null, - metadata: { - type: 'question', - question: question.trim(), - created_by: 'question_memo_function', - }, - }) - .select() - .single(); - if (newMemoryError) { - log('ERROR', `[${functionId}] Fehler beim Erstellen des Memories:`, newMemoryError); - return new Response( - JSON.stringify({ - error: newMemoryError.message, - }), - { - headers: { - ...corsHeaders, - 'Content-Type': 'application/json', - }, - status: 500, - } - ); - } - log('INFO', `[${functionId}] Memory erfolgreich erstellt mit ID ${newMemory.id}`); - log('INFO', `[${functionId}] Question-Memo-Verarbeitung erfolgreich abgeschlossen`); - return new Response( - JSON.stringify({ - success: true, - memory_id: newMemory.id, - answer: answer, - question: question.trim(), - }), - { - headers: { - ...corsHeaders, - 'Content-Type': 'application/json', - }, - status: 200, - } - ); - } catch (error) { - log('ERROR', `[${functionId}] Unerwarteter Fehler bei der Question-Memo-Verarbeitung:`, error); - const errorMessage = error instanceof Error ? error.message : 'Unbekannter Fehler'; - return new Response( - JSON.stringify({ - error: `Unerwarteter Fehler: ${errorMessage}`, - }), - { - headers: { - ...corsHeaders, - 'Content-Type': 'application/json', - }, - status: 500, - } - ); - } -}); diff --git a/apps/memoro/apps/backend/supabase/functions/translate/deno.json b/apps/memoro/apps/backend/supabase/functions/translate/deno.json deleted file mode 100644 index 0967ef424..000000000 --- a/apps/memoro/apps/backend/supabase/functions/translate/deno.json +++ /dev/null @@ -1 +0,0 @@ -{} diff --git a/apps/memoro/apps/backend/supabase/functions/translate/index.ts b/apps/memoro/apps/backend/supabase/functions/translate/index.ts deleted file mode 100644 index 08ab8eb86..000000000 --- a/apps/memoro/apps/backend/supabase/functions/translate/index.ts +++ /dev/null @@ -1,659 +0,0 @@ -// Follow this setup guide to integrate the Deno language server with your editor: -// https://deno.land/manual/getting_started/setup_your_environment -// This enables autocomplete, go to definition, etc. -// Setup type definitions for built-in Supabase Runtime APIs -import 'jsr:@supabase/functions-js/edge-runtime.d.ts'; -import { serve } from 'https://deno.land/std@0.215.0/http/server.ts'; -import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'; -import { getTranscriptText, getRecordingTranscript } from '../_shared/transcript-utils.ts'; -// Inline error handling utilities to avoid deployment issues -// Atomic status update utilities using RPC to prevent race conditions -async function setMemoErrorStatus(supabaseClient, memoId, processName, error) { - if (!memoId) return; - const errorMessage = error instanceof Error ? error.message : String(error); - const timestamp = new Date().toISOString(); - try { - await supabaseClient.rpc('set_memo_process_error', { - p_memo_id: memoId, - p_process_name: processName, - p_timestamp: timestamp, - p_reason: errorMessage, - p_details: null, - }); - } catch (dbError) { - console.error(`Error setting error status for memo ${memoId}:`, dbError); - } -} -async function setMemoProcessingStatus(supabaseClient, memoId, processName) { - const timestamp = new Date().toISOString(); - try { - await supabaseClient.rpc('set_memo_process_status', { - p_memo_id: memoId, - p_process_name: processName, - p_status: 'processing', - p_timestamp: timestamp, - }); - } catch (dbError) { - console.error(`Error setting processing status for memo ${memoId}:`, dbError); - } -} -async function setMemoCompletedStatus(supabaseClient, memoId, processName, details) { - const timestamp = new Date().toISOString(); - try { - await supabaseClient.rpc('set_memo_process_status_with_details', { - p_memo_id: memoId, - p_process_name: processName, - p_status: 'completed', - p_timestamp: timestamp, - p_details: details, - }); - } catch (dbError) { - console.error(`Error setting completed status for memo ${memoId}:`, dbError); - } -} -function createErrorResponse(error, status = 500, corsHeaders = {}) { - const errorMessage = error instanceof Error ? error.message : String(error); - return new Response( - JSON.stringify({ - error: errorMessage, - timestamp: new Date().toISOString(), - }), - { - headers: { - ...corsHeaders, - 'Content-Type': 'application/json', - }, - status, - } - ); -} -/** - * Translate Edge Function - * - * Diese Funktion übersetzt alle Felder eines Memo-Eintrags in eine Zielsprache. - * Übersetzt werden: transcript, headline, intro und alle memory entries (blueprints). - * Die übersetzten Inhalte ersetzen die ursprünglichen Inhalte im selben Memo. - * - * @version 1.0.0 - * @date 2025-05-26 - */ // ─── Umgebungsvariablen ────────────────────────────────────────────── -const SUPABASE_URL = Deno.env.get('SUPABASE_URL'); -if (!SUPABASE_URL) { - throw new Error('SUPABASE_URL not configured'); -} -const SERVICE_KEY = Deno.env.get('C_SUPABASE_SECRET_KEY'); -if (!SERVICE_KEY) { - throw new Error('C_SUPABASE_SECRET_KEY not configured'); -} -// Google Gemini Konfiguration -const GEMINI_API_KEY = Deno.env.get('TRANSLATE_MEMO_GEMINI_MEMORO') || ''; -const GEMINI_MODEL = 'gemini-2.0-flash'; -const GEMINI_ENDPOINT = 'https://generativelanguage.googleapis.com/v1beta/models'; -// Azure OpenAI Konfiguration (Backup) -const AZURE_OPENAI_ENDPOINT = 'https://memoroseopenai.openai.azure.com'; -const AZURE_OPENAI_KEY = Deno.env.get('AZURE_OPENAI_KEY'); -if (!AZURE_OPENAI_KEY) { - throw new Error('AZURE_OPENAI_KEY not configured'); -} -const AZURE_OPENAI_DEPLOYMENT = 'gpt-4.1-mini-se'; -const AZURE_OPENAI_API_VERSION = '2025-01-01-preview'; -const memoro_sb = createClient(SUPABASE_URL, SERVICE_KEY); -// ─── Logging-Funktion ────────────────────────────────────────────── -function log(level, message, data) { - const timestamp = new Date().toISOString(); - const logMessage = `[${timestamp}] [${level.toUpperCase()}] ${message}`; - switch (level.toUpperCase()) { - case 'INFO': - console.log(logMessage); - break; - case 'DEBUG': - console.debug(logMessage); - break; - case 'WARN': - console.warn(logMessage); - break; - case 'ERROR': - console.error(logMessage); - break; - default: - console.log(logMessage); - break; - } - if (data) { - if (level.toUpperCase() === 'ERROR') { - console.error(data); - } else { - console.log(typeof data === 'object' ? JSON.stringify(data, null, 2) : data); - } - } -} -// ─── Sprach-Mapping ────────────────────────────────────────────── -const LANGUAGE_NAMES = { - de: 'German', - en: 'English', - es: 'Spanish', - fr: 'French', - it: 'Italian', - pt: 'Portuguese', - nl: 'Dutch', - pl: 'Polish', - ru: 'Russian', - ja: 'Japanese', - ko: 'Korean', - zh: 'Chinese', - ar: 'Arabic', - hi: 'Hindi', - tr: 'Turkish', - sv: 'Swedish', - da: 'Danish', - no: 'Norwegian', - fi: 'Finnish', - cs: 'Czech', - sk: 'Slovak', - hu: 'Hungarian', - ro: 'Romanian', - bg: 'Bulgarian', - hr: 'Croatian', - sr: 'Serbian', - sl: 'Slovenian', - et: 'Estonian', - lv: 'Latvian', - lt: 'Lithuanian', - mt: 'Maltese', - ga: 'Irish', - el: 'Greek', - uk: 'Ukrainian', - bn: 'Bengali', - ur: 'Urdu', - fa: 'Persian', - vi: 'Vietnamese', - id: 'Indonesian', -}; -function getLanguageName(languageCode) { - return LANGUAGE_NAMES[languageCode.toLowerCase()] || languageCode; -} -// ─── Übersetzungsfunktionen ────────────────────────────────────────────── -/** - * Übersetzt Text mit Google Gemini Flash - */ async function translateWithGemini(text, targetLanguage, functionIdForLog = 'global') { - const requestId = crypto.randomUUID().substring(0, 8); - log('INFO', `[${functionIdForLog}][Gemini-${requestId}] Starte Übersetzung.`); - try { - const targetLanguageName = getLanguageName(targetLanguage); - const prompt = `Translate the following text to ${targetLanguageName}. Keep the original formatting, structure, and meaning. Only return the translated text without any explanations or additions:\n\n${text}`; - log( - 'DEBUG', - `[${functionIdForLog}][Gemini-${requestId}] Prompt erstellt für Zielsprache: ${targetLanguageName}` - ); - const response = await fetch( - `${GEMINI_ENDPOINT}/${GEMINI_MODEL}:generateContent?key=${GEMINI_API_KEY}`, - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - contents: [ - { - parts: [ - { - text: prompt, - }, - ], - }, - ], - generationConfig: { - temperature: 0.3, - maxOutputTokens: Math.min(8192, Math.max(512, text.length * 2)), - }, - }), - } - ); - if (!response.ok) { - const errorText = await response.text(); - log( - 'ERROR', - `[${functionIdForLog}][Gemini-${requestId}] Gemini API Fehler: ${response.status}`, - errorText - ); - throw new Error(`Gemini API Fehler: ${response.status} ${errorText}`); - } - const data = await response.json(); - const content = data.candidates?.[0]?.content?.parts?.[0]?.text?.trim() || ''; - if (!content) { - log('ERROR', `[${functionIdForLog}][Gemini-${requestId}] Leere Antwort von Gemini`); - return null; - } - log( - 'INFO', - `[${functionIdForLog}][Gemini-${requestId}] Übersetzung erfolgreich (${content.length} Zeichen)` - ); - return content; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : 'Unbekannter Fehler'; - log( - 'ERROR', - `[${functionIdForLog}][Gemini-${requestId}] Fehler bei der Gemini-Übersetzung:`, - errorMessage - ); - return null; - } -} -/** - * Übersetzt Text mit Azure OpenAI - */ async function translateWithAzure(text, targetLanguage, functionIdForLog = 'global') { - const requestId = crypto.randomUUID().substring(0, 8); - log('INFO', `[${functionIdForLog}][Azure-${requestId}] Starte Azure OpenAI Übersetzung.`); - try { - const targetLanguageName = getLanguageName(targetLanguage); - const prompt = `Translate the following text to ${targetLanguageName}. Keep the original formatting, structure, and meaning. Only return the translated text without any explanations or additions:\n\n${text}`; - const response = await fetch( - `${AZURE_OPENAI_ENDPOINT}/openai/deployments/${AZURE_OPENAI_DEPLOYMENT}/chat/completions?api-version=${AZURE_OPENAI_API_VERSION}`, - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'api-key': AZURE_OPENAI_KEY, - }, - body: JSON.stringify({ - messages: [ - { - role: 'system', - content: - 'You are a professional translator. Translate the given text accurately while preserving formatting and meaning.', - }, - { - role: 'user', - content: prompt, - }, - ], - max_tokens: Math.min(8192, Math.max(512, text.length * 2)), - temperature: 0.3, - }), - } - ); - if (!response.ok) { - const errorText = await response.text(); - log( - 'ERROR', - `[${functionIdForLog}][Azure-${requestId}] Azure OpenAI API Fehler: ${response.status}`, - errorText - ); - throw new Error(`Azure OpenAI API Fehler: ${response.status} ${errorText}`); - } - const data = await response.json(); - const content = data.choices[0]?.message?.content?.trim() || text; // Fallback auf Originaltext - log( - 'INFO', - `[${functionIdForLog}][Azure-${requestId}] Azure-Übersetzung erfolgreich (${content.length} Zeichen)` - ); - return content; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : 'Unbekannter Fehler'; - log( - 'ERROR', - `[${functionIdForLog}][Azure-${requestId}] Fehler bei der Azure-Übersetzung:`, - errorMessage - ); - return text; // Fallback auf Originaltext - } -} -/** - * Hauptfunktion zur Übersetzung - versucht zuerst Gemini, dann Azure - */ async function translateText(text, targetLanguage, functionIdForLog = 'global') { - if (!text || text.trim().length === 0) { - return text; - } - try { - // Zuerst mit Gemini versuchen - const geminiResult = await translateWithGemini(text, targetLanguage, functionIdForLog); - if (geminiResult) { - log('DEBUG', `[${functionIdForLog}] Übersetzung mit Gemini Flash erfolgreich`); - return geminiResult; - } - // Fallback auf Azure OpenAI - log('DEBUG', `[${functionIdForLog}] Fallback auf Azure OpenAI für Übersetzung`); - return await translateWithAzure(text, targetLanguage, functionIdForLog); - } catch (error) { - const errorMessage = error instanceof Error ? error.message : 'Unbekannter Fehler'; - log('ERROR', `[${functionIdForLog}] Fehler bei der Übersetzung:`, errorMessage); - return text; // Fallback auf Originaltext - } -} -// ─── Hauptfunktion ────────────────────────────────────────────── -serve(async (req) => { - const functionId = crypto.randomUUID().substring(0, 8); - log('INFO', `[${functionId}] Translate-Funktion gestartet`); - // CORS-Header für Entwicklung - const corsHeaders = { - 'Access-Control-Allow-Origin': '*', - 'Access-Control-Allow-Methods': 'POST, OPTIONS', - 'Access-Control-Allow-Headers': 'Content-Type, Authorization', - }; - // OPTIONS-Anfrage für CORS - if (req.method === 'OPTIONS') { - log('DEBUG', `[${functionId}] CORS Preflight-Anfrage bearbeitet`); - return new Response(null, { - headers: corsHeaders, - status: 204, - }); - } - let memo_id_to_update = null; - try { - // Anfrage-Daten extrahieren - const requestData = await req.json(); - const { memo_id, target_language } = requestData; - memo_id_to_update = memo_id; - log( - 'INFO', - `[${functionId}] Anfrage erhalten für memo_id: ${memo_id}, Zielsprache: ${target_language}` - ); - if (!memo_id) { - return createErrorResponse('memo_id ist erforderlich', 400, corsHeaders); - } - if (!target_language) { - return createErrorResponse('target_language ist erforderlich', 400, corsHeaders); - } - // Set processing status - await setMemoProcessingStatus(memoro_sb, memo_id, 'translate'); - // Memo aus der Datenbank abrufen - const { data: memo, error: memoError } = await memoro_sb - .from('memos') - .select('*') - .eq('id', memo_id) - .single(); - if (memoError || !memo) { - log('ERROR', `[${functionId}] Fehler beim Abrufen des Memos:`, memoError); - await setMemoErrorStatus( - memoro_sb, - memo_id, - 'translate', - `Memo nicht gefunden: ${memoError?.message || 'Unbekannter Fehler'}` - ); - return createErrorResponse( - `Memo nicht gefunden: ${memoError?.message || 'Unbekannter Fehler'}`, - 404, - corsHeaders - ); - } - log('INFO', `[${functionId}] Memo erfolgreich abgerufen, beginne mit Übersetzung`); - // 1. Transcript übersetzen (from utterances or legacy fields) - let translatedTranscript = ''; - let transcript = getTranscriptText(memo); - // Handle combined memos with additional_recordings structure - if (!transcript && memo.source?.type === 'combined' && memo.source?.additional_recordings) { - transcript = memo.source.additional_recordings - .map((recording) => getRecordingTranscript(recording)) - .filter(Boolean) - .join('\n\n'); - } - if (transcript) { - log('INFO', `[${functionId}] Übersetze Transcript (${transcript.length} Zeichen)`); - translatedTranscript = await translateText(transcript, target_language, functionId); - } - // 2. Headline übersetzen - let translatedHeadline = ''; - if (memo.title) { - log('INFO', `[${functionId}] Übersetze Headline: "${memo.title}"`); - translatedHeadline = await translateText(memo.title, target_language, functionId); - } - // 3. Intro übersetzen - let translatedIntro = ''; - if (memo.intro) { - log('INFO', `[${functionId}] Übersetze Intro (${memo.intro.length} Zeichen)`); - translatedIntro = await translateText(memo.intro, target_language, functionId); - } - // 4. Neue übersetztes Memo erstellen - log( - 'INFO', - `[${functionId}] Erstelle neues übersetztes Memo basierend auf Original ${memo_id}` - ); - // Bereite source für das neue Memo vor - let newSource = { - ...memo.source, - }; - if (translatedTranscript) { - if (memo.source?.content) { - newSource.content = translatedTranscript; - } else if (memo.source?.transcription) { - newSource.transcription = translatedTranscript; - } else if (memo.source?.transcript) { - newSource.transcript = translatedTranscript; - } else if (memo.source?.type === 'combined' && memo.source?.additional_recordings) { - // Für combined memos, übersetze jeden transcript in den additional_recordings - const translatedRecordings = await Promise.all( - memo.source.additional_recordings.map(async (recording) => { - if (recording.transcript) { - const translated = await translateText( - recording.transcript, - target_language, - functionId - ); - return { - ...recording, - transcript: translated, - }; - } - return recording; - }) - ); - newSource.additional_recordings = translatedRecordings; - } - } - // Bereite Metadata für das neue Memo vor (mit Referenz zum Original) - const newMetadata = { - ...memo.metadata, - translation: { - source_memo_id: memo_id, - source_language: - memo.source?.primary_language || memo.metadata?.primary_language || 'unknown', - target_language: target_language, - translated_at: new Date().toISOString(), - translation_method: 'ai', - translator_model: GEMINI_MODEL, - }, - }; - // Erstelle das neue übersetztes Memo - const { data: newMemo, error: createError } = await memoro_sb - .from('memos') - .insert({ - title: translatedHeadline || memo.title, - intro: translatedIntro || memo.intro, - user_id: memo.user_id, - space_id: memo.space_id, - source: newSource, - metadata: newMetadata, - is_pinned: false, - is_archived: false, - is_public: memo.is_public, - }) - .select() - .single(); - if (createError) { - log('ERROR', `[${functionId}] Fehler beim Erstellen des übersetzten Memos:`, createError); - await setMemoErrorStatus(memoro_sb, memo_id, 'translate', createError); - throw createError; - } - log('INFO', `[${functionId}] Neues übersetztes Memo erstellt mit ID: ${newMemo.id}`); - const newMemoId = newMemo.id; - // 4.1. Aktualisiere das Original-Memo mit Referenz zur Übersetzung - try { - // Lade aktuelles Original-Memo für Broadcast - const { data: originalMemo, error: fetchError } = await memoro_sb - .from('memos') - .select('*') - .eq('id', memo_id) - .single(); - if (!fetchError && originalMemo) { - const currentMetadata = originalMemo.metadata || {}; - const existingTranslations = currentMetadata.translations || []; - // Füge neue Übersetzung zur Liste hinzu (verhindere Duplikate) - const updatedTranslations = existingTranslations.filter( - (t) => t.target_language !== target_language - ); - updatedTranslations.push({ - memo_id: newMemoId, - target_language: target_language, - translated_at: new Date().toISOString(), - translator_model: GEMINI_MODEL, - }); - const updatedMetadata = { - ...currentMetadata, - translations: updatedTranslations, - }; - const { error: updateError } = await memoro_sb - .from('memos') - .update({ - metadata: updatedMetadata, - }) - .eq('id', memo_id); - if (updateError) { - log( - 'WARN', - `[${functionId}] Fehler beim Aktualisieren der Original-Memo-Metadaten:`, - updateError - ); - } else { - log( - 'INFO', - `[${functionId}] Original-Memo erfolgreich mit Übersetzungsreferenz aktualisiert` - ); - - // Send broadcast update to notify clients about the translation reference - try { - const channel = memoro_sb.channel(`memo-updates-${memo_id}`); - - channel.subscribe(async (status) => { - if (status === 'SUBSCRIBED') { - await channel.send({ - type: 'broadcast', - event: 'memo-updated', - payload: { - id: memo_id, - old: originalMemo, - new: { - ...originalMemo, - metadata: updatedMetadata, - }, - user_id: memo.user_id, - }, - }); - log( - 'INFO', - `[${functionId}] Broadcast sent for memo ${memo_id} translation reference update` - ); - // Clean up the channel after sending - memoro_sb.removeChannel(channel); - } - }); - } catch (broadcastError) { - log('WARN', `[${functionId}] Failed to send broadcast update:`, broadcastError); - // Don't fail the function if broadcast fails - } - } - } - } catch (referenceError) { - log('WARN', `[${functionId}] Fehler beim Erstellen der Rückreferenz:`, referenceError); - // Nicht kritisch - Übersetzung ist bereits erstellt - } - // 5. Alle Memories (Blueprint-Antworten) für das neue Memo erstellen - const { data: memories, error: memoriesError } = await memoro_sb - .from('memories') - .select('*') - .eq('memo_id', memo_id); - let translatedMemoriesCount = 0; - if (memoriesError) { - log('WARN', `[${functionId}] Fehler beim Abrufen der Memories:`, memoriesError); - } else if (memories && memories.length > 0) { - log( - 'INFO', - `[${functionId}] Erstelle ${memories.length} übersetzte Memory-Einträge für neues Memo` - ); - for (const memory of memories) { - if (memory.content) { - const translatedContent = await translateText( - memory.content, - target_language, - functionId - ); - const translatedTitle = memory.title - ? await translateText(memory.title, target_language, functionId) - : memory.title; - // Erstelle neues Memory für das übersetzte Memo - const { error: memoryCreateError } = await memoro_sb.from('memories').insert({ - memo_id: newMemoId, - title: translatedTitle, - content: translatedContent, - media: memory.media, - sort_order: memory.sort_order, - metadata: { - ...memory.metadata, - translated_from_memory_id: memory.id, - translation: { - target_language: target_language, - translated_at: new Date().toISOString(), - }, - }, - }); - if (memoryCreateError) { - log( - 'WARN', - `[${functionId}] Fehler beim Erstellen des übersetzten Memory:`, - memoryCreateError - ); - } else { - log( - 'DEBUG', - `[${functionId}] Übersetztes Memory erfolgreich erstellt für Original Memory ${memory.id}` - ); - translatedMemoriesCount++; - } - } - } - } - // Set completed status - await setMemoCompletedStatus(memoro_sb, memo_id, 'translate', { - target_language, - new_memo_id: newMemoId, - translated_fields: { - transcript: !!translatedTranscript, - headline: !!translatedHeadline, - intro: !!translatedIntro, - memories_count: translatedMemoriesCount, - }, - }); - log( - 'INFO', - `[${functionId}] Übersetzung erfolgreich abgeschlossen für Memo ${memo_id}, neues Memo erstellt: ${newMemoId}` - ); - // Erfolgreiche Antwort - return new Response( - JSON.stringify({ - success: true, - original_memo_id: memo_id, - new_memo_id: newMemoId, - translated_fields: { - transcript: !!translatedTranscript, - headline: !!translatedHeadline, - intro: !!translatedIntro, - memories_count: translatedMemoriesCount, - }, - target_language, - }), - { - headers: { - ...corsHeaders, - 'Content-Type': 'application/json', - }, - status: 200, - } - ); - } catch (error) { - log('ERROR', `[${functionId}] Unerwarteter Fehler in der Translate-Funktion:`, error); - // Set error status in database - const errorToLog = error instanceof Error ? error : new Error(String(error)); - await setMemoErrorStatus(memoro_sb, memo_id_to_update, 'translate', errorToLog); - // Return error response - return createErrorResponse(`Unerwarteter Fehler: ${errorToLog.message}`, 500, corsHeaders); - } -}); diff --git a/apps/memoro/apps/backend/test/jest-setup.ts b/apps/memoro/apps/backend/test/jest-setup.ts deleted file mode 100644 index 679a5a6d5..000000000 --- a/apps/memoro/apps/backend/test/jest-setup.ts +++ /dev/null @@ -1,15 +0,0 @@ -// Global test setup -// Add any global test configuration here - -// Increase timeout for longer running tests -jest.setTimeout(30000); - -// Mock console methods to reduce noise during tests -global.console = { - ...console, - log: jest.fn(), - debug: jest.fn(), - info: jest.fn(), - warn: jest.fn(), - error: jest.fn(), -}; diff --git a/apps/memoro/apps/backend/tsconfig.build.json b/apps/memoro/apps/backend/tsconfig.build.json deleted file mode 100644 index 9d5195a3c..000000000 --- a/apps/memoro/apps/backend/tsconfig.build.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "extends": "./tsconfig.json", - "exclude": ["node_modules", "test", "dist", "supabase", "**/*spec.ts", "jest.config.js"] -} diff --git a/apps/memoro/apps/backend/tsconfig.json b/apps/memoro/apps/backend/tsconfig.json deleted file mode 100644 index 8f5aedf3c..000000000 --- a/apps/memoro/apps/backend/tsconfig.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "compilerOptions": { - "module": "commonjs", - "declaration": true, - "removeComments": true, - "emitDecoratorMetadata": true, - "experimentalDecorators": true, - "allowSyntheticDefaultImports": true, - "target": "ES2021", - "sourceMap": true, - "outDir": "./dist", - "baseUrl": "./", - "incremental": true, - "skipLibCheck": true, - "strictNullChecks": false, - "noImplicitAny": false, - "strictBindCallApply": false, - "forceConsistentCasingInFileNames": false, - "noFallthroughCasesInSwitch": false - } -} diff --git a/apps/memoro/apps/backend/verify-build.sh b/apps/memoro/apps/backend/verify-build.sh deleted file mode 100755 index 08433b978..000000000 --- a/apps/memoro/apps/backend/verify-build.sh +++ /dev/null @@ -1,54 +0,0 @@ -#!/bin/bash -# Script to verify the build and debug logging - -echo "=== Build Verification Script ===" -echo "Current directory: $(pwd)" -echo "" - -echo "1. Checking if dist directory exists..." -if [ -d "dist" ]; then - echo "✓ dist directory exists" - echo " Last modified: $(stat -f "%Sm" dist 2>/dev/null || stat -c "%y" dist 2>/dev/null)" -else - echo "✗ dist directory not found" -fi -echo "" - -echo "2. Checking main.js in dist..." -if [ -f "dist/main.js" ]; then - echo "✓ dist/main.js exists" - echo " Checking for debug logs..." - grep -n "STARTUP DEBUG" dist/main.js | head -5 -else - echo "✗ dist/main.js not found" -fi -echo "" - -echo "3. Checking controller debug logs..." -if [ -f "dist/memoro/memoro.controller.js" ]; then - echo "✓ dist/memoro/memoro.controller.js exists" - echo " Checking for CRITICAL DEBUG logs..." - grep -n "CRITICAL DEBUG" dist/memoro/memoro.controller.js | head -5 -else - echo "✗ dist/memoro/memoro.controller.js not found" -fi -echo "" - -echo "4. Building the project..." -npm run build -echo "" - -echo "5. Checking build output again..." -echo " main.js debug logs:" -grep "STARTUP DEBUG" dist/main.js | head -3 -echo "" - -echo "6. Docker image info..." -echo " Current cloudbuild tag: $(grep -o 'memoro-service:v[0-9.]*' cloudbuild-memoro.yaml | head -1)" -echo "" - -echo "=== Recommendations ===" -echo "1. Increment the version in cloudbuild-memoro.yaml (currently v4.9.8)" -echo "2. Ensure Cloud Run environment variables are set correctly" -echo "3. Check Cloud Run logs filter - it might be filtering INFO level logs" -echo "4. Use console.error() instead of console.log() for critical debug messages" \ No newline at end of file diff --git a/apps/memoro/package.json b/apps/memoro/package.json index b78e31ed2..f3acf3403 100644 --- a/apps/memoro/package.json +++ b/apps/memoro/package.json @@ -5,8 +5,8 @@ "description": "Memoro - AI-powered voice recording & memo management", "scripts": { "dev": "pnpm run --filter=@memoro/* --parallel dev", - "dev:backend": "pnpm --filter @memoro/backend start:dev", - "dev:audio-backend": "pnpm --filter @memoro/audio-backend start:dev", + "dev:server": "cd apps/server && bun run --watch src/index.ts", + "dev:audio-server": "cd apps/audio-server && bun run --watch src/index.ts", "dev:web": "pnpm --filter @memoro/web dev", "dev:mobile": "pnpm --filter @memoro/mobile start", "dev:landing": "pnpm --filter @memoro/landing dev" diff --git a/package.json b/package.json index 10405a616..7d9c35e1d 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "version": "1.0.0", "private": true, "type": "module", - "description": "Manacore Monorepo containing manacore, manadeck, picture, chat, zitare, and presi", + "description": "Manacore Monorepo containing manacore, cards, picture, chat, zitare, and presi", "scripts": { "dev": "turbo run dev", "build": "turbo run build", @@ -28,7 +28,7 @@ "build:packages": "pnpm --filter '@manacore/*' build", "postinstall": "node scripts/generate-env.mjs || true && pnpm run build:packages || true", "manacore:dev": "turbo run dev --filter=manacore...", - "manadeck:dev": "turbo run dev --filter=manadeck...", + "cards:dev": "turbo run dev --filter=cards...", "picture:dev": "turbo run dev --filter=picture...", "chat:dev": "turbo run dev --filter=chat...", "dev:manacore:web": "pnpm --filter @manacore/web dev", @@ -36,10 +36,10 @@ "dev:manacore:mobile": "pnpm --filter @manacore/mobile dev", "dev:manacore:app": "pnpm --filter @manacore/web dev", "dev:manacore:full": "concurrently -n web,servers -c cyan,yellow \"pnpm dev:manacore:web\" \"pnpm dev:manacore:servers\"", - "dev:manadeck:web": "pnpm --filter @manadeck/web dev", - "dev:manadeck:landing": "pnpm --filter @manadeck/landing dev", - "dev:manadeck:mobile": "pnpm --filter @manadeck/mobile dev", - "dev:manadeck:app": "concurrently -n server,web -c yellow,cyan \"pnpm dev:manadeck:server\" \"pnpm dev:manadeck:web\"", + "dev:cards:web": "pnpm --filter @cards/web dev", + "dev:cards:landing": "pnpm --filter @cards/landing dev", + "dev:cards:mobile": "pnpm --filter @cards/mobile dev", + "dev:cards:app": "concurrently -n server,web -c yellow,cyan \"pnpm dev:cards:server\" \"pnpm dev:cards:web\"", "dev:picture:web": "pnpm --filter @picture/web dev", "dev:picture:landing": "pnpm --filter @picture/landing dev", "dev:picture:mobile": "pnpm --filter @picture/mobile dev", @@ -54,13 +54,8 @@ "dev:sync:build": "cd services/mana-sync && go build -o server ./cmd/server", "dev:chat:full": "./scripts/setup-databases.sh chat && ./scripts/setup-databases.sh auth && concurrently -n auth,sync,server,web -c blue,magenta,yellow,cyan \"pnpm dev:auth\" \"pnpm dev:sync\" \"pnpm dev:chat:server\" \"pnpm dev:chat:web\"", "memoro:dev": "turbo run dev --filter=memoro...", - "dev:memoro:web": "pnpm --filter @memoro/web dev", "dev:memoro:mobile": "pnpm --filter @memoro/mobile start", "dev:memoro:landing": "pnpm --filter @memoro/landing dev", - "dev:memoro:backend": "pnpm --filter @memoro/backend start:dev", - "dev:memoro:audio-backend": "pnpm --filter @memoro/audio-backend start:dev", - "dev:memoro:app": "concurrently -n backend,web -c yellow,cyan \"pnpm dev:memoro:backend\" \"pnpm dev:memoro:web\"", - "dev:memoro:full": "concurrently -n auth,sync,backend,web -c blue,magenta,yellow,cyan \"pnpm dev:auth\" \"pnpm dev:sync\" \"pnpm dev:memoro:backend\" \"pnpm dev:memoro:web\"", "zitare:dev": "turbo run dev --filter=zitare...", "dev:zitare:mobile": "pnpm --filter @zitare/mobile dev", "dev:zitare:web": "pnpm --filter @zitare/web dev", @@ -224,20 +219,20 @@ "deploy:landing:chat": "pnpm --filter @chat/landing build && npx wrangler pages deploy apps/chat/apps/landing/dist --project-name=chat-landing", "deploy:landing:picture": "pnpm --filter @picture/landing build && npx wrangler pages deploy apps/picture/apps/landing/dist --project-name=picture-landing", "deploy:landing:manacore": "pnpm --filter @manacore/landing build && npx wrangler pages deploy apps/manacore/apps/landing/dist --project-name=manacore-landing", - "deploy:landing:manadeck": "pnpm --filter @manadeck/landing build && npx wrangler pages deploy apps/manadeck/apps/landing/dist --project-name=manadeck-landing", + "deploy:landing:cards": "pnpm --filter @cards/landing build && npx wrangler pages deploy apps/cards/apps/landing/dist --project-name=cards-landing", "deploy:landing:zitare": "pnpm --filter @zitare/landing build && npx wrangler pages deploy apps/zitare/apps/landing/dist --project-name=zitare-landing", "deploy:landing:presi": "pnpm --filter @presi/landing build && npx wrangler pages deploy apps/presi/apps/landing/dist --project-name=presi-landing", "deploy:landing:clock": "pnpm --filter @clock/landing build && npx wrangler pages deploy apps/clock/apps/landing/dist --project-name=clocks-landing", "deploy:landing:mail": "pnpm --filter @mail/landing build && npx wrangler pages deploy apps/mail/apps/landing/dist --project-name=mail-landing", "deploy:landing:moodlit": "pnpm --filter @moodlit/landing build && npx wrangler pages deploy apps/moodlit/apps/landing/dist --project-name=moodlit-landing", "deploy:landing:it": "pnpm --filter @mana/it-landing build && npx wrangler pages deploy services/it-landing/dist --project-name=it-landing", - "deploy:landing:all": "pnpm deploy:landing:calendar && pnpm deploy:landing:chat && pnpm deploy:landing:picture && pnpm deploy:landing:manacore && pnpm deploy:landing:manadeck && pnpm deploy:landing:zitare && pnpm deploy:landing:presi && pnpm deploy:landing:clock && pnpm deploy:landing:mail && pnpm deploy:landing:nutriphi", + "deploy:landing:all": "pnpm deploy:landing:calendar && pnpm deploy:landing:chat && pnpm deploy:landing:picture && pnpm deploy:landing:manacore && pnpm deploy:landing:cards && pnpm deploy:landing:zitare && pnpm deploy:landing:presi && pnpm deploy:landing:clock && pnpm deploy:landing:mail && pnpm deploy:landing:nutriphi", "dev:docs": "pnpm --filter @manacore/docs dev", "build:docs": "pnpm --filter @manacore/docs build", "deploy:docs": "pnpm --filter @manacore/docs build && npx wrangler pages deploy apps/docs/dist --project-name=manacore-docs", "cf:login": "npx wrangler login", "cf:projects:list": "npx wrangler pages project list", - "cf:projects:create": "echo 'Creating Cloudflare Pages projects...' && npx wrangler pages project create chat-landing --production-branch=main && npx wrangler pages project create picture-landing --production-branch=main && npx wrangler pages project create manacore-landing --production-branch=main && npx wrangler pages project create manadeck-landing --production-branch=main && npx wrangler pages project create zitare-landing --production-branch=main", + "cf:projects:create": "echo 'Creating Cloudflare Pages projects...' && npx wrangler pages project create chat-landing --production-branch=main && npx wrangler pages project create picture-landing --production-branch=main && npx wrangler pages project create manacore-landing --production-branch=main && npx wrangler pages project create cards-landing --production-branch=main && npx wrangler pages project create zitare-landing --production-branch=main", "dev:search": "cd services/mana-search && go run ./cmd/server", "dev:crawler": "cd services/mana-crawler && go run ./cmd/server", "dev:notify": "cd services/mana-notify && go run ./cmd/server", @@ -258,7 +253,7 @@ "dev:chat:server": "cd apps/chat/apps/server && bun run --watch src/index.ts", "dev:contacts:server": "cd apps/contacts/apps/server && bun run --watch src/index.ts", "dev:context:server": "cd apps/context/apps/server && bun run --watch src/index.ts", - "dev:manadeck:server": "cd apps/manadeck/apps/server && bun run --watch src/index.ts", + "dev:cards:server": "cd apps/cards/apps/server && bun run --watch src/index.ts", "dev:mukke:server": "cd apps/mukke/apps/server && bun run --watch src/index.ts", "dev:nutriphi:server": "cd apps/nutriphi/apps/server && bun run --watch src/index.ts", "dev:picture:server": "cd apps/picture/apps/server && bun run --watch src/index.ts", @@ -270,7 +265,7 @@ "dev:chat:local": "concurrently -n sync,server,web -c magenta,yellow,cyan \"pnpm dev:sync\" \"pnpm dev:chat:server\" \"pnpm dev:chat:web\"", "dev:contacts:local": "concurrently -n sync,server,web -c magenta,yellow,cyan \"pnpm dev:sync\" \"pnpm dev:contacts:server\" \"pnpm dev:contacts:web\"", "dev:context:local": "concurrently -n sync,server,web -c magenta,yellow,cyan \"pnpm dev:sync\" \"pnpm dev:context:server\" \"pnpm dev:context:web\"", - "dev:manadeck:local": "concurrently -n sync,server,web -c magenta,yellow,cyan \"pnpm dev:sync\" \"pnpm dev:manadeck:server\" \"pnpm dev:manadeck:web\"", + "dev:cards:local": "concurrently -n sync,server,web -c magenta,yellow,cyan \"pnpm dev:sync\" \"pnpm dev:cards:server\" \"pnpm dev:cards:web\"", "dev:mukke:local": "concurrently -n sync,server,web -c magenta,yellow,cyan \"pnpm dev:sync\" \"pnpm dev:mukke:server\" \"pnpm dev:mukke:web\"", "dev:nutriphi:local": "concurrently -n sync,server,web -c magenta,yellow,cyan \"pnpm dev:sync\" \"pnpm dev:nutriphi:server\" \"pnpm dev:nutriphi:web\"", "dev:picture:local": "concurrently -n sync,server,web -c magenta,yellow,cyan \"pnpm dev:sync\" \"pnpm dev:picture:server\" \"pnpm dev:picture:web\"", @@ -288,7 +283,7 @@ "dev:times:local": "concurrently -n sync,web -c magenta,cyan \"pnpm dev:sync\" \"pnpm dev:times:web\"", "dev:calc:local": "concurrently -n sync,web -c magenta,cyan \"pnpm dev:sync\" \"pnpm dev:calc:web\"", "dev:manavoxel:local": "concurrently -n sync,web -c magenta,cyan \"pnpm dev:sync\" \"pnpm dev:manavoxel:web\"", - "dev:manacore:servers": "concurrently -n auth,sync,chat,calendar,contacts,todo,picture,manadeck -c blue,magenta,green,yellow,red,cyan,white,gray \"pnpm dev:auth\" \"pnpm dev:sync\" \"pnpm dev:chat:server\" \"pnpm dev:calendar:server\" \"pnpm dev:contacts:server\" \"pnpm dev:todo:server\" \"pnpm dev:picture:server\" \"pnpm dev:manadeck:server\"" + "dev:manacore:servers": "concurrently -n auth,sync,chat,calendar,contacts,todo,picture,cards -c blue,magenta,green,yellow,red,cyan,white,gray \"pnpm dev:auth\" \"pnpm dev:sync\" \"pnpm dev:chat:server\" \"pnpm dev:calendar:server\" \"pnpm dev:contacts:server\" \"pnpm dev:todo:server\" \"pnpm dev:picture:server\" \"pnpm dev:cards:server\"" }, "devDependencies": { "@manacore/eslint-config": "workspace:*", diff --git a/packages/manadeck-database/.env.example b/packages/cards-database/.env.example similarity index 100% rename from packages/manadeck-database/.env.example rename to packages/cards-database/.env.example diff --git a/packages/manadeck-database/.gitignore b/packages/cards-database/.gitignore similarity index 100% rename from packages/manadeck-database/.gitignore rename to packages/cards-database/.gitignore diff --git a/packages/manadeck-database/docker-compose.yml b/packages/cards-database/docker-compose.yml similarity index 100% rename from packages/manadeck-database/docker-compose.yml rename to packages/cards-database/docker-compose.yml diff --git a/packages/manadeck-database/drizzle.config.ts b/packages/cards-database/drizzle.config.ts similarity index 100% rename from packages/manadeck-database/drizzle.config.ts rename to packages/cards-database/drizzle.config.ts diff --git a/packages/manadeck-database/package.json b/packages/cards-database/package.json similarity index 100% rename from packages/manadeck-database/package.json rename to packages/cards-database/package.json diff --git a/packages/manadeck-database/src/client.ts b/packages/cards-database/src/client.ts similarity index 100% rename from packages/manadeck-database/src/client.ts rename to packages/cards-database/src/client.ts diff --git a/packages/manadeck-database/src/index.ts b/packages/cards-database/src/index.ts similarity index 100% rename from packages/manadeck-database/src/index.ts rename to packages/cards-database/src/index.ts diff --git a/packages/manadeck-database/src/migrate-from-supabase.ts b/packages/cards-database/src/migrate-from-supabase.ts similarity index 100% rename from packages/manadeck-database/src/migrate-from-supabase.ts rename to packages/cards-database/src/migrate-from-supabase.ts diff --git a/packages/manadeck-database/src/migrate.ts b/packages/cards-database/src/migrate.ts similarity index 100% rename from packages/manadeck-database/src/migrate.ts rename to packages/cards-database/src/migrate.ts diff --git a/packages/manadeck-database/src/schema/aiGenerations.ts b/packages/cards-database/src/schema/aiGenerations.ts similarity index 100% rename from packages/manadeck-database/src/schema/aiGenerations.ts rename to packages/cards-database/src/schema/aiGenerations.ts diff --git a/packages/manadeck-database/src/schema/cardProgress.ts b/packages/cards-database/src/schema/cardProgress.ts similarity index 100% rename from packages/manadeck-database/src/schema/cardProgress.ts rename to packages/cards-database/src/schema/cardProgress.ts diff --git a/packages/manadeck-database/src/schema/cards.ts b/packages/cards-database/src/schema/cards.ts similarity index 100% rename from packages/manadeck-database/src/schema/cards.ts rename to packages/cards-database/src/schema/cards.ts diff --git a/packages/manadeck-database/src/schema/dailyProgress.ts b/packages/cards-database/src/schema/dailyProgress.ts similarity index 100% rename from packages/manadeck-database/src/schema/dailyProgress.ts rename to packages/cards-database/src/schema/dailyProgress.ts diff --git a/packages/manadeck-database/src/schema/deckTemplates.ts b/packages/cards-database/src/schema/deckTemplates.ts similarity index 100% rename from packages/manadeck-database/src/schema/deckTemplates.ts rename to packages/cards-database/src/schema/deckTemplates.ts diff --git a/packages/manadeck-database/src/schema/decks.ts b/packages/cards-database/src/schema/decks.ts similarity index 100% rename from packages/manadeck-database/src/schema/decks.ts rename to packages/cards-database/src/schema/decks.ts diff --git a/packages/manadeck-database/src/schema/index.ts b/packages/cards-database/src/schema/index.ts similarity index 100% rename from packages/manadeck-database/src/schema/index.ts rename to packages/cards-database/src/schema/index.ts diff --git a/packages/manadeck-database/src/schema/studySessions.ts b/packages/cards-database/src/schema/studySessions.ts similarity index 100% rename from packages/manadeck-database/src/schema/studySessions.ts rename to packages/cards-database/src/schema/studySessions.ts diff --git a/packages/manadeck-database/src/schema/userStats.ts b/packages/cards-database/src/schema/userStats.ts similarity index 100% rename from packages/manadeck-database/src/schema/userStats.ts rename to packages/cards-database/src/schema/userStats.ts diff --git a/packages/manadeck-database/src/seed.ts b/packages/cards-database/src/seed.ts similarity index 100% rename from packages/manadeck-database/src/seed.ts rename to packages/cards-database/src/seed.ts diff --git a/packages/manadeck-database/src/test-connection.ts b/packages/cards-database/src/test-connection.ts similarity index 100% rename from packages/manadeck-database/src/test-connection.ts rename to packages/cards-database/src/test-connection.ts diff --git a/packages/manadeck-database/tsconfig.json b/packages/cards-database/tsconfig.json similarity index 100% rename from packages/manadeck-database/tsconfig.json rename to packages/cards-database/tsconfig.json diff --git a/packages/shared-branding/src/logos/ManaDeckLogo.svelte b/packages/shared-branding/src/logos/CardsLogo.svelte similarity index 100% rename from packages/shared-branding/src/logos/ManaDeckLogo.svelte rename to packages/shared-branding/src/logos/CardsLogo.svelte diff --git a/packages/shared-landing-ui/src/themes/manadeck.css b/packages/shared-landing-ui/src/themes/cards.css similarity index 100% rename from packages/shared-landing-ui/src/themes/manadeck.css rename to packages/shared-landing-ui/src/themes/cards.css diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b4d85d95e..0c65367ee 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -250,14 +250,14 @@ importers: version: link:../../../../packages/shared-landing-ui astro: specifier: ^5.16.0 - version: 5.16.0(@azure/storage-blob@12.31.0)(@netlify/blobs@10.7.4)(@types/node@20.19.25)(ioredis@5.9.2)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.53.3)(terser@5.44.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3) + version: 5.16.0(@azure/storage-blob@12.31.0)(@netlify/blobs@10.7.4)(@types/node@20.19.25)(ioredis@5.9.2)(jiti@1.21.7)(lightningcss@1.30.2)(rollup@4.53.3)(terser@5.44.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3) typescript: specifier: ^5.9.2 version: 5.9.3 devDependencies: '@astrojs/tailwind': specifier: ^6.0.2 - version: 6.0.2(astro@5.16.0(@azure/storage-blob@12.31.0)(@netlify/blobs@10.7.4)(@types/node@20.19.25)(ioredis@5.9.2)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.53.3)(terser@5.44.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3))(tailwindcss@3.4.18(tsx@4.21.0)(yaml@2.8.3))(ts-node@10.9.2(@types/node@20.19.25)(typescript@5.9.3)) + version: 6.0.2(astro@5.16.0(@azure/storage-blob@12.31.0)(@netlify/blobs@10.7.4)(@types/node@20.19.25)(ioredis@5.9.2)(jiti@1.21.7)(lightningcss@1.30.2)(rollup@4.53.3)(terser@5.44.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3))(tailwindcss@3.4.18(tsx@4.21.0)(yaml@2.8.3))(ts-node@10.9.2(@types/node@20.19.25)(typescript@5.9.3)) '@tailwindcss/typography': specifier: ^0.5.18 version: 0.5.19(tailwindcss@3.4.18(tsx@4.21.0)(yaml@2.8.3)) @@ -266,13 +266,13 @@ importers: version: 20.19.25 eslint: specifier: ^9.0.0 - version: 9.39.1(jiti@2.6.1) + version: 9.39.1(jiti@1.21.7) eslint-config-prettier: specifier: ^9.1.0 - version: 9.1.2(eslint@9.39.1(jiti@2.6.1)) + version: 9.1.2(eslint@9.39.1(jiti@1.21.7)) eslint-plugin-astro: specifier: ^1.0.0 - version: 1.5.0(eslint@9.39.1(jiti@2.6.1)) + version: 1.5.0(eslint@9.39.1(jiti@1.21.7)) prettier: specifier: ^3.6.2 version: 3.6.2 @@ -471,6 +471,325 @@ importers: specifier: ~5.9.2 version: 5.9.3 + apps/cards/apps/landing: + dependencies: + '@astrojs/check': + specifier: ^0.9.0 + version: 0.9.8(prettier-plugin-astro@0.14.1)(prettier@3.6.2)(typescript@5.9.3) + '@astrojs/sitemap': + specifier: ^3.2.1 + version: 3.7.2 + '@manacore/shared-landing-ui': + specifier: workspace:* + version: link:../../../../packages/shared-landing-ui + astro: + specifier: ^5.16.0 + version: 5.18.1(@azure/storage-blob@12.31.0)(@netlify/blobs@10.7.4)(@types/node@24.10.1)(ioredis@5.9.2)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.53.3)(terser@5.44.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3) + astro-icon: + specifier: ^1.1.5 + version: 1.1.5 + typescript: + specifier: ^5.0.0 + version: 5.9.3 + devDependencies: + '@astrojs/tailwind': + specifier: ^6.0.0 + version: 6.0.2(astro@5.18.1(@azure/storage-blob@12.31.0)(@netlify/blobs@10.7.4)(@types/node@24.10.1)(ioredis@5.9.2)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.53.3)(terser@5.44.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3))(tailwindcss@3.4.18(tsx@4.21.0)(yaml@2.8.3))(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3)) + '@tailwindcss/typography': + specifier: ^0.5.16 + version: 0.5.19(tailwindcss@3.4.18(tsx@4.21.0)(yaml@2.8.3)) + tailwindcss: + specifier: ^3.4.17 + version: 3.4.18(tsx@4.21.0)(yaml@2.8.3) + + apps/cards/apps/mobile: + dependencies: + '@expo/ui': + specifier: ~0.2.0-beta.6 + version: 0.2.0-beta.7(expo@55.0.5)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0) + '@expo/vector-icons': + specifier: ^15.0.2 + version: 15.0.3(expo-font@14.0.10)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0) + '@manacore/shared-auth': + specifier: workspace:* + version: link:../../../../packages/shared-auth + '@react-native-async-storage/async-storage': + specifier: 2.2.0 + version: 2.2.0(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0)) + '@react-native-community/netinfo': + specifier: ^11.4.1 + version: 11.4.1(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0)) + '@react-native-segmented-control/segmented-control': + specifier: 2.5.7 + version: 2.5.7(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0) + '@react-navigation/native': + specifier: ^7.0.3 + version: 7.1.33(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0) + base64-js: + specifier: ^1.5.1 + version: 1.5.1 + class-variance-authority: + specifier: ^0.7.1 + version: 0.7.1 + expo: + specifier: ~55.0.5 + version: 55.0.5(@babel/core@7.28.5)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@55.0.7)(expo-router@55.0.5)(react-dom@19.2.0(react@19.2.0))(react-native-webview@13.12.2(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) + expo-blur: + specifier: ~15.0.7 + version: 15.0.7(expo@55.0.5)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0) + expo-build-properties: + specifier: ~1.0.9 + version: 1.0.9(expo@55.0.5) + expo-constants: + specifier: ~55.0.7 + version: 55.0.7(expo@55.0.5)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(typescript@5.9.3) + expo-dev-client: + specifier: ^6.0.13 + version: 6.0.18(expo@55.0.5) + expo-device: + specifier: ~8.0.9 + version: 8.0.9(expo@55.0.5) + expo-file-system: + specifier: ~55.0.10 + version: 55.0.10(expo@55.0.5)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0)) + expo-font: + specifier: ~14.0.9 + version: 14.0.10(expo@55.0.5)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0) + expo-image-picker: + specifier: ~55.0.12 + version: 55.0.12(expo@55.0.5) + expo-linking: + specifier: ~55.0.7 + version: 55.0.7(expo@55.0.5)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) + expo-router: + specifier: ~55.0.5 + version: 55.0.5(6d2zdlrrz5o6rsdio5yhgp3juy) + expo-secure-store: + specifier: ~55.0.8 + version: 55.0.8(expo@55.0.5) + expo-status-bar: + specifier: ~55.0.4 + version: 55.0.4(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0) + expo-symbols: + specifier: ~1.0.7 + version: 1.0.7(expo@55.0.5)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0)) + expo-system-ui: + specifier: ~55.0.9 + version: 55.0.9(expo@55.0.5)(react-native-web@0.21.2(encoding@0.1.13)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0)) + expo-updates: + specifier: ~29.0.12 + version: 29.0.13(expo@55.0.5)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0) + expo-web-browser: + specifier: ~55.0.9 + version: 55.0.9(expo@55.0.5)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0)) + nativewind: + specifier: ~4.2.3 + version: 4.2.3(react-native-reanimated@4.1.5(@babel/core@7.28.5)(react-native-worklets@0.5.1(@babel/core@7.28.5)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native-safe-area-context@5.6.2(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native-svg@15.12.1(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)(tailwindcss@3.4.18(tsx@4.21.0)(yaml@2.8.3)) + react: + specifier: 19.2.0 + version: 19.2.0 + react-dom: + specifier: 19.2.0 + version: 19.2.0(react@19.2.0) + react-native: + specifier: 0.83.2 + version: 0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0) + react-native-calendars: + specifier: ^1.1313.0 + version: 1.1313.0(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0) + react-native-draggable-flatlist: + specifier: ^4.0.3 + version: 4.0.3(@babel/core@7.28.5)(react-native-gesture-handler@2.28.0(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native-reanimated@4.1.5(@babel/core@7.28.5)(react-native-worklets@0.5.1(@babel/core@7.28.5)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0)) + react-native-gesture-handler: + specifier: ~2.28.0 + version: 2.28.0(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0) + react-native-reanimated: + specifier: ~4.1.5 + version: 4.1.5(@babel/core@7.28.5)(react-native-worklets@0.5.1(@babel/core@7.28.5)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0) + react-native-safe-area-context: + specifier: ~5.6.0 + version: 5.6.2(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0) + react-native-screens: + specifier: ~4.16.0 + version: 4.16.0(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0) + react-native-svg: + specifier: 15.12.1 + version: 15.12.1(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0) + react-native-web: + specifier: ~0.21.0 + version: 0.21.2(encoding@0.1.13)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + react-native-worklets: + specifier: 0.5.1 + version: 0.5.1(@babel/core@7.28.5)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0) + zustand: + specifier: ^5.0.8 + version: 5.0.8(@types/react@19.1.17)(react@19.2.0)(use-sync-external-store@1.6.0(react@19.2.0)) + devDependencies: + '@babel/core': + specifier: ^7.20.0 + version: 7.28.5 + '@types/react': + specifier: ~19.1.0 + version: 19.1.17 + '@types/react-dom': + specifier: ^19.2.3 + version: 19.2.3(@types/react@19.1.17) + ajv: + specifier: ^8.12.0 + version: 8.17.1 + eslint: + specifier: ^9.25.1 + version: 9.39.1(jiti@2.6.1) + eslint-config-expo: + specifier: ~10.0.0 + version: 10.0.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) + eslint-config-prettier: + specifier: ^10.1.2 + version: 10.1.8(eslint@9.39.1(jiti@2.6.1)) + prettier: + specifier: ^3.2.5 + version: 3.6.2 + prettier-plugin-tailwindcss: + specifier: ^0.5.11 + version: 0.5.14(prettier-plugin-astro@0.14.1)(prettier-plugin-svelte@3.4.0(prettier@3.6.2)(svelte@5.44.0))(prettier@3.6.2) + tailwindcss: + specifier: ^3.4.0 + version: 3.4.18(tsx@4.21.0)(yaml@2.8.3) + typescript: + specifier: ~5.9.3 + version: 5.9.3 + + apps/cards/apps/server: + dependencies: + '@manacore/shared-hono': + specifier: workspace:* + version: link:../../../../packages/shared-hono + hono: + specifier: ^4.7.0 + version: 4.12.9 + devDependencies: + typescript: + specifier: ^5.9.3 + version: 5.9.3 + + apps/cards/apps/web: + dependencies: + '@manacore/feedback': + specifier: workspace:* + version: link:../../../../packages/feedback + '@manacore/help': + specifier: workspace:* + version: link:../../../../packages/help + '@manacore/local-store': + specifier: workspace:* + version: link:../../../../packages/local-store + '@manacore/shared-app-onboarding': + specifier: workspace:* + version: link:../../../../packages/shared-app-onboarding + '@manacore/shared-auth': + specifier: workspace:* + version: link:../../../../packages/shared-auth + '@manacore/shared-auth-stores': + specifier: workspace:* + version: link:../../../../packages/shared-auth-stores + '@manacore/shared-auth-ui': + specifier: workspace:* + version: link:../../../../packages/shared-auth-ui + '@manacore/shared-branding': + specifier: workspace:* + version: link:../../../../packages/shared-branding + '@manacore/shared-config': + specifier: workspace:* + version: link:../../../../packages/shared-config + '@manacore/shared-error-tracking': + specifier: workspace:* + version: link:../../../../packages/shared-error-tracking + '@manacore/shared-i18n': + specifier: workspace:* + version: link:../../../../packages/shared-i18n + '@manacore/shared-icons': + specifier: workspace:* + version: link:../../../../packages/shared-icons + '@manacore/shared-profile-ui': + specifier: workspace:* + version: link:../../../../packages/shared-profile-ui + '@manacore/shared-stores': + specifier: workspace:* + version: link:../../../../packages/shared-stores + '@manacore/shared-tags': + specifier: workspace:* + version: link:../../../../packages/shared-tags + '@manacore/shared-tailwind': + specifier: workspace:* + version: link:../../../../packages/shared-tailwind + '@manacore/shared-theme': + specifier: workspace:* + version: link:../../../../packages/shared-theme + '@manacore/shared-theme-ui': + specifier: workspace:* + version: link:../../../../packages/shared-theme-ui + '@manacore/shared-types': + specifier: workspace:* + version: link:../../../../packages/shared-types + '@manacore/shared-ui': + specifier: workspace:* + version: link:../../../../packages/shared-ui + '@manacore/shared-utils': + specifier: workspace:* + version: link:../../../../packages/shared-utils + '@manacore/subscriptions': + specifier: workspace:* + version: link:../../../../packages/subscriptions + svelte-i18n: + specifier: ^4.0.1 + version: 4.0.1(svelte@5.44.0) + devDependencies: + '@manacore/shared-pwa': + specifier: workspace:* + version: link:../../../../packages/shared-pwa + '@manacore/shared-vite-config': + specifier: workspace:* + version: link:../../../../packages/shared-vite-config + '@sveltejs/adapter-node': + specifier: ^5.0.0 + version: 5.4.0(@sveltejs/kit@2.49.0(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.44.0)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.44.0)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))) + '@sveltejs/kit': + specifier: ^2.47.1 + version: 2.49.0(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.44.0)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.44.0)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) + '@sveltejs/vite-plugin-svelte': + specifier: ^6.2.1 + version: 6.2.1(svelte@5.44.0)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) + '@tailwindcss/typography': + specifier: ^0.5.19 + version: 0.5.19(tailwindcss@4.1.17) + '@tailwindcss/vite': + specifier: ^4.1.7 + version: 4.1.17(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) + '@vite-pwa/sveltekit': + specifier: ^1.1.0 + version: 1.1.0(@sveltejs/kit@2.49.0(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.44.0)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.44.0)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)))(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(workbox-build@7.4.0(@types/babel__core@7.20.5))(workbox-window@7.4.0) + autoprefixer: + specifier: ^10.4.22 + version: 10.4.22(postcss@8.5.8) + postcss: + specifier: ^8.5.6 + version: 8.5.8 + svelte: + specifier: ^5.41.0 + version: 5.44.0 + svelte-check: + specifier: ^4.3.3 + version: 4.3.4(picomatch@4.0.3)(svelte@5.44.0)(typescript@5.9.3) + tailwindcss: + specifier: ^4.1.17 + version: 4.1.17 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + vite: + specifier: ^7.1.10 + version: 7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3) + apps/chat: {} apps/chat/apps/landing: @@ -535,7 +854,7 @@ importers: version: 55.0.7(expo@55.0.5)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)(typescript@5.3.3) expo-router: specifier: ~55.0.5 - version: 55.0.5(shihlejigi2nkza7wltmngtxfm) + version: 55.0.5(bhhyukj6njqvlfm2o3z6hpwcja) expo-status-bar: specifier: ~55.0.4 version: 55.0.4(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0) @@ -587,19 +906,19 @@ importers: version: 19.1.17 '@typescript-eslint/eslint-plugin': specifier: ^7.7.0 - version: 7.18.0(@typescript-eslint/parser@7.18.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3))(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3) + version: 7.18.0(@typescript-eslint/parser@7.18.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3))(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3) '@typescript-eslint/parser': specifier: ^7.7.0 - version: 7.18.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3) + version: 7.18.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3) dotenv: specifier: ^16.4.7 version: 16.6.1 eslint: specifier: ^9.39.1 - version: 9.39.1(jiti@1.21.7) + version: 9.39.1(jiti@2.6.1) eslint-config-universe: specifier: ^12.0.1 - version: 12.1.0(@types/eslint@9.6.1)(eslint@9.39.1(jiti@1.21.7))(prettier@3.6.2)(typescript@5.3.3) + version: 12.1.0(@types/eslint@9.6.1)(eslint@9.39.1(jiti@2.6.1))(prettier@3.6.2)(typescript@5.3.3) prettier: specifier: ^3.2.5 version: 3.6.2 @@ -1855,7 +2174,7 @@ importers: dependencies: '@expo/vector-icons': specifier: ^15.0.3 - version: 15.0.3(expo-font@55.0.4)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0) + version: 15.0.3(expo-font@55.0.4(expo@55.0.5)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0) '@manacore/shared-auth': specifier: workspace:* version: link:../../../../packages/shared-auth @@ -2123,325 +2442,6 @@ importers: specifier: ^4.0.14 version: 4.0.14(@opentelemetry/api@1.9.0)(@types/node@22.19.1)(@vitest/ui@4.0.14)(jiti@2.6.1)(jsdom@29.0.1(@noble/hashes@2.0.1))(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3) - apps/manadeck/apps/landing: - dependencies: - '@astrojs/check': - specifier: ^0.9.0 - version: 0.9.5(prettier-plugin-astro@0.14.1)(prettier@3.6.2)(typescript@5.9.3) - '@astrojs/sitemap': - specifier: ^3.2.1 - version: 3.6.0 - '@manacore/shared-landing-ui': - specifier: workspace:* - version: link:../../../../packages/shared-landing-ui - astro: - specifier: ^5.16.0 - version: 5.16.0(@azure/storage-blob@12.31.0)(@netlify/blobs@10.7.4)(@types/node@24.10.1)(ioredis@5.9.2)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.53.3)(terser@5.44.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3) - astro-icon: - specifier: ^1.1.5 - version: 1.1.5 - typescript: - specifier: ^5.0.0 - version: 5.9.3 - devDependencies: - '@astrojs/tailwind': - specifier: ^6.0.0 - version: 6.0.2(astro@5.16.0(@azure/storage-blob@12.31.0)(@netlify/blobs@10.7.4)(@types/node@24.10.1)(ioredis@5.9.2)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.53.3)(terser@5.44.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3))(tailwindcss@3.4.18(tsx@4.21.0)(yaml@2.8.3))(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3)) - '@tailwindcss/typography': - specifier: ^0.5.16 - version: 0.5.19(tailwindcss@3.4.18(tsx@4.21.0)(yaml@2.8.3)) - tailwindcss: - specifier: ^3.4.17 - version: 3.4.18(tsx@4.21.0)(yaml@2.8.3) - - apps/manadeck/apps/mobile: - dependencies: - '@expo/ui': - specifier: ~0.2.0-beta.6 - version: 0.2.0-beta.7(expo@55.0.5)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0) - '@expo/vector-icons': - specifier: ^15.0.2 - version: 15.0.3(expo-font@14.0.9)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0) - '@manacore/shared-auth': - specifier: workspace:* - version: link:../../../../packages/shared-auth - '@react-native-async-storage/async-storage': - specifier: 2.2.0 - version: 2.2.0(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0)) - '@react-native-community/netinfo': - specifier: ^11.4.1 - version: 11.4.1(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0)) - '@react-native-segmented-control/segmented-control': - specifier: 2.5.7 - version: 2.5.7(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0) - '@react-navigation/native': - specifier: ^7.0.3 - version: 7.1.21(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0) - base64-js: - specifier: ^1.5.1 - version: 1.5.1 - class-variance-authority: - specifier: ^0.7.1 - version: 0.7.1 - expo: - specifier: ~55.0.5 - version: 55.0.5(@babel/core@7.28.5)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@55.0.7)(expo-router@55.0.5)(react-dom@19.2.0(react@19.2.0))(react-native-webview@13.12.2(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) - expo-blur: - specifier: ~15.0.7 - version: 15.0.7(expo@55.0.5)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0) - expo-build-properties: - specifier: ~1.0.9 - version: 1.0.9(expo@55.0.5) - expo-constants: - specifier: ~55.0.7 - version: 55.0.7(expo@55.0.5)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(typescript@5.9.3) - expo-dev-client: - specifier: ^6.0.13 - version: 6.0.18(expo@55.0.5) - expo-device: - specifier: ~8.0.9 - version: 8.0.9(expo@55.0.5) - expo-file-system: - specifier: ~55.0.10 - version: 55.0.10(expo@55.0.5)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0)) - expo-font: - specifier: ~14.0.9 - version: 14.0.9(expo@55.0.5)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0) - expo-image-picker: - specifier: ~55.0.12 - version: 55.0.12(expo@55.0.5) - expo-linking: - specifier: ~55.0.7 - version: 55.0.7(expo@55.0.5)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) - expo-router: - specifier: ~55.0.5 - version: 55.0.5(wp3gdsn67a3vbeku7d2dtd5jzu) - expo-secure-store: - specifier: ~55.0.8 - version: 55.0.8(expo@55.0.5) - expo-status-bar: - specifier: ~55.0.4 - version: 55.0.4(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0) - expo-symbols: - specifier: ~1.0.7 - version: 1.0.7(expo@55.0.5)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0)) - expo-system-ui: - specifier: ~55.0.9 - version: 55.0.9(expo@55.0.5)(react-native-web@0.21.2(encoding@0.1.13)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0)) - expo-updates: - specifier: ~29.0.12 - version: 29.0.13(expo@55.0.5)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0) - expo-web-browser: - specifier: ~55.0.9 - version: 55.0.9(expo@55.0.5)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0)) - nativewind: - specifier: ~4.2.3 - version: 4.2.3(react-native-reanimated@4.1.5(@babel/core@7.28.5)(react-native-worklets@0.5.1(@babel/core@7.28.5)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native-safe-area-context@5.6.2(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native-svg@15.12.1(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)(tailwindcss@3.4.18(tsx@4.21.0)(yaml@2.8.3)) - react: - specifier: 19.2.0 - version: 19.2.0 - react-dom: - specifier: 19.2.0 - version: 19.2.0(react@19.2.0) - react-native: - specifier: 0.83.2 - version: 0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0) - react-native-calendars: - specifier: ^1.1313.0 - version: 1.1313.0(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0) - react-native-draggable-flatlist: - specifier: ^4.0.3 - version: 4.0.3(@babel/core@7.28.5)(react-native-gesture-handler@2.28.0(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native-reanimated@4.1.5(@babel/core@7.28.5)(react-native-worklets@0.5.1(@babel/core@7.28.5)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0)) - react-native-gesture-handler: - specifier: ~2.28.0 - version: 2.28.0(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0) - react-native-reanimated: - specifier: ~4.1.5 - version: 4.1.5(@babel/core@7.28.5)(react-native-worklets@0.5.1(@babel/core@7.28.5)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0) - react-native-safe-area-context: - specifier: ~5.6.0 - version: 5.6.2(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0) - react-native-screens: - specifier: ~4.16.0 - version: 4.16.0(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0) - react-native-svg: - specifier: 15.12.1 - version: 15.12.1(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0) - react-native-web: - specifier: ~0.21.0 - version: 0.21.2(encoding@0.1.13)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) - react-native-worklets: - specifier: 0.5.1 - version: 0.5.1(@babel/core@7.28.5)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0) - zustand: - specifier: ^5.0.8 - version: 5.0.8(@types/react@19.1.17)(react@19.2.0)(use-sync-external-store@1.6.0(react@19.2.0)) - devDependencies: - '@babel/core': - specifier: ^7.20.0 - version: 7.28.5 - '@types/react': - specifier: ~19.1.0 - version: 19.1.17 - '@types/react-dom': - specifier: ^19.2.3 - version: 19.2.3(@types/react@19.1.17) - ajv: - specifier: ^8.12.0 - version: 8.17.1 - eslint: - specifier: ^9.25.1 - version: 9.39.1(jiti@2.6.1) - eslint-config-expo: - specifier: ~10.0.0 - version: 10.0.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) - eslint-config-prettier: - specifier: ^10.1.2 - version: 10.1.8(eslint@9.39.1(jiti@2.6.1)) - prettier: - specifier: ^3.2.5 - version: 3.6.2 - prettier-plugin-tailwindcss: - specifier: ^0.5.11 - version: 0.5.14(prettier-plugin-astro@0.14.1)(prettier-plugin-svelte@3.4.0(prettier@3.6.2)(svelte@5.44.0))(prettier@3.6.2) - tailwindcss: - specifier: ^3.4.0 - version: 3.4.18(tsx@4.21.0)(yaml@2.8.3) - typescript: - specifier: ~5.9.3 - version: 5.9.3 - - apps/manadeck/apps/server: - dependencies: - '@manacore/shared-hono': - specifier: workspace:* - version: link:../../../../packages/shared-hono - hono: - specifier: ^4.7.0 - version: 4.12.9 - devDependencies: - typescript: - specifier: ^5.9.3 - version: 5.9.3 - - apps/manadeck/apps/web: - dependencies: - '@manacore/feedback': - specifier: workspace:* - version: link:../../../../packages/feedback - '@manacore/help': - specifier: workspace:* - version: link:../../../../packages/help - '@manacore/local-store': - specifier: workspace:* - version: link:../../../../packages/local-store - '@manacore/shared-app-onboarding': - specifier: workspace:* - version: link:../../../../packages/shared-app-onboarding - '@manacore/shared-auth': - specifier: workspace:* - version: link:../../../../packages/shared-auth - '@manacore/shared-auth-stores': - specifier: workspace:* - version: link:../../../../packages/shared-auth-stores - '@manacore/shared-auth-ui': - specifier: workspace:* - version: link:../../../../packages/shared-auth-ui - '@manacore/shared-branding': - specifier: workspace:* - version: link:../../../../packages/shared-branding - '@manacore/shared-config': - specifier: workspace:* - version: link:../../../../packages/shared-config - '@manacore/shared-error-tracking': - specifier: workspace:* - version: link:../../../../packages/shared-error-tracking - '@manacore/shared-i18n': - specifier: workspace:* - version: link:../../../../packages/shared-i18n - '@manacore/shared-icons': - specifier: workspace:* - version: link:../../../../packages/shared-icons - '@manacore/shared-profile-ui': - specifier: workspace:* - version: link:../../../../packages/shared-profile-ui - '@manacore/shared-stores': - specifier: workspace:* - version: link:../../../../packages/shared-stores - '@manacore/shared-tags': - specifier: workspace:* - version: link:../../../../packages/shared-tags - '@manacore/shared-tailwind': - specifier: workspace:* - version: link:../../../../packages/shared-tailwind - '@manacore/shared-theme': - specifier: workspace:* - version: link:../../../../packages/shared-theme - '@manacore/shared-theme-ui': - specifier: workspace:* - version: link:../../../../packages/shared-theme-ui - '@manacore/shared-types': - specifier: workspace:* - version: link:../../../../packages/shared-types - '@manacore/shared-ui': - specifier: workspace:* - version: link:../../../../packages/shared-ui - '@manacore/shared-utils': - specifier: workspace:* - version: link:../../../../packages/shared-utils - '@manacore/subscriptions': - specifier: workspace:* - version: link:../../../../packages/subscriptions - svelte-i18n: - specifier: ^4.0.1 - version: 4.0.1(svelte@5.44.0) - devDependencies: - '@manacore/shared-pwa': - specifier: workspace:* - version: link:../../../../packages/shared-pwa - '@manacore/shared-vite-config': - specifier: workspace:* - version: link:../../../../packages/shared-vite-config - '@sveltejs/adapter-node': - specifier: ^5.0.0 - version: 5.4.0(@sveltejs/kit@2.49.0(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.44.0)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.44.0)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))) - '@sveltejs/kit': - specifier: ^2.47.1 - version: 2.49.0(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.44.0)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.44.0)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) - '@sveltejs/vite-plugin-svelte': - specifier: ^6.2.1 - version: 6.2.1(svelte@5.44.0)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) - '@tailwindcss/typography': - specifier: ^0.5.19 - version: 0.5.19(tailwindcss@4.1.17) - '@tailwindcss/vite': - specifier: ^4.1.7 - version: 4.1.17(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) - '@vite-pwa/sveltekit': - specifier: ^1.1.0 - version: 1.1.0(@sveltejs/kit@2.49.0(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.44.0)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.44.0)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)))(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(workbox-build@7.4.0(@types/babel__core@7.20.5))(workbox-window@7.4.0) - autoprefixer: - specifier: ^10.4.22 - version: 10.4.22(postcss@8.5.6) - postcss: - specifier: ^8.5.6 - version: 8.5.6 - svelte: - specifier: ^5.41.0 - version: 5.44.0 - svelte-check: - specifier: ^4.3.3 - version: 4.3.4(picomatch@4.0.3)(svelte@5.44.0)(typescript@5.9.3) - tailwindcss: - specifier: ^4.1.17 - version: 4.1.17 - typescript: - specifier: ^5.9.3 - version: 5.9.3 - vite: - specifier: ^7.1.10 - version: 7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3) - apps/manavoxel: devDependencies: typescript: @@ -2818,73 +2818,6 @@ importers: apps/memoro: {} - apps/memoro/apps/audio-backend: - dependencies: - '@azure/storage-blob': - specifier: ^12.17.0 - version: 12.31.0 - '@nestjs/common': - specifier: ^10.0.0 - version: 10.4.20(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.1.14)(rxjs@7.8.2) - '@nestjs/config': - specifier: ^3.0.0 - version: 3.3.0(@nestjs/common@10.4.20(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.1.14)(rxjs@7.8.2))(rxjs@7.8.2) - '@nestjs/core': - specifier: ^10.0.0 - version: 10.4.20(@nestjs/common@10.4.20(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.1.14)(rxjs@7.8.2))(@nestjs/platform-express@10.4.20)(@nestjs/websockets@10.4.20)(encoding@0.1.13)(reflect-metadata@0.1.14)(rxjs@7.8.2) - '@nestjs/platform-express': - specifier: ^10.0.0 - version: 10.4.20(@nestjs/common@10.4.20(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.1.14)(rxjs@7.8.2))(@nestjs/core@10.4.20) - '@nestjs/swagger': - specifier: ^7.4.2 - version: 7.4.2(@nestjs/common@10.4.20(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.1.14)(rxjs@7.8.2))(@nestjs/core@10.4.20)(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.1.14) - '@nestjs/throttler': - specifier: ^5.2.0 - version: 5.2.0(@nestjs/common@10.4.20(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.1.14)(rxjs@7.8.2))(@nestjs/core@10.4.20)(reflect-metadata@0.1.14) - '@supabase/supabase-js': - specifier: ^2.41.0 - version: 2.84.0 - class-transformer: - specifier: ^0.5.1 - version: 0.5.1 - class-validator: - specifier: ^0.14.3 - version: 0.14.3 - fluent-ffmpeg: - specifier: ^2.1.2 - version: 2.1.3 - helmet: - specifier: ^8.1.0 - version: 8.1.0 - multer: - specifier: ^1.4.5-lts.1 - version: 1.4.5-lts.2 - reflect-metadata: - specifier: ^0.1.13 - version: 0.1.14 - rxjs: - specifier: ^7.8.1 - version: 7.8.2 - swagger-ui-express: - specifier: ^5.0.1 - version: 5.0.1(express@4.21.2) - devDependencies: - '@nestjs/cli': - specifier: ^10.0.0 - version: 10.4.9 - '@types/fluent-ffmpeg': - specifier: ^2.1.21 - version: 2.1.28 - '@types/multer': - specifier: ^1.4.7 - version: 1.4.13 - '@types/node': - specifier: ^20.3.1 - version: 20.19.25 - typescript: - specifier: ^5.1.3 - version: 5.9.3 - apps/memoro/apps/audio-server: dependencies: '@azure/storage-blob': @@ -2910,94 +2843,6 @@ importers: specifier: ^5.5.0 version: 5.9.3 - apps/memoro/apps/backend: - dependencies: - '@nestjs/axios': - specifier: ^3.0.0 - version: 3.1.3(@nestjs/common@10.4.20(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.1.14)(rxjs@7.8.2))(axios@1.14.0)(rxjs@7.8.2) - '@nestjs/common': - specifier: ^10.0.0 - version: 10.4.20(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.1.14)(rxjs@7.8.2) - '@nestjs/config': - specifier: ^3.0.0 - version: 3.3.0(@nestjs/common@10.4.20(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.1.14)(rxjs@7.8.2))(rxjs@7.8.2) - '@nestjs/core': - specifier: ^10.0.0 - version: 10.4.20(@nestjs/common@10.4.20(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.1.14)(rxjs@7.8.2))(@nestjs/platform-express@10.4.20)(@nestjs/websockets@10.4.20)(encoding@0.1.13)(reflect-metadata@0.1.14)(rxjs@7.8.2) - '@nestjs/platform-express': - specifier: ^10.0.0 - version: 10.4.20(@nestjs/common@10.4.20(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.1.14)(rxjs@7.8.2))(@nestjs/core@10.4.20) - '@supabase/supabase-js': - specifier: ^2.49.5 - version: 2.84.0 - '@types/jsonwebtoken': - specifier: ^9.0.7 - version: 9.0.10 - '@types/multer': - specifier: ^1.4.12 - version: 1.4.13 - '@types/uuid': - specifier: ^10.0.0 - version: 10.0.0 - axios: - specifier: ^1.9.0 - version: 1.14.0 - jsonwebtoken: - specifier: ^9.0.2 - version: 9.0.3 - multer: - specifier: ^2.0.0 - version: 2.0.2 - music-metadata: - specifier: ^7.14.0 - version: 7.14.0 - reflect-metadata: - specifier: ^0.1.13 - version: 0.1.14 - rxjs: - specifier: ^7.8.0 - version: 7.8.2 - uuid: - specifier: ^11.1.0 - version: 11.1.0 - devDependencies: - '@nestjs/cli': - specifier: ^10.0.0 - version: 10.4.9 - '@nestjs/testing': - specifier: ^10.0.0 - version: 10.4.20(@nestjs/common@10.4.20(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.1.14)(rxjs@7.8.2))(@nestjs/core@10.4.20)(@nestjs/platform-express@10.4.20) - '@types/express': - specifier: ^4.17.17 - version: 4.17.25 - '@types/jest': - specifier: ^29.5.2 - version: 29.5.14 - '@types/node': - specifier: ^20.3.1 - version: 20.19.25 - '@types/supertest': - specifier: ^2.0.12 - version: 2.0.16 - jest: - specifier: ^29.5.0 - version: 29.7.0(@types/node@20.19.25)(ts-node@10.9.2(@types/node@20.19.25)(typescript@5.9.3)) - supertest: - specifier: ^6.3.3 - version: 6.3.4 - ts-jest: - specifier: ^29.1.0 - version: 29.4.6(@babel/core@7.28.5)(@jest/transform@30.3.0)(@jest/types@30.3.0)(babel-jest@30.3.0(@babel/core@7.28.5))(jest-util@30.3.0)(jest@29.7.0(@types/node@20.19.25)(ts-node@10.9.2(@types/node@20.19.25)(typescript@5.9.3)))(typescript@5.9.3) - ts-node: - specifier: ^10.9.1 - version: 10.9.2(@types/node@20.19.25)(typescript@5.9.3) - tsconfig-paths: - specifier: ^4.2.0 - version: 4.2.0 - typescript: - specifier: ^5.1.3 - version: 5.9.3 - apps/memoro/apps/landing: dependencies: '@astrojs/check': @@ -4280,7 +4125,7 @@ importers: version: 0.4.3(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0) '@expo/vector-icons': specifier: ^15.0.2 - version: 15.0.3(expo-font@55.0.4)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0) + version: 15.0.3(expo-font@55.0.4(expo@55.0.5)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0) '@manacore/shared-auth': specifier: workspace:* version: link:../../../../packages/shared-auth @@ -5918,7 +5763,7 @@ importers: dependencies: '@expo/vector-icons': specifier: ^15.0.2 - version: 15.0.3(expo-font@55.0.4)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0) + version: 15.0.3(expo-font@55.0.4(expo@55.0.5)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0) '@react-native-async-storage/async-storage': specifier: 2.2.0 version: 2.2.0(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0)) @@ -6618,6 +6463,34 @@ importers: specifier: ^2.7.0 version: 2.7.0(encoding@0.1.13) + packages/cards-database: + dependencies: + drizzle-orm: + specifier: ^0.36.0 + version: 0.36.4(@electric-sql/pglite@0.3.16)(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.11)(expo-sqlite@55.0.10(expo@55.0.5)(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(kysely@0.28.8)(postgres@3.4.7)(react@19.2.4) + postgres: + specifier: ^3.4.5 + version: 3.4.7 + devDependencies: + '@supabase/supabase-js': + specifier: ^2.81.1 + version: 2.84.0 + '@types/node': + specifier: ^22.10.0 + version: 22.19.1 + dotenv-cli: + specifier: ^7.4.0 + version: 7.4.4 + drizzle-kit: + specifier: ^0.28.0 + version: 0.28.1 + tsx: + specifier: ^4.19.0 + version: 4.21.0 + typescript: + specifier: ^5.7.3 + version: 5.9.3 + packages/credits: devDependencies: svelte: @@ -6722,34 +6595,6 @@ importers: specifier: ^5.9.3 version: 5.9.3 - packages/manadeck-database: - dependencies: - drizzle-orm: - specifier: ^0.36.0 - version: 0.36.4(@electric-sql/pglite@0.3.16)(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.11)(expo-sqlite@55.0.10(expo@55.0.5)(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(kysely@0.28.8)(postgres@3.4.7)(react@19.2.4) - postgres: - specifier: ^3.4.5 - version: 3.4.7 - devDependencies: - '@supabase/supabase-js': - specifier: ^2.81.1 - version: 2.84.0 - '@types/node': - specifier: ^22.10.0 - version: 22.19.1 - dotenv-cli: - specifier: ^7.4.0 - version: 7.4.4 - drizzle-kit: - specifier: ^0.28.0 - version: 0.28.1 - tsx: - specifier: ^4.19.0 - version: 4.20.6 - typescript: - specifier: ^5.7.3 - version: 5.9.3 - packages/notify-client: devDependencies: '@nestjs/common': @@ -7338,7 +7183,7 @@ importers: version: 1.57.0 jest: specifier: ^29.0.0 - version: 29.7.0(@types/node@24.10.1) + version: 29.7.0(@types/node@24.10.1)(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3)) vitest: specifier: ^3.0.0 version: 3.2.4(@types/debug@4.1.12)(@types/node@24.10.1)(@vitest/browser@3.2.4)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@29.0.1(@noble/hashes@2.0.1))(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3) @@ -10403,9 +10248,6 @@ packages: '@expo/config@55.0.11': resolution: {integrity: sha512-14AkSmR1gOIUhCsPJ0cAo5ZduMNsPQsmFV9jBNZn1xC5Zb3D8x5eqvUie5QzWaUwdcyrq79uYJ2bTCiC6+nD0Q==} - '@expo/config@55.0.8': - resolution: {integrity: sha512-D7RYYHfErCgEllGxNwdYdkgzLna7zkzUECBV3snbUpf7RvIpB5l1LpCgzuVoc5KVew5h7N1Tn4LnT/tBSUZsQg==} - '@expo/devcert@1.2.1': resolution: {integrity: sha512-qC4eaxmKMTmJC2ahwyui6ud8f3W60Ss7pMkpBq40Hu3zyiAaugPXnZ24145U7K36qO9UHdZUVxsCvIpz2RYYCA==} @@ -10566,11 +10408,6 @@ packages: peerDependencies: expo: '*' - '@expo/prebuild-config@55.0.8': - resolution: {integrity: sha512-VJNJiOmmZgyDnR7JMmc3B8Z0ZepZ17I8Wtw+wAH/2+UCUsFg588XU+bwgYcFGw+is28kwGjY46z43kfufpxOnA==} - peerDependencies: - expo: '*' - '@expo/prebuild-config@9.0.12': resolution: {integrity: sha512-AKH5Scf+gEMgGxZZaimrJI2wlUJlRoqzDNn7/rkhZa5gUTnO4l6slKak2YdaH+nXlOWCNfAQWa76NnpQIfmv6Q==} @@ -10579,14 +10416,6 @@ packages: peerDependencies: react: '>=18.0.0' - '@expo/require-utils@55.0.2': - resolution: {integrity: sha512-dV5oCShQ1umKBKagMMT4B/N+SREsQe3lU4Zgmko5AO0rxKV0tynZT6xXs+e2JxuqT4Rz997atg7pki0BnZb4uw==} - peerDependencies: - typescript: ^5.0.0 || ^5.0.0-0 - peerDependenciesMeta: - typescript: - optional: true - '@expo/require-utils@55.0.3': resolution: {integrity: sha512-TS1m5tW45q4zoaTlt6DwmdYHxvFTIxoLrTHKOFrIirHIqIXnHCzpceg8wumiBi+ZXSaGY2gobTbfv+WVhJY6Fw==} peerDependencies: @@ -11225,9 +11054,6 @@ packages: '@mdx-js/mdx@3.1.1': resolution: {integrity: sha512-f6ZO2ifpwAQIpzGWaBQT2TXxPv6z3RBzQKpVftEWN78Vl/YweF1uwussDx8ECAXVtr3Rs89fKyG9YlzUs9DyGQ==} - '@microsoft/tsdoc@0.15.1': - resolution: {integrity: sha512-4aErSrCR/On/e5G2hDP0wjooqDdauzEbIq8hIkIe5pXV0rtWJZvdCEKL0ykZxex+IxIwBp0eGeV48hQN07dXtw==} - '@mozilla/readability@0.5.0': resolution: {integrity: sha512-Z+CZ3QaosfFaTqvhQsIktyGrjFjSC0Fa4EMph4mqKnWhmyoGICsV/8QK+8HpXut6zV7zwfWwqDmEjtk1Qf6EgQ==} engines: {node: '>=14.0.0'} @@ -11270,13 +11096,6 @@ packages: peerDependencies: svelte: ^3.0.0 || ^4.0.0 || ^5.0.0 - '@nestjs/axios@3.1.3': - resolution: {integrity: sha512-RZ/63c1tMxGLqyG3iOCVt7A72oy4x1eM6QEhd4KzCYpaVWW0igq0WSREeRoEZhIxRcZfDfIIkvsOMiM7yfVGZQ==} - peerDependencies: - '@nestjs/common': ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 - axios: ^1.3.1 - rxjs: ^6.0.0 || ^7.0.0 - '@nestjs/cli@10.4.9': resolution: {integrity: sha512-s8qYd97bggqeK7Op3iD49X2MpFtW4LVNLAwXFkfbRxKME6IYT7X0muNTJ2+QfI8hpbNx9isWkrLWIp+g5FOhiA==} engines: {node: '>= 16.14'} @@ -11339,19 +11158,6 @@ packages: '@nestjs/websockets': optional: true - '@nestjs/mapped-types@2.0.5': - resolution: {integrity: sha512-bSJv4pd6EY99NX9CjBIyn4TVDoSit82DUZlL4I3bqNfy5Gt+gXTa86i3I/i0iIV9P4hntcGM5GyO+FhZAhxtyg==} - peerDependencies: - '@nestjs/common': ^8.0.0 || ^9.0.0 || ^10.0.0 - class-transformer: ^0.4.0 || ^0.5.0 - class-validator: ^0.13.0 || ^0.14.0 - reflect-metadata: ^0.1.12 || ^0.2.0 - peerDependenciesMeta: - class-transformer: - optional: true - class-validator: - optional: true - '@nestjs/platform-express@10.4.20': resolution: {integrity: sha512-rh97mX3rimyf4xLMLHuTOBKe6UD8LOJ14VlJ1F/PTd6C6ZK9Ak6EHuJvdaGcSFQhd3ZMBh3I6CuujKGW9pNdIg==} peerDependencies: @@ -11370,23 +11176,6 @@ packages: peerDependencies: typescript: '>=4.8.2' - '@nestjs/swagger@7.4.2': - resolution: {integrity: sha512-Mu6TEn1M/owIvAx2B4DUQObQXqo2028R2s9rSZ/hJEgBK95+doTwS0DjmVA2wTeZTyVtXOoN7CsoM5pONBzvKQ==} - peerDependencies: - '@fastify/static': ^6.0.0 || ^7.0.0 - '@nestjs/common': ^9.0.0 || ^10.0.0 - '@nestjs/core': ^9.0.0 || ^10.0.0 - class-transformer: '*' - class-validator: '*' - reflect-metadata: ^0.1.12 || ^0.2.0 - peerDependenciesMeta: - '@fastify/static': - optional: true - class-transformer: - optional: true - class-validator: - optional: true - '@nestjs/testing@10.4.20': resolution: {integrity: sha512-nMkRDukDKskdPruM6EsgMq7yJua+CPZM6I6FrLP8yXw8BiVSPv9Nm0CtcGGwt3kgZF9hfxKjGqLjsvVBsv6Vfw==} peerDependencies: @@ -11400,13 +11189,6 @@ packages: '@nestjs/platform-express': optional: true - '@nestjs/throttler@5.2.0': - resolution: {integrity: sha512-G/G/MV3xf6sy1DwmnJsgeL+d2tQ/xGRNa9ZhZjm9Kyxp+3+ylGzwJtcnhWlN82PMEp3TiDQpTt+9waOIg/bpPg==} - peerDependencies: - '@nestjs/common': ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 - '@nestjs/core': ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 - reflect-metadata: ^0.1.13 || ^0.2.0 - '@nestjs/websockets@10.4.20': resolution: {integrity: sha512-tafsPPvQfAXc+cfxvuRDzS5V+Ixg8uVJq8xSocU24yVl/Xp6ajmhqiGiaVjYOX8mXY0NV836QwEZxHF7WvKHSw==} peerDependencies: @@ -11541,10 +11323,6 @@ packages: resolution: {integrity: sha512-xHK3XHPUW8DTAobU+G0XT+/w+JLM7/8k1UFdB5xg/zTFPnFCobhftzw8wl4Lw2aq/Rvir5pxfZV5fEazmeCJ2g==} engines: {node: '>= 20.19.0'} - '@noble/hashes@1.8.0': - resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} - engines: {node: ^14.21.3 || >=16} - '@noble/hashes@2.0.1': resolution: {integrity: sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==} engines: {node: '>= 20.19.0'} @@ -11827,9 +11605,6 @@ packages: cpu: [x64] os: [win32] - '@paralleldrive/cuid2@2.3.1': - resolution: {integrity: sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==} - '@parcel/watcher-android-arm64@2.5.6': resolution: {integrity: sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A==} engines: {node: '>= 10.0.0'} @@ -12890,9 +12665,6 @@ packages: '@rtsao/scc@1.1.0': resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==} - '@scarf/scarf@1.4.0': - resolution: {integrity: sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==} - '@sentry-internal/browser-utils@9.47.1': resolution: {integrity: sha512-twv6YhrUlPkvKz4/iQDH4KHgcv9t4cMjmZPf4/dCSCXn4/GOjzjx2d74c1w+1KOdS7lcsQzI+MtbK6SeYLiGfQ==} engines: {node: '>=18'} @@ -13816,9 +13588,6 @@ packages: '@types/cookie@0.6.0': resolution: {integrity: sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==} - '@types/cookiejar@2.1.5': - resolution: {integrity: sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==} - '@types/cors@2.8.19': resolution: {integrity: sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==} @@ -13942,15 +13711,9 @@ packages: '@types/events@3.0.3': resolution: {integrity: sha512-trOc4AAUThEz9hapPtSd7wf5tiQKvTtu5b371UxXdTuqzIh0ArcRspRP0i0Viu+LXstIQ1z96t1nsPxT9ol01g==} - '@types/express-serve-static-core@4.19.8': - resolution: {integrity: sha512-02S5fmqeoKzVZCHPZid4b8JH2eM5HzQLZWN2FohQEy/0eXTq8VXZfSN6Pcr3F6N9R/vNrj7cpgbhjie6m/1tCA==} - '@types/express-serve-static-core@5.1.0': resolution: {integrity: sha512-jnHMsrd0Mwa9Cf4IdOzbz543y4XJepXrbia2T4b6+spXC2We3t1y6K44D3mR8XMFSXMCf3/l7rCgddfx7UNVBA==} - '@types/express@4.17.25': - resolution: {integrity: sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==} - '@types/express@5.0.5': resolution: {integrity: sha512-LuIQOcb6UmnF7C1PCFmEU1u2hmiHL43fgFQX67sN3H4Z+0Yk0Neo++mFsBjhOAuLzvlQeqAAkeDOZrJs9rzumQ==} @@ -13995,9 +13758,6 @@ packages: '@types/istanbul-reports@3.0.4': resolution: {integrity: sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==} - '@types/jest@29.5.14': - resolution: {integrity: sha512-ZN+4sdnLUbo8EVvVc2ao0GFW6oVrQRPn4K2lglySj7APvSrgzxHiNNK99us4WDMi57xxA2yggblIAMNhXOotLQ==} - '@types/js-yaml@4.0.9': resolution: {integrity: sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==} @@ -14013,9 +13773,6 @@ packages: '@types/jsonfile@6.1.4': resolution: {integrity: sha512-D5qGUYwjvnNNextdU59/+fI+spnwtTFmyQP0h+PfIOSkNfpU6AOICUOkm4i0OnSk+NyjdPJrxCDro0sJsWlRpQ==} - '@types/jsonwebtoken@9.0.10': - resolution: {integrity: sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==} - '@types/leaflet@1.9.21': resolution: {integrity: sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==} @@ -14028,9 +13785,6 @@ packages: '@types/mdx@2.0.13': resolution: {integrity: sha512-+OWZQfAYyio6YkJb3HLxDrvnx6SWWDbC0zVPfBRzUk0/nqoDyf6dNxQi3eArPe8rJ473nobTMQ/8Zk+LxJ+Yuw==} - '@types/methods@1.1.4': - resolution: {integrity: sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==} - '@types/mime-types@2.1.4': resolution: {integrity: sha512-lfU4b34HOri+kAY5UheuFMWPDOI+OPceBSHZKp69gEyTL/mmJ4cnU6Y/rlme3UL3GyOn6Y42hyIEw0/q8sWx5w==} @@ -14040,9 +13794,6 @@ packages: '@types/ms@2.1.0': resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} - '@types/multer@1.4.13': - resolution: {integrity: sha512-bhhdtPw7JqCiEfC9Jimx5LqX9BDIPJEh2q/fQ4bqbBPtyEZYr3cvF22NwG0DmPZNYA0CAf2CnqDB4KIGGpJcaw==} - '@types/mysql@2.15.26': resolution: {integrity: sha512-DSLCOXhkvfS5WNNPbfn2KdICAmk8lLc+/PNvnPnF7gOdMZCxopXduqv0OQ13y/yA/zXTSikZZqVgybUxOEg6YQ==} @@ -14161,12 +13912,6 @@ packages: '@types/suncalc@1.9.2': resolution: {integrity: sha512-ATAGBHHfA1TlE2tjfidLyTcysjoT2JHHEAmWRULh73SU9UTn++j5fqHEW16X6Y/2Li87jEQXzgu4R/OOdlDqzw==} - '@types/superagent@8.1.9': - resolution: {integrity: sha512-pTVjI73witn+9ILmoJdajHGW2jkSaOzhiFYF1Rd3EQ94kymLqB9PjD9ISg7WaALC7+dCHT0FGe9T2LktLq/3GQ==} - - '@types/supertest@2.0.16': - resolution: {integrity: sha512-6c2ogktZ06tr2ENoZivgm7YnprnhYE4ZoXGMY+oA7IuAf17M8FWvujXZGmxLv8y0PTyts4x5A+erSwVUFA8XSg==} - '@types/tedious@4.0.14': resolution: {integrity: sha512-KHPsfX/FoVbUGbyYvk1q9MMQHLPeRZhRJZdO45Q4YjvFkv4hMNghCWTvy7rdKessBsmtz4euWCWAB6/tVpI1Iw==} @@ -15303,9 +15048,6 @@ packages: await-lock@2.2.2: resolution: {integrity: sha512-aDczADvlvTGajTDjcjpJMqRkOF6Qdz3YbPZm/PyW6tKPkx2hlYBzxMhEywM/tU72HrVZjgl5VCdRuMlA7pZ8Gw==} - axios@1.14.0: - resolution: {integrity: sha512-3Y8yrqLSwjuzpXuZ0oIYZ/XGgLwUIBU3uLvbcpb0pidD9ctpShJd43KSlEEkVQg6DS0G9NKyzOvBfUtDKEyHvQ==} - axobject-query@4.1.0: resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} engines: {node: '>= 0.4'} @@ -15677,10 +15419,6 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true - bs-logger@0.2.6: - resolution: {integrity: sha512-pd8DCoxmbgc7hyPKOvxtqNcjYoOsABPQdcCUjGp3d42VR2CX1ORhk2A87oqqu5R1kk+76nsxZupkmyd+MVtCog==} - engines: {node: '>= 6'} - bs58@6.0.0: resolution: {integrity: sha512-PD0wEnEYg6ijszw/u8s+iI3H17cTymlrwkKhDhPZq+Sokl3AU4htyBFTjAeNAlCCmg0f53g6ih3jATyCKftTfw==} @@ -16091,9 +15829,6 @@ packages: commondir@1.0.1: resolution: {integrity: sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==} - component-emitter@1.3.1: - resolution: {integrity: sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==} - compress-commons@6.0.2: resolution: {integrity: sha512-6FqVXeETqWPoGcfzrXb37E50NP0LXT8kAMu5ooZayhWWdgEY4lBEEcbQNXtkuKQsGduxiIcI4gOTsxTmuq/bSg==} engines: {node: '>= 14'} @@ -16109,10 +15844,6 @@ packages: concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} - concat-stream@1.6.2: - resolution: {integrity: sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==} - engines: {'0': node >= 0.8} - concat-stream@2.0.0: resolution: {integrity: sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==} engines: {'0': node >= 6.0} @@ -16176,9 +15907,6 @@ packages: resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} engines: {node: '>=18'} - cookiejar@2.1.4: - resolution: {integrity: sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==} - copy-file@11.1.0: resolution: {integrity: sha512-X8XDzyvYaA6msMyAM575CUoygY5b44QzLcGRKsK3MFmXcOvQa518dNPLsKYwkYsn72g3EiW+LE0ytd/FlqWmyw==} engines: {node: '>=18'} @@ -16674,9 +16402,6 @@ packages: dexie@4.4.1: resolution: {integrity: sha512-4Xec5+yrS+TgyFAnMrneFOt/QG8sD3FxlkUVpfypui3SriRN80UN0SZBWmkNAY7ulfKgk0ilvv7M6pBURprdgA==} - dezalgo@1.0.4: - resolution: {integrity: sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==} - dfa@1.2.0: resolution: {integrity: sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==} @@ -18163,13 +17888,6 @@ packages: react: '*' react-native: '*' - expo-font@14.0.9: - resolution: {integrity: sha512-xCoQbR/36qqB6tew/LQ6GWICpaBmHLhg/Loix5Rku/0ZtNaXMJv08M9o1AcrdiGTn/Xf/BnLu6DgS45cWQEHZg==} - peerDependencies: - expo: '*' - react: '*' - react-native: '*' - expo-font@55.0.4: resolution: {integrity: sha512-ZKeGTFffPygvY5dM/9ATM2p7QDkhsaHopH7wFAWgP2lKzqUMS9B/RxCvw5CaObr9Ro7x9YptyeRKX2HmgmMfrg==} peerDependencies: @@ -18717,10 +18435,6 @@ packages: resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} engines: {node: '>=16.0.0'} - file-type@16.5.4: - resolution: {integrity: sha512-/yFHK0aGjFEgDJjEKP0pWCplsPFPhwyfwevf/pVxiN0tmE4L9LmwWxWukdJSHdoCli4VgQLehjJtwQBnqmsKcw==} - engines: {node: '>=10'} - file-type@20.4.1: resolution: {integrity: sha512-hw9gNZXUfZ02Jo0uafWLaFVPter5/k2rfcrjFJJHX/77xtSDOfJuEFb6oKlFV86FLP1SuyHMW1PSk0U9M5tKkQ==} engines: {node: '>=18'} @@ -18813,15 +18527,6 @@ packages: fn.name@1.1.0: resolution: {integrity: sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==} - follow-redirects@1.15.11: - resolution: {integrity: sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==} - engines: {node: '>=4.0'} - peerDependencies: - debug: '*' - peerDependenciesMeta: - debug: - optional: true - fontace@0.3.1: resolution: {integrity: sha512-9f5g4feWT1jWT8+SbL85aLIRLIXUaDygaM2xPXRmzPYxrOMNok79Lr3FGJoKVNKibE0WCunNiEVG2mwuE+2qEg==} @@ -18868,9 +18573,6 @@ packages: resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==} engines: {node: '>=12.20.0'} - formidable@2.1.5: - resolution: {integrity: sha512-Oz5Hwvwak/DCaXVVUtPn4oLMLLy1CdclLKO1LFgU7XzDpVMUU5UjlSLpGMocyQNNk8F6IJW9M/YdooSn2MRI+Q==} - forwarded-parse@2.1.2: resolution: {integrity: sha512-alTFZZQDKMporBH77856pXgzhEzaUVmLCDk+egLgIgHst3Tpndzz8MnKe+GzRJRfvVdn69HhpW7cmXzvtLvJAw==} @@ -19153,11 +18855,6 @@ packages: h3@1.15.4: resolution: {integrity: sha512-z5cFQWDffyOe4vQ9xIqNfCZdV4p//vy6fBnr8Q1AWnVZ0teurKMG66rLj++TKwKPUP3u7iMUvrvKaEUiQw2QWQ==} - handlebars@4.7.9: - resolution: {integrity: sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==} - engines: {node: '>=0.4.7'} - hasBin: true - has-bigints@1.1.0: resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==} engines: {node: '>= 0.4'} @@ -19253,10 +18950,6 @@ packages: hastscript@9.0.1: resolution: {integrity: sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==} - helmet@8.1.0: - resolution: {integrity: sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg==} - engines: {node: '>=18.0.0'} - hermes-compiler@0.14.1: resolution: {integrity: sha512-+RPPQlayoZ9n6/KXKt5SFILWXCGJ/LV5d24L5smXrvTDrPS4L6dSctPczXauuvzFP3QEJbD1YO7Z3Ra4a+4IhA==} @@ -20148,10 +19841,6 @@ packages: resolution: {integrity: sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==} hasBin: true - js-yaml@4.1.0: - resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==} - hasBin: true - js-yaml@4.1.1: resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} hasBin: true @@ -20593,9 +20282,6 @@ packages: lodash.isstring@4.0.1: resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==} - lodash.memoize@4.1.2: - resolution: {integrity: sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==} - lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} @@ -20837,10 +20523,6 @@ packages: resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} engines: {node: '>= 0.6'} - media-typer@1.1.0: - resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==} - engines: {node: '>= 0.8'} - memfs@3.5.3: resolution: {integrity: sha512-UERzLsxzllchadvbPs5aolHh65ISpKpM+ccLbOJ8/vvpBKmAWf+la7dXFy7Mr0ySHbdHrFv5kGFCUHHe6GFEmw==} engines: {node: '>= 4.0.0'} @@ -21184,11 +20866,6 @@ packages: engines: {node: '>=4'} hasBin: true - mime@2.6.0: - resolution: {integrity: sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==} - engines: {node: '>=4.0.0'} - hasBin: true - mime@3.0.0: resolution: {integrity: sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==} engines: {node: '>=10.0.0'} @@ -21312,11 +20989,6 @@ packages: muggle-string@0.4.1: resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==} - multer@1.4.5-lts.2: - resolution: {integrity: sha512-VzGiVigcG9zUAoCNU+xShztrlr1auZOlurXynNvO9GiWD1/mTBbUljOKY+qMeazBqXgRnjzeEgJI/wyjJUHg9A==} - engines: {node: '>= 6.0.0'} - deprecated: Multer 1.x is impacted by a number of vulnerabilities, which have been patched in 2.x. You should upgrade to the latest 2.x version. - multer@2.0.2: resolution: {integrity: sha512-u7f2xaZ/UG8oLXHvtF/oWTRvT44p9ecwBBqTwgJVq0+4BW1g8OW01TyMEGWBHbyMOYVHXslaut7qEQ1meATXgw==} engines: {node: '>= 10.16.0'} @@ -21324,10 +20996,6 @@ packages: multitars@0.2.4: resolution: {integrity: sha512-XgLbg1HHchFauMCQPRwMj6MSyDd5koPlTA1hM3rUFkeXzGpjU/I9fP3to7yrObE9jcN8ChIOQGrM0tV0kUZaKg==} - music-metadata@7.14.0: - resolution: {integrity: sha512-xrm3w7SV0Wk+OythZcSbaI8mcr/KHd0knJieu8bVpaPfMv/Agz5EooCAPz3OR5hbYMiUG6dgAPKZKnMzV+3amA==} - engines: {node: '>=10'} - mute-stream@0.0.8: resolution: {integrity: sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==} @@ -21425,10 +21093,6 @@ packages: resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - node-forge@1.3.2: - resolution: {integrity: sha512-6xKiQ+cph9KImrRh0VsjH2d8/GXA4FIMlgU4B757iI1ApvcyA9VlouP0yZJha01V+huImO+kKMU7ih+2+E14fw==} - engines: {node: '>= 6.13.0'} - node-forge@1.3.3: resolution: {integrity: sha512-rLvcdSyRCyouf6jcOIPe/BgwG/d7hKjzMKOas33/pHEr6gbq18IK9zV7DiPvzsz0oBJPme6qr6H6kGZuI9/DZg==} engines: {node: '>= 6.13.0'} @@ -21878,10 +21542,6 @@ packages: resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} engines: {node: '>= 14.16'} - peek-readable@4.1.0: - resolution: {integrity: sha512-ZI3LnwUv5nOGbQzD9c2iDG6toheuXSZP5esSHBjopsXH4dg19soufvpUGA3uohi5anFtGb2lhAVdHzH6R/Evvg==} - engines: {node: '>=8'} - pend@1.2.0: resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==} @@ -22363,10 +22023,6 @@ packages: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} - proxy-from-env@2.1.0: - resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==} - engines: {node: '>=10'} - pump@3.0.3: resolution: {integrity: sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==} @@ -22912,10 +22568,6 @@ packages: resolution: {integrity: sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - readable-web-to-node-stream@3.0.4: - resolution: {integrity: sha512-9nX56alTf5bwXQ3ZDipHJhusu9NTQJ/CVPtb/XHAJCXihZeitfJvIRS4GqQ/mfIoOE3IelHMrpayVrosdHBuLw==} - engines: {node: '>=8'} - readdir-glob@1.1.3: resolution: {integrity: sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==} @@ -22963,9 +22615,6 @@ packages: resolution: {integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==} engines: {node: '>=4'} - reflect-metadata@0.1.14: - resolution: {integrity: sha512-ZhYeb6nRaXCfhnndflDK8qI6ZQ/YcWZCISRAWICW9XYqMUwjZM9Z0DveWX/ABN01oxSHwVxKQmxeYZSsm0jh5A==} - reflect-metadata@0.2.2: resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==} @@ -23805,10 +23454,6 @@ packages: resolution: {integrity: sha512-KIy5nylvC5le1OdaaoCJ07L+8iQzJHGH6pWDuzS+d07Cu7n1MZ2x26P8ZKIWfbK02+XIL8Mp4RkWeqdUCrDMfg==} engines: {node: '>=18'} - strtok3@6.3.0: - resolution: {integrity: sha512-fZtbhtvI9I48xDSywd/somNqgUHl2L2cstmXCCif0itOf96jeW18MBSyrLuNicYQVkvpOxkZtkzujiTJ9LW5Jw==} - engines: {node: '>=10'} - structured-headers@0.4.1: resolution: {integrity: sha512-0MP/Cxx5SzeeZ10p/bZI0S6MpgD+yxAhi1BOQ34jgnMXsCq3j1t6tQnZu+KdlL7dvJTLT3g9xN8tl10TqgFMcg==} @@ -23837,16 +23482,6 @@ packages: suncalc@1.9.0: resolution: {integrity: sha512-vMJ8Byp1uIPoj+wb9c1AdK4jpkSKVAywgHX0lqY7zt6+EWRRC3Z+0Ucfjy/0yxTVO1hwwchZe4uoFNqrIC24+A==} - superagent@8.1.2: - resolution: {integrity: sha512-6WTxW1EB6yCxV5VFOIPQruWGHqc3yI7hEmZK6h+pyk69Lk/Ut7rLUY6W/ONF2MjBuGjvmMiIpsrVJ2vjrHlslA==} - engines: {node: '>=6.4.0 <13 || >=14'} - deprecated: Please upgrade to superagent v10.2.2+, see release notes at https://github.com/forwardemail/superagent/releases/tag/v10.2.2 - maintenance is supported by Forward Email @ https://forwardemail.net - - supertest@6.3.4: - resolution: {integrity: sha512-erY3HFDG0dPnhw4U+udPfrzXa4xhSG+n4rxfRuZWCUvjFWwKl+OxWf/7zk50s84/fAAs7vf5QAb9uRa0cCykxw==} - engines: {node: '>=6.4.0'} - deprecated: Please upgrade to supertest v7.1.3+, see release notes at https://github.com/forwardemail/supertest/releases/tag/v7.1.3 - maintenance is supported by Forward Email @ https://forwardemail.net - supports-color@10.2.2: resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==} engines: {node: '>=18'} @@ -23935,18 +23570,6 @@ packages: engines: {node: '>=16'} hasBin: true - swagger-ui-dist@5.17.14: - resolution: {integrity: sha512-CVbSfaLpstV65OnSjbXfVd6Sta3q3F7Cj/yYuvHMp1P90LztOLs6PfUnKEVAeiIVQt9u2SaPwv0LiH/OyMjHRw==} - - swagger-ui-dist@5.32.1: - resolution: {integrity: sha512-6HQoo7+j8PA2QqP5kgAb9dl1uxUjvR0SAoL/WUp1sTEvm0F6D5npgU2OGCLwl++bIInqGlEUQ2mpuZRZYtyCzQ==} - - swagger-ui-express@5.0.1: - resolution: {integrity: sha512-SrNU3RiBGTLLmFU8GIJdOdanJTl4TOmT27tt3bWWHppqYmAZ6IDuEuBvMU6nZq0zLEe6b/1rACXCgLZqO6ZfrA==} - engines: {node: '>= v0.10.32'} - peerDependencies: - express: '>=4.0.0 || >=5.0.0-beta' - symbol-observable@4.0.0: resolution: {integrity: sha512-b19dMThMV4HVFynSAM1++gBHAbk2Tc/osgLIBZMKsyqh34jb2e8Os7T6ZW/Bt3pJFdBTd2JwAnAAEQV7rSNvcQ==} engines: {node: '>=0.10'} @@ -24157,10 +23780,6 @@ packages: resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} engines: {node: '>=0.6'} - token-types@4.2.1: - resolution: {integrity: sha512-6udB24Q737UD/SDsKAHI9FCRP7Bqc9D/MQUV02ORQg5iskjtLJlZJNdN4kKtcdtwCeWIwIHDGaUsTsCCAa8sFQ==} - engines: {node: '>=10'} - token-types@6.1.2: resolution: {integrity: sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==} engines: {node: '>=14.16'} @@ -24241,33 +23860,6 @@ packages: ts-interface-checker@0.1.13: resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} - ts-jest@29.4.6: - resolution: {integrity: sha512-fSpWtOO/1AjSNQguk43hb/JCo16oJDnMJf3CdEGNkqsEX3t0KX96xvyX1D7PfLCpVoKu4MfVrqUkFyblYoY4lA==} - engines: {node: ^14.15.0 || ^16.10.0 || ^18.0.0 || >=20.0.0} - hasBin: true - peerDependencies: - '@babel/core': '>=7.0.0-beta.0 <8' - '@jest/transform': ^29.0.0 || ^30.0.0 - '@jest/types': ^29.0.0 || ^30.0.0 - babel-jest: ^29.0.0 || ^30.0.0 - esbuild: '*' - jest: ^29.0.0 || ^30.0.0 - jest-util: ^29.0.0 || ^30.0.0 - typescript: '>=4.3 <6' - peerDependenciesMeta: - '@babel/core': - optional: true - '@jest/transform': - optional: true - '@jest/types': - optional: true - babel-jest: - optional: true - esbuild: - optional: true - jest-util: - optional: true - ts-node@10.9.2: resolution: {integrity: sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==} hasBin: true @@ -24333,11 +23925,6 @@ packages: typescript: optional: true - tsx@4.20.6: - resolution: {integrity: sha512-ytQKuwgmrrkDTFP4LjR0ToE2nqgy886GpvRSpU0JAnrdBYppuY5rLkRUYPU1yCryb24SsKBTL/hlDQAEFVwtZg==} - engines: {node: '>=18.0.0'} - hasBin: true - tsx@4.21.0: resolution: {integrity: sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==} engines: {node: '>=18.0.0'} @@ -24492,11 +24079,6 @@ packages: ufo@1.6.3: resolution: {integrity: sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==} - uglify-js@3.19.3: - resolution: {integrity: sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==} - engines: {node: '>=0.8.0'} - hasBin: true - uid@2.0.2: resolution: {integrity: sha512-u3xV3X7uzvi5b1MncmZo3i2Aw222Zk1keqLA1YkHldREkAhAqi65wuPfe7lHx8H/Wzy+8CE7S7uS3jekIM5s8g==} engines: {node: '>=8'} @@ -25587,9 +25169,6 @@ packages: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} - wordwrap@1.0.0: - resolution: {integrity: sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==} - workbox-background-sync@7.4.0: resolution: {integrity: sha512-8CB9OxKAgKZKyNMwfGZ1XESx89GryWTfI+V5yEj8sHjFH8MFelUwYXEyldEK6M6oKMmn807GoJFUEA1sC4XS9w==} @@ -26446,6 +26025,16 @@ snapshots: transitivePeerDependencies: - ts-node + '@astrojs/tailwind@6.0.2(astro@5.16.0(@azure/storage-blob@12.31.0)(@netlify/blobs@10.7.4)(@types/node@20.19.25)(ioredis@5.9.2)(jiti@1.21.7)(lightningcss@1.30.2)(rollup@4.53.3)(terser@5.44.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3))(tailwindcss@3.4.18(tsx@4.21.0)(yaml@2.8.3))(ts-node@10.9.2(@types/node@20.19.25)(typescript@5.9.3))': + dependencies: + astro: 5.16.0(@azure/storage-blob@12.31.0)(@netlify/blobs@10.7.4)(@types/node@20.19.25)(ioredis@5.9.2)(jiti@1.21.7)(lightningcss@1.30.2)(rollup@4.53.3)(terser@5.44.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3) + autoprefixer: 10.4.22(postcss@8.5.8) + postcss: 8.5.8 + postcss-load-config: 4.0.2(postcss@8.5.8)(ts-node@10.9.2(@types/node@20.19.25)(typescript@5.9.3)) + tailwindcss: 3.4.18(tsx@4.21.0)(yaml@2.8.3) + transitivePeerDependencies: + - ts-node + '@astrojs/tailwind@6.0.2(astro@5.16.0(@azure/storage-blob@12.31.0)(@netlify/blobs@10.7.4)(@types/node@20.19.25)(ioredis@5.9.2)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.53.3)(terser@5.44.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3))(tailwindcss@3.4.18(tsx@4.21.0)(yaml@2.8.3))(ts-node@10.9.2(@types/node@20.19.25)(typescript@5.9.3))': dependencies: astro: 5.16.0(@azure/storage-blob@12.31.0)(@netlify/blobs@10.7.4)(@types/node@20.19.25)(ioredis@5.9.2)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.53.3)(terser@5.44.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3) @@ -28064,7 +27653,7 @@ snapshots: jose: 6.1.2 kysely: 0.28.8 nanostores: 1.1.0 - zod: 4.1.13 + zod: 4.3.6 '@better-auth/telemetry@1.4.4(@better-auth/core@1.4.4(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.18)(better-call@1.1.3(zod@3.25.76))(jose@6.1.2)(kysely@0.28.8)(nanostores@1.1.0))': dependencies: @@ -28937,7 +28526,7 @@ snapshots: '@expo/cli@0.24.24': dependencies: '@0no-co/graphql.web': 1.2.0 - '@babel/runtime': 7.28.4 + '@babel/runtime': 7.29.2 '@expo/code-signing-certificates': 0.0.6 '@expo/config': 11.0.13 '@expo/config-plugins': 10.1.2 @@ -29081,11 +28670,87 @@ snapshots: - supports-color - utf-8-validate + '@expo/cli@55.0.15(@expo/dom-webview@55.0.3)(@expo/metro-runtime@55.0.7)(expo-constants@55.0.7)(expo-font@55.0.4(expo@55.0.5)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(expo-router@55.0.5)(expo@55.0.5)(react-dom@19.2.0(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)(typescript@5.9.3)': + dependencies: + '@expo/code-signing-certificates': 0.0.6 + '@expo/config': 55.0.11(typescript@5.9.3) + '@expo/config-plugins': 55.0.7 + '@expo/devcert': 1.2.1 + '@expo/env': 2.1.1 + '@expo/image-utils': 0.8.12 + '@expo/json-file': 10.0.12 + '@expo/log-box': 55.0.7(@expo/dom-webview@55.0.3)(expo@55.0.5)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0) + '@expo/metro': 54.2.0 + '@expo/metro-config': 55.0.9(expo@55.0.5)(typescript@5.9.3) + '@expo/osascript': 2.4.2 + '@expo/package-manager': 1.10.3 + '@expo/plist': 0.5.2 + '@expo/prebuild-config': 55.0.11(expo@55.0.5)(typescript@5.9.3) + '@expo/require-utils': 55.0.3(typescript@5.9.3) + '@expo/router-server': 55.0.9(@expo/metro-runtime@55.0.7)(expo-constants@55.0.7)(expo-font@55.0.4(expo@55.0.5)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(expo-router@55.0.5)(expo-server@55.0.6)(expo@55.0.5)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@expo/schema-utils': 55.0.2 + '@expo/spawn-async': 1.7.2 + '@expo/ws-tunnel': 1.0.6 + '@expo/xcpretty': 4.4.1 + '@react-native/dev-middleware': 0.83.2 + accepts: 1.3.8 + arg: 5.0.2 + better-opn: 3.0.2 + bplist-creator: 0.1.0 + bplist-parser: 0.3.2 + chalk: 4.1.2 + ci-info: 3.9.0 + compression: 1.8.1 + connect: 3.7.0 + debug: 4.4.3 + dnssd-advertise: 1.1.3 + expo: 55.0.5(@babel/core@7.28.5)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@55.0.7)(expo-router@55.0.5)(react-dom@19.2.0(react@19.2.0))(react-native-webview@13.12.2(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) + expo-server: 55.0.6 + fetch-nodeshim: 0.4.9 + getenv: 2.0.0 + glob: 13.0.0 + lan-network: 0.2.0 + multitars: 0.2.4 + node-forge: 1.3.3 + npm-package-arg: 11.0.3 + ora: 3.4.0 + picomatch: 4.0.3 + pretty-format: 29.7.0 + progress: 2.0.3 + prompts: 2.4.2 + resolve-from: 5.0.0 + semver: 7.7.3 + send: 0.19.1 + slugify: 1.6.6 + source-map-support: 0.5.21 + stacktrace-parser: 0.1.11 + structured-headers: 0.4.1 + terminal-link: 2.1.1 + toqr: 0.1.1 + wrap-ansi: 7.0.0 + ws: 8.18.3 + zod: 3.25.76 + optionalDependencies: + expo-router: 55.0.5(6d2zdlrrz5o6rsdio5yhgp3juy) + react-native: 0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0) + transitivePeerDependencies: + - '@expo/dom-webview' + - '@expo/metro-runtime' + - bufferutil + - expo-constants + - expo-font + - react + - react-dom + - react-server-dom-webpack + - supports-color + - typescript + - utf-8-validate + '@expo/cli@55.0.15(@expo/dom-webview@55.0.3)(@expo/metro-runtime@55.0.7)(expo-constants@55.0.7)(expo-font@55.0.4)(expo-router@55.0.5)(expo@55.0.5)(react-dom@19.2.0(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)(typescript@5.3.3)': dependencies: '@expo/code-signing-certificates': 0.0.6 - '@expo/config': 55.0.8(typescript@5.3.3) - '@expo/config-plugins': 55.0.6 + '@expo/config': 55.0.11(typescript@5.3.3) + '@expo/config-plugins': 55.0.7 '@expo/devcert': 1.2.1 '@expo/env': 2.1.1 '@expo/image-utils': 0.8.12 @@ -29096,8 +28761,8 @@ snapshots: '@expo/osascript': 2.4.2 '@expo/package-manager': 1.10.3 '@expo/plist': 0.5.2 - '@expo/prebuild-config': 55.0.8(expo@55.0.5)(typescript@5.3.3) - '@expo/require-utils': 55.0.2(typescript@5.3.3) + '@expo/prebuild-config': 55.0.11(expo@55.0.5)(typescript@5.3.3) + '@expo/require-utils': 55.0.3(typescript@5.3.3) '@expo/router-server': 55.0.9(@expo/metro-runtime@55.0.7)(expo-constants@55.0.7)(expo-font@55.0.4)(expo-router@55.0.5)(expo-server@55.0.6)(expo@55.0.5)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) '@expo/schema-utils': 55.0.2 '@expo/spawn-async': 1.7.2 @@ -29142,7 +28807,7 @@ snapshots: ws: 8.18.3 zod: 3.25.76 optionalDependencies: - expo-router: 55.0.5(shihlejigi2nkza7wltmngtxfm) + expo-router: 55.0.5(bhhyukj6njqvlfm2o3z6hpwcja) react-native: 0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0) transitivePeerDependencies: - '@expo/dom-webview' @@ -29160,8 +28825,8 @@ snapshots: '@expo/cli@55.0.15(@expo/dom-webview@55.0.3)(@expo/metro-runtime@55.0.7)(expo-constants@55.0.7)(expo-font@55.0.4)(expo-router@55.0.5)(expo@55.0.5)(react-dom@19.2.0(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)(typescript@5.8.3)': dependencies: '@expo/code-signing-certificates': 0.0.6 - '@expo/config': 55.0.8(typescript@5.8.3) - '@expo/config-plugins': 55.0.6 + '@expo/config': 55.0.11(typescript@5.8.3) + '@expo/config-plugins': 55.0.7 '@expo/devcert': 1.2.1 '@expo/env': 2.1.1 '@expo/image-utils': 0.8.12 @@ -29172,8 +28837,8 @@ snapshots: '@expo/osascript': 2.4.2 '@expo/package-manager': 1.10.3 '@expo/plist': 0.5.2 - '@expo/prebuild-config': 55.0.8(expo@55.0.5)(typescript@5.8.3) - '@expo/require-utils': 55.0.2(typescript@5.8.3) + '@expo/prebuild-config': 55.0.11(expo@55.0.5)(typescript@5.8.3) + '@expo/require-utils': 55.0.3(typescript@5.8.3) '@expo/router-server': 55.0.9(@expo/metro-runtime@55.0.7)(expo-constants@55.0.7)(expo-font@55.0.4)(expo-router@55.0.5)(expo-server@55.0.6)(expo@55.0.5)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) '@expo/schema-utils': 55.0.2 '@expo/spawn-async': 1.7.2 @@ -29233,87 +28898,11 @@ snapshots: - typescript - utf-8-validate - '@expo/cli@55.0.15(@expo/dom-webview@55.0.3)(@expo/metro-runtime@55.0.7)(expo-constants@55.0.7)(expo-font@55.0.4)(expo-router@55.0.5)(expo@55.0.5)(react-dom@19.2.0(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)(typescript@5.9.3)': - dependencies: - '@expo/code-signing-certificates': 0.0.6 - '@expo/config': 55.0.8(typescript@5.9.3) - '@expo/config-plugins': 55.0.6 - '@expo/devcert': 1.2.1 - '@expo/env': 2.1.1 - '@expo/image-utils': 0.8.12 - '@expo/json-file': 10.0.12 - '@expo/log-box': 55.0.7(@expo/dom-webview@55.0.3)(expo@55.0.5)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0) - '@expo/metro': 54.2.0 - '@expo/metro-config': 55.0.9(expo@55.0.5)(typescript@5.9.3) - '@expo/osascript': 2.4.2 - '@expo/package-manager': 1.10.3 - '@expo/plist': 0.5.2 - '@expo/prebuild-config': 55.0.8(expo@55.0.5)(typescript@5.9.3) - '@expo/require-utils': 55.0.2(typescript@5.9.3) - '@expo/router-server': 55.0.9(@expo/metro-runtime@55.0.7)(expo-constants@55.0.7)(expo-font@55.0.4)(expo-router@55.0.5)(expo-server@55.0.6)(expo@55.0.5)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) - '@expo/schema-utils': 55.0.2 - '@expo/spawn-async': 1.7.2 - '@expo/ws-tunnel': 1.0.6 - '@expo/xcpretty': 4.4.1 - '@react-native/dev-middleware': 0.83.2 - accepts: 1.3.8 - arg: 5.0.2 - better-opn: 3.0.2 - bplist-creator: 0.1.0 - bplist-parser: 0.3.2 - chalk: 4.1.2 - ci-info: 3.9.0 - compression: 1.8.1 - connect: 3.7.0 - debug: 4.4.3 - dnssd-advertise: 1.1.3 - expo: 55.0.5(@babel/core@7.28.5)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@55.0.7)(expo-router@55.0.5)(react-dom@19.2.0(react@19.2.0))(react-native-webview@13.12.2(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) - expo-server: 55.0.6 - fetch-nodeshim: 0.4.9 - getenv: 2.0.0 - glob: 13.0.0 - lan-network: 0.2.0 - multitars: 0.2.4 - node-forge: 1.3.3 - npm-package-arg: 11.0.3 - ora: 3.4.0 - picomatch: 4.0.3 - pretty-format: 29.7.0 - progress: 2.0.3 - prompts: 2.4.2 - resolve-from: 5.0.0 - semver: 7.7.3 - send: 0.19.1 - slugify: 1.6.6 - source-map-support: 0.5.21 - stacktrace-parser: 0.1.11 - structured-headers: 0.4.1 - terminal-link: 2.1.1 - toqr: 0.1.1 - wrap-ansi: 7.0.0 - ws: 8.18.3 - zod: 3.25.76 - optionalDependencies: - expo-router: 55.0.5(cdndhew7mqhhoslu6uoygxcgvm) - react-native: 0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0) - transitivePeerDependencies: - - '@expo/dom-webview' - - '@expo/metro-runtime' - - bufferutil - - expo-constants - - expo-font - - react - - react-dom - - react-server-dom-webpack - - supports-color - - typescript - - utf-8-validate - '@expo/cli@55.0.15(@expo/dom-webview@55.0.3)(@expo/metro-runtime@55.0.7)(expo-constants@55.0.7)(expo-font@55.0.4)(expo-router@55.0.5)(expo@55.0.5)(react-dom@19.2.0(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3)': dependencies: '@expo/code-signing-certificates': 0.0.6 - '@expo/config': 55.0.8(typescript@5.9.3) - '@expo/config-plugins': 55.0.6 + '@expo/config': 55.0.11(typescript@5.9.3) + '@expo/config-plugins': 55.0.7 '@expo/devcert': 1.2.1 '@expo/env': 2.1.1 '@expo/image-utils': 0.8.12 @@ -29324,8 +28913,8 @@ snapshots: '@expo/osascript': 2.4.2 '@expo/package-manager': 1.10.3 '@expo/plist': 0.5.2 - '@expo/prebuild-config': 55.0.8(expo@55.0.5)(typescript@5.9.3) - '@expo/require-utils': 55.0.2(typescript@5.9.3) + '@expo/prebuild-config': 55.0.11(expo@55.0.5)(typescript@5.9.3) + '@expo/require-utils': 55.0.3(typescript@5.9.3) '@expo/router-server': 55.0.9(@expo/metro-runtime@55.0.7)(expo-constants@55.0.7)(expo-font@55.0.4)(expo-router@55.0.5)(expo-server@55.0.6)(expo@55.0.5)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) '@expo/schema-utils': 55.0.2 '@expo/spawn-async': 1.7.2 @@ -29388,8 +28977,8 @@ snapshots: '@expo/cli@55.0.15(@expo/dom-webview@55.0.3)(@expo/metro-runtime@55.0.7)(expo-constants@55.0.7)(expo-font@55.0.4)(expo-router@55.0.5)(expo@55.0.5)(react-dom@19.2.4(react@19.2.4))(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4)(typescript@5.9.3)': dependencies: '@expo/code-signing-certificates': 0.0.6 - '@expo/config': 55.0.8(typescript@5.9.3) - '@expo/config-plugins': 55.0.6 + '@expo/config': 55.0.11(typescript@5.9.3) + '@expo/config-plugins': 55.0.7 '@expo/devcert': 1.2.1 '@expo/env': 2.1.1 '@expo/image-utils': 0.8.12 @@ -29400,8 +28989,8 @@ snapshots: '@expo/osascript': 2.4.2 '@expo/package-manager': 1.10.3 '@expo/plist': 0.5.2 - '@expo/prebuild-config': 55.0.8(expo@55.0.5)(typescript@5.9.3) - '@expo/require-utils': 55.0.2(typescript@5.9.3) + '@expo/prebuild-config': 55.0.11(expo@55.0.5)(typescript@5.9.3) + '@expo/require-utils': 55.0.3(typescript@5.9.3) '@expo/router-server': 55.0.9(@expo/metro-runtime@55.0.7)(expo-constants@55.0.7)(expo-font@55.0.4)(expo-router@55.0.5)(expo-server@55.0.6)(expo@55.0.5)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@expo/schema-utils': 55.0.2 '@expo/spawn-async': 1.7.2 @@ -29464,7 +29053,7 @@ snapshots: '@expo/code-signing-certificates@0.0.5': dependencies: - node-forge: 1.3.2 + node-forge: 1.3.3 nullthrows: 1.1.1 '@expo/code-signing-certificates@0.0.6': @@ -29587,6 +29176,40 @@ snapshots: transitivePeerDependencies: - supports-color + '@expo/config@55.0.11(typescript@5.3.3)': + dependencies: + '@expo/config-plugins': 55.0.7 + '@expo/config-types': 55.0.5 + '@expo/json-file': 10.0.12 + '@expo/require-utils': 55.0.3(typescript@5.3.3) + deepmerge: 4.3.1 + getenv: 2.0.0 + glob: 13.0.0 + resolve-from: 5.0.0 + resolve-workspace-root: 2.0.0 + semver: 7.7.3 + slugify: 1.6.6 + transitivePeerDependencies: + - supports-color + - typescript + + '@expo/config@55.0.11(typescript@5.8.3)': + dependencies: + '@expo/config-plugins': 55.0.7 + '@expo/config-types': 55.0.5 + '@expo/json-file': 10.0.12 + '@expo/require-utils': 55.0.3(typescript@5.8.3) + deepmerge: 4.3.1 + getenv: 2.0.0 + glob: 13.0.0 + resolve-from: 5.0.0 + resolve-workspace-root: 2.0.0 + semver: 7.7.3 + slugify: 1.6.6 + transitivePeerDependencies: + - supports-color + - typescript + '@expo/config@55.0.11(typescript@5.9.3)': dependencies: '@expo/config-plugins': 55.0.7 @@ -29604,57 +29227,6 @@ snapshots: - supports-color - typescript - '@expo/config@55.0.8(typescript@5.3.3)': - dependencies: - '@expo/config-plugins': 55.0.6 - '@expo/config-types': 55.0.5 - '@expo/json-file': 10.0.12 - '@expo/require-utils': 55.0.2(typescript@5.3.3) - deepmerge: 4.3.1 - getenv: 2.0.0 - glob: 13.0.0 - resolve-from: 5.0.0 - resolve-workspace-root: 2.0.0 - semver: 7.7.3 - slugify: 1.6.6 - transitivePeerDependencies: - - supports-color - - typescript - - '@expo/config@55.0.8(typescript@5.8.3)': - dependencies: - '@expo/config-plugins': 55.0.6 - '@expo/config-types': 55.0.5 - '@expo/json-file': 10.0.12 - '@expo/require-utils': 55.0.2(typescript@5.8.3) - deepmerge: 4.3.1 - getenv: 2.0.0 - glob: 13.0.0 - resolve-from: 5.0.0 - resolve-workspace-root: 2.0.0 - semver: 7.7.3 - slugify: 1.6.6 - transitivePeerDependencies: - - supports-color - - typescript - - '@expo/config@55.0.8(typescript@5.9.3)': - dependencies: - '@expo/config-plugins': 55.0.6 - '@expo/config-types': 55.0.5 - '@expo/json-file': 10.0.12 - '@expo/require-utils': 55.0.2(typescript@5.9.3) - deepmerge: 4.3.1 - getenv: 2.0.0 - glob: 13.0.0 - resolve-from: 5.0.0 - resolve-workspace-root: 2.0.0 - semver: 7.7.3 - slugify: 1.6.6 - transitivePeerDependencies: - - supports-color - - typescript - '@expo/devcert@1.2.1': dependencies: '@expo/sudo-prompt': 9.3.2 @@ -29707,7 +29279,7 @@ snapshots: '@expo/dom-webview@55.0.3(expo@55.0.5)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)': dependencies: - expo: 55.0.5(@babel/core@7.28.5)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@55.0.7)(expo-router@55.0.5)(react-dom@19.2.0(react@19.2.0))(react-native-webview@13.12.2(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)(typescript@5.3.3) + expo: 55.0.5(@babel/core@7.28.5)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@55.0.7)(expo-router@55.0.5)(react-dom@19.2.0(react@19.2.0))(react-native-webview@13.12.2(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) react: 19.2.0 react-native: 0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0) @@ -29835,7 +29407,7 @@ snapshots: '@expo/local-build-cache-provider@55.0.6(typescript@5.3.3)': dependencies: - '@expo/config': 55.0.8(typescript@5.3.3) + '@expo/config': 55.0.11(typescript@5.3.3) chalk: 4.1.2 transitivePeerDependencies: - supports-color @@ -29843,7 +29415,7 @@ snapshots: '@expo/local-build-cache-provider@55.0.6(typescript@5.8.3)': dependencies: - '@expo/config': 55.0.8(typescript@5.8.3) + '@expo/config': 55.0.11(typescript@5.8.3) chalk: 4.1.2 transitivePeerDependencies: - supports-color @@ -29851,7 +29423,7 @@ snapshots: '@expo/local-build-cache-provider@55.0.6(typescript@5.9.3)': dependencies: - '@expo/config': 55.0.8(typescript@5.9.3) + '@expo/config': 55.0.11(typescript@5.9.3) chalk: 4.1.2 transitivePeerDependencies: - supports-color @@ -29861,7 +29433,7 @@ snapshots: dependencies: '@expo/dom-webview': 55.0.3(expo@55.0.5)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0) anser: 1.4.10 - expo: 55.0.5(@babel/core@7.28.5)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@55.0.7)(expo-router@55.0.5)(react-dom@19.2.0(react@19.2.0))(react-native-webview@13.12.2(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)(typescript@5.3.3) + expo: 55.0.5(@babel/core@7.28.5)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@55.0.7)(expo-router@55.0.5)(react-dom@19.2.0(react@19.2.0))(react-native-webview@13.12.2(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) react: 19.2.0 react-native: 0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0) stacktrace-parser: 0.1.11 @@ -29909,7 +29481,7 @@ snapshots: dependencies: '@expo/dom-webview': 55.0.3(expo@55.0.5)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0) anser: 1.4.10 - expo: 55.0.5(@babel/core@7.28.5)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@55.0.7)(expo-router@55.0.5)(react-dom@19.2.0(react@19.2.0))(react-native-webview@13.12.2(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)(typescript@5.3.3) + expo: 55.0.5(@babel/core@7.28.5)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@55.0.7)(expo-router@55.0.5)(react-dom@19.2.0(react@19.2.0))(react-native-webview@13.12.2(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) react: 19.2.0 react-native: 0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0) stacktrace-parser: 0.1.11 @@ -30001,7 +29573,7 @@ snapshots: '@babel/code-frame': 7.27.1 '@babel/core': 7.28.5 '@babel/generator': 7.28.5 - '@expo/config': 55.0.8(typescript@5.3.3) + '@expo/config': 55.0.11(typescript@5.3.3) '@expo/env': 2.1.1 '@expo/json-file': 10.0.12 '@expo/metro': 54.2.0 @@ -30030,7 +29602,7 @@ snapshots: '@babel/code-frame': 7.27.1 '@babel/core': 7.28.5 '@babel/generator': 7.28.5 - '@expo/config': 55.0.8(typescript@5.8.3) + '@expo/config': 55.0.11(typescript@5.8.3) '@expo/env': 2.1.1 '@expo/json-file': 10.0.12 '@expo/metro': 54.2.0 @@ -30059,7 +29631,7 @@ snapshots: '@babel/code-frame': 7.27.1 '@babel/core': 7.28.5 '@babel/generator': 7.28.5 - '@expo/config': 55.0.8(typescript@5.9.3) + '@expo/config': 55.0.11(typescript@5.9.3) '@expo/env': 2.1.1 '@expo/json-file': 10.0.12 '@expo/metro': 54.2.0 @@ -30076,7 +29648,7 @@ snapshots: postcss: 8.4.49 resolve-from: 5.0.0 optionalDependencies: - expo: 55.0.5(@babel/core@7.28.5)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@55.0.7)(expo-router@55.0.5)(react-dom@19.2.0(react@19.2.0))(react-native-webview@13.12.2(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) + expo: 55.0.5(@babel/core@7.28.5)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@55.0.7)(expo-router@55.0.5)(react-dom@19.2.0(react@19.2.0))(react-native-webview@13.12.2(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) transitivePeerDependencies: - bufferutil - supports-color @@ -30123,7 +29695,7 @@ snapshots: dependencies: '@expo/log-box': 55.0.8(@expo/dom-webview@55.0.3)(expo@55.0.5)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0) anser: 1.4.10 - expo: 55.0.5(@babel/core@7.28.5)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@55.0.7)(expo-router@55.0.5)(react-dom@19.2.0(react@19.2.0))(react-native-webview@13.12.2(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)(typescript@5.3.3) + expo: 55.0.5(@babel/core@7.28.5)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@55.0.7)(expo-router@55.0.5)(react-dom@19.2.0(react@19.2.0))(react-native-webview@13.12.2(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) pretty-format: 29.7.0 react: 19.2.0 react-native: 0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0) @@ -30252,32 +29824,15 @@ snapshots: transitivePeerDependencies: - supports-color - '@expo/prebuild-config@55.0.11(expo@55.0.5)(typescript@5.9.3)': + '@expo/prebuild-config@55.0.11(expo@55.0.5)(typescript@5.3.3)': dependencies: - '@expo/config': 55.0.11(typescript@5.9.3) + '@expo/config': 55.0.11(typescript@5.3.3) '@expo/config-plugins': 55.0.7 '@expo/config-types': 55.0.5 '@expo/image-utils': 0.8.12 '@expo/json-file': 10.0.12 '@react-native/normalize-colors': 0.83.4 debug: 4.4.3 - expo: 55.0.5(@babel/core@7.28.5)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@55.0.7)(expo-router@55.0.5)(react-dom@19.2.0(react@19.2.0))(react-native-webview@13.12.2(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) - resolve-from: 5.0.0 - semver: 7.7.3 - xml2js: 0.6.0 - transitivePeerDependencies: - - supports-color - - typescript - - '@expo/prebuild-config@55.0.8(expo@55.0.5)(typescript@5.3.3)': - dependencies: - '@expo/config': 55.0.8(typescript@5.3.3) - '@expo/config-plugins': 55.0.6 - '@expo/config-types': 55.0.5 - '@expo/image-utils': 0.8.12 - '@expo/json-file': 10.0.12 - '@react-native/normalize-colors': 0.83.2 - debug: 4.4.3 expo: 55.0.5(@babel/core@7.28.5)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@55.0.7)(expo-router@55.0.5)(react-dom@19.2.0(react@19.2.0))(react-native-webview@13.12.2(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)(typescript@5.3.3) resolve-from: 5.0.0 semver: 7.7.3 @@ -30286,14 +29841,14 @@ snapshots: - supports-color - typescript - '@expo/prebuild-config@55.0.8(expo@55.0.5)(typescript@5.8.3)': + '@expo/prebuild-config@55.0.11(expo@55.0.5)(typescript@5.8.3)': dependencies: - '@expo/config': 55.0.8(typescript@5.8.3) - '@expo/config-plugins': 55.0.6 + '@expo/config': 55.0.11(typescript@5.8.3) + '@expo/config-plugins': 55.0.7 '@expo/config-types': 55.0.5 '@expo/image-utils': 0.8.12 '@expo/json-file': 10.0.12 - '@react-native/normalize-colors': 0.83.2 + '@react-native/normalize-colors': 0.83.4 debug: 4.4.3 expo: 55.0.5(@babel/core@7.28.5)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@55.0.7)(expo-router@55.0.5)(react-dom@19.2.0(react@19.2.0))(react-native-webview@13.12.2(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)(typescript@5.8.3) resolve-from: 5.0.0 @@ -30303,16 +29858,16 @@ snapshots: - supports-color - typescript - '@expo/prebuild-config@55.0.8(expo@55.0.5)(typescript@5.9.3)': + '@expo/prebuild-config@55.0.11(expo@55.0.5)(typescript@5.9.3)': dependencies: - '@expo/config': 55.0.8(typescript@5.9.3) - '@expo/config-plugins': 55.0.6 + '@expo/config': 55.0.11(typescript@5.9.3) + '@expo/config-plugins': 55.0.7 '@expo/config-types': 55.0.5 '@expo/image-utils': 0.8.12 '@expo/json-file': 10.0.12 - '@react-native/normalize-colors': 0.83.2 + '@react-native/normalize-colors': 0.83.4 debug: 4.4.3 - expo: 55.0.5(@babel/core@7.28.5)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@55.0.7)(expo-router@55.0.5)(react-dom@19.2.0(react@19.2.0))(react-native-webview@13.12.2(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) + expo: 55.0.5(@babel/core@7.28.5)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@55.0.7)(expo-router@55.0.5)(react-dom@19.2.0(react@19.2.0))(react-native-webview@13.12.2(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) resolve-from: 5.0.0 semver: 7.7.3 xml2js: 0.6.0 @@ -30343,7 +29898,7 @@ snapshots: transitivePeerDependencies: - '@types/react' - '@expo/require-utils@55.0.2(typescript@5.3.3)': + '@expo/require-utils@55.0.3(typescript@5.3.3)': dependencies: '@babel/code-frame': 7.27.1 '@babel/core': 7.28.5 @@ -30353,7 +29908,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@expo/require-utils@55.0.2(typescript@5.8.3)': + '@expo/require-utils@55.0.3(typescript@5.8.3)': dependencies: '@babel/code-frame': 7.27.1 '@babel/core': 7.28.5 @@ -30363,16 +29918,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@expo/require-utils@55.0.2(typescript@5.9.3)': - dependencies: - '@babel/code-frame': 7.27.1 - '@babel/core': 7.28.5 - '@babel/plugin-transform-modules-commonjs': 7.27.1(@babel/core@7.28.5) - optionalDependencies: - typescript: 5.9.3 - transitivePeerDependencies: - - supports-color - '@expo/require-utils@55.0.3(typescript@5.9.3)': dependencies: '@babel/code-frame': 7.27.1 @@ -30383,6 +29928,21 @@ snapshots: transitivePeerDependencies: - supports-color + '@expo/router-server@55.0.9(@expo/metro-runtime@55.0.7)(expo-constants@55.0.7)(expo-font@55.0.4(expo@55.0.5)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(expo-router@55.0.5)(expo-server@55.0.6)(expo@55.0.5)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + debug: 4.4.3 + expo: 55.0.5(@babel/core@7.28.5)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@55.0.7)(expo-router@55.0.5)(react-dom@19.2.0(react@19.2.0))(react-native-webview@13.12.2(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) + expo-constants: 55.0.7(expo@55.0.5)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(typescript@5.9.3) + expo-font: 55.0.4(expo@55.0.5)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0) + expo-server: 55.0.6 + react: 19.2.0 + optionalDependencies: + '@expo/metro-runtime': 55.0.7(@expo/dom-webview@55.0.3)(expo@55.0.5)(react-dom@19.2.0(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0) + expo-router: 55.0.5(6d2zdlrrz5o6rsdio5yhgp3juy) + react-dom: 19.2.0(react@19.2.0) + transitivePeerDependencies: + - supports-color + '@expo/router-server@55.0.9(@expo/metro-runtime@55.0.7)(expo-constants@55.0.7)(expo-font@55.0.4)(expo-router@55.0.5)(expo-server@55.0.6)(expo@55.0.5)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': dependencies: debug: 4.4.3 @@ -30393,7 +29953,7 @@ snapshots: react: 19.2.0 optionalDependencies: '@expo/metro-runtime': 55.0.7(@expo/dom-webview@55.0.3)(expo@55.0.5)(react-dom@19.2.0(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0) - expo-router: 55.0.5(shihlejigi2nkza7wltmngtxfm) + expo-router: 55.0.5(bhhyukj6njqvlfm2o3z6hpwcja) react-dom: 19.2.0(react@19.2.0) transitivePeerDependencies: - supports-color @@ -30440,7 +30000,7 @@ snapshots: expo: 55.0.5(@babel/core@7.28.5)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@55.0.7)(expo-router@55.0.5)(react-dom@19.2.0(react@19.2.0))(react-native-webview@13.12.2(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) react: 19.2.0 react-native: 0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0) - sf-symbols-typescript: 2.1.0 + sf-symbols-typescript: 2.2.0 '@expo/vector-icons@14.1.0(expo-font@13.3.2(expo@53.0.27)(react@19.0.0))(react-native@0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)': dependencies: @@ -30466,9 +30026,15 @@ snapshots: react: 19.1.0 react-native: 0.81.4(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.1.0) - '@expo/vector-icons@15.0.3(expo-font@14.0.9)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)': + '@expo/vector-icons@15.0.3(expo-font@14.0.10)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)': dependencies: - expo-font: 14.0.9(expo@55.0.5)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0) + expo-font: 14.0.10(expo@55.0.5)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0) + react: 19.2.0 + react-native: 0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0) + + '@expo/vector-icons@15.0.3(expo-font@55.0.4(expo@55.0.5)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)': + dependencies: + expo-font: 55.0.4(expo@55.0.5)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0) react: 19.2.0 react-native: 0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0) @@ -30854,7 +30420,7 @@ snapshots: slash: 3.0.0 optional: true - '@jest/core@29.7.0': + '@jest/core@29.7.0(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3))': dependencies: '@jest/console': 29.7.0 '@jest/reporters': 29.7.0 @@ -30868,42 +30434,7 @@ snapshots: exit: 0.1.2 graceful-fs: 4.2.11 jest-changed-files: 29.7.0 - jest-config: 29.7.0(@types/node@22.19.1) - jest-haste-map: 29.7.0 - jest-message-util: 29.7.0 - jest-regex-util: 29.6.3 - jest-resolve: 29.7.0 - jest-resolve-dependencies: 29.7.0 - jest-runner: 29.7.0 - jest-runtime: 29.7.0 - jest-snapshot: 29.7.0 - jest-util: 29.7.0 - jest-validate: 29.7.0 - jest-watcher: 29.7.0 - micromatch: 4.0.8 - pretty-format: 29.7.0 - slash: 3.0.0 - strip-ansi: 6.0.1 - transitivePeerDependencies: - - babel-plugin-macros - - supports-color - - ts-node - - '@jest/core@29.7.0(ts-node@10.9.2(@types/node@20.19.25)(typescript@5.9.3))': - dependencies: - '@jest/console': 29.7.0 - '@jest/reporters': 29.7.0 - '@jest/test-result': 29.7.0 - '@jest/transform': 29.7.0 - '@jest/types': 29.6.3 - '@types/node': 22.19.1 - ansi-escapes: 4.3.2 - chalk: 4.1.2 - ci-info: 3.9.0 - exit: 0.1.2 - graceful-fs: 4.2.11 - jest-changed-files: 29.7.0 - jest-config: 29.7.0(@types/node@22.19.1)(ts-node@10.9.2(@types/node@20.19.25)(typescript@5.9.3)) + jest-config: 29.7.0(@types/node@22.19.1)(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3)) jest-haste-map: 29.7.0 jest-message-util: 29.7.0 jest-regex-util: 29.6.3 @@ -30960,7 +30491,7 @@ snapshots: - ts-node optional: true - '@jest/core@30.3.0(esbuild-register@3.6.0(esbuild@0.27.4))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.3.3))': + '@jest/core@30.3.0(esbuild-register@3.6.0(esbuild@0.27.4))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3))': dependencies: '@jest/console': 30.3.0 '@jest/pattern': 30.0.1 @@ -30975,7 +30506,7 @@ snapshots: exit-x: 0.2.2 graceful-fs: 4.2.11 jest-changed-files: 30.3.0 - jest-config: 30.3.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.27.4))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.3.3)) + jest-config: 30.3.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.27.4))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)) jest-haste-map: 30.3.0 jest-message-util: 30.3.0 jest-regex-util: 30.0.1 @@ -31457,8 +30988,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@microsoft/tsdoc@0.15.1': {} - '@mozilla/readability@0.5.0': {} '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.3': @@ -31490,12 +31019,6 @@ snapshots: dependencies: svelte: 5.44.0 - '@nestjs/axios@3.1.3(@nestjs/common@10.4.20(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.1.14)(rxjs@7.8.2))(axios@1.14.0)(rxjs@7.8.2)': - dependencies: - '@nestjs/common': 10.4.20(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.1.14)(rxjs@7.8.2) - axios: 1.14.0 - rxjs: 7.8.2 - '@nestjs/cli@10.4.9': dependencies: '@angular-devkit/core': 17.3.11(chokidar@3.6.0) @@ -31522,20 +31045,6 @@ snapshots: - uglify-js - webpack-cli - '@nestjs/common@10.4.20(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.1.14)(rxjs@7.8.2)': - dependencies: - file-type: 20.4.1 - iterare: 1.2.1 - reflect-metadata: 0.1.14 - rxjs: 7.8.2 - tslib: 2.8.1 - uid: 2.0.2 - optionalDependencies: - class-transformer: 0.5.1 - class-validator: 0.14.3 - transitivePeerDependencies: - - supports-color - '@nestjs/common@10.4.20(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2)': dependencies: file-type: 20.4.1 @@ -31565,14 +31074,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@nestjs/config@3.3.0(@nestjs/common@10.4.20(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.1.14)(rxjs@7.8.2))(rxjs@7.8.2)': - dependencies: - '@nestjs/common': 10.4.20(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.1.14)(rxjs@7.8.2) - dotenv: 16.4.5 - dotenv-expand: 10.0.0 - lodash: 4.17.21 - rxjs: 7.8.2 - '@nestjs/config@3.3.0(@nestjs/common@10.4.20(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(rxjs@7.8.2)': dependencies: '@nestjs/common': 10.4.20(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2) @@ -31589,23 +31090,6 @@ snapshots: lodash: 4.17.21 rxjs: 7.8.2 - '@nestjs/core@10.4.20(@nestjs/common@10.4.20(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.1.14)(rxjs@7.8.2))(@nestjs/platform-express@10.4.20)(@nestjs/websockets@10.4.20)(encoding@0.1.13)(reflect-metadata@0.1.14)(rxjs@7.8.2)': - dependencies: - '@nestjs/common': 10.4.20(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.1.14)(rxjs@7.8.2) - '@nuxtjs/opencollective': 0.3.2(encoding@0.1.13) - fast-safe-stringify: 2.1.1 - iterare: 1.2.1 - path-to-regexp: 3.3.0 - reflect-metadata: 0.1.14 - rxjs: 7.8.2 - tslib: 2.8.1 - uid: 2.0.2 - optionalDependencies: - '@nestjs/platform-express': 10.4.20(@nestjs/common@10.4.20(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.1.14)(rxjs@7.8.2))(@nestjs/core@10.4.20) - '@nestjs/websockets': 10.4.20(@nestjs/common@10.4.20(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.1.14)(rxjs@7.8.2))(@nestjs/core@10.4.20)(@nestjs/platform-socket.io@10.4.20)(reflect-metadata@0.1.14)(rxjs@7.8.2) - transitivePeerDependencies: - - encoding - '@nestjs/core@10.4.20(@nestjs/common@10.4.20(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@10.4.20)(@nestjs/websockets@10.4.20)(encoding@0.1.13)(reflect-metadata@0.2.2)(rxjs@7.8.2)': dependencies: '@nestjs/common': 10.4.20(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2) @@ -31640,26 +31124,6 @@ snapshots: transitivePeerDependencies: - encoding - '@nestjs/mapped-types@2.0.5(@nestjs/common@10.4.20(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.1.14)(rxjs@7.8.2))(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.1.14)': - dependencies: - '@nestjs/common': 10.4.20(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.1.14)(rxjs@7.8.2) - reflect-metadata: 0.1.14 - optionalDependencies: - class-transformer: 0.5.1 - class-validator: 0.14.3 - - '@nestjs/platform-express@10.4.20(@nestjs/common@10.4.20(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.1.14)(rxjs@7.8.2))(@nestjs/core@10.4.20)': - dependencies: - '@nestjs/common': 10.4.20(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.1.14)(rxjs@7.8.2) - '@nestjs/core': 10.4.20(@nestjs/common@10.4.20(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.1.14)(rxjs@7.8.2))(@nestjs/platform-express@10.4.20)(@nestjs/websockets@10.4.20)(encoding@0.1.13)(reflect-metadata@0.1.14)(rxjs@7.8.2) - body-parser: 1.20.3 - cors: 2.8.5 - express: 4.21.2 - multer: 2.0.2 - tslib: 2.8.1 - transitivePeerDependencies: - - supports-color - '@nestjs/platform-express@10.4.20(@nestjs/common@10.4.20(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.20)': dependencies: '@nestjs/common': 10.4.20(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2) @@ -31685,19 +31149,6 @@ snapshots: - supports-color optional: true - '@nestjs/platform-socket.io@10.4.20(@nestjs/common@10.4.20(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.1.14)(rxjs@7.8.2))(@nestjs/websockets@10.4.20)(rxjs@7.8.2)': - dependencies: - '@nestjs/common': 10.4.20(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.1.14)(rxjs@7.8.2) - '@nestjs/websockets': 10.4.20(@nestjs/common@10.4.20(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.1.14)(rxjs@7.8.2))(@nestjs/core@10.4.20)(@nestjs/platform-socket.io@10.4.20)(reflect-metadata@0.1.14)(rxjs@7.8.2) - rxjs: 7.8.2 - socket.io: 4.8.1 - tslib: 2.8.1 - transitivePeerDependencies: - - bufferutil - - supports-color - - utf-8-validate - optional: true - '@nestjs/platform-socket.io@10.4.20(@nestjs/common@10.4.20(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/websockets@10.4.20)(rxjs@7.8.2)': dependencies: '@nestjs/common': 10.4.20(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2) @@ -31746,29 +31197,6 @@ snapshots: transitivePeerDependencies: - chokidar - '@nestjs/swagger@7.4.2(@nestjs/common@10.4.20(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.1.14)(rxjs@7.8.2))(@nestjs/core@10.4.20)(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.1.14)': - dependencies: - '@microsoft/tsdoc': 0.15.1 - '@nestjs/common': 10.4.20(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.1.14)(rxjs@7.8.2) - '@nestjs/core': 10.4.20(@nestjs/common@10.4.20(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.1.14)(rxjs@7.8.2))(@nestjs/platform-express@10.4.20)(@nestjs/websockets@10.4.20)(encoding@0.1.13)(reflect-metadata@0.1.14)(rxjs@7.8.2) - '@nestjs/mapped-types': 2.0.5(@nestjs/common@10.4.20(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.1.14)(rxjs@7.8.2))(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.1.14) - js-yaml: 4.1.0 - lodash: 4.17.21 - path-to-regexp: 3.3.0 - reflect-metadata: 0.1.14 - swagger-ui-dist: 5.17.14 - optionalDependencies: - class-transformer: 0.5.1 - class-validator: 0.14.3 - - '@nestjs/testing@10.4.20(@nestjs/common@10.4.20(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.1.14)(rxjs@7.8.2))(@nestjs/core@10.4.20)(@nestjs/platform-express@10.4.20)': - dependencies: - '@nestjs/common': 10.4.20(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.1.14)(rxjs@7.8.2) - '@nestjs/core': 10.4.20(@nestjs/common@10.4.20(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.1.14)(rxjs@7.8.2))(@nestjs/platform-express@10.4.20)(@nestjs/websockets@10.4.20)(encoding@0.1.13)(reflect-metadata@0.1.14)(rxjs@7.8.2) - tslib: 2.8.1 - optionalDependencies: - '@nestjs/platform-express': 10.4.20(@nestjs/common@10.4.20(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.1.14)(rxjs@7.8.2))(@nestjs/core@10.4.20) - '@nestjs/testing@10.4.20(@nestjs/common@10.4.20(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.20)(@nestjs/platform-express@10.4.20)': dependencies: '@nestjs/common': 10.4.20(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2) @@ -31777,25 +31205,6 @@ snapshots: optionalDependencies: '@nestjs/platform-express': 10.4.20(@nestjs/common@10.4.20(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.20) - '@nestjs/throttler@5.2.0(@nestjs/common@10.4.20(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.1.14)(rxjs@7.8.2))(@nestjs/core@10.4.20)(reflect-metadata@0.1.14)': - dependencies: - '@nestjs/common': 10.4.20(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.1.14)(rxjs@7.8.2) - '@nestjs/core': 10.4.20(@nestjs/common@10.4.20(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.1.14)(rxjs@7.8.2))(@nestjs/platform-express@10.4.20)(@nestjs/websockets@10.4.20)(encoding@0.1.13)(reflect-metadata@0.1.14)(rxjs@7.8.2) - reflect-metadata: 0.1.14 - - '@nestjs/websockets@10.4.20(@nestjs/common@10.4.20(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.1.14)(rxjs@7.8.2))(@nestjs/core@10.4.20)(@nestjs/platform-socket.io@10.4.20)(reflect-metadata@0.1.14)(rxjs@7.8.2)': - dependencies: - '@nestjs/common': 10.4.20(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.1.14)(rxjs@7.8.2) - '@nestjs/core': 10.4.20(@nestjs/common@10.4.20(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.1.14)(rxjs@7.8.2))(@nestjs/platform-express@10.4.20)(@nestjs/websockets@10.4.20)(encoding@0.1.13)(reflect-metadata@0.1.14)(rxjs@7.8.2) - iterare: 1.2.1 - object-hash: 3.0.0 - reflect-metadata: 0.1.14 - rxjs: 7.8.2 - tslib: 2.8.1 - optionalDependencies: - '@nestjs/platform-socket.io': 10.4.20(@nestjs/common@10.4.20(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.1.14)(rxjs@7.8.2))(@nestjs/websockets@10.4.20)(rxjs@7.8.2) - optional: true - '@nestjs/websockets@10.4.20(@nestjs/common@10.4.20(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.20)(@nestjs/platform-socket.io@10.4.20)(reflect-metadata@0.2.2)(rxjs@7.8.2)': dependencies: '@nestjs/common': 10.4.20(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2) @@ -31873,7 +31282,7 @@ snapshots: validate-npm-package-name: 5.0.1 yaml: 2.8.1 yargs: 17.7.2 - zod: 4.1.13 + zod: 4.3.6 '@netlify/db-dev@0.7.0': dependencies: @@ -32168,8 +31577,6 @@ snapshots: '@noble/ciphers@2.0.1': {} - '@noble/hashes@1.8.0': {} - '@noble/hashes@2.0.1': {} '@nodelib/fs.scandir@2.1.5': @@ -32495,10 +31902,6 @@ snapshots: '@pagefind/windows-x64@1.4.0': optional: true - '@paralleldrive/cuid2@2.3.1': - dependencies: - '@noble/hashes': 1.8.0 - '@parcel/watcher-android-arm64@2.5.6': optional: true @@ -34223,23 +33626,6 @@ snapshots: use-sync-external-store: 1.6.0(react@19.2.4) optional: true - '@react-navigation/drawer@7.7.4(@react-navigation/native@7.1.21(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native-gesture-handler@2.28.0(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native-reanimated@4.1.5(@babel/core@7.28.5)(react-native-worklets@0.5.1(@babel/core@7.28.5)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native-safe-area-context@5.6.2(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native-screens@4.16.0(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)': - dependencies: - '@react-navigation/elements': 2.8.3(@react-navigation/native@7.1.21(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native-safe-area-context@5.6.2(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0) - '@react-navigation/native': 7.1.21(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0) - color: 4.2.3 - react: 19.2.0 - react-native: 0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0) - react-native-drawer-layout: 4.2.0(react-native-gesture-handler@2.28.0(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native-reanimated@4.1.5(@babel/core@7.28.5)(react-native-worklets@0.5.1(@babel/core@7.28.5)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0) - react-native-gesture-handler: 2.28.0(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0) - react-native-reanimated: 4.1.5(@babel/core@7.28.5)(react-native-worklets@0.5.1(@babel/core@7.28.5)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0) - react-native-safe-area-context: 5.6.2(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0) - react-native-screens: 4.16.0(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0) - use-latest-callback: 0.2.6(react@19.2.0) - transitivePeerDependencies: - - '@react-native-masked-view/masked-view' - optional: true - '@react-navigation/drawer@7.7.4(@react-navigation/native@7.1.21(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native-gesture-handler@2.28.0(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native-reanimated@4.1.5(@babel/core@7.28.5)(react-native-worklets@0.6.1(@babel/core@7.28.5)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native-safe-area-context@5.6.2(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native-screens@4.16.0(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)': dependencies: '@react-navigation/elements': 2.8.3(@react-navigation/native@7.1.21(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native-safe-area-context@5.6.2(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0) @@ -34307,6 +33693,23 @@ snapshots: - '@react-native-masked-view/masked-view' optional: true + '@react-navigation/drawer@7.7.4(@react-navigation/native@7.1.33(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native-gesture-handler@2.28.0(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native-reanimated@4.1.5(@babel/core@7.28.5)(react-native-worklets@0.5.1(@babel/core@7.28.5)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native-safe-area-context@5.6.2(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native-screens@4.16.0(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)': + dependencies: + '@react-navigation/elements': 2.8.3(@react-navigation/native@7.1.33(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native-safe-area-context@5.6.2(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0) + '@react-navigation/native': 7.1.33(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0) + color: 4.2.3 + react: 19.2.0 + react-native: 0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0) + react-native-drawer-layout: 4.2.0(react-native-gesture-handler@2.28.0(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native-reanimated@4.1.5(@babel/core@7.28.5)(react-native-worklets@0.5.1(@babel/core@7.28.5)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0) + react-native-gesture-handler: 2.28.0(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0) + react-native-reanimated: 4.1.5(@babel/core@7.28.5)(react-native-worklets@0.5.1(@babel/core@7.28.5)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0) + react-native-safe-area-context: 5.6.2(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0) + react-native-screens: 4.16.0(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0) + use-latest-callback: 0.2.6(react@19.2.0) + transitivePeerDependencies: + - '@react-native-masked-view/masked-view' + optional: true + '@react-navigation/drawer@7.7.4(@react-navigation/native@7.1.33(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native-gesture-handler@2.28.0(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native-reanimated@4.1.5(@babel/core@7.28.5)(react-native-worklets@0.6.1(@babel/core@7.28.5)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native-safe-area-context@5.6.2(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native-screens@4.16.0(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)': dependencies: '@react-navigation/elements': 2.8.3(@react-navigation/native@7.1.33(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native-safe-area-context@5.6.2(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0) @@ -34777,8 +34180,6 @@ snapshots: '@rtsao/scc@1.1.0': {} - '@scarf/scarf@1.4.0': {} - '@sentry-internal/browser-utils@9.47.1': dependencies: '@sentry/core': 9.47.1 @@ -36185,7 +35586,7 @@ snapshots: '@testing-library/dom@10.4.1': dependencies: '@babel/code-frame': 7.27.1 - '@babel/runtime': 7.28.4 + '@babel/runtime': 7.29.2 '@types/aria-query': 5.0.4 aria-query: 5.3.0 dom-accessibility-api: 0.5.16 @@ -36215,7 +35616,7 @@ snapshots: jest: 30.3.0(@types/node@20.19.25)(esbuild-register@3.6.0(esbuild@0.27.4))(ts-node@10.9.2(@types/node@20.19.25)(typescript@5.9.3)) optional: true - '@testing-library/react-native@13.3.3(jest@30.3.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.27.4))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.3.3)))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react-test-renderer@19.1.0(react@19.2.0))(react@19.2.0)': + '@testing-library/react-native@13.3.3(jest@30.3.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.27.4))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react-test-renderer@19.1.0(react@19.2.0))(react@19.2.0)': dependencies: jest-matcher-utils: 30.3.0 picocolors: 1.1.1 @@ -36225,7 +35626,7 @@ snapshots: react-test-renderer: 19.1.0(react@19.2.0) redent: 3.0.0 optionalDependencies: - jest: 30.3.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.27.4))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.3.3)) + jest: 30.3.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.27.4))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)) optional: true '@testing-library/react-native@13.3.3(jest@30.3.0(@types/node@24.10.1)(esbuild-register@3.6.0(esbuild@0.27.4))(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.3.3)))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react-test-renderer@19.1.0(react@19.2.0))(react@19.2.0)': @@ -36358,13 +35759,17 @@ snapshots: '@trysound/sax@0.2.0': {} - '@tsconfig/node10@1.0.12': {} + '@tsconfig/node10@1.0.12': + optional: true - '@tsconfig/node12@1.0.11': {} + '@tsconfig/node12@1.0.11': + optional: true - '@tsconfig/node14@1.0.3': {} + '@tsconfig/node14@1.0.3': + optional: true - '@tsconfig/node16@1.0.4': {} + '@tsconfig/node16@1.0.4': + optional: true '@tybys/wasm-util@0.10.1': dependencies: @@ -36416,8 +35821,6 @@ snapshots: '@types/cookie@0.6.0': {} - '@types/cookiejar@2.1.5': {} - '@types/cors@2.8.19': dependencies: '@types/node': 22.19.1 @@ -36568,13 +35971,6 @@ snapshots: '@types/events@3.0.3': {} - '@types/express-serve-static-core@4.19.8': - dependencies: - '@types/node': 22.19.1 - '@types/qs': 6.14.0 - '@types/range-parser': 1.2.7 - '@types/send': 1.2.1 - '@types/express-serve-static-core@5.1.0': dependencies: '@types/node': 22.19.1 @@ -36582,13 +35978,6 @@ snapshots: '@types/range-parser': 1.2.7 '@types/send': 1.2.1 - '@types/express@4.17.25': - dependencies: - '@types/body-parser': 1.19.6 - '@types/express-serve-static-core': 4.19.8 - '@types/qs': 6.14.0 - '@types/serve-static': 1.15.10 - '@types/express@5.0.5': dependencies: '@types/body-parser': 1.19.6 @@ -36643,11 +36032,6 @@ snapshots: dependencies: '@types/istanbul-lib-report': 3.0.3 - '@types/jest@29.5.14': - dependencies: - expect: 29.7.0 - pretty-format: 29.7.0 - '@types/js-yaml@4.0.9': {} '@types/jsdom@21.1.7': @@ -36664,11 +36048,6 @@ snapshots: dependencies: '@types/node': 22.19.1 - '@types/jsonwebtoken@9.0.10': - dependencies: - '@types/ms': 2.1.0 - '@types/node': 22.19.1 - '@types/leaflet@1.9.21': dependencies: '@types/geojson': 7946.0.16 @@ -36681,18 +36060,12 @@ snapshots: '@types/mdx@2.0.13': {} - '@types/methods@1.1.4': {} - '@types/mime-types@2.1.4': {} '@types/mime@1.3.5': {} '@types/ms@2.1.0': {} - '@types/multer@1.4.13': - dependencies: - '@types/express': 5.0.6 - '@types/mysql@2.15.26': dependencies: '@types/node': 22.19.1 @@ -36829,17 +36202,6 @@ snapshots: '@types/suncalc@1.9.2': {} - '@types/superagent@8.1.9': - dependencies: - '@types/cookiejar': 2.1.5 - '@types/methods': 1.1.4 - '@types/node': 22.19.1 - form-data: 4.0.5 - - '@types/supertest@2.0.16': - dependencies: - '@types/superagent': 8.1.9 - '@types/tedious@4.0.14': dependencies: '@types/node': 22.19.1 @@ -36902,16 +36264,16 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/eslint-plugin@6.21.0(@typescript-eslint/parser@6.21.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3))(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3)': + '@typescript-eslint/eslint-plugin@6.21.0(@typescript-eslint/parser@6.21.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3))(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 6.21.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3) + '@typescript-eslint/parser': 6.21.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3) '@typescript-eslint/scope-manager': 6.21.0 - '@typescript-eslint/type-utils': 6.21.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3) - '@typescript-eslint/utils': 6.21.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3) + '@typescript-eslint/type-utils': 6.21.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3) + '@typescript-eslint/utils': 6.21.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3) '@typescript-eslint/visitor-keys': 6.21.0 debug: 4.4.3 - eslint: 9.39.1(jiti@1.21.7) + eslint: 9.39.1(jiti@2.6.1) graphemer: 1.4.0 ignore: 5.3.2 natural-compare: 1.4.0 @@ -36960,15 +36322,15 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/eslint-plugin@7.18.0(@typescript-eslint/parser@7.18.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3))(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3)': + '@typescript-eslint/eslint-plugin@7.18.0(@typescript-eslint/parser@7.18.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3))(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 7.18.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3) + '@typescript-eslint/parser': 7.18.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3) '@typescript-eslint/scope-manager': 7.18.0 - '@typescript-eslint/type-utils': 7.18.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3) - '@typescript-eslint/utils': 7.18.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3) + '@typescript-eslint/type-utils': 7.18.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3) + '@typescript-eslint/utils': 7.18.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3) '@typescript-eslint/visitor-keys': 7.18.0 - eslint: 9.39.1(jiti@1.21.7) + eslint: 9.39.1(jiti@2.6.1) graphemer: 1.4.0 ignore: 5.3.2 natural-compare: 1.4.0 @@ -37060,14 +36422,14 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@6.21.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3)': + '@typescript-eslint/parser@6.21.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3)': dependencies: '@typescript-eslint/scope-manager': 6.21.0 '@typescript-eslint/types': 6.21.0 '@typescript-eslint/typescript-estree': 6.21.0(typescript@5.3.3) '@typescript-eslint/visitor-keys': 6.21.0 debug: 4.4.3 - eslint: 9.39.1(jiti@1.21.7) + eslint: 9.39.1(jiti@2.6.1) optionalDependencies: typescript: 5.3.3 transitivePeerDependencies: @@ -37099,14 +36461,14 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@7.18.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3)': + '@typescript-eslint/parser@7.18.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3)': dependencies: '@typescript-eslint/scope-manager': 7.18.0 '@typescript-eslint/types': 7.18.0 '@typescript-eslint/typescript-estree': 7.18.0(typescript@5.3.3) '@typescript-eslint/visitor-keys': 7.18.0 debug: 4.4.3 - eslint: 9.39.1(jiti@1.21.7) + eslint: 9.39.1(jiti@2.6.1) optionalDependencies: typescript: 5.3.3 transitivePeerDependencies: @@ -37232,12 +36594,12 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/type-utils@6.21.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3)': + '@typescript-eslint/type-utils@6.21.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3)': dependencies: '@typescript-eslint/typescript-estree': 6.21.0(typescript@5.3.3) - '@typescript-eslint/utils': 6.21.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3) + '@typescript-eslint/utils': 6.21.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3) debug: 4.4.3 - eslint: 9.39.1(jiti@1.21.7) + eslint: 9.39.1(jiti@2.6.1) ts-api-utils: 1.4.3(typescript@5.3.3) optionalDependencies: typescript: 5.3.3 @@ -37268,12 +36630,12 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/type-utils@7.18.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3)': + '@typescript-eslint/type-utils@7.18.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3)': dependencies: '@typescript-eslint/typescript-estree': 7.18.0(typescript@5.3.3) - '@typescript-eslint/utils': 7.18.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3) + '@typescript-eslint/utils': 7.18.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3) debug: 4.4.3 - eslint: 9.39.1(jiti@1.21.7) + eslint: 9.39.1(jiti@2.6.1) ts-api-utils: 1.4.3(typescript@5.3.3) optionalDependencies: typescript: 5.3.3 @@ -37455,15 +36817,15 @@ snapshots: - supports-color - typescript - '@typescript-eslint/utils@6.21.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3)': + '@typescript-eslint/utils@6.21.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3)': dependencies: - '@eslint-community/eslint-utils': 4.9.0(eslint@9.39.1(jiti@1.21.7)) + '@eslint-community/eslint-utils': 4.9.0(eslint@9.39.1(jiti@2.6.1)) '@types/json-schema': 7.0.15 '@types/semver': 7.7.1 '@typescript-eslint/scope-manager': 6.21.0 '@typescript-eslint/types': 6.21.0 '@typescript-eslint/typescript-estree': 6.21.0(typescript@5.3.3) - eslint: 9.39.1(jiti@1.21.7) + eslint: 9.39.1(jiti@2.6.1) semver: 7.7.3 transitivePeerDependencies: - supports-color @@ -37494,13 +36856,13 @@ snapshots: - supports-color - typescript - '@typescript-eslint/utils@7.18.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3)': + '@typescript-eslint/utils@7.18.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3)': dependencies: - '@eslint-community/eslint-utils': 4.9.0(eslint@9.39.1(jiti@1.21.7)) + '@eslint-community/eslint-utils': 4.9.0(eslint@9.39.1(jiti@2.6.1)) '@typescript-eslint/scope-manager': 7.18.0 '@typescript-eslint/types': 7.18.0 '@typescript-eslint/typescript-estree': 7.18.0(typescript@5.3.3) - eslint: 9.39.1(jiti@1.21.7) + eslint: 9.39.1(jiti@2.6.1) transitivePeerDependencies: - supports-color - typescript @@ -37795,11 +37157,11 @@ snapshots: - vite optional: true - '@vitest/browser@3.2.4(playwright@1.57.0)(vite@6.4.1(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(vitest@3.2.4)': + '@vitest/browser@3.2.4(playwright@1.57.0)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(vitest@3.2.4)': dependencies: '@testing-library/dom': 10.4.1 '@testing-library/user-event': 14.6.1(@testing-library/dom@10.4.1) - '@vitest/mocker': 3.2.4(vite@6.4.1(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) + '@vitest/mocker': 3.2.4(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) '@vitest/utils': 3.2.4 magic-string: 0.30.21 sirv: 3.0.2 @@ -37929,6 +37291,15 @@ snapshots: optionalDependencies: vite: 6.4.1(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3) + '@vitest/mocker@3.2.4(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))': + dependencies: + '@vitest/spy': 3.2.4 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3) + optional: true + '@vitest/mocker@4.0.14(vite@6.4.1(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))': dependencies: '@vitest/spy': 4.0.14 @@ -38111,7 +37482,7 @@ snapshots: sirv: 3.0.2 tinyglobby: 0.2.15 tinyrainbow: 2.0.0 - vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.10.1)(@vitest/browser@3.2.4)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@29.0.1(@noble/hashes@2.0.1))(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3) + vitest: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.1)(@vitest/browser@3.2.4)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@29.0.1(@noble/hashes@2.0.1))(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3) optional: true '@vitest/ui@4.0.14(vitest@4.0.14)': @@ -38555,7 +37926,7 @@ snapshots: graceful-fs: 4.2.11 is-stream: 2.0.1 lazystream: 1.0.1 - lodash: 4.17.23 + lodash: 4.17.21 normalize-path: 3.0.0 readable-stream: 4.7.0 @@ -38575,7 +37946,8 @@ snapshots: arg@4.1.0: {} - arg@4.1.3: {} + arg@4.1.3: + optional: true arg@5.0.2: {} @@ -38820,6 +38192,108 @@ snapshots: - terser - typescript + astro@5.16.0(@azure/storage-blob@12.31.0)(@netlify/blobs@10.7.4)(@types/node@20.19.25)(ioredis@5.9.2)(jiti@1.21.7)(lightningcss@1.30.2)(rollup@4.53.3)(terser@5.44.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3): + dependencies: + '@astrojs/compiler': 2.13.0 + '@astrojs/internal-helpers': 0.7.5 + '@astrojs/markdown-remark': 6.3.9 + '@astrojs/telemetry': 3.3.0 + '@capsizecss/unpack': 3.0.1 + '@oslojs/encoding': 1.1.0 + '@rollup/pluginutils': 5.3.0(rollup@4.53.3) + acorn: 8.15.0 + aria-query: 5.3.2 + axobject-query: 4.1.0 + boxen: 8.0.1 + ci-info: 4.3.1 + clsx: 2.1.1 + common-ancestor-path: 1.0.1 + cookie: 1.1.0 + cssesc: 3.0.0 + debug: 4.4.3 + deterministic-object-hash: 2.0.2 + devalue: 5.5.0 + diff: 5.2.0 + dlv: 1.1.3 + dset: 3.1.4 + es-module-lexer: 1.7.0 + esbuild: 0.25.12 + estree-walker: 3.0.3 + flattie: 1.1.1 + fontace: 0.3.1 + github-slugger: 2.0.0 + html-escaper: 3.0.3 + http-cache-semantics: 4.2.0 + import-meta-resolve: 4.2.0 + js-yaml: 4.1.1 + magic-string: 0.30.21 + magicast: 0.5.1 + mrmime: 2.0.1 + neotraverse: 0.6.18 + p-limit: 6.2.0 + p-queue: 8.1.1 + package-manager-detector: 1.5.0 + piccolore: 0.1.3 + picomatch: 4.0.3 + prompts: 2.4.2 + rehype: 13.0.2 + semver: 7.7.3 + shiki: 3.15.0 + smol-toml: 1.5.2 + svgo: 4.0.0 + tinyexec: 1.0.2 + tinyglobby: 0.2.15 + tsconfck: 3.1.6(typescript@5.9.3) + ultrahtml: 1.6.0 + unifont: 0.6.0 + unist-util-visit: 5.0.0 + unstorage: 1.17.3(@azure/storage-blob@12.31.0)(@netlify/blobs@10.7.4)(ioredis@5.9.2) + vfile: 6.0.3 + vite: 6.4.1(@types/node@20.19.25)(jiti@1.21.7)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3) + vitefu: 1.1.1(vite@6.4.1(@types/node@20.19.25)(jiti@1.21.7)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) + xxhash-wasm: 1.1.0 + yargs-parser: 21.1.1 + yocto-spinner: 0.2.3 + zod: 3.25.76 + zod-to-json-schema: 3.25.0(zod@3.25.76) + zod-to-ts: 1.2.0(typescript@5.9.3)(zod@3.25.76) + optionalDependencies: + sharp: 0.34.5 + transitivePeerDependencies: + - '@azure/app-configuration' + - '@azure/cosmos' + - '@azure/data-tables' + - '@azure/identity' + - '@azure/keyvault-secrets' + - '@azure/storage-blob' + - '@capacitor/preferences' + - '@deno/kv' + - '@netlify/blobs' + - '@planetscale/database' + - '@types/node' + - '@upstash/redis' + - '@vercel/blob' + - '@vercel/functions' + - '@vercel/kv' + - aws4fetch + - db0 + - idb-keyval + - ioredis + - jiti + - less + - lightningcss + - rollup + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - typescript + - uploadthing + - yaml + astro@5.16.0(@azure/storage-blob@12.31.0)(@netlify/blobs@10.7.4)(@types/node@20.19.25)(ioredis@5.9.2)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.53.3)(terser@5.44.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3): dependencies: '@astrojs/compiler': 2.13.0 @@ -39376,14 +38850,6 @@ snapshots: await-lock@2.2.2: optional: true - axios@1.14.0: - dependencies: - follow-redirects: 1.15.11 - form-data: 4.0.5 - proxy-from-env: 2.1.0 - transitivePeerDependencies: - - debug - axobject-query@4.1.0: {} b4a@1.8.0: {} @@ -39599,7 +39065,7 @@ snapshots: - '@babel/core' - supports-color - babel-preset-expo@55.0.10(@babel/core@7.28.5)(@babel/runtime@7.28.4)(expo@55.0.5)(react-refresh@0.14.2): + babel-preset-expo@55.0.10(@babel/core@7.28.5)(@babel/runtime@7.29.2)(expo@55.0.5)(react-refresh@0.14.2): dependencies: '@babel/generator': 7.28.5 '@babel/helper-module-imports': 7.27.1 @@ -39626,8 +39092,8 @@ snapshots: react-refresh: 0.14.2 resolve-from: 5.0.0 optionalDependencies: - '@babel/runtime': 7.28.4 - expo: 55.0.5(@babel/core@7.28.5)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@55.0.7)(expo-router@55.0.5)(react-dom@19.2.0(react@19.2.0))(react-native-webview@13.12.2(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)(typescript@5.3.3) + '@babel/runtime': 7.29.2 + expo: 55.0.5(@babel/core@7.28.5)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@55.0.7)(expo-router@55.0.5)(react-dom@19.2.0(react@19.2.0))(react-native-webview@13.12.2(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) transitivePeerDependencies: - '@babel/core' - supports-color @@ -39903,10 +39369,6 @@ snapshots: node-releases: 2.0.27 update-browserslist-db: 1.1.4(browserslist@4.28.0) - bs-logger@0.2.6: - dependencies: - fast-json-stable-stringify: 2.1.0 - bs58@6.0.0: dependencies: base-x: 5.0.1 @@ -40336,8 +39798,6 @@ snapshots: commondir@1.0.1: {} - component-emitter@1.3.1: {} - compress-commons@6.0.2: dependencies: crc-32: 1.2.2 @@ -40364,13 +39824,6 @@ snapshots: concat-map@0.0.1: {} - concat-stream@1.6.2: - dependencies: - buffer-from: 1.1.2 - inherits: 2.0.4 - readable-stream: 2.3.8 - typedarray: 0.0.6 - concat-stream@2.0.0: dependencies: buffer-from: 1.1.2 @@ -40426,8 +39879,6 @@ snapshots: cookie@1.1.1: {} - cookiejar@2.1.4: {} - copy-file@11.1.0: dependencies: graceful-fs: 4.2.11 @@ -40487,13 +39938,13 @@ snapshots: crc-32: 1.2.2 readable-stream: 4.7.0 - create-jest@29.7.0(@types/node@20.19.25)(ts-node@10.9.2(@types/node@20.19.25)(typescript@5.9.3)): + create-jest@29.7.0(@types/node@24.10.1)(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3)): dependencies: '@jest/types': 29.6.3 chalk: 4.1.2 exit: 0.1.2 graceful-fs: 4.2.11 - jest-config: 29.7.0(@types/node@20.19.25)(ts-node@10.9.2(@types/node@20.19.25)(typescript@5.9.3)) + jest-config: 29.7.0(@types/node@24.10.1)(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3)) jest-util: 29.7.0 prompts: 2.4.2 transitivePeerDependencies: @@ -40502,22 +39953,8 @@ snapshots: - supports-color - ts-node - create-jest@29.7.0(@types/node@24.10.1): - dependencies: - '@jest/types': 29.6.3 - chalk: 4.1.2 - exit: 0.1.2 - graceful-fs: 4.2.11 - jest-config: 29.7.0(@types/node@24.10.1) - jest-util: 29.7.0 - prompts: 2.4.2 - transitivePeerDependencies: - - '@types/node' - - babel-plugin-macros - - supports-color - - ts-node - - create-require@1.1.1: {} + create-require@1.1.1: + optional: true cron-parser@4.9.0: dependencies: @@ -40965,18 +40402,14 @@ snapshots: dexie@4.4.1: {} - dezalgo@1.0.4: - dependencies: - asap: 2.0.6 - wrappy: 1.0.2 - dfa@1.2.0: {} didyoumean@1.2.2: {} diff-sequences@29.6.3: {} - diff@4.0.2: {} + diff@4.0.2: + optional: true diff@5.2.0: {} @@ -41723,6 +41156,11 @@ snapshots: eslint: 9.39.1(jiti@2.6.1) semver: 7.7.3 + eslint-compat-utils@0.6.5(eslint@9.39.1(jiti@1.21.7)): + dependencies: + eslint: 9.39.1(jiti@1.21.7) + semver: 7.7.3 + eslint-compat-utils@0.6.5(eslint@9.39.1(jiti@2.6.1)): dependencies: eslint: 9.39.1(jiti@2.6.1) @@ -41752,7 +41190,7 @@ snapshots: eslint: 9.39.1(jiti@2.6.1) eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.1(jiti@2.6.1)) eslint-plugin-expo: 1.0.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@2.6.1)) eslint-plugin-react: 7.37.5(eslint@9.39.1(jiti@2.6.1)) eslint-plugin-react-hooks: 5.2.0(eslint@9.39.1(jiti@2.6.1)) globals: 16.5.0 @@ -41767,9 +41205,9 @@ snapshots: '@typescript-eslint/eslint-plugin': 8.48.1(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.8.3))(eslint@9.39.1(jiti@2.6.1))(typescript@5.8.3) '@typescript-eslint/parser': 8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.8.3) eslint: 9.39.1(jiti@2.6.1) - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.1(jiti@2.6.1)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.8.3))(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1)) eslint-plugin-expo: 0.1.4(eslint@9.39.1(jiti@2.6.1))(typescript@5.8.3) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.8.3))(eslint@9.39.1(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.8.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@2.6.1)) eslint-plugin-react: 7.37.5(eslint@9.39.1(jiti@2.6.1)) eslint-plugin-react-hooks: 5.2.0(eslint@9.39.1(jiti@2.6.1)) globals: 16.5.0 @@ -41787,14 +41225,14 @@ snapshots: dependencies: eslint: 8.57.1 - eslint-config-prettier@8.10.2(eslint@9.39.1(jiti@1.21.7)): - dependencies: - eslint: 9.39.1(jiti@1.21.7) - eslint-config-prettier@8.10.2(eslint@9.39.1(jiti@2.6.1)): dependencies: eslint: 9.39.1(jiti@2.6.1) + eslint-config-prettier@9.1.2(eslint@9.39.1(jiti@1.21.7)): + dependencies: + eslint: 9.39.1(jiti@1.21.7) + eslint-config-prettier@9.1.2(eslint@9.39.1(jiti@2.6.1)): dependencies: eslint: 9.39.1(jiti@2.6.1) @@ -41819,17 +41257,17 @@ snapshots: - supports-color - typescript - eslint-config-universe@12.1.0(@types/eslint@9.6.1)(eslint@9.39.1(jiti@1.21.7))(prettier@3.6.2)(typescript@5.3.3): + eslint-config-universe@12.1.0(@types/eslint@9.6.1)(eslint@9.39.1(jiti@2.6.1))(prettier@3.6.2)(typescript@5.3.3): dependencies: - '@typescript-eslint/eslint-plugin': 6.21.0(@typescript-eslint/parser@6.21.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3))(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3) - '@typescript-eslint/parser': 6.21.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3) - eslint: 9.39.1(jiti@1.21.7) - eslint-config-prettier: 8.10.2(eslint@9.39.1(jiti@1.21.7)) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@6.21.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3))(eslint@9.39.1(jiti@1.21.7)) - eslint-plugin-node: 11.1.0(eslint@9.39.1(jiti@1.21.7)) - eslint-plugin-prettier: 5.5.4(@types/eslint@9.6.1)(eslint-config-prettier@8.10.2(eslint@9.39.1(jiti@1.21.7)))(eslint@9.39.1(jiti@1.21.7))(prettier@3.6.2) - eslint-plugin-react: 7.37.5(eslint@9.39.1(jiti@1.21.7)) - eslint-plugin-react-hooks: 4.6.2(eslint@9.39.1(jiti@1.21.7)) + '@typescript-eslint/eslint-plugin': 6.21.0(@typescript-eslint/parser@6.21.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3))(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3) + '@typescript-eslint/parser': 6.21.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3) + eslint: 9.39.1(jiti@2.6.1) + eslint-config-prettier: 8.10.2(eslint@9.39.1(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@6.21.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3))(eslint@9.39.1(jiti@2.6.1)) + eslint-plugin-node: 11.1.0(eslint@9.39.1(jiti@2.6.1)) + eslint-plugin-prettier: 5.5.4(@types/eslint@9.6.1)(eslint-config-prettier@8.10.2(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1))(prettier@3.6.2) + eslint-plugin-react: 7.37.5(eslint@9.39.1(jiti@2.6.1)) + eslint-plugin-react-hooks: 4.6.2(eslint@9.39.1(jiti@2.6.1)) optionalDependencies: prettier: 3.6.2 transitivePeerDependencies: @@ -41911,6 +41349,21 @@ snapshots: transitivePeerDependencies: - supports-color + eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.8.3))(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1)): + dependencies: + '@nolyfill/is-core-module': 1.0.39 + debug: 4.4.3 + eslint: 9.39.1(jiti@2.6.1) + get-tsconfig: 4.13.0 + is-bun-module: 2.0.0 + stable-hash: 0.0.5 + tinyglobby: 0.2.15 + unrs-resolver: 1.11.1 + optionalDependencies: + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.8.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@2.6.1)) + transitivePeerDependencies: + - supports-color + eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.1(jiti@2.6.1)): dependencies: '@nolyfill/is-core-module': 1.0.39 @@ -41936,12 +41389,12 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.1(@typescript-eslint/parser@6.21.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.1(jiti@1.21.7)): + eslint-module-utils@2.12.1(@typescript-eslint/parser@6.21.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.1(jiti@2.6.1)): dependencies: debug: 3.2.7 optionalDependencies: - '@typescript-eslint/parser': 6.21.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3) - eslint: 9.39.1(jiti@1.21.7) + '@typescript-eslint/parser': 6.21.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3) + eslint: 9.39.1(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 transitivePeerDependencies: - supports-color @@ -41956,14 +41409,14 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.1(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@2.6.1)): + eslint-module-utils@2.12.1(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.8.3))(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1)): dependencies: debug: 3.2.7 optionalDependencies: '@typescript-eslint/parser': 8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.8.3) eslint: 9.39.1(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.1(jiti@2.6.1)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.8.3))(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1)) transitivePeerDependencies: - supports-color @@ -41978,6 +41431,20 @@ snapshots: transitivePeerDependencies: - supports-color + eslint-plugin-astro@1.5.0(eslint@9.39.1(jiti@1.21.7)): + dependencies: + '@eslint-community/eslint-utils': 4.9.0(eslint@9.39.1(jiti@1.21.7)) + '@jridgewell/sourcemap-codec': 1.5.5 + '@typescript-eslint/types': 8.48.0 + astro-eslint-parser: 1.2.2 + eslint: 9.39.1(jiti@1.21.7) + eslint-compat-utils: 0.6.5(eslint@9.39.1(jiti@1.21.7)) + globals: 16.5.0 + postcss: 8.5.6 + postcss-selector-parser: 7.1.0 + transitivePeerDependencies: + - supports-color + eslint-plugin-astro@1.5.0(eslint@9.39.1(jiti@2.6.1)): dependencies: '@eslint-community/eslint-utils': 4.9.0(eslint@9.39.1(jiti@2.6.1)) @@ -42005,12 +41472,6 @@ snapshots: eslint-utils: 2.1.0 regexpp: 3.2.0 - eslint-plugin-es@3.0.1(eslint@9.39.1(jiti@1.21.7)): - dependencies: - eslint: 9.39.1(jiti@1.21.7) - eslint-utils: 2.1.0 - regexpp: 3.2.0 - eslint-plugin-es@3.0.1(eslint@9.39.1(jiti@2.6.1)): dependencies: eslint: 9.39.1(jiti@2.6.1) @@ -42064,7 +41525,7 @@ snapshots: - eslint-import-resolver-webpack - supports-color - eslint-plugin-import@2.32.0(@typescript-eslint/parser@6.21.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3))(eslint@9.39.1(jiti@1.21.7)): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@6.21.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3))(eslint@9.39.1(jiti@2.6.1)): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -42073,9 +41534,9 @@ snapshots: array.prototype.flatmap: 1.3.3 debug: 3.2.7 doctrine: 2.1.0 - eslint: 9.39.1(jiti@1.21.7) + eslint: 9.39.1(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@6.21.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.1(jiti@1.21.7)) + eslint-module-utils: 2.12.1(@typescript-eslint/parser@6.21.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.1(jiti@2.6.1)) hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3 @@ -42087,7 +41548,7 @@ snapshots: string.prototype.trimend: 1.0.9 tsconfig-paths: 3.15.0 optionalDependencies: - '@typescript-eslint/parser': 6.21.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3) + '@typescript-eslint/parser': 6.21.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3) transitivePeerDependencies: - eslint-import-resolver-typescript - eslint-import-resolver-webpack @@ -42122,7 +41583,7 @@ snapshots: - eslint-import-resolver-webpack - supports-color - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.8.3))(eslint@9.39.1(jiti@2.6.1)): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.8.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@2.6.1)): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -42133,7 +41594,7 @@ snapshots: doctrine: 2.1.0 eslint: 9.39.1(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@2.6.1)) + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.8.3))(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1)) hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3 @@ -42151,6 +41612,35 @@ snapshots: - eslint-import-resolver-webpack - supports-color + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@2.6.1)): + dependencies: + '@rtsao/scc': 1.1.0 + array-includes: 3.1.9 + array.prototype.findlastindex: 1.2.6 + array.prototype.flat: 1.3.3 + array.prototype.flatmap: 1.3.3 + debug: 3.2.7 + doctrine: 2.1.0 + eslint: 9.39.1(jiti@2.6.1) + eslint-import-resolver-node: 0.3.9 + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@2.6.1)) + hasown: 2.0.2 + is-core-module: 2.16.1 + is-glob: 4.0.3 + minimatch: 3.1.2 + object.fromentries: 2.0.8 + object.groupby: 1.0.3 + object.values: 1.2.1 + semver: 6.3.1 + string.prototype.trimend: 1.0.9 + tsconfig-paths: 3.15.0 + optionalDependencies: + '@typescript-eslint/parser': 8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) + transitivePeerDependencies: + - eslint-import-resolver-typescript + - eslint-import-resolver-webpack + - supports-color + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1)): dependencies: '@rtsao/scc': 1.1.0 @@ -42205,16 +41695,6 @@ snapshots: resolve: 1.22.11 semver: 6.3.1 - eslint-plugin-node@11.1.0(eslint@9.39.1(jiti@1.21.7)): - dependencies: - eslint: 9.39.1(jiti@1.21.7) - eslint-plugin-es: 3.0.1(eslint@9.39.1(jiti@1.21.7)) - eslint-utils: 2.1.0 - ignore: 5.3.2 - minimatch: 3.1.2 - resolve: 1.22.11 - semver: 6.3.1 - eslint-plugin-node@11.1.0(eslint@9.39.1(jiti@2.6.1)): dependencies: eslint: 9.39.1(jiti@2.6.1) @@ -42245,16 +41725,6 @@ snapshots: '@types/eslint': 9.6.1 eslint-config-prettier: 8.10.2(eslint@8.57.1) - eslint-plugin-prettier@5.5.4(@types/eslint@9.6.1)(eslint-config-prettier@8.10.2(eslint@9.39.1(jiti@1.21.7)))(eslint@9.39.1(jiti@1.21.7))(prettier@3.6.2): - dependencies: - eslint: 9.39.1(jiti@1.21.7) - prettier: 3.6.2 - prettier-linter-helpers: 1.0.0 - synckit: 0.11.11 - optionalDependencies: - '@types/eslint': 9.6.1 - eslint-config-prettier: 8.10.2(eslint@9.39.1(jiti@1.21.7)) - eslint-plugin-prettier@5.5.4(@types/eslint@9.6.1)(eslint-config-prettier@8.10.2(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1))(prettier@3.6.2): dependencies: eslint: 9.39.1(jiti@2.6.1) @@ -42279,10 +41749,6 @@ snapshots: dependencies: eslint: 8.57.1 - eslint-plugin-react-hooks@4.6.2(eslint@9.39.1(jiti@1.21.7)): - dependencies: - eslint: 9.39.1(jiti@1.21.7) - eslint-plugin-react-hooks@4.6.2(eslint@9.39.1(jiti@2.6.1)): dependencies: eslint: 9.39.1(jiti@2.6.1) @@ -42313,28 +41779,6 @@ snapshots: string.prototype.matchall: 4.0.12 string.prototype.repeat: 1.0.0 - eslint-plugin-react@7.37.5(eslint@9.39.1(jiti@1.21.7)): - dependencies: - array-includes: 3.1.9 - array.prototype.findlast: 1.2.5 - array.prototype.flatmap: 1.3.3 - array.prototype.tosorted: 1.1.4 - doctrine: 2.1.0 - es-iterator-helpers: 1.2.1 - eslint: 9.39.1(jiti@1.21.7) - estraverse: 5.3.0 - hasown: 2.0.2 - jsx-ast-utils: 3.3.5 - minimatch: 3.1.2 - object.entries: 1.1.9 - object.fromentries: 2.0.8 - object.values: 1.2.1 - prop-types: 15.8.1 - resolve: 2.0.0-next.5 - semver: 6.3.1 - string.prototype.matchall: 4.0.12 - string.prototype.repeat: 1.0.0 - eslint-plugin-react@7.37.5(eslint@9.39.1(jiti@2.6.1)): dependencies: array-includes: 3.1.9 @@ -42891,7 +42335,7 @@ snapshots: expo-constants@55.0.7(expo@54.0.25)(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.1.0))(typescript@5.9.3): dependencies: - '@expo/config': 55.0.8(typescript@5.9.3) + '@expo/config': 55.0.11(typescript@5.9.3) '@expo/env': 2.1.1 expo: 54.0.25(@babel/core@7.28.5)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@55.0.7)(expo-router@55.0.5)(react-native-webview@13.12.2(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.1.0))(react@19.1.0))(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.1.0))(react@19.1.0) react-native: 0.81.4(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.1.0) @@ -42902,7 +42346,7 @@ snapshots: expo-constants@55.0.7(expo@55.0.5)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(typescript@5.3.3): dependencies: - '@expo/config': 55.0.8(typescript@5.3.3) + '@expo/config': 55.0.11(typescript@5.3.3) '@expo/env': 2.1.1 expo: 55.0.5(@babel/core@7.28.5)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@55.0.7)(expo-router@55.0.5)(react-dom@19.2.0(react@19.2.0))(react-native-webview@13.12.2(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)(typescript@5.3.3) react-native: 0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0) @@ -42912,7 +42356,7 @@ snapshots: expo-constants@55.0.7(expo@55.0.5)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(typescript@5.8.3): dependencies: - '@expo/config': 55.0.8(typescript@5.8.3) + '@expo/config': 55.0.11(typescript@5.8.3) '@expo/env': 2.1.1 expo: 55.0.5(@babel/core@7.28.5)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@55.0.7)(expo-router@55.0.5)(react-dom@19.2.0(react@19.2.0))(react-native-webview@13.12.2(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)(typescript@5.8.3) react-native: 0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0) @@ -42922,7 +42366,7 @@ snapshots: expo-constants@55.0.7(expo@55.0.5)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(typescript@5.9.3): dependencies: - '@expo/config': 55.0.8(typescript@5.9.3) + '@expo/config': 55.0.11(typescript@5.9.3) '@expo/env': 2.1.1 expo: 55.0.5(@babel/core@7.28.5)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@55.0.7)(expo-router@55.0.5)(react-dom@19.2.0(react@19.2.0))(react-native-webview@13.12.2(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) react-native: 0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0) @@ -42932,7 +42376,7 @@ snapshots: expo-constants@55.0.7(expo@55.0.5)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.0))(typescript@5.9.3): dependencies: - '@expo/config': 55.0.8(typescript@5.9.3) + '@expo/config': 55.0.11(typescript@5.9.3) '@expo/env': 2.1.1 expo: 55.0.5(@babel/core@7.28.5)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@55.0.7)(expo-router@55.0.5)(react-dom@19.2.0(react@19.2.0))(react-native-webview@13.12.2(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) react-native: 0.83.2(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.0) @@ -42942,7 +42386,7 @@ snapshots: expo-constants@55.0.7(expo@55.0.5)(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(typescript@5.9.3): dependencies: - '@expo/config': 55.0.8(typescript@5.9.3) + '@expo/config': 55.0.11(typescript@5.9.3) '@expo/env': 2.1.1 expo: 55.0.5(@babel/core@7.28.5)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@55.0.7)(expo-router@55.0.5)(react-dom@19.2.4(react@19.2.4))(react-native-webview@13.12.2(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4)(typescript@5.9.3) react-native: 0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4) @@ -42976,7 +42420,7 @@ snapshots: expo-dev-client@6.0.18(expo@55.0.5): dependencies: - expo: 55.0.5(@babel/core@7.28.5)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@55.0.7)(expo-router@55.0.5)(react-dom@19.2.0(react@19.2.0))(react-native-webview@13.12.2(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)(typescript@5.3.3) + expo: 55.0.5(@babel/core@7.28.5)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@55.0.7)(expo-router@55.0.5)(react-dom@19.2.0(react@19.2.0))(react-native-webview@13.12.2(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) expo-dev-launcher: 6.0.18(expo@55.0.5) expo-dev-menu: 7.0.17(expo@55.0.5) expo-dev-menu-interface: 2.0.0(expo@55.0.5) @@ -43017,7 +42461,7 @@ snapshots: expo-dev-launcher@6.0.18(expo@55.0.5): dependencies: - expo: 55.0.5(@babel/core@7.28.5)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@55.0.7)(expo-router@55.0.5)(react-dom@19.2.0(react@19.2.0))(react-native-webview@13.12.2(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)(typescript@5.3.3) + expo: 55.0.5(@babel/core@7.28.5)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@55.0.7)(expo-router@55.0.5)(react-dom@19.2.0(react@19.2.0))(react-native-webview@13.12.2(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) expo-dev-menu: 7.0.17(expo@55.0.5) expo-manifests: 1.0.9(expo@55.0.5) transitivePeerDependencies: @@ -43029,7 +42473,7 @@ snapshots: expo-dev-menu-interface@2.0.0(expo@55.0.5): dependencies: - expo: 55.0.5(@babel/core@7.28.5)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@55.0.7)(expo-router@55.0.5)(react-dom@19.2.0(react@19.2.0))(react-native-webview@13.12.2(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)(typescript@5.3.3) + expo: 55.0.5(@babel/core@7.28.5)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@55.0.7)(expo-router@55.0.5)(react-dom@19.2.0(react@19.2.0))(react-native-webview@13.12.2(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) expo-dev-menu-interface@55.0.1(expo@55.0.5): dependencies: @@ -43052,7 +42496,7 @@ snapshots: expo-dev-menu@7.0.17(expo@55.0.5): dependencies: - expo: 55.0.5(@babel/core@7.28.5)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@55.0.7)(expo-router@55.0.5)(react-dom@19.2.0(react@19.2.0))(react-native-webview@13.12.2(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)(typescript@5.3.3) + expo: 55.0.5(@babel/core@7.28.5)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@55.0.7)(expo-router@55.0.5)(react-dom@19.2.0(react@19.2.0))(react-native-webview@13.12.2(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) expo-dev-menu-interface: 2.0.0(expo@55.0.5) expo-device@55.0.10(expo@55.0.5): @@ -43083,7 +42527,7 @@ snapshots: expo-file-system@55.0.10(expo@55.0.5)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0)): dependencies: - expo: 55.0.5(@babel/core@7.28.5)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@55.0.7)(expo-router@55.0.5)(react-dom@19.2.0(react@19.2.0))(react-native-webview@13.12.2(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)(typescript@5.3.3) + expo: 55.0.5(@babel/core@7.28.5)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@55.0.7)(expo-router@55.0.5)(react-dom@19.2.0(react@19.2.0))(react-native-webview@13.12.2(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) react-native: 0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0) expo-file-system@55.0.10(expo@55.0.5)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.0)): @@ -43111,13 +42555,6 @@ snapshots: react-native: 0.81.4(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.1.0) expo-font@14.0.10(expo@55.0.5)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0): - dependencies: - expo: 55.0.5(@babel/core@7.28.5)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@55.0.7)(expo-router@55.0.5)(react-dom@19.2.0(react@19.2.0))(react-native-webview@13.12.2(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)(typescript@5.3.3) - fontfaceobserver: 2.3.0 - react: 19.2.0 - react-native: 0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0) - - expo-font@14.0.9(expo@55.0.5)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0): dependencies: expo: 55.0.5(@babel/core@7.28.5)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@55.0.7)(expo-router@55.0.5)(react-dom@19.2.0(react@19.2.0))(react-native-webview@13.12.2(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) fontfaceobserver: 2.3.0 @@ -43126,7 +42563,7 @@ snapshots: expo-font@55.0.4(expo@55.0.5)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0): dependencies: - expo: 55.0.5(@babel/core@7.28.5)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@55.0.7)(expo-router@55.0.5)(react-dom@19.2.0(react@19.2.0))(react-native-webview@13.12.2(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)(typescript@5.3.3) + expo: 55.0.5(@babel/core@7.28.5)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@55.0.7)(expo-router@55.0.5)(react-dom@19.2.0(react@19.2.0))(react-native-webview@13.12.2(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) fontfaceobserver: 2.3.0 react: 19.2.0 react-native: 0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0) @@ -43155,7 +42592,7 @@ snapshots: expo-glass-effect@55.0.8(expo@55.0.5)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0): dependencies: - expo: 55.0.5(@babel/core@7.28.5)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@55.0.7)(expo-router@55.0.5)(react-dom@19.2.0(react@19.2.0))(react-native-webview@13.12.2(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)(typescript@5.3.3) + expo: 55.0.5(@babel/core@7.28.5)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@55.0.7)(expo-router@55.0.5)(react-dom@19.2.0(react@19.2.0))(react-native-webview@13.12.2(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) react: 19.2.0 react-native: 0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0) @@ -43178,11 +42615,11 @@ snapshots: expo-image-loader@55.0.0(expo@55.0.5): dependencies: - expo: 55.0.5(@babel/core@7.28.5)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@55.0.7)(expo-router@55.0.5)(react-dom@19.2.0(react@19.2.0))(react-native-webview@13.12.2(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) + expo: 55.0.5(@babel/core@7.28.5)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@55.0.7)(expo-router@55.0.5)(react-dom@19.2.0(react@19.2.0))(react-native-webview@13.12.2(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) expo-image-picker@55.0.12(expo@55.0.5): dependencies: - expo: 55.0.5(@babel/core@7.28.5)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@55.0.7)(expo-router@55.0.5)(react-dom@19.2.0(react@19.2.0))(react-native-webview@13.12.2(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) + expo: 55.0.5(@babel/core@7.28.5)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@55.0.7)(expo-router@55.0.5)(react-dom@19.2.0(react@19.2.0))(react-native-webview@13.12.2(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) expo-image-loader: 55.0.0(expo@55.0.5) expo-image@55.0.6(expo@54.0.25)(react-native-web@0.21.2(encoding@0.1.13)(react-dom@19.2.4(react@19.1.0))(react@19.1.0))(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.1.0))(react@19.1.0): @@ -43197,7 +42634,7 @@ snapshots: expo-image@55.0.6(expo@55.0.5)(react-native-web@0.21.2(encoding@0.1.13)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0): dependencies: - expo: 55.0.5(@babel/core@7.28.5)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@55.0.7)(expo-router@55.0.5)(react-dom@19.2.0(react@19.2.0))(react-native-webview@13.12.2(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)(typescript@5.3.3) + expo: 55.0.5(@babel/core@7.28.5)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@55.0.7)(expo-router@55.0.5)(react-dom@19.2.0(react@19.2.0))(react-native-webview@13.12.2(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) react: 19.2.0 react-native: 0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0) sf-symbols-typescript: 2.2.0 @@ -43239,7 +42676,7 @@ snapshots: expo-keep-awake@55.0.4(expo@55.0.5)(react@19.2.0): dependencies: - expo: 55.0.5(@babel/core@7.28.5)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@55.0.7)(expo-router@55.0.5)(react-dom@19.2.0(react@19.2.0))(react-native-webview@13.12.2(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)(typescript@5.3.3) + expo: 55.0.5(@babel/core@7.28.5)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@55.0.7)(expo-router@55.0.5)(react-dom@19.2.0(react@19.2.0))(react-native-webview@13.12.2(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) react: 19.2.0 expo-keep-awake@55.0.4(expo@55.0.5)(react@19.2.4): @@ -43371,7 +42808,7 @@ snapshots: expo-manifests@1.0.9(expo@55.0.5): dependencies: '@expo/config': 12.0.10 - expo: 55.0.5(@babel/core@7.28.5)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@55.0.7)(expo-router@55.0.5)(react-dom@19.2.0(react@19.2.0))(react-native-webview@13.12.2(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)(typescript@5.3.3) + expo: 55.0.5(@babel/core@7.28.5)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@55.0.7)(expo-router@55.0.5)(react-dom@19.2.0(react@19.2.0))(react-native-webview@13.12.2(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) expo-json-utils: 0.15.0 transitivePeerDependencies: - supports-color @@ -43415,7 +42852,7 @@ snapshots: expo-modules-autolinking@55.0.8(typescript@5.3.3): dependencies: - '@expo/require-utils': 55.0.2(typescript@5.3.3) + '@expo/require-utils': 55.0.3(typescript@5.3.3) '@expo/spawn-async': 1.7.2 chalk: 4.1.2 commander: 7.2.0 @@ -43425,7 +42862,7 @@ snapshots: expo-modules-autolinking@55.0.8(typescript@5.8.3): dependencies: - '@expo/require-utils': 55.0.2(typescript@5.8.3) + '@expo/require-utils': 55.0.3(typescript@5.8.3) '@expo/spawn-async': 1.7.2 chalk: 4.1.2 commander: 7.2.0 @@ -43435,7 +42872,7 @@ snapshots: expo-modules-autolinking@55.0.8(typescript@5.9.3): dependencies: - '@expo/require-utils': 55.0.2(typescript@5.9.3) + '@expo/require-utils': 55.0.3(typescript@5.9.3) '@expo/spawn-async': 1.7.2 chalk: 4.1.2 commander: 7.2.0 @@ -43517,6 +42954,106 @@ snapshots: - react-native - supports-color + expo-router@55.0.5(6d2zdlrrz5o6rsdio5yhgp3juy): + dependencies: + '@expo/log-box': 55.0.8(@expo/dom-webview@55.0.3)(expo@55.0.5)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0) + '@expo/metro-runtime': 55.0.7(@expo/dom-webview@55.0.3)(expo@55.0.5)(react-dom@19.2.0(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0) + '@expo/schema-utils': 55.0.2 + '@radix-ui/react-slot': 1.2.3(@types/react@19.1.17)(react@19.2.0) + '@radix-ui/react-tabs': 1.1.13(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@react-navigation/bottom-tabs': 7.15.5(@react-navigation/native@7.1.33(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native-safe-area-context@5.6.2(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native-screens@4.16.0(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0) + '@react-navigation/native': 7.1.33(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0) + '@react-navigation/native-stack': 7.14.4(@react-navigation/native@7.1.33(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native-safe-area-context@5.6.2(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native-screens@4.16.0(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0) + client-only: 0.0.1 + debug: 4.4.3 + escape-string-regexp: 4.0.0 + expo: 55.0.5(@babel/core@7.28.5)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@55.0.7)(expo-router@55.0.5)(react-dom@19.2.0(react@19.2.0))(react-native-webview@13.12.2(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) + expo-constants: 55.0.7(expo@55.0.5)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(typescript@5.9.3) + expo-glass-effect: 55.0.8(expo@55.0.5)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0) + expo-image: 55.0.6(expo@55.0.5)(react-native-web@0.21.2(encoding@0.1.13)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0) + expo-linking: 55.0.7(expo@55.0.5)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) + expo-server: 55.0.6 + expo-symbols: 55.0.5(expo-font@14.0.10)(expo@55.0.5)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0) + fast-deep-equal: 3.1.3 + invariant: 2.2.4 + nanoid: 3.3.11 + query-string: 7.1.3 + react: 19.2.0 + react-fast-compare: 3.2.2 + react-native: 0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0) + react-native-is-edge-to-edge: 1.2.1(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0) + react-native-safe-area-context: 5.6.2(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0) + react-native-screens: 4.16.0(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0) + semver: 7.6.3 + server-only: 0.0.1 + sf-symbols-typescript: 2.2.0 + shallowequal: 1.1.0 + use-latest-callback: 0.2.6(react@19.2.0) + vaul: 1.1.2(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + optionalDependencies: + '@react-navigation/drawer': 7.7.4(@react-navigation/native@7.1.33(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native-gesture-handler@2.28.0(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native-reanimated@4.1.5(@babel/core@7.28.5)(react-native-worklets@0.5.1(@babel/core@7.28.5)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native-safe-area-context@5.6.2(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native-screens@4.16.0(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0) + '@testing-library/react-native': 13.3.3(jest@30.3.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.27.4))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react-test-renderer@19.1.0(react@19.2.0))(react@19.2.0) + react-dom: 19.2.0(react@19.2.0) + react-native-gesture-handler: 2.28.0(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0) + react-native-reanimated: 4.1.5(@babel/core@7.28.5)(react-native-worklets@0.5.1(@babel/core@7.28.5)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0) + react-native-web: 0.21.2(encoding@0.1.13)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + transitivePeerDependencies: + - '@react-native-masked-view/masked-view' + - '@types/react' + - '@types/react-dom' + - expo-font + - supports-color + + expo-router@55.0.5(bhhyukj6njqvlfm2o3z6hpwcja): + dependencies: + '@expo/log-box': 55.0.8(@expo/dom-webview@55.0.3)(expo@55.0.5)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0) + '@expo/metro-runtime': 55.0.7(@expo/dom-webview@55.0.3)(expo@55.0.5)(react-dom@19.2.0(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0) + '@expo/schema-utils': 55.0.2 + '@radix-ui/react-slot': 1.2.3(@types/react@19.1.17)(react@19.2.0) + '@radix-ui/react-tabs': 1.1.13(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@react-navigation/bottom-tabs': 7.15.5(@react-navigation/native@7.1.33(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native-safe-area-context@5.6.2(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native-screens@4.16.0(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0) + '@react-navigation/native': 7.1.33(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0) + '@react-navigation/native-stack': 7.14.4(@react-navigation/native@7.1.33(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native-safe-area-context@5.6.2(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native-screens@4.16.0(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0) + client-only: 0.0.1 + debug: 4.4.3 + escape-string-regexp: 4.0.0 + expo: 55.0.5(@babel/core@7.28.5)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@55.0.7)(expo-router@55.0.5)(react-dom@19.2.0(react@19.2.0))(react-native-webview@13.12.2(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)(typescript@5.3.3) + expo-constants: 55.0.7(expo@55.0.5)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(typescript@5.3.3) + expo-glass-effect: 55.0.8(expo@55.0.5)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0) + expo-image: 55.0.6(expo@55.0.5)(react-native-web@0.21.2(encoding@0.1.13)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0) + expo-linking: 55.0.7(expo@55.0.5)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)(typescript@5.3.3) + expo-server: 55.0.6 + expo-symbols: 55.0.5(expo-font@55.0.4)(expo@55.0.5)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0) + fast-deep-equal: 3.1.3 + invariant: 2.2.4 + nanoid: 3.3.11 + query-string: 7.1.3 + react: 19.2.0 + react-fast-compare: 3.2.2 + react-native: 0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0) + react-native-is-edge-to-edge: 1.2.1(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0) + react-native-safe-area-context: 5.6.2(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0) + react-native-screens: 4.16.0(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0) + semver: 7.6.3 + server-only: 0.0.1 + sf-symbols-typescript: 2.2.0 + shallowequal: 1.1.0 + use-latest-callback: 0.2.6(react@19.2.0) + vaul: 1.1.2(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + optionalDependencies: + '@react-navigation/drawer': 7.7.4(@react-navigation/native@7.1.21(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native-gesture-handler@2.28.0(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native-reanimated@4.1.5(@babel/core@7.28.5)(react-native-worklets@0.7.4(@babel/core@7.28.5)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native-safe-area-context@5.6.2(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native-screens@4.16.0(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0) + '@testing-library/react-native': 13.3.3(jest@30.3.0(@types/node@24.10.1)(esbuild-register@3.6.0(esbuild@0.27.4))(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.3.3)))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react-test-renderer@19.1.0(react@19.2.0))(react@19.2.0) + react-dom: 19.2.0(react@19.2.0) + react-native-gesture-handler: 2.28.0(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0) + react-native-reanimated: 4.1.5(@babel/core@7.28.5)(react-native-worklets@0.7.4(@babel/core@7.28.5)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0) + react-native-web: 0.21.2(encoding@0.1.13)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + transitivePeerDependencies: + - '@react-native-masked-view/masked-view' + - '@types/react' + - '@types/react-dom' + - expo-font + - supports-color + expo-router@55.0.5(cdndhew7mqhhoslu6uoygxcgvm): dependencies: '@expo/log-box': 55.0.8(@expo/dom-webview@55.0.3)(expo@55.0.5)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0) @@ -43869,109 +43406,9 @@ snapshots: - expo-font - supports-color - expo-router@55.0.5(shihlejigi2nkza7wltmngtxfm): - dependencies: - '@expo/log-box': 55.0.8(@expo/dom-webview@55.0.3)(expo@55.0.5)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0) - '@expo/metro-runtime': 55.0.7(@expo/dom-webview@55.0.3)(expo@55.0.5)(react-dom@19.2.0(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0) - '@expo/schema-utils': 55.0.2 - '@radix-ui/react-slot': 1.2.3(@types/react@19.1.17)(react@19.2.0) - '@radix-ui/react-tabs': 1.1.13(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) - '@react-navigation/bottom-tabs': 7.15.5(@react-navigation/native@7.1.33(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native-safe-area-context@5.6.2(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native-screens@4.16.0(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0) - '@react-navigation/native': 7.1.33(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0) - '@react-navigation/native-stack': 7.14.4(@react-navigation/native@7.1.33(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native-safe-area-context@5.6.2(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native-screens@4.16.0(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0) - client-only: 0.0.1 - debug: 4.4.3 - escape-string-regexp: 4.0.0 - expo: 55.0.5(@babel/core@7.28.5)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@55.0.7)(expo-router@55.0.5)(react-dom@19.2.0(react@19.2.0))(react-native-webview@13.12.2(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)(typescript@5.3.3) - expo-constants: 55.0.7(expo@55.0.5)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(typescript@5.3.3) - expo-glass-effect: 55.0.8(expo@55.0.5)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0) - expo-image: 55.0.6(expo@55.0.5)(react-native-web@0.21.2(encoding@0.1.13)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0) - expo-linking: 55.0.7(expo@55.0.5)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)(typescript@5.3.3) - expo-server: 55.0.6 - expo-symbols: 55.0.5(expo-font@55.0.4)(expo@55.0.5)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0) - fast-deep-equal: 3.1.3 - invariant: 2.2.4 - nanoid: 3.3.11 - query-string: 7.1.3 - react: 19.2.0 - react-fast-compare: 3.2.2 - react-native: 0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0) - react-native-is-edge-to-edge: 1.2.1(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0) - react-native-safe-area-context: 5.6.2(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0) - react-native-screens: 4.16.0(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0) - semver: 7.6.3 - server-only: 0.0.1 - sf-symbols-typescript: 2.2.0 - shallowequal: 1.1.0 - use-latest-callback: 0.2.6(react@19.2.0) - vaul: 1.1.2(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) - optionalDependencies: - '@react-navigation/drawer': 7.7.4(@react-navigation/native@7.1.21(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native-gesture-handler@2.28.0(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native-reanimated@4.1.5(@babel/core@7.28.5)(react-native-worklets@0.7.4(@babel/core@7.28.5)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native-safe-area-context@5.6.2(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native-screens@4.16.0(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0) - '@testing-library/react-native': 13.3.3(jest@30.3.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.27.4))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.3.3)))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react-test-renderer@19.1.0(react@19.2.0))(react@19.2.0) - react-dom: 19.2.0(react@19.2.0) - react-native-gesture-handler: 2.28.0(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0) - react-native-reanimated: 4.1.5(@babel/core@7.28.5)(react-native-worklets@0.7.4(@babel/core@7.28.5)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0) - react-native-web: 0.21.2(encoding@0.1.13)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) - transitivePeerDependencies: - - '@react-native-masked-view/masked-view' - - '@types/react' - - '@types/react-dom' - - expo-font - - supports-color - - expo-router@55.0.5(wp3gdsn67a3vbeku7d2dtd5jzu): - dependencies: - '@expo/log-box': 55.0.8(@expo/dom-webview@55.0.3)(expo@55.0.5)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0) - '@expo/metro-runtime': 55.0.7(@expo/dom-webview@55.0.3)(expo@55.0.5)(react-dom@19.2.0(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0) - '@expo/schema-utils': 55.0.2 - '@radix-ui/react-slot': 1.2.3(@types/react@19.1.17)(react@19.2.0) - '@radix-ui/react-tabs': 1.1.13(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) - '@react-navigation/bottom-tabs': 7.15.5(@react-navigation/native@7.1.33(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native-safe-area-context@5.6.2(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native-screens@4.16.0(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0) - '@react-navigation/native': 7.1.33(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0) - '@react-navigation/native-stack': 7.14.4(@react-navigation/native@7.1.33(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native-safe-area-context@5.6.2(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native-screens@4.16.0(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0) - client-only: 0.0.1 - debug: 4.4.3 - escape-string-regexp: 4.0.0 - expo: 55.0.5(@babel/core@7.28.5)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@55.0.7)(expo-router@55.0.5)(react-dom@19.2.0(react@19.2.0))(react-native-webview@13.12.2(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) - expo-constants: 55.0.7(expo@55.0.5)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(typescript@5.9.3) - expo-glass-effect: 55.0.8(expo@55.0.5)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0) - expo-image: 55.0.6(expo@55.0.5)(react-native-web@0.21.2(encoding@0.1.13)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0) - expo-linking: 55.0.7(expo@55.0.5)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) - expo-server: 55.0.6 - expo-symbols: 55.0.5(expo-font@14.0.9)(expo@55.0.5)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0) - fast-deep-equal: 3.1.3 - invariant: 2.2.4 - nanoid: 3.3.11 - query-string: 7.1.3 - react: 19.2.0 - react-fast-compare: 3.2.2 - react-native: 0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0) - react-native-is-edge-to-edge: 1.2.1(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0) - react-native-safe-area-context: 5.6.2(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0) - react-native-screens: 4.16.0(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0) - semver: 7.6.3 - server-only: 0.0.1 - sf-symbols-typescript: 2.2.0 - shallowequal: 1.1.0 - use-latest-callback: 0.2.6(react@19.2.0) - vaul: 1.1.2(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) - optionalDependencies: - '@react-navigation/drawer': 7.7.4(@react-navigation/native@7.1.21(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native-gesture-handler@2.28.0(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native-reanimated@4.1.5(@babel/core@7.28.5)(react-native-worklets@0.5.1(@babel/core@7.28.5)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native-safe-area-context@5.6.2(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native-screens@4.16.0(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0) - '@testing-library/react-native': 13.3.3(jest@30.3.0(@types/node@24.10.1)(esbuild-register@3.6.0(esbuild@0.27.4))(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3)))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react-test-renderer@19.1.0(react@19.2.0))(react@19.2.0) - react-dom: 19.2.0(react@19.2.0) - react-native-gesture-handler: 2.28.0(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0) - react-native-reanimated: 4.1.5(@babel/core@7.28.5)(react-native-worklets@0.5.1(@babel/core@7.28.5)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0) - react-native-web: 0.21.2(encoding@0.1.13)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) - transitivePeerDependencies: - - '@react-native-masked-view/masked-view' - - '@types/react' - - '@types/react-dom' - - expo-font - - supports-color - expo-secure-store@55.0.8(expo@55.0.5): dependencies: - expo: 55.0.5(@babel/core@7.28.5)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@55.0.7)(expo-router@55.0.5)(react-dom@19.2.0(react@19.2.0))(react-native-webview@13.12.2(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) + expo: 55.0.5(@babel/core@7.28.5)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@55.0.7)(expo-router@55.0.5)(react-dom@19.2.0(react@19.2.0))(react-native-webview@13.12.2(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) expo-server@1.0.4: {} @@ -44038,7 +43475,7 @@ snapshots: dependencies: expo: 55.0.5(@babel/core@7.28.5)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@55.0.7)(expo-router@55.0.5)(react-dom@19.2.0(react@19.2.0))(react-native-webview@13.12.2(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) react-native: 0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0) - sf-symbols-typescript: 2.1.0 + sf-symbols-typescript: 2.2.0 expo-symbols@55.0.5(expo-font@14.0.10)(expo@54.0.25)(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.1.0))(react@19.1.0): dependencies: @@ -44051,19 +43488,10 @@ snapshots: optional: true expo-symbols@55.0.5(expo-font@14.0.10)(expo@55.0.5)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0): - dependencies: - '@expo-google-fonts/material-symbols': 0.4.25 - expo: 55.0.5(@babel/core@7.28.5)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@55.0.7)(expo-router@55.0.5)(react-dom@19.2.0(react@19.2.0))(react-native-webview@13.12.2(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)(typescript@5.3.3) - expo-font: 14.0.10(expo@55.0.5)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0) - react: 19.2.0 - react-native: 0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0) - sf-symbols-typescript: 2.2.0 - - expo-symbols@55.0.5(expo-font@14.0.9)(expo@55.0.5)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0): dependencies: '@expo-google-fonts/material-symbols': 0.4.25 expo: 55.0.5(@babel/core@7.28.5)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@55.0.7)(expo-router@55.0.5)(react-dom@19.2.0(react@19.2.0))(react-native-webview@13.12.2(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) - expo-font: 14.0.9(expo@55.0.5)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0) + expo-font: 14.0.10(expo@55.0.5)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0) react: 19.2.0 react-native: 0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0) sf-symbols-typescript: 2.2.0 @@ -44111,7 +43539,7 @@ snapshots: dependencies: '@react-native/normalize-colors': 0.83.2 debug: 4.4.3 - expo: 55.0.5(@babel/core@7.28.5)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@55.0.7)(expo-router@55.0.5)(react-dom@19.2.0(react@19.2.0))(react-native-webview@13.12.2(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)(typescript@5.3.3) + expo: 55.0.5(@babel/core@7.28.5)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@55.0.7)(expo-router@55.0.5)(react-dom@19.2.0(react@19.2.0))(react-native-webview@13.12.2(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) react-native: 0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0) optionalDependencies: react-native-web: 0.21.2(encoding@0.1.13)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) @@ -44141,7 +43569,7 @@ snapshots: expo-updates-interface@2.0.0(expo@55.0.5): dependencies: - expo: 55.0.5(@babel/core@7.28.5)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@55.0.7)(expo-router@55.0.5)(react-dom@19.2.0(react@19.2.0))(react-native-webview@13.12.2(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)(typescript@5.3.3) + expo: 55.0.5(@babel/core@7.28.5)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@55.0.7)(expo-router@55.0.5)(react-dom@19.2.0(react@19.2.0))(react-native-webview@13.12.2(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) expo-updates-interface@55.1.3(expo@55.0.5): dependencies: @@ -44176,7 +43604,7 @@ snapshots: expo-web-browser@55.0.9(expo@55.0.5)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0)): dependencies: - expo: 55.0.5(@babel/core@7.28.5)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@55.0.7)(expo-router@55.0.5)(react-dom@19.2.0(react@19.2.0))(react-native-webview@13.12.2(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)(typescript@5.3.3) + expo: 55.0.5(@babel/core@7.28.5)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@55.0.7)(expo-router@55.0.5)(react-dom@19.2.0(react@19.2.0))(react-native-webview@13.12.2(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) react-native: 0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0) expo-web-browser@55.0.9(expo@55.0.5)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.0)): @@ -44257,10 +43685,10 @@ snapshots: expo@55.0.5(@babel/core@7.28.5)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@55.0.7)(expo-router@55.0.5)(react-dom@19.2.0(react@19.2.0))(react-native-webview@13.12.2(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)(typescript@5.3.3): dependencies: - '@babel/runtime': 7.28.4 + '@babel/runtime': 7.29.2 '@expo/cli': 55.0.15(@expo/dom-webview@55.0.3)(@expo/metro-runtime@55.0.7)(expo-constants@55.0.7)(expo-font@55.0.4)(expo-router@55.0.5)(expo@55.0.5)(react-dom@19.2.0(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)(typescript@5.3.3) - '@expo/config': 55.0.8(typescript@5.3.3) - '@expo/config-plugins': 55.0.6 + '@expo/config': 55.0.11(typescript@5.3.3) + '@expo/config-plugins': 55.0.7 '@expo/devtools': 55.0.2(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0) '@expo/fingerprint': 0.16.5 '@expo/local-build-cache-provider': 55.0.6(typescript@5.3.3) @@ -44269,7 +43697,7 @@ snapshots: '@expo/metro-config': 55.0.9(expo@55.0.5)(typescript@5.3.3) '@expo/vector-icons': 15.0.3(expo-font@55.0.4)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0) '@ungap/structured-clone': 1.3.0 - babel-preset-expo: 55.0.10(@babel/core@7.28.5)(@babel/runtime@7.28.4)(expo@55.0.5)(react-refresh@0.14.2) + babel-preset-expo: 55.0.10(@babel/core@7.28.5)(@babel/runtime@7.29.2)(expo@55.0.5)(react-refresh@0.14.2) expo-asset: 55.0.8(expo@55.0.5)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)(typescript@5.3.3) expo-constants: 55.0.7(expo@55.0.5)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(typescript@5.3.3) expo-file-system: 55.0.10(expo@55.0.5)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0)) @@ -44299,10 +43727,10 @@ snapshots: expo@55.0.5(@babel/core@7.28.5)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@55.0.7)(expo-router@55.0.5)(react-dom@19.2.0(react@19.2.0))(react-native-webview@13.12.2(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)(typescript@5.8.3): dependencies: - '@babel/runtime': 7.28.4 + '@babel/runtime': 7.29.2 '@expo/cli': 55.0.15(@expo/dom-webview@55.0.3)(@expo/metro-runtime@55.0.7)(expo-constants@55.0.7)(expo-font@55.0.4)(expo-router@55.0.5)(expo@55.0.5)(react-dom@19.2.0(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)(typescript@5.8.3) - '@expo/config': 55.0.8(typescript@5.8.3) - '@expo/config-plugins': 55.0.6 + '@expo/config': 55.0.11(typescript@5.8.3) + '@expo/config-plugins': 55.0.7 '@expo/devtools': 55.0.2(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0) '@expo/fingerprint': 0.16.5 '@expo/local-build-cache-provider': 55.0.6(typescript@5.8.3) @@ -44311,7 +43739,7 @@ snapshots: '@expo/metro-config': 55.0.9(expo@55.0.5)(typescript@5.8.3) '@expo/vector-icons': 15.0.3(expo-font@55.0.4)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0) '@ungap/structured-clone': 1.3.0 - babel-preset-expo: 55.0.10(@babel/core@7.28.5)(@babel/runtime@7.28.4)(expo@55.0.5)(react-refresh@0.14.2) + babel-preset-expo: 55.0.10(@babel/core@7.28.5)(@babel/runtime@7.29.2)(expo@55.0.5)(react-refresh@0.14.2) expo-asset: 55.0.8(expo@55.0.5)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)(typescript@5.8.3) expo-constants: 55.0.7(expo@55.0.5)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(typescript@5.8.3) expo-file-system: 55.0.10(expo@55.0.5)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0)) @@ -44341,19 +43769,19 @@ snapshots: expo@55.0.5(@babel/core@7.28.5)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@55.0.7)(expo-router@55.0.5)(react-dom@19.2.0(react@19.2.0))(react-native-webview@13.12.2(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)(typescript@5.9.3): dependencies: - '@babel/runtime': 7.28.4 - '@expo/cli': 55.0.15(@expo/dom-webview@55.0.3)(@expo/metro-runtime@55.0.7)(expo-constants@55.0.7)(expo-font@55.0.4)(expo-router@55.0.5)(expo@55.0.5)(react-dom@19.2.0(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) - '@expo/config': 55.0.8(typescript@5.9.3) - '@expo/config-plugins': 55.0.6 + '@babel/runtime': 7.29.2 + '@expo/cli': 55.0.15(@expo/dom-webview@55.0.3)(@expo/metro-runtime@55.0.7)(expo-constants@55.0.7)(expo-font@55.0.4(expo@55.0.5)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(expo-router@55.0.5)(expo@55.0.5)(react-dom@19.2.0(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) + '@expo/config': 55.0.11(typescript@5.9.3) + '@expo/config-plugins': 55.0.7 '@expo/devtools': 55.0.2(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0) '@expo/fingerprint': 0.16.5 '@expo/local-build-cache-provider': 55.0.6(typescript@5.9.3) '@expo/log-box': 55.0.7(@expo/dom-webview@55.0.3)(expo@55.0.5)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0) '@expo/metro': 54.2.0 '@expo/metro-config': 55.0.9(expo@55.0.5)(typescript@5.9.3) - '@expo/vector-icons': 15.0.3(expo-font@55.0.4)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0) + '@expo/vector-icons': 15.0.3(expo-font@55.0.4(expo@55.0.5)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0) '@ungap/structured-clone': 1.3.0 - babel-preset-expo: 55.0.10(@babel/core@7.28.5)(@babel/runtime@7.28.4)(expo@55.0.5)(react-refresh@0.14.2) + babel-preset-expo: 55.0.10(@babel/core@7.28.5)(@babel/runtime@7.29.2)(expo@55.0.5)(react-refresh@0.14.2) expo-asset: 55.0.8(expo@55.0.5)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) expo-constants: 55.0.7(expo@55.0.5)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(typescript@5.9.3) expo-file-system: 55.0.10(expo@55.0.5)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0)) @@ -44383,10 +43811,10 @@ snapshots: expo@55.0.5(@babel/core@7.28.5)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@55.0.7)(expo-router@55.0.5)(react-dom@19.2.0(react@19.2.0))(react-native-webview@13.12.2(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3): dependencies: - '@babel/runtime': 7.28.4 + '@babel/runtime': 7.29.2 '@expo/cli': 55.0.15(@expo/dom-webview@55.0.3)(@expo/metro-runtime@55.0.7)(expo-constants@55.0.7)(expo-font@55.0.4)(expo-router@55.0.5)(expo@55.0.5)(react-dom@19.2.0(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) - '@expo/config': 55.0.8(typescript@5.9.3) - '@expo/config-plugins': 55.0.6 + '@expo/config': 55.0.11(typescript@5.9.3) + '@expo/config-plugins': 55.0.7 '@expo/devtools': 55.0.2(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) '@expo/fingerprint': 0.16.5 '@expo/local-build-cache-provider': 55.0.6(typescript@5.9.3) @@ -44395,7 +43823,7 @@ snapshots: '@expo/metro-config': 55.0.9(expo@55.0.5)(typescript@5.9.3) '@expo/vector-icons': 15.0.3(expo-font@55.0.4)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) '@ungap/structured-clone': 1.3.0 - babel-preset-expo: 55.0.10(@babel/core@7.28.5)(@babel/runtime@7.28.4)(expo@55.0.5)(react-refresh@0.14.2) + babel-preset-expo: 55.0.10(@babel/core@7.28.5)(@babel/runtime@7.29.2)(expo@55.0.5)(react-refresh@0.14.2) expo-asset: 55.0.8(expo@55.0.5)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) expo-constants: 55.0.7(expo@55.0.5)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.0))(typescript@5.9.3) expo-file-system: 55.0.10(expo@55.0.5)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.0)) @@ -44425,10 +43853,10 @@ snapshots: expo@55.0.5(@babel/core@7.28.5)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@55.0.7)(expo-router@55.0.5)(react-dom@19.2.4(react@19.2.4))(react-native-webview@13.12.2(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4)(typescript@5.9.3): dependencies: - '@babel/runtime': 7.28.4 + '@babel/runtime': 7.29.2 '@expo/cli': 55.0.15(@expo/dom-webview@55.0.3)(@expo/metro-runtime@55.0.7)(expo-constants@55.0.7)(expo-font@55.0.4)(expo-router@55.0.5)(expo@55.0.5)(react-dom@19.2.4(react@19.2.4))(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4)(typescript@5.9.3) - '@expo/config': 55.0.8(typescript@5.9.3) - '@expo/config-plugins': 55.0.6 + '@expo/config': 55.0.11(typescript@5.9.3) + '@expo/config-plugins': 55.0.7 '@expo/devtools': 55.0.2(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4) '@expo/fingerprint': 0.16.5 '@expo/local-build-cache-provider': 55.0.6(typescript@5.9.3) @@ -44437,7 +43865,7 @@ snapshots: '@expo/metro-config': 55.0.9(expo@55.0.5)(typescript@5.9.3) '@expo/vector-icons': 15.0.3(expo-font@55.0.4)(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4) '@ungap/structured-clone': 1.3.0 - babel-preset-expo: 55.0.10(@babel/core@7.28.5)(@babel/runtime@7.28.4)(expo@55.0.5)(react-refresh@0.14.2) + babel-preset-expo: 55.0.10(@babel/core@7.28.5)(@babel/runtime@7.29.2)(expo@55.0.5)(react-refresh@0.14.2) expo-asset: 55.0.8(expo@55.0.5)(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4)(typescript@5.9.3) expo-constants: 55.0.7(expo@55.0.5)(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(typescript@5.9.3) expo-file-system: 55.0.10(expo@55.0.5)(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4)) @@ -44646,12 +44074,6 @@ snapshots: dependencies: flat-cache: 4.0.1 - file-type@16.5.4: - dependencies: - readable-web-to-node-stream: 3.0.4 - strtok3: 6.3.0 - token-types: 4.2.1 - file-type@20.4.1: dependencies: '@tokenizer/inflate': 0.2.7 @@ -44773,8 +44195,6 @@ snapshots: fn.name@1.1.0: {} - follow-redirects@1.15.11: {} - fontace@0.3.1: dependencies: '@types/fontkit': 2.0.8 @@ -44847,13 +44267,6 @@ snapshots: dependencies: fetch-blob: 3.2.0 - formidable@2.1.5: - dependencies: - '@paralleldrive/cuid2': 2.3.1 - dezalgo: 1.0.4 - once: 1.4.0 - qs: 6.14.0 - forwarded-parse@2.1.2: {} forwarded@0.2.0: {} @@ -45207,15 +44620,6 @@ snapshots: ufo: 1.6.3 uncrypto: 0.1.3 - handlebars@4.7.9: - dependencies: - minimist: 1.2.8 - neo-async: 2.6.2 - source-map: 0.6.1 - wordwrap: 1.0.0 - optionalDependencies: - uglify-js: 3.19.3 - has-bigints@1.1.0: {} has-flag@3.0.0: {} @@ -45431,8 +44835,6 @@ snapshots: property-information: 7.1.0 space-separated-tokens: 2.0.2 - helmet@8.1.0: {} - hermes-compiler@0.14.1: {} hermes-compiler@250829098.0.9: @@ -45549,7 +44951,7 @@ snapshots: i18next-browser-languagedetector@7.2.2: dependencies: - '@babel/runtime': 7.28.4 + '@babel/runtime': 7.29.2 i18next-fs-backend@2.6.1: {} @@ -45561,11 +44963,11 @@ snapshots: i18next@22.5.1: dependencies: - '@babel/runtime': 7.28.4 + '@babel/runtime': 7.29.2 i18next@23.16.8: dependencies: - '@babel/runtime': 7.28.4 + '@babel/runtime': 7.29.2 i18next@25.6.3(typescript@5.3.3): dependencies: @@ -45660,7 +45062,7 @@ snapshots: cli-width: 3.0.0 external-editor: 3.1.0 figures: 3.2.0 - lodash: 4.17.23 + lodash: 4.17.21 mute-stream: 0.0.8 ora: 5.4.1 run-async: 2.4.1 @@ -45679,7 +45081,7 @@ snapshots: cli-width: 4.1.0 external-editor: 3.1.0 figures: 3.2.0 - lodash: 4.17.23 + lodash: 4.17.21 mute-stream: 1.0.0 ora: 5.4.1 run-async: 3.0.0 @@ -46148,35 +45550,16 @@ snapshots: - supports-color optional: true - jest-cli@29.7.0(@types/node@20.19.25)(ts-node@10.9.2(@types/node@20.19.25)(typescript@5.9.3)): + jest-cli@29.7.0(@types/node@24.10.1)(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3)): dependencies: - '@jest/core': 29.7.0(ts-node@10.9.2(@types/node@20.19.25)(typescript@5.9.3)) + '@jest/core': 29.7.0(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3)) '@jest/test-result': 29.7.0 '@jest/types': 29.6.3 chalk: 4.1.2 - create-jest: 29.7.0(@types/node@20.19.25)(ts-node@10.9.2(@types/node@20.19.25)(typescript@5.9.3)) + create-jest: 29.7.0(@types/node@24.10.1)(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3)) exit: 0.1.2 import-local: 3.2.0 - jest-config: 29.7.0(@types/node@20.19.25)(ts-node@10.9.2(@types/node@20.19.25)(typescript@5.9.3)) - jest-util: 29.7.0 - jest-validate: 29.7.0 - yargs: 17.7.2 - transitivePeerDependencies: - - '@types/node' - - babel-plugin-macros - - supports-color - - ts-node - - jest-cli@29.7.0(@types/node@24.10.1): - dependencies: - '@jest/core': 29.7.0 - '@jest/test-result': 29.7.0 - '@jest/types': 29.6.3 - chalk: 4.1.2 - create-jest: 29.7.0(@types/node@24.10.1) - exit: 0.1.2 - import-local: 3.2.0 - jest-config: 29.7.0(@types/node@24.10.1) + jest-config: 29.7.0(@types/node@24.10.1)(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3)) jest-util: 29.7.0 jest-validate: 29.7.0 yargs: 17.7.2 @@ -46206,15 +45589,15 @@ snapshots: - ts-node optional: true - jest-cli@30.3.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.27.4))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.3.3)): + jest-cli@30.3.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.27.4))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)): dependencies: - '@jest/core': 30.3.0(esbuild-register@3.6.0(esbuild@0.27.4))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.3.3)) + '@jest/core': 30.3.0(esbuild-register@3.6.0(esbuild@0.27.4))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)) '@jest/test-result': 30.3.0 '@jest/types': 30.3.0 chalk: 4.1.2 exit-x: 0.2.2 import-local: 3.2.0 - jest-config: 30.3.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.27.4))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.3.3)) + jest-config: 30.3.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.27.4))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)) jest-util: 30.3.0 jest-validate: 30.3.0 yargs: 17.7.2 @@ -46286,38 +45669,7 @@ snapshots: - ts-node optional: true - jest-config@29.7.0(@types/node@20.19.25)(ts-node@10.9.2(@types/node@20.19.25)(typescript@5.9.3)): - dependencies: - '@babel/core': 7.28.5 - '@jest/test-sequencer': 29.7.0 - '@jest/types': 29.6.3 - babel-jest: 29.7.0(@babel/core@7.28.5) - chalk: 4.1.2 - ci-info: 3.9.0 - deepmerge: 4.3.1 - glob: 7.2.3 - graceful-fs: 4.2.11 - jest-circus: 29.7.0 - jest-environment-node: 29.7.0 - jest-get-type: 29.6.3 - jest-regex-util: 29.6.3 - jest-resolve: 29.7.0 - jest-runner: 29.7.0 - jest-util: 29.7.0 - jest-validate: 29.7.0 - micromatch: 4.0.8 - parse-json: 5.2.0 - pretty-format: 29.7.0 - slash: 3.0.0 - strip-json-comments: 3.1.1 - optionalDependencies: - '@types/node': 20.19.25 - ts-node: 10.9.2(@types/node@20.19.25)(typescript@5.9.3) - transitivePeerDependencies: - - babel-plugin-macros - - supports-color - - jest-config@29.7.0(@types/node@22.19.1): + jest-config@29.7.0(@types/node@22.19.1)(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3)): dependencies: '@babel/core': 7.28.5 '@jest/test-sequencer': 29.7.0 @@ -46343,42 +45695,12 @@ snapshots: strip-json-comments: 3.1.1 optionalDependencies: '@types/node': 22.19.1 + ts-node: 10.9.2(@types/node@24.10.1)(typescript@5.9.3) transitivePeerDependencies: - babel-plugin-macros - supports-color - jest-config@29.7.0(@types/node@22.19.1)(ts-node@10.9.2(@types/node@20.19.25)(typescript@5.9.3)): - dependencies: - '@babel/core': 7.28.5 - '@jest/test-sequencer': 29.7.0 - '@jest/types': 29.6.3 - babel-jest: 29.7.0(@babel/core@7.28.5) - chalk: 4.1.2 - ci-info: 3.9.0 - deepmerge: 4.3.1 - glob: 7.2.3 - graceful-fs: 4.2.11 - jest-circus: 29.7.0 - jest-environment-node: 29.7.0 - jest-get-type: 29.6.3 - jest-regex-util: 29.6.3 - jest-resolve: 29.7.0 - jest-runner: 29.7.0 - jest-util: 29.7.0 - jest-validate: 29.7.0 - micromatch: 4.0.8 - parse-json: 5.2.0 - pretty-format: 29.7.0 - slash: 3.0.0 - strip-json-comments: 3.1.1 - optionalDependencies: - '@types/node': 22.19.1 - ts-node: 10.9.2(@types/node@20.19.25)(typescript@5.9.3) - transitivePeerDependencies: - - babel-plugin-macros - - supports-color - - jest-config@29.7.0(@types/node@24.10.1): + jest-config@29.7.0(@types/node@24.10.1)(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3)): dependencies: '@babel/core': 7.28.5 '@jest/test-sequencer': 29.7.0 @@ -46404,6 +45726,7 @@ snapshots: strip-json-comments: 3.1.1 optionalDependencies: '@types/node': 24.10.1 + ts-node: 10.9.2(@types/node@24.10.1)(typescript@5.9.3) transitivePeerDependencies: - babel-plugin-macros - supports-color @@ -46476,7 +45799,7 @@ snapshots: - supports-color optional: true - jest-config@30.3.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.27.4))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.3.3)): + jest-config@30.3.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.27.4))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)): dependencies: '@babel/core': 7.28.5 '@jest/get-type': 30.1.0 @@ -46504,7 +45827,7 @@ snapshots: optionalDependencies: '@types/node': 22.19.1 esbuild-register: 3.6.0(esbuild@0.27.4) - ts-node: 10.9.2(@types/node@22.19.1)(typescript@5.3.3) + ts-node: 10.9.2(@types/node@22.19.1)(typescript@5.9.3) transitivePeerDependencies: - babel-plugin-macros - supports-color @@ -47170,24 +46493,12 @@ snapshots: supports-color: 8.1.1 optional: true - jest@29.7.0(@types/node@20.19.25)(ts-node@10.9.2(@types/node@20.19.25)(typescript@5.9.3)): + jest@29.7.0(@types/node@24.10.1)(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3)): dependencies: - '@jest/core': 29.7.0(ts-node@10.9.2(@types/node@20.19.25)(typescript@5.9.3)) + '@jest/core': 29.7.0(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3)) '@jest/types': 29.6.3 import-local: 3.2.0 - jest-cli: 29.7.0(@types/node@20.19.25)(ts-node@10.9.2(@types/node@20.19.25)(typescript@5.9.3)) - transitivePeerDependencies: - - '@types/node' - - babel-plugin-macros - - supports-color - - ts-node - - jest@29.7.0(@types/node@24.10.1): - dependencies: - '@jest/core': 29.7.0 - '@jest/types': 29.6.3 - import-local: 3.2.0 - jest-cli: 29.7.0(@types/node@24.10.1) + jest-cli: 29.7.0(@types/node@24.10.1)(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3)) transitivePeerDependencies: - '@types/node' - babel-plugin-macros @@ -47208,12 +46519,12 @@ snapshots: - ts-node optional: true - jest@30.3.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.27.4))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.3.3)): + jest@30.3.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.27.4))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)): dependencies: - '@jest/core': 30.3.0(esbuild-register@3.6.0(esbuild@0.27.4))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.3.3)) + '@jest/core': 30.3.0(esbuild-register@3.6.0(esbuild@0.27.4))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)) '@jest/types': 30.3.0 import-local: 3.2.0 - jest-cli: 30.3.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.27.4))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.3.3)) + jest-cli: 30.3.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.27.4))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)) transitivePeerDependencies: - '@types/node' - babel-plugin-macros @@ -47291,10 +46602,6 @@ snapshots: argparse: 1.0.10 esprima: 4.0.1 - js-yaml@4.1.0: - dependencies: - argparse: 2.0.1 - js-yaml@4.1.1: dependencies: argparse: 2.0.1 @@ -47756,8 +47063,6 @@ snapshots: lodash.isstring@4.0.1: {} - lodash.memoize@4.1.2: {} - lodash.merge@4.6.2: {} lodash.once@4.1.1: {} @@ -47874,7 +47179,8 @@ snapshots: dependencies: semver: 7.7.3 - make-error@1.3.6: {} + make-error@1.3.6: + optional: true makeerror@1.0.12: dependencies: @@ -48121,8 +47427,6 @@ snapshots: media-typer@0.3.0: {} - media-typer@1.1.0: {} - memfs@3.5.3: dependencies: fs-monkey: 1.1.0 @@ -48263,7 +47567,7 @@ snapshots: metro-cache: 0.83.3 metro-core: 0.83.3 metro-runtime: 0.83.3 - yaml: 2.8.1 + yaml: 2.8.3 transitivePeerDependencies: - bufferutil - supports-color @@ -48358,7 +47662,7 @@ snapshots: metro-runtime@0.82.5: dependencies: - '@babel/runtime': 7.28.4 + '@babel/runtime': 7.29.2 flow-enums-runtime: 0.0.6 metro-runtime@0.83.2: @@ -48368,7 +47672,7 @@ snapshots: metro-runtime@0.83.3: dependencies: - '@babel/runtime': 7.28.4 + '@babel/runtime': 7.29.2 flow-enums-runtime: 0.0.6 metro-source-map@0.82.5: @@ -48976,8 +48280,6 @@ snapshots: mime@1.6.0: {} - mime@2.6.0: {} - mime@3.0.0: {} mimic-fn@1.2.0: {} @@ -49110,16 +48412,6 @@ snapshots: muggle-string@0.4.1: {} - multer@1.4.5-lts.2: - dependencies: - append-field: 1.0.0 - busboy: 1.6.0 - concat-stream: 1.6.2 - mkdirp: 0.5.6 - object-assign: 4.1.1 - type-is: 1.6.18 - xtend: 4.0.2 - multer@2.0.2: dependencies: append-field: 1.0.0 @@ -49132,18 +48424,6 @@ snapshots: multitars@0.2.4: {} - music-metadata@7.14.0: - dependencies: - '@tokenizer/token': 0.3.0 - content-type: 1.0.5 - debug: 4.4.3 - file-type: 16.5.4 - media-typer: 1.1.0 - strtok3: 6.3.0 - token-types: 4.2.1 - transitivePeerDependencies: - - supports-color - mute-stream@0.0.8: {} mute-stream@1.0.0: {} @@ -49265,7 +48545,7 @@ snapshots: node-emoji@1.11.0: dependencies: - lodash: 4.17.23 + lodash: 4.17.21 node-fetch-native@1.6.7: {} @@ -49281,8 +48561,6 @@ snapshots: fetch-blob: 3.2.0 formdata-polyfill: 4.0.10 - node-forge@1.3.2: {} - node-forge@1.3.3: {} node-gyp-build-optional-packages@5.2.2: @@ -49768,8 +49046,6 @@ snapshots: pathval@2.0.1: {} - peek-readable@4.1.0: {} - pend@1.2.0: {} performance-now@2.1.0: @@ -50193,8 +49469,6 @@ snapshots: forwarded: 0.2.0 ipaddr.js: 1.9.1 - proxy-from-env@2.1.0: {} - pump@3.0.3: dependencies: end-of-stream: 1.4.5 @@ -50902,7 +50176,7 @@ snapshots: react-native-web@0.21.2(encoding@0.1.13)(react-dom@19.2.0(react@19.2.0))(react@19.2.0): dependencies: - '@babel/runtime': 7.28.4 + '@babel/runtime': 7.29.2 '@react-native/normalize-colors': 0.74.89 fbjs: 3.0.5(encoding@0.1.13) inline-style-prefixer: 7.0.1 @@ -50917,7 +50191,7 @@ snapshots: react-native-web@0.21.2(encoding@0.1.13)(react-dom@19.2.4(react@19.1.0))(react@19.1.0): dependencies: - '@babel/runtime': 7.28.4 + '@babel/runtime': 7.29.2 '@react-native/normalize-colors': 0.74.89 fbjs: 3.0.5(encoding@0.1.13) inline-style-prefixer: 7.0.1 @@ -50933,7 +50207,7 @@ snapshots: react-native-web@0.21.2(encoding@0.1.13)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): dependencies: - '@babel/runtime': 7.28.4 + '@babel/runtime': 7.29.2 '@react-native/normalize-colors': 0.74.89 fbjs: 3.0.5(encoding@0.1.13) inline-style-prefixer: 7.0.1 @@ -51663,10 +50937,6 @@ snapshots: process: 0.11.10 string_decoder: 1.3.0 - readable-web-to-node-stream@3.0.4: - dependencies: - readable-stream: 4.7.0 - readdir-glob@1.1.3: dependencies: minimatch: 5.1.6 @@ -51727,8 +50997,6 @@ snapshots: dependencies: redis-errors: 1.2.0 - reflect-metadata@0.1.14: {} - reflect-metadata@0.2.2: {} reflect.getprototypeof@1.0.10: @@ -52766,11 +52034,6 @@ snapshots: dependencies: '@tokenizer/token': 0.3.0 - strtok3@6.3.0: - dependencies: - '@tokenizer/token': 0.3.0 - peek-readable: 4.1.0 - structured-headers@0.4.1: {} style-to-js@1.1.21: @@ -52809,28 +52072,6 @@ snapshots: suncalc@1.9.0: {} - superagent@8.1.2: - dependencies: - component-emitter: 1.3.1 - cookiejar: 2.1.4 - debug: 4.4.3 - fast-safe-stringify: 2.1.1 - form-data: 4.0.5 - formidable: 2.1.5 - methods: 1.1.2 - mime: 2.6.0 - qs: 6.14.0 - semver: 7.7.3 - transitivePeerDependencies: - - supports-color - - supertest@6.3.4: - dependencies: - methods: 1.1.2 - superagent: 8.1.2 - transitivePeerDependencies: - - supports-color - supports-color@10.2.2: {} supports-color@5.5.0: @@ -52948,17 +52189,6 @@ snapshots: picocolors: 1.1.1 sax: 1.4.3 - swagger-ui-dist@5.17.14: {} - - swagger-ui-dist@5.32.1: - dependencies: - '@scarf/scarf': 1.4.0 - - swagger-ui-express@5.0.1(express@4.21.2): - dependencies: - express: 4.21.2 - swagger-ui-dist: 5.32.1 - symbol-observable@4.0.0: {} symbol-tree@3.2.4: {} @@ -53179,11 +52409,6 @@ snapshots: toidentifier@1.0.1: {} - token-types@4.2.1: - dependencies: - '@tokenizer/token': 0.3.0 - ieee754: 1.2.1 - token-types@6.1.2: dependencies: '@borewit/text-codec': 0.2.2 @@ -53257,26 +52482,6 @@ snapshots: ts-interface-checker@0.1.13: {} - ts-jest@29.4.6(@babel/core@7.28.5)(@jest/transform@30.3.0)(@jest/types@30.3.0)(babel-jest@30.3.0(@babel/core@7.28.5))(jest-util@30.3.0)(jest@29.7.0(@types/node@20.19.25)(ts-node@10.9.2(@types/node@20.19.25)(typescript@5.9.3)))(typescript@5.9.3): - dependencies: - bs-logger: 0.2.6 - fast-json-stable-stringify: 2.1.0 - handlebars: 4.7.9 - jest: 29.7.0(@types/node@20.19.25)(ts-node@10.9.2(@types/node@20.19.25)(typescript@5.9.3)) - json5: 2.2.3 - lodash.memoize: 4.1.2 - make-error: 1.3.6 - semver: 7.7.3 - type-fest: 4.41.0 - typescript: 5.9.3 - yargs-parser: 21.1.1 - optionalDependencies: - '@babel/core': 7.28.5 - '@jest/transform': 30.3.0 - '@jest/types': 30.3.0 - babel-jest: 30.3.0(@babel/core@7.28.5) - jest-util: 30.3.0 - ts-node@10.9.2(@types/node@20.19.25)(typescript@5.9.3): dependencies: '@cspotcode/source-map-support': 0.8.1 @@ -53294,24 +52499,6 @@ snapshots: typescript: 5.9.3 v8-compile-cache-lib: 3.0.1 yn: 3.1.1 - - ts-node@10.9.2(@types/node@22.19.1)(typescript@5.3.3): - dependencies: - '@cspotcode/source-map-support': 0.8.1 - '@tsconfig/node10': 1.0.12 - '@tsconfig/node12': 1.0.11 - '@tsconfig/node14': 1.0.3 - '@tsconfig/node16': 1.0.4 - '@types/node': 22.19.1 - acorn: 8.15.0 - acorn-walk: 8.3.4 - arg: 4.1.3 - create-require: 1.1.1 - diff: 4.0.2 - make-error: 1.3.6 - typescript: 5.3.3 - v8-compile-cache-lib: 3.0.1 - yn: 3.1.1 optional: true ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3): @@ -53450,13 +52637,6 @@ snapshots: - tsx - yaml - tsx@4.20.6: - dependencies: - esbuild: 0.25.12 - get-tsconfig: 4.13.0 - optionalDependencies: - fsevents: 2.3.3 - tsx@4.21.0: dependencies: esbuild: 0.27.4 @@ -53597,9 +52777,6 @@ snapshots: ufo@1.6.3: {} - uglify-js@3.19.3: - optional: true - uid@2.0.2: dependencies: '@lukeed/csprng': 1.1.0 @@ -53947,7 +53124,8 @@ snapshots: uuid@9.0.1: {} - v8-compile-cache-lib@3.0.1: {} + v8-compile-cache-lib@3.0.1: + optional: true v8-to-istanbul@9.3.0: dependencies: @@ -54239,6 +53417,23 @@ snapshots: lightningcss: 1.30.2 terser: 5.44.1 + vite@6.4.1(@types/node@20.19.25)(jiti@1.21.7)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3): + dependencies: + esbuild: 0.25.12 + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + postcss: 8.5.6 + rollup: 4.53.3 + tinyglobby: 0.2.15 + optionalDependencies: + '@types/node': 20.19.25 + fsevents: 2.3.3 + jiti: 1.21.7 + lightningcss: 1.30.2 + terser: 5.44.1 + tsx: 4.21.0 + yaml: 2.8.3 + vite@6.4.1(@types/node@20.19.25)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3): dependencies: esbuild: 0.25.12 @@ -54328,6 +53523,10 @@ snapshots: optionalDependencies: vite: 5.4.21(@types/node@22.19.1)(lightningcss@1.30.2)(terser@5.44.1) + vitefu@1.1.1(vite@6.4.1(@types/node@20.19.25)(jiti@1.21.7)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)): + optionalDependencies: + vite: 6.4.1(@types/node@20.19.25)(jiti@1.21.7)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3) + vitefu@1.1.1(vite@6.4.1(@types/node@20.19.25)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)): optionalDependencies: vite: 6.4.1(@types/node@20.19.25)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3) @@ -54609,7 +53808,7 @@ snapshots: optionalDependencies: '@types/debug': 4.1.12 '@types/node': 24.10.1 - '@vitest/browser': 3.2.4(playwright@1.57.0)(vite@6.4.1(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(vitest@3.2.4) + '@vitest/browser': 3.2.4(playwright@1.57.0)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(vitest@3.2.4) '@vitest/ui': 3.2.4(vitest@3.2.4) jsdom: 29.0.1(@noble/hashes@2.0.1) transitivePeerDependencies: @@ -55256,8 +54455,6 @@ snapshots: word-wrap@1.2.5: {} - wordwrap@1.0.0: {} - workbox-background-sync@7.4.0: dependencies: idb: 7.1.1 @@ -55559,7 +54756,8 @@ snapshots: buffer-crc32: 0.2.13 fd-slicer: 1.1.0 - yn@3.1.1: {} + yn@3.1.1: + optional: true yocto-queue@0.1.0: {} diff --git a/services/mana-matrix-bot/internal/plugins/manadeck/manadeck.go b/services/mana-matrix-bot/internal/plugins/cards/cards.go similarity index 100% rename from services/mana-matrix-bot/internal/plugins/manadeck/manadeck.go rename to services/mana-matrix-bot/internal/plugins/cards/cards.go