Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 66 additions & 33 deletions backend/convex/README.md
Original file line number Diff line number Diff line change
@@ -1,55 +1,88 @@
# PolyBuys Backend (Convex)
# Welcome to your Convex functions directory!

This directory contains the Convex serverless backend for PolyBuys.
Write your Convex functions here.
See https://docs.convex.dev/functions for more.

**Note**: We use self-hosted Convex deployed on Railway, not Convex cloud.
A query function that takes two arguments looks like:

## Getting Started
```ts
// convex/myFunctions.ts
import { query } from './_generated/server';
import { v } from 'convex/values';

First, create `backend/.env.local` with:
export const myQueryFunction = query({
// Validators for arguments.
args: {
first: v.number(),
second: v.string(),
},

```bash
CONVEX_SELF_HOSTED_URL='https://api.polybuys.com'
CONVEX_SELF_HOSTED_ADMIN_KEY='<admin-key-from-team>'
```
// Function implementation.
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();

Then run the Convex development server:
// Arguments passed from the client are properties of the args object.
console.log(args.first, args.second);

```bash
npm run dev:backend
// Write arbitrary JavaScript here: filter, aggregate, build derived data,
// remove non-public properties, or create new objects.
return documents;
},
});
```

This connects to our self-hosted Convex backend, watches for changes, and syncs your schema and functions to the deployment.

## Project Structure

- **`schema.ts`** - Database schema definitions (tables, indexes)
- **`listings.ts`** - CRUD functions for marketplace listings
- **`_generated/`** - Auto-generated types (do not edit manually)
- **`__tests__/`** - Test files for backend functions
Using this query function in a React component looks like:

## Adding New Functions
```ts
const data = useQuery(api.myFunctions.myQueryFunction, {
first: 10,
second: 'hello',
});
```

Create new `.ts` files in this directory and export query/mutation functions:
A mutation function looks like:

```ts
import { query } from './_generated/server';
// convex/myFunctions.ts
import { mutation } from './_generated/server';
import { v } from 'convex/values';

export const myQuery = query({
args: { id: v.id('tableName') },
export const myMutationFunction = mutation({
// Validators for arguments.
args: {
first: v.string(),
second: v.string(),
},

// Function implementation.
handler: async (ctx, args) => {
return await ctx.db.get(args.id);
// Insert or modify documents in the database here.
// 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);

// Optionally, return a value from your mutation.
return await ctx.db.get('messages', id);
},
});
```

## Updating the Schema

Edit `schema.ts` to add tables or modify fields. Convex will automatically migrate your data.
Using this mutation function in a React component looks like:

## Resources
```ts
const mutation = useMutation(api.myFunctions.myMutationFunction);
function handleButtonPress() {
// fire and forget, the most common way to use mutations
mutation({ first: 'Hello!', second: 'me' });
// OR
// use the result once the mutation has completed
mutation({ first: 'Hello!', second: 'me' }).then((result) => console.log(result));
}
```

- [Convex Docs](https://docs.convex.dev)
- [Functions Guide](https://docs.convex.dev/functions)
- [Database Guide](https://docs.convex.dev/database)
Use the Convex CLI to push your functions to a deployment. See everything
the Convex CLI can do by running `npx convex -h` in your project root
directory. To learn more, launch the docs with `npx convex docs`.
76 changes: 76 additions & 0 deletions backend/convex/ResendOTP.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { Email } from '@convex-dev/auth/providers/Email';
import { Resend as ResendAPI } from 'resend';
import type { RandomReader } from '@oslojs/crypto/random';
import { generateRandomString } from '@oslojs/crypto/random';
import { isCalPolyEmail } from '@polybuys/shared';
import { ConvexError } from 'convex/values';

/**
* Resend OTP Email Provider for Cal Poly authentication
* Sends an 8-digit verification code that expires in 15 minutes
*/
export const ResendOTP = Email({
id: 'resend-otp',
apiKey: process.env.AUTH_RESEND_KEY,
maxAge: 60 * 15, // 15 minutes

async generateVerificationToken() {
const random: RandomReader = {
read(bytes: Uint8Array) {
crypto.getRandomValues(bytes);
},
};
const alphabet = '0123456789';
const length = 8;
return generateRandomString(random, alphabet, length);
},

async sendVerificationRequest({ identifier: email, provider, token }) {
// Validate API key is configured
if (!provider.apiKey) {
throw new ConvexError('Email service not configured. Please contact support.');
}

// Validate Cal Poly email domain
if (!isCalPolyEmail(email)) {
throw new ConvexError('Email must be a @calpoly.edu address');
}

const resend = new ResendAPI(provider.apiKey);

const { error } = await resend.emails.send({
from: 'PolyBuys <onboarding@resend.dev>',
to: [email],
subject: 'Your PolyBuys verification code',
text: `Your verification code is: ${token}

This code will expire in 15 minutes.

If you didn't request this code, you can safely ignore this email.`,
html: `
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 480px; margin: 0 auto; padding: 40px 20px;">
<h1 style="color: #154734; margin-bottom: 24px; font-size: 24px;">PolyBuys</h1>
<p style="color: #333; font-size: 16px; margin-bottom: 24px;">
Your verification code is:
</p>
<div style="background: #f4f4f5; border-radius: 8px; padding: 20px; text-align: center; margin-bottom: 24px;">
<span style="font-family: monospace; font-size: 32px; font-weight: bold; letter-spacing: 4px; color: #154734;">
${token}
</span>
</div>
<p style="color: #666; font-size: 14px; margin-bottom: 8px;">
This code will expire in 15 minutes.
</p>
<p style="color: #999; font-size: 12px;">
If you didn't request this code, you can safely ignore this email.
</p>
</div>
`,
});

if (error) {
console.error('Resend error:', error);
throw new ConvexError('Failed to send verification email. Please try again.');
}
},
});
8 changes: 8 additions & 0 deletions backend/convex/_generated/api.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,11 @@
* @module
*/

import type * as ResendOTP from "../ResendOTP.js";
import type * as auth from "../auth.js";
import type * as http from "../http.js";
import type * as listings from "../listings.js";
import type * as users from "../users.js";

import type {
ApiFromModules,
Expand All @@ -17,7 +21,11 @@ import type {
} from "convex/server";

declare const fullApi: ApiFromModules<{
ResendOTP: typeof ResendOTP;
auth: typeof auth;
http: typeof http;
listings: typeof listings;
users: typeof users;
}>;

/**
Expand Down
10 changes: 10 additions & 0 deletions backend/convex/auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { convexAuth } from '@convex-dev/auth/server';
import { ResendOTP } from './ResendOTP';

/**
* Convex Auth configuration with Resend OTP provider
* Users sign in with their @calpoly.edu email and receive a verification code
*/
export const { auth, signIn, signOut, store, isAuthenticated } = convexAuth({
providers: [ResendOTP],
});
9 changes: 9 additions & 0 deletions backend/convex/http.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { httpRouter } from 'convex/server';
import { auth } from './auth';

const http = httpRouter();

// Add Convex Auth HTTP routes for email verification links, etc.
auth.addHttpRoutes(http);

export default http;
5 changes: 5 additions & 0 deletions backend/convex/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,4 +31,9 @@ export default defineSchema({
.searchIndex('search_title', {
searchField: 'title',
}),
users: defineTable({
email: v.string(),
name: v.union(v.string(), v.null()),
createdAt: v.number(),
}).index('by_email', ['email']),
});
3 changes: 1 addition & 2 deletions backend/convex/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@
"jsx": "react-jsx",
"skipLibCheck": true,
"allowSyntheticDefaultImports": true,
"types": [],

/* These compiler options are required by Convex */
"target": "ESNext",
Expand All @@ -22,5 +21,5 @@
"noEmit": true
},
"include": ["./**/*"],
"exclude": ["./_generated", "./__tests__"]
"exclude": ["./_generated"]
}
122 changes: 122 additions & 0 deletions backend/convex/users.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import { getAuthUserId } from '@convex-dev/auth/server';
import { query, mutation } from './_generated/server';
import { v, ConvexError } from 'convex/values';

/**
* Get the current authenticated user's profile from our users table
*/
export const getCurrentUser = query({
args: {},
handler: async (ctx) => {
const userId = await getAuthUserId(ctx);
if (userId === null) {
return null;
}

// Get the auth user record
const authUser = await ctx.db.get(userId);
if (!authUser || !authUser.email) {
return null;
}

// Find user profile by email
const email = authUser.email.toLowerCase().trim();
const userProfile = await ctx.db
.query('users')
.withIndex('by_email', (q) => q.eq('email', email))
.first();
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Comment thread
coderabbitai[bot] marked this conversation as resolved.

return userProfile;
},
});

/**
* Check if an email already exists
*/
export const checkEmailExists = query({
args: { email: v.string() },
handler: async (ctx, args) => {
const existingUser = await ctx.db
.query('users')
.withIndex('by_email', (q) => q.eq('email', args.email.toLowerCase().trim()))
.first();

return existingUser !== null;
},
});

/**
* Update user profile (name, etc.)
*/
export const updateUserProfile = mutation({
args: {
name: v.union(v.string(), v.null()),
},
handler: async (ctx, args) => {
const userId = await getAuthUserId(ctx);
if (userId === null) {
throw new ConvexError('Not authenticated');
}

const authUser = await ctx.db.get(userId);
if (!authUser || !authUser.email) {
throw new ConvexError('User not found');
}

const email = authUser.email.toLowerCase().trim();
const userProfile = await ctx.db
.query('users')
.withIndex('by_email', (q) => q.eq('email', email))
.first();
Comment thread
coderabbitai[bot] marked this conversation as resolved.

if (!userProfile) {
throw new ConvexError('User profile not found');
}

await ctx.db.patch(userProfile._id, {
name: args.name,
});

return await ctx.db.get(userProfile._id);
},
});

/**
* Get or create user profile after authentication
* Called when user successfully authenticates via OTP
*/
export const getOrCreateUser = mutation({
args: {},
handler: async (ctx) => {
const userId = await getAuthUserId(ctx);
if (userId === null) {
throw new ConvexError('Not authenticated');
}

const authUser = await ctx.db.get(userId);
if (!authUser || !authUser.email) {
throw new ConvexError('Auth user not found');
}

const email = authUser.email.toLowerCase().trim();

// Check if user profile already exists
const existingProfile = await ctx.db
.query('users')
.withIndex('by_email', (q) => q.eq('email', email))
.first();

if (existingProfile) {
return existingProfile;
}

// Create new user profile
const profileId = await ctx.db.insert('users', {
email,
name: authUser.name || null,
createdAt: Date.now(),
});

return await ctx.db.get(profileId);
},
});
Loading