mirror of
https://github.com/wasp-lang/open-saas.git
synced 2025-04-10 04:49:03 +02:00
add file upload page & actions
improve fetching download url
This commit is contained in:
parent
389cd2bda1
commit
42e1da4aa5
@ -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
|
@ -4,5 +4,6 @@
|
||||
"singleQuote": true,
|
||||
"endOfLine": "lf",
|
||||
"tabWidth": 2,
|
||||
"jsxSingleQuote": true
|
||||
"jsxSingleQuote": true,
|
||||
"printWidth": 120
|
||||
}
|
||||
|
@ -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]
|
||||
|
@ -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'>
|
||||
|
129
app/src/client/app/FileUploadPage.tsx
Normal file
129
app/src/client/app/FileUploadPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -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 },
|
||||
|
@ -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);
|
||||
|
39
app/src/server/file-upload/s3Utils.ts
Normal file
39
app/src/server/file-upload/s3Utils.ts
Normal 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);
|
||||
}
|
@ -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,
|
||||
};
|
||||
};
|
||||
};
|
||||
|
Loading…
x
Reference in New Issue
Block a user