last commit before trying out opencode on repo

This commit is contained in:
2026-01-11 10:17:30 -05:00
parent 843b264c61
commit 91682bd887
85 changed files with 1589 additions and 1196 deletions

View File

@@ -7,9 +7,9 @@ A query function that takes two arguments looks like:
```ts
// functions.js
import { v } from "convex/values";
import { v } from 'convex/values';
import { query } from "./_generated/server";
import { query } from './_generated/server';
export const myQueryFunction = query({
// Validators for arguments.
@@ -22,7 +22,7 @@ export const myQueryFunction = query({
handler: async (ctx, args) => {
// Read the database as many times as you need here.
// See https://docs.convex.dev/database/reading-data.
const documents = await ctx.db.query("tablename").collect();
const documents = await ctx.db.query('tablename').collect();
// Arguments passed from the client are properties of the args object.
console.log(args.first, args.second);
@@ -39,7 +39,7 @@ Using this query function in a React component looks like:
```ts
const data = useQuery(api.functions.myQueryFunction, {
first: 10,
second: "hello",
second: 'hello',
});
```
@@ -47,9 +47,9 @@ A mutation function looks like:
```ts
// functions.js
import { v } from "convex/values";
import { v } from 'convex/values';
import { mutation } from "./_generated/server";
import { mutation } from './_generated/server';
export const myMutationFunction = mutation({
// Validators for arguments.
@@ -64,7 +64,7 @@ export const myMutationFunction = mutation({
// Mutations can also read from the database like queries.
// See https://docs.convex.dev/database/writing-data.
const message = { body: args.first, author: args.second };
const id = await ctx.db.insert("messages", message);
const id = await ctx.db.insert('messages', message);
// Optionally, return a value from your mutation.
return await ctx.db.get(id);
@@ -78,10 +78,10 @@ Using this mutation function in a React component looks like:
const mutation = useMutation(api.functions.myMutationFunction);
function handleButtonPress() {
// fire and forget, the most common way to use mutations
mutation({ first: "Hello!", second: "me" });
mutation({ first: 'Hello!', second: 'me' });
// OR
// use the result once the mutation has completed
mutation({ first: "Hello!", second: "me" }).then((result) =>
mutation({ first: 'Hello!', second: 'me' }).then((result) =>
console.log(result),
);
}

View File

@@ -14,7 +14,7 @@ import type * as custom_auth_providers_password from "../custom/auth/providers/p
import type * as custom_auth_providers_usesend from "../custom/auth/providers/usesend.js";
import type * as http from "../http.js";
import type * as questions from "../questions.js";
import type * as utils from "../utils.js";
import type * as quiz from "../quiz.js";
import type {
ApiFromModules,
@@ -29,7 +29,7 @@ declare const fullApi: ApiFromModules<{
"custom/auth/providers/usesend": typeof custom_auth_providers_usesend;
http: typeof http;
questions: typeof questions;
utils: typeof utils;
quiz: typeof quiz;
}>;
/**

View File

@@ -2,7 +2,7 @@ export default {
providers: [
{
domain: process.env.CONVEX_SITE_URL,
applicationID: "convex",
applicationID: 'convex',
},
],
};

View File

@@ -1,32 +1,25 @@
import { ConvexError, v } from 'convex/values';
import Authentik from '@auth/core/providers/authentik';
import {
convexAuth,
getAuthUserId,
retrieveAccount,
modifyAccountCredentials,
retrieveAccount,
} from '@convex-dev/auth/server';
import { api } from './_generated/api';
import { ConvexError, v } from 'convex/values';
import type { Doc, Id } from './_generated/dataModel';
import {
action,
mutation,
query,
type MutationCtx,
type QueryCtx,
} from './_generated/server';
import Authentik from '@auth/core/providers/authentik';
import type { MutationCtx, QueryCtx } from './_generated/server';
import { api } from './_generated/api';
import { action, mutation, query } from './_generated/server';
import { Password, validatePassword } from './custom/auth';
export const { auth, signIn, signOut, store, isAuthenticated } = convexAuth({
providers: [
Authentik({ allowDangerousEmailAccountLinking: true }),
Password,
],
providers: [Authentik({ allowDangerousEmailAccountLinking: true }), Password],
});
const getUserById = async (
ctx: QueryCtx,
userId: Id<'users'>
userId: Id<'users'>,
): Promise<Doc<'users'>> => {
const user = await ctx.db.get(userId);
if (!user) throw new ConvexError('User not found.');

View File

@@ -1,8 +1,9 @@
import { Password as DefaultPassword } from '@convex-dev/auth/providers/Password';
import { DataModel } from '../../../_generated/dataModel';
import { UseSendOTP, UseSendOTPPasswordReset } from '..';
import { ConvexError } from 'convex/values';
import { UseSendOTP, UseSendOTPPasswordReset } from '..';
import { DataModel } from '../../../_generated/dataModel';
export const Password = DefaultPassword<DataModel>({
profile(params, ctx) {
return {

View File

@@ -1,6 +1,6 @@
import type { EmailConfig, EmailUserConfig } from '@auth/core/providers/email';
import { alphabet } from 'oslo/crypto';
import { generateRandomString, RandomReader } from '@oslojs/crypto/random';
import { alphabet } from 'oslo/crypto';
import { UseSend } from 'usesend-js';
export default function UseSendProvider(config: EmailUserConfig): EmailConfig {

View File

@@ -1,4 +1,5 @@
import { httpRouter } from 'convex/server';
import { auth } from './auth';
const http = httpRouter();

View File

@@ -1,8 +1,9 @@
import { v } from "convex/values";
import { query, mutation, action } from "./_generated/server";
import { getAuthUserId } from "@convex-dev/auth/server";
import { api } from "./_generated/api";
import OpenAI from "openai";
import { getAuthUserId } from '@convex-dev/auth/server';
import { v } from 'convex/values';
import OpenAI from 'openai';
import { api } from './_generated/api';
import { action, mutation, query } from './_generated/server';
const openai = new OpenAI({
baseURL: process.env.OPENAI_BASE_URL,
@@ -13,8 +14,8 @@ export const getAllQuestions = query({
args: {},
handler: async (ctx) => {
const userId = await getAuthUserId(ctx);
if (!userId) throw new Error("Not authenticated");
return await ctx.db.query("questions").collect();
if (!userId) throw new Error('Not authenticated');
return await ctx.db.query('questions').collect();
},
});
@@ -22,10 +23,10 @@ export const getQuestionsByTopic = query({
args: { topic: v.string() },
handler: async (ctx, args) => {
const userId = await getAuthUserId(ctx);
if (!userId) throw new Error("Not authenticated");
if (!userId) throw new Error('Not authenticated');
return await ctx.db
.query("questions")
.withIndex("by_topic", (q) => q.eq("topic", args.topic))
.query('questions')
.withIndex('by_topic', (q) => q.eq('topic', args.topic))
.collect();
},
});
@@ -41,8 +42,8 @@ export const addQuestion = mutation({
},
handler: async (ctx, args) => {
const userId = await getAuthUserId(ctx);
if (!userId) throw new Error("Not authenticated");
return await ctx.db.insert("questions", {
if (!userId) throw new Error('Not authenticated');
return await ctx.db.insert('questions', {
question: args.question,
options: args.options,
correctAnswer: args.correctAnswer,
@@ -60,7 +61,7 @@ export const generateQuestions = action({
},
handler: async (ctx, args) => {
const userId = await getAuthUserId(ctx);
if (!userId) throw new Error("Not authenticated");
if (!userId) throw new Error('Not authenticated');
const prompt = `Generate ${args.count} multiple choice questions for the CompTIA Network+ exam on the topic: "${args.topic}".
Return ONLY a valid JSON array with this exact structure:
@@ -83,19 +84,19 @@ Rules:
- Return ONLY the JSON array, no other text`;
const response = await openai.chat.completions.create({
model: "gpt-4o-mini",
messages: [{ role: "user", content: prompt }],
model: 'gpt-4o-mini',
messages: [{ role: 'user', content: prompt }],
temperature: 0.8,
});
const content = response.choices[0].message.content;
if (!content) throw new Error("No response from AI");
if (!content) throw new Error('No response from AI');
let questions;
try {
questions = JSON.parse(content);
} catch (e) {
throw new Error("Failed to parse AI response as JSON");
throw new Error('Failed to parse AI response as JSON');
}
const questionIds = [];

View File

@@ -0,0 +1,236 @@
import { getAuthUserId } from '@convex-dev/auth/server';
import { v } from 'convex/values';
import { mutation, query } from './_generated/server';
export const startQuizSession = mutation({
args: {
questionIds: v.optional(v.array(v.id('questions'))),
},
handler: async (ctx, args) => {
const userId = await getAuthUserId(ctx);
if (!userId) throw new Error('Not authenticated');
// End any active sessions
const activeSessions = await ctx.db
.query('quizSessions')
.withIndex('by_user_and_active', (q) =>
q.eq('userId', userId).eq('isActive', true),
)
.collect();
for (const session of activeSessions) {
await ctx.db.patch(session._id, { isActive: false });
}
// Get questions
let questionIds = args.questionIds;
if (!questionIds || questionIds.length === 0) {
const allQuestions = await ctx.db.query('questions').collect();
questionIds = allQuestions.map((q) => q._id);
}
// Shuffle questions
const shuffled = [...questionIds].sort(() => Math.random() - 0.5);
return await ctx.db.insert('quizSessions', {
userId,
activeQuestions: shuffled,
missedQuestions: [],
completedQuestions: [],
currentQuestionIndex: 0,
isActive: true,
});
},
});
export const getActiveSession = query({
args: {},
handler: async (ctx) => {
const userId = await getAuthUserId(ctx);
if (!userId) throw new Error('Not authenticated');
const session = await ctx.db
.query('quizSessions')
.withIndex('by_user_and_active', (q) =>
q.eq('userId', userId).eq('isActive', true),
)
.first();
return session;
},
});
export const getCurrentQuestion = query({
args: {},
handler: async (ctx) => {
const userId = await getAuthUserId(ctx);
if (!userId) throw new Error('Not authenticated');
const session = await ctx.db
.query('quizSessions')
.withIndex('by_user_and_active', (q) =>
q.eq('userId', userId).eq('isActive', true),
)
.first();
if (!session) return null;
const allQuestions = [
...session.activeQuestions,
...session.missedQuestions,
];
if (allQuestions.length === 0) return null;
const currentQuestionId = allQuestions[session.currentQuestionIndex];
const question = await ctx.db.get(currentQuestionId);
return {
question,
sessionId: session._id,
progress: {
current: session.currentQuestionIndex + 1,
total: allQuestions.length,
completed: session.completedQuestions.length,
},
};
},
});
export const answerQuestion = mutation({
args: {
sessionId: v.id('quizSessions'),
questionId: v.id('questions'),
selectedAnswer: v.number(),
},
handler: async (ctx, args) => {
const userId = await getAuthUserId(ctx);
if (!userId) throw new Error('Not authenticated');
const session = await ctx.db.get(args.sessionId);
if (!session || session.userId !== userId) {
throw new Error('Invalid session');
}
const question = await ctx.db.get(args.questionId);
if (!question) throw new Error('Question not found');
const isCorrect = args.selectedAnswer === question.correctAnswer;
// Update user progress
const existingProgress = await ctx.db
.query('userProgress')
.withIndex('by_user_and_question', (q) =>
q.eq('userId', userId).eq('questionId', args.questionId),
)
.first();
if (existingProgress) {
await ctx.db.patch(existingProgress._id, {
correctCount: existingProgress.correctCount + (isCorrect ? 1 : 0),
incorrectCount: existingProgress.incorrectCount + (isCorrect ? 0 : 1),
lastAttempted: Date.now(),
});
} else {
await ctx.db.insert('userProgress', {
userId,
questionId: args.questionId,
correctCount: isCorrect ? 1 : 0,
incorrectCount: isCorrect ? 0 : 1,
lastAttempted: Date.now(),
});
}
// Update session
const allQuestions = [
...session.activeQuestions,
...session.missedQuestions,
];
let newActiveQuestions = [...session.activeQuestions];
let newMissedQuestions = [...session.missedQuestions];
let newCompletedQuestions = [...session.completedQuestions];
// Remove current question from active list
const currentIndex = newActiveQuestions.indexOf(args.questionId);
if (currentIndex !== -1) {
newActiveQuestions.splice(currentIndex, 1);
} else {
const missedIndex = newMissedQuestions.indexOf(args.questionId);
if (missedIndex !== -1) {
newMissedQuestions.splice(missedIndex, 1);
}
}
if (isCorrect) {
newCompletedQuestions.push(args.questionId);
} else {
newMissedQuestions.push(args.questionId);
}
const newAllQuestions = [...newActiveQuestions, ...newMissedQuestions];
const newIndex =
newAllQuestions.length > 0 ? 0 : session.currentQuestionIndex;
await ctx.db.patch(args.sessionId, {
activeQuestions: newActiveQuestions,
missedQuestions: newMissedQuestions,
completedQuestions: newCompletedQuestions,
currentQuestionIndex: newIndex,
});
return {
isCorrect,
correctAnswer: question.correctAnswer,
explanation: question.explanation,
};
},
});
export const endQuizSession = mutation({
args: {
sessionId: v.id('quizSessions'),
},
handler: async (ctx, args) => {
const userId = await getAuthUserId(ctx);
if (!userId) throw new Error('Not authenticated');
const session = await ctx.db.get(args.sessionId);
if (!session || session.userId !== userId) {
throw new Error('Invalid session');
}
await ctx.db.patch(args.sessionId, { isActive: false });
return null;
},
});
export const getUserStats = query({
args: {},
handler: async (ctx) => {
const userId = await getAuthUserId(ctx);
if (!userId) throw new Error('Not authenticated');
const progress = await ctx.db
.query('userProgress')
.withIndex('by_user', (q) => q.eq('userId', userId))
.collect();
const totalQuestions = progress.length;
const totalCorrect = progress.reduce((sum, p) => sum + p.correctCount, 0);
const totalIncorrect = progress.reduce(
(sum, p) => sum + p.incorrectCount,
0,
);
const totalAttempts = totalCorrect + totalIncorrect;
return {
totalQuestions,
totalAttempts,
totalCorrect,
totalIncorrect,
accuracy: totalAttempts > 0 ? (totalCorrect / totalAttempts) * 100 : 0,
};
},
});

View File

@@ -1,6 +1,6 @@
import { defineSchema, defineTable } from "convex/server";
import { v, VId } from "convex/values";
import { authTables } from '@convex-dev/auth/server';
import { defineSchema, defineTable } from 'convex/server';
import { v, VId } from 'convex/values';
const applicationTables = {
questions: defineTable({
@@ -10,8 +10,7 @@ const applicationTables = {
topic: v.string(),
difficulty: v.optional(v.string()),
explanation: v.optional(v.string()),
})
.index('by_topic', ['topic']),
}).index('by_topic', ['topic']),
userProgress: defineTable({
userId: v.id('users'),
@@ -30,8 +29,7 @@ const applicationTables = {
completedQuestions: v.array(v.id('questions')),
currentQuestionIndex: v.number(),
isActive: v.boolean(),
})
.index('by_user_and_active', ['userId', 'isActive']),
}).index('by_user_and_active', ['userId', 'isActive']),
};
export default defineSchema({

View File

@@ -1,16 +0,0 @@
export function missingEnvVariableUrl(envVarName: string, whereToGet: string) {
const deployment = deploymentName();
if (!deployment) return `Missing ${envVarName} in environment variables.`;
return (
`\n Missing ${envVarName} in environment variables.\n\n` +
` Get it from ${whereToGet} .\n Paste it on the Convex dashboard:\n` +
` https://dashboard.convex.dev/d/${deployment}/settings?var=${envVarName}`
);
}
export function deploymentName() {
const url = process.env.CONVEX_CLOUD_URL;
if (!url) return undefined;
const regex = new RegExp("https://(.+).convex.cloud");
return regex.exec(url)?.[1];
}