mirror of
https://github.com/wasp-lang/open-saas.git
synced 2025-07-25 04:12:14 +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,34 +170,7 @@ 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'
|
|
||||||
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>
|
|
||||||
</span>
|
</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'>
|
<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=''>USA</option>
|
||||||
@@ -205,22 +178,7 @@ const FormElements = ({ user }: { user: AuthUser }) => {
|
|||||||
<option value=''>Canada</option>
|
<option value=''>Canada</option>
|
||||||
</select>
|
</select>
|
||||||
<span className='absolute top-1/2 right-4 z-10 -translate-y-1/2'>
|
<span className='absolute top-1/2 right-4 z-10 -translate-y-1/2'>
|
||||||
<svg
|
<ChevronDownIcon />
|
||||||
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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</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'>
|
<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
|
Design
|
||||||
<span className='cursor-pointer pl-2 hover:text-danger'>
|
<span className='cursor-pointer pl-2 hover:text-danger'>
|
||||||
<svg
|
<XIcon />
|
||||||
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>
|
</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'>
|
<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
|
Development
|
||||||
<span className='cursor-pointer pl-2 hover:text-danger'>
|
<span className='cursor-pointer pl-2 hover:text-danger'>
|
||||||
<svg
|
<XIcon />
|
||||||
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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -277,22 +209,7 @@ const FormElements = ({ user }: { user: AuthUser }) => {
|
|||||||
<option value=''>Option</option>
|
<option value=''>Option</option>
|
||||||
</select>
|
</select>
|
||||||
<span className='absolute top-1/2 right-4 z-10 -translate-y-1/2'>
|
<span className='absolute top-1/2 right-4 z-10 -translate-y-1/2'>
|
||||||
<svg
|
<ChevronDownIcon />
|
||||||
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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -309,10 +226,65 @@ function SwitchExamples() {
|
|||||||
const [isSecondOn, setIsSecondOn] = useState<boolean>(false);
|
const [isSecondOn, setIsSecondOn] = useState<boolean>(false);
|
||||||
return (
|
return (
|
||||||
<div className='flex flex-col gap-5.5 p-6.5'>
|
<div className='flex flex-col gap-5.5 p-6.5'>
|
||||||
<SwitcherOne isOn={isFirstOn} onChange={() => setIsFirstOn(!isFirstOn)} />
|
<SwitcherOne isOn={isFirstOn} onChange={setIsFirstOn} />
|
||||||
<SwitcherTwo isOn={isSecondOn} onChange={() => setIsSecondOn(!isSecondOn)} />
|
<SwitcherTwo isOn={isSecondOn} onChange={setIsSecondOn} />
|
||||||
</div>
|
</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;
|
export default FormElements;
|
||||||
|
@@ -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