mirror of
https://github.com/wasp-lang/open-saas.git
synced 2025-06-24 15:52:30 +02:00
Merge pull request #391 from wasp-lang/filip-transactions
Add database transactions where necessary
This commit is contained in:
commit
855ddf1499
3
opensaas-sh/.gitignore
vendored
Normal file
3
opensaas-sh/.gitignore
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
# We can't ignore `app/` because it messes up our patch/diff procedure (check
|
||||
# the README for more info on this)
|
||||
# app/
|
@ -1,5 +1,5 @@
|
||||
import { type DailyStats, type PageViewSource } from 'wasp/entities';
|
||||
import { HttpError } from 'wasp/server';
|
||||
import { HttpError, prisma } from 'wasp/server';
|
||||
import { type GetDailyStats } from 'wasp/server/operations';
|
||||
|
||||
type DailyStatsWithSources = DailyStats & {
|
||||
@ -12,31 +12,32 @@ type DailyStatsValues = {
|
||||
};
|
||||
|
||||
export const getDailyStats: GetDailyStats<void, DailyStatsValues | undefined> = async (_args, context) => {
|
||||
if (!context.user?.isAdmin) {
|
||||
throw new HttpError(401);
|
||||
if (!context.user) {
|
||||
throw new HttpError(401, 'Only authenticated users are allowed to perform this operation');
|
||||
}
|
||||
const dailyStats = await context.entities.DailyStats.findFirst({
|
||||
|
||||
if (!context.user.isAdmin) {
|
||||
throw new HttpError(403, 'Only admins are allowed to perform this operation');
|
||||
}
|
||||
|
||||
const statsQuery = {
|
||||
orderBy: {
|
||||
date: 'desc',
|
||||
},
|
||||
include: {
|
||||
sources: true,
|
||||
},
|
||||
});
|
||||
} as const;
|
||||
|
||||
const [dailyStats, weeklyStats] = await prisma.$transaction([
|
||||
context.entities.DailyStats.findFirst(statsQuery),
|
||||
context.entities.DailyStats.findMany({ ...statsQuery, take: 7 }),
|
||||
]);
|
||||
|
||||
if (!dailyStats) {
|
||||
console.log('\x1b[34mNote: No daily stats have been generated by the dailyStatsJob yet. \x1b[0m');
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const weeklyStats = await context.entities.DailyStats.findMany({
|
||||
orderBy: {
|
||||
date: 'desc',
|
||||
},
|
||||
take: 7,
|
||||
include: {
|
||||
sources: true,
|
||||
},
|
||||
});
|
||||
|
||||
return { dailyStats, weeklyStats };
|
||||
};
|
||||
|
@ -42,7 +42,7 @@ export default function DemoAppPage() {
|
||||
|
||||
function NewTaskForm({ handleCreateTask }: { handleCreateTask: typeof createTask }) {
|
||||
const [description, setDescription] = useState<string>('');
|
||||
const [todaysHours, setTodaysHours] = useState<string>('8');
|
||||
const [todaysHours, setTodaysHours] = useState<number>(8);
|
||||
const [response, setResponse] = useState<GeneratedSchedule | null>({
|
||||
mainTasks: [
|
||||
{
|
||||
@ -186,7 +186,7 @@ function NewTaskForm({ handleCreateTask }: { handleCreateTask: typeof createTask
|
||||
max={24}
|
||||
className='min-w-[7rem] text-gray-800/90 text-center font-medium rounded-md border border-gray-200 bg-yellow-50 hover:bg-yellow-100 shadow-md focus:outline-none focus:border-transparent focus:shadow-none duration-200 ease-in-out hover:shadow-none'
|
||||
value={todaysHours}
|
||||
onChange={(e) => setTodaysHours(e.currentTarget.value)}
|
||||
onChange={(e) => setTodaysHours(+e.currentTarget.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -1,5 +1,5 @@
|
||||
import * as z from 'zod';
|
||||
import type { Task, GptResponse } from 'wasp/entities';
|
||||
import type { Task, GptResponse, User } from 'wasp/entities';
|
||||
import type {
|
||||
GenerateGptResponse,
|
||||
CreateTask,
|
||||
@ -8,23 +8,24 @@ import type {
|
||||
GetGptResponses,
|
||||
GetAllTasksByUser,
|
||||
} from 'wasp/server/operations';
|
||||
import { HttpError } from 'wasp/server';
|
||||
import { HttpError, prisma } from 'wasp/server';
|
||||
import { GeneratedSchedule } from './schedule';
|
||||
import OpenAI from 'openai';
|
||||
import { SubscriptionStatus } from '../payment/plans';
|
||||
import { ensureArgsSchemaOrThrowHttpError } from '../server/validation';
|
||||
|
||||
const openai = setupOpenAI();
|
||||
function setupOpenAI() {
|
||||
if (!process.env.OPENAI_API_KEY) {
|
||||
return new HttpError(500, 'OpenAI API key is not set');
|
||||
const openAi = setUpOpenAi();
|
||||
function setUpOpenAi(): OpenAI {
|
||||
if (process.env.OPENAI_API_KEY) {
|
||||
return new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
|
||||
} else {
|
||||
throw new Error('OpenAI API key is not set');
|
||||
}
|
||||
return new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
|
||||
}
|
||||
|
||||
//#region Actions
|
||||
const generateGptResponseInputSchema = z.object({
|
||||
hours: z.string().regex(/^\d+(\.\d+)?$/, 'Hours must be a number'),
|
||||
hours: z.number(),
|
||||
});
|
||||
|
||||
type GenerateGptResponseInput = z.infer<typeof generateGptResponseInputSchema>;
|
||||
@ -34,11 +35,14 @@ export const generateGptResponse: GenerateGptResponse<GenerateGptResponseInput,
|
||||
context
|
||||
) => {
|
||||
if (!context.user) {
|
||||
throw new HttpError(401);
|
||||
throw new HttpError(401, 'Only authenticated users are allowed to perform this operation');
|
||||
}
|
||||
|
||||
if (!isEligibleForResponse(context.user)) {
|
||||
throw new HttpError(402, 'User has not paid or is out of credits');
|
||||
}
|
||||
|
||||
const { hours } = ensureArgsSchemaOrThrowHttpError(generateGptResponseInputSchema, rawArgs);
|
||||
|
||||
const tasks = await context.entities.Task.findMany({
|
||||
where: {
|
||||
user: {
|
||||
@ -47,150 +51,50 @@ export const generateGptResponse: GenerateGptResponse<GenerateGptResponseInput,
|
||||
},
|
||||
});
|
||||
|
||||
const parsedTasks = tasks.map(({ description, time }) => ({
|
||||
description,
|
||||
time,
|
||||
}));
|
||||
|
||||
try {
|
||||
// check if openai is initialized correctly with the API key
|
||||
if (openai instanceof Error) {
|
||||
throw openai;
|
||||
}
|
||||
|
||||
const hasCredits = context.user.credits > 0;
|
||||
const hasValidSubscription =
|
||||
!!context.user.subscriptionStatus &&
|
||||
context.user.subscriptionStatus !== SubscriptionStatus.Deleted &&
|
||||
context.user.subscriptionStatus !== SubscriptionStatus.PastDue;
|
||||
const canUserContinue = hasCredits || hasValidSubscription;
|
||||
|
||||
if (!canUserContinue) {
|
||||
throw new HttpError(402, 'User has not paid or is out of credits');
|
||||
} else {
|
||||
console.log('decrementing credits');
|
||||
await context.entities.User.update({
|
||||
where: { id: context.user.id },
|
||||
data: {
|
||||
credits: {
|
||||
decrement: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const completion = await openai.chat.completions.create({
|
||||
model: 'gpt-3.5-turbo', // you can use any model here, e.g. 'gpt-3.5-turbo', 'gpt-4', etc.
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content:
|
||||
'you are an expert daily planner. you will be given a list of main tasks and an estimated time to complete each task. You will also receive the total amount of hours to be worked that day. Your job is to return a detailed plan of how to achieve those tasks by breaking each task down into at least 3 subtasks each. MAKE SURE TO ALWAYS CREATE AT LEAST 3 SUBTASKS FOR EACH MAIN TASK PROVIDED BY THE USER! YOU WILL BE REWARDED IF YOU DO.',
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: `I will work ${hours} hours today. Here are the tasks I have to complete: ${JSON.stringify(
|
||||
parsedTasks
|
||||
)}. Please help me plan my day by breaking the tasks down into actionable subtasks with time and priority status.`,
|
||||
},
|
||||
],
|
||||
tools: [
|
||||
{
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'parseTodaysSchedule',
|
||||
description: 'parses the days tasks and returns a schedule',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
mainTasks: {
|
||||
type: 'array',
|
||||
description: 'Name of main tasks provided by user, ordered by priority',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: {
|
||||
type: 'string',
|
||||
description: 'Name of main task provided by user',
|
||||
},
|
||||
priority: {
|
||||
type: 'string',
|
||||
enum: ['low', 'medium', 'high'],
|
||||
description: 'task priority',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
subtasks: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
description: {
|
||||
type: 'string',
|
||||
description:
|
||||
'detailed breakdown and description of sub-task related to main task. e.g., "Prepare your learning session by first reading through the documentation"',
|
||||
},
|
||||
time: {
|
||||
type: 'number',
|
||||
description: 'time allocated for a given subtask in hours, e.g. 0.5',
|
||||
},
|
||||
mainTaskName: {
|
||||
type: 'string',
|
||||
description: 'name of main task related to subtask',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
required: ['mainTasks', 'subtasks', 'time', 'priority'],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
tool_choice: {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'parseTodaysSchedule',
|
||||
},
|
||||
},
|
||||
temperature: 1,
|
||||
});
|
||||
|
||||
const gptArgs = completion?.choices[0]?.message?.tool_calls?.[0]?.function.arguments;
|
||||
|
||||
if (!gptArgs) {
|
||||
throw new HttpError(500, 'Bad response from OpenAI');
|
||||
}
|
||||
|
||||
console.log('gpt function call arguments: ', gptArgs);
|
||||
|
||||
await context.entities.GptResponse.create({
|
||||
data: {
|
||||
user: { connect: { id: context.user.id } },
|
||||
content: JSON.stringify(gptArgs),
|
||||
},
|
||||
});
|
||||
|
||||
return JSON.parse(gptArgs);
|
||||
} catch (error: any) {
|
||||
if (!context.user.subscriptionStatus && error?.statusCode != 402) {
|
||||
await context.entities.User.update({
|
||||
where: { id: context.user.id },
|
||||
data: {
|
||||
credits: {
|
||||
increment: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
console.error(error);
|
||||
const statusCode = error.statusCode || 500;
|
||||
const errorMessage = error.message || 'Internal server error';
|
||||
throw new HttpError(statusCode, errorMessage);
|
||||
console.log('Calling open AI api');
|
||||
const generatedSchedule = await generateScheduleWithGpt(tasks, hours);
|
||||
if (generatedSchedule === null) {
|
||||
throw new HttpError(500, 'Encountered a problem in communication with OpenAI');
|
||||
}
|
||||
|
||||
// We decrement the credits after using up tokens to get a daily plan
|
||||
// from Chat GPT.
|
||||
//
|
||||
// This way, users don't feel cheated if something goes wrong.
|
||||
// On the flipside, users can theoretically abuse this and spend more
|
||||
// credits than they have, but the damage should be pretty limited.
|
||||
//
|
||||
// Think about which option you prefer for you and edit the code accordingly.
|
||||
const decrementCredit = context.entities.User.update({
|
||||
where: { id: context.user.id },
|
||||
data: {
|
||||
credits: {
|
||||
decrement: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const createResponse = context.entities.GptResponse.create({
|
||||
data: {
|
||||
user: { connect: { id: context.user.id } },
|
||||
content: JSON.stringify(generatedSchedule),
|
||||
},
|
||||
});
|
||||
|
||||
console.log('Decrementing credits and saving response');
|
||||
prisma.$transaction([decrementCredit, createResponse]);
|
||||
|
||||
return generatedSchedule;
|
||||
};
|
||||
|
||||
function isEligibleForResponse(user: User) {
|
||||
const isUserSubscribed =
|
||||
user.subscriptionStatus === SubscriptionStatus.Active ||
|
||||
user.subscriptionStatus === SubscriptionStatus.CancelAtPeriodEnd;
|
||||
const userHasCredits = user.credits > 0;
|
||||
return isUserSubscribed || userHasCredits;
|
||||
}
|
||||
|
||||
const createTaskInputSchema = z.object({
|
||||
description: z.string().nonempty(),
|
||||
});
|
||||
@ -301,3 +205,91 @@ export const getAllTasksByUser: GetAllTasksByUser<void, Task[]> = async (_args,
|
||||
});
|
||||
};
|
||||
//#endregion
|
||||
|
||||
async function generateScheduleWithGpt(tasks: Task[], hours: number): Promise<GeneratedSchedule | null> {
|
||||
const parsedTasks = tasks.map(({ description, time }) => ({
|
||||
description,
|
||||
time,
|
||||
}));
|
||||
|
||||
const completion = await openAi.chat.completions.create({
|
||||
model: 'gpt-3.5-turbo', // you can use any model here, e.g. 'gpt-3.5-turbo', 'gpt-4', etc.
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content:
|
||||
'you are an expert daily planner. you will be given a list of main tasks and an estimated time to complete each task. You will also receive the total amount of hours to be worked that day. Your job is to return a detailed plan of how to achieve those tasks by breaking each task down into at least 3 subtasks each. MAKE SURE TO ALWAYS CREATE AT LEAST 3 SUBTASKS FOR EACH MAIN TASK PROVIDED BY THE USER! YOU WILL BE REWARDED IF YOU DO.',
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: `I will work ${hours} hours today. Here are the tasks I have to complete: ${JSON.stringify(
|
||||
parsedTasks
|
||||
)}. Please help me plan my day by breaking the tasks down into actionable subtasks with time and priority status.`,
|
||||
},
|
||||
],
|
||||
tools: [
|
||||
{
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'parseTodaysSchedule',
|
||||
description: 'parses the days tasks and returns a schedule',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
mainTasks: {
|
||||
type: 'array',
|
||||
description: 'Name of main tasks provided by user, ordered by priority',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: {
|
||||
type: 'string',
|
||||
description: 'Name of main task provided by user',
|
||||
},
|
||||
priority: {
|
||||
type: 'string',
|
||||
enum: ['low', 'medium', 'high'],
|
||||
description: 'task priority',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
subtasks: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
description: {
|
||||
type: 'string',
|
||||
description:
|
||||
'detailed breakdown and description of sub-task related to main task. e.g., "Prepare your learning session by first reading through the documentation"',
|
||||
},
|
||||
time: {
|
||||
type: 'number',
|
||||
description: 'time allocated for a given subtask in hours, e.g. 0.5',
|
||||
},
|
||||
mainTaskName: {
|
||||
type: 'string',
|
||||
description: 'name of main task related to subtask',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
required: ['mainTasks', 'subtasks', 'time', 'priority'],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
tool_choice: {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'parseTodaysSchedule',
|
||||
},
|
||||
},
|
||||
temperature: 1,
|
||||
});
|
||||
|
||||
const gptResponse = completion?.choices[0]?.message?.tool_calls?.[0]?.function.arguments;
|
||||
return gptResponse !== undefined ? JSON.parse(gptResponse) : null;
|
||||
}
|
||||
|
@ -19,8 +19,9 @@ export const generateCheckoutSession: GenerateCheckoutSession<
|
||||
CheckoutSession
|
||||
> = async (rawPaymentPlanId, context) => {
|
||||
if (!context.user) {
|
||||
throw new HttpError(401);
|
||||
throw new HttpError(401, 'Only authenticated users are allowed to perform this operation');
|
||||
}
|
||||
|
||||
const paymentPlanId = ensureArgsSchemaOrThrowHttpError(generateCheckoutSessionSchema, rawPaymentPlanId);
|
||||
const userId = context.user.id;
|
||||
const userEmail = context.user.email;
|
||||
@ -45,8 +46,9 @@ export const generateCheckoutSession: GenerateCheckoutSession<
|
||||
|
||||
export const getCustomerPortalUrl: GetCustomerPortalUrl<void, string | null> = async (_args, context) => {
|
||||
if (!context.user) {
|
||||
throw new HttpError(401);
|
||||
throw new HttpError(401, 'Only authenticated users are allowed to perform this operation');
|
||||
}
|
||||
|
||||
return paymentProcessor.fetchCustomerPortalUrl({
|
||||
userId: context.user.id,
|
||||
prismaUserDelegate: context.entities.User,
|
||||
|
@ -1,4 +1,3 @@
|
||||
import * as z from 'zod';
|
||||
import { requireNodeEnvVar } from '../server/utils';
|
||||
|
||||
export enum SubscriptionStatus {
|
||||
|
Loading…
x
Reference in New Issue
Block a user