From acfed861ab826c3caf43b5be040cda2b722b7c11 Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Thu, 22 Jan 2026 12:21:12 -0700 Subject: [PATCH] Flesh out payment stuff --- App.tsx | 17 ++- authentik/blueprints/kaboot-setup.yaml | 17 +++ components/ApiKeyModal.tsx | 124 +++++++++++++++++- components/DefaultConfigModal.tsx | 5 +- components/GameConfigPanel.tsx | 70 ++++++++++- components/Landing.tsx | 43 ++++++- components/PreferencesModal.tsx | 10 +- components/QuizEditor.tsx | 3 + docker-compose.yml | 2 +- docs/PRODUCTION.md | 21 +++- hooks/useGame.ts | 74 ++++++++++- hooks/useUserPreferences.ts | 4 + server/src/index.ts | 3 +- server/src/middleware/auth.ts | 2 +- server/src/routes/games.ts | 3 + server/src/routes/generate.ts | 168 +++++++++++++++---------- server/src/routes/payments.ts | 100 ++++++++++++++- server/src/routes/quizzes.ts | 6 +- server/src/routes/upload.ts | 15 ++- server/src/routes/users.ts | 10 +- server/src/services/stripe.ts | 138 ++++++++++++++++---- server/src/shared/quizPrompt.ts | 58 +++++++++ server/tests/api.test.ts | 120 ++++++++++++++++++ server/tests/get-token.ts | 4 +- server/tests/run-tests.ts | 46 +++++-- services/geminiService.ts | 43 ++----- types.ts | 5 +- 27 files changed, 938 insertions(+), 173 deletions(-) create mode 100644 server/src/shared/quizPrompt.ts diff --git a/App.tsx b/App.tsx index eee54cc..270be7b 100644 --- a/App.tsx +++ b/App.tsx @@ -1,9 +1,10 @@ import React, { useState } from 'react'; 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 { useQuizLibrary } from './hooks/useQuizLibrary'; import { useUserConfig } from './hooks/useUserConfig'; +import { useUserPreferences } from './hooks/useUserPreferences'; import { Landing } from './components/Landing'; import { Lobby } from './components/Lobby'; import { GameScreen } from './components/GameScreen'; @@ -50,8 +51,11 @@ const FloatingShapes = React.memo(() => { function App() { const auth = useAuth(); const location = useLocation(); + const navigate = useNavigate(); const { saveQuiz, updateQuiz, saving } = useQuizLibrary(); const { defaultConfig } = useUserConfig(); + const { subscription } = useUserPreferences(); + const maxPlayersLimit = (!subscription || subscription.accessType === 'none') ? 10 : 150; const [showSaveOptions, setShowSaveOptions] = useState(false); const [pendingEditedQuiz, setPendingEditedQuiz] = useState(null); const { @@ -99,7 +103,7 @@ function App() { sendAdvance, kickPlayer, leaveGame - } = useGame(); + } = useGame(defaultConfig); const handleSaveQuiz = async () => { if (!pendingQuizToSave) return; @@ -121,6 +125,7 @@ function App() { const source = pendingQuizToSave?.topic ? 'ai_generated' : 'manual'; const topic = pendingQuizToSave?.topic || undefined; await saveQuiz(editedQuiz, source, topic); + dismissSavePrompt(); } } }; @@ -130,6 +135,7 @@ function App() { await updateQuiz(sourceQuizId, pendingEditedQuiz); setShowSaveOptions(false); setPendingEditedQuiz(null); + dismissSavePrompt(); }; const handleSaveAsNew = async () => { @@ -137,6 +143,7 @@ function App() { await saveQuiz(pendingEditedQuiz, 'manual'); setShowSaveOptions(false); setPendingEditedQuiz(null); + dismissSavePrompt(); }; const currentQ = quiz?.questions[currentQuestionIndex]; @@ -153,8 +160,7 @@ function App() { const isPaymentCancelRoute = location.pathname === '/payment/cancel' && gameState === 'LANDING'; const navigateHome = () => { - window.history.replaceState({}, document.title, '/'); - window.location.reload(); + navigate('/', { replace: true }); }; if (isUpgradeRoute) { @@ -212,6 +218,7 @@ function App() { onBack={backFromEditor} showSaveButton={auth.isAuthenticated} defaultConfig={defaultConfig} + maxPlayersLimit={maxPlayersLimit} /> ) : null} @@ -362,4 +369,4 @@ function App() { ); } -export default App; \ No newline at end of file +export default App; diff --git a/authentik/blueprints/kaboot-setup.yaml b/authentik/blueprints/kaboot-setup.yaml index 106a170..0ff99c1 100644 --- a/authentik/blueprints/kaboot-setup.yaml +++ b/authentik/blueprints/kaboot-setup.yaml @@ -11,6 +11,9 @@ # - Kaboot OAuth2/OIDC Provider (public client) # - Kaboot Application # - kaboot-users Group +# - kaboot-ai-access Group +# - kaboot-admin Group +# - kaboot-early-access Group # - Enrollment flow with prompt, user write, and login stages # - Password complexity policy # - Test user (kaboottest) - for manual browser testing @@ -106,6 +109,20 @@ entries: attrs: 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 # ═══════════════════════════════════════════════════════════════════════════════ diff --git a/components/ApiKeyModal.tsx b/components/ApiKeyModal.tsx index 72b1c28..2afeffe 100644 --- a/components/ApiKeyModal.tsx +++ b/components/ApiKeyModal.tsx @@ -1,9 +1,18 @@ import React, { useState, useEffect, useRef } from 'react'; 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 { useAuthenticatedFetch } from '../hooks/useAuthenticatedFetch'; 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 { id: string; owned_by: string; @@ -16,6 +25,8 @@ interface ApiKeyModalProps { onSave: (prefs: Partial) => Promise; saving: boolean; hasAIAccess: boolean; + subscription?: SubscriptionInfo | null; + hasEarlyAccess?: boolean; } export const ApiKeyModal: React.FC = ({ @@ -25,8 +36,11 @@ export const ApiKeyModal: React.FC = ({ onSave, saving, hasAIAccess, + subscription, + hasEarlyAccess, }) => { useBodyScrollLock(isOpen); + const { authFetch } = useAuthenticatedFetch(); const [localProvider, setLocalProvider] = useState(preferences.aiProvider || 'gemini'); const [localGeminiKey, setLocalGeminiKey] = useState(preferences.geminiApiKey || ''); const [localGeminiModel, setLocalGeminiModel] = useState(preferences.geminiModel || ''); @@ -44,6 +58,10 @@ export const ApiKeyModal: React.FC = ({ const [isModelDropdownOpen, setIsModelDropdownOpen] = useState(false); const modelDropdownRef = useRef(null); + const [portalLoading, setPortalLoading] = useState(false); + const [portalError, setPortalError] = useState(null); + const [showPortalBanner, setShowPortalBanner] = useState(false); + useEffect(() => { if (isOpen) { setLocalProvider(preferences.aiProvider || 'gemini'); @@ -55,9 +73,21 @@ export const ApiKeyModal: React.FC = ({ setLocalOpenAIModel(preferences.openAIModel || ''); setModelSearchQuery(''); setIsModelDropdownOpen(false); + + const params = new URLSearchParams(window.location.search); + if (params.get('portal') === 'return') { + setShowPortalBanner(true); + } } }, [isOpen, preferences]); + const dismissPortalBanner = () => { + setShowPortalBanner(false); + const url = new URL(window.location.href); + url.searchParams.delete('portal'); + window.history.replaceState({}, '', url.toString()); + }; + useEffect(() => { const fetchOpenAIModels = async () => { if (!localOpenAIKey || localOpenAIKey.length < 10) { @@ -123,6 +153,37 @@ export const ApiKeyModal: React.FC = ({ 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 ( = ({
+ {showPortalBanner && ( +
+
+ +
+
+

Subscription Updated

+

+ Your subscription settings have been updated successfully. +

+
+ +
+ )} + + {subscription?.accessType === 'subscription' && ( +
+
+
+ + Subscription Active +
+ {subscription.generationsRemaining !== null && ( + + {subscription.generationsRemaining} generations left + + )} +
+

+ Manage your billing, payment methods, and invoices. +

+ {portalError && ( +

+ {portalError} +

+ )} + +
+ )} +
@@ -194,6 +313,9 @@ export const ApiKeyModal: React.FC = ({ OpenRouter
+

+ Choose the provider you want to use for quiz generation. +

{localProvider === 'gemini' && ( diff --git a/components/DefaultConfigModal.tsx b/components/DefaultConfigModal.tsx index c2831c1..561d1cf 100644 --- a/components/DefaultConfigModal.tsx +++ b/components/DefaultConfigModal.tsx @@ -12,6 +12,7 @@ interface DefaultConfigModalProps { onChange: (config: GameConfig) => void; onSave: () => void; saving: boolean; + maxPlayersLimit?: number; } export const DefaultConfigModal: React.FC = ({ @@ -21,6 +22,7 @@ export const DefaultConfigModal: React.FC = ({ onChange, onSave, saving, + maxPlayersLimit = 150, }) => { useBodyScrollLock(isOpen); @@ -41,7 +43,7 @@ export const DefaultConfigModal: React.FC = ({ className="bg-white rounded-2xl max-w-lg w-full shadow-xl max-h-[90vh] overflow-hidden flex flex-col" onClick={(e) => e.stopPropagation()} > -
+
@@ -64,6 +66,7 @@ export const DefaultConfigModal: React.FC = ({ config={config} onChange={onChange} questionCount={10} + maxPlayersLimit={maxPlayersLimit} />
diff --git a/components/GameConfigPanel.tsx b/components/GameConfigPanel.tsx index b7c81bd..7c1338e 100644 --- a/components/GameConfigPanel.tsx +++ b/components/GameConfigPanel.tsx @@ -1,5 +1,5 @@ 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'; interface GameConfigPanelProps { @@ -7,6 +7,7 @@ interface GameConfigPanelProps { onChange: (config: GameConfig) => void; questionCount: number; compact?: boolean; + maxPlayersLimit?: number; } interface TooltipProps { @@ -128,6 +129,42 @@ const ToggleRow: React.FC = ({ ); }; +interface ValueRowProps { + icon: React.ReactNode; + label: string; + description: string; + tooltip?: string; + children: React.ReactNode; +} + +const ValueRow: React.FC = ({ + icon, + label: labelText, + description, + tooltip, + children, +}) => { + return ( +
+
+
+
+ {icon} +
+
+

{labelText}

+

{description}

+
+
+
+ {tooltip && } + {children} +
+
+
+ ); +}; + interface NumberInputProps { label: string; value: number; @@ -161,9 +198,17 @@ export const GameConfigPanel: React.FC = ({ onChange, questionCount, compact = false, + maxPlayersLimit = 150, }) => { 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) => { onChange({ ...config, ...partial }); }; @@ -195,6 +240,29 @@ export const GameConfigPanel: React.FC = ({ )} + } + 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." + > +
+ update({ maxPlayers: Math.min(v, maxPlayersLimit) })} + min={2} + max={maxPlayersLimit} + suffix="players" + /> + {maxPlayersLimit < 150 && ( + + Free plan max: {maxPlayersLimit} players + + )} +
+
+ } iconActive={config.shuffleQuestions} diff --git a/components/Landing.tsx b/components/Landing.tsx index 1a7bdc2..160d64f 100644 --- a/components/Landing.tsx +++ b/components/Landing.tsx @@ -133,13 +133,19 @@ export const Landing: React.FC = ({ onGenerate, onCreateManual, on const showOcrOption = hasImageFile || hasDocumentFile; 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 = (() => { if (preferences.aiProvider === 'openrouter') return !!preferences.openRouterApiKey; 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 { quizzes, @@ -249,15 +255,21 @@ export const Landing: React.FC = ({ onGenerate, onCreateManual, on const handleHostSubmit = (e: React.FormEvent) => { e.preventDefault(); if (canGenerate && !isLoading) { - const aiProvider = preferences.aiProvider || 'gemini'; + const selectedProvider = preferences.aiProvider || 'gemini'; + let aiProvider = selectedProvider; let apiKey: string | undefined; - if (aiProvider === 'openrouter') { + + if (selectedProvider === 'openrouter') { apiKey = preferences.openRouterApiKey; - } else if (aiProvider === 'openai') { + } else if (selectedProvider === 'openai') { apiKey = preferences.openAIApiKey; } else { apiKey = preferences.geminiApiKey; } + + if (!apiKey && selectedProvider === 'gemini' && canUseSystemGeneration) { + aiProvider = 'system'; + } onGenerate({ topic: generateMode === 'topic' ? topic.trim() : undefined, @@ -327,6 +339,14 @@ export const Landing: React.FC = ({ onGenerate, onCreateManual, on left
)} + + {auth.isAuthenticated && subscription && subscription.accessType === 'none' && subscription.generationsRemaining !== null && ( +
+ Free tier: + {subscription.generationsRemaining} + left +
+ )} {auth.isAuthenticated && !hasAIAccess && (!subscription || subscription.accessType === 'none') && ( = ({ onGenerate, onCreateManual, on />
+ {auth.isAuthenticated && subscription && subscription.accessType === 'none' && subscription.generationsRemaining !== null && ( +
+ Free tier: + {subscription.generationsRemaining} + generations left +
+ )} +
); diff --git a/components/PreferencesModal.tsx b/components/PreferencesModal.tsx index f2130bf..1b90f67 100644 --- a/components/PreferencesModal.tsx +++ b/components/PreferencesModal.tsx @@ -63,19 +63,19 @@ export const PreferencesModal: React.FC = ({ className="bg-white rounded-2xl max-w-lg w-full shadow-xl max-h-[90vh] overflow-hidden flex flex-col" onClick={(e) => e.stopPropagation()} > -
-
-
+
+
+

Color Scheme

-

Customize your theme

+

Customize your theme

diff --git a/components/QuizEditor.tsx b/components/QuizEditor.tsx index 9eff413..b5afadb 100644 --- a/components/QuizEditor.tsx +++ b/components/QuizEditor.tsx @@ -19,6 +19,7 @@ interface QuizEditorProps { showSaveButton?: boolean; isSaving?: boolean; defaultConfig?: GameConfig; + maxPlayersLimit?: number; } export const QuizEditor: React.FC = ({ @@ -30,6 +31,7 @@ export const QuizEditor: React.FC = ({ showSaveButton = true, isSaving, defaultConfig, + maxPlayersLimit = 150, }) => { const [quiz, setQuiz] = useState(initialQuiz); const [expandedId, setExpandedId] = useState(null); @@ -300,6 +302,7 @@ export const QuizEditor: React.FC = ({ onChange={handleConfigChange} questionCount={quiz.questions.length} compact={false} + maxPlayersLimit={maxPlayersLimit} />
)} diff --git a/docker-compose.yml b/docker-compose.yml index 59a2dee..0d97add 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -108,7 +108,7 @@ services: container_name: kaboot-backend restart: unless-stopped environment: - NODE_ENV: production + NODE_ENV: ${NODE_ENV:-production} PORT: 3001 DATABASE_PATH: /data/kaboot.db ENCRYPTION_KEY: ${ENCRYPTION_KEY:?encryption key required} diff --git a/docs/PRODUCTION.md b/docs/PRODUCTION.md index 7c7ffd2..ab91b7d 100644 --- a/docs/PRODUCTION.md +++ b/docs/PRODUCTION.md @@ -230,12 +230,16 @@ STRIPE_PRICE_ID_YEARLY=price_... # Yearly subscription price ID 3. **Configure Webhook**: - Go to [Developers > Webhooks](https://dashboard.stripe.com/webhooks) - Add endpoint: `https://your-domain.com/api/payments/webhook` - - Select events: - - `checkout.session.completed` - - `customer.subscription.updated` - - `customer.subscription.deleted` - - `invoice.paid` - - `invoice.payment_failed` + - Select events: + - `checkout.session.completed` + - `customer.subscription.updated` + - `customer.subscription.deleted` + - `invoice.paid` + - `invoice.payment_failed` + - `refund.created` + - `refund.updated` + - `refund.failed` + - `charge.refunded` - Copy the Signing Secret (starts with `whsec_`) 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 ``` +#### 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 The project includes pre-configured compose files: diff --git a/hooks/useGame.ts b/hooks/useGame.ts index aa72aa0..a5a66b6 100644 --- a/hooks/useGame.ts +++ b/hooks/useGame.ts @@ -1,6 +1,7 @@ import { useState, useEffect, useRef, useCallback } from 'react'; import { useNavigate, useLocation } from 'react-router-dom'; 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 { generateQuiz } from '../services/geminiService'; import { QUESTION_TIME, QUESTION_TIME_MS, PLAYER_COLORS, calculatePointsWithBreakdown, getPlayerRank } from '../constants'; @@ -96,10 +97,11 @@ const clearDraftQuiz = () => { sessionStorage.removeItem(DRAFT_QUIZ_KEY); }; -export const useGame = () => { +export const useGame = (defaultGameConfig?: GameConfig) => { const navigate = useNavigate(); const location = useLocation(); const auth = useAuth(); + const { authFetch } = useAuthenticatedFetch(); const [role, setRole] = useState('HOST'); const [gameState, setGameState] = useState('LANDING'); @@ -120,7 +122,9 @@ export const useGame = () => { const [currentPlayerName, setCurrentPlayerName] = useState(null); const [pendingQuizToSave, setPendingQuizToSave] = useState<{ quiz: Quiz; topic: string } | null>(null); const [sourceQuizId, setSourceQuizId] = useState(null); - const [gameConfig, setGameConfig] = useState(DEFAULT_GAME_CONFIG); + const [gameConfig, setGameConfig] = useState(defaultGameConfig || DEFAULT_GAME_CONFIG); + const defaultConfigRef = useRef(defaultGameConfig || DEFAULT_GAME_CONFIG); + const [subscriptionAccessType, setSubscriptionAccessType] = useState<'group' | 'subscription' | 'none'>('none'); const [firstCorrectPlayerId, setFirstCorrectPlayerId] = useState(null); const [hostSecret, setHostSecret] = useState(null); const [isReconnecting, setIsReconnecting] = useState(false); @@ -149,6 +153,10 @@ export const useGame = () => { useEffect(() => { currentQuestionIndexRef.current = currentQuestionIndex; }, [currentQuestionIndex]); useEffect(() => { quizRef.current = quiz; }, [quiz]); useEffect(() => { gameConfigRef.current = gameConfig; }, [gameConfig]); + useEffect(() => { + if (!defaultGameConfig) return; + defaultConfigRef.current = defaultGameConfig; + }, [defaultGameConfig]); useEffect(() => { gamePinRef.current = gamePin; }, [gamePin]); useEffect(() => { hostSecretRef.current = hostSecret; }, [hostSecret]); useEffect(() => { gameStateRef.current = gameState; }, [gameState]); @@ -156,6 +164,38 @@ export const useGame = () => { useEffect(() => { currentCorrectShapeRef.current = currentCorrectShape; }, [currentCorrectShape]); 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); useEffect(() => { @@ -723,10 +763,12 @@ export const useGame = () => { }; const generatedQuiz = await generateQuiz(generateOptions); + const withDefaultConfig = { ...generatedQuiz, config: defaultConfigRef.current }; const saveLabel = options.topic || options.files?.map(f => f.name).join(', ') || ''; - setPendingQuizToSave({ quiz: generatedQuiz, topic: saveLabel }); - setQuiz(generatedQuiz); - storeDraftQuiz({ quiz: generatedQuiz, topic: saveLabel }); + setPendingQuizToSave({ quiz: withDefaultConfig, topic: saveLabel }); + setQuiz(withDefaultConfig); + setGameConfig(defaultConfigRef.current); + storeDraftQuiz({ quiz: withDefaultConfig, topic: saveLabel }); setGameState('EDITING'); } catch (e) { const message = e instanceof Error ? e.message : "Failed to generate quiz."; @@ -741,6 +783,7 @@ export const useGame = () => { const startManualCreation = () => { setRole('HOST'); + setGameConfig(defaultConfigRef.current); setGameState('CREATING'); }; @@ -758,6 +801,7 @@ export const useGame = () => { const loadSavedQuiz = (savedQuiz: Quiz, quizId?: string) => { setRole('HOST'); setQuiz(savedQuiz); + setGameConfig(savedQuiz.config || defaultConfigRef.current); setSourceQuizId(quizId || null); storeDraftQuiz({ quiz: savedQuiz, sourceQuizId: quizId }); setGameState('EDITING'); @@ -818,6 +862,20 @@ export const useGame = () => { if (!reconnectedPlayer && gameConfigRef.current.randomNamesEnabled) { 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 newPlayer: Player | null = null; @@ -1294,6 +1352,12 @@ export const useGame = () => { }; 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') { const payload = data.payload; console.log('[CLIENT] Received WELCOME:', { diff --git a/hooks/useUserPreferences.ts b/hooks/useUserPreferences.ts index bb81d0e..d7fe49b 100644 --- a/hooks/useUserPreferences.ts +++ b/hooks/useUserPreferences.ts @@ -77,6 +77,7 @@ interface SubscriptionInfo { interface UseUserPreferencesReturn { preferences: UserPreferences; hasAIAccess: boolean; + hasEarlyAccess: boolean; subscription: SubscriptionInfo | null; loading: boolean; saving: boolean; @@ -89,6 +90,7 @@ export const useUserPreferences = (): UseUserPreferencesReturn => { const { authFetch, isAuthenticated } = useAuthenticatedFetch(); const [preferences, setPreferences] = useState(DEFAULT_PREFERENCES); const [hasAIAccess, setHasAIAccess] = useState(false); + const [hasEarlyAccess, setHasEarlyAccess] = useState(false); const [subscription, setSubscription] = useState(null); const [loading, setLoading] = useState(false); const [saving, setSaving] = useState(false); @@ -121,6 +123,7 @@ export const useUserPreferences = (): UseUserPreferencesReturn => { writeLocalApiKeys(mergedLocalKeys); setPreferences(mergedPrefs); setHasAIAccess(data.hasAIAccess || false); + setHasEarlyAccess(data.hasEarlyAccess || false); applyColorScheme(mergedPrefs.colorScheme); const backendUrl = import.meta.env.VITE_BACKEND_URL || 'http://localhost:3001'; @@ -194,6 +197,7 @@ export const useUserPreferences = (): UseUserPreferencesReturn => { return { preferences, hasAIAccess, + hasEarlyAccess, subscription, loading, saving, diff --git a/server/src/index.ts b/server/src/index.ts index 2c236f8..882ed6f 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -28,6 +28,7 @@ app.use(helmet({ })); const isDev = process.env.NODE_ENV !== 'production'; +const isTest = process.env.NODE_ENV === 'test'; const apiLimiter = rateLimit({ windowMs: 15 * 60 * 1000, @@ -35,7 +36,7 @@ const apiLimiter = rateLimit({ standardHeaders: true, legacyHeaders: false, 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()); diff --git a/server/src/middleware/auth.ts b/server/src/middleware/auth.ts index 94073cc..b660447 100644 --- a/server/src/middleware/auth.ts +++ b/server/src/middleware/auth.ts @@ -112,7 +112,7 @@ export function requireAIAccess( } (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, } as AIAccessInfo; diff --git a/server/src/routes/games.ts b/server/src/routes/games.ts index eaf0b7d..1978529 100644 --- a/server/src/routes/games.ts +++ b/server/src/routes/games.ts @@ -6,12 +6,14 @@ import { randomBytes } from 'crypto'; const router = Router(); const isDev = process.env.NODE_ENV !== 'production'; +const isTest = process.env.NODE_ENV === 'test'; const gameCreationLimiter = rateLimit({ windowMs: 15 * 60 * 1000, max: isDev ? 100 : 30, standardHeaders: true, legacyHeaders: false, message: { error: 'Too many game creations, please try again later.' }, + skip: () => isTest, }); const gameLookupLimiter = rateLimit({ @@ -20,6 +22,7 @@ const gameLookupLimiter = rateLimit({ standardHeaders: true, legacyHeaders: false, message: { error: 'Too many requests, please try again later.' }, + skip: () => isTest, }); const SESSION_TTL_MINUTES = parseInt(process.env.GAME_SESSION_TTL_MINUTES || '5', 10); diff --git a/server/src/routes/generate.ts b/server/src/routes/generate.ts index fc5bd5c..20c06c7 100644 --- a/server/src/routes/generate.ts +++ b/server/src/routes/generate.ts @@ -1,13 +1,49 @@ import { Router, Response } from 'express'; import { GoogleGenAI, Type, createUserContent, createPartFromUri } from '@google/genai'; 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 { buildQuizPrompt } from '../shared/quizPrompt.js'; const router = Router(); 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 = { + priority: number; + run: () => Promise; + resolve: (value: T) => void; + reject: (error: unknown) => void; +}; + +const generationQueue: GenerationJob[] = []; +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 = (priority: number, run: () => Promise): Promise => { + return new Promise((resolve, reject) => { + generationQueue.push({ priority, run, resolve, reject }); + generationQueue.sort((a, b) => b.priority - a.priority); + processGenerationQueue(); + }); +}; interface GenerateRequest { topic: string; @@ -49,18 +85,8 @@ const QUIZ_SCHEMA = { required: ["title", "questions"] }; -function 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. - -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}`; -} +const buildPrompt = (topic: string, questionCount: number, hasDocuments: boolean): string => + buildQuizPrompt({ topic, questionCount, hasDocuments }); function shuffleArray(array: T[]): T[] { const shuffled = [...array]; @@ -121,63 +147,79 @@ router.post('/', requireAuth, requireAIAccess, async (req: AuthenticatedRequest, } try { - const ai = new GoogleGenAI({ apiKey: GEMINI_API_KEY }); - const hasDocuments = documents.length > 0; - const prompt = buildPrompt(topic, questionCount, hasDocuments); - - let contents: any; - - if (hasDocuments) { - const parts: any[] = []; - - for (const doc of documents) { - if (doc.type === 'native' && doc.mimeType) { - const buffer = Buffer.from(doc.content, 'base64'); - const blob = new Blob([buffer], { type: doc.mimeType }); - - const uploadedFile = await ai.files.upload({ - file: blob, - config: { mimeType: doc.mimeType } - }); - - if (uploadedFile.uri && uploadedFile.mimeType) { - parts.push(createPartFromUri(uploadedFile.uri, uploadedFile.mimeType)); - } - } else if (doc.type === 'text') { - parts.push({ text: doc.content }); - } - } - - parts.push({ text: prompt }); - contents = createUserContent(parts); - } else { - contents = prompt; - } - - const response = await ai.models.generateContent({ - model: DEFAULT_MODEL, - contents, - config: { - responseMimeType: "application/json", - responseSchema: QUIZ_SCHEMA - } - }); - - if (!response.text) { - res.status(500).json({ error: 'Failed to generate quiz content' }); + 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 data = JSON.parse(response.text); - const quiz = transformToQuiz(data); - + 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 hasDocuments = documents.length > 0; + const prompt = buildPrompt(topic, questionCount, hasDocuments); + + let contents: any; + + if (hasDocuments) { + const parts: any[] = []; + + for (const doc of documents) { + if (doc.type === 'native' && doc.mimeType) { + const buffer = Buffer.from(doc.content, 'base64'); + const blob = new Blob([buffer], { type: doc.mimeType }); + + const uploadedFile = await ai.files.upload({ + file: blob, + config: { mimeType: doc.mimeType } + }); + + if (uploadedFile.uri && uploadedFile.mimeType) { + parts.push(createPartFromUri(uploadedFile.uri, uploadedFile.mimeType)); + } + } else if (doc.type === 'text') { + parts.push({ text: doc.content }); + } + } + + parts.push({ text: prompt }); + contents = createUserContent(parts); + } else { + contents = prompt; + } + + const response = await ai.models.generateContent({ + model: DEFAULT_MODEL, + contents, + config: { + responseMimeType: "application/json", + responseSchema: QUIZ_SCHEMA + } + }); + + if (!response.text) { + throw new Error('Failed to generate quiz content'); + } + + const data = JSON.parse(response.text); + return transformToQuiz(data); + }); + + const waitMs = Date.now() - queuedAt; + if (waitMs > 0) { + console.log('AI generation queued', { waitMs, priority }); + } + const groups = req.user!.groups || []; if (!groups.includes('kaboot-ai-access')) { 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.json(quiz); } catch (err: any) { console.error('AI generation error:', err); diff --git a/server/src/routes/payments.ts b/server/src/routes/payments.ts index 4911c04..042a9aa 100644 --- a/server/src/routes/payments.ts +++ b/server/src/routes/payments.ts @@ -12,12 +12,22 @@ import { updateSubscriptionStatus, resetGenerationCount, recordPayment, + updatePaymentStatus, GENERATION_LIMIT, } from '../services/stripe.js'; const router = Router(); 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) => { res.json({ configured: isStripeConfigured(), @@ -92,15 +102,28 @@ router.post('/portal', requireAuth, async (req: AuthenticatedRequest, res: Respo } const userId = req.user!.sub; - const { returnUrl } = req.body; + const { returnUrl, cancelSubscriptionId, afterCompletionUrl } = req.body; if (!returnUrl) { res.status(400).json({ error: 'returnUrl is required' }); 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 { - const session = await createPortalSession(userId, returnUrl); + const session = await createPortalSession(userId, returnUrl, { + cancelSubscriptionId, + afterCompletionUrl, + }); res.json({ url: session.url }); } catch (err: any) { 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 { const user = db.prepare('SELECT id FROM users WHERE stripe_customer_id = ?').get(customerId) as { id: string } | undefined; return user?.id || null; @@ -228,6 +294,30 @@ async function handleInvoicePaymentFailed(invoice: Stripe.Invoice): Promise { + 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 { + 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 => { if (!STRIPE_WEBHOOK_SECRET) { res.status(503).json({ error: 'Webhook secret not configured' }); @@ -268,6 +358,12 @@ export const webhookHandler = async (req: Request, res: Response): Promise case 'invoice.payment_failed': await handleInvoicePaymentFailed(event.data.object as Stripe.Invoice); 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: console.log(`Unhandled event type: ${event.type}`); } diff --git a/server/src/routes/quizzes.ts b/server/src/routes/quizzes.ts index 9677fa9..a405672 100644 --- a/server/src/routes/quizzes.ts +++ b/server/src/routes/quizzes.ts @@ -12,6 +12,8 @@ interface GameConfig { shuffleQuestions: boolean; shuffleAnswers: boolean; hostParticipates: boolean; + randomNamesEnabled?: boolean; + maxPlayers?: number; streakBonusEnabled: boolean; streakThreshold: number; streakMultiplier: number; @@ -63,7 +65,8 @@ router.get('/', (req: AuthenticatedRequest, res: Response) => { router.get('/:id', (req: AuthenticatedRequest, res: Response) => { 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 WHERE id = ? AND user_id = ? `).get(req.params.id, req.user!.sub) as Record | undefined; @@ -108,6 +111,7 @@ router.get('/:id', (req: AuthenticatedRequest, res: Response) => { res.json({ ...quiz, + isShared: Boolean(quiz.isShared), gameConfig: parsedConfig, questions: questionsWithOptions, }); diff --git a/server/src/routes/upload.ts b/server/src/routes/upload.ts index 0ba4ff0..333e2b9 100644 --- a/server/src/routes/upload.ts +++ b/server/src/routes/upload.ts @@ -1,7 +1,8 @@ import { Router } from 'express'; import multer from 'multer'; 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(); @@ -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 { if (!req.file) { return res.status(400).json({ error: 'No file uploaded' }); } 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 processed = await processDocument(req.file.buffer, normalizedMime, { useOcr }); diff --git a/server/src/routes/users.ts b/server/src/routes/users.ts index 9b3e580..b1589f7 100644 --- a/server/src/routes/users.ts +++ b/server/src/routes/users.ts @@ -44,6 +44,7 @@ router.get('/me', (req: AuthenticatedRequest, res: Response) => { const groups = req.user!.groups || []; const hasAIAccess = groups.includes('kaboot-ai-access'); + const hasEarlyAccess = groups.includes('kaboot-early-access'); if (!row) { res.json({ @@ -54,6 +55,7 @@ router.get('/me', (req: AuthenticatedRequest, res: Response) => { defaultGameConfig: null, colorScheme: 'blue', hasAIAccess, + hasEarlyAccess, createdAt: null, lastLogin: null, isNew: true, @@ -69,6 +71,7 @@ router.get('/me', (req: AuthenticatedRequest, res: Response) => { email: req.user!.email, displayName: req.user!.name, hasAIAccess, + hasEarlyAccess, isNew: false }); }); @@ -116,6 +119,7 @@ router.get('/me/preferences', (req: AuthenticatedRequest, res: Response) => { const groups = req.user!.groups || []; const hasAIAccess = groups.includes('kaboot-ai-access'); + const hasEarlyAccess = groups.includes('kaboot-early-access'); res.json({ colorScheme: user?.colorScheme || 'blue', @@ -127,12 +131,14 @@ router.get('/me/preferences', (req: AuthenticatedRequest, res: Response) => { openAIApiKey: null, openAIModel: user?.openAIModel || null, hasAIAccess, + hasEarlyAccess, }); }); router.put('/me/preferences', (req: AuthenticatedRequest, res: Response) => { const userSub = req.user!.sub; const { colorScheme, geminiModel, aiProvider, openRouterModel, openAIModel } = req.body; + const requestedProvider = aiProvider || 'gemini'; const upsertUser = db.prepare(` 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, colorScheme || 'blue', geminiModel || null, - aiProvider || 'gemini', + requestedProvider, openRouterModel || null, openAIModel || null, colorScheme || 'blue', geminiModel || null, - aiProvider || 'gemini', + requestedProvider, openRouterModel || null, openAIModel || null ); diff --git a/server/src/services/stripe.ts b/server/src/services/stripe.ts index 7ae1de8..69bcc51 100644 --- a/server/src/services/stripe.ts +++ b/server/src/services/stripe.ts @@ -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; export const GENERATION_LIMIT = 250; +export const FREE_TIER_LIMIT = 5; let stripeClient: Stripe | null = null; @@ -90,20 +91,58 @@ export async function createCheckoutSession( export async function createPortalSession( userId: string, - returnUrl: string + returnUrl: string, + options?: { + cancelSubscriptionId?: string; + afterCompletionUrl?: string; + } ): Promise { 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) { 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, 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; } @@ -116,6 +155,25 @@ export interface SubscriptionStatus { 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 { const user = db.prepare(` 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 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 { status, - currentPeriodEnd: user?.subscription_current_period_end || null, + currentPeriodEnd: isSubscriptionActive ? (user?.subscription_current_period_end || null) : effectiveResetDate, generationCount, - generationLimit: GENERATION_LIMIT, - generationsRemaining: Math.max(0, GENERATION_LIMIT - generationCount), + generationLimit, + generationsRemaining: Math.max(0, generationLimit - generationCount), }; } @@ -196,31 +261,27 @@ export function incrementGenerationCount(userId: string): number { 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')) { - return { allowed: true }; + return { allowed: true, accessType: 'group' }; } const status = getSubscriptionStatus(userId); - - if (status.status !== 'active') { - return { - allowed: false, - reason: 'No active subscription. Upgrade to access AI generation.', - }; - } - + const accessType: 'subscription' | 'none' = status.status === 'active' ? 'subscription' : 'none'; + if (status.generationsRemaining <= 0) { - return { - allowed: false, + return { + allowed: false, reason: 'Generation limit reached for this billing period.', remaining: 0, + accessType, }; } - - return { - allowed: true, + + return { + allowed: true, remaining: status.generationsRemaining, + accessType, }; } @@ -240,3 +301,34 @@ export function recordPayment( VALUES (?, ?, ?, ?, ?, ?, ?, ?) `).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; +} diff --git a/server/src/shared/quizPrompt.ts b/server/src/shared/quizPrompt.ts new file mode 100644 index 0000000..5c18d5e --- /dev/null +++ b/server/src/shared/quizPrompt.ts @@ -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}`; +} diff --git a/server/tests/api.test.ts b/server/tests/api.test.ts index 3f1aa5f..a1de080 100644 --- a/server/tests/api.test.ts +++ b/server/tests/api.test.ts @@ -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; + 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; + 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; + 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 ==='); let shareTestQuizId: string | null = null; diff --git a/server/tests/get-token.ts b/server/tests/get-token.ts index ecb231a..b1f9769 100644 --- a/server/tests/get-token.ts +++ b/server/tests/get-token.ts @@ -36,14 +36,14 @@ async function getTokenWithServiceAccount(): Promise { const tokenUrl = `${AUTHENTIK_URL}/application/o/token/`; const params = new URLSearchParams({ - grant_type: 'client_credentials', + grant_type: 'password', client_id: CLIENT_ID, username: USERNAME, password: PASSWORD, 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, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, diff --git a/server/tests/run-tests.ts b/server/tests/run-tests.ts index 5e33360..04323a5 100644 --- a/server/tests/run-tests.ts +++ b/server/tests/run-tests.ts @@ -4,10 +4,34 @@ import { dirname, join } from 'path'; const AUTHENTIK_URL = process.env.AUTHENTIK_URL || 'http://localhost:9000'; const CLIENT_ID = process.env.CLIENT_ID || 'kaboot-spa'; +const CLIENT_SECRET = process.env.CLIENT_SECRET || ''; const USERNAME = process.env.TEST_USERNAME || ''; const PASSWORD = process.env.TEST_PASSWORD || ''; +const TEST_TOKEN = process.env.TEST_TOKEN || ''; async function getToken(): Promise { + 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) { throw new Error( 'TEST_USERNAME and TEST_PASSWORD must be set in .env.test\n' + @@ -15,9 +39,8 @@ async function getToken(): Promise { ); } - const tokenUrl = `${AUTHENTIK_URL}/application/o/token/`; const params = new URLSearchParams({ - grant_type: 'client_credentials', + grant_type: 'password', client_id: CLIENT_ID, username: USERNAME, password: PASSWORD, @@ -65,14 +88,19 @@ async function main() { console.log('Kaboot API Test Runner'); console.log('======================\n'); - console.log('Obtaining access token from Authentik...'); let token: string; - try { - token = await getToken(); - console.log(' Token obtained successfully.\n'); - } catch (error) { - console.error(` Failed: ${error instanceof Error ? error.message : error}`); - process.exit(1); + if (TEST_TOKEN) { + console.log('Using TEST_TOKEN from environment.\n'); + token = TEST_TOKEN; + } else { + console.log('Obtaining access token from Authentik...'); + try { + token = await getToken(); + console.log(' Token obtained successfully.\n'); + } catch (error) { + console.error(` Failed: ${error instanceof Error ? error.message : error}`); + process.exit(1); + } } console.log('Running API tests...\n'); diff --git a/services/geminiService.ts b/services/geminiService.ts index 5206c86..a408c20 100644 --- a/services/geminiService.ts +++ b/services/geminiService.ts @@ -1,5 +1,6 @@ import { GoogleGenAI, Type, createUserContent, createPartFromUri } from "@google/genai"; 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'; const getGeminiClient = (apiKey?: string) => { @@ -50,45 +51,17 @@ const QUIZ_SCHEMA = { function buildPrompt(options: GenerateQuizOptions, hasDocuments: boolean, includeJsonExample: boolean = false): string { const questionCount = options.questionCount || 10; - - let baseInstructions = `Create ${questionCount} engaging multiple-choice questions. Each question must have exactly 4 options, and exactly one correct answer. Vary the difficulty. - -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.`; + let prompt = buildQuizPrompt({ + topic: options.topic, + questionCount, + hasDocuments, + }); if (includeJsonExample) { - baseInstructions += ` - -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.`; + prompt += `\n\n${JSON_EXAMPLE_GUIDANCE}`; } - if (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}`; + return prompt; } function shuffleArray(array: T[]): T[] { diff --git a/types.ts b/types.ts index a86750a..3e9d399 100644 --- a/types.ts +++ b/types.ts @@ -71,6 +71,7 @@ export interface GameConfig { shuffleAnswers: boolean; hostParticipates: boolean; randomNamesEnabled: boolean; + maxPlayers: number; streakBonusEnabled: boolean; streakThreshold: number; streakMultiplier: number; @@ -87,6 +88,7 @@ export const DEFAULT_GAME_CONFIG: GameConfig = { shuffleAnswers: false, hostParticipates: true, randomNamesEnabled: false, + maxPlayers: 10, streakBonusEnabled: false, streakThreshold: 3, streakMultiplier: 1.1, @@ -185,6 +187,7 @@ export interface QuizExportFile { export type NetworkMessage = | { type: 'JOIN'; payload: { name: string; reconnect?: boolean; previousId?: string } } + | { type: 'JOIN_DENIED'; payload: { reason?: string } } | { type: 'WELCOME'; payload: { playerId: string; quizTitle: string; @@ -230,4 +233,4 @@ export type NetworkMessage = | { type: 'KICK'; payload: { playerId: string } } | { type: 'KICKED'; payload: { reason?: string } } | { type: 'LEAVE'; payload: {} } - | { type: 'PLAYER_LEFT'; payload: { playerId: string } }; \ No newline at end of file + | { type: 'PLAYER_LEFT'; payload: { playerId: string } };