Flesh out payment stuff

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

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