diff --git a/.gitignore b/.gitignore index dd47c81c..330c676a 100644 --- a/.gitignore +++ b/.gitignore @@ -40,6 +40,13 @@ github-actions-*.json # vercel .vercel +# PWA (auto-generated by next-pwa) +/public/sw.js +/public/sw.js.map +/public/workbox-*.js +/public/workbox-*.js.map +/public/worker-*.js + # typescript *.tsbuildinfo next-env.d.ts diff --git a/app/api/admin/test-push-notification/route.ts b/app/api/admin/test-push-notification/route.ts new file mode 100644 index 00000000..1f3e1010 --- /dev/null +++ b/app/api/admin/test-push-notification/route.ts @@ -0,0 +1,111 @@ +import { NextRequest, NextResponse } from 'next/server' +import { createClient } from '@/lib/supabase/server' +import webpush from 'web-push' + +export async function POST(request: NextRequest) { + try { + // Configure web-push with VAPID keys at runtime + webpush.setVapidDetails( + `mailto:${process.env.VAPID_CONTACT_EMAIL}`, + process.env.VAPID_PUBLIC_KEY!, + process.env.VAPID_PRIVATE_KEY! + ) + + const supabase = await createClient() + + // Get current user + const { data: { user }, error: authError } = await supabase.auth.getUser() + + if (authError || !user) { + return NextResponse.json( + { error: 'Unauthorized - must be logged in' }, + { status: 401 } + ) + } + + // Get user's push subscription + const { data: subscription, error: subError } = await supabase + .from('push_subscriptions') + .select('*') + .eq('user_id', user.id) + .single() + + if (subError || !subscription) { + return NextResponse.json( + { error: 'No push subscription found. Please enable notifications first.' }, + { status: 404 } + ) + } + + // Parse nested subscription JSONB structure + const subData = subscription.subscription as { + endpoint: string + keys: { p256dh: string; auth: string } + } + + const pushSubscription = { + endpoint: subData.endpoint, + keys: { + p256dh: subData.keys.p256dh, + auth: subData.keys.auth + } + } + + // Create test notification payload + const notificationPayload = JSON.stringify({ + title: 'Test Notification', + body: 'Push notifications are working correctly!', + icon: '/icon-192x192.svg', + badge: '/icon-192x192.svg', + tag: 'test-notification', + data: { + url: '/profile', + type: 'test', + timestamp: Date.now() + }, + actions: [ + { + action: 'open', + title: 'Open App', + icon: '/icon-192x192.svg' + }, + { + action: 'close', + title: 'Dismiss' + } + ] + }) + + // Send the test notification + await webpush.sendNotification(pushSubscription, notificationPayload) + + console.log(`Test notification sent to user ${user.id}`) + + return NextResponse.json({ + success: true, + message: 'Test notification sent successfully' + }) + + } catch (error: any) { + console.error('Test notification error:', error) + + // Handle specific web-push errors + if (error.statusCode === 410 || error.statusCode === 404) { + return NextResponse.json( + { + error: 'Push subscription is no longer valid. Please re-enable notifications.', + code: 'SUBSCRIPTION_EXPIRED' + }, + { status: 410 } + ) + } + + return NextResponse.json( + { + error: 'Failed to send test notification', + message: error.message || 'Unknown error' + }, + { status: 500 } + ) + } +} diff --git a/app/api/cron/daily-notifications/route.ts b/app/api/cron/daily-notifications/route.ts index 980042b5..84500ccd 100644 --- a/app/api/cron/daily-notifications/route.ts +++ b/app/api/cron/daily-notifications/route.ts @@ -96,11 +96,13 @@ export async function GET(request: NextRequest) { // 4. Send notifications to all subscribers const sendPromises = subscriptions.map(async (sub) => { + // Parse nested subscription JSONB structure + const subData = sub.subscription as { endpoint: string; keys: { p256dh: string; auth: string } }; const pushSubscription = { - endpoint: sub.endpoint, + endpoint: subData.endpoint, keys: { - p256dh: sub.p256dh_key, - auth: sub.auth_key + p256dh: subData.keys.p256dh, + auth: subData.keys.auth } }; diff --git a/app/api/cron/daily-word-generation/route.ts b/app/api/cron/daily-word-generation/route.ts index 80c00350..85186bfc 100644 --- a/app/api/cron/daily-word-generation/route.ts +++ b/app/api/cron/daily-word-generation/route.ts @@ -28,22 +28,24 @@ export async function GET(request: NextRequest) { const today = new Date().toISOString().split('T')[0]; - // Step 1: Generate Words for Both Languages + // Step 1: Generate Words for All Languages console.log('📚 Generating daily words...'); const generateSpanish = await generateWordOfDay('spanish', today, request.nextUrl.origin); const generateLatin = await generateWordOfDay('latin', today, request.nextUrl.origin); + const generateIcelandic = await generateWordOfDay('icelandic', today, request.nextUrl.origin); const generationResults = { spanish: generateSpanish, latin: generateLatin, + icelandic: generateIcelandic, date: today }; // Step 2: Send Teaser Notifications console.log('🔔 Sending teaser notifications...'); - const notificationResults = await sendTeaserNotifications(); + const notificationResults = await sendTeaserNotifications(supabase); return NextResponse.json({ success: true, @@ -68,7 +70,7 @@ export async function GET(request: NextRequest) { } } -async function generateWordOfDay(language: 'spanish' | 'latin', date: string, origin: string) { +async function generateWordOfDay(language: 'spanish' | 'latin' | 'icelandic', date: string, origin: string) { try { console.log(`🎲 Generating ${language} word for ${date}`); @@ -99,7 +101,7 @@ async function generateWordOfDay(language: 'spanish' | 'latin', date: string, or } } -async function sendTeaserNotifications() { +async function sendTeaserNotifications(supabase: any) { try { // Get all users with notification preferences const { data: usersWithNotifications, error: prefError } = await supabase @@ -149,13 +151,31 @@ async function sendTeaserNotifications() { } const isSpanish = userPrefs.preferred_language === 'spanish'; - const title = isSpanish - ? '📚 ¡Tu palabra del día está lista!' - : '📚 Verbum diei paratum est!'; - - const body = isSpanish - ? 'Descubre una nueva palabra española fascinante' - : 'Discover a fascinating Latin word today'; + const isLatin = userPrefs.preferred_language === 'latin'; + const isIcelandic = userPrefs.preferred_language === 'icelandic'; + + let title: string; + let body: string; + let actionTitle: string; + let closeTitle: string; + + if (isSpanish) { + title = '📚 ¡Tu palabra del día está lista!'; + body = 'Descubre una nueva palabra española fascinante'; + actionTitle = 'Ver Palabra'; + closeTitle = 'Cerrar'; + } else if (isIcelandic) { + title = '📚 Orðið dagsins er tilbúið!'; + body = 'Uppgötvaðu nýtt íslenskt orð í dag'; + actionTitle = 'Sjá orð'; + closeTitle = 'Loka'; + } else { + // Latin or default + title = '📚 Verbum diei paratum est!'; + body = 'Discover a fascinating Latin word today'; + actionTitle = 'See Word'; + closeTitle = 'Close'; + } const notificationPayload = JSON.stringify({ title, @@ -172,21 +192,23 @@ async function sendTeaserNotifications() { actions: [ { action: 'open', - title: isSpanish ? 'Ver Palabra' : 'See Word', + title: actionTitle, icon: '/icon-192x192.svg' }, { action: 'close', - title: isSpanish ? 'Cerrar' : 'Close' + title: closeTitle } ] }); + // Parse nested subscription JSONB structure + const subData = sub.subscription as { endpoint: string; keys: { p256dh: string; auth: string } }; const pushSubscription = { - endpoint: sub.endpoint, + endpoint: subData.endpoint, keys: { - p256dh: sub.p256dh_key, - auth: sub.auth_key + p256dh: subData.keys.p256dh, + auth: subData.keys.auth } }; diff --git a/app/api/cron/welcome-notifications/route.ts b/app/api/cron/welcome-notifications/route.ts index 9841f5fd..8acd11f7 100644 --- a/app/api/cron/welcome-notifications/route.ts +++ b/app/api/cron/welcome-notifications/route.ts @@ -51,7 +51,7 @@ export async function GET(request: NextRequest) { if (userError) { console.error('❌ Error fetching recent users:', userError); // Try alternative approach with RPC call - return await sendWelcomeNotificationsAlternative(oneHourFifteenMinutesAgo, oneHourAgo); + return await sendWelcomeNotificationsAlternative(oneHourFifteenMinutesAgo, oneHourAgo, supabase); } if (!recentUsers || recentUsers.length === 0) { @@ -87,7 +87,7 @@ export async function GET(request: NextRequest) { } // Send welcome notifications - const welcomeResults = await sendWelcomeNotifications(subscriptions, recentUsers); + const welcomeResults = await sendWelcomeNotifications(subscriptions, recentUsers, supabase); return NextResponse.json({ success: true, @@ -109,7 +109,11 @@ export async function GET(request: NextRequest) { } } -async function sendWelcomeNotifications(subscriptions: any[], users: any[]) { +async function sendWelcomeNotifications( + subscriptions: any[], + users: any[], + supabase: any +) { const sendPromises = subscriptions.map(async (sub) => { const user = users.find(u => u.id === sub.user_id); @@ -142,11 +146,13 @@ async function sendWelcomeNotifications(subscriptions: any[], users: any[]) { ] }); + // Parse nested subscription JSONB structure + const subData = sub.subscription as { endpoint: string; keys: { p256dh: string; auth: string } }; const pushSubscription = { - endpoint: sub.endpoint, + endpoint: subData.endpoint, keys: { - p256dh: sub.p256dh_key, - auth: sub.auth_key + p256dh: subData.keys.p256dh, + auth: subData.keys.auth } }; @@ -198,7 +204,11 @@ async function sendWelcomeNotifications(subscriptions: any[], users: any[]) { } // Alternative approach if direct auth.users access fails -async function sendWelcomeNotificationsAlternative(startTime: Date, endTime: Date) { +async function sendWelcomeNotificationsAlternative( + startTime: Date, + endTime: Date, + supabase: any +) { try { console.log('🔄 Using alternative approach for welcome notifications'); @@ -261,11 +271,13 @@ async function sendWelcomeNotificationsSimple(subscriptions: any[]) { ] }); + // Parse nested subscription JSONB structure + const subData = sub.subscription as { endpoint: string; keys: { p256dh: string; auth: string } }; const pushSubscription = { - endpoint: sub.endpoint, + endpoint: subData.endpoint, keys: { - p256dh: sub.p256dh_key, - auth: sub.auth_key + p256dh: subData.keys.p256dh, + auth: subData.keys.auth } }; diff --git a/app/api/generate-word-of-day/route.ts b/app/api/generate-word-of-day/route.ts index abef9660..458eadbb 100644 --- a/app/api/generate-word-of-day/route.ts +++ b/app/api/generate-word-of-day/route.ts @@ -1,6 +1,6 @@ import { NextRequest, NextResponse } from 'next/server'; import { createClient } from '@supabase/supabase-js'; -import { generateSpanishWordOfDay, generateLatinWordOfDay } from '@/lib/mastra/tools/wordOfDay'; +import { generateSpanishWordOfDay, generateLatinWordOfDay, generateIcelandicWordOfDay } from '@/lib/mastra/tools/wordOfDay'; export async function POST(request: NextRequest) { try { @@ -13,8 +13,8 @@ export async function POST(request: NextRequest) { const MERRIAM_WEBSTER_API_KEY = process.env.MERRIAM_WEBSTER_API_KEY; const { language = 'spanish', date = new Date().toISOString().split('T')[0] } = await request.json(); - if (!['spanish', 'latin'].includes(language)) { - return NextResponse.json({ error: 'Invalid language. Use "spanish" or "latin"' }, { status: 400 }); + if (!['spanish', 'latin', 'icelandic'].includes(language)) { + return NextResponse.json({ error: 'Invalid language. Use "spanish", "latin", or "icelandic"' }, { status: 400 }); } console.log(`🎲 Generating Word of Day for ${language} on ${date}`); @@ -41,6 +41,9 @@ export async function POST(request: NextRequest) { if (language === 'spanish') { // Spanish: Use MW API + LLM workflow wordData = await generateSpanishWord(date); + } else if (language === 'icelandic') { + // Icelandic: Full LLM generation workflow + wordData = await generateIcelandicWord(date); } else { // Latin: Full LLM generation workflow wordData = await generateLatinWord(date); @@ -119,6 +122,22 @@ async function generateLatinWord(date: string) { } } +async function generateIcelandicWord(date: string) { + try { + // Use direct function call (bypass hanging workflow) + const result = await generateIcelandicWordOfDay({ date }); + + if (!result.success) { + throw new Error(result.error || 'Icelandic word generation failed'); + } + + return result.data; + } catch (error) { + console.error('❌ Error in generateIcelandicWord:', error); + throw error; + } +} + // Helper functions for MW API data extraction function extractPronunciation(mwData: any): string | null { if (!mwData || !Array.isArray(mwData) || mwData.length === 0) return null; diff --git a/app/api/tts/__tests__/synthesize.test.ts b/app/api/tts/__tests__/synthesize.test.ts index c921e0f4..ef2e5569 100644 --- a/app/api/tts/__tests__/synthesize.test.ts +++ b/app/api/tts/__tests__/synthesize.test.ts @@ -31,7 +31,7 @@ describe('TTS Synthesize API Route', () => { beforeEach(async () => { mockFetch.mockReset(); - vi.stubEnv('ELEVEN_LABS_API_KEY', 'test-api-key'); + vi.stubEnv('ELEVENLABS_API_KEY', 'test-api-key'); // Clear module cache and reimport to pick up env changes vi.resetModules(); @@ -45,8 +45,8 @@ describe('TTS Synthesize API Route', () => { }); describe('API key validation', () => { - it('returns 500 if ELEVEN_LABS_API_KEY is not configured', async () => { - vi.stubEnv('ELEVEN_LABS_API_KEY', ''); + it('returns 500 if ELEVENLABS_API_KEY is not configured', async () => { + vi.stubEnv('ELEVENLABS_API_KEY', ''); vi.resetModules(); const module = await import('../synthesize/route'); diff --git a/app/api/tts/synthesize/route.ts b/app/api/tts/synthesize/route.ts index d0005411..4e4b7142 100644 --- a/app/api/tts/synthesize/route.ts +++ b/app/api/tts/synthesize/route.ts @@ -3,7 +3,7 @@ import { getVoiceId, isLanguageSupported, LanguageCodeSchema } from '@/lib/langu import type { LanguageCode } from '@/lib/languages' const ELEVENLABS_API_URL = 'https://api.elevenlabs.io/v1/text-to-speech' -const API_KEY = process.env.ELEVEN_LABS_API_KEY +const API_KEY = process.env.ELEVENLABS_API_KEY const MODEL_ID = process.env.ELEVENLABS_MODEL_ID || 'eleven_v3' export interface TTSRequest { diff --git a/app/api/word-of-day/fresh-sentences/route.ts b/app/api/word-of-day/fresh-sentences/route.ts index 332490c8..e423e4b2 100644 --- a/app/api/word-of-day/fresh-sentences/route.ts +++ b/app/api/word-of-day/fresh-sentences/route.ts @@ -12,9 +12,9 @@ export async function POST(request: NextRequest) { ); } - if (!['spanish', 'latin'].includes(language)) { + if (!['spanish', 'latin', 'icelandic'].includes(language)) { return NextResponse.json( - { error: 'Invalid language. Use "spanish" or "latin"' }, + { error: 'Invalid language. Use "spanish", "latin", or "icelandic"' }, { status: 400 } ); } @@ -23,7 +23,7 @@ export async function POST(request: NextRequest) { const result = await generateFreshSentences({ word, - language: language as 'spanish' | 'latin', + language: language as 'spanish' | 'latin' | 'icelandic', definitions, count: Math.min(count, 5) // Max 5 sentences }); diff --git a/app/api/word-of-day/route.ts b/app/api/word-of-day/route.ts index 88d6c0e2..040f2ad6 100644 --- a/app/api/word-of-day/route.ts +++ b/app/api/word-of-day/route.ts @@ -12,9 +12,9 @@ export async function GET(request: NextRequest) { const language = url.searchParams.get('language') || 'spanish'; const date = url.searchParams.get('date') || new Date().toISOString().split('T')[0]; - if (!['spanish', 'latin'].includes(language)) { + if (!['spanish', 'latin', 'icelandic'].includes(language)) { return NextResponse.json( - { error: 'Invalid language. Use "spanish" or "latin"' }, + { error: 'Invalid language. Use "spanish", "latin", or "icelandic"' }, { status: 400 } ); } @@ -99,6 +99,12 @@ export async function GET(request: NextRequest) { // Support for user preferences - get word based on user's preferred language export async function POST(request: NextRequest) { try { + // Initialize Supabase client at runtime + const supabase = createClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.SUPABASE_SERVICE_ROLE_KEY! + ); + const { userId, date = new Date().toISOString().split('T')[0] } = await request.json(); if (!userId) { diff --git a/app/word-of-day/page.tsx b/app/word-of-day/page.tsx index 34e4a3f9..0134aee2 100644 --- a/app/word-of-day/page.tsx +++ b/app/word-of-day/page.tsx @@ -25,7 +25,7 @@ export default function WordOfDayPage() { const [loading, setLoading] = useState(true) const [sentencesLoading, setSentencesLoading] = useState(false) const [user, setUser] = useState(null) - const [selectedLanguage, setSelectedLanguage] = useState<'spanish' | 'latin'>('spanish') + const [selectedLanguage, setSelectedLanguage] = useState<'spanish' | 'latin' | 'icelandic'>('spanish') const router = useRouter() const supabase = createClient() @@ -154,30 +154,30 @@ export default function WordOfDayPage() { if (loading) { return ( -
-
+
+
) } if (!wordData) { return ( -
+
-

Word of the Day

+

Word of the Day

-
+
📚
-

No Word Available

-

+

No Word Available

+

There's no Word of the Day available yet. Check back later or help us generate one!

@@ -185,7 +185,7 @@ export default function WordOfDayPage() {
@@ -199,24 +199,26 @@ export default function WordOfDayPage() { const isSpanish = wordData.language === 'spanish' const isLatin = wordData.language === 'latin' + const isIcelandic = wordData.language === 'icelandic' return ( -
+
{/* Header */}
-

+

{isSpanish && '🇪🇸 Palabra del Día'} {isLatin && '🏛️ Verbum Diei'} + {isIcelandic && '🇮🇸 Orðið dagsins'}

- + {new Date(wordData.date).toLocaleDateString()}
@@ -224,14 +226,14 @@ export default function WordOfDayPage() { {/* Language Toggle */}
-
+
+
{/* Main Word Display */} -
+
{/* Word Header */}
-

{wordData.word}

+

{wordData.word}

{wordData.pronunciation && ( -
+
/{wordData.pronunciation}/
)} - + {wordData.partOfSpeech}
{/* Definitions */}
-

+

{isSpanish && '📖 Definiciones'} {isLatin && '📖 Definitiones'} + {isIcelandic && '📖 Skilgreiningar'}

{wordData.definitions.map((definition, index) => (
- {index + 1}. - {definition} + {index + 1}. + {definition}
))}
@@ -286,9 +300,10 @@ export default function WordOfDayPage() { {/* Example Sentences */}
-

+

{isSpanish && '🎭 Ejemplos'} {isLatin && '🎭 Exempla'} + {isIcelandic && '🎭 Dæmi'}