diff --git a/App.tsx b/App.tsx index 270be7b..eee54cc 100644 --- a/App.tsx +++ b/App.tsx @@ -1,10 +1,9 @@ import React, { useState } from 'react'; import { useAuth } from 'react-oidc-context'; -import { useLocation, useNavigate } from 'react-router-dom'; +import { useLocation } 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'; @@ -51,11 +50,8 @@ 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 { @@ -103,7 +99,7 @@ function App() { sendAdvance, kickPlayer, leaveGame - } = useGame(defaultConfig); + } = useGame(); const handleSaveQuiz = async () => { if (!pendingQuizToSave) return; @@ -125,7 +121,6 @@ function App() { const source = pendingQuizToSave?.topic ? 'ai_generated' : 'manual'; const topic = pendingQuizToSave?.topic || undefined; await saveQuiz(editedQuiz, source, topic); - dismissSavePrompt(); } } }; @@ -135,7 +130,6 @@ function App() { await updateQuiz(sourceQuizId, pendingEditedQuiz); setShowSaveOptions(false); setPendingEditedQuiz(null); - dismissSavePrompt(); }; const handleSaveAsNew = async () => { @@ -143,7 +137,6 @@ function App() { await saveQuiz(pendingEditedQuiz, 'manual'); setShowSaveOptions(false); setPendingEditedQuiz(null); - dismissSavePrompt(); }; const currentQ = quiz?.questions[currentQuestionIndex]; @@ -160,7 +153,8 @@ function App() { const isPaymentCancelRoute = location.pathname === '/payment/cancel' && gameState === 'LANDING'; const navigateHome = () => { - navigate('/', { replace: true }); + window.history.replaceState({}, document.title, '/'); + window.location.reload(); }; if (isUpgradeRoute) { @@ -218,7 +212,6 @@ function App() { onBack={backFromEditor} showSaveButton={auth.isAuthenticated} defaultConfig={defaultConfig} - maxPlayersLimit={maxPlayersLimit} /> ) : null} @@ -369,4 +362,4 @@ function App() { ); } -export default App; +export default App; \ No newline at end of file diff --git a/authentik/blueprints/kaboot-setup.yaml b/authentik/blueprints/kaboot-setup.yaml index 0ff99c1..106a170 100644 --- a/authentik/blueprints/kaboot-setup.yaml +++ b/authentik/blueprints/kaboot-setup.yaml @@ -11,9 +11,6 @@ # - 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 @@ -109,20 +106,6 @@ 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 2afeffe..72b1c28 100644 --- a/components/ApiKeyModal.tsx +++ b/components/ApiKeyModal.tsx @@ -1,18 +1,9 @@ import React, { useState, useEffect, useRef } from 'react'; import { motion } from 'framer-motion'; -import { X, Key, Eye, EyeOff, Loader2, ChevronDown, Search, CreditCard, CheckCircle } from 'lucide-react'; +import { X, Key, Eye, EyeOff, Loader2, ChevronDown, Search } 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; @@ -25,8 +16,6 @@ interface ApiKeyModalProps { onSave: (prefs: Partial) => Promise; saving: boolean; hasAIAccess: boolean; - subscription?: SubscriptionInfo | null; - hasEarlyAccess?: boolean; } export const ApiKeyModal: React.FC = ({ @@ -36,11 +25,8 @@ 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 || ''); @@ -58,10 +44,6 @@ 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'); @@ -73,21 +55,9 @@ 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) { @@ -153,37 +123,6 @@ 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} -

- )} - -
- )} -
@@ -313,9 +194,6 @@ 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 561d1cf..c2831c1 100644 --- a/components/DefaultConfigModal.tsx +++ b/components/DefaultConfigModal.tsx @@ -12,7 +12,6 @@ interface DefaultConfigModalProps { onChange: (config: GameConfig) => void; onSave: () => void; saving: boolean; - maxPlayersLimit?: number; } export const DefaultConfigModal: React.FC = ({ @@ -22,7 +21,6 @@ export const DefaultConfigModal: React.FC = ({ onChange, onSave, saving, - maxPlayersLimit = 150, }) => { useBodyScrollLock(isOpen); @@ -43,7 +41,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()} > -
+
@@ -66,7 +64,6 @@ export const DefaultConfigModal: React.FC = ({ config={config} onChange={onChange} questionCount={10} - maxPlayersLimit={maxPlayersLimit} />
diff --git a/components/GameConfigPanel.tsx b/components/GameConfigPanel.tsx index 7c1338e..b7c81bd 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, Users } from 'lucide-react'; +import { Shuffle, Eye, Flame, TrendingUp, MinusCircle, Award, Info, ChevronDown, ChevronUp, Dices } from 'lucide-react'; import type { GameConfig } from '../types'; interface GameConfigPanelProps { @@ -7,7 +7,6 @@ interface GameConfigPanelProps { onChange: (config: GameConfig) => void; questionCount: number; compact?: boolean; - maxPlayersLimit?: number; } interface TooltipProps { @@ -129,42 +128,6 @@ 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; @@ -198,17 +161,9 @@ 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 }); }; @@ -240,29 +195,6 @@ 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 160d64f..1a7bdc2 100644 --- a/components/Landing.tsx +++ b/components/Landing.tsx @@ -133,19 +133,13 @@ export const Landing: React.FC = ({ onGenerate, onCreateManual, on const showOcrOption = hasImageFile || hasDocumentFile; const { defaultConfig, saving: savingConfig, saveDefaultConfig } = useUserConfig(); - const { preferences, hasAIAccess, hasEarlyAccess, subscription, saving: savingPrefs, savePreferences, applyColorScheme } = useUserPreferences(); - const maxPlayersLimit = (!subscription || subscription.accessType === 'none') ? 10 : 150; + const { preferences, hasAIAccess, subscription, saving: savingPrefs, savePreferences, applyColorScheme } = useUserPreferences(); const hasValidApiKey = (() => { if (preferences.aiProvider === 'openrouter') return !!preferences.openRouterApiKey; if (preferences.aiProvider === 'openai') return !!preferences.openAIApiKey; - return !!preferences.geminiApiKey; + return hasAIAccess || !!preferences.geminiApiKey; })(); - const canUseSystemGeneration = auth.isAuthenticated && ( - hasAIAccess || - subscription?.accessType === 'subscription' || - (subscription?.accessType === 'none' && subscription.generationsRemaining !== null && subscription.generationsRemaining > 0) - ); - const canUseAI = auth.isAuthenticated && (hasValidApiKey || canUseSystemGeneration); + const canUseAI = auth.isAuthenticated && hasValidApiKey; const { quizzes, @@ -255,21 +249,15 @@ export const Landing: React.FC = ({ onGenerate, onCreateManual, on const handleHostSubmit = (e: React.FormEvent) => { e.preventDefault(); if (canGenerate && !isLoading) { - const selectedProvider = preferences.aiProvider || 'gemini'; - let aiProvider = selectedProvider; + const aiProvider = preferences.aiProvider || 'gemini'; let apiKey: string | undefined; - - if (selectedProvider === 'openrouter') { + if (aiProvider === 'openrouter') { apiKey = preferences.openRouterApiKey; - } else if (selectedProvider === 'openai') { + } else if (aiProvider === 'openai') { apiKey = preferences.openAIApiKey; } else { apiKey = preferences.geminiApiKey; } - - if (!apiKey && selectedProvider === 'gemini' && canUseSystemGeneration) { - aiProvider = 'system'; - } onGenerate({ topic: generateMode === 'topic' ? topic.trim() : undefined, @@ -339,14 +327,6 @@ 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 1b90f67..88bf8a8 100644 --- a/components/PreferencesModal.tsx +++ b/components/PreferencesModal.tsx @@ -63,7 +63,7 @@ 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()} > -
+
diff --git a/components/QuizEditor.tsx b/components/QuizEditor.tsx index b5afadb..9eff413 100644 --- a/components/QuizEditor.tsx +++ b/components/QuizEditor.tsx @@ -19,7 +19,6 @@ interface QuizEditorProps { showSaveButton?: boolean; isSaving?: boolean; defaultConfig?: GameConfig; - maxPlayersLimit?: number; } export const QuizEditor: React.FC = ({ @@ -31,7 +30,6 @@ export const QuizEditor: React.FC = ({ showSaveButton = true, isSaving, defaultConfig, - maxPlayersLimit = 150, }) => { const [quiz, setQuiz] = useState(initialQuiz); const [expandedId, setExpandedId] = useState(null); @@ -302,7 +300,6 @@ 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 0d97add..59a2dee 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -108,7 +108,7 @@ services: container_name: kaboot-backend restart: unless-stopped environment: - NODE_ENV: ${NODE_ENV:-production} + 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 ab91b7d..7c7ffd2 100644 --- a/docs/PRODUCTION.md +++ b/docs/PRODUCTION.md @@ -230,16 +230,12 @@ 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` - - `refund.created` - - `refund.updated` - - `refund.failed` - - `charge.refunded` + - Select events: + - `checkout.session.completed` + - `customer.subscription.updated` + - `customer.subscription.deleted` + - `invoice.paid` + - `invoice.payment_failed` - Copy the Signing Secret (starts with `whsec_`) 4. **Test with Stripe CLI** (optional, for local development): @@ -247,11 +243,6 @@ 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/docs/STRIPE_SETUP.md b/docs/STRIPE_SETUP.md deleted file mode 100644 index 9153a5e..0000000 --- a/docs/STRIPE_SETUP.md +++ /dev/null @@ -1,119 +0,0 @@ -# Stripe Setup Guide (Development) - -This guide provides step-by-step instructions for setting up a Stripe test account and configuring payments for Kaboot in your local development environment. - -## Overview - -Kaboot uses Stripe to manage subscriptions for "AI Pro" access. Users get 250 AI quiz generations per month with a paid subscription, while free users are limited to 5. - -## Prerequisites - -1. A [Stripe Account](https://dashboard.stripe.com/register). -2. [Stripe CLI](https://stripe.com/docs/stripe-cli) installed on your machine. -3. Kaboot local development environment running (see [README.md](../README.md)). - ---- - -## 1. Stripe Dashboard Configuration - -### Step 1: Enable Test Mode -Log in to your Stripe Dashboard and ensure the **Test Mode** toggle (usually in the top right) is turned **ON**. - -### Step 2: Create a Product -1. Navigate to **Products** > **Add Product**. -2. **Name**: `Kaboot AI Pro` -3. **Description**: `250 AI quiz generations per month` -4. **Pricing**: - * **Price 1**: $5.00 USD, Recurring, Monthly. - * **Price 2**: $50.00 USD, Recurring, Yearly. -5. Click **Save Product**. - -### Step 3: Get Price IDs -After saving, copy the **API IDs** for both prices. They look like `price_1P...`. You will need these for your environment variables. - ---- - -## 2. Environment Configuration - -Add the following variables to your `.env` file in the project root: - -```env -# Stripe API Keys (Test Mode) -# Found at: https://dashboard.stripe.com/test/apikeys -STRIPE_SECRET_KEY=sk_test_51P... - -# Stripe Price IDs (from Step 3 above) -STRIPE_PRICE_ID_MONTHLY=price_... -STRIPE_PRICE_ID_YEARLY=price_... - -# Stripe Webhook Secret (Leave empty initially, see Section 3) -STRIPE_WEBHOOK_SECRET=whsec_... -``` - -> **Note**: In development, `STRIPE_SECRET_KEY` should always start with `sk_test_`. - ---- - -## 3. Webhook Setup (Local Development) - -Stripe needs to send events (like successful payments) to your local server. Since Stripe cannot access `localhost` directly, you must use the Stripe CLI to forward events. - -### Step 1: Login to Stripe CLI -```bash -stripe login -``` - -### Step 2: Start Forwarding -Run the following command in a new terminal window: - -```bash -stripe listen --forward-to localhost:3001/api/payments/webhook -``` - -### Step 3: Get Webhook Secret -The command above will output a **webhook signing secret** (e.g., `whsec_abc123...`). -1. Copy this secret. -2. Paste it into your `.env` file as `STRIPE_WEBHOOK_SECRET`. -3. **Restart your backend server** to apply the changes. - ---- - -## 4. Testing the Flow - -### Test Credit Card -Use Stripe's [test card numbers](https://stripe.com/docs/testing#cards). The most common is: -* **Card Number**: `4242 4242 4242 4242` -* **Expiry**: Any future date -* **CVC**: Any 3 digits -* **Zip**: Any 5 digits - -### Webhook Events to Monitor -When testing, watch your `stripe listen` terminal. You should see: -- `checkout.session.completed`: When a user finishes the payment. -- `customer.subscription.updated`: When status changes. -- `invoice.paid`: When the monthly/yearly bill is settled (resets generation counts). - ---- - -## 5. Troubleshooting - -### Webhook Signature Mismatch -**Error**: `Webhook signature verification failed` -- **Solution**: Ensure `STRIPE_WEBHOOK_SECRET` in `.env` matches the secret shown in your `stripe listen` output. Each time you run `stripe listen`, a new secret might be generated if not configured as a permanent endpoint. - -### Missing Keys -**Error**: `Stripe API Key not found` -- **Solution**: Verify `STRIPE_SECRET_KEY` is set in the `.env` file that the **backend** (server) is reading. - -### Checkout Session Fails -**Error**: `No such price: price_...` -- **Solution**: Ensure your `STRIPE_PRICE_ID_MONTHLY` and `STRIPE_PRICE_ID_YEARLY` variables match the Price IDs in your Stripe **Test Mode** dashboard. - ---- - -## Technical Details - -- **Webhook Endpoint**: `POST /api/payments/webhook` -- **Checkout Initiation**: `POST /api/payments/checkout` -- **Billing Portal**: `POST /api/payments/portal` (Allows users to manage/cancel subscriptions) -- **Database Fields**: User subscription status is stored in the `users` table (`subscription_status`, `generation_count`, etc.). diff --git a/hooks/useGame.ts b/hooks/useGame.ts index a5a66b6..aa72aa0 100644 --- a/hooks/useGame.ts +++ b/hooks/useGame.ts @@ -1,7 +1,6 @@ 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'; @@ -97,11 +96,10 @@ const clearDraftQuiz = () => { sessionStorage.removeItem(DRAFT_QUIZ_KEY); }; -export const useGame = (defaultGameConfig?: GameConfig) => { +export const useGame = () => { const navigate = useNavigate(); const location = useLocation(); const auth = useAuth(); - const { authFetch } = useAuthenticatedFetch(); const [role, setRole] = useState('HOST'); const [gameState, setGameState] = useState('LANDING'); @@ -122,9 +120,7 @@ export const useGame = (defaultGameConfig?: GameConfig) => { const [currentPlayerName, setCurrentPlayerName] = useState(null); const [pendingQuizToSave, setPendingQuizToSave] = useState<{ quiz: Quiz; topic: string } | null>(null); const [sourceQuizId, setSourceQuizId] = useState(null); - const [gameConfig, setGameConfig] = useState(defaultGameConfig || DEFAULT_GAME_CONFIG); - const defaultConfigRef = useRef(defaultGameConfig || DEFAULT_GAME_CONFIG); - const [subscriptionAccessType, setSubscriptionAccessType] = useState<'group' | 'subscription' | 'none'>('none'); + const [gameConfig, setGameConfig] = useState(DEFAULT_GAME_CONFIG); const [firstCorrectPlayerId, setFirstCorrectPlayerId] = useState(null); const [hostSecret, setHostSecret] = useState(null); const [isReconnecting, setIsReconnecting] = useState(false); @@ -153,10 +149,6 @@ export const useGame = (defaultGameConfig?: GameConfig) => { 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]); @@ -164,38 +156,6 @@ export const useGame = (defaultGameConfig?: GameConfig) => { 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(() => { @@ -763,12 +723,10 @@ export const useGame = (defaultGameConfig?: GameConfig) => { }; const generatedQuiz = await generateQuiz(generateOptions); - const withDefaultConfig = { ...generatedQuiz, config: defaultConfigRef.current }; const saveLabel = options.topic || options.files?.map(f => f.name).join(', ') || ''; - setPendingQuizToSave({ quiz: withDefaultConfig, topic: saveLabel }); - setQuiz(withDefaultConfig); - setGameConfig(defaultConfigRef.current); - storeDraftQuiz({ quiz: withDefaultConfig, topic: saveLabel }); + setPendingQuizToSave({ quiz: generatedQuiz, topic: saveLabel }); + setQuiz(generatedQuiz); + storeDraftQuiz({ quiz: generatedQuiz, topic: saveLabel }); setGameState('EDITING'); } catch (e) { const message = e instanceof Error ? e.message : "Failed to generate quiz."; @@ -783,7 +741,6 @@ export const useGame = (defaultGameConfig?: GameConfig) => { const startManualCreation = () => { setRole('HOST'); - setGameConfig(defaultConfigRef.current); setGameState('CREATING'); }; @@ -801,7 +758,6 @@ export const useGame = (defaultGameConfig?: GameConfig) => { 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'); @@ -862,20 +818,6 @@ export const useGame = (defaultGameConfig?: GameConfig) => { 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; @@ -1352,12 +1294,6 @@ export const useGame = (defaultGameConfig?: GameConfig) => { }; 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 d7fe49b..bb81d0e 100644 --- a/hooks/useUserPreferences.ts +++ b/hooks/useUserPreferences.ts @@ -77,7 +77,6 @@ interface SubscriptionInfo { interface UseUserPreferencesReturn { preferences: UserPreferences; hasAIAccess: boolean; - hasEarlyAccess: boolean; subscription: SubscriptionInfo | null; loading: boolean; saving: boolean; @@ -90,7 +89,6 @@ 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); @@ -123,7 +121,6 @@ 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'; @@ -197,7 +194,6 @@ export const useUserPreferences = (): UseUserPreferencesReturn => { return { preferences, hasAIAccess, - hasEarlyAccess, subscription, loading, saving, diff --git a/server/src/index.ts b/server/src/index.ts index 882ed6f..2c236f8 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -28,7 +28,6 @@ app.use(helmet({ })); const isDev = process.env.NODE_ENV !== 'production'; -const isTest = process.env.NODE_ENV === 'test'; const apiLimiter = rateLimit({ windowMs: 15 * 60 * 1000, @@ -36,7 +35,7 @@ const apiLimiter = rateLimit({ standardHeaders: true, legacyHeaders: false, message: { error: 'Too many requests, please try again later.' }, - skip: (req) => isTest || req.path === '/health', + skip: (req) => 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 b660447..94073cc 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' : (result.accessType || 'none'), + accessType: groups.includes('kaboot-ai-access') ? 'group' : 'subscription', remaining: result.remaining, } as AIAccessInfo; diff --git a/server/src/routes/games.ts b/server/src/routes/games.ts index 1978529..eaf0b7d 100644 --- a/server/src/routes/games.ts +++ b/server/src/routes/games.ts @@ -6,14 +6,12 @@ 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({ @@ -22,7 +20,6 @@ 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 20c06c7..fc5bd5c 100644 --- a/server/src/routes/generate.ts +++ b/server/src/routes/generate.ts @@ -1,49 +1,13 @@ 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, FREE_TIER_LIMIT } from '../services/stripe.js'; +import { incrementGenerationCount, GENERATION_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-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(); - }); -}; +const DEFAULT_MODEL = 'gemini-2.5-flash-preview-05-20'; interface GenerateRequest { topic: string; @@ -85,8 +49,18 @@ const QUIZ_SCHEMA = { required: ["title", "questions"] }; -const buildPrompt = (topic: string, questionCount: number, hasDocuments: boolean): string => - buildQuizPrompt({ topic, questionCount, hasDocuments }); +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}`; +} function shuffleArray(array: T[]): T[] { const shuffled = [...array]; @@ -147,79 +121,63 @@ router.post('/', requireAuth, requireAIAccess, async (req: AuthenticatedRequest, } 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.' }); + 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' }); 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 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 data = JSON.parse(response.text); + const quiz = transformToQuiz(data); + const groups = req.user!.groups || []; if (!groups.includes('kaboot-ai-access')) { const newCount = incrementGenerationCount(req.user!.sub); - const limit = accessInfo?.accessType === 'subscription' ? GENERATION_LIMIT : FREE_TIER_LIMIT; - const remaining = Math.max(0, limit - newCount); + const remaining = Math.max(0, GENERATION_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 042a9aa..4911c04 100644 --- a/server/src/routes/payments.ts +++ b/server/src/routes/payments.ts @@ -12,22 +12,12 @@ 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(), @@ -102,28 +92,15 @@ router.post('/portal', requireAuth, async (req: AuthenticatedRequest, res: Respo } const userId = req.user!.sub; - const { returnUrl, cancelSubscriptionId, afterCompletionUrl } = req.body; + const { returnUrl } = 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, { - cancelSubscriptionId, - afterCompletionUrl, - }); + const session = await createPortalSession(userId, returnUrl); res.json({ url: session.url }); } catch (err: any) { console.error('Portal session error:', err); @@ -131,49 +108,6 @@ 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; @@ -294,30 +228,6 @@ 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' }); @@ -358,12 +268,6 @@ 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 a405672..9677fa9 100644 --- a/server/src/routes/quizzes.ts +++ b/server/src/routes/quizzes.ts @@ -12,8 +12,6 @@ interface GameConfig { shuffleQuestions: boolean; shuffleAnswers: boolean; hostParticipates: boolean; - randomNamesEnabled?: boolean; - maxPlayers?: number; streakBonusEnabled: boolean; streakThreshold: number; streakMultiplier: number; @@ -65,8 +63,7 @@ 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, share_token as shareToken, is_shared as isShared, - created_at as createdAt, updated_at as updatedAt + SELECT id, title, source, ai_topic as aiTopic, game_config as gameConfig, 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; @@ -111,7 +108,6 @@ 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 333e2b9..0ba4ff0 100644 --- a/server/src/routes/upload.ts +++ b/server/src/routes/upload.ts @@ -1,8 +1,7 @@ import { Router } from 'express'; import multer from 'multer'; import { processDocument, SUPPORTED_TYPES, normalizeMimeType } from '../services/documentParser.js'; -import { requireAuth, AuthenticatedRequest } from '../middleware/auth.js'; -import { getSubscriptionStatus } from '../services/stripe.js'; +import { requireAuth } from '../middleware/auth.js'; const router = Router(); @@ -25,23 +24,13 @@ const upload = multer({ } }); -router.post('/', upload.single('document'), async (req: AuthenticatedRequest, res) => { +router.post('/', upload.single('document'), async (req, 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 b1589f7..9b3e580 100644 --- a/server/src/routes/users.ts +++ b/server/src/routes/users.ts @@ -44,7 +44,6 @@ 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({ @@ -55,7 +54,6 @@ router.get('/me', (req: AuthenticatedRequest, res: Response) => { defaultGameConfig: null, colorScheme: 'blue', hasAIAccess, - hasEarlyAccess, createdAt: null, lastLogin: null, isNew: true, @@ -71,7 +69,6 @@ router.get('/me', (req: AuthenticatedRequest, res: Response) => { email: req.user!.email, displayName: req.user!.name, hasAIAccess, - hasEarlyAccess, isNew: false }); }); @@ -119,7 +116,6 @@ 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', @@ -131,14 +127,12 @@ 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) @@ -156,12 +150,12 @@ router.put('/me/preferences', (req: AuthenticatedRequest, res: Response) => { userSub, colorScheme || 'blue', geminiModel || null, - requestedProvider, + aiProvider || 'gemini', openRouterModel || null, openAIModel || null, colorScheme || 'blue', geminiModel || null, - requestedProvider, + aiProvider || 'gemini', openRouterModel || null, openAIModel || null ); diff --git a/server/src/services/stripe.ts b/server/src/services/stripe.ts index 69bcc51..7ae1de8 100644 --- a/server/src/services/stripe.ts +++ b/server/src/services/stripe.ts @@ -6,7 +6,6 @@ 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; @@ -91,58 +90,20 @@ export async function createCheckoutSession( export async function createPortalSession( userId: string, - returnUrl: string, - options?: { - cancelSubscriptionId?: string; - afterCompletionUrl?: string; - } + returnUrl: string ): Promise { const stripe = getStripe(); - 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; + const user = db.prepare('SELECT stripe_customer_id FROM users WHERE id = ?').get(userId) as { stripe_customer_id: string | null } | undefined; if (!user?.stripe_customer_id) { throw new Error('No Stripe customer found for this user'); } - - 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 = { + + const session = await stripe.billingPortal.sessions.create({ 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; } @@ -155,25 +116,6 @@ 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 @@ -187,20 +129,13 @@ 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: isSubscriptionActive ? (user?.subscription_current_period_end || null) : effectiveResetDate, + currentPeriodEnd: user?.subscription_current_period_end || null, generationCount, - generationLimit, - generationsRemaining: Math.max(0, generationLimit - generationCount), + generationLimit: GENERATION_LIMIT, + generationsRemaining: Math.max(0, GENERATION_LIMIT - generationCount), }; } @@ -261,27 +196,31 @@ export function incrementGenerationCount(userId: string): number { return result?.generation_count || 1; } -export function canGenerate(userId: string, groups: string[]): { allowed: boolean; reason?: string; remaining?: number; accessType?: 'group' | 'subscription' | 'none' } { +export function canGenerate(userId: string, groups: string[]): { allowed: boolean; reason?: string; remaining?: number } { if (groups.includes('kaboot-ai-access')) { - return { allowed: true, accessType: 'group' }; + return { allowed: true }; } const status = getSubscriptionStatus(userId); - const accessType: 'subscription' | 'none' = status.status === 'active' ? 'subscription' : 'none'; - - if (status.generationsRemaining <= 0) { - return { - allowed: false, - reason: 'Generation limit reached for this billing period.', - remaining: 0, - accessType, + + if (status.status !== 'active') { + return { + allowed: false, + reason: 'No active subscription. Upgrade to access AI generation.', }; } - - return { - allowed: true, + + if (status.generationsRemaining <= 0) { + return { + allowed: false, + reason: 'Generation limit reached for this billing period.', + remaining: 0, + }; + } + + return { + allowed: true, remaining: status.generationsRemaining, - accessType, }; } @@ -301,34 +240,3 @@ 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 deleted file mode 100644 index 5c18d5e..0000000 --- a/server/src/shared/quizPrompt.ts +++ /dev/null @@ -1,58 +0,0 @@ -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 a1de080..3f1aa5f 100644 --- a/server/tests/api.test.ts +++ b/server/tests/api.test.ts @@ -2159,126 +2159,6 @@ 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 b1f9769..ecb231a 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: 'password', + grant_type: 'client_credentials', client_id: CLIENT_ID, username: USERNAME, password: PASSWORD, scope: 'openid profile email', }); - console.log(` Trying password grant with username/password...`); + console.log(` Trying client_credentials 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 04323a5..5e33360 100644 --- a/server/tests/run-tests.ts +++ b/server/tests/run-tests.ts @@ -4,34 +4,10 @@ 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' + @@ -39,8 +15,9 @@ async function getToken(): Promise { ); } + const tokenUrl = `${AUTHENTIK_URL}/application/o/token/`; const params = new URLSearchParams({ - grant_type: 'password', + grant_type: 'client_credentials', client_id: CLIENT_ID, username: USERNAME, password: PASSWORD, @@ -88,19 +65,14 @@ async function main() { console.log('Kaboot API Test Runner'); console.log('======================\n'); + console.log('Obtaining access token from Authentik...'); 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 { - token = await getToken(); - console.log(' Token obtained successfully.\n'); - } catch (error) { - console.error(` Failed: ${error instanceof Error ? error.message : error}`); - process.exit(1); - } + try { + token = await getToken(); + console.log(' Token obtained successfully.\n'); + } catch (error) { + console.error(` Failed: ${error instanceof Error ? error.message : error}`); + process.exit(1); } console.log('Running API tests...\n'); diff --git a/services/geminiService.ts b/services/geminiService.ts index a408c20..5206c86 100644 --- a/services/geminiService.ts +++ b/services/geminiService.ts @@ -1,6 +1,5 @@ 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) => { @@ -51,17 +50,45 @@ const QUIZ_SCHEMA = { function buildPrompt(options: GenerateQuizOptions, hasDocuments: boolean, includeJsonExample: boolean = false): string { const questionCount = options.questionCount || 10; - let prompt = buildQuizPrompt({ - topic: options.topic, - questionCount, - hasDocuments, - }); + + 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.`; if (includeJsonExample) { - prompt += `\n\n${JSON_EXAMPLE_GUIDANCE}`; + 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.`; } - return prompt; + 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}`; } function shuffleArray(array: T[]): T[] { diff --git a/types.ts b/types.ts index 3e9d399..a86750a 100644 --- a/types.ts +++ b/types.ts @@ -71,7 +71,6 @@ export interface GameConfig { shuffleAnswers: boolean; hostParticipates: boolean; randomNamesEnabled: boolean; - maxPlayers: number; streakBonusEnabled: boolean; streakThreshold: number; streakMultiplier: number; @@ -88,7 +87,6 @@ export const DEFAULT_GAME_CONFIG: GameConfig = { shuffleAnswers: false, hostParticipates: true, randomNamesEnabled: false, - maxPlayers: 10, streakBonusEnabled: false, streakThreshold: 3, streakMultiplier: 1.1, @@ -187,7 +185,6 @@ 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; @@ -233,4 +230,4 @@ export type NetworkMessage = | { type: 'KICK'; payload: { playerId: string } } | { type: 'KICKED'; payload: { reason?: string } } | { type: 'LEAVE'; payload: {} } - | { type: 'PLAYER_LEFT'; payload: { playerId: string } }; + | { type: 'PLAYER_LEFT'; payload: { playerId: string } }; \ No newline at end of file