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
218 lines
8.2 KiB
TypeScript
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 });
|
|
}
|
|
}
|