From 911461b4752c3478fe67afee690ebf0b5c5d9460 Mon Sep 17 00:00:00 2001
From: vincanger <70215737+vincanger@users.noreply.github.com>
Date: Thu, 31 Jul 2025 13:04:53 +0200
Subject: [PATCH 01/14] Refactor file upload and toast notifications
Replaces react-hot-toast with a custom toast system using @radix-ui/react-toast, updating all usages and adding new UI components for toast and dialog. Refactors file upload to use a two-step process: first generating an S3 upload URL, then adding the file to the database, and adds file deletion support with confirmation dialog and S3 cleanup. Updates Prisma schema, removes unused fields, and cleans up navigation and admin settings page.
---
template/app/.cursor/rules/authentication.mdc | 6 +-
template/app/main.wasp | 14 +-
template/app/package.json | 2 +-
template/app/schema.prisma | 1 -
.../admin/elements/settings/SettingsPage.tsx | 12 +-
template/app/src/client/App.tsx | 2 +
.../src/client/components/NavBar/constants.ts | 1 -
template/app/src/components/ui/dialog.tsx | 120 ++++++++
template/app/src/components/ui/toast.tsx | 143 ++++++++++
template/app/src/components/ui/toaster.tsx | 37 +++
template/app/src/demo-ai-app/DemoAppPage.tsx | 32 ++-
.../app/src/file-upload/FileUploadPage.tsx | 256 ++++++++++++------
template/app/src/file-upload/fileUploading.ts | 12 +-
template/app/src/file-upload/operations.ts | 73 ++++-
template/app/src/file-upload/s3Utils.ts | 10 +-
template/app/src/hooks/use-toast.ts | 194 +++++++++++++
16 files changed, 796 insertions(+), 119 deletions(-)
create mode 100644 template/app/src/components/ui/dialog.tsx
create mode 100644 template/app/src/components/ui/toast.tsx
create mode 100644 template/app/src/components/ui/toaster.tsx
create mode 100644 template/app/src/hooks/use-toast.ts
diff --git a/template/app/.cursor/rules/authentication.mdc b/template/app/.cursor/rules/authentication.mdc
index cf8e7addc..25e622f43 100644
--- a/template/app/.cursor/rules/authentication.mdc
+++ b/template/app/.cursor/rules/authentication.mdc
@@ -92,16 +92,18 @@ See the Wasp Auth docs for available methods and complete guides [wasp-overview.
- Redirect or show alternative content if the user is not authenticated.
```typescript
import { useAuth } from 'wasp/client/auth';
- import { Redirect } from 'wasp/client/router'; // Or use Link
+ import { useNavigate } from 'react-router-dom';
const MyProtectedPage = () => {
const { data: user, isLoading, error } = useAuth(); // Returns AuthUser | null
+ const navigate = useNavigate();
if (isLoading) return
Loading...
;
// If error, it likely means the auth session is invalid/expired
if (error || !user) {
// Redirect to login page defined in main.wasp (auth.onAuthFailedRedirectTo)
- // Or return ;
+ // or use the navigate hook from react-router-dom for more fine-grained control
+ navigate('/some-other-path');
return
+ {action}
+
+
+ )
+ })}
+
+
+ )
+}
diff --git a/template/app/src/demo-ai-app/DemoAppPage.tsx b/template/app/src/demo-ai-app/DemoAppPage.tsx
index 517b08f2c..4dd2b6237 100644
--- a/template/app/src/demo-ai-app/DemoAppPage.tsx
+++ b/template/app/src/demo-ai-app/DemoAppPage.tsx
@@ -1,4 +1,5 @@
import { type Task } from 'wasp/entities';
+import type { GeneratedSchedule, Task as ScheduleTask, TaskItem, TaskPriority } from './schedule';
import {
createTask,
@@ -8,8 +9,10 @@ import {
updateTask,
useQuery,
} from 'wasp/client/operations';
+import { routes, Link } from 'wasp/client/router';
+import { useNavigate } from 'react-router-dom';
-import { Loader2, Trash2 } from 'lucide-react';
+import { ArrowRight, Loader2, Trash2 } from 'lucide-react';
import { useMemo, useState } from 'react';
import { Button } from '../components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '../components/ui/card';
@@ -17,7 +20,7 @@ import { Checkbox } from '../components/ui/checkbox';
import { Input } from '../components/ui/input';
import { Label } from '../components/ui/label';
import { cn } from '../lib/utils';
-import type { GeneratedSchedule, Task as ScheduleTask, TaskItem, TaskPriority } from './schedule';
+import { toast } from '../hooks/use-toast';
export default function DemoAppPage() {
return (
@@ -113,6 +116,7 @@ function NewTaskForm({ handleCreateTask }: { handleCreateTask: typeof createTask
const [isPlanGenerating, setIsPlanGenerating] = useState(false);
const { data: tasks, isLoading: isTasksLoading } = useQuery(getAllTasksByUser);
+ const navigate = useNavigate();
const handleSubmit = async () => {
try {
@@ -130,10 +134,30 @@ function NewTaskForm({ handleCreateTask }: { handleCreateTask: typeof createTask
hours: todaysHours,
});
if (response) {
- setResponse(response as unknown as GeneratedSchedule);
+ setResponse(response);
}
} catch (err: any) {
- window.alert('Error: ' + (err.message || 'Something went wrong'));
+ if (err.statusCode === 402) {
+ const toastId = toast({
+ title: '⚠️ You are out of credits!',
+ style: {
+ minWidth: '400px',
+ },
+ action: (
+
+
+
+ ),
+ });
+ } else {
+ toast({
+ title: 'Error',
+ description: err.message || 'Something went wrong',
+ variant: 'destructive',
+ });
+ }
} finally {
setIsPlanGenerating(false);
}
diff --git a/template/app/src/file-upload/FileUploadPage.tsx b/template/app/src/file-upload/FileUploadPage.tsx
index 4c725a732..a129a80db 100644
--- a/template/app/src/file-upload/FileUploadPage.tsx
+++ b/template/app/src/file-upload/FileUploadPage.tsx
@@ -1,9 +1,24 @@
import { FormEvent, useEffect, useState } from 'react';
-import { getAllFilesByUser, getDownloadFileSignedURL, useQuery } from 'wasp/client/operations';
+import {
+ getAllFilesByUser,
+ getDownloadFileSignedURL,
+ useQuery,
+ createFileUploadUrl,
+ addFileToDb,
+ deleteFile,
+} from 'wasp/client/operations';
import type { File } from 'wasp/entities';
import { Alert, AlertDescription } from '../components/ui/alert';
import { Button } from '../components/ui/button';
import { Card, CardContent, CardTitle } from '../components/ui/card';
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from '../components/ui/dialog';
import { Input } from '../components/ui/input';
import { Label } from '../components/ui/label';
import { Progress } from '../components/ui/progress';
@@ -15,11 +30,14 @@ import {
validateFile,
} from './fileUploading';
import { ALLOWED_FILE_TYPES } from './validation';
+import { Trash, Download } from 'lucide-react';
+import { toast } from '../hooks/use-toast';
export default function FileUploadPage() {
const [fileKeyForS3, setFileKeyForS3] = useState('');
const [uploadProgressPercent, setUploadProgressPercent] = useState(0);
const [uploadError, setUploadError] = useState(null);
+ const [fileToDelete, setFileToDelete] = useState | null>(null);
const allUserFiles = useQuery(getAllFilesByUser, undefined, {
// We disable automatic refetching because otherwise files would be refetched after `createFile` is called and the S3 URL is returned,
@@ -82,7 +100,26 @@ export default function FileUploadPage() {
return;
}
- await uploadFileWithProgress({ file: file as FileWithValidType, setUploadProgressPercent });
+ const fileWithValidType = file as FileWithValidType;
+
+ const { s3UploadUrl, s3UploadFields, key } = await createFileUploadUrl({
+ fileType: fileWithValidType.type,
+ fileName: fileWithValidType.name,
+ });
+
+ await uploadFileWithProgress({
+ file: fileWithValidType,
+ s3UploadUrl,
+ s3UploadFields,
+ setUploadProgressPercent,
+ });
+
+ await addFileToDb({
+ key,
+ fileType: fileWithValidType.type,
+ fileName: fileWithValidType.name,
+ });
+
formElement.reset();
allUserFiles.refetch();
} catch (error) {
@@ -98,86 +135,143 @@ export default function FileUploadPage() {
};
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 🤝
-
+ 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 🤝
+
+- 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 🤝
++ 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 🤝
+
+
+
diff --git a/opensaas-sh/app_diff/src/file-upload/fileUploading.ts.diff b/opensaas-sh/app_diff/src/file-upload/fileUploading.ts.diff
index a85000b7d..327fede1b 100644
--- a/opensaas-sh/app_diff/src/file-upload/fileUploading.ts.diff
+++ b/opensaas-sh/app_diff/src/file-upload/fileUploading.ts.diff
@@ -1,9 +1,29 @@
--- template/app/src/file-upload/fileUploading.ts
+++ opensaas-sh/app/src/file-upload/fileUploading.ts
-@@ -1,5 +1,5 @@
--import { createFile } from 'wasp/client/operations';
+@@ -1,5 +1,7 @@
++import type { User } from 'wasp/entities';
import axios from 'axios';
-+import { createFile } from 'wasp/client/operations';
import { ALLOWED_FILE_TYPES, MAX_FILE_SIZE_BYTES } from './validation';
++import { PrismaClient } from '@prisma/client';
export type FileWithValidType = Omit & { type: AllowedFileType };
+ type AllowedFileType = (typeof ALLOWED_FILE_TYPES)[number];
+@@ -63,3 +65,17 @@
+ function isAllowedFileType(fileType: string): fileType is AllowedFileType {
+ return (ALLOWED_FILE_TYPES as readonly string[]).includes(fileType);
+ }
++
++export async function checkIfUserHasReachedFileUploadLimit({ userId, prismaFileDelegate }: { userId: User['id']; prismaFileDelegate: PrismaClient['file'] }) {
++ const numberOfFilesByUser = await prismaFileDelegate.count({
++ where: {
++ user: {
++ id: userId,
++ },
++ },
++ });
++ if (numberOfFilesByUser >= 2) {
++ return true;
++ }
++ return false;
++}
+\ No newline at end of file
diff --git a/opensaas-sh/app_diff/src/file-upload/operations.ts.diff b/opensaas-sh/app_diff/src/file-upload/operations.ts.diff
index bacecb0cb..f93ea0b11 100644
--- a/opensaas-sh/app_diff/src/file-upload/operations.ts.diff
+++ b/opensaas-sh/app_diff/src/file-upload/operations.ts.diff
@@ -1,39 +1,34 @@
--- template/app/src/file-upload/operations.ts
+++ opensaas-sh/app/src/file-upload/operations.ts
-@@ -1,14 +1,14 @@
+@@ -1,6 +1,5 @@
-import * as z from 'zod';
-import { HttpError } from 'wasp/server';
import { type File } from 'wasp/entities';
+import { HttpError } from 'wasp/server';
import {
- type CreateFile,
type GetAllFilesByUser,
type GetDownloadFileSignedURL,
+@@ -8,6 +7,8 @@
+ type CreateFileUploadUrl,
+ type AddFileToDb,
} from 'wasp/server/operations';
++import { checkIfUserHasReachedFileUploadLimit } from './fileUploading';
+import * as z from 'zod';
--import { getUploadFileSignedURLFromS3, getDownloadFileSignedURLFromS3 } from './s3Utils';
+ import { getUploadFileSignedURLFromS3, getDownloadFileSignedURLFromS3, deleteFileFromS3 } from './s3Utils';
import { ensureArgsSchemaOrThrowHttpError } from '../server/validation';
-+import { getDownloadFileSignedURLFromS3, getUploadFileSignedURLFromS3 } from './s3Utils';
- import { ALLOWED_FILE_TYPES } from './validation';
+@@ -32,6 +33,14 @@
+ throw new HttpError(401);
+ }
- const createFileInputSchema = z.object({
-@@ -37,6 +37,18 @@
- userId: context.user.id,
- });
-
-+ const numberOfFilesByUser = await context.entities.File.count({
-+ where: {
-+ user: {
-+ id: context.user.id,
-+ },
-+ },
++ const userFileLimitReached = await checkIfUserHasReachedFileUploadLimit({
++ userId: context.user.id,
++ prismaFileDelegate: context.entities.File,
+ });
-+
-+ if (numberOfFilesByUser >= 2) {
-+ throw new HttpError(403, 'Thanks for trying Open SaaS. This demo only allows 2 file uploads per user.');
++ if (userFileLimitReached) {
++ throw new HttpError(403, 'This demo only allows 2 file uploads per user.');
+ }
+
- await context.entities.File.create({
- data: {
- name: fileName,
+ const { fileType, fileName } = ensureArgsSchemaOrThrowHttpError(createFileInputSchema, rawArgs);
+
+ const { s3UploadUrl, s3UploadFields, key } = await getUploadFileSignedURLFromS3({
diff --git a/opensaas-sh/app_diff/src/file-upload/s3Utils.ts.diff b/opensaas-sh/app_diff/src/file-upload/s3Utils.ts.diff
deleted file mode 100644
index ddf4b4ea1..000000000
--- a/opensaas-sh/app_diff/src/file-upload/s3Utils.ts.diff
+++ /dev/null
@@ -1,15 +0,0 @@
---- template/app/src/file-upload/s3Utils.ts
-+++ opensaas-sh/app/src/file-upload/s3Utils.ts
-@@ -1,8 +1,8 @@
--import * as path from 'path';
--import { randomUUID } from 'crypto';
--import { S3Client, GetObjectCommand } from '@aws-sdk/client-s3';
--import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
-+import { GetObjectCommand, S3Client } from '@aws-sdk/client-s3';
- import { createPresignedPost } from '@aws-sdk/s3-presigned-post';
-+import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
-+import { randomUUID } from 'crypto';
-+import * as path from 'path';
- import { MAX_FILE_SIZE_BYTES } from './validation';
-
- const s3Client = new S3Client({
diff --git a/opensaas-sh/app_diff/src/landing-page/contentSections.tsx.diff b/opensaas-sh/app_diff/src/landing-page/contentSections.tsx.diff
index 809e0d43b..e9e65b120 100644
--- a/opensaas-sh/app_diff/src/landing-page/contentSections.tsx.diff
+++ b/opensaas-sh/app_diff/src/landing-page/contentSections.tsx.diff
@@ -1,6 +1,6 @@
--- template/app/src/landing-page/contentSections.tsx
+++ opensaas-sh/app/src/landing-page/contentSections.tsx
-@@ -0,0 +1,247 @@
+@@ -0,0 +1,239 @@
+import { routes } from 'wasp/client/router';
+import type { NavigationItem } from '../client/components/NavBar/NavBar';
+import blog from '../client/static/assets/blog.webp';
@@ -113,20 +113,12 @@
+ 'I used Wasp to build and sell my AI-augmented SaaS app for marketplace vendors within two months!',
+ },
+ {
-+ name: 'Jonathan Cocharan',
-+ role: 'Entrepreneur',
-+ avatarSrc: 'https://pbs.twimg.com/profile_images/1910056203863883776/jtfVWaEG_400x400.jpg',
-+ socialUrl: 'https://twitter.com/JonathanCochran',
-+ quote:
-+ 'In just 6 nights... my SaaS app is live 🎉! Huge thanks to the amazing @wasplang community 🙌 for their guidance along the way. These tools are incredibly efficient 🤯!',
-+ },
-+ {
+ name: 'Billy Howell',
-+ role: 'Entrepreneur',
++ role: 'Founder @ Stupid Simple Apps',
+ avatarSrc: 'https://pbs.twimg.com/profile_images/1877734205561430016/jjpG4mS6_400x400.jpg',
+ socialUrl: 'https://twitter.com/billyjhowell',
+ quote:
-+ "Congrats! I am loving Wasp. It's really helped me, a self-taught coder increase my confidence. I feel like I've finally found the perfect, versatile stack for all my projects instead of trying out a new one each time.",
++ "Congrats! I am loving Wasp & Open SaaS. It's really helped me, a self-taught coder increase my confidence. I feel like I've finally found the perfect, versatile stack for all my projects instead of trying out a new one each time.",
+ },
+ {
+ name: 'Tim Skaggs',
@@ -140,7 +132,7 @@
+ role: 'Founder @ Microinfluencer.club',
+ avatarSrc: 'https://pbs.twimg.com/profile_images/1927721707164377089/it8oCAkf_400x400.jpg',
+ socialUrl: 'https://twitter.com/CamBlackwood95',
-+ quote: 'Setting up a full stack SaaS in 1 minute with WaspLang.',
++ quote: 'Setting up a full stack SaaS in 1 minute with Wasp.',
+ },
+ {
+ name: 'JLegendz',
diff --git a/opensaas-sh/blog/src/content/docs/guides/vibe-coding.mdx b/opensaas-sh/blog/src/content/docs/guides/vibe-coding.mdx
index b7ec3780f..5c352bcb1 100644
--- a/opensaas-sh/blog/src/content/docs/guides/vibe-coding.mdx
+++ b/opensaas-sh/blog/src/content/docs/guides/vibe-coding.mdx
@@ -32,8 +32,8 @@ The template comes with:
We've also created a bunch of LLM-friendly documentation:
- [Open SaaS Docs - LLMs.txt](https://docs.opensaas.sh/llms.txt) - Links to the raw text docs.
- **[Open SaaS Docs - LLMs-full.txt](https://docs.opensaas.sh/llms-full.txt) - Complete docs as one text file.** ✅😎
-- Coming Soon! ~~[Wasp Docs - LLMs.txt](https://wasp.sh/llms.txt)~~ - Links to the raw text docs.
-- Coming Soon! ~~[Wasp Docs - LLMs-full.txt](https://wasp.sh/llms-full.txt)~~ - Complete docs as one text file.
+- [Wasp Docs - LLMs.txt](https://wasp.sh/llms.txt) - Links to the raw text docs.
+- **[Wasp Docs - LLMs-full.txt](https://wasp.sh/llms-full.txt) - Complete docs as one text file.**
Add these to your AI-assisted IDE settings so you can easily reference them in your chat sessions with the LLM.
**In most cases, you'll want to pass the `llms-full.txt` to the LLM and ask it to help you with a specific task.**
From f56de2343c8c6b062d3238d871023f04d6588501 Mon Sep 17 00:00:00 2001
From: vincanger <70215737+vincanger@users.noreply.github.com>
Date: Thu, 31 Jul 2025 16:33:44 +0200
Subject: [PATCH 03/14] update tests
---
template/e2e-tests/tests/utils.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/template/e2e-tests/tests/utils.ts b/template/e2e-tests/tests/utils.ts
index d002c9aec..51deeac96 100644
--- a/template/e2e-tests/tests/utils.ts
+++ b/template/e2e-tests/tests/utils.ts
@@ -84,7 +84,7 @@ export const makeStripePayment = async ({
}) => {
test.slow(); // Stripe payments take a long time to confirm and can cause tests to fail so we use a longer timeout
- await page.click('text="Pricing"');
+ await page.goto('/pricing');
await page.waitForURL('**/pricing');
const buyBtn = page.locator(`button[aria-describedby="${planId}"]`);
From e91a93bdcf771d80bfe8a4b5471201526aa54b4a Mon Sep 17 00:00:00 2001
From: vincanger <70215737+vincanger@users.noreply.github.com>
Date: Fri, 1 Aug 2025 15:49:33 +0200
Subject: [PATCH 04/14] Improve file deletion error handling and cleanup
Refactors file deletion to delete the database record before attempting S3 deletion, ensuring the file is removed from the app even if S3 deletion fails. Adds error logging for failed S3 deletions to aid in manual cleanup. Also simplifies error handling in the file upload page and removes unused imports in the demo app page.
---
template/app/src/demo-ai-app/DemoAppPage.tsx | 2 --
template/app/src/file-upload/FileUploadPage.tsx | 6 +++---
template/app/src/file-upload/operations.ts | 15 +++++++--------
3 files changed, 10 insertions(+), 13 deletions(-)
diff --git a/template/app/src/demo-ai-app/DemoAppPage.tsx b/template/app/src/demo-ai-app/DemoAppPage.tsx
index 4dd2b6237..95e32fcf8 100644
--- a/template/app/src/demo-ai-app/DemoAppPage.tsx
+++ b/template/app/src/demo-ai-app/DemoAppPage.tsx
@@ -10,7 +10,6 @@ import {
useQuery,
} from 'wasp/client/operations';
import { routes, Link } from 'wasp/client/router';
-import { useNavigate } from 'react-router-dom';
import { ArrowRight, Loader2, Trash2 } from 'lucide-react';
import { useMemo, useState } from 'react';
@@ -116,7 +115,6 @@ function NewTaskForm({ handleCreateTask }: { handleCreateTask: typeof createTask
const [isPlanGenerating, setIsPlanGenerating] = useState(false);
const { data: tasks, isLoading: isTasksLoading } = useQuery(getAllTasksByUser);
- const navigate = useNavigate();
const handleSubmit = async () => {
try {
diff --git a/template/app/src/file-upload/FileUploadPage.tsx b/template/app/src/file-upload/FileUploadPage.tsx
index a129a80db..bbbb6d41b 100644
--- a/template/app/src/file-upload/FileUploadPage.tsx
+++ b/template/app/src/file-upload/FileUploadPage.tsx
@@ -245,7 +245,7 @@ export default function FileUploadPage() {
variant='destructive'
onClick={async () => {
try {
- await deleteFile({ id: fileToDelete.id, key: fileToDelete.key });
+ await deleteFile({ id: fileToDelete.id });
toast({
title: 'File deleted',
description: (
@@ -255,10 +255,10 @@ export default function FileUploadPage() {
),
});
allUserFiles.refetch();
- } catch (error: any) {
+ } catch (error) {
toast({
title: 'Error',
- description: error.message || 'Error deleting file.',
+ description: 'Error deleting file.',
variant: 'destructive',
});
} finally {
diff --git a/template/app/src/file-upload/operations.ts b/template/app/src/file-upload/operations.ts
index efe4e450b..40884a0b6 100644
--- a/template/app/src/file-upload/operations.ts
+++ b/template/app/src/file-upload/operations.ts
@@ -100,7 +100,6 @@ export const addFileToDb: AddFileToDb = async (args, con
const deleteFileInputSchema = z.object({
id: z.string(),
- key: z.string(),
});
type DeleteFileInput = z.infer;
@@ -110,7 +109,7 @@ export const deleteFile: DeleteFile = async (args, contex
throw new HttpError(401);
}
- await context.entities.File.findUniqueOrThrow({
+ const deletedFile = await context.entities.File.delete({
where: {
id: args.id,
user: {
@@ -119,11 +118,11 @@ export const deleteFile: DeleteFile = async (args, contex
},
});
- await deleteFileFromS3({ key: args.key });
+ try {
+ await deleteFileFromS3({ key: deletedFile.key });
+ } catch (error) {
+ console.error(`S3 deletion failed. Orphaned file key: ${deletedFile.key}`, error);
+ }
- return context.entities.File.delete({
- where: {
- id: args.id,
- },
- });
+ return deletedFile;
};
From 5013b48b208b1ac5a8937ff8eb8d4efbbb8977bd Mon Sep 17 00:00:00 2001
From: vincanger <70215737+vincanger@users.noreply.github.com>
Date: Tue, 5 Aug 2025 17:10:17 +0200
Subject: [PATCH 05/14] Add credit check and S3 file existence validation
Added logic to decrement user credits or throw an error if out of credits in the AI demo app. Updated file upload operations to validate file existence in S3 before adding to the database, and implemented S3 file existence check utility. Minor UI and code improvements included.
---
template/app/src/components/ui/toast.tsx | 2 +-
template/app/src/demo-ai-app/DemoAppPage.tsx | 11 ++--
template/app/src/demo-ai-app/operations.ts | 16 +++++
.../app/src/file-upload/FileUploadPage.tsx | 4 +-
template/app/src/file-upload/operations.ts | 58 +++++++++++--------
template/app/src/file-upload/s3Utils.ts | 17 +++++-
6 files changed, 75 insertions(+), 33 deletions(-)
diff --git a/template/app/src/components/ui/toast.tsx b/template/app/src/components/ui/toast.tsx
index 874c688b7..24f92a57f 100644
--- a/template/app/src/components/ui/toast.tsx
+++ b/template/app/src/components/ui/toast.tsx
@@ -76,7 +76,7 @@ const ToastAction = React.forwardRef<
-
-
+
+
),
});
} else {
diff --git a/template/app/src/demo-ai-app/operations.ts b/template/app/src/demo-ai-app/operations.ts
index 26137b522..da7a10c5c 100644
--- a/template/app/src/demo-ai-app/operations.ts
+++ b/template/app/src/demo-ai-app/operations.ts
@@ -39,6 +39,22 @@ export const generateGptResponse: GenerateGptResponse 0) {
+ const decrementCredit = context.entities.User.update({
+ where: { id: context.user.id },
+ data: {
+ credits: {
+ decrement: 1,
+ },
+ },
+ });
+
+ } else {
+ 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: {
diff --git a/template/app/src/file-upload/FileUploadPage.tsx b/template/app/src/file-upload/FileUploadPage.tsx
index bbbb6d41b..c34e4bb36 100644
--- a/template/app/src/file-upload/FileUploadPage.tsx
+++ b/template/app/src/file-upload/FileUploadPage.tsx
@@ -144,8 +144,8 @@ export default function FileUploadPage() {
- 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 🤝
+ 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 🤝
-- 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 🤝
-+ 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 🤝
-
-
-
diff --git a/opensaas-sh/app_diff/src/file-upload/operations.ts.diff b/opensaas-sh/app_diff/src/file-upload/operations.ts.diff
index f93ea0b11..b27fc5e93 100644
--- a/opensaas-sh/app_diff/src/file-upload/operations.ts.diff
+++ b/opensaas-sh/app_diff/src/file-upload/operations.ts.diff
@@ -15,9 +15,9 @@
+import { checkIfUserHasReachedFileUploadLimit } from './fileUploading';
+import * as z from 'zod';
- import { getUploadFileSignedURLFromS3, getDownloadFileSignedURLFromS3, deleteFileFromS3 } from './s3Utils';
- import { ensureArgsSchemaOrThrowHttpError } from '../server/validation';
-@@ -32,6 +33,14 @@
+ import {
+ getUploadFileSignedURLFromS3,
+@@ -37,6 +38,14 @@
throw new HttpError(401);
}
From 605220d3ecf44f482e16d3eb2e5e0bee414489b2 Mon Sep 17 00:00:00 2001
From: vincanger <70215737+vincanger@users.noreply.github.com>
Date: Tue, 5 Aug 2025 17:57:49 +0200
Subject: [PATCH 08/14] fix diff
---
.../src/landing-page/contentSections.tsx.diff | 236 +++++++++++++-----
...contentSections.ts => contentSections.tsx} | 0
2 files changed, 167 insertions(+), 69 deletions(-)
rename template/app/src/landing-page/{contentSections.ts => contentSections.tsx} (100%)
diff --git a/opensaas-sh/app_diff/src/landing-page/contentSections.tsx.diff b/opensaas-sh/app_diff/src/landing-page/contentSections.tsx.diff
index d841cb699..27cb7d75a 100644
--- a/opensaas-sh/app_diff/src/landing-page/contentSections.tsx.diff
+++ b/opensaas-sh/app_diff/src/landing-page/contentSections.tsx.diff
@@ -1,53 +1,73 @@
--- template/app/src/landing-page/contentSections.tsx
+++ opensaas-sh/app/src/landing-page/contentSections.tsx
-@@ -0,0 +1,239 @@
+@@ -1,4 +1,9 @@
+-import daBoiAvatar from '../client/static/da-boi.webp';
+import { routes } from 'wasp/client/router';
+import type { NavigationItem } from '../client/components/NavBar/NavBar';
+import blog from '../client/static/assets/blog.webp';
+import email from '../client/static/assets/email.webp';
+import fileupload from '../client/static/assets/fileupload.webp';
+import ai from '../client/static/assets/openapi.webp';
-+import kivo from '../client/static/examples/kivo.webp';
-+import messync from '../client/static/examples/messync.webp';
-+import microinfluencerClub from '../client/static/examples/microinfluencers.webp';
-+import promptpanda from '../client/static/examples/promptpanda.webp';
-+import reviewradar from '../client/static/examples/reviewradar.webp';
-+import scribeist from '../client/static/examples/scribeist.webp';
-+import searchcraft from '../client/static/examples/searchcraft.webp';
+ import kivo from '../client/static/examples/kivo.webp';
+ import messync from '../client/static/examples/messync.webp';
+ import microinfluencerClub from '../client/static/examples/microinfluencers.webp';
+@@ -6,161 +11,226 @@
+ import reviewradar from '../client/static/examples/reviewradar.webp';
+ import scribeist from '../client/static/examples/scribeist.webp';
+ import searchcraft from '../client/static/examples/searchcraft.webp';
+-import { BlogUrl, DocsUrl } from '../shared/common';
+-import type { GridFeature } from './components/FeaturesGrid';
+import logo from '../client/static/logo.webp';
+import { BlogUrl, DocsUrl, GithubUrl, WaspUrl } from '../shared/common';
+import { GridFeature } from './components/FeaturesGrid';
-+
+
+export const landingPageNavigationItems: NavigationItem[] = [
+ { name: 'Features', to: '#features' },
+ { name: 'Documentation', to: DocsUrl },
+ { name: 'Blog', to: BlogUrl },
+];
-+export const features: GridFeature[] = [
-+ {
+ export const features: GridFeature[] = [
+ {
+- name: 'Cool Feature 1',
+- description: 'Your feature',
+- emoji: '🤝',
+ description: 'Have a sweet AI-powered app concept? Get your idea shipped to potential customers in days!',
+ icon: ,
-+ href: DocsUrl,
+ href: DocsUrl,
+- size: 'small',
+ size: 'medium',
+ fullWidthIcon: true,
+ align: 'left',
-+ },
-+ {
+ },
+ {
+- name: 'Cool Feature 2',
+- description: 'Feature description',
+- emoji: '🔐',
+ name: 'Full-stack Type Safety',
+ description:
+ 'Full support for TypeScript with auto-generated types that span the whole stack. Nothing to configure!',
+ emoji: '🥞',
-+ href: DocsUrl,
+ href: DocsUrl,
+- size: 'small',
+ size: 'medium',
-+ },
-+ {
+ },
+ {
+- name: 'Cool Feature 3',
+- description: 'Describe your cool feature here',
+- emoji: '🥞',
+- href: DocsUrl,
+ description: 'File upload examples with AWS S3 presigned URLs are included and fully documented!',
+ icon: ,
+ href: DocsUrl + '/guides/file-uploading/',
-+ size: 'medium',
+ size: 'medium',
+ fullWidthIcon: true,
-+ },
-+ {
+ },
+ {
+- name: 'Cool Feature 4',
+- description: 'Describe your cool feature here',
+- emoji: '💸',
+- href: DocsUrl,
+- size: 'large',
+ name: 'Email Sending',
+ description:
+ 'Email sending built-in. Combine it with the cron jobs feature to easily send emails to your customers.',
@@ -56,16 +76,26 @@
+ size: 'medium',
+ fullWidthIcon: true,
+ direction: 'col-reverse',
-+ },
-+ {
+ },
+ {
+- name: 'Cool Feature 5',
+- description: 'Describe your cool feature here',
+- emoji: '💼',
+- href: DocsUrl,
+- size: 'large',
+ name: 'Open SaaS',
+ description: 'Try the demo app',
+ icon: ,
+ href: routes.LoginRoute.to,
+ size: 'medium',
+ highlight: true,
-+ },
-+ {
+ },
+ {
+- name: 'Cool Feature 6',
+- description: 'It is cool',
+- emoji: '📈',
+- href: DocsUrl,
+- size: 'small',
+ name: 'Blog w/ Astro',
+ description:
+ 'Built-in blog with the Astro framework. Write your posts in Markdown, and watch your SEO performance take off.',
@@ -73,8 +103,11 @@
+ href: DocsUrl + '/start/guided-tour/',
+ size: 'medium',
+ fullWidthIcon: true,
-+ },
-+ {
+ },
+ {
+- name: 'Cool Feature 7',
+- description: 'Cool feature',
+- emoji: '📧',
+ name: 'Deploy Anywhere. Easily.',
+ description:
+ 'No vendor lock-in because you own all your code. Deploy yourself, or let Wasp deploy it for you with a single command.',
@@ -85,26 +118,54 @@
+ {
+ name: 'Complete Documentation & Support',
+ description: 'And a Discord community to help!',
-+ href: DocsUrl,
-+ size: 'small',
-+ },
-+ {
+ href: DocsUrl,
+ size: 'small',
+ },
+ {
+- name: 'Cool Feature 8',
+- description: 'Describe your cool feature here',
+- emoji: '🤖',
+- href: DocsUrl,
+- size: 'medium',
+ name: 'E2E Tests w/ Playwright',
+ description: 'Tests and a CI pipeline w/ GitHub Actions',
+ href: DocsUrl + '/guides/tests/',
+ size: 'small',
-+ },
-+ {
+ },
+ {
+- name: 'Cool Feature 9',
+- description: 'Describe your cool feature here',
+- emoji: '🚀',
+ name: 'Open-Source Philosophy',
+ description:
+ 'The repo and framework are 100% open-source, and so are the services wherever possible. Still missing something? Contribute!',
+ emoji: '🤝',
-+ href: DocsUrl,
-+ size: 'medium',
-+ },
-+];
-+export const testimonials = [
-+ {
+ href: DocsUrl,
+ size: 'medium',
+ },
+ ];
+-
+ export const testimonials = [
+ {
+- name: 'Da Boi',
+- role: 'Wasp Mascot',
+- avatarSrc: daBoiAvatar,
+- socialUrl: 'https://twitter.com/wasplang',
+- quote: "I don't even know how to code. I'm just a plushie.",
+- },
+- {
+- name: 'Mr. Foobar',
+- role: 'Founder @ Cool Startup',
+- avatarSrc: daBoiAvatar,
+- socialUrl: '',
+- quote: 'This product makes me cooler than I already am.',
+- },
+- {
+- name: 'Jamie',
+- role: 'Happy Customer',
+- avatarSrc: daBoiAvatar,
+- socialUrl: '#',
+- quote: 'My cats love it!',
+ name: 'Max Khamrovskyi',
+ role: 'Senior Eng @ Red Hat',
+ avatarSrc: 'https://pbs.twimg.com/profile_images/1719397191205179392/V_QrGPSO_400x400.jpg',
@@ -165,11 +226,15 @@
+ socialUrl: 'https://dev.to/wasp/our-web-framework-reached-9000-stars-on-github-9000-jij#comment-2dech',
+ quote:
+ "This is exactly the framework I've been dreaming of ever since I've been waiting to fully venture into the JS Backend Dev world. I believe Wasp will go above 50k stars this year. The documentation alone gives me the confidence that this is my permanent Nodejs framework and I'm staying with Wasp. Phenomenal work by the team... Please keep up your amazing spirits. Thank you",
-+ },
-+];
-+export const faqs = [
-+ {
-+ id: 1,
+ },
+ ];
+-
+ export const faqs = [
+ {
+ id: 1,
+- question: 'Whats the meaning of life?',
+- answer: '42.',
+- href: 'https://en.wikipedia.org/wiki/42_(number)',
+ question: 'Why is this SaaS Template free and open-source?',
+ answer:
+ 'We believe the best product is made when the community puts their heads together. We also believe a quality starting point for a web app should be free and available to everyone. Our hope is that together we can create the best SaaS template out there and bring our ideas to customers quickly.',
@@ -180,60 +245,93 @@
+ href: 'https://wasp-lang.dev',
+ answer:
+ "It's the fastest way to develop full-stack React + NodeJS + Prisma apps and it's what gives this template superpowers. Wasp relies on React, NodeJS, and Prisma to define web components and server queries and actions. Wasp's secret sauce is its compiler which takes the client, server code, and config file and outputs the client app, server app and deployment code, supercharging the development experience. Combined with this template, you can build a SaaS app in record time.",
-+ },
-+];
-+export const footerNavigation = {
-+ app: [
+ },
+ ];
+-
+ export const footerNavigation = {
+ app: [
+ { name: 'Github', href: GithubUrl },
-+ { name: 'Documentation', href: DocsUrl },
-+ { name: 'Blog', href: BlogUrl },
-+ ],
-+ company: [
+ { name: 'Documentation', href: DocsUrl },
+ { name: 'Blog', href: BlogUrl },
+ ],
+ company: [
+- { name: 'About', href: 'https://wasp.sh' },
+- { name: 'Privacy', href: '#' },
+- { name: 'Terms of Service', href: '#' },
+ { name: 'Terms of Service', href: GithubUrl + '/blob/main/LICENSE' },
+ { name: 'Made by the Wasp team = }', href: WaspUrl },
-+ ],
-+};
-+export const examples = [
-+ {
+ ],
+ };
+-
+ export const examples = [
+ {
+- name: 'Example #1',
+- description: 'Describe your example here.',
+- imageSrc: kivo,
+- href: '#',
+ name: 'Microinfluencers',
+ description: 'microinfluencer.club',
+ imageSrc: microinfluencerClub,
+ href: 'https://microinfluencer.club',
-+ },
-+ {
+ },
+ {
+- name: 'Example #2',
+- description: 'Describe your example here.',
+- imageSrc: messync,
+- href: '#',
+ name: 'Kivo',
+ description: 'kivo.dev',
+ imageSrc: kivo,
+ href: 'https://kivo.dev',
-+ },
-+ {
+ },
+ {
+- name: 'Example #3',
+- description: 'Describe your example here.',
+- imageSrc: microinfluencerClub,
+- href: '#',
+ name: 'Searchcraft',
+ description: 'searchcraft.io',
+ imageSrc: searchcraft,
+ href: 'https://www.searchcraft.io',
-+ },
-+ {
+ },
+ {
+- name: 'Example #4',
+- description: 'Describe your example here.',
+- imageSrc: promptpanda,
+- href: '#',
+ name: 'Scribeist',
+ description: 'scribeist.com',
+ imageSrc: scribeist,
+ href: 'https://scribeist.com',
-+ },
-+ {
+ },
+ {
+- name: 'Example #5',
+- description: 'Describe your example here.',
+- imageSrc: reviewradar,
+- href: '#',
+ name: 'Messync',
+ description: 'messync.com',
+ imageSrc: messync,
+ href: 'https://messync.com',
-+ },
-+ {
+ },
+ {
+- name: 'Example #6',
+- description: 'Describe your example here.',
+- imageSrc: scribeist,
+- href: '#',
+ name: 'Prompt Panda',
+ description: 'promptpanda.io',
+ imageSrc: promptpanda,
+ href: 'https://promptpanda.io',
-+ },
-+ {
+ },
+ {
+- name: 'Example #7',
+- description: 'Describe your example here.',
+- imageSrc: searchcraft,
+- href: '#',
+ name: 'Review Radar',
+ description: 'reviewradar.ai',
+ imageSrc: reviewradar,
+ href: 'https://reviewradar.ai',
-+ },
-+];
+ },
+ ];
diff --git a/template/app/src/landing-page/contentSections.ts b/template/app/src/landing-page/contentSections.tsx
similarity index 100%
rename from template/app/src/landing-page/contentSections.ts
rename to template/app/src/landing-page/contentSections.tsx
From 2f1117d8ebc27c343f6164d5ade3a45ba677b795 Mon Sep 17 00:00:00 2001
From: vincanger <70215737+vincanger@users.noreply.github.com>
Date: Wed, 6 Aug 2025 10:08:43 +0200
Subject: [PATCH 09/14] Update deletions
---
opensaas-sh/app_diff/deletions | 1 -
1 file changed, 1 deletion(-)
diff --git a/opensaas-sh/app_diff/deletions b/opensaas-sh/app_diff/deletions
index 90fe220da..3344ea0e2 100644
--- a/opensaas-sh/app_diff/deletions
+++ b/opensaas-sh/app_diff/deletions
@@ -1,7 +1,6 @@
src/client/static/open-saas-banner-dark.png
src/client/static/open-saas-banner-light.png
src/landing-page/components/Hero.tsx
-src/landing-page/contentSections.ts
src/payment/lemonSqueezy/checkoutUtils.ts
src/payment/lemonSqueezy/paymentDetails.ts
src/payment/lemonSqueezy/paymentProcessor.ts
From 4f94475056732d461c413a06cf2c5213501bcb71 Mon Sep 17 00:00:00 2001
From: vincanger <70215737+vincanger@users.noreply.github.com>
Date: Wed, 6 Aug 2025 10:54:55 +0200
Subject: [PATCH 10/14] Improve toast UI, error handling, and credit messaging
Updated toast action hover style and icon spacing for better UI consistency. Enhanced error handling in file deletion to display specific error messages. Refined credit/subscription error message in GPT response operation for clarity and removed redundant credit decrement logic.
---
template/app/src/components/ui/toast.tsx | 4 ++--
template/app/src/demo-ai-app/DemoAppPage.tsx | 2 +-
template/app/src/demo-ai-app/operations.ts | 18 +-----------------
.../app/src/file-upload/FileUploadPage.tsx | 3 ++-
4 files changed, 6 insertions(+), 21 deletions(-)
diff --git a/template/app/src/components/ui/toast.tsx b/template/app/src/components/ui/toast.tsx
index 24f92a57f..7dd25f7a9 100644
--- a/template/app/src/components/ui/toast.tsx
+++ b/template/app/src/components/ui/toast.tsx
@@ -76,12 +76,12 @@ const ToastAction = React.forwardRef<
-))
+));
ToastAction.displayName = ToastPrimitives.Action.displayName
const ToastClose = React.forwardRef<
diff --git a/template/app/src/demo-ai-app/DemoAppPage.tsx b/template/app/src/demo-ai-app/DemoAppPage.tsx
index 4c6729aff..7ef9d4c12 100644
--- a/template/app/src/demo-ai-app/DemoAppPage.tsx
+++ b/template/app/src/demo-ai-app/DemoAppPage.tsx
@@ -145,7 +145,7 @@ function NewTaskForm({ handleCreateTask }: { handleCreateTask: typeof createTask
action: (
- Go to pricing page
+ Go to pricing page
),
diff --git a/template/app/src/demo-ai-app/operations.ts b/template/app/src/demo-ai-app/operations.ts
index da7a10c5c..dc3bd32c6 100644
--- a/template/app/src/demo-ai-app/operations.ts
+++ b/template/app/src/demo-ai-app/operations.ts
@@ -39,22 +39,6 @@ export const generateGptResponse: GenerateGptResponse 0) {
- const decrementCredit = context.entities.User.update({
- where: { id: context.user.id },
- data: {
- credits: {
- decrement: 1,
- },
- },
- });
-
- } else {
- 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: {
@@ -99,7 +83,7 @@ export const generateGptResponse: GenerateGptResponse
Date: Wed, 6 Aug 2025 12:18:55 +0200
Subject: [PATCH 11/14] Refactor file upload validation and error handling
Replaces error state management with toast notifications for file upload errors and success. Refactors file type validation to use a new ALLOWED_FILE_TYPES_CONST and type AllowedFileTypes. Updates validation logic to throw errors instead of returning error objects, and simplifies type handling across file upload modules.
---
.../app/src/file-upload/FileUploadPage.tsx | 66 ++++++++-----------
template/app/src/file-upload/fileUploading.ts | 43 +++++-------
template/app/src/file-upload/operations.ts | 14 ++--
template/app/src/file-upload/validation.ts | 3 +-
4 files changed, 50 insertions(+), 76 deletions(-)
diff --git a/template/app/src/file-upload/FileUploadPage.tsx b/template/app/src/file-upload/FileUploadPage.tsx
index bf8dd57d6..42af67ed9 100644
--- a/template/app/src/file-upload/FileUploadPage.tsx
+++ b/template/app/src/file-upload/FileUploadPage.tsx
@@ -23,20 +23,14 @@ import { Input } from '../components/ui/input';
import { Label } from '../components/ui/label';
import { Progress } from '../components/ui/progress';
import { cn } from '../lib/utils';
-import {
- type FileUploadError,
- type FileWithValidType,
- uploadFileWithProgress,
- validateFile,
-} from './fileUploading';
-import { ALLOWED_FILE_TYPES } from './validation';
+import { uploadFileWithProgress, validateFile } from './fileUploading';
+import { ALLOWED_FILE_TYPES_CONST } from './validation';
import { Trash, Download } from 'lucide-react';
import { toast } from '../hooks/use-toast';
export default function FileUploadPage() {
const [fileKeyForS3, setFileKeyForS3] = useState('');
const [uploadProgressPercent, setUploadProgressPercent] = useState(0);
- const [uploadError, setUploadError] = useState(null);
const [fileToDelete, setFileToDelete] = useState | null>(null);
const allUserFiles = useQuery(getAllFilesByUser, undefined, {
@@ -61,7 +55,11 @@ export default function FileUploadPage() {
switch (urlQuery.status) {
case 'error':
console.error('Error fetching download URL', urlQuery.error);
- alert('Error fetching download');
+ toast({
+ title: 'Error fetching download link',
+ description: 'Please try again later.',
+ variant: 'destructive',
+ });
return;
case 'success':
window.open(urlQuery.data, '_blank');
@@ -84,31 +82,26 @@ export default function FileUploadPage() {
}
const formData = new FormData(formElement);
- const file = formData.get('file-upload');
+ const formDataFileUpload = formData.get('file-upload');
- if (!file || !(file instanceof File)) {
- setUploadError({
- message: 'Please select a file to upload.',
- code: 'NO_FILE',
+ if (!formDataFileUpload || !(formDataFileUpload instanceof File) || formDataFileUpload.size === 0) {
+ toast({
+ title: 'No file selected',
+ description: 'Please select a file to upload.',
+ variant: 'destructive',
});
return;
}
- const fileValidationError = validateFile(file);
- if (fileValidationError !== null) {
- setUploadError(fileValidationError);
- return;
- }
-
- const fileWithValidType = file as FileWithValidType;
+ const file = validateFile(formDataFileUpload);
const { s3UploadUrl, s3UploadFields, key } = await createFileUploadUrl({
- fileType: fileWithValidType.type,
- fileName: fileWithValidType.name,
+ fileType: file.type,
+ fileName: file.name,
});
await uploadFileWithProgress({
- file: fileWithValidType,
+ file,
s3UploadUrl,
s3UploadFields,
setUploadProgressPercent,
@@ -116,18 +109,23 @@ export default function FileUploadPage() {
await addFileToDb({
key,
- fileType: fileWithValidType.type,
- fileName: fileWithValidType.name,
+ fileType: file.type,
+ fileName: file.name,
});
formElement.reset();
allUserFiles.refetch();
+ toast({
+ title: 'File uploaded',
+ description: 'Your file has been successfully uploaded.',
+ });
} catch (error) {
console.error('Error uploading file:', error);
- setUploadError({
- message:
- error instanceof Error ? error.message : 'An unexpected error occurred while uploading the file.',
- code: 'UPLOAD_FAILED',
+ const errorMessage = error instanceof Error ? error.message : 'Error uploading file.';
+ toast({
+ title: 'Error uploading file',
+ description: errorMessage,
+ variant: 'destructive',
});
} finally {
setUploadProgressPercent(0);
@@ -158,8 +156,7 @@ export default function FileUploadPage() {
type='file'
id='file-upload'
name='file-upload'
- accept={ALLOWED_FILE_TYPES.join(',')}
- onChange={() => setUploadError(null)}
+ accept={ALLOWED_FILE_TYPES_CONST.join(',')}
className='cursor-pointer'
/>
@@ -169,11 +166,6 @@ export default function FileUploadPage() {
{uploadProgressPercent > 0 && }
- {uploadError && (
-
- {uploadError.message}
-
- )}
diff --git a/template/app/src/file-upload/fileUploading.ts b/template/app/src/file-upload/fileUploading.ts
index dc05a9c9c..a5f1d439e 100644
--- a/template/app/src/file-upload/fileUploading.ts
+++ b/template/app/src/file-upload/fileUploading.ts
@@ -1,21 +1,19 @@
import axios from 'axios';
-import { ALLOWED_FILE_TYPES, MAX_FILE_SIZE_BYTES } from './validation';
+import { type AllowedFileTypes, ALLOWED_FILE_TYPES_CONST, MAX_FILE_SIZE_BYTES } from './validation';
-export type FileWithValidType = Omit & { type: AllowedFileType };
-type AllowedFileType = (typeof ALLOWED_FILE_TYPES)[number];
-interface FileUploadProgress {
- file: FileWithValidType;
- s3UploadUrl: string;
- s3UploadFields: Record;
- setUploadProgressPercent: (percentage: number) => void;
-}
+export type FileWithValidType = File & { type: AllowedFileTypes };
export async function uploadFileWithProgress({
file,
s3UploadUrl,
s3UploadFields,
setUploadProgressPercent,
-}: FileUploadProgress) {
+}: {
+ file: FileWithValidType;
+ s3UploadUrl: string;
+ s3UploadFields: Record;
+ setUploadProgressPercent: (percentage: number) => void;
+}) {
const formData = getFileUploadFormData(file, s3UploadFields);
return axios.post(s3UploadUrl, formData, {
@@ -37,29 +35,18 @@ function getFileUploadFormData(file: File, s3UploadFields: Record MAX_FILE_SIZE_BYTES) {
- return {
- message: `File size exceeds ${MAX_FILE_SIZE_BYTES / 1024 / 1024}MB limit.`,
- code: 'FILE_TOO_LARGE' as const,
- };
+ throw new Error(`File size exceeds ${MAX_FILE_SIZE_BYTES / 1024 / 1024}MB limit.`);
}
- if (!isAllowedFileType(file.type)) {
- return {
- message: `File type '${file.type}' is not supported.`,
- code: 'INVALID_FILE_TYPE' as const,
- };
+ if (!isFileWithAllowedFileType(file)) {
+ throw new Error(`File type '${file.type}' is not supported.`);
}
- return null;
+ return file;
}
-function isAllowedFileType(fileType: string): fileType is AllowedFileType {
- return (ALLOWED_FILE_TYPES as readonly string[]).includes(fileType);
+function isFileWithAllowedFileType(file: File): file is FileWithValidType {
+ return ALLOWED_FILE_TYPES_CONST.includes(file.type as AllowedFileTypes);
}
diff --git a/template/app/src/file-upload/operations.ts b/template/app/src/file-upload/operations.ts
index ccc6f7448..7ffe1dae3 100644
--- a/template/app/src/file-upload/operations.ts
+++ b/template/app/src/file-upload/operations.ts
@@ -16,10 +16,10 @@ import {
checkFileExistsInS3,
} from './s3Utils';
import { ensureArgsSchemaOrThrowHttpError } from '../server/validation';
-import { ALLOWED_FILE_TYPES } from './validation';
+import { ALLOWED_FILE_TYPES_CONST } from './validation';
const createFileInputSchema = z.object({
- fileType: z.enum(ALLOWED_FILE_TYPES),
+ fileType: z.enum(ALLOWED_FILE_TYPES_CONST),
fileName: z.string().nonempty(),
});
@@ -39,22 +39,16 @@ export const createFileUploadUrl: CreateFileUploadUrl<
const { fileType, fileName } = ensureArgsSchemaOrThrowHttpError(createFileInputSchema, rawArgs);
- const { s3UploadUrl, s3UploadFields, key } = await getUploadFileSignedURLFromS3({
+ return await getUploadFileSignedURLFromS3({
fileType,
fileName,
userId: context.user.id,
});
-
- return {
- s3UploadUrl,
- s3UploadFields,
- key,
- };
};
const addFileToDbInputSchema = z.object({
key: z.string(),
- fileType: z.enum(ALLOWED_FILE_TYPES),
+ fileType: z.enum(ALLOWED_FILE_TYPES_CONST),
fileName: z.string(),
});
diff --git a/template/app/src/file-upload/validation.ts b/template/app/src/file-upload/validation.ts
index 045a36394..a0791f399 100644
--- a/template/app/src/file-upload/validation.ts
+++ b/template/app/src/file-upload/validation.ts
@@ -1,6 +1,6 @@
// Set this to the max file size you want to allow (currently 5MB).
export const MAX_FILE_SIZE_BYTES = 5 * 1024 * 1024;
-export const ALLOWED_FILE_TYPES = [
+export const ALLOWED_FILE_TYPES_CONST = [
'image/jpeg',
'image/png',
'application/pdf',
@@ -8,3 +8,4 @@ export const ALLOWED_FILE_TYPES = [
'video/quicktime',
'video/mp4',
] as const;
+export type AllowedFileTypes = (typeof ALLOWED_FILE_TYPES_CONST)[number];
From 2d2b2d0dba76c30cf07a1dc495f4b6d3b48d5c19 Mon Sep 17 00:00:00 2001
From: vincanger <70215737+vincanger@users.noreply.github.com>
Date: Wed, 6 Aug 2025 14:53:06 +0200
Subject: [PATCH 12/14] Refactor file upload to use s3Key and add cleanup job
Replaces the 'key' field with 's3Key' for file storage references throughout the codebase and database schema. Updates all related logic, types, and API contracts to use 's3Key'. Adds a scheduled job to delete old files from S3 and the database. Cleans up file type validation constants and improves consistency in file upload and download operations.
---
opensaas-sh/app_diff/main.wasp.diff | 21 +++++++++++-
.../migration.sql.diff | 14 ++++++++
.../src/file-upload/fileUploading.ts.diff | 11 +++----
.../src/file-upload/operations.ts.diff | 2 +-
.../app_diff/src/file-upload/workers.ts.diff | 32 +++++++++++++++++++
template/app/schema.prisma | 2 +-
.../app/src/file-upload/FileUploadPage.tsx | 22 ++++++-------
template/app/src/file-upload/fileUploading.ts | 5 +--
template/app/src/file-upload/operations.ts | 24 +++++++-------
template/app/src/file-upload/s3Utils.ts | 18 +++++------
template/app/src/file-upload/validation.ts | 3 +-
11 files changed, 109 insertions(+), 45 deletions(-)
create mode 100644 opensaas-sh/app_diff/migrations/20250806121259_add_s3_key_file/migration.sql.diff
create mode 100644 opensaas-sh/app_diff/src/file-upload/workers.ts.diff
diff --git a/opensaas-sh/app_diff/main.wasp.diff b/opensaas-sh/app_diff/main.wasp.diff
index 057e48346..efb6710ae 100644
--- a/opensaas-sh/app_diff/main.wasp.diff
+++ b/opensaas-sh/app_diff/main.wasp.diff
@@ -119,7 +119,26 @@
httpRoute: (POST, "/payments-webhook")
}
//#endregion
-@@ -291,7 +289,6 @@
+@@ -245,6 +243,18 @@
+ fn: import { deleteFile } from "@src/file-upload/operations",
+ entities: [User, File]
+ }
++
++job deleteFilesJob {
++ executor: PgBoss,
++ perform: {
++ fn: import { deleteFilesJob } from "@src/file-upload/workers"
++ },
++ schedule: {
++ cron: "0 0 * * *" // every day at midnight
++ // cron: "* * * * *" // every minute. useful for debugging
++ },
++ entities: [File]
++}
+ //#endregion
+
+ //#region Analytics
+@@ -291,7 +301,6 @@
component: import AdminCalendar from "@src/admin/elements/calendar/CalendarPage"
}
diff --git a/opensaas-sh/app_diff/migrations/20250806121259_add_s3_key_file/migration.sql.diff b/opensaas-sh/app_diff/migrations/20250806121259_add_s3_key_file/migration.sql.diff
new file mode 100644
index 000000000..b72ee9c56
--- /dev/null
+++ b/opensaas-sh/app_diff/migrations/20250806121259_add_s3_key_file/migration.sql.diff
@@ -0,0 +1,14 @@
+--- template/app/migrations/20250806121259_add_s3_key_file/migration.sql
++++ opensaas-sh/app/migrations/20250806121259_add_s3_key_file/migration.sql
+@@ -0,0 +1,11 @@
++/*
++ Warnings:
++
++ - You are about to drop the column `key` on the `File` table. All the data in the column will be lost.
++ - Added the required column `s3Key` to the `File` table without a default value. This is not possible if the table is not empty.
++
++*/
++-- AlterTable
++DELETE FROM "File";
++ALTER TABLE "File" DROP COLUMN "key",
++ADD COLUMN "s3Key" TEXT NOT NULL;
diff --git a/opensaas-sh/app_diff/src/file-upload/fileUploading.ts.diff b/opensaas-sh/app_diff/src/file-upload/fileUploading.ts.diff
index 327fede1b..bdc5f0c55 100644
--- a/opensaas-sh/app_diff/src/file-upload/fileUploading.ts.diff
+++ b/opensaas-sh/app_diff/src/file-upload/fileUploading.ts.diff
@@ -6,11 +6,11 @@
import { ALLOWED_FILE_TYPES, MAX_FILE_SIZE_BYTES } from './validation';
+import { PrismaClient } from '@prisma/client';
- export type FileWithValidType = Omit & { type: AllowedFileType };
- type AllowedFileType = (typeof ALLOWED_FILE_TYPES)[number];
-@@ -63,3 +65,17 @@
- function isAllowedFileType(fileType: string): fileType is AllowedFileType {
- return (ALLOWED_FILE_TYPES as readonly string[]).includes(fileType);
+ type AllowedFileTypes = (typeof ALLOWED_FILE_TYPES)[number];
+ export type FileWithValidType = File & { type: AllowedFileTypes };
+@@ -51,3 +53,17 @@
+ function isFileWithAllowedFileType(file: File): file is FileWithValidType {
+ return ALLOWED_FILE_TYPES.includes(file.type as AllowedFileTypes);
}
+
+export async function checkIfUserHasReachedFileUploadLimit({ userId, prismaFileDelegate }: { userId: User['id']; prismaFileDelegate: PrismaClient['file'] }) {
@@ -26,4 +26,3 @@
+ }
+ return false;
+}
-\ No newline at end of file
diff --git a/opensaas-sh/app_diff/src/file-upload/operations.ts.diff b/opensaas-sh/app_diff/src/file-upload/operations.ts.diff
index b27fc5e93..feb0d18be 100644
--- a/opensaas-sh/app_diff/src/file-upload/operations.ts.diff
+++ b/opensaas-sh/app_diff/src/file-upload/operations.ts.diff
@@ -31,4 +31,4 @@
+
const { fileType, fileName } = ensureArgsSchemaOrThrowHttpError(createFileInputSchema, rawArgs);
- const { s3UploadUrl, s3UploadFields, key } = await getUploadFileSignedURLFromS3({
+ return await getUploadFileSignedURLFromS3({
diff --git a/opensaas-sh/app_diff/src/file-upload/workers.ts.diff b/opensaas-sh/app_diff/src/file-upload/workers.ts.diff
new file mode 100644
index 000000000..75c6112ba
--- /dev/null
+++ b/opensaas-sh/app_diff/src/file-upload/workers.ts.diff
@@ -0,0 +1,32 @@
+--- template/app/src/file-upload/workers.ts
++++ opensaas-sh/app/src/file-upload/workers.ts
+@@ -0,0 +1,29 @@
++import type { DeleteFilesJob } from 'wasp/server/jobs';
++import { deleteFileFromS3 } from './s3Utils';
++
++export const deleteFilesJob: DeleteFilesJob = async (_args, context) => {
++ const filesToDelete = await context.entities.File.findMany({
++ where: {
++ createdAt: {
++ lt: new Date(Date.now() - 1000 * 60 * 60 * 24 * 7),
++ },
++ },
++ select: { s3Key: true, id: true },
++ });
++
++ for (const file of filesToDelete) {
++ try {
++ await deleteFileFromS3({ s3Key: file.s3Key });
++ } catch (err) {
++ console.error(`Failed to delete S3 file with key ${file.s3Key}:`, err);
++ }
++ }
++
++ const deletedFiles = await context.entities.File.deleteMany({
++ where: {
++ id: { in: filesToDelete.map((file) => file.id) },
++ },
++ });
++
++ console.log(`Deleted ${deletedFiles.count} files`);
++};
diff --git a/template/app/schema.prisma b/template/app/schema.prisma
index 53647e718..3c4ff6e98 100644
--- a/template/app/schema.prisma
+++ b/template/app/schema.prisma
@@ -60,7 +60,7 @@ model File {
name String
type String
- key String
+ s3Key String
}
model DailyStats {
diff --git a/template/app/src/file-upload/FileUploadPage.tsx b/template/app/src/file-upload/FileUploadPage.tsx
index 42af67ed9..3b03a7988 100644
--- a/template/app/src/file-upload/FileUploadPage.tsx
+++ b/template/app/src/file-upload/FileUploadPage.tsx
@@ -24,14 +24,14 @@ import { Label } from '../components/ui/label';
import { Progress } from '../components/ui/progress';
import { cn } from '../lib/utils';
import { uploadFileWithProgress, validateFile } from './fileUploading';
-import { ALLOWED_FILE_TYPES_CONST } from './validation';
+import { ALLOWED_FILE_TYPES } from './validation';
import { Trash, Download } from 'lucide-react';
import { toast } from '../hooks/use-toast';
export default function FileUploadPage() {
- const [fileKeyForS3, setFileKeyForS3] = useState('');
+ const [fileKeyForS3, setFileKeyForS3] = useState('');
const [uploadProgressPercent, setUploadProgressPercent] = useState(0);
- const [fileToDelete, setFileToDelete] = useState | null>(null);
+ const [fileToDelete, setFileToDelete] = useState | null>(null);
const allUserFiles = useQuery(getAllFilesByUser, undefined, {
// We disable automatic refetching because otherwise files would be refetched after `createFile` is called and the S3 URL is returned,
@@ -40,7 +40,7 @@ export default function FileUploadPage() {
});
const { isLoading: isDownloadUrlLoading, refetch: refetchDownloadUrl } = useQuery(
getDownloadFileSignedURL,
- { key: fileKeyForS3 },
+ { s3Key: fileKeyForS3 },
{ enabled: false }
);
@@ -95,7 +95,7 @@ export default function FileUploadPage() {
const file = validateFile(formDataFileUpload);
- const { s3UploadUrl, s3UploadFields, key } = await createFileUploadUrl({
+ const { s3UploadUrl, s3UploadFields, s3Key } = await createFileUploadUrl({
fileType: file.type,
fileName: file.name,
});
@@ -108,7 +108,7 @@ export default function FileUploadPage() {
});
await addFileToDb({
- key,
+ s3Key,
fileType: file.type,
fileName: file.name,
});
@@ -156,7 +156,7 @@ export default function FileUploadPage() {
type='file'
id='file-upload'
name='file-upload'
- accept={ALLOWED_FILE_TYPES_CONST.join(',')}
+ accept={ALLOWED_FILE_TYPES.join(',')}
className='cursor-pointer'
/>