mirror of
https://github.com/wasp-lang/open-saas.git
synced 2025-07-28 05:42:15 +02:00
Merge branch 'filip-refactor-user-module' into filip-refactor-users-table
This commit is contained in:
@@ -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,
|
||||||
|
@@ -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;
|
|
@@ -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,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
@@ -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 (
|
||||||
|
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -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;
|
||||||
|
@@ -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 (
|
||||||
|
@@ -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,
|
||||||
|
@@ -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>
|
||||||
</>
|
</>
|
||||||
|
@@ -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);
|
||||||
|
}
|
||||||
|
@@ -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 });
|
||||||
};
|
};
|
||||||
|
@@ -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}`;
|
||||||
}
|
}
|
||||||
|
10
template/app/src/file-upload/validation.ts
Normal file
10
template/app/src/file-upload/validation.ts
Normal 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;
|
@@ -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 {
|
||||||
|
@@ -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',
|
||||||
|
15
template/app/src/server/validation.ts
Normal file
15
template/app/src/server/validation.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
@@ -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);
|
||||||
|
|
||||||
|
Reference in New Issue
Block a user