From 42e1da4aa5e9085c7052596a7734ae388d9273bf Mon Sep 17 00:00:00 2001 From: vincanger <70215737+vincanger@users.noreply.github.com> Date: Mon, 5 Feb 2024 17:09:46 -0500 Subject: [PATCH] add file upload page & actions improve fetching download url --- app/.env.server.example | 8 +- app/.prettierrc | 3 +- app/main.wasp | 37 +++++- app/src/client/app/DemoAppPage.tsx | 4 +- app/src/client/app/FileUploadPage.tsx | 129 +++++++++++++++++++ app/src/client/components/AppNavBar.tsx | 1 + app/src/server/actions.ts | 58 +++++++-- app/src/server/file-upload/s3Utils.ts | 39 ++++++ app/src/server/{ => payments}/stripeUtils.ts | 0 app/src/server/queries.ts | 36 +++++- 10 files changed, 297 insertions(+), 18 deletions(-) create mode 100644 app/src/client/app/FileUploadPage.tsx create mode 100644 app/src/server/file-upload/s3Utils.ts rename app/src/server/{ => payments}/stripeUtils.ts (100%) diff --git a/app/.env.server.example b/app/.env.server.example index 5fda680..7f99181 100644 --- a/app/.env.server.example +++ b/app/.env.server.example @@ -37,4 +37,10 @@ GOOGLE_ANALYTICS_CLIENT_EMAIL=email@example.gserviceaccount.com # Make sure you convert the private key within the JSON file to base64 first with `echo -n "PRIVATE_KEY" | base64`. see the docs for more info. GOOGLE_ANALYTICS_PRIVATE_KEY=LS02... # You will find your Property ID in the Google Analytics dashboard. It will look like '987654321' -GOOGLE_ANALYTICS_PROPERTY_ID=123456789 \ No newline at end of file +GOOGLE_ANALYTICS_PROPERTY_ID=123456789 + +# (OPTIONAL) get your aws s3 credentials at https://console.aws.amazon.com and create a new IAM user with S3 access +AWS_S3_IAM_ACCESS_KEY=ACK... +AWS_S3_IAM_SECRET_KEY=t+33a... +AWS_S3_FILES_BUCKET=your-bucket-name +AWS_S3_REGION=your-region \ No newline at end of file diff --git a/app/.prettierrc b/app/.prettierrc index 2b3e125..8fa71eb 100644 --- a/app/.prettierrc +++ b/app/.prettierrc @@ -4,5 +4,6 @@ "singleQuote": true, "endOfLine": "lf", "tabWidth": 2, - "jsxSingleQuote": true + "jsxSingleQuote": true, + "printWidth": 120 } diff --git a/app/main.wasp b/app/main.wasp index 57187e9..d13c43d 100644 --- a/app/main.wasp +++ b/app/main.wasp @@ -85,7 +85,8 @@ app SaaSTemplate { ("openai", "^4.24.1"), ("prettier", "3.1.1"), ("prettier-plugin-tailwindcss", "0.5.11"), - ("zod", "3.22.4") + ("zod", "3.22.4"), + ("aws-sdk", "^2.1551.0") ], } @@ -116,6 +117,7 @@ entity User {=psl externalAuthAssociations SocialLogin[] contactFormMessages ContactFormMessage[] tasks Task[] + files File[] psl=} entity SocialLogin {=psl @@ -147,6 +149,17 @@ entity Task {=psl createdAt DateTime @default(now()) psl=} +entity File {=psl + id String @id @default(uuid()) + name String + type String + key String + uploadUrl String + user User @relation(fields: [userId], references: [id]) + userId Int + createdAt DateTime @default(now()) +psl=} + // TODO: add functionality to allow users to send messages to admin // and make them accessible via the admin dashboard entity ContactFormMessage {=psl @@ -246,6 +259,12 @@ page CheckoutPage { component: import Checkout from "@client/app/CheckoutPage" } +route FileUploadRoute { path: "/file-upload", to: FileUploadPage } +page FileUploadPage { + authRequired: true, + component: import FileUpload from "@client/app/FileUploadPage" +} + route AdminRoute { path: "/admin", to: DashboardPage } page DashboardPage { authRequired: true, @@ -347,6 +366,12 @@ action updateUserById { entities: [User] } +action createFile { + fn: import { createFile } from "@server/actions.js", + entities: [User, File] +} + + // 📚 Queries query getGptResponses { @@ -359,6 +384,16 @@ query getAllTasksByUser { entities: [Task] } +query getAllFilesByUser { + fn: import { getAllFilesByUser } from "@server/queries.js", + entities: [User, File] +} + +query getDownloadFileSignedURL { + fn: import { getDownloadFileSignedURL } from "@server/queries.js", + entities: [User, File] +} + query getDailyStats { fn: import { getDailyStats } from "@server/queries.js", entities: [User, DailyStats] diff --git a/app/src/client/app/DemoAppPage.tsx b/app/src/client/app/DemoAppPage.tsx index e3618a6..ea00d7d 100644 --- a/app/src/client/app/DemoAppPage.tsx +++ b/app/src/client/app/DemoAppPage.tsx @@ -11,7 +11,7 @@ import { TiDelete } from 'react-icons/ti'; export default function DemoAppPage() { return ( -
+

@@ -19,7 +19,7 @@ export default function DemoAppPage() {

- Enter your day's tasks and let AI do the rest! + This example app uses OpenAI's chat completions with function calling to return a structured JSON object. Try it out, enter your day's tasks, and let AI do the rest!

{/* begin AI-powered Todo List */}
diff --git a/app/src/client/app/FileUploadPage.tsx b/app/src/client/app/FileUploadPage.tsx new file mode 100644 index 0000000..1856af8 --- /dev/null +++ b/app/src/client/app/FileUploadPage.tsx @@ -0,0 +1,129 @@ +import axios from 'axios'; +import { useQuery } from '@wasp/queries'; +import { useState, useEffect, FormEvent } from 'react'; +import getAllFilesByUser from '@wasp/queries/getAllFilesByUser'; +import createFile from '@wasp/actions/createFile'; +import getDownloadFileSignedURL from '@wasp/queries/getDownloadFileSignedURL'; + +export default function FileUploadPage() { + const [fileToDownload, setFileToDownload] = useState(''); + + const { data: files, error: filesError, isLoading: isFilesLoading } = useQuery(getAllFilesByUser); + const { isLoading: isDownloadUrlLoading, refetch: refetchDownloadUrl } = useQuery( + getDownloadFileSignedURL, + { key: fileToDownload }, + { enabled: false } + ); + + useEffect(() => { + if (fileToDownload.length > 0) { + refetchDownloadUrl() + .then((urlQuery) => { + switch (urlQuery.status) { + case 'error': + console.error('Error fetching download URL', urlQuery.error); + alert('Error fetching download'); + return; + case 'success': + window.open(urlQuery.data, '_blank'); + return; + } + }) + .finally(() => { + setFileToDownload(''); + }); + } + }, [fileToDownload]); + + const handleUpload = async (e: FormEvent) => { + e.preventDefault(); + const formData = new FormData(e.target as HTMLFormElement); + const file = formData.get('file-upload') as File; + if (!file) { + console.error('No file selected'); + return; + } + + const fileType = file.type; + const name = file.name; + + try { + const { uploadUrl } = await createFile({ fileType, name }); + if (!uploadUrl) { + throw new Error('Failed to get upload URL'); + } + const res = await axios.put(uploadUrl, file, { + headers: { + 'Content-Type': fileType, + }, + }); + if (res.status !== 200) { + throw new Error('File upload to S3 failed'); + } + } catch (error) { + alert('Error uploading file. Please try again'); + console.error('Error uploading file', error); + } + }; + + return ( +
+
+
+

+ AWS File Upload +

+
+

+ This is an example file upload page using AWS S3. Maybe your app needs this. Maybe it + doesn't. But a lot of people asked for this feature, so here you go 🤝 +

+
+
+
+ + +
+
+
+

Uploaded Files

+ {isFilesLoading &&

Loading...

} + {filesError &&

Error: {filesError.message}

} + {!!files && files.length > 0 ? ( + files.map((file: any) => ( +
+

{file.name}

+ +
+ )) + ) : ( +

No files uploaded yet :(

+ )} +
+
+
+
+
+ ); +} diff --git a/app/src/client/components/AppNavBar.tsx b/app/src/client/components/AppNavBar.tsx index 85d36de..abeeaa9 100644 --- a/app/src/client/components/AppNavBar.tsx +++ b/app/src/client/components/AppNavBar.tsx @@ -13,6 +13,7 @@ import { Link } from '@wasp/router'; const navigation = [ { name: 'AI Scheduler (Demo App)', href: '/demo-app' }, + { name: 'File Upload (AWS S3)', href: '/file-upload'}, { name: 'Pricing', href: '/pricing' }, { name: 'Documentation', href: DOCS_URL }, { name: 'Blog', href: BLOG_URL }, diff --git a/app/src/server/actions.ts b/app/src/server/actions.ts index 65998c2..ed1e6c4 100644 --- a/app/src/server/actions.ts +++ b/app/src/server/actions.ts @@ -1,12 +1,21 @@ import Stripe from 'stripe'; import fetch from 'node-fetch'; import HttpError from '@wasp/core/HttpError.js'; -import type { User, Task } from '@wasp/entities'; -import type { GenerateGptResponse, StripePayment } from '@wasp/actions/types'; +import type { User, Task, File } from '@wasp/entities'; import type { StripePaymentResult } from './types'; -import { UpdateCurrentUser, UpdateUserById, CreateTask, DeleteTask, UpdateTask } from '@wasp/actions/types'; -import { fetchStripeCustomer, createStripeCheckoutSession } from './stripeUtils.js'; +import { + GenerateGptResponse, + StripePayment, + UpdateCurrentUser, + UpdateUserById, + CreateTask, + DeleteTask, + UpdateTask, + CreateFile, +} from '@wasp/actions/types'; +import { fetchStripeCustomer, createStripeCheckoutSession } from './payments/stripeUtils.js'; import { TierIds } from '@wasp/shared/constants.js'; +import { getUploadFileSignedURLFromS3 } from './file-upload/s3Utils.js'; export const stripePayment: StripePayment = async (tier, context) => { if (!context.user || !context.user.email) { @@ -68,7 +77,10 @@ export const generateGptResponse: GenerateGptResponse = asyn }); // use map to extract the description and time from each task - const parsedTasks = tasks.map(({ description, time }) => ({ description, time })); + const parsedTasks = tasks.map(({ description, time }) => ({ + description, + time, + })); const payload = { model: 'gpt-3.5-turbo', // e.g. 'gpt-3.5-turbo', 'gpt-4', 'gpt-4-0613', gpt-4-1106-preview @@ -95,7 +107,10 @@ export const generateGptResponse: GenerateGptResponse = asyn items: { type: 'object', properties: { - name: { type: 'string', description: 'Name of main task provided by user' }, + name: { + type: 'string', + description: 'Name of main task provided by user', + }, subtasks: { type: 'array', items: { @@ -123,7 +138,10 @@ export const generateGptResponse: GenerateGptResponse = asyn description: 'detailed breakdown and description of break. e.g., "take a 15 minute standing break and reflect on what you have learned".', }, - time: { type: 'number', description: 'time allocated for a given break in hours, e.g. 0.2' }, + time: { + type: 'number', + description: 'time allocated for a given break in hours, e.g. 0.2', + }, }, }, }, @@ -190,7 +208,6 @@ export const generateGptResponse: GenerateGptResponse = asyn }); return gptArgs; - } catch (error: any) { if (!context.user.hasPaid && error?.statusCode != 402) { await context.entities.User.update({ @@ -278,6 +295,31 @@ export const updateUserById: UpdateUserById<{ id: number; data: Partial }, return updatedUser; }; +type fileArgs = { + fileType: string; + name: string; +}; + +export const createFile: CreateFile = async ({ fileType, name }, context) => { + if (!context.user) { + throw new HttpError(401); + } + + const userInfo = context.user.id.toString(); + + const { uploadUrl, key } = getUploadFileSignedURLFromS3({ fileType, userInfo }); + + return await context.entities.File.create({ + data: { + name, + key, + uploadUrl, + type: fileType, + user: { connect: { id: context.user.id } }, + }, + }); +}; + export const updateCurrentUser: UpdateCurrentUser, User> = async (user, context) => { if (!context.user) { throw new HttpError(401); diff --git a/app/src/server/file-upload/s3Utils.ts b/app/src/server/file-upload/s3Utils.ts new file mode 100644 index 0000000..014e1f4 --- /dev/null +++ b/app/src/server/file-upload/s3Utils.ts @@ -0,0 +1,39 @@ +import S3 from 'aws-sdk/clients/s3.js'; +import { randomUUID } from 'crypto'; + +const s3Client = new S3({ + apiVersion: '2006-03-01', + accessKeyId: process.env.AWS_S3_IAM_ACCESS_KEY, + secretAccessKey: process.env.AWS_S3_IAM_SECRET_KEY, + region: process.env.AWS_S3_REGION, + signatureVersion: 'v4', +}); + +type S3Upload = { + fileType: string; + userInfo: string; +} + +export const getUploadFileSignedURLFromS3 = ({fileType, userInfo}: S3Upload) => { + + const ex = fileType.split('/')[1]; + + const Key = `${userInfo}/${randomUUID()}.${ex}`; + const s3Params = { + Bucket: process.env.AWS_S3_FILES_BUCKET, + Key, + Expires: 30, + ContentType: `${fileType}`, + }; + const uploadUrl = s3Client.getSignedUrl("putObject", s3Params); + return { uploadUrl, key: Key }; +} + +export const getDownloadFileSignedURLFromS3 = ({ key }: { key: string }) => { + const s3Params = { + Bucket: process.env.AWS_S3_FILES_BUCKET, + Key: key, + Expires: 30, + }; + return s3Client.getSignedUrl("getObject", s3Params); +} \ No newline at end of file diff --git a/app/src/server/stripeUtils.ts b/app/src/server/payments/stripeUtils.ts similarity index 100% rename from app/src/server/stripeUtils.ts rename to app/src/server/payments/stripeUtils.ts diff --git a/app/src/server/queries.ts b/app/src/server/queries.ts index 68c26b9..d663d8c 100644 --- a/app/src/server/queries.ts +++ b/app/src/server/queries.ts @@ -1,11 +1,14 @@ import HttpError from '@wasp/core/HttpError.js'; -import type { DailyStats, GptResponse, User, PageViewSource, Task } from '@wasp/entities'; +import type { DailyStats, GptResponse, User, PageViewSource, Task, File } from '@wasp/entities'; import type { GetGptResponses, GetDailyStats, GetPaginatedUsers, - GetAllTasksByUser + GetAllTasksByUser, + GetAllFilesByUser, + GetDownloadFileSignedURL, } from '@wasp/queries/types'; +import { getDownloadFileSignedURLFromS3 } from './file-upload/s3Utils.js'; type DailyStatsWithSources = DailyStats & { sources: PageViewSource[]; @@ -43,7 +46,27 @@ export const getAllTasksByUser: GetAllTasksByUser = async (_args, createdAt: 'desc', }, }); -} +}; + +export const getAllFilesByUser: GetAllFilesByUser = async (_args, context) => { + if (!context.user) { + throw new HttpError(401); + } + return context.entities.File.findMany({ + where: { + user: { + id: context.user.id, + }, + }, + orderBy: { + createdAt: 'desc', + }, + }); +}; + +export const getDownloadFileSignedURL: GetDownloadFileSignedURL<{ key: string }, string> = async ({ key }, _context) => { + return getDownloadFileSignedURLFromS3({ key }); +}; export const getDailyStats: GetDailyStats = async (_args, context) => { if (!context.user?.isAdmin) { @@ -79,7 +102,10 @@ type GetPaginatedUsersInput = { subscriptionStatus?: string[]; }; type GetPaginatedUsersOutput = { - users: Pick[]; + users: Pick< + User, + 'id' | 'email' | 'username' | 'lastActiveTimestamp' | 'hasPaid' | 'subscriptionStatus' | 'stripeId' + >[]; totalPages: number; }; @@ -134,4 +160,4 @@ export const getPaginatedUsers: GetPaginatedUsers