Initial commit for project Spoon!
This commit is contained in:
@@ -0,0 +1,92 @@
|
||||
# Welcome to your Convex functions directory!
|
||||
|
||||
Write your Convex functions here. See
|
||||
https://docs.convex.dev/using/writing-convex-functions for more.
|
||||
|
||||
A query function that takes two arguments looks like:
|
||||
|
||||
```ts
|
||||
// functions.js
|
||||
import { v } from 'convex/values';
|
||||
|
||||
import { query } from './_generated/server';
|
||||
|
||||
export const myQueryFunction = query({
|
||||
// Validators for arguments.
|
||||
args: {
|
||||
first: v.number(),
|
||||
second: v.string(),
|
||||
},
|
||||
|
||||
// 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();
|
||||
|
||||
// Arguments passed from the client are properties of the args object.
|
||||
console.log(args.first, args.second);
|
||||
|
||||
// Write arbitrary JavaScript here: filter, aggregate, build derived data,
|
||||
// remove non-public properties, or create new objects.
|
||||
return documents;
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Using this query function in a React component looks like:
|
||||
|
||||
```ts
|
||||
const data = useQuery(api.functions.myQueryFunction, {
|
||||
first: 10,
|
||||
second: 'hello',
|
||||
});
|
||||
```
|
||||
|
||||
A mutation function looks like:
|
||||
|
||||
```ts
|
||||
// functions.js
|
||||
import { v } from 'convex/values';
|
||||
|
||||
import { mutation } from './_generated/server';
|
||||
|
||||
export const myMutationFunction = mutation({
|
||||
// Validators for arguments.
|
||||
args: {
|
||||
first: v.string(),
|
||||
second: v.string(),
|
||||
},
|
||||
|
||||
// Function implementation.
|
||||
handler: async (ctx, args) => {
|
||||
// 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(id);
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Using this mutation function in a React component looks like:
|
||||
|
||||
```ts
|
||||
const mutation = useMutation(api.functions.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),
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
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`.
|
||||
@@ -0,0 +1,62 @@
|
||||
import { ConvexError, v } from 'convex/values';
|
||||
|
||||
import { mutation, query } from './_generated/server';
|
||||
import {
|
||||
getOwnedSpoon,
|
||||
getRequiredUserId,
|
||||
optionalText,
|
||||
requireText,
|
||||
} from './model';
|
||||
|
||||
export const listRecent = query({
|
||||
args: { limit: v.optional(v.number()) },
|
||||
handler: async (ctx, { limit }) => {
|
||||
const ownerId = await getRequiredUserId(ctx);
|
||||
return await ctx.db
|
||||
.query('agentRequests')
|
||||
.withIndex('by_owner', (q) => q.eq('ownerId', ownerId))
|
||||
.order('desc')
|
||||
.take(limit ?? 25);
|
||||
},
|
||||
});
|
||||
|
||||
export const create = mutation({
|
||||
args: {
|
||||
spoonId: v.id('spoons'),
|
||||
prompt: v.string(),
|
||||
targetBranch: v.optional(v.string()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const ownerId = await getRequiredUserId(ctx);
|
||||
await getOwnedSpoon(ctx, args.spoonId, ownerId);
|
||||
const now = Date.now();
|
||||
return await ctx.db.insert('agentRequests', {
|
||||
spoonId: args.spoonId,
|
||||
ownerId,
|
||||
prompt: requireText(args.prompt, 'Prompt'),
|
||||
status: 'queued',
|
||||
targetBranch: optionalText(args.targetBranch),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const cancel = mutation({
|
||||
args: { requestId: v.id('agentRequests') },
|
||||
handler: async (ctx, { requestId }) => {
|
||||
const ownerId = await getRequiredUserId(ctx);
|
||||
const request = await ctx.db.get(requestId);
|
||||
if (request?.ownerId !== ownerId) {
|
||||
throw new ConvexError('Agent request not found.');
|
||||
}
|
||||
if (request.status !== 'draft' && request.status !== 'queued') {
|
||||
throw new ConvexError('Only draft or queued requests can be cancelled.');
|
||||
}
|
||||
await ctx.db.patch(requestId, {
|
||||
status: 'cancelled',
|
||||
updatedAt: Date.now(),
|
||||
});
|
||||
return { success: true };
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
export default {
|
||||
providers: [
|
||||
{
|
||||
domain: process.env.CONVEX_SITE_URL,
|
||||
applicationID: 'convex',
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,122 @@
|
||||
import Authentik from '@auth/core/providers/authentik';
|
||||
import {
|
||||
convexAuth,
|
||||
getAuthUserId,
|
||||
modifyAccountCredentials,
|
||||
retrieveAccount,
|
||||
} from '@convex-dev/auth/server';
|
||||
import { ConvexError, v } from 'convex/values';
|
||||
|
||||
import type { Doc, Id } from './_generated/dataModel';
|
||||
import type { 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,
|
||||
clientId: process.env.AUTH_AUTHENTIK_ID,
|
||||
clientSecret: process.env.AUTH_AUTHENTIK_SECRET,
|
||||
issuer: process.env.AUTH_AUTHENTIK_ISSUER,
|
||||
}),
|
||||
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 getAuthAccountById = async (ctx: QueryCtx, userId: Id<'users'>) => {
|
||||
const user = await ctx.db.get(userId);
|
||||
if (!user) throw new ConvexError('User not found.');
|
||||
const authAccount = await ctx.db
|
||||
.query('authAccounts')
|
||||
.withIndex('userIdAndProvider', (q) => q.eq('userId', userId))
|
||||
.first();
|
||||
if (!authAccount) throw new ConvexError('Auth account not found');
|
||||
return authAccount;
|
||||
};
|
||||
export const getUserProvider = query({
|
||||
args: { userId: v.optional(v.id('users')) },
|
||||
handler: async (ctx, args) => {
|
||||
const userId = args.userId ?? (await getAuthUserId(ctx));
|
||||
if (!userId) return null;
|
||||
const authAccount = await getAuthAccountById(ctx, userId);
|
||||
return authAccount.provider;
|
||||
},
|
||||
});
|
||||
|
||||
export const getUser = query({
|
||||
args: { userId: v.optional(v.id('users')) },
|
||||
handler: async (ctx, args) => {
|
||||
const userId = args.userId ?? (await getAuthUserId(ctx));
|
||||
if (!userId) return null;
|
||||
return getUserById(ctx, userId);
|
||||
},
|
||||
});
|
||||
|
||||
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.');
|
||||
await retrieveAccount(ctx, {
|
||||
provider: 'password',
|
||||
account: { id: user.email, secret: currentPassword },
|
||||
});
|
||||
|
||||
if (!validatePassword(newPassword))
|
||||
throw new ConvexError('Invalid password.');
|
||||
|
||||
await modifyAccountCredentials(ctx, {
|
||||
provider: 'password',
|
||||
account: { id: user.email, secret: newPassword },
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import { cronJobs } from 'convex/server';
|
||||
|
||||
// Cron order: Minute Hour DayOfMonth Month DayOfWeek
|
||||
const crons = cronJobs();
|
||||
/* Example cron jobs
|
||||
crons.cron(
|
||||
// Run at 7:00 AM CST / 8:00 AM CDT
|
||||
// Only on weekdays
|
||||
'Schedule Automatic Lunches',
|
||||
'0 13 * * 1-5',
|
||||
api.statuses.automaticLunch,
|
||||
);
|
||||
crons.cron(
|
||||
// Run at 4:00 PM CST / 5:00 PM CDT
|
||||
// Only on weekdays
|
||||
'End of shift (weekdays 5pm CT)',
|
||||
'0 22 * * 1-5',
|
||||
api.statuses.endOfShiftUpdate,
|
||||
);
|
||||
*/
|
||||
export default crons;
|
||||
@@ -0,0 +1,2 @@
|
||||
export { Password, validatePassword } from './providers/password';
|
||||
export { UseSendOTP, UseSendOTPPasswordReset } from './providers/usesend';
|
||||
@@ -0,0 +1,34 @@
|
||||
import { Password as DefaultPassword } from '@convex-dev/auth/providers/Password';
|
||||
import { ConvexError } from 'convex/values';
|
||||
|
||||
import { UseSendOTP, UseSendOTPPasswordReset } from '..';
|
||||
import { DataModel } from '../../../_generated/dataModel';
|
||||
|
||||
export const Password = DefaultPassword<DataModel>({
|
||||
profile: (params) => ({
|
||||
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 ||
|
||||
/\s/.test(password) ||
|
||||
!/\d/.test(password) ||
|
||||
!/[a-z]/.test(password) ||
|
||||
!/[A-Z]/.test(password) ||
|
||||
!/[\p{P}\p{S}]/u.test(password)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
@@ -0,0 +1,94 @@
|
||||
import type { EmailConfig, EmailUserConfig } from '@auth/core/providers/email';
|
||||
import { generateRandomString, RandomReader } from '@oslojs/crypto/random';
|
||||
import { alphabet } from 'oslo/crypto';
|
||||
import { UseSend } from 'usesend-js';
|
||||
|
||||
export default function UseSendProvider(config: EmailUserConfig): EmailConfig {
|
||||
return {
|
||||
id: 'usesend',
|
||||
type: 'email',
|
||||
name: 'UseSend',
|
||||
from: process.env.USESEND_FROM_EMAIL ?? 'noreply@example.com',
|
||||
maxAge: 24 * 60 * 60, // 24 hours
|
||||
|
||||
generateVerificationToken: () => {
|
||||
const random: RandomReader = {
|
||||
read: (bytes) => {
|
||||
crypto.getRandomValues(bytes as Uint8Array<ArrayBuffer>);
|
||||
},
|
||||
};
|
||||
return generateRandomString(random, alphabet('0-9'), 6);
|
||||
},
|
||||
|
||||
sendVerificationRequest: async (params) => {
|
||||
const { identifier: to, provider, url, token } = params;
|
||||
// Derive a display name from the site URL, fallback to 'App'
|
||||
const siteUrl = process.env.USESEND_FROM_EMAIL ?? '';
|
||||
const appName = siteUrl.split('@')[1]?.split('.')[0] ?? 'App';
|
||||
|
||||
const apiKey = process.env.USESEND_API_KEY;
|
||||
const useSendUrl = process.env.USESEND_URL;
|
||||
if (!apiKey || !useSendUrl) {
|
||||
throw new Error('USESEND_API_KEY and USESEND_URL must be set.');
|
||||
}
|
||||
|
||||
const useSend = new UseSend(apiKey, useSendUrl);
|
||||
|
||||
// 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 ?? 'noreply@example.com',
|
||||
to: [to],
|
||||
subject: isPasswordReset
|
||||
? `Reset your password - ${appName}`
|
||||
: `Sign in to ${appName}`,
|
||||
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.USESEND_API_KEY,
|
||||
maxAge: 60 * 60, // 1 hour
|
||||
});
|
||||
|
||||
export const UseSendOTP = UseSendProvider({
|
||||
id: 'usesend-otp',
|
||||
apiKey: process.env.USESEND_API_KEY,
|
||||
maxAge: 60 * 20, // 20 minutes
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
import { getAuthUserId } from '@convex-dev/auth/server';
|
||||
import { ConvexError, v } from 'convex/values';
|
||||
|
||||
import { mutation, query } from './_generated/server';
|
||||
|
||||
export const generateUploadUrl = mutation(async (ctx) => {
|
||||
const userId = await getAuthUserId(ctx);
|
||||
if (!userId) throw new ConvexError('Not authenticated.');
|
||||
return await ctx.storage.generateUploadUrl();
|
||||
});
|
||||
|
||||
export const getImageUrl = query({
|
||||
args: { storageId: v.id('_storage') },
|
||||
handler: async (ctx, { storageId }) => {
|
||||
const url = await ctx.storage.getUrl(storageId);
|
||||
return url ?? null;
|
||||
},
|
||||
});
|
||||
Vendored
+10
@@ -0,0 +1,10 @@
|
||||
// Declare process.env for Convex backend environment variables.
|
||||
// Convex supports process.env to read variables set in the Convex Dashboard.
|
||||
declare const process: {
|
||||
readonly env: {
|
||||
readonly USESEND_API_KEY?: string;
|
||||
readonly USESEND_URL?: string;
|
||||
readonly USESEND_FROM_EMAIL?: string;
|
||||
readonly [key: string]: string | undefined;
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
import { httpRouter } from 'convex/server';
|
||||
|
||||
import { auth } from './auth';
|
||||
|
||||
const http = httpRouter();
|
||||
|
||||
auth.addHttpRoutes(http);
|
||||
|
||||
export default http;
|
||||
@@ -0,0 +1,37 @@
|
||||
import { getAuthUserId } from '@convex-dev/auth/server';
|
||||
import { ConvexError } from 'convex/values';
|
||||
|
||||
import type { Doc, Id } from './_generated/dataModel';
|
||||
import type { MutationCtx, QueryCtx } from './_generated/server';
|
||||
|
||||
type Ctx = QueryCtx | MutationCtx;
|
||||
|
||||
export const getRequiredUserId = async (ctx: Ctx): Promise<Id<'users'>> => {
|
||||
const userId = await getAuthUserId(ctx);
|
||||
if (!userId) throw new ConvexError('Not authenticated.');
|
||||
return userId;
|
||||
};
|
||||
|
||||
export const getOwnedSpoon = async (
|
||||
ctx: Ctx,
|
||||
spoonId: Id<'spoons'>,
|
||||
ownerId: Id<'users'>,
|
||||
): Promise<Doc<'spoons'>> => {
|
||||
const spoon = await ctx.db.get(spoonId);
|
||||
if (spoon?.ownerId !== ownerId) {
|
||||
throw new ConvexError('Spoon not found.');
|
||||
}
|
||||
return spoon;
|
||||
};
|
||||
|
||||
export const requireText = (value: string, label: string): string => {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) throw new ConvexError(`${label} is required.`);
|
||||
return trimmed;
|
||||
};
|
||||
|
||||
export const optionalText = (value: string | undefined) => {
|
||||
const trimmed = value?.trim();
|
||||
if (!trimmed) return undefined;
|
||||
return trimmed;
|
||||
};
|
||||
@@ -0,0 +1,175 @@
|
||||
import { authTables } from '@convex-dev/auth/server';
|
||||
import { defineSchema, defineTable } from 'convex/server';
|
||||
import { v } from 'convex/values';
|
||||
|
||||
const applicationTables = {
|
||||
/*
|
||||
* Below is the users table definition from authTables
|
||||
* You can add additional fields here. You can also remove
|
||||
* the users table here & create a 'profiles' table if you
|
||||
* prefer to keep auth data separate from application data.
|
||||
*/
|
||||
users: defineTable({
|
||||
name: v.optional(v.string()),
|
||||
image: v.optional(v.string()),
|
||||
email: v.optional(v.string()),
|
||||
emailVerificationTime: v.optional(v.number()),
|
||||
phone: v.optional(v.string()),
|
||||
phoneVerificationTime: v.optional(v.number()),
|
||||
isAnonymous: v.optional(v.boolean()),
|
||||
/* Fields below here are custom & not defined in authTables */
|
||||
isAdmin: v.optional(v.boolean()),
|
||||
role: v.optional(v.union(v.literal('owner'), v.literal('member'))),
|
||||
lastSeenAt: v.optional(v.number()),
|
||||
themePreference: v.optional(
|
||||
v.union(v.literal('light'), v.literal('dark'), v.literal('system')),
|
||||
),
|
||||
})
|
||||
.index('email', ['email'])
|
||||
.index('phone', ['phone'])
|
||||
/* Indexes below here are custom & not defined in authTables */
|
||||
.index('name', ['name']),
|
||||
gitConnections: defineTable({
|
||||
userId: v.id('users'),
|
||||
provider: v.union(
|
||||
v.literal('github'),
|
||||
v.literal('gitea'),
|
||||
v.literal('gitlab'),
|
||||
v.literal('other'),
|
||||
),
|
||||
providerAccountId: v.optional(v.string()),
|
||||
displayName: v.string(),
|
||||
username: v.optional(v.string()),
|
||||
avatarUrl: v.optional(v.string()),
|
||||
installationId: v.optional(v.string()),
|
||||
scopes: v.optional(v.array(v.string())),
|
||||
status: v.union(
|
||||
v.literal('active'),
|
||||
v.literal('needs_reauth'),
|
||||
v.literal('revoked'),
|
||||
),
|
||||
connectedAt: v.number(),
|
||||
updatedAt: v.number(),
|
||||
})
|
||||
.index('by_user', ['userId'])
|
||||
.index('by_user_provider', ['userId', 'provider'])
|
||||
.index('by_status', ['status']),
|
||||
spoons: defineTable({
|
||||
ownerId: v.id('users'),
|
||||
name: v.string(),
|
||||
description: v.optional(v.string()),
|
||||
provider: v.union(
|
||||
v.literal('github'),
|
||||
v.literal('gitea'),
|
||||
v.literal('gitlab'),
|
||||
v.literal('other'),
|
||||
),
|
||||
upstreamOwner: v.string(),
|
||||
upstreamRepo: v.string(),
|
||||
upstreamDefaultBranch: v.string(),
|
||||
upstreamUrl: v.string(),
|
||||
forkOwner: v.optional(v.string()),
|
||||
forkRepo: v.optional(v.string()),
|
||||
forkDefaultBranch: v.optional(v.string()),
|
||||
forkUrl: v.optional(v.string()),
|
||||
visibility: v.union(
|
||||
v.literal('public'),
|
||||
v.literal('private'),
|
||||
v.literal('internal'),
|
||||
v.literal('unknown'),
|
||||
),
|
||||
maintenanceMode: v.union(
|
||||
v.literal('watch'),
|
||||
v.literal('auto_pr'),
|
||||
v.literal('paused'),
|
||||
),
|
||||
syncCadence: v.union(
|
||||
v.literal('daily'),
|
||||
v.literal('weekly'),
|
||||
v.literal('manual'),
|
||||
),
|
||||
productionRefStrategy: v.union(
|
||||
v.literal('default_branch'),
|
||||
v.literal('latest_release'),
|
||||
v.literal('tag_pattern'),
|
||||
),
|
||||
tagPattern: v.optional(v.string()),
|
||||
status: v.union(
|
||||
v.literal('draft'),
|
||||
v.literal('active'),
|
||||
v.literal('needs_connection'),
|
||||
v.literal('paused'),
|
||||
v.literal('archived'),
|
||||
),
|
||||
lastCheckedAt: v.optional(v.number()),
|
||||
lastUpstreamCommit: v.optional(v.string()),
|
||||
lastForkCommit: v.optional(v.string()),
|
||||
createdAt: v.number(),
|
||||
updatedAt: v.number(),
|
||||
})
|
||||
.index('by_owner', ['ownerId'])
|
||||
.index('by_owner_status', ['ownerId', 'status'])
|
||||
.index('by_owner_provider', ['ownerId', 'provider'])
|
||||
.index('by_upstream', ['provider', 'upstreamOwner', 'upstreamRepo']),
|
||||
syncRuns: defineTable({
|
||||
spoonId: v.id('spoons'),
|
||||
ownerId: v.id('users'),
|
||||
kind: v.union(
|
||||
v.literal('scheduled_check'),
|
||||
v.literal('manual_check'),
|
||||
v.literal('upstream_update'),
|
||||
v.literal('merge_attempt'),
|
||||
v.literal('ai_review'),
|
||||
),
|
||||
status: v.union(
|
||||
v.literal('queued'),
|
||||
v.literal('running'),
|
||||
v.literal('clean'),
|
||||
v.literal('conflict'),
|
||||
v.literal('needs_review'),
|
||||
v.literal('failed'),
|
||||
v.literal('merged'),
|
||||
),
|
||||
upstreamFrom: v.optional(v.string()),
|
||||
upstreamTo: v.optional(v.string()),
|
||||
summary: v.optional(v.string()),
|
||||
aiAssessment: v.optional(v.string()),
|
||||
mergeRequestUrl: v.optional(v.string()),
|
||||
error: v.optional(v.string()),
|
||||
createdAt: v.number(),
|
||||
updatedAt: v.number(),
|
||||
})
|
||||
.index('by_owner', ['ownerId'])
|
||||
.index('by_spoon', ['spoonId'])
|
||||
.index('by_owner_status', ['ownerId', 'status'])
|
||||
.index('by_created', ['createdAt']),
|
||||
agentRequests: defineTable({
|
||||
spoonId: v.id('spoons'),
|
||||
ownerId: v.id('users'),
|
||||
prompt: v.string(),
|
||||
status: v.union(
|
||||
v.literal('draft'),
|
||||
v.literal('queued'),
|
||||
v.literal('running'),
|
||||
v.literal('changes_ready'),
|
||||
v.literal('merge_request_opened'),
|
||||
v.literal('failed'),
|
||||
v.literal('cancelled'),
|
||||
),
|
||||
targetBranch: v.optional(v.string()),
|
||||
mergeRequestUrl: v.optional(v.string()),
|
||||
summary: v.optional(v.string()),
|
||||
error: v.optional(v.string()),
|
||||
createdAt: v.number(),
|
||||
updatedAt: v.number(),
|
||||
})
|
||||
.index('by_owner', ['ownerId'])
|
||||
.index('by_spoon', ['spoonId'])
|
||||
.index('by_owner_status', ['ownerId', 'status'])
|
||||
.index('by_created', ['createdAt']),
|
||||
};
|
||||
|
||||
export default defineSchema({
|
||||
...authTables,
|
||||
...applicationTables,
|
||||
});
|
||||
@@ -0,0 +1,178 @@
|
||||
import { ConvexError, v } from 'convex/values';
|
||||
|
||||
import type { Doc } from './_generated/dataModel';
|
||||
import { mutation, query } from './_generated/server';
|
||||
import {
|
||||
getOwnedSpoon,
|
||||
getRequiredUserId,
|
||||
optionalText,
|
||||
requireText,
|
||||
} from './model';
|
||||
|
||||
const provider = v.union(
|
||||
v.literal('github'),
|
||||
v.literal('gitea'),
|
||||
v.literal('gitlab'),
|
||||
v.literal('other'),
|
||||
);
|
||||
|
||||
const visibility = v.union(
|
||||
v.literal('public'),
|
||||
v.literal('private'),
|
||||
v.literal('internal'),
|
||||
v.literal('unknown'),
|
||||
);
|
||||
|
||||
const maintenanceMode = v.union(
|
||||
v.literal('watch'),
|
||||
v.literal('auto_pr'),
|
||||
v.literal('paused'),
|
||||
);
|
||||
|
||||
const syncCadence = v.union(
|
||||
v.literal('daily'),
|
||||
v.literal('weekly'),
|
||||
v.literal('manual'),
|
||||
);
|
||||
|
||||
const productionRefStrategy = v.union(
|
||||
v.literal('default_branch'),
|
||||
v.literal('latest_release'),
|
||||
v.literal('tag_pattern'),
|
||||
);
|
||||
|
||||
const spoonStatus = v.union(
|
||||
v.literal('draft'),
|
||||
v.literal('active'),
|
||||
v.literal('needs_connection'),
|
||||
v.literal('paused'),
|
||||
v.literal('archived'),
|
||||
);
|
||||
|
||||
const hasForkMetadata = (args: {
|
||||
forkOwner?: string;
|
||||
forkRepo?: string;
|
||||
forkUrl?: string;
|
||||
}) =>
|
||||
Boolean(
|
||||
args.forkOwner?.trim() && args.forkRepo?.trim() && args.forkUrl?.trim(),
|
||||
);
|
||||
|
||||
export const listMine = query({
|
||||
args: {},
|
||||
handler: async (ctx) => {
|
||||
const ownerId = await getRequiredUserId(ctx);
|
||||
const spoons = await ctx.db
|
||||
.query('spoons')
|
||||
.withIndex('by_owner', (q) => q.eq('ownerId', ownerId))
|
||||
.order('desc')
|
||||
.collect();
|
||||
return spoons.filter((spoon) => spoon.status !== 'archived');
|
||||
},
|
||||
});
|
||||
|
||||
export const get = query({
|
||||
args: { spoonId: v.id('spoons') },
|
||||
handler: async (ctx, { spoonId }) => {
|
||||
const ownerId = await getRequiredUserId(ctx);
|
||||
return getOwnedSpoon(ctx, spoonId, ownerId);
|
||||
},
|
||||
});
|
||||
|
||||
export const createManual = mutation({
|
||||
args: {
|
||||
name: v.string(),
|
||||
description: v.optional(v.string()),
|
||||
provider,
|
||||
upstreamOwner: v.string(),
|
||||
upstreamRepo: v.string(),
|
||||
upstreamDefaultBranch: v.string(),
|
||||
upstreamUrl: v.string(),
|
||||
forkOwner: v.optional(v.string()),
|
||||
forkRepo: v.optional(v.string()),
|
||||
forkDefaultBranch: v.optional(v.string()),
|
||||
forkUrl: v.optional(v.string()),
|
||||
visibility,
|
||||
maintenanceMode,
|
||||
syncCadence,
|
||||
productionRefStrategy,
|
||||
tagPattern: v.optional(v.string()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const ownerId = await getRequiredUserId(ctx);
|
||||
const now = Date.now();
|
||||
const forkOwner = optionalText(args.forkOwner);
|
||||
const forkRepo = optionalText(args.forkRepo);
|
||||
const forkUrl = optionalText(args.forkUrl);
|
||||
const status = hasForkMetadata({ forkOwner, forkRepo, forkUrl })
|
||||
? 'draft'
|
||||
: 'needs_connection';
|
||||
|
||||
return await ctx.db.insert('spoons', {
|
||||
ownerId,
|
||||
name: requireText(args.name, 'Spoon name'),
|
||||
description: optionalText(args.description),
|
||||
provider: args.provider,
|
||||
upstreamOwner: requireText(args.upstreamOwner, 'Upstream owner'),
|
||||
upstreamRepo: requireText(args.upstreamRepo, 'Upstream repository'),
|
||||
upstreamDefaultBranch: requireText(
|
||||
args.upstreamDefaultBranch,
|
||||
'Upstream default branch',
|
||||
),
|
||||
upstreamUrl: requireText(args.upstreamUrl, 'Upstream URL'),
|
||||
forkOwner,
|
||||
forkRepo,
|
||||
forkDefaultBranch: optionalText(args.forkDefaultBranch),
|
||||
forkUrl,
|
||||
visibility: args.visibility,
|
||||
maintenanceMode: args.maintenanceMode,
|
||||
syncCadence: args.syncCadence,
|
||||
productionRefStrategy: args.productionRefStrategy,
|
||||
tagPattern: optionalText(args.tagPattern),
|
||||
status,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const updateSettings = mutation({
|
||||
args: {
|
||||
spoonId: v.id('spoons'),
|
||||
maintenanceMode: v.optional(maintenanceMode),
|
||||
syncCadence: v.optional(syncCadence),
|
||||
productionRefStrategy: v.optional(productionRefStrategy),
|
||||
tagPattern: v.optional(v.string()),
|
||||
status: v.optional(spoonStatus),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const ownerId = await getRequiredUserId(ctx);
|
||||
await getOwnedSpoon(ctx, args.spoonId, ownerId);
|
||||
const patch: Partial<Doc<'spoons'>> = { updatedAt: Date.now() };
|
||||
if (args.maintenanceMode) patch.maintenanceMode = args.maintenanceMode;
|
||||
if (args.syncCadence) patch.syncCadence = args.syncCadence;
|
||||
if (args.productionRefStrategy) {
|
||||
patch.productionRefStrategy = args.productionRefStrategy;
|
||||
}
|
||||
if (args.tagPattern !== undefined)
|
||||
patch.tagPattern = optionalText(args.tagPattern);
|
||||
if (args.status) patch.status = args.status;
|
||||
await ctx.db.patch(args.spoonId, patch);
|
||||
return { success: true };
|
||||
},
|
||||
});
|
||||
|
||||
export const archive = mutation({
|
||||
args: { spoonId: v.id('spoons') },
|
||||
handler: async (ctx, { spoonId }) => {
|
||||
const ownerId = await getRequiredUserId(ctx);
|
||||
const spoon = await getOwnedSpoon(ctx, spoonId, ownerId);
|
||||
if (spoon.status === 'archived')
|
||||
throw new ConvexError('Spoon is archived.');
|
||||
await ctx.db.patch(spoonId, {
|
||||
status: 'archived',
|
||||
updatedAt: Date.now(),
|
||||
});
|
||||
return { success: true };
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import { v } from 'convex/values';
|
||||
|
||||
import { query } from './_generated/server';
|
||||
import { getOwnedSpoon, getRequiredUserId } from './model';
|
||||
|
||||
export const listRecent = query({
|
||||
args: { limit: v.optional(v.number()) },
|
||||
handler: async (ctx, { limit }) => {
|
||||
const ownerId = await getRequiredUserId(ctx);
|
||||
const runs = await ctx.db
|
||||
.query('syncRuns')
|
||||
.withIndex('by_owner', (q) => q.eq('ownerId', ownerId))
|
||||
.order('desc')
|
||||
.take(limit ?? 25);
|
||||
return runs;
|
||||
},
|
||||
});
|
||||
|
||||
export const listForSpoon = query({
|
||||
args: { spoonId: v.id('spoons'), limit: v.optional(v.number()) },
|
||||
handler: async (ctx, { spoonId, limit }) => {
|
||||
const ownerId = await getRequiredUserId(ctx);
|
||||
await getOwnedSpoon(ctx, spoonId, ownerId);
|
||||
return await ctx.db
|
||||
.query('syncRuns')
|
||||
.withIndex('by_spoon', (q) => q.eq('spoonId', spoonId))
|
||||
.order('desc')
|
||||
.take(limit ?? 25);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
/* This TypeScript project config describes the environment that
|
||||
* Convex functions run in and is used to typecheck them.
|
||||
* You can modify it, but some settings required to use Convex.
|
||||
*/
|
||||
"compilerOptions": {
|
||||
/* These settings are not required by Convex and can be modified. */
|
||||
"allowJs": true,
|
||||
"strict": true,
|
||||
|
||||
/* These compiler options are required by Convex */
|
||||
"target": "ESNext",
|
||||
"lib": ["ES2021", "dom"],
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"isolatedModules": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["./**/*"],
|
||||
"exclude": ["./_generated"]
|
||||
}
|
||||
Reference in New Issue
Block a user