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
Please log in to access this page.
; } diff --git a/template/app/main.wasp b/template/app/main.wasp index d1b3ea365..8befdd28d 100644 --- a/template/app/main.wasp +++ b/template/app/main.wasp @@ -221,8 +221,13 @@ page FileUploadPage { component: import FileUpload from "@src/file-upload/FileUploadPage" } -action createFile { - fn: import { createFile } from "@src/file-upload/operations", +action createFileUploadUrl { + fn: import { createFileUploadUrl } from "@src/file-upload/operations", + entities: [User, File] +} + +action addFileToDb { + fn: import { addFileToDb } from "@src/file-upload/operations", entities: [User, File] } @@ -235,6 +240,11 @@ query getDownloadFileSignedURL { fn: import { getDownloadFileSignedURL } from "@src/file-upload/operations", entities: [User, File] } + +action deleteFile { + fn: import { deleteFile } from "@src/file-upload/operations", + entities: [User, File] +} //#endregion //#region Analytics diff --git a/template/app/package.json b/template/app/package.json index adb0b6119..4606d6074 100644 --- a/template/app/package.json +++ b/template/app/package.json @@ -20,6 +20,7 @@ "@radix-ui/react-separator": "^1.1.7", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-switch": "^1.2.5", + "@radix-ui/react-toast": "^1.2.14", "@tailwindcss/forms": "^0.5.3", "@tailwindcss/typography": "^0.5.7", "apexcharts": "3.41.0", @@ -35,7 +36,6 @@ "react-apexcharts": "1.4.1", "react-dom": "^18.2.0", "react-hook-form": "^7.60.0", - "react-hot-toast": "^2.4.1", "react-router-dom": "^6.26.2", "stripe": "18.1.0", "tailwind-merge": "^2.2.1", diff --git a/template/app/schema.prisma b/template/app/schema.prisma index eda467170..53647e718 100644 --- a/template/app/schema.prisma +++ b/template/app/schema.prisma @@ -61,7 +61,6 @@ model File { name String type String key String - uploadUrl String } model DailyStats { diff --git a/template/app/src/admin/elements/settings/SettingsPage.tsx b/template/app/src/admin/elements/settings/SettingsPage.tsx index 087d8ce38..4e6b425d5 100644 --- a/template/app/src/admin/elements/settings/SettingsPage.tsx +++ b/template/app/src/admin/elements/settings/SettingsPage.tsx @@ -1,6 +1,5 @@ import { FileText, Mail, Upload, User } from 'lucide-react'; import { FormEvent } from 'react'; -import toast from 'react-hot-toast'; import { type AuthUser } from 'wasp/auth'; import { Button } from '../../../components/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '../../../components/ui/card'; @@ -15,15 +14,10 @@ const SettingsPage = ({ user }: { user: AuthUser }) => { useRedirectHomeUnlessUserIsAdmin({ user }); const handleSubmit = (event: FormEvent) => { - // TODO add toast provider / wrapper + // TODO implement event.preventDefault(); - const confirmed = confirm('Are you sure you want to save the changes?'); - if (confirmed) { - toast.success('Your changes have been saved successfully!'); - } else { - toast.error('Your changes have not been saved!'); - } - }; + alert('Not yet implemented'); + } return ( diff --git a/template/app/src/client/App.tsx b/template/app/src/client/App.tsx index b077dc822..6bd042e0c 100644 --- a/template/app/src/client/App.tsx +++ b/template/app/src/client/App.tsx @@ -5,6 +5,7 @@ import './Main.css'; import NavBar from './components/NavBar/NavBar'; import { demoNavigationitems, marketingNavigationItems } from './components/NavBar/constants'; import CookieConsentBanner from './components/cookie-consent/Banner'; +import { Toaster } from '../components/ui/toaster'; /** * use this component to wrap all child components @@ -52,6 +53,7 @@ export default function App() { )} + ); diff --git a/template/app/src/client/components/NavBar/constants.ts b/template/app/src/client/components/NavBar/constants.ts index df65fca96..8e0ecfad8 100644 --- a/template/app/src/client/components/NavBar/constants.ts +++ b/template/app/src/client/components/NavBar/constants.ts @@ -16,6 +16,5 @@ export const marketingNavigationItems: NavigationItem[] = [ export const demoNavigationitems: NavigationItem[] = [ { name: 'AI Scheduler', to: routes.DemoAppRoute.to }, { name: 'File Upload', to: routes.FileUploadRoute.to }, - { name: 'Pricing', to: routes.PricingPageRoute.to }, ...staticNavigationItems, ] as const; diff --git a/template/app/src/components/ui/dialog.tsx b/template/app/src/components/ui/dialog.tsx new file mode 100644 index 000000000..24c7b7454 --- /dev/null +++ b/template/app/src/components/ui/dialog.tsx @@ -0,0 +1,120 @@ +import * as React from "react" +import * as DialogPrimitive from "@radix-ui/react-dialog" +import { X } from "lucide-react" + +import { cn } from "../../lib/utils" + +const Dialog = DialogPrimitive.Root + +const DialogTrigger = DialogPrimitive.Trigger + +const DialogPortal = DialogPrimitive.Portal + +const DialogClose = DialogPrimitive.Close + +const DialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DialogOverlay.displayName = DialogPrimitive.Overlay.displayName + +const DialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + {children} + + + Close + + + +)) +DialogContent.displayName = DialogPrimitive.Content.displayName + +const DialogHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +DialogHeader.displayName = "DialogHeader" + +const DialogFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +DialogFooter.displayName = "DialogFooter" + +const DialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DialogTitle.displayName = DialogPrimitive.Title.displayName + +const DialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DialogDescription.displayName = DialogPrimitive.Description.displayName + +export { + Dialog, + DialogPortal, + DialogOverlay, + DialogTrigger, + DialogClose, + DialogContent, + DialogHeader, + DialogFooter, + DialogTitle, + DialogDescription, +} diff --git a/template/app/src/components/ui/toast.tsx b/template/app/src/components/ui/toast.tsx new file mode 100644 index 000000000..874c688b7 --- /dev/null +++ b/template/app/src/components/ui/toast.tsx @@ -0,0 +1,143 @@ +import * as React from "react" +import * as ToastPrimitives from "@radix-ui/react-toast" +import { cva, type VariantProps } from "class-variance-authority" +import { X } from "lucide-react" + +import { cn } from "../../lib/utils" + +const ToastProvider = ToastPrimitives.Provider + +const ToastViewport = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + position?: "top-left" | "top-center" | "top-right" | "bottom-left" | "bottom-center" | "bottom-right" + } +>(({ className, position, ...props }, ref) => { + const positionClasses = position + ? { + "top-left": "top-0 left-0", + "top-center": "top-0 left-1/2 -translate-x-1/2", + "top-right": "top-0 right-0", + "bottom-left": "bottom-0 left-0", + "bottom-center": "bottom-0 left-1/2 -translate-x-1/2", + "bottom-right": "bottom-0 right-0", + }[position] + : "top-0 sm:bottom-0 sm:right-0 sm:top-auto" + + return ( + + ) +}) +ToastViewport.displayName = ToastPrimitives.Viewport.displayName + +const toastVariants = cva( + "group pointer-events-auto relative flex w-full items-center justify-between space-x-2 overflow-hidden rounded-md border p-4 pr-6 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full", + { + variants: { + variant: { + default: "border bg-background text-foreground", + destructive: + "destructive group border-destructive bg-destructive text-destructive-foreground", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +const Toast = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & + VariantProps +>(({ className, variant, ...props }, ref) => { + return ( + + ) +}) +Toast.displayName = ToastPrimitives.Root.displayName + +const ToastAction = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +ToastAction.displayName = ToastPrimitives.Action.displayName + +const ToastClose = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)) +ToastClose.displayName = ToastPrimitives.Close.displayName + +const ToastTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +ToastTitle.displayName = ToastPrimitives.Title.displayName + +const ToastDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +ToastDescription.displayName = ToastPrimitives.Description.displayName + +type ToastProps = React.ComponentPropsWithoutRef + +type ToastActionElement = React.ReactElement + +export { + type ToastProps, + type ToastActionElement, + ToastProvider, + ToastViewport, + Toast, + ToastTitle, + ToastDescription, + ToastClose, + ToastAction, +} diff --git a/template/app/src/components/ui/toaster.tsx b/template/app/src/components/ui/toaster.tsx new file mode 100644 index 000000000..9ba3afdb6 --- /dev/null +++ b/template/app/src/components/ui/toaster.tsx @@ -0,0 +1,37 @@ +import { useToast } from "../../hooks/use-toast" +import { + Toast, + ToastClose, + ToastDescription, + ToastProvider, + ToastTitle, + ToastViewport, +} from "./toast" + +export function Toaster({ + position, +}: { + position?: "top-left" | "top-center" | "top-right" | "bottom-left" | "bottom-center" | "bottom-right" +}) { + const { toasts } = useToast() + + return ( + + {toasts.map(function ({ id, title, description, action, ...props }) { + return ( + +
+ {title && {title}} + {description && ( + {description} + )} +
+ {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 🤝 -

- - -
-
- - setUploadError(null)} - className='cursor-pointer' - /> -
-
- - {uploadProgressPercent > 0 && } -
- {uploadError && ( - - {uploadError.message} - - )} -
-
-
- Uploaded Files - {allUserFiles.isLoading &&

Loading...

} - {allUserFiles.error && ( - - Error: {allUserFiles.error.message} - - )} - {!!allUserFiles.data && allUserFiles.data.length > 0 && !allUserFiles.isLoading ? ( -
- {allUserFiles.data.map((file: File) => ( - -
-

{file.name}

- -
-
- ))} + <> +
+
+
+

+ 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 🤝 +

+ + +
+
+ + setUploadError(null)} + className='cursor-pointer' + />
- ) : ( -

No files uploaded yet :(

- )} -
- - +
+ + {uploadProgressPercent > 0 && } +
+ {uploadError && ( + + {uploadError.message} + + )} + +
+
+ Uploaded Files + {allUserFiles.isLoading &&

Loading...

} + {allUserFiles.error && ( + + Error: {allUserFiles.error.message} + + )} + {!!allUserFiles.data && allUserFiles.data.length > 0 && !allUserFiles.isLoading ? ( +
+ {allUserFiles.data.map((file: File) => ( + +
+

{file.name}

+
+ + +
+
+
+ ))} +
+ ) : ( +

No files uploaded yet :(

+ )} +
+ + +
-
+ {fileToDelete && ( + !isOpen && setFileToDelete(null)}> + + + Delete file + + Are you sure you want to delete {fileToDelete.name}? This action cannot be + undone. + + + + + + + + + )} + ); } diff --git a/template/app/src/file-upload/fileUploading.ts b/template/app/src/file-upload/fileUploading.ts index 3c94917de..dc05a9c9c 100644 --- a/template/app/src/file-upload/fileUploading.ts +++ b/template/app/src/file-upload/fileUploading.ts @@ -1,4 +1,3 @@ -import { createFile } from 'wasp/client/operations'; import axios from 'axios'; import { ALLOWED_FILE_TYPES, MAX_FILE_SIZE_BYTES } from './validation'; @@ -6,12 +5,17 @@ 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 async function uploadFileWithProgress({ file, setUploadProgressPercent }: FileUploadProgress) { - const { s3UploadUrl, s3UploadFields } = await createFile({ fileType: file.type, fileName: file.name }); - +export async function uploadFileWithProgress({ + file, + s3UploadUrl, + s3UploadFields, + setUploadProgressPercent, +}: FileUploadProgress) { const formData = getFileUploadFormData(file, s3UploadFields); return axios.post(s3UploadUrl, formData, { diff --git a/template/app/src/file-upload/operations.ts b/template/app/src/file-upload/operations.ts index 6fe8386e8..efe4e450b 100644 --- a/template/app/src/file-upload/operations.ts +++ b/template/app/src/file-upload/operations.ts @@ -2,12 +2,14 @@ import * as z from 'zod'; import { HttpError } from 'wasp/server'; import { type File } from 'wasp/entities'; import { - type CreateFile, type GetAllFilesByUser, type GetDownloadFileSignedURL, + type DeleteFile, + type CreateFileUploadUrl, + type AddFileToDb, } from 'wasp/server/operations'; -import { getUploadFileSignedURLFromS3, getDownloadFileSignedURLFromS3 } from './s3Utils'; +import { getUploadFileSignedURLFromS3, getDownloadFileSignedURLFromS3, deleteFileFromS3 } from './s3Utils'; import { ensureArgsSchemaOrThrowHttpError } from '../server/validation'; import { ALLOWED_FILE_TYPES } from './validation'; @@ -18,11 +20,12 @@ const createFileInputSchema = z.object({ type CreateFileInput = z.infer; -export const createFile: CreateFile< +export const createFileUploadUrl: CreateFileUploadUrl< CreateFileInput, { s3UploadUrl: string; s3UploadFields: Record; + key: string; } > = async (rawArgs, context) => { if (!context.user) { @@ -37,19 +40,10 @@ export const createFile: CreateFile< userId: context.user.id, }); - await context.entities.File.create({ - data: { - name: fileName, - key, - uploadUrl: s3UploadUrl, - type: fileType, - user: { connect: { id: context.user.id } }, - }, - }); - return { s3UploadUrl, s3UploadFields, + key, }; }; @@ -80,3 +74,56 @@ export const getDownloadFileSignedURL: GetDownloadFileSignedURL< const { key } = ensureArgsSchemaOrThrowHttpError(getDownloadFileSignedURLInputSchema, rawArgs); return await getDownloadFileSignedURLFromS3({ key }); }; + +const addFileToDbInputSchema = z.object({ + key: z.string(), + fileType: z.enum(ALLOWED_FILE_TYPES), + fileName: z.string(), +}); + +type AddFileToDbInput = z.infer; + +export const addFileToDb: AddFileToDb = async (args, context) => { + if (!context.user) { + throw new HttpError(401); + } + + return context.entities.File.create({ + data: { + name: args.fileName, + key: args.key, + type: args.fileType, + user: { connect: { id: context.user.id } } + }, + }); +}; + +const deleteFileInputSchema = z.object({ + id: z.string(), + key: z.string(), +}); + +type DeleteFileInput = z.infer; + +export const deleteFile: DeleteFile = async (args, context) => { + if (!context.user) { + throw new HttpError(401); + } + + await context.entities.File.findUniqueOrThrow({ + where: { + id: args.id, + user: { + id: context.user.id, + }, + }, + }); + + await deleteFileFromS3({ key: args.key }); + + return context.entities.File.delete({ + where: { + id: args.id, + }, + }); +}; diff --git a/template/app/src/file-upload/s3Utils.ts b/template/app/src/file-upload/s3Utils.ts index 9f16a65bc..ce8c0d44b 100644 --- a/template/app/src/file-upload/s3Utils.ts +++ b/template/app/src/file-upload/s3Utils.ts @@ -1,6 +1,6 @@ import * as path from 'path'; import { randomUUID } from 'crypto'; -import { S3Client, GetObjectCommand } from '@aws-sdk/client-s3'; +import { S3Client, GetObjectCommand, DeleteObjectCommand } from '@aws-sdk/client-s3'; import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; import { createPresignedPost } from '@aws-sdk/s3-presigned-post'; import { MAX_FILE_SIZE_BYTES } from './validation'; @@ -43,6 +43,14 @@ export const getDownloadFileSignedURLFromS3 = async ({ key }: { key: string }) = return await getSignedUrl(s3Client, command, { expiresIn: 3600 }); }; +export const deleteFileFromS3 = async ({ key }: { key: string }) => { + const command = new DeleteObjectCommand({ + Bucket: process.env.AWS_S3_FILES_BUCKET, + Key: key, + }); + await s3Client.send(command); +}; + function getS3Key(fileName: string, userId: string) { const ext = path.extname(fileName).slice(1); return `${userId}/${randomUUID()}.${ext}`; diff --git a/template/app/src/hooks/use-toast.ts b/template/app/src/hooks/use-toast.ts new file mode 100644 index 000000000..a33ac511c --- /dev/null +++ b/template/app/src/hooks/use-toast.ts @@ -0,0 +1,194 @@ +"use client" + +// Inspired by react-hot-toast library +import * as React from "react" + +import type { + ToastActionElement, + ToastProps, +} from "../components/ui/toast" + +const TOAST_LIMIT = 1 +const TOAST_REMOVE_DELAY = 1000000 + +type ToasterToast = ToastProps & { + id: string + title?: React.ReactNode + description?: React.ReactNode + action?: ToastActionElement +} + +const actionTypes = { + ADD_TOAST: "ADD_TOAST", + UPDATE_TOAST: "UPDATE_TOAST", + DISMISS_TOAST: "DISMISS_TOAST", + REMOVE_TOAST: "REMOVE_TOAST", +} as const + +let count = 0 + +function genId() { + count = (count + 1) % Number.MAX_SAFE_INTEGER + return count.toString() +} + +type ActionType = typeof actionTypes + +type Action = + | { + type: ActionType["ADD_TOAST"] + toast: ToasterToast + } + | { + type: ActionType["UPDATE_TOAST"] + toast: Partial + } + | { + type: ActionType["DISMISS_TOAST"] + toastId?: ToasterToast["id"] + } + | { + type: ActionType["REMOVE_TOAST"] + toastId?: ToasterToast["id"] + } + +interface State { + toasts: ToasterToast[] +} + +const toastTimeouts = new Map>() + +const addToRemoveQueue = (toastId: string) => { + if (toastTimeouts.has(toastId)) { + return + } + + const timeout = setTimeout(() => { + toastTimeouts.delete(toastId) + dispatch({ + type: "REMOVE_TOAST", + toastId: toastId, + }) + }, TOAST_REMOVE_DELAY) + + toastTimeouts.set(toastId, timeout) +} + +export const reducer = (state: State, action: Action): State => { + switch (action.type) { + case "ADD_TOAST": + return { + ...state, + toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT), + } + + case "UPDATE_TOAST": + return { + ...state, + toasts: state.toasts.map((t) => + t.id === action.toast.id ? { ...t, ...action.toast } : t + ), + } + + case "DISMISS_TOAST": { + const { toastId } = action + + // ! Side effects ! - This could be extracted into a dismissToast() action, + // but I'll keep it here for simplicity + if (toastId) { + addToRemoveQueue(toastId) + } else { + state.toasts.forEach((toast) => { + addToRemoveQueue(toast.id) + }) + } + + return { + ...state, + toasts: state.toasts.map((t) => + t.id === toastId || toastId === undefined + ? { + ...t, + open: false, + } + : t + ), + } + } + case "REMOVE_TOAST": + if (action.toastId === undefined) { + return { + ...state, + toasts: [], + } + } + return { + ...state, + toasts: state.toasts.filter((t) => t.id !== action.toastId), + } + } +} + +const listeners: Array<(state: State) => void> = [] + +let memoryState: State = { toasts: [] } + +function dispatch(action: Action) { + memoryState = reducer(memoryState, action) + listeners.forEach((listener) => { + listener(memoryState) + }) +} + +type Toast = Omit + +function toast({ ...props }: Toast) { + const id = genId() + + const update = (props: ToasterToast) => + dispatch({ + type: "UPDATE_TOAST", + toast: { ...props, id }, + }) + const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id }) + + dispatch({ + type: "ADD_TOAST", + toast: { + ...props, + id, + open: true, + onOpenChange: (open) => { + if (!open) dismiss() + }, + }, + }) + + return { + id: id, + dismiss, + update, + } +} + +function useToast() { + const [state, setState] = React.useState(memoryState) + + React.useEffect(() => { + listeners.push(setState) + return () => { + const index = listeners.indexOf(setState) + if (index > -1) { + listeners.splice(index, 1) + } + } + }, [state]) + + return { + ...state, + toast, + dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }), + } +} + +export { useToast, toast } From 0317b06507dc647709807d1f2a85359b727d7aad Mon Sep 17 00:00:00 2001 From: vincanger <70215737+vincanger@users.noreply.github.com> Date: Thu, 31 Jul 2025 16:03:08 +0200 Subject: [PATCH 02/14] Enforce file upload limit and update dependencies on opensaas-sh Added a check to restrict users to 2 file uploads in the demo, with a new helper function and error handling. Updated navigation items, improved landing page content, and removed unused dependencies (react-hot-toast). Added @radix-ui/react-toast, updated testimonials, and made minor content and code improvements. --- opensaas-sh/app_diff/main.wasp.diff | 2 +- .../migration.sql.diff | 11 ++++ opensaas-sh/app_diff/package-lock.json.diff | 64 +++++++++++-------- opensaas-sh/app_diff/package.json.diff | 2 +- opensaas-sh/app_diff/src/client/App.tsx.diff | 24 +++++++ .../components/NavBar/constants.ts.diff | 8 ++- .../src/file-upload/FileUploadPage.tsx.diff | 13 ++++ .../src/file-upload/fileUploading.ts.diff | 26 +++++++- .../src/file-upload/operations.ts.diff | 39 +++++------ .../app_diff/src/file-upload/s3Utils.ts.diff | 15 ----- .../src/landing-page/contentSections.tsx.diff | 16 ++--- .../src/content/docs/guides/vibe-coding.mdx | 4 +- 12 files changed, 139 insertions(+), 85 deletions(-) create mode 100644 opensaas-sh/app_diff/migrations/20250731133938_drop_upload_url_from_file/migration.sql.diff create mode 100644 opensaas-sh/app_diff/src/client/App.tsx.diff create mode 100644 opensaas-sh/app_diff/src/file-upload/FileUploadPage.tsx.diff delete mode 100644 opensaas-sh/app_diff/src/file-upload/s3Utils.ts.diff diff --git a/opensaas-sh/app_diff/main.wasp.diff b/opensaas-sh/app_diff/main.wasp.diff index 04ad5c0c3..93c4779d4 100644 --- a/opensaas-sh/app_diff/main.wasp.diff +++ b/opensaas-sh/app_diff/main.wasp.diff @@ -119,7 +119,7 @@ httpRoute: (POST, "/payments-webhook") } //#endregion -@@ -281,7 +279,6 @@ +@@ -291,7 +289,6 @@ component: import AdminCalendar from "@src/admin/elements/calendar/CalendarPage" } diff --git a/opensaas-sh/app_diff/migrations/20250731133938_drop_upload_url_from_file/migration.sql.diff b/opensaas-sh/app_diff/migrations/20250731133938_drop_upload_url_from_file/migration.sql.diff new file mode 100644 index 000000000..1a2122bb3 --- /dev/null +++ b/opensaas-sh/app_diff/migrations/20250731133938_drop_upload_url_from_file/migration.sql.diff @@ -0,0 +1,11 @@ +--- template/app/migrations/20250731133938_drop_upload_url_from_file/migration.sql ++++ opensaas-sh/app/migrations/20250731133938_drop_upload_url_from_file/migration.sql +@@ -0,0 +1,8 @@ ++/* ++ Warnings: ++ ++ - You are about to drop the column `uploadUrl` on the `File` table. All the data in the column will be lost. ++ ++*/ ++-- AlterTable ++ALTER TABLE "File" DROP COLUMN "uploadUrl"; diff --git a/opensaas-sh/app_diff/package-lock.json.diff b/opensaas-sh/app_diff/package-lock.json.diff index a26292375..9d604e79a 100644 --- a/opensaas-sh/app_diff/package-lock.json.diff +++ b/opensaas-sh/app_diff/package-lock.json.diff @@ -1,6 +1,6 @@ --- template/app/package-lock.json +++ opensaas-sh/app/package-lock.json -@@ -0,0 +1,13784 @@ +@@ -0,0 +1,13792 @@ +{ + "name": "opensaas", + "lockfileVersion": 3, @@ -27,6 +27,7 @@ + "@radix-ui/react-separator": "^1.1.7", + "@radix-ui/react-slot": "^1.2.3", + "@radix-ui/react-switch": "^1.2.5", ++ "@radix-ui/react-toast": "^1.2.14", + "@tailwindcss/forms": "^0.5.3", + "@tailwindcss/typography": "^0.5.7", + "apexcharts": "3.41.0", @@ -42,7 +43,6 @@ + "react-apexcharts": "1.4.1", + "react-dom": "^18.2.0", + "react-hook-form": "^7.60.0", -+ "react-hot-toast": "^2.4.1", + "react-icons": "^5.5.0", + "react-router-dom": "^6.26.2", + "stripe": "18.1.0", @@ -3249,6 +3249,40 @@ + } + } + }, ++ "node_modules/@radix-ui/react-toast": { ++ "version": "1.2.14", ++ "resolved": "https://registry.npmjs.org/@radix-ui/react-toast/-/react-toast-1.2.14.tgz", ++ "integrity": "sha512-nAP5FBxBJGQ/YfUB+r+O6USFVkWq3gAInkxyEnmvEV5jtSbfDhfa4hwX8CraCnbjMLsE7XSf/K75l9xXY7joWg==", ++ "license": "MIT", ++ "dependencies": { ++ "@radix-ui/primitive": "1.1.2", ++ "@radix-ui/react-collection": "1.1.7", ++ "@radix-ui/react-compose-refs": "1.1.2", ++ "@radix-ui/react-context": "1.1.2", ++ "@radix-ui/react-dismissable-layer": "1.1.10", ++ "@radix-ui/react-portal": "1.1.9", ++ "@radix-ui/react-presence": "1.1.4", ++ "@radix-ui/react-primitive": "2.1.3", ++ "@radix-ui/react-use-callback-ref": "1.1.1", ++ "@radix-ui/react-use-controllable-state": "1.2.2", ++ "@radix-ui/react-use-layout-effect": "1.1.1", ++ "@radix-ui/react-visually-hidden": "1.2.3" ++ }, ++ "peerDependencies": { ++ "@types/react": "*", ++ "@types/react-dom": "*", ++ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", ++ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" ++ }, ++ "peerDependenciesMeta": { ++ "@types/react": { ++ "optional": true ++ }, ++ "@types/react-dom": { ++ "optional": true ++ } ++ } ++ }, + "node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", @@ -7549,15 +7583,6 @@ + "node": ">=10.13.0" + } + }, -+ "node_modules/goober": { -+ "version": "2.1.16", -+ "resolved": "https://registry.npmjs.org/goober/-/goober-2.1.16.tgz", -+ "integrity": "sha512-erjk19y1U33+XAMe1VTvIONHYoSqE4iS7BYUZfHaqeohLmnC0FdxEh7rQU+6MZ4OajItzjZFSRtVANrQwNq6/g==", -+ "license": "MIT", -+ "peerDependencies": { -+ "csstype": "^3.0.10" -+ } -+ }, + "node_modules/google-auth-library": { + "version": "9.15.1", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-9.15.1.tgz", @@ -10629,23 +10654,6 @@ + "react": "^16.8.0 || ^17 || ^18 || ^19" + } + }, -+ "node_modules/react-hot-toast": { -+ "version": "2.5.2", -+ "resolved": "https://registry.npmjs.org/react-hot-toast/-/react-hot-toast-2.5.2.tgz", -+ "integrity": "sha512-Tun3BbCxzmXXM7C+NI4qiv6lT0uwGh4oAfeJyNOjYUejTsm35mK9iCaYLGv8cBz9L5YxZLx/2ii7zsIwPtPUdw==", -+ "license": "MIT", -+ "dependencies": { -+ "csstype": "^3.1.3", -+ "goober": "^2.1.16" -+ }, -+ "engines": { -+ "node": ">=10" -+ }, -+ "peerDependencies": { -+ "react": ">=16", -+ "react-dom": ">=16" -+ } -+ }, + "node_modules/react-icons": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.5.0.tgz", diff --git a/opensaas-sh/app_diff/package.json.diff b/opensaas-sh/app_diff/package.json.diff index b0e1ec570..285a469f2 100644 --- a/opensaas-sh/app_diff/package.json.diff +++ b/opensaas-sh/app_diff/package.json.diff @@ -13,9 +13,9 @@ "@aws-sdk/client-s3": "^3.523.0", "@aws-sdk/s3-presigned-post": "^3.750.0", @@ -36,6 +41,7 @@ + "react-apexcharts": "1.4.1", "react-dom": "^18.2.0", "react-hook-form": "^7.60.0", - "react-hot-toast": "^2.4.1", + "react-icons": "^5.5.0", "react-router-dom": "^6.26.2", "stripe": "18.1.0", diff --git a/opensaas-sh/app_diff/src/client/App.tsx.diff b/opensaas-sh/app_diff/src/client/App.tsx.diff new file mode 100644 index 000000000..0d7f343bb --- /dev/null +++ b/opensaas-sh/app_diff/src/client/App.tsx.diff @@ -0,0 +1,24 @@ +--- template/app/src/client/App.tsx ++++ opensaas-sh/app/src/client/App.tsx +@@ -4,6 +4,7 @@ + import './Main.css'; + import NavBar from './components/NavBar/NavBar'; + import { demoNavigationitems, marketingNavigationItems } from './components/NavBar/constants'; ++import { useIsLandingPage } from './hooks/useIsLandingPage'; + import CookieConsentBanner from './components/cookie-consent/Banner'; + import { Toaster } from '../components/ui/toaster'; + +@@ -13,11 +14,8 @@ + */ + export default function App() { + const location = useLocation(); +- const isMarketingPage = useMemo(() => { +- return location.pathname === '/' || location.pathname.startsWith('/pricing'); +- }, [location]); +- +- const navigationItems = isMarketingPage ? marketingNavigationItems : demoNavigationitems; ++ const isLandingPage = useIsLandingPage(); ++ const navigationItems = isLandingPage ? marketingNavigationItems : demoNavigationitems; + + const shouldDisplayAppNavBar = useMemo(() => { + return ( diff --git a/opensaas-sh/app_diff/src/client/components/NavBar/constants.ts.diff b/opensaas-sh/app_diff/src/client/components/NavBar/constants.ts.diff index 32e07ec22..7eed19f13 100644 --- a/opensaas-sh/app_diff/src/client/components/NavBar/constants.ts.diff +++ b/opensaas-sh/app_diff/src/client/components/NavBar/constants.ts.diff @@ -1,6 +1,6 @@ --- template/app/src/client/components/NavBar/constants.ts +++ opensaas-sh/app/src/client/components/NavBar/constants.ts -@@ -9,7 +9,6 @@ +@@ -9,12 +9,12 @@ export const marketingNavigationItems: NavigationItem[] = [ { name: 'Features', to: '/#features' }, @@ -8,3 +8,9 @@ ...staticNavigationItems, ] as const; + export const demoNavigationitems: NavigationItem[] = [ + { name: 'AI Scheduler', to: routes.DemoAppRoute.to }, + { name: 'File Upload', to: routes.FileUploadRoute.to }, ++ { name: 'Pricing', to: routes.PricingPageRoute.to }, + ...staticNavigationItems, + ] as const; diff --git a/opensaas-sh/app_diff/src/file-upload/FileUploadPage.tsx.diff b/opensaas-sh/app_diff/src/file-upload/FileUploadPage.tsx.diff new file mode 100644 index 000000000..ba26a04c1 --- /dev/null +++ b/opensaas-sh/app_diff/src/file-upload/FileUploadPage.tsx.diff @@ -0,0 +1,13 @@ +--- template/app/src/file-upload/FileUploadPage.tsx ++++ opensaas-sh/app/src/file-upload/FileUploadPage.tsx +@@ -144,8 +144,8 @@ + +
+

+- 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 🤝

diff --git a/template/app/src/file-upload/operations.ts b/template/app/src/file-upload/operations.ts index 40884a0b6..ccc6f7448 100644 --- a/template/app/src/file-upload/operations.ts +++ b/template/app/src/file-upload/operations.ts @@ -9,7 +9,12 @@ import { type AddFileToDb, } from 'wasp/server/operations'; -import { getUploadFileSignedURLFromS3, getDownloadFileSignedURLFromS3, deleteFileFromS3 } from './s3Utils'; +import { + getUploadFileSignedURLFromS3, + getDownloadFileSignedURLFromS3, + deleteFileFromS3, + checkFileExistsInS3, +} from './s3Utils'; import { ensureArgsSchemaOrThrowHttpError } from '../server/validation'; import { ALLOWED_FILE_TYPES } from './validation'; @@ -47,6 +52,34 @@ export const createFileUploadUrl: CreateFileUploadUrl< }; }; +const addFileToDbInputSchema = z.object({ + key: z.string(), + fileType: z.enum(ALLOWED_FILE_TYPES), + fileName: z.string(), +}); + +type AddFileToDbInput = z.infer; + +export const addFileToDb: AddFileToDb = async (args, context) => { + if (!context.user) { + throw new HttpError(401); + } + + const fileExists = await checkFileExistsInS3({ key: args.key }); + if (!fileExists) { + throw new HttpError(404, 'File not found in S3.'); + } + + return context.entities.File.create({ + data: { + name: args.fileName, + key: args.key, + type: args.fileType, + user: { connect: { id: context.user.id } }, + }, + }); +}; + export const getAllFilesByUser: GetAllFilesByUser = async (_args, context) => { if (!context.user) { throw new HttpError(401); @@ -75,29 +108,6 @@ export const getDownloadFileSignedURL: GetDownloadFileSignedURL< return await getDownloadFileSignedURLFromS3({ key }); }; -const addFileToDbInputSchema = z.object({ - key: z.string(), - fileType: z.enum(ALLOWED_FILE_TYPES), - fileName: z.string(), -}); - -type AddFileToDbInput = z.infer; - -export const addFileToDb: AddFileToDb = async (args, context) => { - if (!context.user) { - throw new HttpError(401); - } - - return context.entities.File.create({ - data: { - name: args.fileName, - key: args.key, - type: args.fileType, - user: { connect: { id: context.user.id } } - }, - }); -}; - const deleteFileInputSchema = z.object({ id: z.string(), }); diff --git a/template/app/src/file-upload/s3Utils.ts b/template/app/src/file-upload/s3Utils.ts index ce8c0d44b..381bbf8d8 100644 --- a/template/app/src/file-upload/s3Utils.ts +++ b/template/app/src/file-upload/s3Utils.ts @@ -1,6 +1,6 @@ import * as path from 'path'; import { randomUUID } from 'crypto'; -import { S3Client, GetObjectCommand, DeleteObjectCommand } from '@aws-sdk/client-s3'; +import { S3Client, GetObjectCommand, DeleteObjectCommand, HeadObjectCommand } from '@aws-sdk/client-s3'; import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; import { createPresignedPost } from '@aws-sdk/s3-presigned-post'; import { MAX_FILE_SIZE_BYTES } from './validation'; @@ -51,6 +51,21 @@ export const deleteFileFromS3 = async ({ key }: { key: string }) => { await s3Client.send(command); }; + const command = new HeadObjectCommand({ + Bucket: process.env.AWS_S3_FILES_BUCKET, + Key: key, + }); + try { + await s3Client.send(command); + return true; + } catch (error: any) { + if (error.name === 'NotFound') { + return false; + } + throw error; + } +}; + function getS3Key(fileName: string, userId: string) { const ext = path.extname(fileName).slice(1); return `${userId}/${randomUUID()}.${ext}`; From 0815fd65d9f29480f774e60859fe43bdadd2ab2a Mon Sep 17 00:00:00 2001 From: vincanger <70215737+vincanger@users.noreply.github.com> Date: Tue, 5 Aug 2025 17:11:05 +0200 Subject: [PATCH 06/14] Update s3Utils.ts --- template/app/src/file-upload/s3Utils.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/template/app/src/file-upload/s3Utils.ts b/template/app/src/file-upload/s3Utils.ts index 381bbf8d8..33c68310f 100644 --- a/template/app/src/file-upload/s3Utils.ts +++ b/template/app/src/file-upload/s3Utils.ts @@ -51,6 +51,7 @@ export const deleteFileFromS3 = async ({ key }: { key: string }) => { await s3Client.send(command); }; +export const checkFileExistsInS3 = async ({ key }: { key: string }) => { const command = new HeadObjectCommand({ Bucket: process.env.AWS_S3_FILES_BUCKET, Key: key, From 16bb3c506b1c0295172ef91920817da0133e03cb Mon Sep 17 00:00:00 2001 From: vincanger <70215737+vincanger@users.noreply.github.com> Date: Tue, 5 Aug 2025 17:15:44 +0200 Subject: [PATCH 07/14] update app_diff --- .../src/file-upload/FileUploadPage.tsx.diff | 13 ------------- .../app_diff/src/file-upload/operations.ts.diff | 6 +++--- 2 files changed, 3 insertions(+), 16 deletions(-) delete mode 100644 opensaas-sh/app_diff/src/file-upload/FileUploadPage.tsx.diff diff --git a/opensaas-sh/app_diff/src/file-upload/FileUploadPage.tsx.diff b/opensaas-sh/app_diff/src/file-upload/FileUploadPage.tsx.diff deleted file mode 100644 index ba26a04c1..000000000 --- a/opensaas-sh/app_diff/src/file-upload/FileUploadPage.tsx.diff +++ /dev/null @@ -1,13 +0,0 @@ ---- template/app/src/file-upload/FileUploadPage.tsx -+++ opensaas-sh/app/src/file-upload/FileUploadPage.tsx -@@ -144,8 +144,8 @@ - -
-

-- 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: AI illustration, -+ 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: File upload illustration, + 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: Wasp Logo, + 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' />
@@ -179,20 +179,20 @@ export default function FileUploadPage() { {!!allUserFiles.data && allUserFiles.data.length > 0 && !allUserFiles.isLoading ? (
{allUserFiles.data.map((file: File) => ( - +

{file.name}

diff --git a/template/app/src/file-upload/s3Utils.ts b/template/app/src/file-upload/s3Utils.ts index b629b7669..6e68958e6 100644 --- a/template/app/src/file-upload/s3Utils.ts +++ b/template/app/src/file-upload/s3Utils.ts @@ -1,11 +1,11 @@ import * as path from 'path'; import { randomUUID } from 'crypto'; -import { S3Client, GetObjectCommand, DeleteObjectCommand, HeadObjectCommand } from '@aws-sdk/client-s3'; +import { S3Client, GetObjectCommand, DeleteObjectCommand, HeadObjectCommand, S3ServiceException } from '@aws-sdk/client-s3'; import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; import { createPresignedPost } from '@aws-sdk/s3-presigned-post'; import { MAX_FILE_SIZE_BYTES } from './validation'; -const s3Client = new S3Client({ +export const s3Client = new S3Client({ region: process.env.AWS_S3_REGION, credentials: { accessKeyId: process.env.AWS_S3_IAM_ACCESS_KEY!, @@ -59,8 +59,8 @@ export const checkFileExistsInS3 = async ({ s3Key }: { s3Key: string }) => { try { await s3Client.send(command); return true; - } catch (error: any) { - if (error.name === 'NotFound') { + } catch (error) { + if (error instanceof S3ServiceException && error.name === 'NotFound') { return false; } throw error; diff --git a/template/app/src/file-upload/workers.ts b/template/app/src/file-upload/workers.ts new file mode 100644 index 000000000..28479d673 --- /dev/null +++ b/template/app/src/file-upload/workers.ts @@ -0,0 +1,65 @@ +import type { CleanUpOrphanedFilesS3Job } from 'wasp/server/jobs'; +import { s3Client, deleteFileFromS3 } from './s3Utils'; +import { ListObjectsV2Command, ListObjectsV2CommandOutput } from '@aws-sdk/client-s3'; + +export const cleanUpOrphanedFilesS3: CleanUpOrphanedFilesS3Job = async ( + _args, + context +) => { + const allFileKeysFromS3 = await fetchAllFileKeysFromS3(); + const allFileKeysFromDb = await context.entities.File.findMany({ + select: { s3Key: true }, + }); + await findAndDeleteOrphanedFilesInS3(allFileKeysFromS3, allFileKeysFromDb); +}; + +const fetchAllFileKeysFromS3 = async () => { + const allS3Keys: string[] = []; + let continuationToken: string | undefined = undefined; + + do { + const command = new ListObjectsV2Command({ + Bucket: process.env.AWS_S3_FILES_BUCKET, + ContinuationToken: continuationToken, + }); + + const response: ListObjectsV2CommandOutput = await s3Client.send(command); + + if (response.Contents) { + const keys = response.Contents.reduce((acc: string[], object) => { + if (object.Key) { + acc.push(object.Key); + } + return acc; + }, []); + allS3Keys.push(...keys); + } + + continuationToken = response.NextContinuationToken; + } while (continuationToken); + + console.log(`Found ${allS3Keys.length} total files in S3`); + + return allS3Keys; +}; + +const findAndDeleteOrphanedFilesInS3 = async ( + allFileKeysFromS3: string[], + allFileKeysFromDb: { s3Key: string }[] +) => { + const s3KeysNotFoundInDb = allFileKeysFromS3.filter( + (s3Key) => !allFileKeysFromDb.some((file) => file.s3Key === s3Key) + ); + + // Delete files from S3 that are not in the database + // If any file deletion fails, the job can continue and pick it up next run. + const s3DeletionResults = await Promise.allSettled( + s3KeysNotFoundInDb.map((s3Key) => deleteFileFromS3({ s3Key })) + ); + + const successfulDeletions = s3DeletionResults.filter((result) => result.status === 'fulfilled'); + + console.log( + `Successfully deleted ${successfulDeletions.length} out of ${s3KeysNotFoundInDb.length} orphaned files from S3` + ); +}; diff --git a/template/app/src/hooks/use-toast.ts b/template/app/src/hooks/use-toast.ts index a33ac511c..46bf0cbbf 100644 --- a/template/app/src/hooks/use-toast.ts +++ b/template/app/src/hooks/use-toast.ts @@ -1,6 +1,3 @@ -"use client" - -// Inspired by react-hot-toast library import * as React from "react" import type { From 1e28adea2b7ff2aff5d6072636de43982125f585 Mon Sep 17 00:00:00 2001 From: vincanger <70215737+vincanger@users.noreply.github.com> Date: Sun, 17 Aug 2025 20:48:01 +0200 Subject: [PATCH 14/14] remove s3 cleanup job from template removed but added suggestion to docs. --- opensaas-sh/app_diff/main.wasp.diff | 10 +- .../app_diff/src/file-upload/workers.ts.diff | 21 +--- .../content/docs/guides/file-uploading.mdx | 110 ++++++++++++++++++ template/app/main.wasp | 12 -- template/app/src/file-upload/workers.ts | 65 ----------- 5 files changed, 119 insertions(+), 99 deletions(-) delete mode 100644 template/app/src/file-upload/workers.ts diff --git a/opensaas-sh/app_diff/main.wasp.diff b/opensaas-sh/app_diff/main.wasp.diff index 09e01dfd1..e235df343 100644 --- a/opensaas-sh/app_diff/main.wasp.diff +++ b/opensaas-sh/app_diff/main.wasp.diff @@ -119,10 +119,12 @@ httpRoute: (POST, "/payments-webhook") } //#endregion -@@ -253,6 +251,17 @@ +@@ -259,7 +257,18 @@ + fn: import { calculateDailyStats } from "@src/analytics/stats" }, schedule: { - cron: "0 5 * * *" // every day at 5am +- cron: "0 * * * *" // every hour. useful in production ++ cron: "0 5 * * *" // every day at 5am + }, + entities: [File] +} @@ -136,8 +138,8 @@ + cron: "0 0 * * *" // every day at midnight // cron: "* * * * *" // every minute. useful for debugging }, - entities: [File] -@@ -303,7 +312,6 @@ + entities: [User, DailyStats, Logs, PageViewSource] +@@ -291,7 +300,6 @@ component: import AdminCalendar from "@src/admin/elements/calendar/CalendarPage" } diff --git a/opensaas-sh/app_diff/src/file-upload/workers.ts.diff b/opensaas-sh/app_diff/src/file-upload/workers.ts.diff index 1dd9e8b29..4288cc9d8 100644 --- a/opensaas-sh/app_diff/src/file-upload/workers.ts.diff +++ b/opensaas-sh/app_diff/src/file-upload/workers.ts.diff @@ -1,23 +1,8 @@ --- template/app/src/file-upload/workers.ts +++ opensaas-sh/app/src/file-upload/workers.ts -@@ -1,11 +1,8 @@ --import type { CleanUpOrphanedFilesS3Job } from 'wasp/server/jobs'; -+import type { DeleteFilesJob, CleanUpOrphanedFilesS3Job } from 'wasp/server/jobs'; - import { s3Client, deleteFileFromS3 } from './s3Utils'; - import { ListObjectsV2Command, ListObjectsV2CommandOutput } from '@aws-sdk/client-s3'; - --export const cleanUpOrphanedFilesS3: CleanUpOrphanedFilesS3Job = async ( -- _args, -- context --) => { -+export const cleanUpOrphanedFilesS3: CleanUpOrphanedFilesS3Job = async (_args, context) => { - const allFileKeysFromS3 = await fetchAllFileKeysFromS3(); - const allFileKeysFromDb = await context.entities.File.findMany({ - select: { s3Key: true }, -@@ -63,3 +60,35 @@ - `Successfully deleted ${successfulDeletions.length} out of ${s3KeysNotFoundInDb.length} orphaned files from S3` - ); - }; +@@ -0,0 +1,34 @@ ++import type { DeleteFilesJob } from 'wasp/server/jobs'; ++import { deleteFileFromS3 } from './s3Utils'; + +export const deleteFilesJob: DeleteFilesJob = async (_args, context) => { + const dayInMiliseconds = 1000 * 60 * 60 * 24; diff --git a/opensaas-sh/blog/src/content/docs/guides/file-uploading.mdx b/opensaas-sh/blog/src/content/docs/guides/file-uploading.mdx index bdb8ab19c..c97f1a146 100644 --- a/opensaas-sh/blog/src/content/docs/guides/file-uploading.mdx +++ b/opensaas-sh/blog/src/content/docs/guides/file-uploading.mdx @@ -143,6 +143,116 @@ To begin customizing file uploads, is important to know where everything lives i - The `getAllFilesByUser` fetches all File information uploaded by the user. Note that the files do not exist in the app database, but rather the file data, its name and its `key`, which is used to fetch the file from S3. - The `getDownloadFileSignedURL` query fetches the presigned URL for a file to be downloaded from S3 using the file's `key` stored in the app's database. +### Cleaning up "orphaned" files in S3 + +In the current logic, files are first deleted from the app's database before attempting to delete them from S3. If, for some reason, the S3 deletion were to fail, the file would remain in S3 and not in the app's database and be orphaned: + +```ts +// src/file-upload/operations.ts +export const deleteFile: DeleteFile = async (args, context) => { + + const deletedFile = await context.entities.File.delete(args.fileId); + + try { + return await deleteFileFromS3({ s3Key: deletedFile.s3Key }); + } catch (error) { + console.error(`S3 deletion failed. Orphaned file s3Key: ${deletedFile.s3Key}`, error); + } +}; +``` + +To clean up these orphaned files, you could add a cleanup job that runs at an interval of your choosing to: + +1. Fetch all file keys from S3 +2. Fetch all file keys from the app's database +3. Compare the two lists and delete any files from S3 that are not in the database + +Here's an example of how you could implement this: + +```ts +// .wasp config file +job cleanUpOrphanedFilesS3Job { + executor: PgBoss, + perform: { + fn: import { cleanUpOrphanedFilesS3 } from "@src/file-upload/workers" + }, + schedule: { + cron: "0 5 * * 0" // every week on Sunday at 5am + }, + entities: [File] +} +``` + +```ts +// src/file-upload/workers.ts + +import type { CleanUpOrphanedFilesS3Job } from 'wasp/server/jobs'; +import { s3Client, deleteFileFromS3 } from './s3Utils'; +import { ListObjectsV2Command, ListObjectsV2CommandOutput } from '@aws-sdk/client-s3'; + +export const cleanUpOrphanedFilesS3: CleanUpOrphanedFilesS3Job = async ( + _args, + context +) => { + const allFileKeysFromS3 = await fetchAllFileKeysFromS3(); + const allFileKeysFromDb = await context.entities.File.findMany({ + select: { s3Key: true }, + }); + await findAndDeleteOrphanedFilesInS3(allFileKeysFromS3, allFileKeysFromDb); +}; + +const fetchAllFileKeysFromS3 = async () => { + const allS3Keys: string[] = []; + let continuationToken: string | undefined = undefined; + + do { + const command = new ListObjectsV2Command({ + Bucket: process.env.AWS_S3_FILES_BUCKET, + ContinuationToken: continuationToken, + }); + + const response: ListObjectsV2CommandOutput = await s3Client.send(command); + + if (response.Contents) { + const keys = response.Contents.reduce((acc: string[], object) => { + if (object.Key) { + acc.push(object.Key); + } + return acc; + }, []); + allS3Keys.push(...keys); + } + + continuationToken = response.NextContinuationToken; + } while (continuationToken); + + console.log(`Found ${allS3Keys.length} total files in S3`); + + return allS3Keys; +}; + +const findAndDeleteOrphanedFilesInS3 = async ( + allFileKeysFromS3: string[], + allFileKeysFromDb: { s3Key: string }[] +) => { + const s3KeysNotFoundInDb = allFileKeysFromS3.filter( + (s3Key) => !allFileKeysFromDb.some((file) => file.s3Key === s3Key) + ); + + // Delete files from S3 that are not in the database + // If any file deletion fails, the job can continue and pick it up next run. + const s3DeletionResults = await Promise.allSettled( + s3KeysNotFoundInDb.map((s3Key) => deleteFileFromS3({ s3Key })) + ); + + const successfulDeletions = s3DeletionResults.filter((result) => result.status === 'fulfilled'); + + console.log( + `Successfully deleted ${successfulDeletions.length} out of ${s3KeysNotFoundInDb.length} orphaned files from S3` + ); +}; +``` + ## Using Multer to upload files to your server If you're looking to upload files to the app server, you can use the Multer middleware to handle file uploads. This will allow you to store files on your server and is a good option if you need a quick and dirty, free solution for simple file uploads. diff --git a/template/app/main.wasp b/template/app/main.wasp index ee6fef866..8befdd28d 100644 --- a/template/app/main.wasp +++ b/template/app/main.wasp @@ -245,18 +245,6 @@ action deleteFile { fn: import { deleteFile } from "@src/file-upload/operations", entities: [User, File] } - -job cleanUpOrphanedFilesS3Job { - executor: PgBoss, - perform: { - fn: import { cleanUpOrphanedFilesS3 } from "@src/file-upload/workers" - }, - schedule: { - cron: "0 5 * * *" // every day at 5am - // cron: "* * * * *" // every minute. useful for debugging - }, - entities: [File] -} //#endregion //#region Analytics diff --git a/template/app/src/file-upload/workers.ts b/template/app/src/file-upload/workers.ts deleted file mode 100644 index 28479d673..000000000 --- a/template/app/src/file-upload/workers.ts +++ /dev/null @@ -1,65 +0,0 @@ -import type { CleanUpOrphanedFilesS3Job } from 'wasp/server/jobs'; -import { s3Client, deleteFileFromS3 } from './s3Utils'; -import { ListObjectsV2Command, ListObjectsV2CommandOutput } from '@aws-sdk/client-s3'; - -export const cleanUpOrphanedFilesS3: CleanUpOrphanedFilesS3Job = async ( - _args, - context -) => { - const allFileKeysFromS3 = await fetchAllFileKeysFromS3(); - const allFileKeysFromDb = await context.entities.File.findMany({ - select: { s3Key: true }, - }); - await findAndDeleteOrphanedFilesInS3(allFileKeysFromS3, allFileKeysFromDb); -}; - -const fetchAllFileKeysFromS3 = async () => { - const allS3Keys: string[] = []; - let continuationToken: string | undefined = undefined; - - do { - const command = new ListObjectsV2Command({ - Bucket: process.env.AWS_S3_FILES_BUCKET, - ContinuationToken: continuationToken, - }); - - const response: ListObjectsV2CommandOutput = await s3Client.send(command); - - if (response.Contents) { - const keys = response.Contents.reduce((acc: string[], object) => { - if (object.Key) { - acc.push(object.Key); - } - return acc; - }, []); - allS3Keys.push(...keys); - } - - continuationToken = response.NextContinuationToken; - } while (continuationToken); - - console.log(`Found ${allS3Keys.length} total files in S3`); - - return allS3Keys; -}; - -const findAndDeleteOrphanedFilesInS3 = async ( - allFileKeysFromS3: string[], - allFileKeysFromDb: { s3Key: string }[] -) => { - const s3KeysNotFoundInDb = allFileKeysFromS3.filter( - (s3Key) => !allFileKeysFromDb.some((file) => file.s3Key === s3Key) - ); - - // Delete files from S3 that are not in the database - // If any file deletion fails, the job can continue and pick it up next run. - const s3DeletionResults = await Promise.allSettled( - s3KeysNotFoundInDb.map((s3Key) => deleteFileFromS3({ s3Key })) - ); - - const successfulDeletions = s3DeletionResults.filter((result) => result.status === 'fulfilled'); - - console.log( - `Successfully deleted ${successfulDeletions.length} out of ${s3KeysNotFoundInDb.length} orphaned files from S3` - ); -};