add file upload page & actions

improve fetching download url
This commit is contained in:
vincanger 2024-02-05 17:09:46 -05:00
parent 389cd2bda1
commit 42e1da4aa5
10 changed files with 297 additions and 18 deletions

View File

@ -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
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

View File

@ -4,5 +4,6 @@
"singleQuote": true,
"endOfLine": "lf",
"tabWidth": 2,
"jsxSingleQuote": true
"jsxSingleQuote": true,
"printWidth": 120
}

View File

@ -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]

View File

@ -11,7 +11,7 @@ import { TiDelete } from 'react-icons/ti';
export default function DemoAppPage() {
return (
<div className='my-10 lg:mt-20'>
<div className='py-10 lg:mt-10'>
<div className='mx-auto max-w-7xl px-6 lg:px-8'>
<div className='mx-auto max-w-4xl text-center'>
<h2 className='mt-2 text-4xl font-bold tracking-tight text-gray-900 sm:text-5xl dark:text-white'>
@ -19,7 +19,7 @@ export default function DemoAppPage() {
</h2>
</div>
<p className='mx-auto mt-6 max-w-2xl text-center text-lg leading-8 text-gray-600 dark:text-white'>
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!
</p>
{/* begin AI-powered Todo List */}
<div className='my-8 border rounded-3xl border-gray-900/10'>

View File

@ -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<string>('');
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<HTMLFormElement>) => {
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 (
<div className='py-10 lg:mt-10'>
<div className='mx-auto max-w-7xl px-6 lg:px-8'>
<div className='mx-auto max-w-4xl text-center'>
<h2 className='mt-2 text-4xl font-bold tracking-tight text-gray-900 sm:text-5xl dark:text-white'>
<span className='text-yellow-500'>AWS</span> File Upload
</h2>
</div>
<p className='mx-auto mt-6 max-w-2xl text-center text-lg leading-8 text-gray-600 dark:text-white'>
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 🤝
</p>
<div className='my-8 border rounded-3xl border-gray-900/10'>
<div className='space-y-10 my-10 py-8 px-4 mx-auto sm:max-w-lg'>
<form onSubmit={handleUpload} className='flex flex-col gap-2'>
<input
type='file'
name='file-upload'
accept='image/jpeg, image/png, .pdf, text/*'
className='text-gray-600 '
/>
<button
type='submit'
className='min-w-[7rem] font-medium text-gray-800/90 bg-yellow-50 shadow-md ring-1 ring-inset ring-slate-200 py-2 px-4 rounded-md hover:bg-yellow-100 duration-200 ease-in-out focus:outline-none focus:shadow-none hover:shadow-none'
>
Upload
</button>
</form>
<div className='border-b-2 border-gray-200'></div>
<div className='space-y-4 col-span-full'>
<h2 className='text-xl font-bold'>Uploaded Files</h2>
{isFilesLoading && <p>Loading...</p>}
{filesError && <p>Error: {filesError.message}</p>}
{!!files && files.length > 0 ? (
files.map((file: any) => (
<div
key={file.key}
className={`flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3 ${
file.key === fileToDownload && isDownloadUrlLoading && 'opacity-70'
}`}
>
<p>{file.name}</p>
<button
onClick={() => setFileToDownload(file.key)}
disabled={file.key === fileToDownload && isDownloadUrlLoading}
className='min-w-[7rem] text-sm text-gray-800/90 bg-purple-50 shadow-md ring-1 ring-inset ring-slate-200 py-1 px-2 rounded-md hover:bg-purple-100 duration-200 ease-in-out focus:outline-none focus:shadow-none hover:shadow-none disabled:cursor-not-allowed'
>
{file.key === fileToDownload && isDownloadUrlLoading ? 'Loading...' : 'Download'}
</button>
</div>
))
) : (
<p>No files uploaded yet :(</p>
)}
</div>
</div>
</div>
</div>
</div>
);
}

View File

@ -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 },

View File

@ -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<string, StripePaymentResult> = async (tier, context) => {
if (!context.user || !context.user.email) {
@ -68,7 +77,10 @@ export const generateGptResponse: GenerateGptResponse<GptPayload, string> = 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<GptPayload, string> = 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<GptPayload, string> = 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<GptPayload, string> = 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<User> },
return updatedUser;
};
type fileArgs = {
fileType: string;
name: string;
};
export const createFile: CreateFile<fileArgs, File> = 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<Partial<User>, User> = async (user, context) => {
if (!context.user) {
throw new HttpError(401);

View File

@ -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);
}

View File

@ -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<void, Task[]> = async (_args,
createdAt: 'desc',
},
});
}
};
export const getAllFilesByUser: GetAllFilesByUser<void, File[]> = 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<void, DailyStatsValues> = async (_args, context) => {
if (!context.user?.isAdmin) {
@ -79,7 +102,10 @@ type GetPaginatedUsersInput = {
subscriptionStatus?: string[];
};
type GetPaginatedUsersOutput = {
users: Pick<User, 'id' | 'email' | 'username' | 'lastActiveTimestamp' | 'hasPaid' | 'subscriptionStatus' | 'stripeId'>[];
users: Pick<
User,
'id' | 'email' | 'username' | 'lastActiveTimestamp' | 'hasPaid' | 'subscriptionStatus' | 'stripeId'
>[];
totalPages: number;
};
@ -134,4 +160,4 @@ export const getPaginatedUsers: GetPaginatedUsers<GetPaginatedUsersInput, GetPag
users: queryResults,
totalPages,
};
};
};