You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
365 lines
12 KiB
365 lines
12 KiB
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/auth/profile', requireAuth, async (req, res) => {
|
|
try {
|
|
const { data, error } = await req.supabase
|
|
.from('profiles')
|
|
.select('id, display_name, email, avatar_url')
|
|
.eq('id', req.userId)
|
|
.single();
|
|
if (error && error.code !== 'PGRST116') {
|
|
res.status(500).json({ error: error.message });
|
|
return;
|
|
}
|
|
if (!data) {
|
|
res.status(404).json({ error: 'Profile not found' });
|
|
return;
|
|
}
|
|
res.json(data);
|
|
} catch (e) {
|
|
res.status(500).json({ error: e?.message ?? 'Failed to load profile' });
|
|
}
|
|
});
|
|
|
|
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, express.json(), 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;
|
|
}
|
|
const published = req.body?.published !== false;
|
|
await decksApi.togglePublished(req.supabase, req.params.id, published);
|
|
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' });
|
|
}
|
|
});
|
|
|
|
app.delete('/api/decks/:id', 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.deleteDeck(req.supabase, req.params.id);
|
|
res.status(204).send();
|
|
} catch (e) {
|
|
res.status(500).json({ error: e?.message ?? 'Failed to delete deck' });
|
|
}
|
|
});
|
|
|
|
const PORT = process.env.PORT || 3001;
|
|
app.listen(PORT, () => {
|
|
console.log(`API server listening on http://localhost:${PORT}`);
|
|
});
|