Flesh out payment stuff

This commit is contained in:
Joey Yakimowich-Payne 2026-01-22 12:21:12 -07:00
commit acfed861ab
No known key found for this signature in database
GPG key ID: 6BFE655FA5ABD1E1
27 changed files with 938 additions and 173 deletions

15
App.tsx
View file

@ -1,9 +1,10 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { useAuth } from 'react-oidc-context'; import { useAuth } from 'react-oidc-context';
import { useLocation } from 'react-router-dom'; import { useLocation, useNavigate } from 'react-router-dom';
import { useGame } from './hooks/useGame'; import { useGame } from './hooks/useGame';
import { useQuizLibrary } from './hooks/useQuizLibrary'; import { useQuizLibrary } from './hooks/useQuizLibrary';
import { useUserConfig } from './hooks/useUserConfig'; import { useUserConfig } from './hooks/useUserConfig';
import { useUserPreferences } from './hooks/useUserPreferences';
import { Landing } from './components/Landing'; import { Landing } from './components/Landing';
import { Lobby } from './components/Lobby'; import { Lobby } from './components/Lobby';
import { GameScreen } from './components/GameScreen'; import { GameScreen } from './components/GameScreen';
@ -50,8 +51,11 @@ const FloatingShapes = React.memo(() => {
function App() { function App() {
const auth = useAuth(); const auth = useAuth();
const location = useLocation(); const location = useLocation();
const navigate = useNavigate();
const { saveQuiz, updateQuiz, saving } = useQuizLibrary(); const { saveQuiz, updateQuiz, saving } = useQuizLibrary();
const { defaultConfig } = useUserConfig(); const { defaultConfig } = useUserConfig();
const { subscription } = useUserPreferences();
const maxPlayersLimit = (!subscription || subscription.accessType === 'none') ? 10 : 150;
const [showSaveOptions, setShowSaveOptions] = useState(false); const [showSaveOptions, setShowSaveOptions] = useState(false);
const [pendingEditedQuiz, setPendingEditedQuiz] = useState<Quiz | null>(null); const [pendingEditedQuiz, setPendingEditedQuiz] = useState<Quiz | null>(null);
const { const {
@ -99,7 +103,7 @@ function App() {
sendAdvance, sendAdvance,
kickPlayer, kickPlayer,
leaveGame leaveGame
} = useGame(); } = useGame(defaultConfig);
const handleSaveQuiz = async () => { const handleSaveQuiz = async () => {
if (!pendingQuizToSave) return; if (!pendingQuizToSave) return;
@ -121,6 +125,7 @@ function App() {
const source = pendingQuizToSave?.topic ? 'ai_generated' : 'manual'; const source = pendingQuizToSave?.topic ? 'ai_generated' : 'manual';
const topic = pendingQuizToSave?.topic || undefined; const topic = pendingQuizToSave?.topic || undefined;
await saveQuiz(editedQuiz, source, topic); await saveQuiz(editedQuiz, source, topic);
dismissSavePrompt();
} }
} }
}; };
@ -130,6 +135,7 @@ function App() {
await updateQuiz(sourceQuizId, pendingEditedQuiz); await updateQuiz(sourceQuizId, pendingEditedQuiz);
setShowSaveOptions(false); setShowSaveOptions(false);
setPendingEditedQuiz(null); setPendingEditedQuiz(null);
dismissSavePrompt();
}; };
const handleSaveAsNew = async () => { const handleSaveAsNew = async () => {
@ -137,6 +143,7 @@ function App() {
await saveQuiz(pendingEditedQuiz, 'manual'); await saveQuiz(pendingEditedQuiz, 'manual');
setShowSaveOptions(false); setShowSaveOptions(false);
setPendingEditedQuiz(null); setPendingEditedQuiz(null);
dismissSavePrompt();
}; };
const currentQ = quiz?.questions[currentQuestionIndex]; const currentQ = quiz?.questions[currentQuestionIndex];
@ -153,8 +160,7 @@ function App() {
const isPaymentCancelRoute = location.pathname === '/payment/cancel' && gameState === 'LANDING'; const isPaymentCancelRoute = location.pathname === '/payment/cancel' && gameState === 'LANDING';
const navigateHome = () => { const navigateHome = () => {
window.history.replaceState({}, document.title, '/'); navigate('/', { replace: true });
window.location.reload();
}; };
if (isUpgradeRoute) { if (isUpgradeRoute) {
@ -212,6 +218,7 @@ function App() {
onBack={backFromEditor} onBack={backFromEditor}
showSaveButton={auth.isAuthenticated} showSaveButton={auth.isAuthenticated}
defaultConfig={defaultConfig} defaultConfig={defaultConfig}
maxPlayersLimit={maxPlayersLimit}
/> />
) : null} ) : null}

View file

@ -11,6 +11,9 @@
# - Kaboot OAuth2/OIDC Provider (public client) # - Kaboot OAuth2/OIDC Provider (public client)
# - Kaboot Application # - Kaboot Application
# - kaboot-users Group # - kaboot-users Group
# - kaboot-ai-access Group
# - kaboot-admin Group
# - kaboot-early-access Group
# - Enrollment flow with prompt, user write, and login stages # - Enrollment flow with prompt, user write, and login stages
# - Password complexity policy # - Password complexity policy
# - Test user (kaboottest) - for manual browser testing # - Test user (kaboottest) - for manual browser testing
@ -106,6 +109,20 @@ entries:
attrs: attrs:
name: kaboot-ai-access name: kaboot-ai-access
- id: kaboot-admin-group
model: authentik_core.group
identifiers:
name: kaboot-admin
attrs:
name: kaboot-admin
- id: kaboot-early-access-group
model: authentik_core.group
identifiers:
name: kaboot-early-access
attrs:
name: kaboot-early-access
# ═══════════════════════════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════════════════════════
# GROUPS SCOPE MAPPING # GROUPS SCOPE MAPPING
# ═══════════════════════════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════════════════════════

View file

@ -1,9 +1,18 @@
import React, { useState, useEffect, useRef } from 'react'; import React, { useState, useEffect, useRef } from 'react';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import { X, Key, Eye, EyeOff, Loader2, ChevronDown, Search } from 'lucide-react'; import { X, Key, Eye, EyeOff, Loader2, ChevronDown, Search, CreditCard, CheckCircle } from 'lucide-react';
import { useBodyScrollLock } from '../hooks/useBodyScrollLock'; import { useBodyScrollLock } from '../hooks/useBodyScrollLock';
import { useAuthenticatedFetch } from '../hooks/useAuthenticatedFetch';
import type { AIProvider, UserPreferences } from '../types'; import type { AIProvider, UserPreferences } from '../types';
interface SubscriptionInfo {
hasAccess: boolean;
accessType: 'group' | 'subscription' | 'none';
generationCount: number | null;
generationLimit: number | null;
generationsRemaining: number | null;
}
interface OpenAIModel { interface OpenAIModel {
id: string; id: string;
owned_by: string; owned_by: string;
@ -16,6 +25,8 @@ interface ApiKeyModalProps {
onSave: (prefs: Partial<UserPreferences>) => Promise<void>; onSave: (prefs: Partial<UserPreferences>) => Promise<void>;
saving: boolean; saving: boolean;
hasAIAccess: boolean; hasAIAccess: boolean;
subscription?: SubscriptionInfo | null;
hasEarlyAccess?: boolean;
} }
export const ApiKeyModal: React.FC<ApiKeyModalProps> = ({ export const ApiKeyModal: React.FC<ApiKeyModalProps> = ({
@ -25,8 +36,11 @@ export const ApiKeyModal: React.FC<ApiKeyModalProps> = ({
onSave, onSave,
saving, saving,
hasAIAccess, hasAIAccess,
subscription,
hasEarlyAccess,
}) => { }) => {
useBodyScrollLock(isOpen); useBodyScrollLock(isOpen);
const { authFetch } = useAuthenticatedFetch();
const [localProvider, setLocalProvider] = useState<AIProvider>(preferences.aiProvider || 'gemini'); const [localProvider, setLocalProvider] = useState<AIProvider>(preferences.aiProvider || 'gemini');
const [localGeminiKey, setLocalGeminiKey] = useState(preferences.geminiApiKey || ''); const [localGeminiKey, setLocalGeminiKey] = useState(preferences.geminiApiKey || '');
const [localGeminiModel, setLocalGeminiModel] = useState(preferences.geminiModel || ''); const [localGeminiModel, setLocalGeminiModel] = useState(preferences.geminiModel || '');
@ -44,6 +58,10 @@ export const ApiKeyModal: React.FC<ApiKeyModalProps> = ({
const [isModelDropdownOpen, setIsModelDropdownOpen] = useState(false); const [isModelDropdownOpen, setIsModelDropdownOpen] = useState(false);
const modelDropdownRef = useRef<HTMLDivElement>(null); const modelDropdownRef = useRef<HTMLDivElement>(null);
const [portalLoading, setPortalLoading] = useState(false);
const [portalError, setPortalError] = useState<string | null>(null);
const [showPortalBanner, setShowPortalBanner] = useState(false);
useEffect(() => { useEffect(() => {
if (isOpen) { if (isOpen) {
setLocalProvider(preferences.aiProvider || 'gemini'); setLocalProvider(preferences.aiProvider || 'gemini');
@ -55,9 +73,21 @@ export const ApiKeyModal: React.FC<ApiKeyModalProps> = ({
setLocalOpenAIModel(preferences.openAIModel || ''); setLocalOpenAIModel(preferences.openAIModel || '');
setModelSearchQuery(''); setModelSearchQuery('');
setIsModelDropdownOpen(false); setIsModelDropdownOpen(false);
const params = new URLSearchParams(window.location.search);
if (params.get('portal') === 'return') {
setShowPortalBanner(true);
}
} }
}, [isOpen, preferences]); }, [isOpen, preferences]);
const dismissPortalBanner = () => {
setShowPortalBanner(false);
const url = new URL(window.location.href);
url.searchParams.delete('portal');
window.history.replaceState({}, '', url.toString());
};
useEffect(() => { useEffect(() => {
const fetchOpenAIModels = async () => { const fetchOpenAIModels = async () => {
if (!localOpenAIKey || localOpenAIKey.length < 10) { if (!localOpenAIKey || localOpenAIKey.length < 10) {
@ -123,6 +153,37 @@ export const ApiKeyModal: React.FC<ApiKeyModalProps> = ({
onClose(); onClose();
}; };
const handleManageSubscription = async () => {
setPortalLoading(true);
setPortalError(null);
try {
const response = await authFetch('/api/payments/portal', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
returnUrl: `${window.location.origin}?portal=return`,
}),
});
if (!response.ok) {
throw new Error('Failed to create customer portal session');
}
const data = await response.json();
if (data.url) {
window.location.href = data.url;
} else {
throw new Error('No portal URL returned');
}
} catch (err) {
console.error('Portal error:', err);
setPortalError('Failed to open subscription management. Please try again.');
setPortalLoading(false);
}
};
return ( return (
<motion.div <motion.div
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
@ -157,6 +218,64 @@ export const ApiKeyModal: React.FC<ApiKeyModalProps> = ({
</div> </div>
<div className="p-6 bg-gray-50 space-y-4"> <div className="p-6 bg-gray-50 space-y-4">
{showPortalBanner && (
<div className="bg-green-50 p-4 rounded-xl border border-green-200 shadow-sm flex items-start gap-3">
<div className="p-1 bg-green-100 rounded-lg text-green-600">
<CheckCircle size={20} />
</div>
<div className="flex-1">
<h3 className="font-bold text-green-900">Subscription Updated</h3>
<p className="text-sm text-green-700 mt-1">
Your subscription settings have been updated successfully.
</p>
</div>
<button
onClick={dismissPortalBanner}
className="text-green-500 hover:text-green-700 transition"
>
<X size={20} />
</button>
</div>
)}
{subscription?.accessType === 'subscription' && (
<div className="mb-6 bg-white p-4 rounded-xl border border-gray-200 shadow-sm">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2 text-theme-primary font-bold">
<CreditCard size={20} />
<span>Subscription Active</span>
</div>
{subscription.generationsRemaining !== null && (
<span className="text-sm font-bold bg-theme-primary/10 text-theme-primary px-2 py-1 rounded-lg">
{subscription.generationsRemaining} generations left
</span>
)}
</div>
<p className="text-sm text-gray-500 mb-3">
Manage your billing, payment methods, and invoices.
</p>
{portalError && (
<p className="text-sm text-red-500 mb-3 bg-red-50 p-2 rounded-lg border border-red-100">
{portalError}
</p>
)}
<button
onClick={handleManageSubscription}
disabled={portalLoading}
className="w-full py-2 bg-gray-100 hover:bg-gray-200 text-gray-800 rounded-lg font-bold text-sm transition-colors flex items-center justify-center gap-2 disabled:opacity-50"
>
{portalLoading ? (
<>
<Loader2 size={16} className="animate-spin" />
Redirecting...
</>
) : (
'Manage Subscription'
)}
</button>
</div>
)}
<div> <div>
<label className="block font-bold text-gray-800 mb-2">AI Provider</label> <label className="block font-bold text-gray-800 mb-2">AI Provider</label>
<div className="flex bg-gray-200 p-1 rounded-xl"> <div className="flex bg-gray-200 p-1 rounded-xl">
@ -194,6 +313,9 @@ export const ApiKeyModal: React.FC<ApiKeyModalProps> = ({
OpenRouter OpenRouter
</button> </button>
</div> </div>
<p className="text-xs text-gray-500 mt-2 ml-1">
Choose the provider you want to use for quiz generation.
</p>
</div> </div>
{localProvider === 'gemini' && ( {localProvider === 'gemini' && (

View file

@ -12,6 +12,7 @@ interface DefaultConfigModalProps {
onChange: (config: GameConfig) => void; onChange: (config: GameConfig) => void;
onSave: () => void; onSave: () => void;
saving: boolean; saving: boolean;
maxPlayersLimit?: number;
} }
export const DefaultConfigModal: React.FC<DefaultConfigModalProps> = ({ export const DefaultConfigModal: React.FC<DefaultConfigModalProps> = ({
@ -21,6 +22,7 @@ export const DefaultConfigModal: React.FC<DefaultConfigModalProps> = ({
onChange, onChange,
onSave, onSave,
saving, saving,
maxPlayersLimit = 150,
}) => { }) => {
useBodyScrollLock(isOpen); useBodyScrollLock(isOpen);
@ -41,7 +43,7 @@ export const DefaultConfigModal: React.FC<DefaultConfigModalProps> = ({
className="bg-white rounded-2xl max-w-lg w-full shadow-xl max-h-[90vh] overflow-hidden flex flex-col" className="bg-white rounded-2xl max-w-lg w-full shadow-xl max-h-[90vh] overflow-hidden flex flex-col"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
<div className="p-6 border-b border-gray-100 flex items-center justify-between bg-gradient-to-r from-theme-primary to-purple-600"> <div className="p-6 border-b border-gray-100 flex items-center justify-between bg-theme-primary">
<div className="flex items-center gap-3 text-white"> <div className="flex items-center gap-3 text-white">
<div className="p-2 bg-white/20 rounded-xl"> <div className="p-2 bg-white/20 rounded-xl">
<Settings size={24} /> <Settings size={24} />
@ -64,6 +66,7 @@ export const DefaultConfigModal: React.FC<DefaultConfigModalProps> = ({
config={config} config={config}
onChange={onChange} onChange={onChange}
questionCount={10} questionCount={10}
maxPlayersLimit={maxPlayersLimit}
/> />
</div> </div>

View file

@ -1,5 +1,5 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { Shuffle, Eye, Flame, TrendingUp, MinusCircle, Award, Info, ChevronDown, ChevronUp, Dices } from 'lucide-react'; import { Shuffle, Eye, Flame, TrendingUp, MinusCircle, Award, Info, ChevronDown, ChevronUp, Dices, Users } from 'lucide-react';
import type { GameConfig } from '../types'; import type { GameConfig } from '../types';
interface GameConfigPanelProps { interface GameConfigPanelProps {
@ -7,6 +7,7 @@ interface GameConfigPanelProps {
onChange: (config: GameConfig) => void; onChange: (config: GameConfig) => void;
questionCount: number; questionCount: number;
compact?: boolean; compact?: boolean;
maxPlayersLimit?: number;
} }
interface TooltipProps { interface TooltipProps {
@ -128,6 +129,42 @@ const ToggleRow: React.FC<ToggleRowProps> = ({
); );
}; };
interface ValueRowProps {
icon: React.ReactNode;
label: string;
description: string;
tooltip?: string;
children: React.ReactNode;
}
const ValueRow: React.FC<ValueRowProps> = ({
icon,
label: labelText,
description,
tooltip,
children,
}) => {
return (
<div className="bg-white rounded-xl border-2 border-gray-200 overflow-hidden">
<div className="flex items-center justify-between p-4 hover:bg-gray-50 transition group">
<div className="flex items-center gap-3 flex-1">
<div className="p-2 rounded-lg bg-theme-primary text-white transition">
{icon}
</div>
<div>
<p className="font-bold text-gray-900">{labelText}</p>
<p className="text-sm text-gray-500">{description}</p>
</div>
</div>
<div className="flex items-center gap-2">
{tooltip && <Tooltip content={tooltip} />}
{children}
</div>
</div>
</div>
);
};
interface NumberInputProps { interface NumberInputProps {
label: string; label: string;
value: number; value: number;
@ -161,9 +198,17 @@ export const GameConfigPanel: React.FC<GameConfigPanelProps> = ({
onChange, onChange,
questionCount, questionCount,
compact = false, compact = false,
maxPlayersLimit = 150,
}) => { }) => {
const [expanded, setExpanded] = useState(!compact); const [expanded, setExpanded] = useState(!compact);
// Clamp maxPlayers if it exceeds the limit
React.useEffect(() => {
if (config.maxPlayers && config.maxPlayers > maxPlayersLimit) {
onChange({ ...config, maxPlayers: maxPlayersLimit });
}
}, [maxPlayersLimit, config.maxPlayers, onChange]);
const update = (partial: Partial<GameConfig>) => { const update = (partial: Partial<GameConfig>) => {
onChange({ ...config, ...partial }); onChange({ ...config, ...partial });
}; };
@ -195,6 +240,29 @@ export const GameConfigPanel: React.FC<GameConfigPanelProps> = ({
</button> </button>
)} )}
<ValueRow
icon={<Users size={20} />}
label="Max Players"
description="Limit the number of players who can join"
tooltip="The maximum number of players allowed in the game (2-150). Subscription required for >10 players."
>
<div className="flex flex-col items-end">
<NumberInput
label=""
value={config.maxPlayers || 10}
onChange={(v) => update({ maxPlayers: Math.min(v, maxPlayersLimit) })}
min={2}
max={maxPlayersLimit}
suffix="players"
/>
{maxPlayersLimit < 150 && (
<span className="text-xs text-amber-600 font-bold">
Free plan max: {maxPlayersLimit} players
</span>
)}
</div>
</ValueRow>
<ToggleRow <ToggleRow
icon={<Shuffle size={20} />} icon={<Shuffle size={20} />}
iconActive={config.shuffleQuestions} iconActive={config.shuffleQuestions}

View file

@ -133,13 +133,19 @@ export const Landing: React.FC<LandingProps> = ({ onGenerate, onCreateManual, on
const showOcrOption = hasImageFile || hasDocumentFile; const showOcrOption = hasImageFile || hasDocumentFile;
const { defaultConfig, saving: savingConfig, saveDefaultConfig } = useUserConfig(); const { defaultConfig, saving: savingConfig, saveDefaultConfig } = useUserConfig();
const { preferences, hasAIAccess, subscription, saving: savingPrefs, savePreferences, applyColorScheme } = useUserPreferences(); const { preferences, hasAIAccess, hasEarlyAccess, subscription, saving: savingPrefs, savePreferences, applyColorScheme } = useUserPreferences();
const maxPlayersLimit = (!subscription || subscription.accessType === 'none') ? 10 : 150;
const hasValidApiKey = (() => { const hasValidApiKey = (() => {
if (preferences.aiProvider === 'openrouter') return !!preferences.openRouterApiKey; if (preferences.aiProvider === 'openrouter') return !!preferences.openRouterApiKey;
if (preferences.aiProvider === 'openai') return !!preferences.openAIApiKey; if (preferences.aiProvider === 'openai') return !!preferences.openAIApiKey;
return hasAIAccess || !!preferences.geminiApiKey; return !!preferences.geminiApiKey;
})(); })();
const canUseAI = auth.isAuthenticated && hasValidApiKey; const canUseSystemGeneration = auth.isAuthenticated && (
hasAIAccess ||
subscription?.accessType === 'subscription' ||
(subscription?.accessType === 'none' && subscription.generationsRemaining !== null && subscription.generationsRemaining > 0)
);
const canUseAI = auth.isAuthenticated && (hasValidApiKey || canUseSystemGeneration);
const { const {
quizzes, quizzes,
@ -249,16 +255,22 @@ export const Landing: React.FC<LandingProps> = ({ onGenerate, onCreateManual, on
const handleHostSubmit = (e: React.FormEvent) => { const handleHostSubmit = (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (canGenerate && !isLoading) { if (canGenerate && !isLoading) {
const aiProvider = preferences.aiProvider || 'gemini'; const selectedProvider = preferences.aiProvider || 'gemini';
let aiProvider = selectedProvider;
let apiKey: string | undefined; let apiKey: string | undefined;
if (aiProvider === 'openrouter') {
if (selectedProvider === 'openrouter') {
apiKey = preferences.openRouterApiKey; apiKey = preferences.openRouterApiKey;
} else if (aiProvider === 'openai') { } else if (selectedProvider === 'openai') {
apiKey = preferences.openAIApiKey; apiKey = preferences.openAIApiKey;
} else { } else {
apiKey = preferences.geminiApiKey; apiKey = preferences.geminiApiKey;
} }
if (!apiKey && selectedProvider === 'gemini' && canUseSystemGeneration) {
aiProvider = 'system';
}
onGenerate({ onGenerate({
topic: generateMode === 'topic' ? topic.trim() : undefined, topic: generateMode === 'topic' ? topic.trim() : undefined,
questionCount, questionCount,
@ -328,6 +340,14 @@ export const Landing: React.FC<LandingProps> = ({ onGenerate, onCreateManual, on
</div> </div>
)} )}
{auth.isAuthenticated && subscription && subscription.accessType === 'none' && subscription.generationsRemaining !== null && (
<div className="flex items-center gap-2 bg-white/90 px-3 py-2 rounded-xl shadow-md text-sm font-bold">
<span className="text-gray-500">Free tier:</span>
<span className="text-gray-700">{subscription.generationsRemaining}</span>
<span className="text-gray-400">left</span>
</div>
)}
{auth.isAuthenticated && !hasAIAccess && (!subscription || subscription.accessType === 'none') && ( {auth.isAuthenticated && !hasAIAccess && (!subscription || subscription.accessType === 'none') && (
<a <a
href="/upgrade" href="/upgrade"
@ -554,6 +574,14 @@ export const Landing: React.FC<LandingProps> = ({ onGenerate, onCreateManual, on
/> />
</div> </div>
{auth.isAuthenticated && subscription && subscription.accessType === 'none' && subscription.generationsRemaining !== null && (
<div className="flex justify-center items-center gap-1.5 text-xs font-bold pt-2">
<span className="text-gray-500">Free tier:</span>
<span className="text-gray-700">{subscription.generationsRemaining}</span>
<span className="text-gray-400">generations left</span>
</div>
)}
<button <button
type="submit" type="submit"
disabled={isLoading || !canGenerate} disabled={isLoading || !canGenerate}
@ -720,6 +748,7 @@ export const Landing: React.FC<LandingProps> = ({ onGenerate, onCreateManual, on
} }
}} }}
saving={savingConfig} saving={savingConfig}
maxPlayersLimit={maxPlayersLimit}
/> />
<PreferencesModal <PreferencesModal
@ -740,6 +769,8 @@ export const Landing: React.FC<LandingProps> = ({ onGenerate, onCreateManual, on
}} }}
saving={savingPrefs} saving={savingPrefs}
hasAIAccess={hasAIAccess} hasAIAccess={hasAIAccess}
subscription={subscription}
hasEarlyAccess={hasEarlyAccess}
/> />
</div> </div>
); );

View file

@ -63,19 +63,19 @@ export const PreferencesModal: React.FC<PreferencesModalProps> = ({
className="bg-white rounded-2xl max-w-lg w-full shadow-xl max-h-[90vh] overflow-hidden flex flex-col" className="bg-white rounded-2xl max-w-lg w-full shadow-xl max-h-[90vh] overflow-hidden flex flex-col"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
<div className="p-6 border-b border-gray-100 flex items-center justify-between bg-white"> <div className="p-6 border-b border-gray-100 flex items-center justify-between bg-theme-primary">
<div className="flex items-center gap-3 text-gray-900"> <div className="flex items-center gap-3 text-white">
<div className="p-2 bg-gray-100 rounded-xl"> <div className="p-2 bg-white/20 rounded-xl">
<Palette size={24} /> <Palette size={24} />
</div> </div>
<div> <div>
<h2 className="text-xl font-black">Color Scheme</h2> <h2 className="text-xl font-black">Color Scheme</h2>
<p className="text-sm text-gray-500">Customize your theme</p> <p className="text-sm opacity-80">Customize your theme</p>
</div> </div>
</div> </div>
<button <button
onClick={handleClose} onClick={handleClose}
className="p-2 hover:bg-gray-100 rounded-xl transition text-gray-400 hover:text-gray-600" className="p-2 hover:bg-white/20 rounded-xl transition text-white"
> >
<X size={24} /> <X size={24} />
</button> </button>

View file

@ -19,6 +19,7 @@ interface QuizEditorProps {
showSaveButton?: boolean; showSaveButton?: boolean;
isSaving?: boolean; isSaving?: boolean;
defaultConfig?: GameConfig; defaultConfig?: GameConfig;
maxPlayersLimit?: number;
} }
export const QuizEditor: React.FC<QuizEditorProps> = ({ export const QuizEditor: React.FC<QuizEditorProps> = ({
@ -30,6 +31,7 @@ export const QuizEditor: React.FC<QuizEditorProps> = ({
showSaveButton = true, showSaveButton = true,
isSaving, isSaving,
defaultConfig, defaultConfig,
maxPlayersLimit = 150,
}) => { }) => {
const [quiz, setQuiz] = useState<Quiz>(initialQuiz); const [quiz, setQuiz] = useState<Quiz>(initialQuiz);
const [expandedId, setExpandedId] = useState<string | null>(null); const [expandedId, setExpandedId] = useState<string | null>(null);
@ -300,6 +302,7 @@ export const QuizEditor: React.FC<QuizEditorProps> = ({
onChange={handleConfigChange} onChange={handleConfigChange}
questionCount={quiz.questions.length} questionCount={quiz.questions.length}
compact={false} compact={false}
maxPlayersLimit={maxPlayersLimit}
/> />
</div> </div>
)} )}

View file

@ -108,7 +108,7 @@ services:
container_name: kaboot-backend container_name: kaboot-backend
restart: unless-stopped restart: unless-stopped
environment: environment:
NODE_ENV: production NODE_ENV: ${NODE_ENV:-production}
PORT: 3001 PORT: 3001
DATABASE_PATH: /data/kaboot.db DATABASE_PATH: /data/kaboot.db
ENCRYPTION_KEY: ${ENCRYPTION_KEY:?encryption key required} ENCRYPTION_KEY: ${ENCRYPTION_KEY:?encryption key required}

View file

@ -230,12 +230,16 @@ STRIPE_PRICE_ID_YEARLY=price_... # Yearly subscription price ID
3. **Configure Webhook**: 3. **Configure Webhook**:
- Go to [Developers > Webhooks](https://dashboard.stripe.com/webhooks) - Go to [Developers > Webhooks](https://dashboard.stripe.com/webhooks)
- Add endpoint: `https://your-domain.com/api/payments/webhook` - Add endpoint: `https://your-domain.com/api/payments/webhook`
- Select events: - Select events:
- `checkout.session.completed` - `checkout.session.completed`
- `customer.subscription.updated` - `customer.subscription.updated`
- `customer.subscription.deleted` - `customer.subscription.deleted`
- `invoice.paid` - `invoice.paid`
- `invoice.payment_failed` - `invoice.payment_failed`
- `refund.created`
- `refund.updated`
- `refund.failed`
- `charge.refunded`
- Copy the Signing Secret (starts with `whsec_`) - Copy the Signing Secret (starts with `whsec_`)
4. **Test with Stripe CLI** (optional, for local development): 4. **Test with Stripe CLI** (optional, for local development):
@ -243,6 +247,11 @@ STRIPE_PRICE_ID_YEARLY=price_... # Yearly subscription price ID
stripe listen --forward-to localhost:3001/api/payments/webhook stripe listen --forward-to localhost:3001/api/payments/webhook
``` ```
#### Refund Policy Notes
- Stripe does not enforce refund timing. The “7-day money-back guarantee” is a product policy.
- Use the refund events above to keep payment records in sync when refunds happen.
## Docker Compose Files ## Docker Compose Files
The project includes pre-configured compose files: The project includes pre-configured compose files:

View file

@ -1,6 +1,7 @@
import { useState, useEffect, useRef, useCallback } from 'react'; import { useState, useEffect, useRef, useCallback } from 'react';
import { useNavigate, useLocation } from 'react-router-dom'; import { useNavigate, useLocation } from 'react-router-dom';
import { useAuth } from 'react-oidc-context'; import { useAuth } from 'react-oidc-context';
import { useAuthenticatedFetch } from './useAuthenticatedFetch';
import { Quiz, Player, GameState, GameRole, NetworkMessage, AnswerOption, Question, GenerateQuizOptions, ProcessedDocument, GameConfig, DEFAULT_GAME_CONFIG, PointsBreakdown } from '../types'; import { Quiz, Player, GameState, GameRole, NetworkMessage, AnswerOption, Question, GenerateQuizOptions, ProcessedDocument, GameConfig, DEFAULT_GAME_CONFIG, PointsBreakdown } from '../types';
import { generateQuiz } from '../services/geminiService'; import { generateQuiz } from '../services/geminiService';
import { QUESTION_TIME, QUESTION_TIME_MS, PLAYER_COLORS, calculatePointsWithBreakdown, getPlayerRank } from '../constants'; import { QUESTION_TIME, QUESTION_TIME_MS, PLAYER_COLORS, calculatePointsWithBreakdown, getPlayerRank } from '../constants';
@ -96,10 +97,11 @@ const clearDraftQuiz = () => {
sessionStorage.removeItem(DRAFT_QUIZ_KEY); sessionStorage.removeItem(DRAFT_QUIZ_KEY);
}; };
export const useGame = () => { export const useGame = (defaultGameConfig?: GameConfig) => {
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const auth = useAuth(); const auth = useAuth();
const { authFetch } = useAuthenticatedFetch();
const [role, setRole] = useState<GameRole>('HOST'); const [role, setRole] = useState<GameRole>('HOST');
const [gameState, setGameState] = useState<GameState>('LANDING'); const [gameState, setGameState] = useState<GameState>('LANDING');
@ -120,7 +122,9 @@ export const useGame = () => {
const [currentPlayerName, setCurrentPlayerName] = useState<string | null>(null); const [currentPlayerName, setCurrentPlayerName] = useState<string | null>(null);
const [pendingQuizToSave, setPendingQuizToSave] = useState<{ quiz: Quiz; topic: string } | null>(null); const [pendingQuizToSave, setPendingQuizToSave] = useState<{ quiz: Quiz; topic: string } | null>(null);
const [sourceQuizId, setSourceQuizId] = useState<string | null>(null); const [sourceQuizId, setSourceQuizId] = useState<string | null>(null);
const [gameConfig, setGameConfig] = useState<GameConfig>(DEFAULT_GAME_CONFIG); const [gameConfig, setGameConfig] = useState<GameConfig>(defaultGameConfig || DEFAULT_GAME_CONFIG);
const defaultConfigRef = useRef<GameConfig>(defaultGameConfig || DEFAULT_GAME_CONFIG);
const [subscriptionAccessType, setSubscriptionAccessType] = useState<'group' | 'subscription' | 'none'>('none');
const [firstCorrectPlayerId, setFirstCorrectPlayerId] = useState<string | null>(null); const [firstCorrectPlayerId, setFirstCorrectPlayerId] = useState<string | null>(null);
const [hostSecret, setHostSecret] = useState<string | null>(null); const [hostSecret, setHostSecret] = useState<string | null>(null);
const [isReconnecting, setIsReconnecting] = useState(false); const [isReconnecting, setIsReconnecting] = useState(false);
@ -149,6 +153,10 @@ export const useGame = () => {
useEffect(() => { currentQuestionIndexRef.current = currentQuestionIndex; }, [currentQuestionIndex]); useEffect(() => { currentQuestionIndexRef.current = currentQuestionIndex; }, [currentQuestionIndex]);
useEffect(() => { quizRef.current = quiz; }, [quiz]); useEffect(() => { quizRef.current = quiz; }, [quiz]);
useEffect(() => { gameConfigRef.current = gameConfig; }, [gameConfig]); useEffect(() => { gameConfigRef.current = gameConfig; }, [gameConfig]);
useEffect(() => {
if (!defaultGameConfig) return;
defaultConfigRef.current = defaultGameConfig;
}, [defaultGameConfig]);
useEffect(() => { gamePinRef.current = gamePin; }, [gamePin]); useEffect(() => { gamePinRef.current = gamePin; }, [gamePin]);
useEffect(() => { hostSecretRef.current = hostSecret; }, [hostSecret]); useEffect(() => { hostSecretRef.current = hostSecret; }, [hostSecret]);
useEffect(() => { gameStateRef.current = gameState; }, [gameState]); useEffect(() => { gameStateRef.current = gameState; }, [gameState]);
@ -156,6 +164,38 @@ export const useGame = () => {
useEffect(() => { currentCorrectShapeRef.current = currentCorrectShape; }, [currentCorrectShape]); useEffect(() => { currentCorrectShapeRef.current = currentCorrectShape; }, [currentCorrectShape]);
useEffect(() => { presenterIdRef.current = presenterId; }, [presenterId]); useEffect(() => { presenterIdRef.current = presenterId; }, [presenterId]);
useEffect(() => {
let isMounted = true;
if (auth.isLoading || !auth.isAuthenticated) {
setSubscriptionAccessType('none');
return () => {
isMounted = false;
};
}
const fetchSubscriptionAccess = async () => {
try {
const response = await authFetch('/api/payments/status');
if (!response.ok) return;
const data = await response.json();
if (!isMounted) return;
setSubscriptionAccessType(
data.accessType === 'group' ? 'group' : (data.accessType === 'subscription' ? 'subscription' : 'none')
);
} catch {
if (!isMounted) return;
setSubscriptionAccessType('none');
}
};
fetchSubscriptionAccess();
return () => {
isMounted = false;
};
}, [auth.isLoading, auth.isAuthenticated, authFetch]);
const isInitializingFromUrl = useRef(false); const isInitializingFromUrl = useRef(false);
useEffect(() => { useEffect(() => {
@ -723,10 +763,12 @@ export const useGame = () => {
}; };
const generatedQuiz = await generateQuiz(generateOptions); const generatedQuiz = await generateQuiz(generateOptions);
const withDefaultConfig = { ...generatedQuiz, config: defaultConfigRef.current };
const saveLabel = options.topic || options.files?.map(f => f.name).join(', ') || ''; const saveLabel = options.topic || options.files?.map(f => f.name).join(', ') || '';
setPendingQuizToSave({ quiz: generatedQuiz, topic: saveLabel }); setPendingQuizToSave({ quiz: withDefaultConfig, topic: saveLabel });
setQuiz(generatedQuiz); setQuiz(withDefaultConfig);
storeDraftQuiz({ quiz: generatedQuiz, topic: saveLabel }); setGameConfig(defaultConfigRef.current);
storeDraftQuiz({ quiz: withDefaultConfig, topic: saveLabel });
setGameState('EDITING'); setGameState('EDITING');
} catch (e) { } catch (e) {
const message = e instanceof Error ? e.message : "Failed to generate quiz."; const message = e instanceof Error ? e.message : "Failed to generate quiz.";
@ -741,6 +783,7 @@ export const useGame = () => {
const startManualCreation = () => { const startManualCreation = () => {
setRole('HOST'); setRole('HOST');
setGameConfig(defaultConfigRef.current);
setGameState('CREATING'); setGameState('CREATING');
}; };
@ -758,6 +801,7 @@ export const useGame = () => {
const loadSavedQuiz = (savedQuiz: Quiz, quizId?: string) => { const loadSavedQuiz = (savedQuiz: Quiz, quizId?: string) => {
setRole('HOST'); setRole('HOST');
setQuiz(savedQuiz); setQuiz(savedQuiz);
setGameConfig(savedQuiz.config || defaultConfigRef.current);
setSourceQuizId(quizId || null); setSourceQuizId(quizId || null);
storeDraftQuiz({ quiz: savedQuiz, sourceQuizId: quizId }); storeDraftQuiz({ quiz: savedQuiz, sourceQuizId: quizId });
setGameState('EDITING'); setGameState('EDITING');
@ -819,6 +863,20 @@ export const useGame = () => {
assignedName = generateRandomName(); assignedName = generateRandomName();
} }
if (!reconnectedPlayer) {
const configuredMax = gameConfigRef.current.maxPlayers || 0;
const cap = subscriptionAccessType === 'subscription' || subscriptionAccessType === 'group' ? 150 : 10;
const effectiveMax = Math.min(configuredMax || cap, cap);
const realPlayersCount = playersRef.current.filter(p => p.id !== 'host').length;
if (realPlayersCount >= effectiveMax) {
conn.send({ type: 'JOIN_DENIED', payload: { reason: 'Lobby is full.' } });
connectionsRef.current.delete(conn.peer);
conn.close();
return;
}
}
let updatedPlayers = playersRef.current; let updatedPlayers = playersRef.current;
let newPlayer: Player | null = null; let newPlayer: Player | null = null;
@ -1294,6 +1352,12 @@ export const useGame = () => {
}; };
const handleClientData = (data: NetworkMessage) => { const handleClientData = (data: NetworkMessage) => {
if (data.type === 'JOIN_DENIED') {
setError(data.payload.reason || 'Unable to join the game.');
setGamePin(null);
setGameState('LANDING');
return;
}
if (data.type === 'WELCOME') { if (data.type === 'WELCOME') {
const payload = data.payload; const payload = data.payload;
console.log('[CLIENT] Received WELCOME:', { console.log('[CLIENT] Received WELCOME:', {

View file

@ -77,6 +77,7 @@ interface SubscriptionInfo {
interface UseUserPreferencesReturn { interface UseUserPreferencesReturn {
preferences: UserPreferences; preferences: UserPreferences;
hasAIAccess: boolean; hasAIAccess: boolean;
hasEarlyAccess: boolean;
subscription: SubscriptionInfo | null; subscription: SubscriptionInfo | null;
loading: boolean; loading: boolean;
saving: boolean; saving: boolean;
@ -89,6 +90,7 @@ export const useUserPreferences = (): UseUserPreferencesReturn => {
const { authFetch, isAuthenticated } = useAuthenticatedFetch(); const { authFetch, isAuthenticated } = useAuthenticatedFetch();
const [preferences, setPreferences] = useState<UserPreferences>(DEFAULT_PREFERENCES); const [preferences, setPreferences] = useState<UserPreferences>(DEFAULT_PREFERENCES);
const [hasAIAccess, setHasAIAccess] = useState(false); const [hasAIAccess, setHasAIAccess] = useState(false);
const [hasEarlyAccess, setHasEarlyAccess] = useState(false);
const [subscription, setSubscription] = useState<SubscriptionInfo | null>(null); const [subscription, setSubscription] = useState<SubscriptionInfo | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
@ -121,6 +123,7 @@ export const useUserPreferences = (): UseUserPreferencesReturn => {
writeLocalApiKeys(mergedLocalKeys); writeLocalApiKeys(mergedLocalKeys);
setPreferences(mergedPrefs); setPreferences(mergedPrefs);
setHasAIAccess(data.hasAIAccess || false); setHasAIAccess(data.hasAIAccess || false);
setHasEarlyAccess(data.hasEarlyAccess || false);
applyColorScheme(mergedPrefs.colorScheme); applyColorScheme(mergedPrefs.colorScheme);
const backendUrl = import.meta.env.VITE_BACKEND_URL || 'http://localhost:3001'; const backendUrl = import.meta.env.VITE_BACKEND_URL || 'http://localhost:3001';
@ -194,6 +197,7 @@ export const useUserPreferences = (): UseUserPreferencesReturn => {
return { return {
preferences, preferences,
hasAIAccess, hasAIAccess,
hasEarlyAccess,
subscription, subscription,
loading, loading,
saving, saving,

View file

@ -28,6 +28,7 @@ app.use(helmet({
})); }));
const isDev = process.env.NODE_ENV !== 'production'; const isDev = process.env.NODE_ENV !== 'production';
const isTest = process.env.NODE_ENV === 'test';
const apiLimiter = rateLimit({ const apiLimiter = rateLimit({
windowMs: 15 * 60 * 1000, windowMs: 15 * 60 * 1000,
@ -35,7 +36,7 @@ const apiLimiter = rateLimit({
standardHeaders: true, standardHeaders: true,
legacyHeaders: false, legacyHeaders: false,
message: { error: 'Too many requests, please try again later.' }, message: { error: 'Too many requests, please try again later.' },
skip: (req) => req.path === '/health', skip: (req) => isTest || req.path === '/health',
}); });
const corsOrigins = (process.env.CORS_ORIGIN || 'http://localhost:5173').split(',').map(o => o.trim()); const corsOrigins = (process.env.CORS_ORIGIN || 'http://localhost:5173').split(',').map(o => o.trim());

View file

@ -112,7 +112,7 @@ export function requireAIAccess(
} }
(req as any).aiAccessInfo = { (req as any).aiAccessInfo = {
accessType: groups.includes('kaboot-ai-access') ? 'group' : 'subscription', accessType: groups.includes('kaboot-ai-access') ? 'group' : (result.accessType || 'none'),
remaining: result.remaining, remaining: result.remaining,
} as AIAccessInfo; } as AIAccessInfo;

View file

@ -6,12 +6,14 @@ import { randomBytes } from 'crypto';
const router = Router(); const router = Router();
const isDev = process.env.NODE_ENV !== 'production'; const isDev = process.env.NODE_ENV !== 'production';
const isTest = process.env.NODE_ENV === 'test';
const gameCreationLimiter = rateLimit({ const gameCreationLimiter = rateLimit({
windowMs: 15 * 60 * 1000, windowMs: 15 * 60 * 1000,
max: isDev ? 100 : 30, max: isDev ? 100 : 30,
standardHeaders: true, standardHeaders: true,
legacyHeaders: false, legacyHeaders: false,
message: { error: 'Too many game creations, please try again later.' }, message: { error: 'Too many game creations, please try again later.' },
skip: () => isTest,
}); });
const gameLookupLimiter = rateLimit({ const gameLookupLimiter = rateLimit({
@ -20,6 +22,7 @@ const gameLookupLimiter = rateLimit({
standardHeaders: true, standardHeaders: true,
legacyHeaders: false, legacyHeaders: false,
message: { error: 'Too many requests, please try again later.' }, message: { error: 'Too many requests, please try again later.' },
skip: () => isTest,
}); });
const SESSION_TTL_MINUTES = parseInt(process.env.GAME_SESSION_TTL_MINUTES || '5', 10); const SESSION_TTL_MINUTES = parseInt(process.env.GAME_SESSION_TTL_MINUTES || '5', 10);

View file

@ -1,13 +1,49 @@
import { Router, Response } from 'express'; import { Router, Response } from 'express';
import { GoogleGenAI, Type, createUserContent, createPartFromUri } from '@google/genai'; import { GoogleGenAI, Type, createUserContent, createPartFromUri } from '@google/genai';
import { requireAuth, AuthenticatedRequest, requireAIAccess } from '../middleware/auth.js'; import { requireAuth, AuthenticatedRequest, requireAIAccess } from '../middleware/auth.js';
import { incrementGenerationCount, GENERATION_LIMIT } from '../services/stripe.js'; import { incrementGenerationCount, GENERATION_LIMIT, FREE_TIER_LIMIT } from '../services/stripe.js';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import { buildQuizPrompt } from '../shared/quizPrompt.js';
const router = Router(); const router = Router();
const GEMINI_API_KEY = process.env.GEMINI_API_KEY; const GEMINI_API_KEY = process.env.GEMINI_API_KEY;
const DEFAULT_MODEL = 'gemini-2.5-flash-preview-05-20'; const DEFAULT_MODEL = 'gemini-3-flash-preview';
const MAX_CONCURRENT_GENERATIONS = 2;
type GenerationJob<T> = {
priority: number;
run: () => Promise<T>;
resolve: (value: T) => void;
reject: (error: unknown) => void;
};
const generationQueue: GenerationJob<any>[] = [];
let activeGenerations = 0;
const processGenerationQueue = () => {
while (activeGenerations < MAX_CONCURRENT_GENERATIONS && generationQueue.length > 0) {
const next = generationQueue.shift();
if (!next) return;
activeGenerations += 1;
next
.run()
.then((result) => next.resolve(result))
.catch((err) => next.reject(err))
.finally(() => {
activeGenerations -= 1;
processGenerationQueue();
});
}
};
const enqueueGeneration = <T,>(priority: number, run: () => Promise<T>): Promise<T> => {
return new Promise((resolve, reject) => {
generationQueue.push({ priority, run, resolve, reject });
generationQueue.sort((a, b) => b.priority - a.priority);
processGenerationQueue();
});
};
interface GenerateRequest { interface GenerateRequest {
topic: string; topic: string;
@ -49,18 +85,8 @@ const QUIZ_SCHEMA = {
required: ["title", "questions"] required: ["title", "questions"]
}; };
function buildPrompt(topic: string, questionCount: number, hasDocuments: boolean): string { const buildPrompt = (topic: string, questionCount: number, hasDocuments: boolean): string =>
const baseInstructions = `Create ${questionCount} engaging multiple-choice questions. Each question must have exactly 4 options, and exactly one correct answer. Vary the difficulty. buildQuizPrompt({ topic, questionCount, hasDocuments });
IMPORTANT: For each option's reason, write as if you are directly explaining facts - never reference "the document", "the text", "the material", or "the source". Write explanations as standalone factual statements.`;
if (hasDocuments) {
const topicContext = topic ? ` Focus on aspects related to "${topic}".` : '';
return `Generate a quiz based on the provided content.${topicContext}\n\n${baseInstructions}`;
}
return `Generate a trivia quiz about "${topic}".\n\n${baseInstructions}`;
}
function shuffleArray<T>(array: T[]): T[] { function shuffleArray<T>(array: T[]): T[] {
const shuffled = [...array]; const shuffled = [...array];
@ -121,60 +147,76 @@ router.post('/', requireAuth, requireAIAccess, async (req: AuthenticatedRequest,
} }
try { try {
const ai = new GoogleGenAI({ apiKey: GEMINI_API_KEY }); const accessInfo = (req as any).aiAccessInfo as { accessType?: 'group' | 'subscription' | 'none'; remaining?: number } | undefined;
const hasDocuments = documents.length > 0; if (accessInfo?.accessType === 'none' && documents.length > 1) {
const prompt = buildPrompt(topic, questionCount, hasDocuments); res.status(403).json({ error: 'Free plan allows a single document per generation.' });
let contents: any;
if (hasDocuments) {
const parts: any[] = [];
for (const doc of documents) {
if (doc.type === 'native' && doc.mimeType) {
const buffer = Buffer.from(doc.content, 'base64');
const blob = new Blob([buffer], { type: doc.mimeType });
const uploadedFile = await ai.files.upload({
file: blob,
config: { mimeType: doc.mimeType }
});
if (uploadedFile.uri && uploadedFile.mimeType) {
parts.push(createPartFromUri(uploadedFile.uri, uploadedFile.mimeType));
}
} else if (doc.type === 'text') {
parts.push({ text: doc.content });
}
}
parts.push({ text: prompt });
contents = createUserContent(parts);
} else {
contents = prompt;
}
const response = await ai.models.generateContent({
model: DEFAULT_MODEL,
contents,
config: {
responseMimeType: "application/json",
responseSchema: QUIZ_SCHEMA
}
});
if (!response.text) {
res.status(500).json({ error: 'Failed to generate quiz content' });
return; return;
} }
const data = JSON.parse(response.text); const priority = accessInfo?.accessType === 'subscription' || accessInfo?.accessType === 'group' ? 1 : 0;
const quiz = transformToQuiz(data); const queuedAt = Date.now();
const quiz = await enqueueGeneration(priority, async () => {
const ai = new GoogleGenAI({ apiKey: GEMINI_API_KEY });
const hasDocuments = documents.length > 0;
const prompt = buildPrompt(topic, questionCount, hasDocuments);
let contents: any;
if (hasDocuments) {
const parts: any[] = [];
for (const doc of documents) {
if (doc.type === 'native' && doc.mimeType) {
const buffer = Buffer.from(doc.content, 'base64');
const blob = new Blob([buffer], { type: doc.mimeType });
const uploadedFile = await ai.files.upload({
file: blob,
config: { mimeType: doc.mimeType }
});
if (uploadedFile.uri && uploadedFile.mimeType) {
parts.push(createPartFromUri(uploadedFile.uri, uploadedFile.mimeType));
}
} else if (doc.type === 'text') {
parts.push({ text: doc.content });
}
}
parts.push({ text: prompt });
contents = createUserContent(parts);
} else {
contents = prompt;
}
const response = await ai.models.generateContent({
model: DEFAULT_MODEL,
contents,
config: {
responseMimeType: "application/json",
responseSchema: QUIZ_SCHEMA
}
});
if (!response.text) {
throw new Error('Failed to generate quiz content');
}
const data = JSON.parse(response.text);
return transformToQuiz(data);
});
const waitMs = Date.now() - queuedAt;
if (waitMs > 0) {
console.log('AI generation queued', { waitMs, priority });
}
const groups = req.user!.groups || []; const groups = req.user!.groups || [];
if (!groups.includes('kaboot-ai-access')) { if (!groups.includes('kaboot-ai-access')) {
const newCount = incrementGenerationCount(req.user!.sub); const newCount = incrementGenerationCount(req.user!.sub);
const remaining = Math.max(0, GENERATION_LIMIT - newCount); const limit = accessInfo?.accessType === 'subscription' ? GENERATION_LIMIT : FREE_TIER_LIMIT;
const remaining = Math.max(0, limit - newCount);
res.setHeader('X-Generations-Remaining', remaining.toString()); res.setHeader('X-Generations-Remaining', remaining.toString());
} }

View file

@ -12,12 +12,22 @@ import {
updateSubscriptionStatus, updateSubscriptionStatus,
resetGenerationCount, resetGenerationCount,
recordPayment, recordPayment,
updatePaymentStatus,
GENERATION_LIMIT, GENERATION_LIMIT,
} from '../services/stripe.js'; } from '../services/stripe.js';
const router = Router(); const router = Router();
const STRIPE_WEBHOOK_SECRET = process.env.STRIPE_WEBHOOK_SECRET; const STRIPE_WEBHOOK_SECRET = process.env.STRIPE_WEBHOOK_SECRET;
function requireAdmin(req: AuthenticatedRequest, res: Response): boolean {
const groups = req.user?.groups || [];
if (!groups.includes('kaboot-admin')) {
res.status(403).json({ error: 'Admin access required' });
return false;
}
return true;
}
router.get('/config', (_req: Request, res: Response) => { router.get('/config', (_req: Request, res: Response) => {
res.json({ res.json({
configured: isStripeConfigured(), configured: isStripeConfigured(),
@ -92,15 +102,28 @@ router.post('/portal', requireAuth, async (req: AuthenticatedRequest, res: Respo
} }
const userId = req.user!.sub; const userId = req.user!.sub;
const { returnUrl } = req.body; const { returnUrl, cancelSubscriptionId, afterCompletionUrl } = req.body;
if (!returnUrl) { if (!returnUrl) {
res.status(400).json({ error: 'returnUrl is required' }); res.status(400).json({ error: 'returnUrl is required' });
return; return;
} }
if (cancelSubscriptionId && typeof cancelSubscriptionId !== 'string') {
res.status(400).json({ error: 'cancelSubscriptionId must be a string' });
return;
}
if (afterCompletionUrl && typeof afterCompletionUrl !== 'string') {
res.status(400).json({ error: 'afterCompletionUrl must be a string' });
return;
}
try { try {
const session = await createPortalSession(userId, returnUrl); const session = await createPortalSession(userId, returnUrl, {
cancelSubscriptionId,
afterCompletionUrl,
});
res.json({ url: session.url }); res.json({ url: session.url });
} catch (err: any) { } catch (err: any) {
console.error('Portal session error:', err); console.error('Portal session error:', err);
@ -108,6 +131,49 @@ router.post('/portal', requireAuth, async (req: AuthenticatedRequest, res: Respo
} }
}); });
router.post('/refund', requireAuth, async (req: AuthenticatedRequest, res: Response) => {
if (!isStripeConfigured()) {
res.status(503).json({ error: 'Payments are not configured' });
return;
}
if (!requireAdmin(req, res)) {
return;
}
const { paymentIntentId, chargeId, amount, reason } = req.body as {
paymentIntentId?: string;
chargeId?: string;
amount?: number;
reason?: 'duplicate' | 'fraudulent' | 'requested_by_customer';
};
if (!paymentIntentId && !chargeId) {
res.status(400).json({ error: 'paymentIntentId or chargeId is required' });
return;
}
if (amount !== undefined && (typeof amount !== 'number' || amount <= 0)) {
res.status(400).json({ error: 'amount must be a positive number if provided' });
return;
}
try {
const stripe = getStripe();
const refund = await stripe.refunds.create({
payment_intent: paymentIntentId,
charge: chargeId,
amount,
reason,
});
res.json({ refund });
} catch (err: any) {
console.error('Refund error:', err);
res.status(500).json({ error: err.message || 'Failed to create refund' });
}
});
function getUserIdFromCustomer(customerId: string): string | null { function getUserIdFromCustomer(customerId: string): string | null {
const user = db.prepare('SELECT id FROM users WHERE stripe_customer_id = ?').get(customerId) as { id: string } | undefined; const user = db.prepare('SELECT id FROM users WHERE stripe_customer_id = ?').get(customerId) as { id: string } | undefined;
return user?.id || null; return user?.id || null;
@ -228,6 +294,30 @@ async function handleInvoicePaymentFailed(invoice: Stripe.Invoice): Promise<void
); );
} }
async function handleRefundCreated(refund: Stripe.Refund): Promise<void> {
const paymentIntentId = refund.payment_intent ? String(refund.payment_intent) : null;
const updated = updatePaymentStatus(paymentIntentId, null, 'refunded', 'Refund created');
if (!updated) {
console.warn('Refund created but no matching payment record found:', refund.id);
return;
}
console.log('Recorded refund for payment intent:', paymentIntentId || refund.id);
}
async function handleChargeRefunded(charge: Stripe.Charge): Promise<void> {
const paymentIntentId = charge.payment_intent ? String(charge.payment_intent) : null;
const updated = updatePaymentStatus(paymentIntentId, null, 'refunded', 'Charge refunded');
if (!updated) {
console.warn('Charge refunded but no matching payment record found:', charge.id);
return;
}
console.log('Recorded charge refund for payment intent:', paymentIntentId || charge.id);
}
export const webhookHandler = async (req: Request, res: Response): Promise<void> => { export const webhookHandler = async (req: Request, res: Response): Promise<void> => {
if (!STRIPE_WEBHOOK_SECRET) { if (!STRIPE_WEBHOOK_SECRET) {
res.status(503).json({ error: 'Webhook secret not configured' }); res.status(503).json({ error: 'Webhook secret not configured' });
@ -268,6 +358,12 @@ export const webhookHandler = async (req: Request, res: Response): Promise<void>
case 'invoice.payment_failed': case 'invoice.payment_failed':
await handleInvoicePaymentFailed(event.data.object as Stripe.Invoice); await handleInvoicePaymentFailed(event.data.object as Stripe.Invoice);
break; break;
case 'refund.created':
await handleRefundCreated(event.data.object as Stripe.Refund);
break;
case 'charge.refunded':
await handleChargeRefunded(event.data.object as Stripe.Charge);
break;
default: default:
console.log(`Unhandled event type: ${event.type}`); console.log(`Unhandled event type: ${event.type}`);
} }

View file

@ -12,6 +12,8 @@ interface GameConfig {
shuffleQuestions: boolean; shuffleQuestions: boolean;
shuffleAnswers: boolean; shuffleAnswers: boolean;
hostParticipates: boolean; hostParticipates: boolean;
randomNamesEnabled?: boolean;
maxPlayers?: number;
streakBonusEnabled: boolean; streakBonusEnabled: boolean;
streakThreshold: number; streakThreshold: number;
streakMultiplier: number; streakMultiplier: number;
@ -63,7 +65,8 @@ router.get('/', (req: AuthenticatedRequest, res: Response) => {
router.get('/:id', (req: AuthenticatedRequest, res: Response) => { router.get('/:id', (req: AuthenticatedRequest, res: Response) => {
const quiz = db.prepare(` const quiz = db.prepare(`
SELECT id, title, source, ai_topic as aiTopic, game_config as gameConfig, created_at as createdAt, updated_at as updatedAt SELECT id, title, source, ai_topic as aiTopic, game_config as gameConfig, share_token as shareToken, is_shared as isShared,
created_at as createdAt, updated_at as updatedAt
FROM quizzes FROM quizzes
WHERE id = ? AND user_id = ? WHERE id = ? AND user_id = ?
`).get(req.params.id, req.user!.sub) as Record<string, unknown> | undefined; `).get(req.params.id, req.user!.sub) as Record<string, unknown> | undefined;
@ -108,6 +111,7 @@ router.get('/:id', (req: AuthenticatedRequest, res: Response) => {
res.json({ res.json({
...quiz, ...quiz,
isShared: Boolean(quiz.isShared),
gameConfig: parsedConfig, gameConfig: parsedConfig,
questions: questionsWithOptions, questions: questionsWithOptions,
}); });

View file

@ -1,7 +1,8 @@
import { Router } from 'express'; import { Router } from 'express';
import multer from 'multer'; import multer from 'multer';
import { processDocument, SUPPORTED_TYPES, normalizeMimeType } from '../services/documentParser.js'; import { processDocument, SUPPORTED_TYPES, normalizeMimeType } from '../services/documentParser.js';
import { requireAuth } from '../middleware/auth.js'; import { requireAuth, AuthenticatedRequest } from '../middleware/auth.js';
import { getSubscriptionStatus } from '../services/stripe.js';
const router = Router(); const router = Router();
@ -24,13 +25,23 @@ const upload = multer({
} }
}); });
router.post('/', upload.single('document'), async (req, res) => { router.post('/', upload.single('document'), async (req: AuthenticatedRequest, res) => {
try { try {
if (!req.file) { if (!req.file) {
return res.status(400).json({ error: 'No file uploaded' }); return res.status(400).json({ error: 'No file uploaded' });
} }
const useOcr = req.body?.useOcr === 'true' || req.body?.useOcr === true; const useOcr = req.body?.useOcr === 'true' || req.body?.useOcr === true;
if (useOcr) {
const groups = req.user?.groups || [];
const hasGroupAccess = groups.includes('kaboot-ai-access');
const status = req.user ? getSubscriptionStatus(req.user.sub) : null;
const hasSubscriptionAccess = status?.status === 'active';
if (!hasGroupAccess && !hasSubscriptionAccess) {
return res.status(403).json({ error: 'OCR is available to Pro subscribers only.' });
}
}
const normalizedMime = normalizeMimeType(req.file.mimetype, req.file.originalname); const normalizedMime = normalizeMimeType(req.file.mimetype, req.file.originalname);
const processed = await processDocument(req.file.buffer, normalizedMime, { useOcr }); const processed = await processDocument(req.file.buffer, normalizedMime, { useOcr });

View file

@ -44,6 +44,7 @@ router.get('/me', (req: AuthenticatedRequest, res: Response) => {
const groups = req.user!.groups || []; const groups = req.user!.groups || [];
const hasAIAccess = groups.includes('kaboot-ai-access'); const hasAIAccess = groups.includes('kaboot-ai-access');
const hasEarlyAccess = groups.includes('kaboot-early-access');
if (!row) { if (!row) {
res.json({ res.json({
@ -54,6 +55,7 @@ router.get('/me', (req: AuthenticatedRequest, res: Response) => {
defaultGameConfig: null, defaultGameConfig: null,
colorScheme: 'blue', colorScheme: 'blue',
hasAIAccess, hasAIAccess,
hasEarlyAccess,
createdAt: null, createdAt: null,
lastLogin: null, lastLogin: null,
isNew: true, isNew: true,
@ -69,6 +71,7 @@ router.get('/me', (req: AuthenticatedRequest, res: Response) => {
email: req.user!.email, email: req.user!.email,
displayName: req.user!.name, displayName: req.user!.name,
hasAIAccess, hasAIAccess,
hasEarlyAccess,
isNew: false isNew: false
}); });
}); });
@ -116,6 +119,7 @@ router.get('/me/preferences', (req: AuthenticatedRequest, res: Response) => {
const groups = req.user!.groups || []; const groups = req.user!.groups || [];
const hasAIAccess = groups.includes('kaboot-ai-access'); const hasAIAccess = groups.includes('kaboot-ai-access');
const hasEarlyAccess = groups.includes('kaboot-early-access');
res.json({ res.json({
colorScheme: user?.colorScheme || 'blue', colorScheme: user?.colorScheme || 'blue',
@ -127,12 +131,14 @@ router.get('/me/preferences', (req: AuthenticatedRequest, res: Response) => {
openAIApiKey: null, openAIApiKey: null,
openAIModel: user?.openAIModel || null, openAIModel: user?.openAIModel || null,
hasAIAccess, hasAIAccess,
hasEarlyAccess,
}); });
}); });
router.put('/me/preferences', (req: AuthenticatedRequest, res: Response) => { router.put('/me/preferences', (req: AuthenticatedRequest, res: Response) => {
const userSub = req.user!.sub; const userSub = req.user!.sub;
const { colorScheme, geminiModel, aiProvider, openRouterModel, openAIModel } = req.body; const { colorScheme, geminiModel, aiProvider, openRouterModel, openAIModel } = req.body;
const requestedProvider = aiProvider || 'gemini';
const upsertUser = db.prepare(` const upsertUser = db.prepare(`
INSERT INTO users (id, color_scheme, gemini_model, ai_provider, openrouter_model, openai_model, last_login) INSERT INTO users (id, color_scheme, gemini_model, ai_provider, openrouter_model, openai_model, last_login)
@ -150,12 +156,12 @@ router.put('/me/preferences', (req: AuthenticatedRequest, res: Response) => {
userSub, userSub,
colorScheme || 'blue', colorScheme || 'blue',
geminiModel || null, geminiModel || null,
aiProvider || 'gemini', requestedProvider,
openRouterModel || null, openRouterModel || null,
openAIModel || null, openAIModel || null,
colorScheme || 'blue', colorScheme || 'blue',
geminiModel || null, geminiModel || null,
aiProvider || 'gemini', requestedProvider,
openRouterModel || null, openRouterModel || null,
openAIModel || null openAIModel || null
); );

View file

@ -6,6 +6,7 @@ const STRIPE_PRICE_ID_MONTHLY = process.env.STRIPE_PRICE_ID_MONTHLY;
const STRIPE_PRICE_ID_YEARLY = process.env.STRIPE_PRICE_ID_YEARLY; const STRIPE_PRICE_ID_YEARLY = process.env.STRIPE_PRICE_ID_YEARLY;
export const GENERATION_LIMIT = 250; export const GENERATION_LIMIT = 250;
export const FREE_TIER_LIMIT = 5;
let stripeClient: Stripe | null = null; let stripeClient: Stripe | null = null;
@ -90,20 +91,58 @@ export async function createCheckoutSession(
export async function createPortalSession( export async function createPortalSession(
userId: string, userId: string,
returnUrl: string returnUrl: string,
options?: {
cancelSubscriptionId?: string;
afterCompletionUrl?: string;
}
): Promise<Stripe.BillingPortal.Session> { ): Promise<Stripe.BillingPortal.Session> {
const stripe = getStripe(); const stripe = getStripe();
const user = db.prepare('SELECT stripe_customer_id FROM users WHERE id = ?').get(userId) as { stripe_customer_id: string | null } | undefined; const user = db.prepare('SELECT stripe_customer_id, subscription_id FROM users WHERE id = ?').get(userId) as {
stripe_customer_id: string | null;
subscription_id: string | null;
} | undefined;
if (!user?.stripe_customer_id) { if (!user?.stripe_customer_id) {
throw new Error('No Stripe customer found for this user'); throw new Error('No Stripe customer found for this user');
} }
const session = await stripe.billingPortal.sessions.create({ if (options?.cancelSubscriptionId) {
if (!user.subscription_id) {
throw new Error('No active subscription found for this user');
}
if (user.subscription_id !== options.cancelSubscriptionId) {
throw new Error('Subscription mismatch for cancellation request');
}
}
const portalParams: Stripe.BillingPortal.SessionCreateParams = {
customer: user.stripe_customer_id, customer: user.stripe_customer_id,
return_url: returnUrl, return_url: returnUrl,
}); };
if (options?.cancelSubscriptionId) {
portalParams.flow_data = {
type: 'subscription_cancel',
subscription_cancel: {
subscription: options.cancelSubscriptionId,
},
...(options.afterCompletionUrl
? {
after_completion: {
type: 'redirect',
redirect: {
return_url: options.afterCompletionUrl,
},
},
}
: {}),
};
}
const session = await stripe.billingPortal.sessions.create(portalParams);
return session; return session;
} }
@ -116,6 +155,25 @@ export interface SubscriptionStatus {
generationsRemaining: number; generationsRemaining: number;
} }
function nextFreeTierResetDate(now: Date): Date {
const next = new Date(now);
next.setMonth(next.getMonth() + 1);
return next;
}
function ensureFreeTierReset(userId: string, resetDate: string | null): string {
const now = new Date();
const currentReset = resetDate ? new Date(resetDate) : null;
if (!currentReset || Number.isNaN(currentReset.getTime()) || currentReset <= now) {
const nextReset = nextFreeTierResetDate(now);
resetGenerationCount(userId, nextReset);
return nextReset.toISOString();
}
return currentReset.toISOString();
}
export function getSubscriptionStatus(userId: string): SubscriptionStatus { export function getSubscriptionStatus(userId: string): SubscriptionStatus {
const user = db.prepare(` const user = db.prepare(`
SELECT subscription_status, subscription_current_period_end, generation_count, generation_reset_date SELECT subscription_status, subscription_current_period_end, generation_count, generation_reset_date
@ -129,13 +187,20 @@ export function getSubscriptionStatus(userId: string): SubscriptionStatus {
const status = (user?.subscription_status || 'none') as SubscriptionStatus['status']; const status = (user?.subscription_status || 'none') as SubscriptionStatus['status'];
const generationCount = user?.generation_count || 0; const generationCount = user?.generation_count || 0;
const isSubscriptionActive = status === 'active';
const effectiveResetDate = !isSubscriptionActive
? ensureFreeTierReset(userId, user?.generation_reset_date || null)
: user?.subscription_current_period_end || null;
const generationLimit = isSubscriptionActive ? GENERATION_LIMIT : FREE_TIER_LIMIT;
return { return {
status, status,
currentPeriodEnd: user?.subscription_current_period_end || null, currentPeriodEnd: isSubscriptionActive ? (user?.subscription_current_period_end || null) : effectiveResetDate,
generationCount, generationCount,
generationLimit: GENERATION_LIMIT, generationLimit,
generationsRemaining: Math.max(0, GENERATION_LIMIT - generationCount), generationsRemaining: Math.max(0, generationLimit - generationCount),
}; };
} }
@ -196,31 +261,27 @@ export function incrementGenerationCount(userId: string): number {
return result?.generation_count || 1; return result?.generation_count || 1;
} }
export function canGenerate(userId: string, groups: string[]): { allowed: boolean; reason?: string; remaining?: number } { export function canGenerate(userId: string, groups: string[]): { allowed: boolean; reason?: string; remaining?: number; accessType?: 'group' | 'subscription' | 'none' } {
if (groups.includes('kaboot-ai-access')) { if (groups.includes('kaboot-ai-access')) {
return { allowed: true }; return { allowed: true, accessType: 'group' };
} }
const status = getSubscriptionStatus(userId); const status = getSubscriptionStatus(userId);
const accessType: 'subscription' | 'none' = status.status === 'active' ? 'subscription' : 'none';
if (status.status !== 'active') {
return {
allowed: false,
reason: 'No active subscription. Upgrade to access AI generation.',
};
}
if (status.generationsRemaining <= 0) { if (status.generationsRemaining <= 0) {
return { return {
allowed: false, allowed: false,
reason: 'Generation limit reached for this billing period.', reason: 'Generation limit reached for this billing period.',
remaining: 0, remaining: 0,
accessType,
}; };
} }
return { return {
allowed: true, allowed: true,
remaining: status.generationsRemaining, remaining: status.generationsRemaining,
accessType,
}; };
} }
@ -240,3 +301,34 @@ export function recordPayment(
VALUES (?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`).run(id, userId, paymentIntentId, invoiceId, amount, currency, status, description); `).run(id, userId, paymentIntentId, invoiceId, amount, currency, status, description);
} }
export function updatePaymentStatus(
paymentIntentId: string | null,
invoiceId: string | null,
status: string,
description?: string
): boolean {
const updateByPaymentIntent = paymentIntentId
? db.prepare(`
UPDATE payments
SET status = ?, description = COALESCE(?, description)
WHERE stripe_payment_intent_id = ?
`).run(status, description ?? null, paymentIntentId)
: null;
if (updateByPaymentIntent && updateByPaymentIntent.changes > 0) {
return true;
}
if (!invoiceId) {
return false;
}
const updateByInvoice = db.prepare(`
UPDATE payments
SET status = ?, description = COALESCE(?, description)
WHERE stripe_invoice_id = ?
`).run(status, description ?? null, invoiceId);
return updateByInvoice.changes > 0;
}

View file

@ -0,0 +1,58 @@
export interface QuizPromptOptions {
topic?: string;
questionCount: number;
hasDocuments: boolean;
includeJsonExample?: boolean;
}
export const TITLE_GUIDANCE = `Title guidance: Make the title fun and varied. Use playful phrasing, light wordplay, or energetic wording. Avoid templates like "The Ultimate ... Quiz" or "Test your knowledge". Keep it short (2-6 words) and specific to the topic, with clear reference to the original topic.`;
export const EXPLANATION_GUIDANCE = `IMPORTANT: For each option's reason, write as if you are directly explaining facts - never reference "the document", "the text", "the material", or "the source". Write explanations as standalone factual statements.`;
export const JSON_EXAMPLE_GUIDANCE = `You MUST respond with a single JSON object in this exact structure:
{
"title": "Quiz Title Here",
"questions": [
{
"text": "Question text here?",
"options": [
{ "text": "Option A", "isCorrect": false, "reason": "Explanation why this is wrong" },
{ "text": "Option B", "isCorrect": true, "reason": "Explanation why this is correct" },
{ "text": "Option C", "isCorrect": false, "reason": "Explanation why this is wrong" },
{ "text": "Option D", "isCorrect": false, "reason": "Explanation why this is wrong" }
]
}
]
}
There can be 2-6 options. Use 2 for true/false style questions.
Return ONLY valid JSON with no additional text before or after.`;
export function buildQuizPrompt(options: QuizPromptOptions): string {
const questionCount = options.questionCount || 10;
let baseInstructions = `Create ${questionCount} engaging multiple-choice questions. Each question must have 2-6 options, and exactly one correct answer. Vary the difficulty.
${TITLE_GUIDANCE}
${EXPLANATION_GUIDANCE}`;
if (options.includeJsonExample) {
baseInstructions += `
${JSON_EXAMPLE_GUIDANCE}`;
}
if (options.hasDocuments) {
const topicContext = options.topic
? ` Focus on aspects related to "${options.topic}".`
: '';
return `Generate a quiz based on the provided content.${topicContext}
${baseInstructions}`;
}
return `Generate a trivia quiz about "${options.topic}".
${baseInstructions}`;
}

View file

@ -2159,6 +2159,126 @@ console.log('\n=== Game Session Tests ===');
} }
}); });
await test('GET /api/payments/status returns expected generation limit for access type', async () => {
const { data } = await request('GET', '/api/payments/status');
const status = data as Record<string, unknown>;
const accessType = status.accessType as string;
const limit = status.generationLimit as number | null;
if (accessType === 'group') {
if (limit !== null) throw new Error('Expected null generationLimit for group access');
return;
}
if (accessType === 'subscription') {
if (limit !== 250) throw new Error(`Expected generationLimit 250, got ${limit}`);
return;
}
if (accessType === 'none') {
if (limit !== 5) throw new Error(`Expected free tier generationLimit 5, got ${limit}`);
return;
}
throw new Error(`Unexpected accessType: ${accessType}`);
});
await test('POST /api/generate with two documents blocks free tier users', async () => {
const statusRes = await request('GET', '/api/payments/status');
const status = statusRes.data as Record<string, unknown>;
const accessType = status.accessType as string;
const res = await fetch(`${API_URL}/api/generate`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${TOKEN}`,
},
body: JSON.stringify({
topic: 'Document content',
documents: [
{ type: 'text', content: 'Doc A' },
{ type: 'text', content: 'Doc B' }
],
questionCount: 2
}),
});
if (accessType === 'none') {
if (res.status !== 403) throw new Error(`Expected 403 for free tier, got ${res.status}`);
const data = await res.json();
if (!data.error || !String(data.error).toLowerCase().includes('document')) {
throw new Error('Expected document limit error message');
}
return;
}
const VALID_STATUSES = [200, 503];
if (!VALID_STATUSES.includes(res.status)) {
throw new Error(`Expected one of ${VALID_STATUSES.join('/')}, got ${res.status}`);
}
});
await test('POST /api/upload with OCR blocks free tier users', async () => {
const statusRes = await request('GET', '/api/payments/status');
const status = statusRes.data as Record<string, unknown>;
const accessType = status.accessType as string;
const formData = new FormData();
const blob = new Blob(['test content'], { type: 'text/plain' });
formData.append('document', blob, 'test.txt');
formData.append('useOcr', 'true');
const res = await fetch(`${API_URL}/api/upload`, {
method: 'POST',
headers: {
Authorization: `Bearer ${TOKEN}`,
},
body: formData,
});
if (accessType === 'none') {
if (res.status !== 403) throw new Error(`Expected 403 for free tier OCR, got ${res.status}`);
const data = await res.json();
if (!data.error || !String(data.error).toLowerCase().includes('ocr')) {
throw new Error('Expected OCR access error message');
}
return;
}
const VALID_STATUSES = [200, 400];
if (!VALID_STATUSES.includes(res.status)) {
throw new Error(`Expected one of ${VALID_STATUSES.join('/')}, got ${res.status}`);
}
});
console.log('\nPayments Refund Tests:');
await test('POST /api/payments/refund without token returns 401', async () => {
const res = await fetch(`${API_URL}/api/payments/refund`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ paymentIntentId: 'pi_test' }),
});
if (res.status !== 401) throw new Error(`Expected 401, got ${res.status}`);
});
await test('POST /api/payments/refund with non-admin returns 403 or 503', async () => {
const res = await fetch(`${API_URL}/api/payments/refund`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${TOKEN}`,
},
body: JSON.stringify({ paymentIntentId: 'pi_test' }),
});
const VALID_STATUSES = [403, 503];
if (!VALID_STATUSES.includes(res.status)) {
throw new Error(`Expected one of ${VALID_STATUSES.join('/')}, got ${res.status}`);
}
});
console.log('\n=== Quiz Sharing Tests ==='); console.log('\n=== Quiz Sharing Tests ===');
let shareTestQuizId: string | null = null; let shareTestQuizId: string | null = null;

View file

@ -36,14 +36,14 @@ async function getTokenWithServiceAccount(): Promise<string> {
const tokenUrl = `${AUTHENTIK_URL}/application/o/token/`; const tokenUrl = `${AUTHENTIK_URL}/application/o/token/`;
const params = new URLSearchParams({ const params = new URLSearchParams({
grant_type: 'client_credentials', grant_type: 'password',
client_id: CLIENT_ID, client_id: CLIENT_ID,
username: USERNAME, username: USERNAME,
password: PASSWORD, password: PASSWORD,
scope: 'openid profile email', scope: 'openid profile email',
}); });
console.log(` Trying client_credentials with username/password...`); console.log(` Trying password grant with username/password...`);
const response = await fetch(tokenUrl, { const response = await fetch(tokenUrl, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, headers: { 'Content-Type': 'application/x-www-form-urlencoded' },

View file

@ -4,10 +4,34 @@ import { dirname, join } from 'path';
const AUTHENTIK_URL = process.env.AUTHENTIK_URL || 'http://localhost:9000'; const AUTHENTIK_URL = process.env.AUTHENTIK_URL || 'http://localhost:9000';
const CLIENT_ID = process.env.CLIENT_ID || 'kaboot-spa'; const CLIENT_ID = process.env.CLIENT_ID || 'kaboot-spa';
const CLIENT_SECRET = process.env.CLIENT_SECRET || '';
const USERNAME = process.env.TEST_USERNAME || ''; const USERNAME = process.env.TEST_USERNAME || '';
const PASSWORD = process.env.TEST_PASSWORD || ''; const PASSWORD = process.env.TEST_PASSWORD || '';
const TEST_TOKEN = process.env.TEST_TOKEN || '';
async function getToken(): Promise<string> { async function getToken(): Promise<string> {
const tokenUrl = `${AUTHENTIK_URL}/application/o/token/`;
if (CLIENT_SECRET) {
const params = new URLSearchParams({
grant_type: 'client_credentials',
client_id: CLIENT_ID,
client_secret: CLIENT_SECRET,
scope: 'openid profile email',
});
const response = await fetch(tokenUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: params.toString(),
});
if (response.ok) {
const data = await response.json();
return data.access_token;
}
}
if (!USERNAME || !PASSWORD) { if (!USERNAME || !PASSWORD) {
throw new Error( throw new Error(
'TEST_USERNAME and TEST_PASSWORD must be set in .env.test\n' + 'TEST_USERNAME and TEST_PASSWORD must be set in .env.test\n' +
@ -15,9 +39,8 @@ async function getToken(): Promise<string> {
); );
} }
const tokenUrl = `${AUTHENTIK_URL}/application/o/token/`;
const params = new URLSearchParams({ const params = new URLSearchParams({
grant_type: 'client_credentials', grant_type: 'password',
client_id: CLIENT_ID, client_id: CLIENT_ID,
username: USERNAME, username: USERNAME,
password: PASSWORD, password: PASSWORD,
@ -65,14 +88,19 @@ async function main() {
console.log('Kaboot API Test Runner'); console.log('Kaboot API Test Runner');
console.log('======================\n'); console.log('======================\n');
console.log('Obtaining access token from Authentik...');
let token: string; let token: string;
try { if (TEST_TOKEN) {
token = await getToken(); console.log('Using TEST_TOKEN from environment.\n');
console.log(' Token obtained successfully.\n'); token = TEST_TOKEN;
} catch (error) { } else {
console.error(` Failed: ${error instanceof Error ? error.message : error}`); console.log('Obtaining access token from Authentik...');
process.exit(1); try {
token = await getToken();
console.log(' Token obtained successfully.\n');
} catch (error) {
console.error(` Failed: ${error instanceof Error ? error.message : error}`);
process.exit(1);
}
} }
console.log('Running API tests...\n'); console.log('Running API tests...\n');

View file

@ -1,5 +1,6 @@
import { GoogleGenAI, Type, createUserContent, createPartFromUri } from "@google/genai"; import { GoogleGenAI, Type, createUserContent, createPartFromUri } from "@google/genai";
import { Quiz, Question, AnswerOption, GenerateQuizOptions, ProcessedDocument, AIProvider } from "../types"; import { Quiz, Question, AnswerOption, GenerateQuizOptions, ProcessedDocument, AIProvider } from "../types";
import { buildQuizPrompt, JSON_EXAMPLE_GUIDANCE } from "../server/src/shared/quizPrompt";
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
const getGeminiClient = (apiKey?: string) => { const getGeminiClient = (apiKey?: string) => {
@ -50,45 +51,17 @@ const QUIZ_SCHEMA = {
function buildPrompt(options: GenerateQuizOptions, hasDocuments: boolean, includeJsonExample: boolean = false): string { function buildPrompt(options: GenerateQuizOptions, hasDocuments: boolean, includeJsonExample: boolean = false): string {
const questionCount = options.questionCount || 10; const questionCount = options.questionCount || 10;
let prompt = buildQuizPrompt({
let baseInstructions = `Create ${questionCount} engaging multiple-choice questions. Each question must have exactly 4 options, and exactly one correct answer. Vary the difficulty. topic: options.topic,
questionCount,
IMPORTANT: For each option's reason, write as if you are directly explaining facts - never reference "the document", "the text", "the material", or "the source". Write explanations as standalone factual statements.`; hasDocuments,
});
if (includeJsonExample) { if (includeJsonExample) {
baseInstructions += ` prompt += `\n\n${JSON_EXAMPLE_GUIDANCE}`;
You MUST respond with a single JSON object in this exact structure:
{
"title": "Quiz Title Here",
"questions": [
{
"text": "Question text here?",
"options": [
{ "text": "Option A", "isCorrect": false, "reason": "Explanation why this is wrong" },
{ "text": "Option B", "isCorrect": true, "reason": "Explanation why this is correct" },
{ "text": "Option C", "isCorrect": false, "reason": "Explanation why this is wrong" },
{ "text": "Option D", "isCorrect": false, "reason": "Explanation why this is wrong" }
]
}
]
}
Return ONLY valid JSON with no additional text before or after.`;
} }
if (hasDocuments) { return prompt;
const topicContext = options.topic
? ` Focus on aspects related to "${options.topic}".`
: '';
return `Generate a quiz based on the provided content.${topicContext}
${baseInstructions}`;
}
return `Generate a trivia quiz about "${options.topic}".
${baseInstructions}`;
} }
function shuffleArray<T>(array: T[]): T[] { function shuffleArray<T>(array: T[]): T[] {

View file

@ -71,6 +71,7 @@ export interface GameConfig {
shuffleAnswers: boolean; shuffleAnswers: boolean;
hostParticipates: boolean; hostParticipates: boolean;
randomNamesEnabled: boolean; randomNamesEnabled: boolean;
maxPlayers: number;
streakBonusEnabled: boolean; streakBonusEnabled: boolean;
streakThreshold: number; streakThreshold: number;
streakMultiplier: number; streakMultiplier: number;
@ -87,6 +88,7 @@ export const DEFAULT_GAME_CONFIG: GameConfig = {
shuffleAnswers: false, shuffleAnswers: false,
hostParticipates: true, hostParticipates: true,
randomNamesEnabled: false, randomNamesEnabled: false,
maxPlayers: 10,
streakBonusEnabled: false, streakBonusEnabled: false,
streakThreshold: 3, streakThreshold: 3,
streakMultiplier: 1.1, streakMultiplier: 1.1,
@ -185,6 +187,7 @@ export interface QuizExportFile {
export type NetworkMessage = export type NetworkMessage =
| { type: 'JOIN'; payload: { name: string; reconnect?: boolean; previousId?: string } } | { type: 'JOIN'; payload: { name: string; reconnect?: boolean; previousId?: string } }
| { type: 'JOIN_DENIED'; payload: { reason?: string } }
| { type: 'WELCOME'; payload: { | { type: 'WELCOME'; payload: {
playerId: string; playerId: string;
quizTitle: string; quizTitle: string;