Compare commits

...

3 commits

28 changed files with 1053 additions and 169 deletions

17
App.tsx
View file

@ -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<Quiz | null>(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;
export default App;

View file

@ -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
# ═══════════════════════════════════════════════════════════════════════════════

View file

@ -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<UserPreferences>) => Promise<void>;
saving: boolean;
hasAIAccess: boolean;
subscription?: SubscriptionInfo | null;
hasEarlyAccess?: boolean;
}
export const ApiKeyModal: React.FC<ApiKeyModalProps> = ({
@ -25,8 +36,11 @@ export const ApiKeyModal: React.FC<ApiKeyModalProps> = ({
onSave,
saving,
hasAIAccess,
subscription,
hasEarlyAccess,
}) => {
useBodyScrollLock(isOpen);
const { authFetch } = useAuthenticatedFetch();
const [localProvider, setLocalProvider] = useState<AIProvider>(preferences.aiProvider || 'gemini');
const [localGeminiKey, setLocalGeminiKey] = useState(preferences.geminiApiKey || '');
const [localGeminiModel, setLocalGeminiModel] = useState(preferences.geminiModel || '');
@ -44,6 +58,10 @@ export const ApiKeyModal: React.FC<ApiKeyModalProps> = ({
const [isModelDropdownOpen, setIsModelDropdownOpen] = useState(false);
const modelDropdownRef = useRef<HTMLDivElement>(null);
const [portalLoading, setPortalLoading] = useState(false);
const [portalError, setPortalError] = useState<string | null>(null);
const [showPortalBanner, setShowPortalBanner] = useState(false);
useEffect(() => {
if (isOpen) {
setLocalProvider(preferences.aiProvider || 'gemini');
@ -55,9 +73,21 @@ export const ApiKeyModal: React.FC<ApiKeyModalProps> = ({
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<ApiKeyModalProps> = ({
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 (
<motion.div
initial={{ opacity: 0 }}
@ -157,6 +218,64 @@ export const ApiKeyModal: React.FC<ApiKeyModalProps> = ({
</div>
<div className="p-6 bg-gray-50 space-y-4">
{showPortalBanner && (
<div className="bg-green-50 p-4 rounded-xl border border-green-200 shadow-sm flex items-start gap-3">
<div className="p-1 bg-green-100 rounded-lg text-green-600">
<CheckCircle size={20} />
</div>
<div className="flex-1">
<h3 className="font-bold text-green-900">Subscription Updated</h3>
<p className="text-sm text-green-700 mt-1">
Your subscription settings have been updated successfully.
</p>
</div>
<button
onClick={dismissPortalBanner}
className="text-green-500 hover:text-green-700 transition"
>
<X size={20} />
</button>
</div>
)}
{subscription?.accessType === 'subscription' && (
<div className="mb-6 bg-white p-4 rounded-xl border border-gray-200 shadow-sm">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2 text-theme-primary font-bold">
<CreditCard size={20} />
<span>Subscription Active</span>
</div>
{subscription.generationsRemaining !== null && (
<span className="text-sm font-bold bg-theme-primary/10 text-theme-primary px-2 py-1 rounded-lg">
{subscription.generationsRemaining} generations left
</span>
)}
</div>
<p className="text-sm text-gray-500 mb-3">
Manage your billing, payment methods, and invoices.
</p>
{portalError && (
<p className="text-sm text-red-500 mb-3 bg-red-50 p-2 rounded-lg border border-red-100">
{portalError}
</p>
)}
<button
onClick={handleManageSubscription}
disabled={portalLoading}
className="w-full py-2 bg-gray-100 hover:bg-gray-200 text-gray-800 rounded-lg font-bold text-sm transition-colors flex items-center justify-center gap-2 disabled:opacity-50"
>
{portalLoading ? (
<>
<Loader2 size={16} className="animate-spin" />
Redirecting...
</>
) : (
'Manage Subscription'
)}
</button>
</div>
)}
<div>
<label className="block font-bold text-gray-800 mb-2">AI Provider</label>
<div className="flex bg-gray-200 p-1 rounded-xl">
@ -194,6 +313,9 @@ export const ApiKeyModal: React.FC<ApiKeyModalProps> = ({
OpenRouter
</button>
</div>
<p className="text-xs text-gray-500 mt-2 ml-1">
Choose the provider you want to use for quiz generation.
</p>
</div>
{localProvider === 'gemini' && (

View file

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

View file

@ -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<ToggleRowProps> = ({
);
};
interface ValueRowProps {
icon: React.ReactNode;
label: string;
description: string;
tooltip?: string;
children: React.ReactNode;
}
const ValueRow: React.FC<ValueRowProps> = ({
icon,
label: labelText,
description,
tooltip,
children,
}) => {
return (
<div className="bg-white rounded-xl border-2 border-gray-200 overflow-hidden">
<div className="flex items-center justify-between p-4 hover:bg-gray-50 transition group">
<div className="flex items-center gap-3 flex-1">
<div className="p-2 rounded-lg bg-theme-primary text-white transition">
{icon}
</div>
<div>
<p className="font-bold text-gray-900">{labelText}</p>
<p className="text-sm text-gray-500">{description}</p>
</div>
</div>
<div className="flex items-center gap-2">
{tooltip && <Tooltip content={tooltip} />}
{children}
</div>
</div>
</div>
);
};
interface NumberInputProps {
label: string;
value: number;
@ -161,9 +198,17 @@ export const GameConfigPanel: React.FC<GameConfigPanelProps> = ({
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<GameConfig>) => {
onChange({ ...config, ...partial });
};
@ -195,6 +240,29 @@ export const GameConfigPanel: React.FC<GameConfigPanelProps> = ({
</button>
)}
<ValueRow
icon={<Users size={20} />}
label="Max Players"
description="Limit the number of players who can join"
tooltip="The maximum number of players allowed in the game (2-150). Subscription required for >10 players."
>
<div className="flex flex-col items-end">
<NumberInput
label=""
value={config.maxPlayers || 10}
onChange={(v) => update({ maxPlayers: Math.min(v, maxPlayersLimit) })}
min={2}
max={maxPlayersLimit}
suffix="players"
/>
{maxPlayersLimit < 150 && (
<span className="text-xs text-amber-600 font-bold">
Free plan max: {maxPlayersLimit} players
</span>
)}
</div>
</ValueRow>
<ToggleRow
icon={<Shuffle size={20} />}
iconActive={config.shuffleQuestions}

View file

@ -133,13 +133,19 @@ export const Landing: React.FC<LandingProps> = ({ 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<LandingProps> = ({ 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<LandingProps> = ({ onGenerate, onCreateManual, on
<span className="text-gray-400">left</span>
</div>
)}
{auth.isAuthenticated && subscription && subscription.accessType === 'none' && subscription.generationsRemaining !== null && (
<div className="flex items-center gap-2 bg-white/90 px-3 py-2 rounded-xl shadow-md text-sm font-bold">
<span className="text-gray-500">Free tier:</span>
<span className="text-gray-700">{subscription.generationsRemaining}</span>
<span className="text-gray-400">left</span>
</div>
)}
{auth.isAuthenticated && !hasAIAccess && (!subscription || subscription.accessType === 'none') && (
<a
@ -554,6 +574,14 @@ export const Landing: React.FC<LandingProps> = ({ onGenerate, onCreateManual, on
/>
</div>
{auth.isAuthenticated && subscription && subscription.accessType === 'none' && subscription.generationsRemaining !== null && (
<div className="flex justify-center items-center gap-1.5 text-xs font-bold pt-2">
<span className="text-gray-500">Free tier:</span>
<span className="text-gray-700">{subscription.generationsRemaining}</span>
<span className="text-gray-400">generations left</span>
</div>
)}
<button
type="submit"
disabled={isLoading || !canGenerate}
@ -720,6 +748,7 @@ export const Landing: React.FC<LandingProps> = ({ onGenerate, onCreateManual, on
}
}}
saving={savingConfig}
maxPlayersLimit={maxPlayersLimit}
/>
<PreferencesModal
@ -740,6 +769,8 @@ export const Landing: React.FC<LandingProps> = ({ onGenerate, onCreateManual, on
}}
saving={savingPrefs}
hasAIAccess={hasAIAccess}
subscription={subscription}
hasEarlyAccess={hasEarlyAccess}
/>
</div>
);

View file

@ -63,7 +63,7 @@ export const PreferencesModal: React.FC<PreferencesModalProps> = ({
className="bg-white rounded-2xl max-w-lg w-full shadow-xl max-h-[90vh] overflow-hidden flex flex-col"
onClick={(e) => e.stopPropagation()}
>
<div className="p-6 border-b border-gray-100 flex items-center justify-between bg-gradient-to-r from-theme-primary to-purple-600">
<div className="p-6 border-b border-gray-100 flex items-center justify-between bg-theme-primary">
<div className="flex items-center gap-3 text-white">
<div className="p-2 bg-white/20 rounded-xl">
<Palette size={24} />

View file

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

View file

@ -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}

View file

@ -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:

119
docs/STRIPE_SETUP.md Normal file
View file

@ -0,0 +1,119 @@
# 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.).

View file

@ -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<GameRole>('HOST');
const [gameState, setGameState] = useState<GameState>('LANDING');
@ -120,7 +122,9 @@ export const useGame = () => {
const [currentPlayerName, setCurrentPlayerName] = useState<string | null>(null);
const [pendingQuizToSave, setPendingQuizToSave] = useState<{ quiz: Quiz; topic: string } | null>(null);
const [sourceQuizId, setSourceQuizId] = useState<string | null>(null);
const [gameConfig, setGameConfig] = useState<GameConfig>(DEFAULT_GAME_CONFIG);
const [gameConfig, setGameConfig] = useState<GameConfig>(defaultGameConfig || DEFAULT_GAME_CONFIG);
const defaultConfigRef = useRef<GameConfig>(defaultGameConfig || DEFAULT_GAME_CONFIG);
const [subscriptionAccessType, setSubscriptionAccessType] = useState<'group' | 'subscription' | 'none'>('none');
const [firstCorrectPlayerId, setFirstCorrectPlayerId] = useState<string | null>(null);
const [hostSecret, setHostSecret] = useState<string | null>(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:', {

View file

@ -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<UserPreferences>(DEFAULT_PREFERENCES);
const [hasAIAccess, setHasAIAccess] = useState(false);
const [hasEarlyAccess, setHasEarlyAccess] = useState(false);
const [subscription, setSubscription] = useState<SubscriptionInfo | null>(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,

View file

@ -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());

View file

@ -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;

View file

@ -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);

View file

@ -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<T> = {
priority: number;
run: () => Promise<T>;
resolve: (value: T) => void;
reject: (error: unknown) => void;
};
const generationQueue: GenerationJob<any>[] = [];
let activeGenerations = 0;
const processGenerationQueue = () => {
while (activeGenerations < MAX_CONCURRENT_GENERATIONS && generationQueue.length > 0) {
const next = generationQueue.shift();
if (!next) return;
activeGenerations += 1;
next
.run()
.then((result) => next.resolve(result))
.catch((err) => next.reject(err))
.finally(() => {
activeGenerations -= 1;
processGenerationQueue();
});
}
};
const enqueueGeneration = <T,>(priority: number, run: () => Promise<T>): Promise<T> => {
return new Promise((resolve, reject) => {
generationQueue.push({ priority, run, resolve, reject });
generationQueue.sort((a, b) => b.priority - a.priority);
processGenerationQueue();
});
};
interface GenerateRequest {
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<T>(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);

View file

@ -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<void
);
}
async function handleRefundCreated(refund: Stripe.Refund): Promise<void> {
const paymentIntentId = refund.payment_intent ? String(refund.payment_intent) : null;
const updated = updatePaymentStatus(paymentIntentId, null, 'refunded', 'Refund created');
if (!updated) {
console.warn('Refund created but no matching payment record found:', refund.id);
return;
}
console.log('Recorded refund for payment intent:', paymentIntentId || refund.id);
}
async function handleChargeRefunded(charge: Stripe.Charge): Promise<void> {
const paymentIntentId = charge.payment_intent ? String(charge.payment_intent) : null;
const updated = updatePaymentStatus(paymentIntentId, null, 'refunded', 'Charge refunded');
if (!updated) {
console.warn('Charge refunded but no matching payment record found:', charge.id);
return;
}
console.log('Recorded charge refund for payment intent:', paymentIntentId || charge.id);
}
export const webhookHandler = async (req: Request, res: Response): Promise<void> => {
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<void>
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}`);
}

View file

@ -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<string, unknown> | undefined;
@ -108,6 +111,7 @@ router.get('/:id', (req: AuthenticatedRequest, res: Response) => {
res.json({
...quiz,
isShared: Boolean(quiz.isShared),
gameConfig: parsedConfig,
questions: questionsWithOptions,
});

View file

@ -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 });

View file

@ -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
);

View file

@ -6,6 +6,7 @@ const STRIPE_PRICE_ID_MONTHLY = process.env.STRIPE_PRICE_ID_MONTHLY;
const STRIPE_PRICE_ID_YEARLY = process.env.STRIPE_PRICE_ID_YEARLY;
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<Stripe.BillingPortal.Session> {
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;
}

View file

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

View file

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

View file

@ -36,14 +36,14 @@ async function getTokenWithServiceAccount(): Promise<string> {
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' },

View file

@ -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<string> {
const tokenUrl = `${AUTHENTIK_URL}/application/o/token/`;
if (CLIENT_SECRET) {
const params = new URLSearchParams({
grant_type: 'client_credentials',
client_id: CLIENT_ID,
client_secret: CLIENT_SECRET,
scope: 'openid profile email',
});
const response = await fetch(tokenUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: params.toString(),
});
if (response.ok) {
const data = await response.json();
return data.access_token;
}
}
if (!USERNAME || !PASSWORD) {
throw new Error(
'TEST_USERNAME and TEST_PASSWORD must be set in .env.test\n' +
@ -15,9 +39,8 @@ async function getToken(): Promise<string> {
);
}
const tokenUrl = `${AUTHENTIK_URL}/application/o/token/`;
const params = new URLSearchParams({
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');

View file

@ -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<T>(array: T[]): T[] {

View file

@ -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 } };
| { type: 'PLAYER_LEFT'; payload: { playerId: string } };