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
commit 145a02b0b9
17 changed files with 296 additions and 242 deletions

View File

@ -1,8 +1,8 @@
--- template/app/src/file-upload/operations.ts
+++ opensaas-sh/app/src/file-upload/operations.ts
@@ -18,6 +18,18 @@
throw new HttpError(401);
}
@@ -25,6 +25,18 @@
const { fileType, fileName } = ensureArgsSchemaOrThrowHttpError(createFileInputSchema, rawArgs);
+ const numberOfFilesByUser = await context.entities.File.count({
+ where: {
@ -16,6 +16,6 @@
+ 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, userInfo });
const { uploadUrl, key } = await getUploadFileSignedURLFromS3({
fileType,
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
+++ opensaas-sh/app/src/user/operations.ts
@@ -38,7 +38,10 @@
subscriptionStatus?: SubscriptionStatus[];
@@ -41,7 +41,10 @@
};
type GetPaginatedUsersOutput = {
- users: Pick<User, 'id' | 'email' | 'username' | 'subscriptionStatus' | 'paymentProcessorUserId'>[];
+ users: Pick<
@ -12,28 +12,15 @@
totalPages: number;
};
@@ -51,8 +54,10 @@
}
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 @@
@@ -85,6 +88,7 @@
mode: 'insensitive',
},
isAdmin: args.isAdmin,
isAdmin,
+ isMockUser: true,
},
{
OR: [
@@ -88,7 +94,7 @@
@@ -108,7 +112,7 @@
username: true,
isAdmin: true,
subscriptionStatus: true,
@ -42,10 +29,10 @@
},
orderBy: {
id: 'desc',
@@ -104,6 +110,7 @@
@@ -124,6 +128,7 @@
mode: 'insensitive',
},
isAdmin: args.isAdmin,
isAdmin,
+ isMockUser: true,
},
{

View File

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

View File

@ -170,34 +170,7 @@ const FormElements = ({ user }: { user: AuthUser }) => {
<label className='mb-3 block text-black dark:text-white'>Select Country</label>
<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'>
<svg
width='20'
height='20'
viewBox='0 0 20 20'
fill='none'
xmlns='http://www.w3.org/2000/svg'
>
<g opacity='0.8'>
<path
fillRule='evenodd'
clipRule='evenodd'
d='M10.0007 2.50065C5.85852 2.50065 2.50065 5.85852 2.50065 10.0007C2.50065 14.1428 5.85852 17.5007 10.0007 17.5007C14.1428 17.5007 17.5007 14.1428 17.5007 10.0007C17.5007 5.85852 14.1428 2.50065 10.0007 2.50065ZM0.833984 10.0007C0.833984 4.93804 4.93804 0.833984 10.0007 0.833984C15.0633 0.833984 19.1673 4.93804 19.1673 10.0007C19.1673 15.0633 15.0633 19.1673 10.0007 19.1673C4.93804 19.1673 0.833984 15.0633 0.833984 10.0007Z'
fill='#637381'
></path>
<path
fillRule='evenodd'
clipRule='evenodd'
d='M0.833984 9.99935C0.833984 9.53911 1.20708 9.16602 1.66732 9.16602H18.334C18.7942 9.16602 19.1673 9.53911 19.1673 9.99935C19.1673 10.4596 18.7942 10.8327 18.334 10.8327H1.66732C1.20708 10.8327 0.833984 10.4596 0.833984 9.99935Z'
fill='#637381'
></path>
<path
fillRule='evenodd'
clipRule='evenodd'
d='M7.50084 10.0008C7.55796 12.5632 8.4392 15.0301 10.0006 17.0418C11.5621 15.0301 12.4433 12.5632 12.5005 10.0008C12.4433 7.43845 11.5621 4.97153 10.0007 2.95982C8.4392 4.97153 7.55796 7.43845 7.50084 10.0008ZM10.0007 1.66749L9.38536 1.10547C7.16473 3.53658 5.90275 6.69153 5.83417 9.98346C5.83392 9.99503 5.83392 10.0066 5.83417 10.0182C5.90275 13.3101 7.16473 16.4651 9.38536 18.8962C9.54325 19.069 9.76655 19.1675 10.0007 19.1675C10.2348 19.1675 10.4581 19.069 10.6159 18.8962C12.8366 16.4651 14.0986 13.3101 14.1671 10.0182C14.1674 10.0066 14.1674 9.99503 14.1671 9.98346C14.0986 6.69153 12.8366 3.53658 10.6159 1.10547L10.0007 1.66749Z'
fill='#637381'
></path>
</g>
</svg>
<GlobeIcon />
</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>
@ -205,22 +178,7 @@ const FormElements = ({ user }: { user: AuthUser }) => {
<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>
<ChevronDownIcon />
</span>
</div>
</div>
@ -232,39 +190,13 @@ const FormElements = ({ user }: { user: AuthUser }) => {
<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>
<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'>
<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>
<XIcon />
</span>
</span>
</div>
@ -277,22 +209,7 @@ const FormElements = ({ user }: { user: AuthUser }) => {
<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>
<ChevronDownIcon />
</span>
</div>
</div>
@ -309,10 +226,65 @@ function SwitchExamples() {
const [isSecondOn, setIsSecondOn] = useState<boolean>(false);
return (
<div className='flex flex-col gap-5.5 p-6.5'>
<SwitcherOne isOn={isFirstOn} onChange={() => setIsFirstOn(!isFirstOn)} />
<SwitcherTwo isOn={isSecondOn} onChange={() => setIsSecondOn(!isSecondOn)} />
<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'>
<path
fillRule='evenodd'
clipRule='evenodd'
d='M10.0007 2.50065C5.85852 2.50065 2.50065 5.85852 2.50065 10.0007C2.50065 14.1428 5.85852 17.5007 10.0007 17.5007C14.1428 17.5007 17.5007 14.1428 17.5007 10.0007C17.5007 5.85852 14.1428 2.50065 10.0007 2.50065ZM0.833984 10.0007C0.833984 4.93804 4.93804 0.833984 10.0007 0.833984C15.0633 0.833984 19.1673 4.93804 19.1673 10.0007C19.1673 15.0633 15.0633 19.1673 10.0007 19.1673C4.93804 19.1673 0.833984 15.0633 0.833984 10.0007Z'
fill='#637381'
></path>
<path
fillRule='evenodd'
clipRule='evenodd'
d='M0.833984 9.99935C0.833984 9.53911 1.20708 9.16602 1.66732 9.16602H18.334C18.7942 9.16602 19.1673 9.53911 19.1673 9.99935C19.1673 10.4596 18.7942 10.8327 18.334 10.8327H1.66732C1.20708 10.8327 0.833984 10.4596 0.833984 9.99935Z'
fill='#637381'
></path>
<path
fillRule='evenodd'
clipRule='evenodd'
d='M7.50084 10.0008C7.55796 12.5632 8.4392 15.0301 10.0006 17.0418C11.5621 15.0301 12.4433 12.5632 12.5005 10.0008C12.4433 7.43845 11.5621 4.97153 10.0007 2.95982C8.4392 4.97153 7.55796 7.43845 7.50084 10.0008ZM10.0007 1.66749L9.38536 1.10547C7.16473 3.53658 5.90275 6.69153 5.83417 9.98346C5.83392 9.99503 5.83392 10.0066 5.83417 10.0182C5.90275 13.3101 7.16473 16.4651 9.38536 18.8962C9.54325 19.069 9.76655 19.1675 10.0007 19.1675C10.2348 19.1675 10.4581 19.069 10.6159 18.8962C12.8366 16.4651 14.0986 13.3101 14.1671 10.0182C14.1674 10.0066 14.1674 9.99503 14.1671 9.98346C14.0986 6.69153 12.8366 3.53658 10.6159 1.10547L10.0007 1.66749Z'
fill='#637381'
></path>
</g>
</svg>
);
}
function ChevronDownIcon() {
return (
<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>
);
}
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>
);
}
export default FormElements;

View File

@ -1,18 +1,14 @@
import { useId } from 'react';
import { cn } from '../../../client/cn';
import { ChangeEventHandler } from 'react';
const SwitcherOne = ({
isOn,
onChange,
}: {
isOn: boolean;
onChange: ChangeEventHandler<HTMLInputElement>;
}) => {
function SwitcherOne({ isOn, onChange }: { isOn: boolean; onChange: (value: boolean) => void }) {
const id = useId();
return (
<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'>
<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={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>
</div>
);
};
}
export default SwitcherOne;

View File

@ -1,18 +1,14 @@
import { ChangeEventHandler } from 'react';
import { useId } from 'react';
import { cn } from '../../../client/cn';
const SwitcherTwo = ({
isOn,
onChange,
}: {
isOn: boolean;
onChange: ChangeEventHandler<HTMLInputElement>;
}) => {
function SwitcherTwo({ isOn, onChange }: { isOn: boolean; onChange: (value: boolean) => void }) {
const id = useId();
return (
<div>
<label className='flex cursor-pointer select-none items-center'>
<label htmlFor={id} className='flex cursor-pointer select-none items-center'>
<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={cn(
@ -33,7 +29,7 @@ const SwitcherTwo = ({
</label>
</div>
);
};
}
const XIcon = () => {
return (

View File

@ -1,3 +1,4 @@
import * as z from 'zod';
import type { Task, GptResponse } from 'wasp/entities';
import type {
GenerateGptResponse,
@ -10,6 +11,7 @@ import type {
import { HttpError } from 'wasp/server';
import { GeneratedSchedule } from './schedule';
import OpenAI from 'openai';
import { ensureArgsSchemaOrThrowHttpError } from '../server/validation';
const openai = setupOpenAI();
function setupOpenAI() {
@ -20,15 +22,23 @@ function setupOpenAI() {
}
//#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) {
throw new HttpError(401);
}
const { hours } = ensureArgsSchemaOrThrowHttpError(generateGptResponseInputSchema, rawArgs);
const tasks = await context.entities.Task.findMany({
where: {
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) {
throw new HttpError(401);
}
const { description } = ensureArgsSchemaOrThrowHttpError(createTaskInputSchema, rawArgs);
const task = await context.entities.Task.create({
data: {
description,
@ -196,11 +214,21 @@ export const createTask: CreateTask<Pick<Task, 'description'>, Task> = async ({
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) {
throw new HttpError(401);
}
const { id, isDone, time } = ensureArgsSchemaOrThrowHttpError(updateTaskInputSchema, rawArgs);
const task = await context.entities.Task.update({
where: {
id,
@ -214,11 +242,19 @@ export const updateTask: UpdateTask<Partial<Task>, Task> = async ({ id, isDone,
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) {
throw new HttpError(401);
}
const { id } = ensureArgsSchemaOrThrowHttpError(deleteTaskInputSchema, rawArgs);
const task = await context.entities.Task.delete({
where: {
id,

View File

@ -2,7 +2,13 @@ import { cn } from '../client/cn';
import { useState, useEffect, FormEvent } from 'react';
import type { File } from 'wasp/entities';
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() {
const [fileKeyForS3, setFileKeyForS3] = useState<File['key']>('');
@ -10,7 +16,7 @@ export default function FileUploadPage() {
const [uploadError, setUploadError] = useState<FileUploadError | 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,
// We disable automatic refetching because otherwise files would be refetched after `createFile` is called and the S3 URL is returned,
// which happens before the file is actually fully uploaded. Instead, we manually (re)fetch on mount and after the upload is complete.
enabled: false,
});
@ -64,13 +70,13 @@ export default function FileUploadPage() {
return;
}
const validationError = validateFile(file);
if (validationError) {
setUploadError(validationError);
const fileValidationError = validateFile(file);
if (fileValidationError !== null) {
setUploadError(fileValidationError);
return;
}
await uploadFileWithProgress({ file, setUploadProgressPercent });
await uploadFileWithProgress({ file: file as FileWithValidType, setUploadProgressPercent });
formElement.reset();
allUserFiles.refetch();
} catch (error) {
@ -117,11 +123,11 @@ export default function FileUploadPage() {
<>
<span>Uploading {uploadProgressPercent}%</span>
<div
role="progressbar"
role='progressbar'
aria-valuenow={uploadProgressPercent}
aria-valuemin={0}
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}%` }}
></div>
</>

View File

@ -1,30 +1,18 @@
import { Dispatch, SetStateAction } from 'react';
import { createFile } from 'wasp/client/operations';
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 {
file: File;
file: FileWithValidType;
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) {
const { uploadUrl } = await createFile({ fileType: file.type, name: file.name });
return await axios.put(uploadUrl, file, {
const { uploadUrl } = await createFile({ fileType: file.type, fileName: file.name });
return axios.put(uploadUrl, file, {
headers: {
'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) {
return {
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 {
message: `File type '${file.type}' is not supported.`,
code: 'INVALID_FILE_TYPE',
code: 'INVALID_FILE_TYPE' as const,
};
}
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 { type File } from 'wasp/entities';
import {
@ -7,24 +8,32 @@ import {
} from 'wasp/server/operations';
import { getUploadFileSignedURLFromS3, getDownloadFileSignedURLFromS3 } from './s3Utils';
import { ensureArgsSchemaOrThrowHttpError } from '../server/validation';
import { ALLOWED_FILE_TYPES } from './validation';
type FileDescription = {
fileType: string;
name: string;
};
const createFileInputSchema = z.object({
fileType: z.enum(ALLOWED_FILE_TYPES),
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) {
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({
data: {
name,
name: fileName,
key,
uploadUrl,
type: fileType,
@ -49,9 +58,14 @@ export const getAllFilesByUser: GetAllFilesByUser<void, File[]> = async (_args,
});
};
export const getDownloadFileSignedURL: GetDownloadFileSignedURL<{ key: string }, string> = async (
{ key },
_context
) => {
const getDownloadFileSignedURLInputSchema = z.object({ key: z.string().nonempty() });
type GetDownloadFileSignedURLInput = z.infer<typeof getDownloadFileSignedURLInputSchema>;
export const getDownloadFileSignedURL: GetDownloadFileSignedURL<
GetDownloadFileSignedURLInput,
string
> = async (rawArgs, _context) => {
const { key } = ensureArgsSchemaOrThrowHttpError(getDownloadFileSignedURLInputSchema, rawArgs);
return await getDownloadFileSignedURLFromS3({ key });
};

View File

@ -1,3 +1,4 @@
import * as path from 'path';
import { randomUUID } from 'crypto';
import { S3Client } from '@aws-sdk/client-s3';
import { GetObjectCommand, PutObjectCommand } from '@aws-sdk/client-s3';
@ -13,27 +14,30 @@ const s3Client = new S3Client({
type S3Upload = {
fileType: string;
userInfo: string;
}
fileName: string;
userId: string;
};
export const getUploadFileSignedURLFromS3 = async ({fileType, userInfo}: S3Upload) => {
const ex = fileType.split('/')[1];
const Key = `${userInfo}/${randomUUID()}.${ex}`;
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 = {
export const getUploadFileSignedURLFromS3 = async ({ fileName, fileType, userId }: S3Upload) => {
const key = getS3Key(fileName, userId);
const command = new PutObjectCommand({
Bucket: process.env.AWS_S3_FILES_BUCKET,
Key: key,
};
const command = new GetObjectCommand(s3Params);
ContentType: fileType,
});
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 });
};
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 { PaymentPlanId, paymentPlans } from '../payment/plans';
import { paymentProcessor } from './paymentProcessor';
import { HttpError } from 'wasp/server';
import { ensureArgsSchemaOrThrowHttpError } from '../server/validation';
export type CheckoutSession = {
sessionUrl: string | null;
sessionId: string;
};
export const generateCheckoutSession: GenerateCheckoutSession<PaymentPlanId, CheckoutSession> = async (
paymentPlanId,
context
) => {
const generateCheckoutSessionSchema = z.nativeEnum(PaymentPlanId);
type GenerateCheckoutSessionInput = z.infer<typeof generateCheckoutSessionSchema>;
export const generateCheckoutSession: GenerateCheckoutSession<
GenerateCheckoutSessionInput,
CheckoutSession
> = async (rawPaymentPlanId, context) => {
if (!context.user) {
throw new HttpError(401);
}
const paymentPlanId = ensureArgsSchemaOrThrowHttpError(generateCheckoutSessionSchema, rawPaymentPlanId);
const userId = context.user.id;
const userEmail = context.user.email;
if (!userEmail) {
@ -29,7 +36,7 @@ export const generateCheckoutSession: GenerateCheckoutSession<PaymentPlanId, Che
userId,
userEmail,
paymentPlan,
prismaUserDelegate: context.entities.User
prismaUserDelegate: context.entities.User,
});
return {

View File

@ -1,6 +1,13 @@
import * as z from 'zod';
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 {
Hobby = 'hobby',
@ -9,7 +16,7 @@ export enum PaymentPlanId {
}
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

@ -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 User } from 'wasp/entities';
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 { ensureArgsSchemaOrThrowHttpError } from '../server/validation';
export const updateIsUserAdminById: UpdateIsUserAdminById<Pick<User, 'id' | 'isAdmin'>, User> = async (
{ id, isAdmin },
const updateUserAdminByIdInputSchema = z.object({
id: z.string().nonempty(),
isAdmin: z.boolean(),
});
type UpdateUserAdminByIdInput = z.infer<typeof updateUserAdminByIdInputSchema>;
export const updateIsUserAdminById: UpdateIsUserAdminById<UpdateUserAdminByIdInput, User> = async (
rawArgs,
context
) => {
const { id, isAdmin } = ensureArgsSchemaOrThrowHttpError(updateUserAdminByIdInputSchema, rawArgs);
if (!context.user) {
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 = {
users: Pick<
User,
@ -39,8 +41,19 @@ type GetPaginatedUsersOutput = {
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 (
args,
rawArgs,
context
) => {
if (!context.user) {
@ -54,7 +67,8 @@ export const getPaginatedUsers: GetPaginatedUsers<GetPaginatedUsersInput, GetPag
const {
skipPages,
filter: { subscriptionStatusIn: subscriptionStatus, emailContains, isAdmin },
} = args;
} = ensureArgsSchemaOrThrowHttpError(getPaginatorArgsSchema, rawArgs);
const includeUnsubscribedUsers = !!subscriptionStatus?.some((status) => status === null);
const desiredSubscriptionStatuses = subscriptionStatus?.filter((status) => status !== null);