Update repo

This commit is contained in:
2026-01-10 12:01:28 -05:00
parent 235c928dc5
commit 843b264c61
22 changed files with 680 additions and 336 deletions

View File

@@ -8,34 +8,54 @@
* @module
*/
import type * as auth from "../auth.js";
import type * as custom_auth_index from "../custom/auth/index.js";
import type * as custom_auth_providers_password from "../custom/auth/providers/password.js";
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 {
ApiFromModules,
FilterApi,
FunctionReference,
} from "convex/server";
import type * as notes from "../notes.js";
import type * as openai from "../openai.js";
import type * as utils from "../utils.js";
declare const fullApi: ApiFromModules<{
auth: typeof auth;
"custom/auth/index": typeof custom_auth_index;
"custom/auth/providers/password": typeof custom_auth_providers_password;
"custom/auth/providers/usesend": typeof custom_auth_providers_usesend;
http: typeof http;
questions: typeof questions;
utils: typeof utils;
}>;
/**
* A utility for referencing Convex functions in your app's API.
* A utility for referencing Convex functions in your app's public API.
*
* Usage:
* ```js
* const myFunctionReference = api.myModule.myFunction;
* ```
*/
declare const fullApi: ApiFromModules<{
notes: typeof notes;
openai: typeof openai;
utils: typeof utils;
}>;
export declare const api: FilterApi<
typeof fullApi,
FunctionReference<any, "public">
>;
/**
* A utility for referencing Convex functions in your app's internal API.
*
* Usage:
* ```js
* const myFunctionReference = internal.myModule.myFunction;
* ```
*/
export declare const internal: FilterApi<
typeof fullApi,
FunctionReference<any, "internal">
>;
export declare const components: {};

View File

@@ -8,7 +8,7 @@
* @module
*/
import { anyApi } from "convex/server";
import { anyApi, componentsGeneric } from "convex/server";
/**
* A utility for referencing Convex functions in your app's API.
@@ -20,3 +20,4 @@ import { anyApi } from "convex/server";
*/
export const api = anyApi;
export const internal = anyApi;
export const components = componentsGeneric();

View File

@@ -11,11 +11,10 @@
import type {
DataModelFromSchemaDefinition,
DocumentByName,
SystemTableNames,
TableNamesInDataModel,
SystemTableNames,
} from "convex/server";
import type { GenericId } from "convex/values";
import schema from "../schema.js";
/**
@@ -39,7 +38,7 @@ export type Doc<TableName extends TableNames> = DocumentByName<
* Convex documents are uniquely identified by their `Id`, which is accessible
* on the `_id` field. To learn more, see [Document IDs](https://docs.convex.dev/using/document-ids).
*
* Documents can be loaded using `db.get(id)` in query and mutation functions.
* Documents can be loaded using `db.get(tableName, id)` in query and mutation functions.
*
* IDs are just strings at runtime, but this type can be used to distinguish them from other
* strings when type checking.

View File

@@ -10,16 +10,15 @@
import {
ActionBuilder,
GenericActionCtx,
GenericDatabaseReader,
GenericDatabaseWriter,
GenericMutationCtx,
GenericQueryCtx,
HttpActionBuilder,
MutationBuilder,
QueryBuilder,
GenericActionCtx,
GenericMutationCtx,
GenericQueryCtx,
GenericDatabaseReader,
GenericDatabaseWriter,
} from "convex/server";
import type { DataModel } from "./dataModel.js";
/**
@@ -86,11 +85,12 @@ export declare const internalAction: ActionBuilder<DataModel, "internal">;
/**
* Define an HTTP action.
*
* This function will be used to respond to HTTP requests received by a Convex
* deployment if the requests matches the path and method where this action
* is routed. Be sure to route your action in `convex/http.js`.
* The wrapped function will be used to respond to HTTP requests received
* by a Convex deployment if the requests matches the path and method where
* this action is routed. Be sure to route your httpAction in `convex/http.js`.
*
* @param func - The function. It receives an {@link ActionCtx} as its first argument.
* @param func - The function. It receives an {@link ActionCtx} as its first argument
* and a Fetch API `Request` object as its second.
* @returns The wrapped function. Import this function from `convex/http.js` and route it to hook it up.
*/
export declare const httpAction: HttpActionBuilder;

View File

@@ -11,11 +11,11 @@
import {
actionGeneric,
httpActionGeneric,
queryGeneric,
mutationGeneric,
internalActionGeneric,
internalMutationGeneric,
internalQueryGeneric,
mutationGeneric,
queryGeneric,
} from "convex/server";
/**
@@ -80,10 +80,14 @@ export const action = actionGeneric;
export const internalAction = internalActionGeneric;
/**
* Define a Convex HTTP action.
* Define an HTTP action.
*
* @param func - The function. It receives an {@link ActionCtx} as its first argument, and a `Request` object
* as its second.
* @returns The wrapped endpoint function. Route a URL path to this function in `convex/http.js`.
* The wrapped function will be used to respond to HTTP requests received
* by a Convex deployment if the requests matches the path and method where
* this action is routed. Be sure to route your httpAction in `convex/http.js`.
*
* @param func - The function. It receives an {@link ActionCtx} as its first argument
* and a Fetch API `Request` object as its second.
* @returns The wrapped function. Import this function from `convex/http.js` and route it to hook it up.
*/
export const httpAction = httpActionGeneric;

View File

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

View File

@@ -0,0 +1,121 @@
import { ConvexError, v } from 'convex/values';
import {
convexAuth,
getAuthUserId,
retrieveAccount,
modifyAccountCredentials,
} from '@convex-dev/auth/server';
import { api } from './_generated/api';
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 { Password, validatePassword } from './custom/auth';
export const { auth, signIn, signOut, store, isAuthenticated } = convexAuth({
providers: [
Authentik({ allowDangerousEmailAccountLinking: true }),
Password,
],
});
const getUserById = async (
ctx: QueryCtx,
userId: Id<'users'>
): Promise<Doc<'users'>> => {
const user = await ctx.db.get(userId);
if (!user) throw new ConvexError('User not found.');
return user;
};
const isSignedIn = async (ctx: QueryCtx): Promise<Doc<'users'> | null> => {
const userId = await getAuthUserId(ctx);
if (!userId) return null;
const user = await ctx.db.get(userId);
if (!user) return null;
return user;
};
export const getUser = query({
args: { userId: v.optional(v.id('users')) },
handler: async (ctx, args) => {
const user = await isSignedIn(ctx);
const userId = args.userId ?? user?._id;
if (!userId) throw new ConvexError('Not authenticated or no ID provided.');
return getUserById(ctx, userId);
},
});
export const getAllUsers = query(async (ctx) => {
const users = await ctx.db.query('users').collect();
return users ?? null;
});
export const getAllUserIds = query(async (ctx) => {
const users = await ctx.db.query('users').collect();
return users.map((u) => u._id);
});
export const updateUser = mutation({
args: {
name: v.optional(v.string()),
email: v.optional(v.string()),
image: v.optional(v.id('_storage')),
},
handler: async (ctx, args) => {
const userId = await getAuthUserId(ctx);
if (!userId) throw new ConvexError('Not authenticated.');
const user = await ctx.db.get(userId);
if (!user) throw new ConvexError('User not found.');
const patch: Partial<{
name: string;
email: string;
image: Id<'_storage'>;
}> = {};
if (args.name !== undefined) patch.name = args.name;
if (args.email !== undefined) patch.email = args.email;
if (args.image !== undefined) {
const oldImage = user.image as Id<'_storage'> | undefined;
patch.image = args.image;
if (oldImage && oldImage !== args.image) {
await ctx.storage.delete(oldImage);
}
}
if (Object.keys(patch).length > 0) {
await ctx.db.patch(userId, patch);
}
return { success: true };
},
});
export const updateUserPassword = action({
args: {
currentPassword: v.string(),
newPassword: v.string(),
},
handler: async (ctx, { currentPassword, newPassword }) => {
const userId = await getAuthUserId(ctx);
if (!userId) throw new ConvexError('Not authenticated.');
const user = await ctx.runQuery(api.auth.getUser, { userId });
if (!user?.email) throw new ConvexError('User not found.');
const verified = await retrieveAccount(ctx, {
provider: 'password',
account: { id: user.email, secret: currentPassword },
});
if (!verified) throw new ConvexError('Current password is incorrect.');
if (!validatePassword(newPassword))
throw new ConvexError('Invalid password.');
await modifyAccountCredentials(ctx, {
provider: 'password',
account: { id: user.email, secret: newPassword },
});
return { success: true };
},
});

View File

@@ -0,0 +1,2 @@
export { Password, validatePassword } from './providers/password';
export { UseSendOTP, UseSendOTPPasswordReset } from './providers/usesend';

View File

@@ -0,0 +1,33 @@
import { Password as DefaultPassword } from '@convex-dev/auth/providers/Password';
import { DataModel } from '../../../_generated/dataModel';
import { UseSendOTP, UseSendOTPPasswordReset } from '..';
import { ConvexError } from 'convex/values';
export const Password = DefaultPassword<DataModel>({
profile(params, ctx) {
return {
email: params.email as string,
name: params.name as string,
};
},
validatePasswordRequirements: (password: string) => {
if (!validatePassword(password)) {
throw new ConvexError('Invalid password.');
}
},
reset: UseSendOTPPasswordReset,
verify: UseSendOTP,
});
export const validatePassword = (password: string): boolean => {
if (
password.length < 8 ||
password.length > 100 ||
!/\d/.test(password) ||
!/[a-z]/.test(password) ||
!/[A-Z]/.test(password)
) {
return false;
}
return true;
};

View File

@@ -0,0 +1,90 @@
import type { EmailConfig, EmailUserConfig } from '@auth/core/providers/email';
import { alphabet } from 'oslo/crypto';
import { generateRandomString, RandomReader } from '@oslojs/crypto/random';
import { UseSend } from 'usesend-js';
export default function UseSendProvider(config: EmailUserConfig): EmailConfig {
return {
id: 'usesend',
type: 'email',
name: 'UseSend',
from: 'TechTracker <admin@techtracker.gbrown.org>',
maxAge: 24 * 60 * 60, // 24 hours
async generateVerificationToken() {
const random: RandomReader = {
read(bytes) {
crypto.getRandomValues(bytes);
},
};
return generateRandomString(random, alphabet('0-9'), 6);
},
async sendVerificationRequest(params) {
const { identifier: to, provider, url, theme, token } = params;
//const { host } = new URL(url);
const host = 'TechTracker';
const useSend = new UseSend(
process.env.AUTH_USESEND_API_KEY!,
'https://usesend.gbrown.org',
);
// For password reset, we want to send the code, not the magic link
const isPasswordReset =
url.includes('reset') || provider.id?.includes('reset');
const result = await useSend.emails.send({
from: provider.from!,
to: [to],
subject: isPasswordReset
? `Reset your password - ${host}`
: `Sign in to ${host}`,
text: isPasswordReset
? `Your password reset code is ${token}`
: `Your sign in code is ${token}`,
html: isPasswordReset
? `
<div style="max-width: 600px; margin: 0 auto; font-family: Arial, sans-serif;">
<h2>Password Reset Request</h2>
<p>You requested a password reset. Your reset code is:</p>
<div style="font-size: 32px; font-weight: bold; text-align: center; padding: 20px; background: #f5f5f5; margin: 20px 0; border-radius: 8px;">
${token}
</div>
<p>This code expires in 1 hour.</p>
<p>If you didn't request this, please ignore this email.</p>
</div>
`
: `
<div style="max-width: 600px; margin: 0 auto; font-family: Arial, sans-serif;">
<h2>Your Sign In Code</h2>
<p>Your verification code is:</p>
<div style="font-size: 32px; font-weight: bold; text-align: center; padding: 20px; background: #f5f5f5; margin: 20px 0; border-radius: 8px;">
${token}
</div>
<p>This code expires in 24 hours.</p>
</div>
`,
});
if (result.error) {
throw new Error('UseSend error: ' + JSON.stringify(result.error));
}
},
options: config,
};
}
// Create specific instances for password reset and email verification
export const UseSendOTPPasswordReset = UseSendProvider({
id: 'usesend-otp-password-reset',
apiKey: process.env.AUTH_USESEND_API_KEY,
maxAge: 60 * 60, // 1 hour
});
export const UseSendOTP = UseSendProvider({
id: 'usesend-otp',
apiKey: process.env.AUTH_USESEND_API_KEY,
maxAge: 60 * 20, // 20 minutes
});

View File

@@ -0,0 +1,8 @@
import { httpRouter } from 'convex/server';
import { auth } from './auth';
const http = httpRouter();
auth.addHttpRoutes(http);
export default http;

View File

@@ -1,71 +0,0 @@
import { Auth } from "convex/server";
import { v } from "convex/values";
import { internal } from "../convex/_generated/api";
import { mutation, query } from "./_generated/server";
export const getUserId = async (ctx: { auth: Auth }) => {
return (await ctx.auth.getUserIdentity())?.subject;
};
// Get all notes for a specific user
export const getNotes = query({
args: {},
handler: async (ctx) => {
const userId = await getUserId(ctx);
if (!userId) return null;
const notes = await ctx.db
.query("notes")
.filter((q) => q.eq(q.field("userId"), userId))
.collect();
return notes;
},
});
// Get note for a specific note
export const getNote = query({
args: {
id: v.optional(v.id("notes")),
},
handler: async (ctx, args) => {
const { id } = args;
if (!id) return null;
const note = await ctx.db.get(id);
return note;
},
});
// Create a new note for a user
export const createNote = mutation({
args: {
title: v.string(),
content: v.string(),
isSummary: v.boolean(),
},
handler: async (ctx, { title, content, isSummary }) => {
const userId = await getUserId(ctx);
if (!userId) throw new Error("User not found");
const noteId = await ctx.db.insert("notes", { userId, title, content });
if (isSummary) {
await ctx.scheduler.runAfter(0, internal.openai.summary, {
id: noteId,
title,
content,
});
}
return noteId;
},
});
export const deleteNote = mutation({
args: {
noteId: v.id("notes"),
},
handler: async (ctx, args) => {
await ctx.db.delete(args.noteId);
},
});

View File

@@ -1,76 +0,0 @@
import { v } from "convex/values";
import OpenAI from "openai";
import { internal } from "./_generated/api";
import { internalAction, internalMutation, query } from "./_generated/server";
import { missingEnvVariableUrl } from "./utils";
export const openaiKeySet = query({
args: {},
handler: async () => {
return !!process.env.OPENAI_API_KEY;
},
});
export const summary = internalAction({
args: {
id: v.id("notes"),
title: v.string(),
content: v.string(),
},
handler: async (ctx, { id, title, content }) => {
const prompt = `Take in the following note and return a summary: Title: ${title}, Note content: ${content}`;
const apiKey = process.env.OPENAI_API_KEY;
if (!apiKey) {
const error = missingEnvVariableUrl(
"OPENAI_API_KEY",
"https://platform.openai.com/account/api-keys",
);
console.error(error);
await ctx.runMutation(internal.openai.saveSummary, {
id: id,
summary: error,
});
return;
}
const openai = new OpenAI({ apiKey });
const output = await openai.chat.completions.create({
messages: [
{
role: "system",
content:
"You are a helpful assistant designed to output JSON in this format: {summary: string}",
},
{ role: "user", content: prompt },
],
model: "gpt-4-1106-preview",
response_format: { type: "json_object" },
});
// Pull the message content out of the response
const messageContent = output.choices[0]?.message.content;
console.log({ messageContent });
const parsedOutput = JSON.parse(messageContent!);
console.log({ parsedOutput });
await ctx.runMutation(internal.openai.saveSummary, {
id: id,
summary: parsedOutput.summary,
});
},
});
export const saveSummary = internalMutation({
args: {
id: v.id("notes"),
summary: v.string(),
},
handler: async (ctx, { id, summary }) => {
await ctx.db.patch(id, {
summary: summary,
});
},
});

View File

@@ -0,0 +1,116 @@
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";
const openai = new OpenAI({
baseURL: process.env.OPENAI_BASE_URL,
apiKey: process.env.OPENAI_API_KEY,
});
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();
},
});
export const getQuestionsByTopic = query({
args: { topic: v.string() },
handler: async (ctx, args) => {
const userId = await getAuthUserId(ctx);
if (!userId) throw new Error("Not authenticated");
return await ctx.db
.query("questions")
.withIndex("by_topic", (q) => q.eq("topic", args.topic))
.collect();
},
});
export const addQuestion = mutation({
args: {
question: v.string(),
options: v.array(v.string()),
correctAnswer: v.number(),
topic: v.string(),
difficulty: v.optional(v.string()),
explanation: v.optional(v.string()),
},
handler: async (ctx, args) => {
const userId = await getAuthUserId(ctx);
if (!userId) throw new Error("Not authenticated");
return await ctx.db.insert("questions", {
question: args.question,
options: args.options,
correctAnswer: args.correctAnswer,
topic: args.topic,
difficulty: args.difficulty,
explanation: args.explanation,
});
},
});
export const generateQuestions = action({
args: {
topic: v.string(),
count: v.number(),
},
handler: async (ctx, args) => {
const userId = await getAuthUserId(ctx);
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:
[
{
"question": "Question text here?",
"options": ["Option A", "Option B", "Option C", "Option D"],
"correctAnswer": 0,
"topic": "${args.topic}",
"difficulty": "medium",
"explanation": "Brief explanation of the correct answer"
}
]
Rules:
- Each question must have exactly 4 options
- correctAnswer is the index (0-3) of the correct option
- Make questions realistic for Network+ certification
- Include technical details and scenarios
- Return ONLY the JSON array, no other text`;
const response = await openai.chat.completions.create({
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");
let questions;
try {
questions = JSON.parse(content);
} catch (e) {
throw new Error("Failed to parse AI response as JSON");
}
const questionIds = [];
for (const q of questions) {
const id: string = await ctx.runMutation(api.questions.addQuestion, {
question: q.question,
options: q.options,
correctAnswer: q.correctAnswer,
topic: q.topic,
difficulty: q.difficulty,
explanation: q.explanation,
});
questionIds.push(id);
}
return questionIds;
},
});

View File

@@ -1,11 +1,40 @@
import { defineSchema, defineTable } from "convex/server";
import { v } from "convex/values";
import { v, VId } from "convex/values";
import { authTables } from '@convex-dev/auth/server';
const applicationTables = {
questions: defineTable({
question: v.string(),
options: v.array(v.string()),
correctAnswer: v.number(),
topic: v.string(),
difficulty: v.optional(v.string()),
explanation: v.optional(v.string()),
})
.index('by_topic', ['topic']),
userProgress: defineTable({
userId: v.id('users'),
questionId: v.id('questions'),
correctCount: v.number(),
incorrectCount: v.number(),
lastAttempted: v.number(),
})
.index('by_user', ['userId'])
.index('by_user_and_question', ['userId', 'questionId']),
quizSessions: defineTable({
userId: v.id('users'),
activeQuestions: v.array(v.id('questions')),
missedQuestions: v.array(v.id('questions')),
completedQuestions: v.array(v.id('questions')),
currentQuestionIndex: v.number(),
isActive: v.boolean(),
})
.index('by_user_and_active', ['userId', 'isActive']),
};
export default defineSchema({
notes: defineTable({
userId: v.string(),
title: v.string(),
content: v.string(),
summary: v.optional(v.string()),
}),
...authTables,
...applicationTables,
});

View File

@@ -8,9 +8,9 @@
"license": "MIT",
"exports": {},
"scripts": {
"dev": "convex dev",
"dev:tunnel": "convex dev",
"setup": "convex dev --until-success",
"dev": "dotenv -e ../../.env -- convex dev",
"dev:tunnel": "dotenv -e ../../.env -- convex dev",
"setup": "dotenv -e ../../.env -- convex dev --until-success",
"clean": "git clean -xdf .cache .turbo dist node_modules",
"format": "prettier --check . --ignore-path ../../.gitignore",
"lint": "eslint --flag unstable_native_nodejs_ts_config",
@@ -21,6 +21,7 @@
"@react-email/components": "0.5.4",
"@react-email/render": "^1.4.0",
"convex": "catalog:convex",
"openai": "catalog:",
"react": "catalog:react19",
"react-dom": "catalog:react19",
"usesend-js": "^1.5.6",