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.

329 lines
11 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/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}`);
});

Powered by TurnKey Linux.