My-Weekly-ToDo-List/src/app/api/goal/route.ts
mARTin 2ec1278b19 feat: quote sources — preset buttons, universal parser, expanded local collection
Goal API (route.ts):
- Now actually uses profile.quoteSourceUrls — tries each user-configured URL
  in order before falling back to ZenQuotes
- Universal JSON parser handles ZenQuotes [{q,a}], Quotable {content,author},
  Forismatic {quoteText,quoteAuthor}, generic {text/quote/body, author/by},
  nested {data:{...}}, and advice-slip {slip:{advice}} formats
- ZenQuotes remains the built-in EN fallback when no user URLs are configured

SettingsSidebar:
- Adds clickable preset-source pill buttons below the "Add source" button:
  ZenQuotes, Stoic Quotes, Quotable, Forismatic (EN), Advice Slip
- Pills show ✓ and are disabled when the URL is already in the list
- Note explains DE/FR/IT/ES are served from the curated local collection

quotes.ts:
- Updated PRESET_QUOTE_SOURCES to match the 5 working English APIs
- +14 French quotes (Marcus Aurelius, Lombardi, Ali, MLK, Picasso, etc.)
- +13 Spanish quotes (Collier, Eliot, Darwin, Voltaire, Ashe, etc.)
- +13 Italian quotes (Collier, Eliot, Ali, MLK, Aurelius, da Vinci, etc.)

v1.84.0
2026-04-05 12:50:46 +02:00

218 lines
8.2 KiB
TypeScript

import { NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
import { prisma } from '@/lib/prisma';
import { getHolidayHint } from '@/lib/holidays';
import { getRandomLocalQuote } from '@/lib/quotes';
export const dynamic = 'force-dynamic';
export async function GET(req: Request) {
try {
const session = await getServerSession(authOptions);
if (!session?.user?.email) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { searchParams } = new URL(req.url);
const weekStartParam = searchParams.get('weekStart');
if (!weekStartParam) {
return NextResponse.json({ error: 'weekStart is required' }, { status: 400 });
}
const date = new Date(weekStartParam);
date.setUTCHours(0, 0, 0, 0);
// Look up by email — session ID can be stale after DB restore
const user = await prisma.user.findUnique({
where: { email: session.user.email },
select: {
id: true,
goalFallbackType: true,
goalDefaultSentence: true,
language: true,
quoteLanguages: true,
quoteSourceUrls: true,
}
});
if (!user) return NextResponse.json({ error: 'User not found' }, { status: 404 });
const userId = user.id;
// 1. Check if user has a custom set goal for THIS week specifically
const goal = await prisma.weeklyGoal.findUnique({
where: {
userId_weekStart: {
userId: userId,
weekStart: date,
},
},
});
if (goal && goal.text) {
return NextResponse.json({ goal: goal.text });
}
// 2. Handle Fallbacks based on user settings
const fallbackType = user?.goalFallbackType || 'quote';
if (fallbackType === 'next_todo') {
// Fetch first incomplete task for this week
const weekEnd = new Date(date);
weekEnd.setDate(weekEnd.getDate() + 7);
const nextTask = await prisma.task.findFirst({
where: {
userId,
completed: false,
scheduledDate: {
gte: date,
lt: weekEnd
}
},
orderBy: [
{ scheduledDate: 'asc' },
{ order: 'asc' }
]
});
if (nextTask) {
return NextResponse.json({ goal: `Next: ${nextTask.title}`, isNextTask: true });
}
// If no tasks, fall back to quote or default? Let's go to quote.
}
if (fallbackType === 'default') {
return NextResponse.json({ goal: user?.goalDefaultSentence || 'goal of the week', isDefault: true });
}
// 3. Fallback to holiday/celebration hints (High priority for "quote" type)
const holidayHint = getHolidayHint(date);
if (holidayHint) {
return NextResponse.json({ goal: holidayHint, isHoliday: true });
}
// 4. Try user-configured quote source URLs, then ZenQuotes
const quoteLangs: string[] = (user as any)?.quoteLanguages?.length > 0
? (user as any).quoteLanguages
: [user?.language || 'en'];
// Universal JSON quote parser — handles the most common free API response shapes
const parseQuoteResponse = (data: any): string | null => {
try {
// Array responses: pick a random element
if (Array.isArray(data)) {
if (data.length === 0) return null;
data = data[Math.floor(Math.random() * data.length)];
}
// ZenQuotes: [{q, a}]
if (data.q && data.a) return `${data.q}${data.a}`;
// Quotable: {content, author}
if (data.content && data.author) return `${data.content}${data.author}`;
// Forismatic: {quoteText, quoteAuthor}
if (data.quoteText) return data.quoteAuthor ? `${data.quoteText.trim()}${data.quoteAuthor.trim()}` : data.quoteText.trim();
// Common: {text, author} or {quote, author}
const text = data.text || data.quote || data.body || data.sentence;
const author = data.author || data.by || data.from || data.speaker;
if (text) return author ? `${text}${author}` : text;
// Nested: {data: {quote, author}}
if (data.data) return parseQuoteResponse(data.data);
// Nested: {slip: {advice}}
if (data.slip?.advice) return data.slip.advice;
} catch { /* ignore */ }
return null;
};
// Try each user-configured URL first
const userUrls: string[] = (user as any)?.quoteSourceUrls?.filter(Boolean) || [];
for (const url of userUrls) {
try {
const res = await fetch(url, { signal: AbortSignal.timeout(4000) });
if (res.ok) {
const data = await res.json();
const parsed = parseQuoteResponse(data);
if (parsed) return NextResponse.json({ goal: parsed, isQuote: true });
}
} catch (e) {
console.error(`Failed to fetch from ${url}:`, e);
}
}
// ZenQuotes as built-in English fallback
if (quoteLangs.includes('en')) {
try {
const res = await fetch('https://zenquotes.io/api/random', { signal: AbortSignal.timeout(3000) });
if (res.ok) {
const data = await res.json();
if (data && data[0] && data[0].q) {
return NextResponse.json({ goal: `${data[0].q}${data[0].a}`, isQuote: true });
}
}
} catch (e) {
console.error('Failed to fetch from ZenQuotes:', e);
}
}
// 5. Fallback to local curated quotes (pick random from selected languages)
const randomLang = quoteLangs[Math.floor(Math.random() * quoteLangs.length)];
const localQuote = getRandomLocalQuote(randomLang);
if (localQuote) {
return NextResponse.json({ goal: `${localQuote.text}${localQuote.author}`, isQuote: true });
}
// Final default fallback
return NextResponse.json({ goal: user?.goalDefaultSentence || 'goal of the week', isDefault: true });
} catch (error) {
console.error('API Error:', error);
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
}
}
// POST delegates to PUT for client compatibility
export async function POST(req: Request) {
return PUT(req);
}
export async function PUT(req: Request) {
try {
const session = await getServerSession(authOptions);
if (!session?.user?.email) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const dbUser = await prisma.user.findUnique({ where: { email: session.user.email }, select: { id: true } });
if (!dbUser) return NextResponse.json({ error: 'User not found' }, { status: 404 });
const userId = dbUser.id;
const { weekStart, text } = await req.json();
if (!weekStart) {
return NextResponse.json({ error: 'weekStart is required' }, { status: 400 });
}
const date = new Date(weekStart);
date.setUTCHours(0, 0, 0, 0);
const goal = await prisma.weeklyGoal.upsert({
where: {
userId_weekStart: {
userId: userId,
weekStart: date,
},
},
update: { text },
create: {
userId: userId,
weekStart: date,
text,
},
});
return NextResponse.json({ goal: goal.text });
} catch (error) {
console.error('API Error:', error);
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
}
}