Merge branch 'filip-refactor-user-module' into filip-refactor-users-table

This commit is contained in:
Filip Sodić
2025-02-23 16:11:55 +01:00
17 changed files with 296 additions and 242 deletions

View File

@@ -1,8 +1,8 @@
--- template/app/src/file-upload/operations.ts --- template/app/src/file-upload/operations.ts
+++ opensaas-sh/app/src/file-upload/operations.ts +++ opensaas-sh/app/src/file-upload/operations.ts
@@ -18,6 +18,18 @@ @@ -25,6 +25,18 @@
throw new HttpError(401);
} const { fileType, fileName } = ensureArgsSchemaOrThrowHttpError(createFileInputSchema, rawArgs);
+ const numberOfFilesByUser = await context.entities.File.count({ + const numberOfFilesByUser = await context.entities.File.count({
+ where: { + where: {
@@ -16,6 +16,6 @@
+ throw new HttpError(403, 'Thanks for trying Open SaaS. This demo only allows 2 file uploads per user.'); + throw new HttpError(403, 'Thanks for trying Open SaaS. This demo only allows 2 file uploads per user.');
+ } + }
+ +
const userInfo = context.user.id; const { uploadUrl, key } = await getUploadFileSignedURLFromS3({
fileType,
const { uploadUrl, key } = await getUploadFileSignedURLFromS3({ fileType, userInfo }); fileName,

View File

@@ -1,11 +0,0 @@
--- template/app/src/payment/plans.ts
+++ opensaas-sh/app/src/payment/plans.ts
@@ -9,7 +9,7 @@
}
export interface PaymentPlan {
- // Returns the id under which this payment plan is identified on your payment processor.
+ // Returns the id under which this payment plan is identified on your payment processor.
// E.g. this might be price id on Stripe, or variant id on LemonSqueezy.
getPaymentProcessorPlanId: () => string;
effect: PaymentPlanEffect;

View File

@@ -1,8 +1,8 @@
--- template/app/src/user/operations.ts --- template/app/src/user/operations.ts
+++ opensaas-sh/app/src/user/operations.ts +++ opensaas-sh/app/src/user/operations.ts
@@ -38,7 +38,10 @@ @@ -41,7 +41,10 @@
subscriptionStatus?: SubscriptionStatus[];
}; };
type GetPaginatedUsersOutput = { type GetPaginatedUsersOutput = {
- users: Pick<User, 'id' | 'email' | 'username' | 'subscriptionStatus' | 'paymentProcessorUserId'>[]; - users: Pick<User, 'id' | 'email' | 'username' | 'subscriptionStatus' | 'paymentProcessorUserId'>[];
+ users: Pick< + users: Pick<
@@ -12,28 +12,15 @@
totalPages: number; totalPages: number;
}; };
@@ -51,8 +54,10 @@ @@ -85,6 +88,7 @@
}
const allSubscriptionStatusOptions = args.subscriptionStatus as Array<string | null> | undefined;
- const hasNotSubscribed = allSubscriptionStatusOptions?.find((status) => status === null)
- let subscriptionStatusStrings = allSubscriptionStatusOptions?.filter((status) => status !== null) as string[] | undefined
+ const hasNotSubscribed = allSubscriptionStatusOptions?.find((status) => status === null);
+ let subscriptionStatusStrings = allSubscriptionStatusOptions?.filter((status) => status !== null) as
+ | string[]
+ | undefined;
const queryResults = await context.entities.User.findMany({
skip: args.skip,
@@ -65,6 +70,7 @@
mode: 'insensitive', mode: 'insensitive',
}, },
isAdmin: args.isAdmin, isAdmin,
+ isMockUser: true, + isMockUser: true,
}, },
{ {
OR: [ OR: [
@@ -88,7 +94,7 @@ @@ -108,7 +112,7 @@
username: true, username: true,
isAdmin: true, isAdmin: true,
subscriptionStatus: true, subscriptionStatus: true,
@@ -42,10 +29,10 @@
}, },
orderBy: { orderBy: {
id: 'desc', id: 'desc',
@@ -104,6 +110,7 @@ @@ -124,6 +128,7 @@
mode: 'insensitive', mode: 'insensitive',
}, },
isAdmin: args.isAdmin, isAdmin,
+ isMockUser: true, + isMockUser: true,
}, },
{ {

View File

@@ -7,9 +7,11 @@ import DropdownEditDelete from './DropdownEditDelete';
import { updateIsUserAdminById } from 'wasp/client/operations'; import { updateIsUserAdminById } from 'wasp/client/operations';
import { type User } from 'wasp/entities'; import { type User } from 'wasp/entities';
const AdminSwitch = ({ id, isAdmin }: Pick<User, 'id' | 'isAdmin'>) => { function AdminSwitch({ id, isAdmin }: Pick<User, 'id' | 'isAdmin'>) {
return <SwitcherOne isOn={isAdmin} onChange={() => updateIsUserAdminById({ id: id, isAdmin: !isAdmin })} />; return (
}; <SwitcherOne isOn={isAdmin} onChange={(value) => updateIsUserAdminById({ id: id, isAdmin: value })} />
);
}
const UsersTable = () => { const UsersTable = () => {
const [currentPage, setCurrentPage] = useState(1); const [currentPage, setCurrentPage] = useState(1);
@@ -214,7 +216,7 @@ const UsersTable = () => {
</div> </div>
</div> </div>
); );
}; }
function ChevronDownIcon() { function ChevronDownIcon() {
return ( return (

View File

@@ -170,13 +170,71 @@ const FormElements = ({ user }: { user: AuthUser }) => {
<label className='mb-3 block text-black dark:text-white'>Select Country</label> <label className='mb-3 block text-black dark:text-white'>Select Country</label>
<div className='relative z-20 bg-white dark:bg-form-input'> <div className='relative z-20 bg-white dark:bg-form-input'>
<span className='absolute top-1/2 left-4 z-30 -translate-y-1/2'> <span className='absolute top-1/2 left-4 z-30 -translate-y-1/2'>
<svg <GlobeIcon />
width='20' </span>
height='20' <select className='relative z-20 w-full appearance-none rounded border border-stroke bg-transparent py-3 px-12 outline-none transition focus:border-primary active:border-primary dark:border-form-strokedark dark:bg-form-input'>
viewBox='0 0 20 20' <option value=''>USA</option>
fill='none' <option value=''>UK</option>
xmlns='http://www.w3.org/2000/svg' <option value=''>Canada</option>
</select>
<span className='absolute top-1/2 right-4 z-10 -translate-y-1/2'>
<ChevronDownIcon />
</span>
</div>
</div>
<div>
<label className='mb-3 block text-black dark:text-white'>Multiselect Dropdown</label>
<div className='relative z-20 w-full rounded border border-stroke p-1.5 pr-8 font-medium outline-none transition focus:border-primary active:border-primary dark:border-form-strokedark dark:bg-form-input'>
<div className='flex flex-wrap items-center'>
<span className='m-1.5 flex items-center justify-center rounded border-[.5px] border-stroke bg-gray py-1.5 px-2.5 text-sm font-medium dark:border-strokedark dark:bg-white/30'>
Design
<span className='cursor-pointer pl-2 hover:text-danger'>
<XIcon />
</span>
</span>
<span className='m-1.5 flex items-center justify-center rounded border-[.5px] border-stroke bg-gray py-1.5 px-2.5 text-sm font-medium dark:border-strokedark dark:bg-white/30'>
Development
<span className='cursor-pointer pl-2 hover:text-danger'>
<XIcon />
</span>
</span>
</div>
<select
name=''
id=''
className='absolute top-0 left-0 z-20 h-full w-full bg-transparent opacity-0'
> >
<option value=''>Option</option>
<option value=''>Option</option>
</select>
<span className='absolute top-1/2 right-4 z-10 -translate-y-1/2'>
<ChevronDownIcon />
</span>
</div>
</div>
</div>
</div>
</div>
</div>
</DefaultLayout>
);
};
function SwitchExamples() {
const [isFirstOn, setIsFirstOn] = useState<boolean>(false);
const [isSecondOn, setIsSecondOn] = useState<boolean>(false);
return (
<div className='flex flex-col gap-5.5 p-6.5'>
<SwitcherOne isOn={isFirstOn} onChange={setIsFirstOn} />
<SwitcherTwo isOn={isSecondOn} onChange={setIsSecondOn} />
</div>
);
}
function GlobeIcon() {
return (
<svg width='20' height='20' viewBox='0 0 20 20' fill='none' xmlns='http://www.w3.org/2000/svg'>
<g opacity='0.8'> <g opacity='0.8'>
<path <path
fillRule='evenodd' fillRule='evenodd'
@@ -198,120 +256,34 @@ const FormElements = ({ user }: { user: AuthUser }) => {
></path> ></path>
</g> </g>
</svg> </svg>
</span>
<select className='relative z-20 w-full appearance-none rounded border border-stroke bg-transparent py-3 px-12 outline-none transition focus:border-primary active:border-primary dark:border-form-strokedark dark:bg-form-input'>
<option value=''>USA</option>
<option value=''>UK</option>
<option value=''>Canada</option>
</select>
<span className='absolute top-1/2 right-4 z-10 -translate-y-1/2'>
<svg
width='24'
height='24'
viewBox='0 0 24 24'
fill='none'
xmlns='http://www.w3.org/2000/svg'
>
<g opacity='0.8'>
<path
fillRule='evenodd'
clipRule='evenodd'
d='M5.29289 8.29289C5.68342 7.90237 6.31658 7.90237 6.70711 8.29289L12 13.5858L17.2929 8.29289C17.6834 7.90237 18.3166 7.90237 18.7071 8.29289C19.0976 8.68342 19.0976 9.31658 18.7071 9.70711L12.7071 15.7071C12.3166 16.0976 11.6834 16.0976 11.2929 15.7071L5.29289 9.70711C4.90237 9.31658 4.90237 8.68342 5.29289 8.29289Z'
fill='#637381'
></path>
</g>
</svg>
</span>
</div>
</div>
<div>
<label className='mb-3 block text-black dark:text-white'>Multiselect Dropdown</label>
<div className='relative z-20 w-full rounded border border-stroke p-1.5 pr-8 font-medium outline-none transition focus:border-primary active:border-primary dark:border-form-strokedark dark:bg-form-input'>
<div className='flex flex-wrap items-center'>
<span className='m-1.5 flex items-center justify-center rounded border-[.5px] border-stroke bg-gray py-1.5 px-2.5 text-sm font-medium dark:border-strokedark dark:bg-white/30'>
Design
<span className='cursor-pointer pl-2 hover:text-danger'>
<svg
width='12'
height='12'
viewBox='0 0 12 12'
fill='none'
xmlns='http://www.w3.org/2000/svg'
>
<path
fillRule='evenodd'
clipRule='evenodd'
d='M9.35355 3.35355C9.54882 3.15829 9.54882 2.84171 9.35355 2.64645C9.15829 2.45118 8.84171 2.45118 8.64645 2.64645L6 5.29289L3.35355 2.64645C3.15829 2.45118 2.84171 2.45118 2.64645 2.64645C2.45118 2.84171 2.45118 3.15829 2.64645 3.35355L5.29289 6L2.64645 8.64645C2.45118 8.84171 2.45118 9.15829 2.64645 9.35355C2.84171 9.54882 3.15829 9.54882 3.35355 9.35355L6 6.70711L8.64645 9.35355C8.84171 9.54882 9.15829 9.54882 9.35355 9.35355C9.54882 9.15829 9.54882 8.84171 9.35355 8.64645L6.70711 6L9.35355 3.35355Z'
fill='currentColor'
></path>
</svg>
</span>
</span>
<span className='m-1.5 flex items-center justify-center rounded border-[.5px] border-stroke bg-gray py-1.5 px-2.5 text-sm font-medium dark:border-strokedark dark:bg-white/30'>
Development
<span className='cursor-pointer pl-2 hover:text-danger'>
<svg
width='12'
height='12'
viewBox='0 0 12 12'
fill='none'
xmlns='http://www.w3.org/2000/svg'
>
<path
fillRule='evenodd'
clipRule='evenodd'
d='M9.35355 3.35355C9.54882 3.15829 9.54882 2.84171 9.35355 2.64645C9.15829 2.45118 8.84171 2.45118 8.64645 2.64645L6 5.29289L3.35355 2.64645C3.15829 2.45118 2.84171 2.45118 2.64645 2.64645C2.45118 2.84171 2.45118 3.15829 2.64645 3.35355L5.29289 6L2.64645 8.64645C2.45118 8.84171 2.45118 9.15829 2.64645 9.35355C2.84171 9.54882 3.15829 9.54882 3.35355 9.35355L6 6.70711L8.64645 9.35355C8.84171 9.54882 9.15829 9.54882 9.35355 9.35355C9.54882 9.15829 9.54882 8.84171 9.35355 8.64645L6.70711 6L9.35355 3.35355Z'
fill='currentColor'
></path>
</svg>
</span>
</span>
</div>
<select
name=''
id=''
className='absolute top-0 left-0 z-20 h-full w-full bg-transparent opacity-0'
>
<option value=''>Option</option>
<option value=''>Option</option>
</select>
<span className='absolute top-1/2 right-4 z-10 -translate-y-1/2'>
<svg
width='24'
height='24'
viewBox='0 0 24 24'
fill='none'
xmlns='http://www.w3.org/2000/svg'
>
<g opacity='0.8'>
<path
fillRule='evenodd'
clipRule='evenodd'
d='M5.29289 8.29289C5.68342 7.90237 6.31658 7.90237 6.70711 8.29289L12 13.5858L17.2929 8.29289C17.6834 7.90237 18.3166 7.90237 18.7071 8.29289C19.0976 8.68342 19.0976 9.31658 18.7071 9.70711L12.7071 15.7071C12.3166 16.0976 11.6834 16.0976 11.2929 15.7071L5.29289 9.70711C4.90237 9.31658 4.90237 8.68342 5.29289 8.29289Z'
fill='#637381'
></path>
</g>
</svg>
</span>
</div>
</div>
</div>
</div>
</div>
</div>
</DefaultLayout>
); );
}; }
function SwitchExamples() { function ChevronDownIcon() {
const [isFirstOn, setIsFirstOn] = useState<boolean>(false);
const [isSecondOn, setIsSecondOn] = useState<boolean>(false);
return ( return (
<div className='flex flex-col gap-5.5 p-6.5'> <svg width='24' height='24' viewBox='0 0 24 24' fill='none' xmlns='http://www.w3.org/2000/svg'>
<SwitcherOne isOn={isFirstOn} onChange={() => setIsFirstOn(!isFirstOn)} /> <g opacity='0.8'>
<SwitcherTwo isOn={isSecondOn} onChange={() => setIsSecondOn(!isSecondOn)} /> <path
</div> fillRule='evenodd'
clipRule='evenodd'
d='M5.29289 8.29289C5.68342 7.90237 6.31658 7.90237 6.70711 8.29289L12 13.5858L17.2929 8.29289C17.6834 7.90237 18.3166 7.90237 18.7071 8.29289C19.0976 8.68342 19.0976 9.31658 18.7071 9.70711L12.7071 15.7071C12.3166 16.0976 11.6834 16.0976 11.2929 15.7071L5.29289 9.70711C4.90237 9.31658 4.90237 8.68342 5.29289 8.29289Z'
fill='#637381'
></path>
</g>
</svg>
);
}
function XIcon() {
return (
<svg width='12' height='12' viewBox='0 0 12 12' fill='none' xmlns='http://www.w3.org/2000/svg'>
<path
fillRule='evenodd'
clipRule='evenodd'
d='M9.35355 3.35355C9.54882 3.15829 9.54882 2.84171 9.35355 2.64645C9.15829 2.45118 8.84171 2.45118 8.64645 2.64645L6 5.29289L3.35355 2.64645C3.15829 2.45118 2.84171 2.45118 2.64645 2.64645C2.45118 2.84171 2.45118 3.15829 2.64645 3.35355L5.29289 6L2.64645 8.64645C2.45118 8.84171 2.45118 9.15829 2.64645 9.35355C2.84171 9.54882 3.15829 9.54882 3.35355 9.35355L6 6.70711L8.64645 9.35355C8.84171 9.54882 9.15829 9.54882 9.35355 9.35355C9.54882 9.15829 9.54882 8.84171 9.35355 8.64645L6.70711 6L9.35355 3.35355Z'
fill='currentColor'
></path>
</svg>
); );
} }

View File

@@ -1,18 +1,14 @@
import { useId } from 'react';
import { cn } from '../../../client/cn'; import { cn } from '../../../client/cn';
import { ChangeEventHandler } from 'react';
const SwitcherOne = ({ function SwitcherOne({ isOn, onChange }: { isOn: boolean; onChange: (value: boolean) => void }) {
isOn, const id = useId();
onChange,
}: {
isOn: boolean;
onChange: ChangeEventHandler<HTMLInputElement>;
}) => {
return ( return (
<div className='relative'> <div className='relative'>
<label className='flex cursor-pointer select-none items-center'> <label htmlFor={id} className='flex cursor-pointer select-none items-center'>
<div className='relative'> <div className='relative'>
<input type='checkbox' className='sr-only' onChange={onChange} /> <input id={id} type='checkbox' className='sr-only' onChange={(e) => onChange(e.target.checked)} />
<div className='reblock h-8 w-14 rounded-full bg-meta-9 dark:bg-[#5A616B]'></div> <div className='reblock h-8 w-14 rounded-full bg-meta-9 dark:bg-[#5A616B]'></div>
<div <div
className={cn('absolute left-1 top-1 h-6 w-6 rounded-full bg-white dark:bg-gray-400 transition', { className={cn('absolute left-1 top-1 h-6 w-6 rounded-full bg-white dark:bg-gray-400 transition', {
@@ -23,6 +19,6 @@ const SwitcherOne = ({
</label> </label>
</div> </div>
); );
}; }
export default SwitcherOne; export default SwitcherOne;

View File

@@ -1,18 +1,14 @@
import { ChangeEventHandler } from 'react'; import { useId } from 'react';
import { cn } from '../../../client/cn'; import { cn } from '../../../client/cn';
const SwitcherTwo = ({ function SwitcherTwo({ isOn, onChange }: { isOn: boolean; onChange: (value: boolean) => void }) {
isOn, const id = useId();
onChange,
}: {
isOn: boolean;
onChange: ChangeEventHandler<HTMLInputElement>;
}) => {
return ( return (
<div> <div>
<label className='flex cursor-pointer select-none items-center'> <label htmlFor={id} className='flex cursor-pointer select-none items-center'>
<div className='relative'> <div className='relative'>
<input type='checkbox' id='toggle3' className='sr-only' onChange={onChange} /> <input type='checkbox' id={id} className='sr-only' onChange={(e) => onChange(e.target.checked)} />
<div className='block h-8 w-14 rounded-full bg-meta-9 dark:bg-[#5A616B]'></div> <div className='block h-8 w-14 rounded-full bg-meta-9 dark:bg-[#5A616B]'></div>
<div <div
className={cn( className={cn(
@@ -33,7 +29,7 @@ const SwitcherTwo = ({
</label> </label>
</div> </div>
); );
}; }
const XIcon = () => { const XIcon = () => {
return ( return (

View File

@@ -1,3 +1,4 @@
import * as z from 'zod';
import type { Task, GptResponse } from 'wasp/entities'; import type { Task, GptResponse } from 'wasp/entities';
import type { import type {
GenerateGptResponse, GenerateGptResponse,
@@ -10,6 +11,7 @@ import type {
import { HttpError } from 'wasp/server'; import { HttpError } from 'wasp/server';
import { GeneratedSchedule } from './schedule'; import { GeneratedSchedule } from './schedule';
import OpenAI from 'openai'; import OpenAI from 'openai';
import { ensureArgsSchemaOrThrowHttpError } from '../server/validation';
const openai = setupOpenAI(); const openai = setupOpenAI();
function setupOpenAI() { function setupOpenAI() {
@@ -20,15 +22,23 @@ function setupOpenAI() {
} }
//#region Actions //#region Actions
type GptPayload = {
hours: string;
};
export const generateGptResponse: GenerateGptResponse<GptPayload, GeneratedSchedule> = async ({ hours }, context) => { const generateGptResponseInputSchema = z.object({
hours: z.string().regex(/^\d+(\.\d+)?$/, 'Hours must be a number'),
});
type GenerateGptResponseInput = z.infer<typeof generateGptResponseInputSchema>;
export const generateGptResponse: GenerateGptResponse<GenerateGptResponseInput, GeneratedSchedule> = async (
rawArgs,
context
) => {
if (!context.user) { if (!context.user) {
throw new HttpError(401); throw new HttpError(401);
} }
const { hours } = ensureArgsSchemaOrThrowHttpError(generateGptResponseInputSchema, rawArgs);
const tasks = await context.entities.Task.findMany({ const tasks = await context.entities.Task.findMany({
where: { where: {
user: { user: {
@@ -181,11 +191,19 @@ export const generateGptResponse: GenerateGptResponse<GptPayload, GeneratedSched
} }
}; };
export const createTask: CreateTask<Pick<Task, 'description'>, Task> = async ({ description }, context) => { const createTaskInputSchema = z.object({
description: z.string().nonempty(),
});
type CreateTaskInput = z.infer<typeof createTaskInputSchema>;
export const createTask: CreateTask<CreateTaskInput, Task> = async (rawArgs, context) => {
if (!context.user) { if (!context.user) {
throw new HttpError(401); throw new HttpError(401);
} }
const { description } = ensureArgsSchemaOrThrowHttpError(createTaskInputSchema, rawArgs);
const task = await context.entities.Task.create({ const task = await context.entities.Task.create({
data: { data: {
description, description,
@@ -196,11 +214,21 @@ export const createTask: CreateTask<Pick<Task, 'description'>, Task> = async ({
return task; return task;
}; };
export const updateTask: UpdateTask<Partial<Task>, Task> = async ({ id, isDone, time }, context) => { const updateTaskInputSchema = z.object({
id: z.string().nonempty(),
isDone: z.boolean().optional(),
time: z.string().optional(),
});
type UpdateTaskInput = z.infer<typeof updateTaskInputSchema>;
export const updateTask: UpdateTask<UpdateTaskInput, Task> = async (rawArgs, context) => {
if (!context.user) { if (!context.user) {
throw new HttpError(401); throw new HttpError(401);
} }
const { id, isDone, time } = ensureArgsSchemaOrThrowHttpError(updateTaskInputSchema, rawArgs);
const task = await context.entities.Task.update({ const task = await context.entities.Task.update({
where: { where: {
id, id,
@@ -214,11 +242,19 @@ export const updateTask: UpdateTask<Partial<Task>, Task> = async ({ id, isDone,
return task; return task;
}; };
export const deleteTask: DeleteTask<Pick<Task, 'id'>, Task> = async ({ id }, context) => { const deleteTaskInputSchema = z.object({
id: z.string().nonempty(),
});
type DeleteTaskInput = z.infer<typeof deleteTaskInputSchema>;
export const deleteTask: DeleteTask<DeleteTaskInput, Task> = async (rawArgs, context) => {
if (!context.user) { if (!context.user) {
throw new HttpError(401); throw new HttpError(401);
} }
const { id } = ensureArgsSchemaOrThrowHttpError(deleteTaskInputSchema, rawArgs);
const task = await context.entities.Task.delete({ const task = await context.entities.Task.delete({
where: { where: {
id, id,

View File

@@ -2,7 +2,13 @@ import { cn } from '../client/cn';
import { useState, useEffect, FormEvent } from 'react'; import { useState, useEffect, FormEvent } from 'react';
import type { File } from 'wasp/entities'; import type { File } from 'wasp/entities';
import { useQuery, getAllFilesByUser, getDownloadFileSignedURL } from 'wasp/client/operations'; import { useQuery, getAllFilesByUser, getDownloadFileSignedURL } from 'wasp/client/operations';
import { type FileUploadError, uploadFileWithProgress, validateFile, ALLOWED_FILE_TYPES } from './fileUploading'; import {
type FileWithValidType,
type FileUploadError,
validateFile,
uploadFileWithProgress,
} from './fileUploading';
import { ALLOWED_FILE_TYPES } from './validation';
export default function FileUploadPage() { export default function FileUploadPage() {
const [fileKeyForS3, setFileKeyForS3] = useState<File['key']>(''); const [fileKeyForS3, setFileKeyForS3] = useState<File['key']>('');
@@ -64,13 +70,13 @@ export default function FileUploadPage() {
return; return;
} }
const validationError = validateFile(file); const fileValidationError = validateFile(file);
if (validationError) { if (fileValidationError !== null) {
setUploadError(validationError); setUploadError(fileValidationError);
return; return;
} }
await uploadFileWithProgress({ file, setUploadProgressPercent }); await uploadFileWithProgress({ file: file as FileWithValidType, setUploadProgressPercent });
formElement.reset(); formElement.reset();
allUserFiles.refetch(); allUserFiles.refetch();
} catch (error) { } catch (error) {
@@ -117,11 +123,11 @@ export default function FileUploadPage() {
<> <>
<span>Uploading {uploadProgressPercent}%</span> <span>Uploading {uploadProgressPercent}%</span>
<div <div
role="progressbar" role='progressbar'
aria-valuenow={uploadProgressPercent} aria-valuenow={uploadProgressPercent}
aria-valuemin={0} aria-valuemin={0}
aria-valuemax={100} aria-valuemax={100}
className="absolute bottom-0 left-0 h-1 bg-yellow-500 transition-all duration-300 ease-in-out rounded-b-md" className='absolute bottom-0 left-0 h-1 bg-yellow-500 transition-all duration-300 ease-in-out rounded-b-md'
style={{ width: `${uploadProgressPercent}%` }} style={{ width: `${uploadProgressPercent}%` }}
></div> ></div>
</> </>

View File

@@ -1,30 +1,18 @@
import { Dispatch, SetStateAction } from 'react'; import { Dispatch, SetStateAction } from 'react';
import { createFile } from 'wasp/client/operations'; import { createFile } from 'wasp/client/operations';
import axios from 'axios'; import axios from 'axios';
import { ALLOWED_FILE_TYPES, MAX_FILE_SIZE } from './validation';
export type FileWithValidType = Omit<File, 'type'> & { type: AllowedFileType };
type AllowedFileType = (typeof ALLOWED_FILE_TYPES)[number];
interface FileUploadProgress { interface FileUploadProgress {
file: File; file: FileWithValidType;
setUploadProgressPercent: Dispatch<SetStateAction<number>>; setUploadProgressPercent: Dispatch<SetStateAction<number>>;
} }
export interface FileUploadError {
message: string;
code: 'NO_FILE' | 'INVALID_FILE_TYPE' | 'FILE_TOO_LARGE' | 'UPLOAD_FAILED';
}
export const MAX_FILE_SIZE = 5 * 1024 * 1024; // Set this to the max file size you want to allow (currently 5MB).
export const ALLOWED_FILE_TYPES = [
'image/jpeg',
'image/png',
'application/pdf',
'text/*',
'video/quicktime',
'video/mp4',
];
export async function uploadFileWithProgress({ file, setUploadProgressPercent }: FileUploadProgress) { export async function uploadFileWithProgress({ file, setUploadProgressPercent }: FileUploadProgress) {
const { uploadUrl } = await createFile({ fileType: file.type, name: file.name }); const { uploadUrl } = await createFile({ fileType: file.type, fileName: file.name });
return await axios.put(uploadUrl, file, { return axios.put(uploadUrl, file, {
headers: { headers: {
'Content-Type': file.type, 'Content-Type': file.type,
}, },
@@ -37,18 +25,29 @@ export async function uploadFileWithProgress({ file, setUploadProgressPercent }:
}); });
} }
export function validateFile(file: File): FileUploadError | null { export interface FileUploadError {
message: string;
code: 'NO_FILE' | 'INVALID_FILE_TYPE' | 'FILE_TOO_LARGE' | 'UPLOAD_FAILED';
}
export function validateFile(file: File) {
if (file.size > MAX_FILE_SIZE) { if (file.size > MAX_FILE_SIZE) {
return { return {
message: `File size exceeds ${MAX_FILE_SIZE / 1024 / 1024}MB limit.`, message: `File size exceeds ${MAX_FILE_SIZE / 1024 / 1024}MB limit.`,
code: 'FILE_TOO_LARGE', code: 'FILE_TOO_LARGE' as const,
}; };
} }
if (!ALLOWED_FILE_TYPES.includes(file.type)) {
if (!isAllowedFileType(file.type)) {
return { return {
message: `File type '${file.type}' is not supported.`, message: `File type '${file.type}' is not supported.`,
code: 'INVALID_FILE_TYPE', code: 'INVALID_FILE_TYPE' as const,
}; };
} }
return null; return null;
} }
function isAllowedFileType(fileType: string): fileType is AllowedFileType {
return (ALLOWED_FILE_TYPES as readonly string[]).includes(fileType);
}

View File

@@ -1,3 +1,4 @@
import * as z from 'zod';
import { HttpError } from 'wasp/server'; import { HttpError } from 'wasp/server';
import { type File } from 'wasp/entities'; import { type File } from 'wasp/entities';
import { import {
@@ -7,24 +8,32 @@ import {
} from 'wasp/server/operations'; } from 'wasp/server/operations';
import { getUploadFileSignedURLFromS3, getDownloadFileSignedURLFromS3 } from './s3Utils'; import { getUploadFileSignedURLFromS3, getDownloadFileSignedURLFromS3 } from './s3Utils';
import { ensureArgsSchemaOrThrowHttpError } from '../server/validation';
import { ALLOWED_FILE_TYPES } from './validation';
type FileDescription = { const createFileInputSchema = z.object({
fileType: string; fileType: z.enum(ALLOWED_FILE_TYPES),
name: string; fileName: z.string().nonempty(),
}; });
export const createFile: CreateFile<FileDescription, File> = async ({ fileType, name }, context) => { type CreateFileInput = z.infer<typeof createFileInputSchema>;
export const createFile: CreateFile<CreateFileInput, File> = async (rawArgs, context) => {
if (!context.user) { if (!context.user) {
throw new HttpError(401); throw new HttpError(401);
} }
const userInfo = context.user.id; const { fileType, fileName } = ensureArgsSchemaOrThrowHttpError(createFileInputSchema, rawArgs);
const { uploadUrl, key } = await getUploadFileSignedURLFromS3({ fileType, userInfo }); const { uploadUrl, key } = await getUploadFileSignedURLFromS3({
fileType,
fileName,
userId: context.user.id,
});
return await context.entities.File.create({ return await context.entities.File.create({
data: { data: {
name, name: fileName,
key, key,
uploadUrl, uploadUrl,
type: fileType, type: fileType,
@@ -49,9 +58,14 @@ export const getAllFilesByUser: GetAllFilesByUser<void, File[]> = async (_args,
}); });
}; };
export const getDownloadFileSignedURL: GetDownloadFileSignedURL<{ key: string }, string> = async ( const getDownloadFileSignedURLInputSchema = z.object({ key: z.string().nonempty() });
{ key },
_context type GetDownloadFileSignedURLInput = z.infer<typeof getDownloadFileSignedURLInputSchema>;
) => {
export const getDownloadFileSignedURL: GetDownloadFileSignedURL<
GetDownloadFileSignedURLInput,
string
> = async (rawArgs, _context) => {
const { key } = ensureArgsSchemaOrThrowHttpError(getDownloadFileSignedURLInputSchema, rawArgs);
return await getDownloadFileSignedURLFromS3({ key }); return await getDownloadFileSignedURLFromS3({ key });
}; };

View File

@@ -1,3 +1,4 @@
import * as path from 'path';
import { randomUUID } from 'crypto'; import { randomUUID } from 'crypto';
import { S3Client } from '@aws-sdk/client-s3'; import { S3Client } from '@aws-sdk/client-s3';
import { GetObjectCommand, PutObjectCommand } from '@aws-sdk/client-s3'; import { GetObjectCommand, PutObjectCommand } from '@aws-sdk/client-s3';
@@ -13,27 +14,30 @@ const s3Client = new S3Client({
type S3Upload = { type S3Upload = {
fileType: string; fileType: string;
userInfo: string; fileName: string;
} userId: string;
};
export const getUploadFileSignedURLFromS3 = async ({fileType, userInfo}: S3Upload) => { export const getUploadFileSignedURLFromS3 = async ({ fileName, fileType, userId }: S3Upload) => {
const ex = fileType.split('/')[1]; const key = getS3Key(fileName, userId);
const Key = `${userInfo}/${randomUUID()}.${ex}`; const command = new PutObjectCommand({
const s3Params = {
Bucket: process.env.AWS_S3_FILES_BUCKET,
Key,
ContentType: `${fileType}`,
};
const command = new PutObjectCommand(s3Params);
const uploadUrl = await getSignedUrl(s3Client, command, { expiresIn: 3600,});
return { uploadUrl, key: Key };
}
export const getDownloadFileSignedURLFromS3 = async ({ key }: { key: string }) => {
const s3Params = {
Bucket: process.env.AWS_S3_FILES_BUCKET, Bucket: process.env.AWS_S3_FILES_BUCKET,
Key: key, Key: key,
}; ContentType: fileType,
const command = new GetObjectCommand(s3Params); });
const uploadUrl = await getSignedUrl(s3Client, command, { expiresIn: 3600 });
return { uploadUrl, key };
};
export const getDownloadFileSignedURLFromS3 = async ({ key }: { key: string }) => {
const command = new GetObjectCommand({
Bucket: process.env.AWS_S3_FILES_BUCKET,
Key: key,
});
return await getSignedUrl(s3Client, command, { expiresIn: 3600 }); return await getSignedUrl(s3Client, command, { expiresIn: 3600 });
};
function getS3Key(fileName: string, userId: string) {
const ext = path.extname(fileName).slice(1);
return `${userId}/${randomUUID()}.${ext}`;
} }

View File

@@ -0,0 +1,10 @@
// Set this to the max file size you want to allow (currently 5MB).
export const MAX_FILE_SIZE = 5 * 1024 * 1024;
export const ALLOWED_FILE_TYPES = [
'image/jpeg',
'image/png',
'application/pdf',
'text/*',
'video/quicktime',
'video/mp4',
] as const;

View File

@@ -1,20 +1,27 @@
import * as z from 'zod';
import type { GenerateCheckoutSession, GetCustomerPortalUrl } from 'wasp/server/operations'; import type { GenerateCheckoutSession, GetCustomerPortalUrl } from 'wasp/server/operations';
import { PaymentPlanId, paymentPlans } from '../payment/plans'; import { PaymentPlanId, paymentPlans } from '../payment/plans';
import { paymentProcessor } from './paymentProcessor'; import { paymentProcessor } from './paymentProcessor';
import { HttpError } from 'wasp/server'; import { HttpError } from 'wasp/server';
import { ensureArgsSchemaOrThrowHttpError } from '../server/validation';
export type CheckoutSession = { export type CheckoutSession = {
sessionUrl: string | null; sessionUrl: string | null;
sessionId: string; sessionId: string;
}; };
export const generateCheckoutSession: GenerateCheckoutSession<PaymentPlanId, CheckoutSession> = async ( const generateCheckoutSessionSchema = z.nativeEnum(PaymentPlanId);
paymentPlanId,
context type GenerateCheckoutSessionInput = z.infer<typeof generateCheckoutSessionSchema>;
) => {
export const generateCheckoutSession: GenerateCheckoutSession<
GenerateCheckoutSessionInput,
CheckoutSession
> = async (rawPaymentPlanId, context) => {
if (!context.user) { if (!context.user) {
throw new HttpError(401); throw new HttpError(401);
} }
const paymentPlanId = ensureArgsSchemaOrThrowHttpError(generateCheckoutSessionSchema, rawPaymentPlanId);
const userId = context.user.id; const userId = context.user.id;
const userEmail = context.user.email; const userEmail = context.user.email;
if (!userEmail) { if (!userEmail) {
@@ -29,7 +36,7 @@ export const generateCheckoutSession: GenerateCheckoutSession<PaymentPlanId, Che
userId, userId,
userEmail, userEmail,
paymentPlan, paymentPlan,
prismaUserDelegate: context.entities.User prismaUserDelegate: context.entities.User,
}); });
return { return {

View File

@@ -1,6 +1,13 @@
import * as z from 'zod';
import { requireNodeEnvVar } from '../server/utils'; import { requireNodeEnvVar } from '../server/utils';
export type SubscriptionStatus = 'past_due' | 'cancel_at_period_end' | 'active' | 'deleted'; export const subscriptionStatusSchema = z
.literal('past_due')
.or(z.literal('cancel_at_period_end'))
.or(z.literal('active'))
.or(z.literal('deleted'));
export type SubscriptionStatus = z.infer<typeof subscriptionStatusSchema>;
export enum PaymentPlanId { export enum PaymentPlanId {
Hobby = 'hobby', Hobby = 'hobby',

View File

@@ -0,0 +1,15 @@
import { HttpError } from 'wasp/server';
import * as z from 'zod';
export function ensureArgsSchemaOrThrowHttpError<Schema extends z.ZodType>(
schema: Schema,
rawArgs: unknown
): z.infer<Schema> {
const parseResult = schema.safeParse(rawArgs);
if (!parseResult.success) {
console.error(parseResult.error);
throw new HttpError(400, 'Operation arguments validation failed', { errors: parseResult.error.errors });
} else {
return parseResult.data;
}
}

View File

@@ -1,13 +1,24 @@
import * as z from 'zod';
import { type UpdateIsUserAdminById, type GetPaginatedUsers } from 'wasp/server/operations'; import { type UpdateIsUserAdminById, type GetPaginatedUsers } from 'wasp/server/operations';
import { type User } from 'wasp/entities'; import { type User } from 'wasp/entities';
import { HttpError, prisma } from 'wasp/server'; import { HttpError, prisma } from 'wasp/server';
import { type SubscriptionStatus } from '../payment/plans'; import { subscriptionStatusSchema, type SubscriptionStatus } from '../payment/plans';
import { type Prisma } from '@prisma/client'; import { type Prisma } from '@prisma/client';
import { ensureArgsSchemaOrThrowHttpError } from '../server/validation';
export const updateIsUserAdminById: UpdateIsUserAdminById<Pick<User, 'id' | 'isAdmin'>, User> = async ( const updateUserAdminByIdInputSchema = z.object({
{ id, isAdmin }, id: z.string().nonempty(),
isAdmin: z.boolean(),
});
type UpdateUserAdminByIdInput = z.infer<typeof updateUserAdminByIdInputSchema>;
export const updateIsUserAdminById: UpdateIsUserAdminById<UpdateUserAdminByIdInput, User> = async (
rawArgs,
context context
) => { ) => {
const { id, isAdmin } = ensureArgsSchemaOrThrowHttpError(updateUserAdminByIdInputSchema, rawArgs);
if (!context.user) { if (!context.user) {
throw new HttpError(401, 'Only authenticated users are allowed to perform this operation'); throw new HttpError(401, 'Only authenticated users are allowed to perform this operation');
} }
@@ -22,15 +33,6 @@ export const updateIsUserAdminById: UpdateIsUserAdminById<Pick<User, 'id' | 'isA
}); });
}; };
type GetPaginatedUsersInput = {
skipPages: number;
filter: {
emailContains?: string;
isAdmin?: boolean;
subscriptionStatusIn?: SubscriptionStatus[];
};
};
type GetPaginatedUsersOutput = { type GetPaginatedUsersOutput = {
users: Pick< users: Pick<
User, User,
@@ -39,8 +41,19 @@ type GetPaginatedUsersOutput = {
totalPages: number; totalPages: number;
}; };
const getPaginatorArgsSchema = z.object({
skipPages: z.number(),
filter: z.object({
emailContains: z.string().nonempty().optional(),
isAdmin: z.boolean().optional(),
subscriptionStatusIn: z.array(subscriptionStatusSchema.nullable()).optional(),
}),
});
type GetPaginatedUsersInput = z.infer<typeof getPaginatorArgsSchema>;
export const getPaginatedUsers: GetPaginatedUsers<GetPaginatedUsersInput, GetPaginatedUsersOutput> = async ( export const getPaginatedUsers: GetPaginatedUsers<GetPaginatedUsersInput, GetPaginatedUsersOutput> = async (
args, rawArgs,
context context
) => { ) => {
if (!context.user) { if (!context.user) {
@@ -54,7 +67,8 @@ export const getPaginatedUsers: GetPaginatedUsers<GetPaginatedUsersInput, GetPag
const { const {
skipPages, skipPages,
filter: { subscriptionStatusIn: subscriptionStatus, emailContains, isAdmin }, filter: { subscriptionStatusIn: subscriptionStatus, emailContains, isAdmin },
} = args; } = ensureArgsSchemaOrThrowHttpError(getPaginatorArgsSchema, rawArgs);
const includeUnsubscribedUsers = !!subscriptionStatus?.some((status) => status === null); const includeUnsubscribedUsers = !!subscriptionStatus?.some((status) => status === null);
const desiredSubscriptionStatuses = subscriptionStatus?.filter((status) => status !== null); const desiredSubscriptionStatuses = subscriptionStatus?.filter((status) => status !== null);