import 'dotenv/config'; import express from 'express'; import cors from 'cors'; import { createClient } from '@supabase/supabase-js'; import * as decksApi from '../src/lib/api/decks-core.js'; const app = express(); app.use(cors({ origin: true, credentials: true })); app.use(express.json()); const supabaseUrl = process.env.SUPABASE_URL || process.env.VITE_SUPABASE_URL; const supabaseAnonKey = process.env.SUPABASE_ANON_KEY || process.env.VITE_SUPABASE_ANON_KEY; if (!supabaseUrl || !supabaseAnonKey) { console.warn('Missing SUPABASE_URL or SUPABASE_ANON_KEY (or VITE_*). API may fail.'); } function supabaseClient(accessToken = null) { const options = { db: { schema: 'omotomo' } }; if (accessToken) { options.global = { headers: { Authorization: `Bearer ${accessToken}` } }; } return createClient(supabaseUrl ?? '', supabaseAnonKey ?? '', options); } const anonSupabase = () => supabaseClient(); async function resolveEmailFromUsername(username) { const trimmed = (username ?? '').trim(); if (!trimmed) return null; const { data, error } = await anonSupabase() .from('profiles') .select('email') .ilike('display_name', trimmed) .limit(1); if (error || !data?.length || !data[0]?.email) return null; return data[0].email; } function requireAuth(req, res, next) { const authHeader = req.headers.authorization; const token = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null; if (!token) { res.status(401).json({ error: 'Missing or invalid Authorization header' }); return; } const supabase = supabaseClient(token); supabase.auth.getUser(token).then(({ data: { user }, error }) => { if (error || !user) { res.status(401).json({ error: 'Invalid or expired token' }); return; } req.supabase = supabase; req.userId = user.id; next(); }); } function optionalAuth(req, res, next) { const authHeader = req.headers.authorization; const token = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null; if (!token) { req.supabase = anonSupabase(); req.userId = null; next(); return; } const supabase = supabaseClient(token); supabase.auth.getUser(token).then(({ data: { user }, error }) => { req.supabase = supabase; req.userId = error || !user ? null : user.id; next(); }); } app.post('/api/auth/login', express.json(), async (req, res) => { try { const { email_or_username, password } = req.body || {}; if (!email_or_username || !password) { res.status(400).json({ error: 'email_or_username and password required' }); return; } let email = String(email_or_username).trim(); if (!email.includes('@')) { const resolved = await resolveEmailFromUsername(email); if (!resolved) { res.status(401).json({ error: 'No account with that username' }); return; } email = resolved; } const { data, error } = await anonSupabase().auth.signInWithPassword({ email, password }); if (error) { res.status(401).json({ error: error.message }); return; } res.json({ access_token: data.session?.access_token, refresh_token: data.session?.refresh_token, expires_at: data.session?.expires_at, user: data.user, }); } catch (e) { res.status(500).json({ error: e?.message ?? 'Login failed' }); } }); app.post('/api/auth/register', express.json(), async (req, res) => { try { const { email, password, display_name } = req.body || {}; if (!email || !password) { res.status(400).json({ error: 'email and password required' }); return; } const { data, error } = await anonSupabase().auth.signUp({ email: String(email).trim(), password, options: { data: { display_name: (display_name ?? '').trim() || null } }, }); if (error) { res.status(400).json({ error: error.message }); return; } const needsConfirmation = data?.user && !data?.session; res.status(201).json({ access_token: data.session?.access_token ?? null, refresh_token: data.session?.refresh_token ?? null, expires_at: data.session?.expires_at ?? null, user: data.user, needs_confirmation: needsConfirmation, }); } catch (e) { res.status(500).json({ error: e?.message ?? 'Registration failed' }); } }); app.get('/api/auth/session', requireAuth, (req, res) => { res.json({ user: { id: req.userId } }); }); app.get('/api/decks/mine', requireAuth, async (req, res) => { try { const decks = await decksApi.fetchMyDecks(req.supabase, req.userId); res.json(decks); } catch (e) { res.status(500).json({ error: e?.message ?? 'Failed to load decks' }); } }); app.get('/api/decks/published', optionalAuth, async (req, res) => { try { const decks = await decksApi.fetchPublishedDecks(req.supabase, req.userId ?? undefined); res.json(decks); } catch (e) { res.status(500).json({ error: e?.message ?? 'Failed to load decks' }); } }); app.get('/api/decks/:id', optionalAuth, async (req, res) => { try { const deck = await decksApi.fetchDeckWithQuestions(req.supabase, req.params.id); const isMyCopy = !!(req.userId && deck.owner_id === req.userId && deck.copied_from_deck_id); const isOwn = !!(req.userId && deck.owner_id === req.userId && !deck.copied_from_deck_id); const canView = deck.published || isMyCopy || isOwn; if (!canView) { res.status(404).json({ error: 'This deck is not available' }); return; } res.json(deck); } catch (e) { if (e?.message === 'Deck not found') { res.status(404).json({ error: e.message }); return; } res.status(500).json({ error: e?.message ?? 'Failed to load deck' }); } }); app.post('/api/decks', requireAuth, express.json(), async (req, res) => { try { const { title, description, config, questions } = req.body || {}; if (!title || typeof title !== 'string' || !title.trim()) { res.status(400).json({ error: 'title required' }); return; } const normalized = Array.isArray(questions) ? questions .map((q) => { const answers = (q.answers ?? []).map((a) => String(a).trim()).filter(Boolean); if (answers.length === 0) return null; const indices = (q.correct_answer_indices ?? [0]).filter((i) => i >= 0 && i < answers.length); return { prompt: (q.prompt ?? '').trim(), explanation: (q.explanation ?? '').trim() || null, answers, correct_answer_indices: indices.length ? indices : [0], }; }) .filter(Boolean) : []; if (normalized.length === 0) { res.status(400).json({ error: 'At least one question with one answer required' }); return; } const id = await decksApi.createDeck(req.supabase, req.userId, { title: title.trim(), description: (description ?? '').trim(), config: config ?? {}, questions: normalized, }); res.status(201).json({ id }); } catch (e) { res.status(500).json({ error: e?.message ?? 'Failed to create deck' }); } }); app.patch('/api/decks/:id', requireAuth, express.json(), async (req, res) => { try { const full = await decksApi.fetchDeckWithQuestions(req.supabase, req.params.id); if (full.owner_id !== req.userId) { res.status(404).json({ error: 'Deck not found' }); return; } const { title, description, config, questions } = req.body || {}; const newTitle = title !== undefined ? String(title).trim() : (full.title ?? ''); const newDesc = description !== undefined ? String(description).trim() : (full.description ?? ''); const newConfig = config !== undefined ? (config ?? {}) : (full.config ?? {}); let qList = questions; if (qList === undefined) { qList = (full.questions ?? []).map((q) => ({ prompt: q.prompt ?? '', explanation: q.explanation ?? '', answers: q.answers ?? [], correct_answer_indices: q.correct_answer_indices ?? [0], })); } else { qList = Array.isArray(qList) ? qList .map((q) => { const answers = (q.answers ?? []).map((a) => String(a).trim()).filter(Boolean); if (answers.length === 0) return null; const indices = (q.correct_answer_indices ?? [0]).filter((i) => i >= 0 && i < answers.length); return { prompt: (q.prompt ?? '').trim(), explanation: (q.explanation ?? '').trim() || null, answers, correct_answer_indices: indices.length ? indices : [0], }; }) .filter(Boolean) : []; if (qList.length === 0) { res.status(400).json({ error: 'At least one question with one answer required' }); return; } } await decksApi.updateDeck(req.supabase, req.params.id, { title: newTitle, description: newDesc, config: newConfig, questions: qList, }); res.json({ ok: true }); } catch (e) { if (e?.message === 'Deck not found') { res.status(404).json({ error: e.message }); return; } res.status(500).json({ error: e?.message ?? 'Failed to update deck' }); } }); app.post('/api/decks/:id/publish', requireAuth, async (req, res) => { try { const { data: deck } = await req.supabase.from('decks').select('owner_id').eq('id', req.params.id).single(); if (!deck || deck.owner_id !== req.userId) { res.status(404).json({ error: 'Deck not found' }); return; } await decksApi.togglePublished(req.supabase, req.params.id, true); res.json({ ok: true }); } catch (e) { res.status(500).json({ error: e?.message ?? 'Failed to publish' }); } }); app.post('/api/decks/:id/copy', requireAuth, async (req, res) => { try { const id = await decksApi.copyDeckToUser(req.supabase, req.params.id, req.userId); res.status(201).json({ id }); } catch (e) { if (e?.message?.includes('not available to copy')) { res.status(400).json({ error: e.message }); return; } res.status(500).json({ error: e?.message ?? 'Failed to copy deck' }); } }); app.get('/api/decks/:id/update-preview', requireAuth, async (req, res) => { try { const preview = await decksApi.getSourceUpdatePreview(req.supabase, req.params.id, req.userId); res.json(preview); } catch (e) { if (e?.message?.includes('not found or not a copy')) { res.status(404).json({ error: e.message }); return; } res.status(500).json({ error: e?.message ?? 'Failed to load preview' }); } }); app.post('/api/decks/:id/apply-update', requireAuth, async (req, res) => { try { await decksApi.applySourceUpdate(req.supabase, req.params.id, req.userId); res.json({ ok: true }); } catch (e) { if (e?.message?.includes('not found or not a copy') || e?.message?.includes('no longer available')) { res.status(400).json({ error: e.message }); return; } res.status(500).json({ error: e?.message ?? 'Failed to apply update' }); } }); const PORT = process.env.PORT || 3001; app.listen(PORT, () => { console.log(`API server listening on http://localhost:${PORT}`); });