From 805599504e2a4839368ebe24aafc971c1055f1b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Filip=20Sodi=C4=87?= Date: Thu, 27 Feb 2025 14:42:33 +0100 Subject: [PATCH 1/8] Add transactions to getDailyStats --- template/app/src/analytics/operations.ts | 31 ++++++++++++------------ template/app/src/payment/operations.ts | 6 +++-- 2 files changed, 20 insertions(+), 17 deletions(-) diff --git a/template/app/src/analytics/operations.ts b/template/app/src/analytics/operations.ts index bfe5041cc..c9f0ec3ee 100644 --- a/template/app/src/analytics/operations.ts +++ b/template/app/src/analytics/operations.ts @@ -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 = 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 }; }; diff --git a/template/app/src/payment/operations.ts b/template/app/src/payment/operations.ts index 8873468cb..d3b7645d6 100644 --- a/template/app/src/payment/operations.ts +++ b/template/app/src/payment/operations.ts @@ -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; @@ -47,8 +48,9 @@ export const generateCheckoutSession: GenerateCheckoutSession< export const getCustomerPortalUrl: GetCustomerPortalUrl = 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, From ed6add742e0c2f53f000c53541d402dd4f6bd15d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Filip=20Sodi=C4=87?= Date: Fri, 28 Feb 2025 10:28:23 +0100 Subject: [PATCH 2/8] Refactor generateGptResponse to include a transaction --- template/app/src/demo-ai-app/operations.ts | 300 +++++++++++---------- template/app/src/payment/plans.ts | 1 - 2 files changed, 155 insertions(+), 146 deletions(-) diff --git a/template/app/src/demo-ai-app/operations.ts b/template/app/src/demo-ai-app/operations.ts index e07f80c0b..d4d731d25 100644 --- a/template/app/src/demo-ai-app/operations.ts +++ b/template/app/src/demo-ai-app/operations.ts @@ -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,18 +8,18 @@ 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'); +function getOpenAI(): OpenAI | null { + if (process.env.OPENAI_API_KEY) { + return new OpenAI({ apiKey: process.env.OPENAI_API_KEY }); + } else { + return null; } - return new OpenAI({ apiKey: process.env.OPENAI_API_KEY }); } //#region Actions @@ -35,11 +35,15 @@ export const generateGptResponse: GenerateGptResponse { if (!context.user) { - throw new HttpError(401); + throw new HttpError(401, 'Only authenticated users are allowed to perform this operation'); } const { hours } = ensureArgsSchemaOrThrowHttpError(generateGptResponseInputSchema, rawArgs); + if (!isEligibleForResponse(context.user)) { + throw new HttpError(402, 'User has not paid or is out of credits'); + } + const tasks = await context.entities.Task.findMany({ where: { user: { @@ -48,150 +52,61 @@ export const generateGptResponse: GenerateGptResponse ({ - description, - time, - })); + console.log('Calling open AI api'); + const dailyPlanJson = await getDailyPlanFromGpt(tasks, hours); + if (dailyPlanJson === null) { + throw new HttpError(500, 'Bad response from OpenAI'); + } - 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, - }); + // TODO: Do I need a try catch now that I'm saving a response and + // decrementing credits in a transaction? - const gptArgs = completion?.choices[0]?.message?.tool_calls?.[0]?.function.arguments; + // NOTE: I changed this up, first I do the request, and then I decrement + // credits. Is that dangerous? Protecting from it could be too complicated + // TODO: Potential issue here, user can lose credits between the point we + // inject it into the context and here + // Seems to me that there should be users in the database with negative credits + const decrementCredit = context.entities.User.update({ + where: { id: context.user.id }, + data: { + credits: { + decrement: 1, + }, + }, + }); - if (!gptArgs) { - throw new HttpError(500, 'Bad response from OpenAI'); - } + const createResponse = context.entities.GptResponse.create({ + data: { + user: { connect: { id: context.user.id } }, + content: dailyPlanJson, + }, + }); - console.log('gpt function call arguments: ', gptArgs); + // NOTE: Since these two are now brought together, I put them in a single transaction - so no rollback necessary + // TODO: But what if the server crashes after the transaction and before + // the response? I guess no big deal, since the response is in the db. + console.log('Decrementing credits and saving response'); + prisma.$transaction([decrementCredit, createResponse]); - 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); - } + // TODO: Can this ever fail? + return JSON.parse(dailyPlanJson); }; +function isEligibleForResponse(user: User) { + // TODO: Why not check for allowed states? + const isUserSubscribed = + user.subscriptionStatus !== SubscriptionStatus.Deleted && + user.subscriptionStatus !== SubscriptionStatus.PastDue; + const userHasCredits = user.credits > 0; + // TODO: If the user is subscribed (flat rate, use their subscription) - + // shouldn't take priority over credits since it makes no sece spending + // credits when a subscription is active? + // If they aren't subscribed, then it would make sense to spend credits. + // The old code always subtracted credits so I kept that, is this a bug? + // Also, no one checks for subscription plan (10credits or flat rate). + return isUserSubscribed || userHasCredits; +} + const createTaskInputSchema = z.object({ description: z.string().nonempty(), }); @@ -296,3 +211,98 @@ export const getAllTasksByUser: GetAllTasksByUser = async (_args, }); }; //#endregion + +// TODO: Why is hours a string? +async function getDailyPlanFromGpt(tasks: Task[], hours: string): Promise { + // TODO: Why was this a singleton + const openai = getOpenAI(); + if (openai === null) { + return 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 ?? null; +} diff --git a/template/app/src/payment/plans.ts b/template/app/src/payment/plans.ts index 46e68e698..85d6a1ecc 100644 --- a/template/app/src/payment/plans.ts +++ b/template/app/src/payment/plans.ts @@ -1,4 +1,3 @@ -import * as z from 'zod'; import { requireNodeEnvVar } from '../server/utils'; export enum SubscriptionStatus { From 994cd1e52dd70f88ffe05347c4c23235f46132bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Filip=20Sodi=C4=87?= Date: Tue, 15 Apr 2025 14:23:09 +0200 Subject: [PATCH 3/8] Review changes --- template/app/src/demo-ai-app/operations.ts | 26 ++++++---------------- 1 file changed, 7 insertions(+), 19 deletions(-) diff --git a/template/app/src/demo-ai-app/operations.ts b/template/app/src/demo-ai-app/operations.ts index d4d731d25..d8d00ba7f 100644 --- a/template/app/src/demo-ai-app/operations.ts +++ b/template/app/src/demo-ai-app/operations.ts @@ -14,7 +14,8 @@ import OpenAI from 'openai'; import { SubscriptionStatus } from '../payment/plans'; import { ensureArgsSchemaOrThrowHttpError } from '../server/validation'; -function getOpenAI(): OpenAI | null { +const openAi = getOpenAi(); +function getOpenAi(): OpenAI | null { if (process.env.OPENAI_API_KEY) { return new OpenAI({ apiKey: process.env.OPENAI_API_KEY }); } else { @@ -55,7 +56,7 @@ export const generateGptResponse: GenerateGptResponse 0; - // TODO: If the user is subscribed (flat rate, use their subscription) - - // shouldn't take priority over credits since it makes no sece spending - // credits when a subscription is active? - // If they aren't subscribed, then it would make sense to spend credits. - // The old code always subtracted credits so I kept that, is this a bug? - // Also, no one checks for subscription plan (10credits or flat rate). return isUserSubscribed || userHasCredits; } @@ -212,11 +203,8 @@ export const getAllTasksByUser: GetAllTasksByUser = async (_args, }; //#endregion -// TODO: Why is hours a string? async function getDailyPlanFromGpt(tasks: Task[], hours: string): Promise { - // TODO: Why was this a singleton - const openai = getOpenAI(); - if (openai === null) { + if (openAi === null) { return null; } @@ -225,7 +213,7 @@ async function getDailyPlanFromGpt(tasks: Task[], hours: string): Promise Date: Tue, 15 Apr 2025 14:28:34 +0200 Subject: [PATCH 4/8] Remove another todo --- template/app/src/demo-ai-app/operations.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/template/app/src/demo-ai-app/operations.ts b/template/app/src/demo-ai-app/operations.ts index d8d00ba7f..f62e09833 100644 --- a/template/app/src/demo-ai-app/operations.ts +++ b/template/app/src/demo-ai-app/operations.ts @@ -64,9 +64,6 @@ export const generateGptResponse: GenerateGptResponse Date: Mon, 28 Apr 2025 22:41:20 +0200 Subject: [PATCH 5/8] Address review comments --- template/app/src/demo-ai-app/operations.ts | 37 ++++++++++------------ 1 file changed, 17 insertions(+), 20 deletions(-) diff --git a/template/app/src/demo-ai-app/operations.ts b/template/app/src/demo-ai-app/operations.ts index 023ee059e..1b8d4ae09 100644 --- a/template/app/src/demo-ai-app/operations.ts +++ b/template/app/src/demo-ai-app/operations.ts @@ -15,11 +15,11 @@ import { SubscriptionStatus } from '../payment/plans'; import { ensureArgsSchemaOrThrowHttpError } from '../server/validation'; const openAi = getOpenAi(); -function getOpenAi(): OpenAI | null { +function getOpenAi(): OpenAI { if (process.env.OPENAI_API_KEY) { return new OpenAI({ apiKey: process.env.OPENAI_API_KEY }); } else { - return null; + throw new Error('OpenAI API key is not set'); } } @@ -38,12 +38,11 @@ export const generateGptResponse: GenerateGptResponse = async (_args, }; //#endregion -async function getDailyPlanFromGpt(tasks: Task[], hours: string): Promise { - if (openAi === null) { - return null; - } - +async function generateScheduleWithGpt(tasks: Task[], hours: string): Promise { const parsedTasks = tasks.map(({ description, time }) => ({ description, time, @@ -294,5 +291,5 @@ async function getDailyPlanFromGpt(tasks: Task[], hours: string): Promise Date: Mon, 28 Apr 2025 22:45:27 +0200 Subject: [PATCH 6/8] Change name of open AI setup function --- template/app/src/demo-ai-app/operations.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/template/app/src/demo-ai-app/operations.ts b/template/app/src/demo-ai-app/operations.ts index 1b8d4ae09..29c8bfe77 100644 --- a/template/app/src/demo-ai-app/operations.ts +++ b/template/app/src/demo-ai-app/operations.ts @@ -14,8 +14,8 @@ import OpenAI from 'openai'; import { SubscriptionStatus } from '../payment/plans'; import { ensureArgsSchemaOrThrowHttpError } from '../server/validation'; -const openAi = getOpenAi(); -function getOpenAi(): OpenAI { +const openAi = setUpOpenAi(); +function setUpOpenAi(): OpenAI { if (process.env.OPENAI_API_KEY) { return new OpenAI({ apiKey: process.env.OPENAI_API_KEY }); } else { From 36aa5dfe1476da1334538415302b2f27f05b857c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Filip=20Sodi=C4=87?= Date: Mon, 28 Apr 2025 22:52:20 +0200 Subject: [PATCH 7/8] Convert string to number --- template/app/src/demo-ai-app/DemoAppPage.tsx | 4 ++-- template/app/src/demo-ai-app/operations.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/template/app/src/demo-ai-app/DemoAppPage.tsx b/template/app/src/demo-ai-app/DemoAppPage.tsx index a14dc5469..75d5e6d94 100644 --- a/template/app/src/demo-ai-app/DemoAppPage.tsx +++ b/template/app/src/demo-ai-app/DemoAppPage.tsx @@ -42,7 +42,7 @@ export default function DemoAppPage() { function NewTaskForm({ handleCreateTask }: { handleCreateTask: typeof createTask }) { const [description, setDescription] = useState(''); - const [todaysHours, setTodaysHours] = useState('8'); + const [todaysHours, setTodaysHours] = useState(8); const [response, setResponse] = useState({ 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)} /> diff --git a/template/app/src/demo-ai-app/operations.ts b/template/app/src/demo-ai-app/operations.ts index 29c8bfe77..a9c1039f2 100644 --- a/template/app/src/demo-ai-app/operations.ts +++ b/template/app/src/demo-ai-app/operations.ts @@ -25,7 +25,7 @@ function setUpOpenAi(): OpenAI { //#region Actions const generateGptResponseInputSchema = z.object({ - hours: z.string().regex(/^\d+(\.\d+)?$/, 'Hours must be a number'), + hours: z.number(), }); type GenerateGptResponseInput = z.infer; @@ -206,7 +206,7 @@ export const getAllTasksByUser: GetAllTasksByUser = async (_args, }; //#endregion -async function generateScheduleWithGpt(tasks: Task[], hours: string): Promise { +async function generateScheduleWithGpt(tasks: Task[], hours: number): Promise { const parsedTasks = tasks.map(({ description, time }) => ({ description, time, From 6963e38bb3088706bab91eb457627b8c9696e3d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Filip=20Sodi=C4=87?= Date: Tue, 29 Apr 2025 14:02:00 +0200 Subject: [PATCH 8/8] Explain diff problem in gitignore --- opensaas-sh/.gitignore | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 opensaas-sh/.gitignore diff --git a/opensaas-sh/.gitignore b/opensaas-sh/.gitignore new file mode 100644 index 000000000..83beb0163 --- /dev/null +++ b/opensaas-sh/.gitignore @@ -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/