Flesh out payment stuff
This commit is contained in:
parent
b0dcdd6438
commit
acfed861ab
27 changed files with 938 additions and 173 deletions
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
58
server/src/shared/quizPrompt.ts
Normal file
58
server/src/shared/quizPrompt.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
export interface QuizPromptOptions {
|
||||
topic?: string;
|
||||
questionCount: number;
|
||||
hasDocuments: boolean;
|
||||
includeJsonExample?: boolean;
|
||||
}
|
||||
|
||||
export const TITLE_GUIDANCE = `Title guidance: Make the title fun and varied. Use playful phrasing, light wordplay, or energetic wording. Avoid templates like "The Ultimate ... Quiz" or "Test your knowledge". Keep it short (2-6 words) and specific to the topic, with clear reference to the original topic.`;
|
||||
|
||||
export const EXPLANATION_GUIDANCE = `IMPORTANT: For each option's reason, write as if you are directly explaining facts - never reference "the document", "the text", "the material", or "the source". Write explanations as standalone factual statements.`;
|
||||
|
||||
export const JSON_EXAMPLE_GUIDANCE = `You MUST respond with a single JSON object in this exact structure:
|
||||
{
|
||||
"title": "Quiz Title Here",
|
||||
"questions": [
|
||||
{
|
||||
"text": "Question text here?",
|
||||
"options": [
|
||||
{ "text": "Option A", "isCorrect": false, "reason": "Explanation why this is wrong" },
|
||||
{ "text": "Option B", "isCorrect": true, "reason": "Explanation why this is correct" },
|
||||
{ "text": "Option C", "isCorrect": false, "reason": "Explanation why this is wrong" },
|
||||
{ "text": "Option D", "isCorrect": false, "reason": "Explanation why this is wrong" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
There can be 2-6 options. Use 2 for true/false style questions.
|
||||
|
||||
Return ONLY valid JSON with no additional text before or after.`;
|
||||
|
||||
export function buildQuizPrompt(options: QuizPromptOptions): string {
|
||||
const questionCount = options.questionCount || 10;
|
||||
let baseInstructions = `Create ${questionCount} engaging multiple-choice questions. Each question must have 2-6 options, and exactly one correct answer. Vary the difficulty.
|
||||
|
||||
${TITLE_GUIDANCE}
|
||||
|
||||
${EXPLANATION_GUIDANCE}`;
|
||||
|
||||
if (options.includeJsonExample) {
|
||||
baseInstructions += `
|
||||
|
||||
${JSON_EXAMPLE_GUIDANCE}`;
|
||||
}
|
||||
|
||||
if (options.hasDocuments) {
|
||||
const topicContext = options.topic
|
||||
? ` Focus on aspects related to "${options.topic}".`
|
||||
: '';
|
||||
return `Generate a quiz based on the provided content.${topicContext}
|
||||
|
||||
${baseInstructions}`;
|
||||
}
|
||||
|
||||
return `Generate a trivia quiz about "${options.topic}".
|
||||
|
||||
${baseInstructions}`;
|
||||
}
|
||||
|
|
@ -2159,6 +2159,126 @@ console.log('\n=== Game Session Tests ===');
|
|||
}
|
||||
});
|
||||
|
||||
await test('GET /api/payments/status returns expected generation limit for access type', async () => {
|
||||
const { data } = await request('GET', '/api/payments/status');
|
||||
const status = data as Record<string, unknown>;
|
||||
const accessType = status.accessType as string;
|
||||
const limit = status.generationLimit as number | null;
|
||||
|
||||
if (accessType === 'group') {
|
||||
if (limit !== null) throw new Error('Expected null generationLimit for group access');
|
||||
return;
|
||||
}
|
||||
|
||||
if (accessType === 'subscription') {
|
||||
if (limit !== 250) throw new Error(`Expected generationLimit 250, got ${limit}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (accessType === 'none') {
|
||||
if (limit !== 5) throw new Error(`Expected free tier generationLimit 5, got ${limit}`);
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error(`Unexpected accessType: ${accessType}`);
|
||||
});
|
||||
|
||||
await test('POST /api/generate with two documents blocks free tier users', async () => {
|
||||
const statusRes = await request('GET', '/api/payments/status');
|
||||
const status = statusRes.data as Record<string, unknown>;
|
||||
const accessType = status.accessType as string;
|
||||
|
||||
const res = await fetch(`${API_URL}/api/generate`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${TOKEN}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
topic: 'Document content',
|
||||
documents: [
|
||||
{ type: 'text', content: 'Doc A' },
|
||||
{ type: 'text', content: 'Doc B' }
|
||||
],
|
||||
questionCount: 2
|
||||
}),
|
||||
});
|
||||
|
||||
if (accessType === 'none') {
|
||||
if (res.status !== 403) throw new Error(`Expected 403 for free tier, got ${res.status}`);
|
||||
const data = await res.json();
|
||||
if (!data.error || !String(data.error).toLowerCase().includes('document')) {
|
||||
throw new Error('Expected document limit error message');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const VALID_STATUSES = [200, 503];
|
||||
if (!VALID_STATUSES.includes(res.status)) {
|
||||
throw new Error(`Expected one of ${VALID_STATUSES.join('/')}, got ${res.status}`);
|
||||
}
|
||||
});
|
||||
|
||||
await test('POST /api/upload with OCR blocks free tier users', async () => {
|
||||
const statusRes = await request('GET', '/api/payments/status');
|
||||
const status = statusRes.data as Record<string, unknown>;
|
||||
const accessType = status.accessType as string;
|
||||
|
||||
const formData = new FormData();
|
||||
const blob = new Blob(['test content'], { type: 'text/plain' });
|
||||
formData.append('document', blob, 'test.txt');
|
||||
formData.append('useOcr', 'true');
|
||||
|
||||
const res = await fetch(`${API_URL}/api/upload`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${TOKEN}`,
|
||||
},
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (accessType === 'none') {
|
||||
if (res.status !== 403) throw new Error(`Expected 403 for free tier OCR, got ${res.status}`);
|
||||
const data = await res.json();
|
||||
if (!data.error || !String(data.error).toLowerCase().includes('ocr')) {
|
||||
throw new Error('Expected OCR access error message');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const VALID_STATUSES = [200, 400];
|
||||
if (!VALID_STATUSES.includes(res.status)) {
|
||||
throw new Error(`Expected one of ${VALID_STATUSES.join('/')}, got ${res.status}`);
|
||||
}
|
||||
});
|
||||
|
||||
console.log('\nPayments Refund Tests:');
|
||||
|
||||
await test('POST /api/payments/refund without token returns 401', async () => {
|
||||
const res = await fetch(`${API_URL}/api/payments/refund`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ paymentIntentId: 'pi_test' }),
|
||||
});
|
||||
if (res.status !== 401) throw new Error(`Expected 401, got ${res.status}`);
|
||||
});
|
||||
|
||||
await test('POST /api/payments/refund with non-admin returns 403 or 503', async () => {
|
||||
const res = await fetch(`${API_URL}/api/payments/refund`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${TOKEN}`,
|
||||
},
|
||||
body: JSON.stringify({ paymentIntentId: 'pi_test' }),
|
||||
});
|
||||
|
||||
const VALID_STATUSES = [403, 503];
|
||||
if (!VALID_STATUSES.includes(res.status)) {
|
||||
throw new Error(`Expected one of ${VALID_STATUSES.join('/')}, got ${res.status}`);
|
||||
}
|
||||
});
|
||||
|
||||
console.log('\n=== Quiz Sharing Tests ===');
|
||||
|
||||
let shareTestQuizId: string | null = null;
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue