Flesh out payment stuff
This commit is contained in:
parent
b0dcdd6438
commit
acfed861ab
27 changed files with 938 additions and 173 deletions
15
App.tsx
15
App.tsx
|
|
@ -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}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
# ═══════════════════════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
|
||||||
|
|
@ -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' && (
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -236,6 +236,10 @@ STRIPE_PRICE_ID_YEARLY=price_... # Yearly subscription price ID
|
||||||
- `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:
|
||||||
|
|
|
||||||
|
|
@ -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:', {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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());
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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,6 +147,16 @@ router.post('/', requireAuth, requireAIAccess, async (req: AuthenticatedRequest,
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const accessInfo = (req as any).aiAccessInfo as { accessType?: 'group' | 'subscription' | 'none'; remaining?: number } | undefined;
|
||||||
|
if (accessInfo?.accessType === 'none' && documents.length > 1) {
|
||||||
|
res.status(403).json({ error: 'Free plan allows a single document per generation.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const priority = accessInfo?.accessType === 'subscription' || accessInfo?.accessType === 'group' ? 1 : 0;
|
||||||
|
const queuedAt = Date.now();
|
||||||
|
|
||||||
|
const quiz = await enqueueGeneration(priority, async () => {
|
||||||
const ai = new GoogleGenAI({ apiKey: GEMINI_API_KEY });
|
const ai = new GoogleGenAI({ apiKey: GEMINI_API_KEY });
|
||||||
const hasDocuments = documents.length > 0;
|
const hasDocuments = documents.length > 0;
|
||||||
const prompt = buildPrompt(topic, questionCount, hasDocuments);
|
const prompt = buildPrompt(topic, questionCount, hasDocuments);
|
||||||
|
|
@ -164,17 +200,23 @@ router.post('/', requireAuth, requireAIAccess, async (req: AuthenticatedRequest,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.text) {
|
if (!response.text) {
|
||||||
res.status(500).json({ error: 'Failed to generate quiz content' });
|
throw new Error('Failed to generate quiz content');
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = JSON.parse(response.text);
|
const data = JSON.parse(response.text);
|
||||||
const quiz = transformToQuiz(data);
|
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());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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}`);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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 });
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
|
||||||
58
server/src/shared/quizPrompt.ts
Normal file
58
server/src/shared/quizPrompt.ts
Normal 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}`;
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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' },
|
||||||
|
|
|
||||||
|
|
@ -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,8 +88,12 @@ 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;
|
||||||
|
if (TEST_TOKEN) {
|
||||||
|
console.log('Using TEST_TOKEN from environment.\n');
|
||||||
|
token = TEST_TOKEN;
|
||||||
|
} else {
|
||||||
|
console.log('Obtaining access token from Authentik...');
|
||||||
try {
|
try {
|
||||||
token = await getToken();
|
token = await getToken();
|
||||||
console.log(' Token obtained successfully.\n');
|
console.log(' Token obtained successfully.\n');
|
||||||
|
|
@ -74,6 +101,7 @@ async function main() {
|
||||||
console.error(` Failed: ${error instanceof Error ? error.message : error}`);
|
console.error(` Failed: ${error instanceof Error ? error.message : error}`);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
console.log('Running API tests...\n');
|
console.log('Running API tests...\n');
|
||||||
const exitCode = await runTests(token);
|
const exitCode = await runTests(token);
|
||||||
|
|
|
||||||
|
|
@ -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[] {
|
||||||
|
|
|
||||||
3
types.ts
3
types.ts
|
|
@ -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;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue