mirror of
https://github.com/wasp-lang/open-saas.git
synced 2025-04-10 12:59:05 +02:00
Merge branch 'main' into miho-webhooks-runtime-validations
This commit is contained in:
commit
40afc5e25d
@ -104,7 +104,7 @@
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -212,9 +208,9 @@
|
||||
@@ -207,9 +203,9 @@
|
||||
}
|
||||
|
||||
api paymentsWebhook {
|
||||
|
@ -0,0 +1,11 @@
|
||||
--- template/app/migrations/20250220095333_remove_last_active_timestamp/migration.sql
|
||||
+++ opensaas-sh/app/migrations/20250220095333_remove_last_active_timestamp/migration.sql
|
||||
@@ -0,0 +1,8 @@
|
||||
+/*
|
||||
+ Warnings:
|
||||
+
|
||||
+ - You are about to drop the column `lastActiveTimestamp` on the `User` table. All the data in the column will be lost.
|
||||
+
|
||||
+*/
|
||||
+-- AlterTable
|
||||
+ALTER TABLE "User" DROP COLUMN "lastActiveTimestamp";
|
@ -1,9 +1,9 @@
|
||||
--- template/app/schema.prisma
|
||||
+++ opensaas-sh/app/schema.prisma
|
||||
@@ -14,10 +14,12 @@
|
||||
@@ -13,10 +13,12 @@
|
||||
|
||||
email String? @unique
|
||||
username String? @unique
|
||||
lastActiveTimestamp DateTime @default(now())
|
||||
- isAdmin Boolean @default(false)
|
||||
+ isAdmin Boolean @default(true)
|
||||
+ // isMockUser is an extra property for the demo app ensuring that all users can access
|
||||
|
@ -1,14 +1,6 @@
|
||||
--- template/app/src/admin/dashboards/users/UsersTable.tsx
|
||||
+++ opensaas-sh/app/src/admin/dashboards/users/UsersTable.tsx
|
||||
@@ -9,6 +9,7 @@
|
||||
const [skip, setskip] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [email, setEmail] = useState<string | undefined>(undefined);
|
||||
+
|
||||
const [isAdminFilter, setIsAdminFilter] = useState<boolean | undefined>(undefined);
|
||||
const [statusOptions, setStatusOptions] = useState<SubscriptionStatus[]>([]);
|
||||
const { data, isLoading, error } = useQuery(getPaginatedUsers, {
|
||||
@@ -222,7 +223,7 @@
|
||||
@@ -207,7 +207,7 @@
|
||||
<p className='text-sm text-black dark:text-white'>{user.subscriptionStatus}</p>
|
||||
</div>
|
||||
<div className='col-span-2 flex items-center'>
|
||||
|
@ -1,18 +1,19 @@
|
||||
--- template/app/src/analytics/stats.ts
|
||||
+++ opensaas-sh/app/src/analytics/stats.ts
|
||||
@@ -2,10 +2,8 @@
|
||||
@@ -2,11 +2,9 @@
|
||||
import { type DailyStatsJob } from 'wasp/server/jobs';
|
||||
import Stripe from 'stripe';
|
||||
import { stripe } from '../payment/stripe/stripeClient'
|
||||
import { stripe } from '../payment/stripe/stripeClient';
|
||||
-import { listOrders } from '@lemonsqueezy/lemonsqueezy.js';
|
||||
import { getDailyPageViews, getSources } from './providers/plausibleAnalyticsUtils';
|
||||
-// import { getDailyPageViews, getSources } from './providers/googleAnalyticsUtils';
|
||||
-import { paymentProcessor } from '../payment/paymentProcessor';
|
||||
+// import { getDailyPageViews, getSources } from './providers/googleAnalyticsUtils;
|
||||
import { SubscriptionStatus } from '../payment/plans';
|
||||
+// import { getDailyPageViews, getSources } from './providers/googleAnalyticsUtils';
|
||||
|
||||
export type DailyStatsProps = { dailyStats?: DailyStats; weeklyStats?: DailyStats[]; isLoading?: boolean };
|
||||
|
||||
@@ -41,17 +39,7 @@
|
||||
@@ -42,17 +40,7 @@
|
||||
paidUserDelta -= yesterdaysStats.paidUserCount;
|
||||
}
|
||||
|
||||
@ -27,11 +28,11 @@
|
||||
- default:
|
||||
- throw new Error(`Unsupported payment processor: ${paymentProcessor.id}`);
|
||||
- }
|
||||
+ let totalRevenue = await fetchTotalStripeRevenue()
|
||||
+ let totalRevenue = await fetchTotalStripeRevenue();
|
||||
|
||||
const { totalViews, prevDayViewsChangePercent } = await getDailyPageViews();
|
||||
|
||||
@@ -162,38 +150,3 @@
|
||||
@@ -163,38 +151,3 @@
|
||||
// Revenue is in cents so we convert to dollars (or your main currency unit)
|
||||
return totalRevenue / 100;
|
||||
}
|
||||
@ -70,4 +71,3 @@
|
||||
- throw error;
|
||||
- }
|
||||
-}
|
||||
\ No newline at end of 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,
|
||||
|
@ -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;
|
@ -7,7 +7,7 @@
|
||||
- lemonSqueezyCustomerPortalUrl: null,
|
||||
- paymentProcessorUserId: hasUserPaidOnStripe ? `cus_test_${faker.string.uuid()}` : null,
|
||||
+ stripeId: hasUserPaidOnStripe ? `cus_test_${faker.string.uuid()}` : null,
|
||||
datePaid: hasUserPaidOnStripe ? faker.date.between({ from: createdAt, to: lastActiveTimestamp }) : null,
|
||||
datePaid: hasUserPaidOnStripe ? faker.date.between({ from: createdAt, to: timePaid }) : null,
|
||||
subscriptionPlan: subscriptionStatus ? faker.helpers.arrayElement(getSubscriptionPaymentPlanIds()) : null,
|
||||
+ // For the demo app, we want to default isMockUser to true so that our admin dash only shows mock users
|
||||
+ // and not real users signing up to test the app
|
||||
|
@ -1,52 +1,31 @@
|
||||
--- template/app/src/user/operations.ts
|
||||
+++ opensaas-sh/app/src/user/operations.ts
|
||||
@@ -52,7 +52,10 @@
|
||||
subscriptionStatus?: SubscriptionStatus[];
|
||||
@@ -34,10 +34,7 @@
|
||||
};
|
||||
|
||||
type GetPaginatedUsersOutput = {
|
||||
- users: Pick<User, 'id' | 'email' | 'username' | 'lastActiveTimestamp' | 'subscriptionStatus' | 'paymentProcessorUserId'>[];
|
||||
+ users: Pick<
|
||||
+ User,
|
||||
+ 'id' | 'email' | 'username' | 'lastActiveTimestamp' | 'subscriptionStatus' | 'stripeId'
|
||||
+ >[];
|
||||
- users: Pick<
|
||||
- User,
|
||||
- 'id' | 'email' | 'username' | 'subscriptionStatus' | 'paymentProcessorUserId' | 'isAdmin'
|
||||
- >[];
|
||||
+ users: Pick<User, 'id' | 'email' | 'username' | 'subscriptionStatus' | 'stripeId' | 'isAdmin'>[];
|
||||
totalPages: number;
|
||||
};
|
||||
|
||||
@@ -65,8 +68,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,
|
||||
@@ -79,6 +84,7 @@
|
||||
@@ -85,6 +82,7 @@
|
||||
mode: 'insensitive',
|
||||
},
|
||||
isAdmin: args.isAdmin,
|
||||
isAdmin,
|
||||
+ isMockUser: true,
|
||||
},
|
||||
{
|
||||
OR: [
|
||||
@@ -103,7 +109,7 @@
|
||||
@@ -106,7 +104,7 @@
|
||||
username: true,
|
||||
isAdmin: true,
|
||||
lastActiveTimestamp: true,
|
||||
subscriptionStatus: true,
|
||||
- paymentProcessorUserId: true,
|
||||
+ stripeId: true,
|
||||
},
|
||||
orderBy: {
|
||||
id: 'desc',
|
||||
@@ -119,6 +125,7 @@
|
||||
mode: 'insensitive',
|
||||
},
|
||||
isAdmin: args.isAdmin,
|
||||
+ isMockUser: true,
|
||||
},
|
||||
{
|
||||
OR: [
|
||||
username: 'asc',
|
||||
|
@ -18,7 +18,6 @@ entity User {=psl
|
||||
email String? @unique
|
||||
username String?
|
||||
createdAt DateTime @default(now())
|
||||
lastActiveTimestamp DateTime @default(now())
|
||||
isAdmin Boolean @default(false)
|
||||
paymentProcessorUserId String? @unique
|
||||
lemonSqueezyCustomerPortalUrl String? // You can delete this if you're not using Lemon Squeezy as your payments processor.
|
||||
@ -116,7 +115,6 @@ entity User {=psl
|
||||
email String? @unique
|
||||
username String?
|
||||
createdAt DateTime @default(now())
|
||||
lastActiveTimestamp DateTime @default(now())
|
||||
isAdmin Boolean @default(false)
|
||||
//...
|
||||
psl=}
|
||||
|
@ -79,7 +79,7 @@ Authorization on the server-side is the core of your access control logic, and d
|
||||
You can authorize access to server-side operations by adding a check for a logged-in user on the `context.user` object which is passed to all operations in Wasp:
|
||||
|
||||
```tsx title="src/server/actions.ts"
|
||||
export const updateCurrentUser: UpdateCurrentUser<...> = async (args, context) => {
|
||||
export const someServerAction: SomeServerAction<...> = async (args, context) => {
|
||||
if (!context.user) {
|
||||
throw new HttpError(401); // throw an error if user is not logged in
|
||||
}
|
||||
|
@ -140,11 +140,6 @@ query getPaginatedUsers {
|
||||
entities: [User]
|
||||
}
|
||||
|
||||
action updateCurrentUserLastActiveTimestamp {
|
||||
fn: import { updateCurrentUserLastActiveTimestamp } from "@src/user/operations",
|
||||
entities: [User]
|
||||
}
|
||||
|
||||
action updateIsUserAdminById {
|
||||
fn: import { updateIsUserAdminById } from "@src/user/operations",
|
||||
entities: [User]
|
||||
|
@ -13,7 +13,6 @@ model User {
|
||||
|
||||
email String? @unique
|
||||
username String? @unique
|
||||
lastActiveTimestamp DateTime @default(now())
|
||||
isAdmin Boolean @default(false)
|
||||
|
||||
paymentProcessorUserId String? @unique
|
||||
@ -111,4 +110,4 @@ model ContactFormMessage {
|
||||
content String
|
||||
isRead Boolean @default(false)
|
||||
repliedAt DateTime?
|
||||
}
|
||||
}
|
||||
|
@ -1,33 +0,0 @@
|
||||
import { type User } from 'wasp/entities';
|
||||
import { useState } from 'react';
|
||||
import { cn } from '../../../client/cn';
|
||||
|
||||
const SwitcherOne = ({ user, updateIsUserAdminById }: { user?: Partial<User>; updateIsUserAdminById?: any }) => {
|
||||
const [enabled, setEnabled] = useState<boolean>(user?.isAdmin || false);
|
||||
|
||||
return (
|
||||
<div className='relative'>
|
||||
<label htmlFor={`toggle1-${user?.id}`} className='flex cursor-pointer select-none items-center'>
|
||||
<div className='relative'>
|
||||
<input
|
||||
type='checkbox'
|
||||
id={`toggle1-${user?.id}`}
|
||||
className='sr-only'
|
||||
onChange={() => {
|
||||
setEnabled(!enabled);
|
||||
updateIsUserAdminById && updateIsUserAdminById({ id: user?.id, data: { isAdmin: !enabled } });
|
||||
}}
|
||||
/>
|
||||
<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', {
|
||||
'!right-1 !translate-x-full !bg-primary dark:!bg-white': enabled,
|
||||
})}
|
||||
></div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SwitcherOne;
|
@ -1,30 +1,43 @@
|
||||
import { type SubscriptionStatus } from '../../../payment/plans';
|
||||
import { updateIsUserAdminById, useQuery, getPaginatedUsers } from 'wasp/client/operations';
|
||||
import { SubscriptionStatus } from '../../../payment/plans';
|
||||
import { useQuery, getPaginatedUsers } from 'wasp/client/operations';
|
||||
import { useState, useEffect } from 'react';
|
||||
import SwitcherOne from './SwitcherOne';
|
||||
import SwitcherOne from '../../elements/forms/SwitcherOne';
|
||||
import LoadingSpinner from '../../layout/LoadingSpinner';
|
||||
import DropdownEditDelete from './DropdownEditDelete';
|
||||
import { updateIsUserAdminById } from 'wasp/client/operations';
|
||||
import { type User } from 'wasp/entities';
|
||||
|
||||
function AdminSwitch({ id, isAdmin }: Pick<User, 'id' | 'isAdmin'>) {
|
||||
return (
|
||||
<SwitcherOne isOn={isAdmin} onChange={(value) => updateIsUserAdminById({ id: id, isAdmin: value })} />
|
||||
);
|
||||
}
|
||||
|
||||
const UsersTable = () => {
|
||||
const [skip, setskip] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [email, setEmail] = useState<string | undefined>(undefined);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [emailFilter, setEmailFilter] = useState<string | undefined>(undefined);
|
||||
const [isAdminFilter, setIsAdminFilter] = useState<boolean | undefined>(undefined);
|
||||
const [statusOptions, setStatusOptions] = useState<SubscriptionStatus[]>([]);
|
||||
const { data, isLoading, error } = useQuery(getPaginatedUsers, {
|
||||
skip,
|
||||
emailContains: email,
|
||||
isAdmin: isAdminFilter,
|
||||
subscriptionStatus: statusOptions?.length > 0 ? statusOptions : undefined,
|
||||
const [subscriptionStatusFilter, setSubcriptionStatusFilter] = useState<Array<SubscriptionStatus | null>>(
|
||||
[]
|
||||
);
|
||||
|
||||
const skipPages = currentPage - 1;
|
||||
|
||||
const { data, isLoading } = useQuery(getPaginatedUsers, {
|
||||
skipPages,
|
||||
filter: {
|
||||
...(emailFilter && { emailContains: emailFilter }),
|
||||
...(isAdminFilter !== undefined && { isAdmin: isAdminFilter }),
|
||||
...(subscriptionStatusFilter.length > 0 && { subscriptionStatusIn: subscriptionStatusFilter }),
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
}, [email, statusOptions]);
|
||||
|
||||
useEffect(() => {
|
||||
setskip((page - 1) * 10);
|
||||
}, [page]);
|
||||
useEffect(
|
||||
function backToPageOne() {
|
||||
setCurrentPage(1);
|
||||
},
|
||||
[emailFilter, subscriptionStatusFilter, isAdminFilter]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className='flex flex-col gap-4'>
|
||||
@ -41,7 +54,8 @@ const UsersTable = () => {
|
||||
id='email-filter'
|
||||
placeholder='dude@example.com'
|
||||
onChange={(e) => {
|
||||
setEmail(e.currentTarget.value);
|
||||
const value = e.currentTarget.value;
|
||||
setEmailFilter(value === '' ? undefined : value);
|
||||
}}
|
||||
className='rounded border border-stroke py-2 px-5 bg-white outline-none transition focus:border-primary active:border-primary disabled:cursor-default disabled:bg-whiter dark:border-form-strokedark dark:bg-form-input dark:focus:border-primary'
|
||||
/>
|
||||
@ -50,8 +64,8 @@ const UsersTable = () => {
|
||||
</label>
|
||||
<div className='flex-grow relative z-20 rounded border border-stroke pr-8 outline-none bg-white transition focus:border-primary active:border-primary dark:border-form-strokedark dark:bg-form-input'>
|
||||
<div className='flex items-center'>
|
||||
{!!statusOptions && statusOptions.length > 0 ? (
|
||||
statusOptions.map((opt, idx) => (
|
||||
{subscriptionStatusFilter.length > 0 ? (
|
||||
subscriptionStatusFilter.map((opt) => (
|
||||
<span
|
||||
key={opt}
|
||||
className='z-30 flex items-center my-1 mx-2 py-1 px-2 outline-none transition focus:border-primary active:border-primary disabled:cursor-default disabled:bg-whiter dark:border-form-strokedark dark:bg-form-input dark:focus:border-primary'
|
||||
@ -60,26 +74,13 @@ const UsersTable = () => {
|
||||
<span
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setStatusOptions((prevValue) => {
|
||||
setSubcriptionStatusFilter((prevValue) => {
|
||||
return prevValue?.filter((val) => val !== opt);
|
||||
});
|
||||
}}
|
||||
className='z-30 cursor-pointer pl-2 hover:text-danger'
|
||||
>
|
||||
<svg
|
||||
width='14'
|
||||
height='14'
|
||||
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>
|
||||
))
|
||||
@ -91,45 +92,37 @@ const UsersTable = () => {
|
||||
</div>
|
||||
<select
|
||||
onChange={(e) => {
|
||||
const targetValue = e.target.value === '' ? null : e.target.value;
|
||||
setStatusOptions((prevValue) => {
|
||||
if (prevValue?.includes(targetValue as SubscriptionStatus)) {
|
||||
return prevValue?.filter((val) => val !== targetValue);
|
||||
} else if (!!prevValue) {
|
||||
return [...prevValue, targetValue as SubscriptionStatus];
|
||||
} else {
|
||||
return prevValue;
|
||||
}
|
||||
});
|
||||
const selectedValue = e.target.value == 'has_not_subscribed' ? null : e.target.value;
|
||||
|
||||
console.log(selectedValue);
|
||||
if (selectedValue === 'clear-all') {
|
||||
setSubcriptionStatusFilter([]);
|
||||
} else {
|
||||
setSubcriptionStatusFilter((prevValue) => {
|
||||
if (prevValue.includes(selectedValue as SubscriptionStatus)) {
|
||||
return prevValue.filter((val) => val !== selectedValue);
|
||||
} else {
|
||||
return [...prevValue, selectedValue as SubscriptionStatus];
|
||||
}
|
||||
});
|
||||
}
|
||||
}}
|
||||
name='status-filter'
|
||||
id='status-filter'
|
||||
className='absolute top-0 left-0 z-20 h-full w-full bg-white opacity-0'
|
||||
>
|
||||
<option value=''>Select filters</option>
|
||||
{['past_due', 'canceled', 'active', 'deleted', null].map((status) => {
|
||||
if (!statusOptions.includes(status as SubscriptionStatus)) {
|
||||
return <option value={status || ''}>{status ? status : 'has not subscribed'}</option>;
|
||||
}
|
||||
})}
|
||||
<option value='select-filters'>Select filters</option>
|
||||
{[...Object.values(SubscriptionStatus), null]
|
||||
.filter((status) => !subscriptionStatusFilter.includes(status))
|
||||
.map((status) => {
|
||||
const extendedStatus = status ?? 'has_not_subscribed'
|
||||
return <option key={extendedStatus} value={extendedStatus}>
|
||||
{extendedStatus}
|
||||
</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 className='flex items-center gap-2'>
|
||||
@ -158,11 +151,14 @@ const UsersTable = () => {
|
||||
<span className='text-md mr-2 text-black dark:text-white'>page</span>
|
||||
<input
|
||||
type='number'
|
||||
value={page}
|
||||
min={1}
|
||||
defaultValue={currentPage}
|
||||
max={data?.totalPages}
|
||||
onChange={(e) => {
|
||||
setPage(parseInt(e.currentTarget.value));
|
||||
const value = parseInt(e.currentTarget.value);
|
||||
if (data?.totalPages && value <= data?.totalPages && value > 0) {
|
||||
setCurrentPage(value);
|
||||
}
|
||||
}}
|
||||
className='rounded-md border-1 border-stroke bg-transparent px-4 font-medium outline-none transition focus:border-primary active:border-primary dark:border-form-strokedark dark:bg-form-input dark:focus:border-primary'
|
||||
/>
|
||||
@ -172,13 +168,10 @@ const UsersTable = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='grid grid-cols-12 border-t-4 border-stroke py-4.5 px-4 dark:border-strokedark md:px-6 '>
|
||||
<div className='grid grid-cols-9 border-t-4 border-stroke py-4.5 px-4 dark:border-strokedark md:px-6 '>
|
||||
<div className='col-span-3 flex items-center'>
|
||||
<p className='font-medium'>Email / Username</p>
|
||||
</div>
|
||||
<div className='col-span-3 hidden items-center sm:flex'>
|
||||
<p className='font-medium'>Last Active</p>
|
||||
</div>
|
||||
<div className='col-span-2 flex items-center'>
|
||||
<p className='font-medium'>Subscription Status</p>
|
||||
</div>
|
||||
@ -202,7 +195,7 @@ const UsersTable = () => {
|
||||
data.users.map((user) => (
|
||||
<div
|
||||
key={user.id}
|
||||
className='grid grid-cols-12 gap-4 border-t border-stroke py-4.5 px-4 dark:border-strokedark md:px-6 '
|
||||
className='grid grid-cols-9 gap-4 border-t border-stroke py-4.5 px-4 dark:border-strokedark md:px-6 '
|
||||
>
|
||||
<div className='col-span-3 flex items-center'>
|
||||
<div className='flex flex-col gap-1 '>
|
||||
@ -210,14 +203,6 @@ const UsersTable = () => {
|
||||
<p className='text-sm text-black dark:text-white'>{user.username}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='col-span-3 hidden items-center sm:flex'>
|
||||
<p className='text-sm text-black dark:text-white'>
|
||||
{user.lastActiveTimestamp.toLocaleDateString() +
|
||||
' ' +
|
||||
user.lastActiveTimestamp.toLocaleTimeString()}
|
||||
</p>
|
||||
</div>
|
||||
<div className='col-span-2 flex items-center'>
|
||||
<p className='text-sm text-black dark:text-white'>{user.subscriptionStatus}</p>
|
||||
</div>
|
||||
@ -226,7 +211,7 @@ const UsersTable = () => {
|
||||
</div>
|
||||
<div className='col-span-1 flex items-center'>
|
||||
<div className='text-sm text-black dark:text-white'>
|
||||
<SwitcherOne user={user} updateIsUserAdminById={updateIsUserAdminById} />
|
||||
<AdminSwitch {...user} />
|
||||
</div>
|
||||
</div>
|
||||
<div className='col-span-1 flex items-center'>
|
||||
@ -239,4 +224,32 @@ const UsersTable = () => {
|
||||
);
|
||||
};
|
||||
|
||||
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='14' height='14' 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 UsersTable;
|
||||
|
@ -2,9 +2,10 @@ import { type AuthUser } from 'wasp/auth';
|
||||
import Breadcrumb from '../../layout/Breadcrumb';
|
||||
import DefaultLayout from '../../layout/DefaultLayout';
|
||||
import CheckboxOne from './CheckboxOne';
|
||||
import SwitcherOne from '../../dashboards/users/SwitcherOne';
|
||||
import SwitcherTwo from './SwitcherTwo';
|
||||
import SwitcherOne from './SwitcherOne';
|
||||
import { useRedirectHomeUnlessUserIsAdmin } from '../../useRedirectHomeUnlessUserIsAdmin';
|
||||
import { useState } from 'react';
|
||||
|
||||
const FormElements = ({ user }: { user: AuthUser }) => {
|
||||
useRedirectHomeUnlessUserIsAdmin({ user });
|
||||
@ -56,10 +57,7 @@ const FormElements = ({ user }: { user: AuthUser }) => {
|
||||
<div className='border-b border-stroke py-4 px-6.5 dark:border-strokedark'>
|
||||
<h3 className='font-medium text-black dark:text-white'>Toggle switch input</h3>
|
||||
</div>
|
||||
<div className='flex flex-col gap-5.5 p-6.5'>
|
||||
<SwitcherOne />
|
||||
<SwitcherTwo />
|
||||
</div>
|
||||
<SwitchExamples />
|
||||
</div>
|
||||
|
||||
{/* <!-- Time and date --> */}
|
||||
@ -172,28 +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>
|
||||
@ -201,16 +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>
|
||||
@ -222,45 +190,26 @@ 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>
|
||||
<select name='' id='' className='absolute top-0 left-0 z-20 h-full w-full bg-transparent opacity-0'>
|
||||
<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>
|
||||
<ChevronDownIcon />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@ -272,4 +221,70 @@ const FormElements = ({ user }: { user: AuthUser }) => {
|
||||
);
|
||||
};
|
||||
|
||||
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'>
|
||||
<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;
|
||||
|
30
template/app/src/admin/elements/forms/SwitcherOne.tsx
Normal file
30
template/app/src/admin/elements/forms/SwitcherOne.tsx
Normal file
@ -0,0 +1,30 @@
|
||||
import { useId } from 'react';
|
||||
import { cn } from '../../../client/cn';
|
||||
|
||||
function SwitcherOne({ isOn, onChange }: { isOn: boolean; onChange: (value: boolean) => void }) {
|
||||
const id = useId();
|
||||
|
||||
return (
|
||||
<div className='relative'>
|
||||
<label htmlFor={id} className='flex cursor-pointer select-none items-center'>
|
||||
<div className='relative'>
|
||||
<input
|
||||
id={id}
|
||||
type='checkbox'
|
||||
className='sr-only'
|
||||
checked={isOn}
|
||||
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', {
|
||||
'!right-1 !translate-x-full !bg-primary dark:!bg-white': isOn,
|
||||
})}
|
||||
></div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SwitcherOne;
|
@ -1,66 +1,73 @@
|
||||
import { useState } from 'react';
|
||||
import { useId } from 'react';
|
||||
import { cn } from '../../../client/cn';
|
||||
|
||||
const SwitcherTwo = () => {
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
function SwitcherTwo({ isOn, onChange }: { isOn: boolean; onChange: (value: boolean) => void }) {
|
||||
const id = useId();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<label htmlFor='toggle3' 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'
|
||||
id={id}
|
||||
className='sr-only'
|
||||
onChange={() => {
|
||||
setEnabled(!enabled);
|
||||
}}
|
||||
checked={isOn}
|
||||
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(
|
||||
'dot absolute left-1 top-1 flex h-6 w-6 items-center justify-center rounded-full bg-white transition',
|
||||
{
|
||||
'!right-1 !translate-x-full !bg-primary dark:!bg-white': enabled,
|
||||
'!right-1 !translate-x-full !bg-primary dark:!bg-white': isOn,
|
||||
}
|
||||
)}
|
||||
>
|
||||
<span className={cn('hidden', { '!block': enabled })}>
|
||||
<svg
|
||||
className='fill-white dark:fill-black'
|
||||
width='11'
|
||||
height='8'
|
||||
viewBox='0 0 11 8'
|
||||
fill='none'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
>
|
||||
<path
|
||||
d='M10.0915 0.951972L10.0867 0.946075L10.0813 0.940568C9.90076 0.753564 9.61034 0.753146 9.42927 0.939309L4.16201 6.22962L1.58507 3.63469C1.40401 3.44841 1.11351 3.44879 0.932892 3.63584C0.755703 3.81933 0.755703 4.10875 0.932892 4.29224L0.932878 4.29225L0.934851 4.29424L3.58046 6.95832C3.73676 7.11955 3.94983 7.2 4.1473 7.2C4.36196 7.2 4.55963 7.11773 4.71406 6.9584L10.0468 1.60234C10.2436 1.4199 10.2421 1.1339 10.0915 0.951972ZM4.2327 6.30081L4.2317 6.2998C4.23206 6.30015 4.23237 6.30049 4.23269 6.30082L4.2327 6.30081Z'
|
||||
fill=''
|
||||
stroke=''
|
||||
strokeWidth='0.4'
|
||||
></path>
|
||||
</svg>
|
||||
<span className={cn('hidden', { '!block': isOn })}>
|
||||
<CheckIcon />
|
||||
</span>
|
||||
<span
|
||||
className={cn({
|
||||
hidden: enabled,
|
||||
})}
|
||||
>
|
||||
<svg
|
||||
className='h-4 w-4 stroke-current'
|
||||
fill='none'
|
||||
viewBox='0 0 24 24'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
>
|
||||
<path strokeLinecap='round' strokeLinejoin='round' strokeWidth='2' d='M6 18L18 6M6 6l12 12'></path>
|
||||
</svg>
|
||||
<span className={cn({ hidden: isOn })}>
|
||||
<XIcon />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const XIcon = () => {
|
||||
return (
|
||||
<svg
|
||||
className='h-4 w-4 stroke-current'
|
||||
fill='none'
|
||||
viewBox='0 0 24 24'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
>
|
||||
<path strokeLinecap='round' strokeLinejoin='round' strokeWidth='2' d='M6 18L18 6M6 6l12 12'></path>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
const CheckIcon = () => {
|
||||
return (
|
||||
<svg
|
||||
className='fill-white dark:fill-black'
|
||||
width='11'
|
||||
height='8'
|
||||
viewBox='0 0 11 8'
|
||||
fill='none'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
>
|
||||
<path
|
||||
d='M10.0915 0.951972L10.0867 0.946075L10.0813 0.940568C9.90076 0.753564 9.61034 0.753146 9.42927 0.939309L4.16201 6.22962L1.58507 3.63469C1.40401 3.44841 1.11351 3.44879 0.932892 3.63584C0.755703 3.81933 0.755703 4.10875 0.932892 4.29224L0.932878 4.29225L0.934851 4.29424L3.58046 6.95832C3.73676 7.11955 3.94983 7.2 4.1473 7.2C4.36196 7.2 4.55963 7.11773 4.71406 6.9584L10.0468 1.60234C10.2436 1.4199 10.2421 1.1339 10.0915 0.951972ZM4.2327 6.30081L4.2317 6.2998C4.23206 6.30015 4.23237 6.30049 4.23269 6.30082L4.2327 6.30081Z'
|
||||
fill=''
|
||||
stroke=''
|
||||
strokeWidth='0.4'
|
||||
></path>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export default SwitcherTwo;
|
||||
|
@ -1,11 +1,12 @@
|
||||
import { type DailyStats } from 'wasp/entities';
|
||||
import { type DailyStatsJob } from 'wasp/server/jobs';
|
||||
import Stripe from 'stripe';
|
||||
import { stripe } from '../payment/stripe/stripeClient'
|
||||
import { stripe } from '../payment/stripe/stripeClient';
|
||||
import { listOrders } from '@lemonsqueezy/lemonsqueezy.js';
|
||||
import { getDailyPageViews, getSources } from './providers/plausibleAnalyticsUtils';
|
||||
// import { getDailyPageViews, getSources } from './providers/googleAnalyticsUtils';
|
||||
import { paymentProcessor } from '../payment/paymentProcessor';
|
||||
import { SubscriptionStatus } from '../payment/plans';
|
||||
|
||||
export type DailyStatsProps = { dailyStats?: DailyStats; weeklyStats?: DailyStats[]; isLoading?: boolean };
|
||||
|
||||
@ -30,7 +31,7 @@ export const calculateDailyStats: DailyStatsJob<never, void> = async (_args, con
|
||||
// we don't want to count those users as current paying users
|
||||
const paidUserCount = await context.entities.User.count({
|
||||
where: {
|
||||
subscriptionStatus: 'active',
|
||||
subscriptionStatus: SubscriptionStatus.Active,
|
||||
},
|
||||
});
|
||||
|
||||
@ -196,4 +197,4 @@ async function fetchTotalLemonSqueezyRevenue() {
|
||||
console.error('Error fetching Lemon Squeezy revenue:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -8,7 +8,6 @@ import { routes } from 'wasp/client/router';
|
||||
import { Outlet, useLocation } from 'react-router-dom';
|
||||
import { useAuth } from 'wasp/client/auth';
|
||||
import { useIsLandingPage } from './hooks/useIsLandingPage';
|
||||
import { updateCurrentUserLastActiveTimestamp } from 'wasp/client/operations';
|
||||
|
||||
/**
|
||||
* use this component to wrap all child components
|
||||
@ -28,16 +27,6 @@ export default function App() {
|
||||
return location.pathname.startsWith('/admin');
|
||||
}, [location]);
|
||||
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
const lastSeenAt = new Date(user.lastActiveTimestamp);
|
||||
const today = new Date();
|
||||
if (today.getTime() - lastSeenAt.getTime() > 5 * 60 * 1000) {
|
||||
updateCurrentUserLastActiveTimestamp({ lastActiveTimestamp: today });
|
||||
}
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
useEffect(() => {
|
||||
if (location.hash) {
|
||||
const id = location.hash.replace('#', '');
|
||||
|
@ -1,3 +1,4 @@
|
||||
import * as z from 'zod';
|
||||
import type { Task, GptResponse } from 'wasp/entities';
|
||||
import type {
|
||||
GenerateGptResponse,
|
||||
@ -10,6 +11,8 @@ import type {
|
||||
import { HttpError } from 'wasp/server';
|
||||
import { GeneratedSchedule } from './schedule';
|
||||
import OpenAI from 'openai';
|
||||
import { SubscriptionStatus } from '../payment/plans';
|
||||
import { ensureArgsSchemaOrThrowHttpError } from '../server/validation';
|
||||
|
||||
const openai = setupOpenAI();
|
||||
function setupOpenAI() {
|
||||
@ -20,15 +23,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: {
|
||||
@ -51,8 +62,8 @@ export const generateGptResponse: GenerateGptResponse<GptPayload, GeneratedSched
|
||||
const hasCredits = context.user.credits > 0;
|
||||
const hasValidSubscription =
|
||||
!!context.user.subscriptionStatus &&
|
||||
context.user.subscriptionStatus !== 'deleted' &&
|
||||
context.user.subscriptionStatus !== 'past_due';
|
||||
context.user.subscriptionStatus !== SubscriptionStatus.Deleted &&
|
||||
context.user.subscriptionStatus !== SubscriptionStatus.PastDue;
|
||||
const canUserContinue = hasCredits || hasValidSubscription;
|
||||
|
||||
if (!canUserContinue) {
|
||||
@ -181,11 +192,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 +215,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 +243,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,
|
||||
|
@ -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>
|
||||
</>
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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 });
|
||||
};
|
||||
|
@ -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}`;
|
||||
}
|
||||
|
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,6 +1,6 @@
|
||||
import { useAuth } from 'wasp/client/auth';
|
||||
import { generateCheckoutSession, getCustomerPortalUrl, useQuery } from 'wasp/client/operations';
|
||||
import { PaymentPlanId, paymentPlans, prettyPaymentPlanName } from './plans';
|
||||
import { PaymentPlanId, paymentPlans, prettyPaymentPlanName, SubscriptionStatus } from './plans';
|
||||
import { AiFillCheckCircle } from 'react-icons/ai';
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
@ -40,7 +40,7 @@ const PricingPage = () => {
|
||||
const [isPaymentLoading, setIsPaymentLoading] = useState<boolean>(false);
|
||||
|
||||
const { data: user } = useAuth();
|
||||
const isUserSubscribed = !!user && !!user.subscriptionStatus && user.subscriptionStatus !== 'deleted';
|
||||
const isUserSubscribed = !!user && !!user.subscriptionStatus && user.subscriptionStatus !== SubscriptionStatus.Deleted;
|
||||
|
||||
const {
|
||||
data: customerPortalUrl,
|
||||
|
@ -2,7 +2,7 @@ import { type MiddlewareConfigFn, HttpError } from 'wasp/server';
|
||||
import { type PaymentsWebhook } from 'wasp/server/api';
|
||||
import { type PrismaClient } from '@prisma/client';
|
||||
import express from 'express';
|
||||
import { paymentPlans, PaymentPlanId } from '../plans';
|
||||
import { paymentPlans, PaymentPlanId, SubscriptionStatus } from '../plans';
|
||||
import { updateUserLemonSqueezyPaymentDetails } from './paymentDetails';
|
||||
import { getCustomer } from '@lemonsqueezy/lemonsqueezy.js';
|
||||
import crypto from 'crypto';
|
||||
@ -125,7 +125,7 @@ async function handleSubscriptionCreated(
|
||||
lemonSqueezyId,
|
||||
userId,
|
||||
subscriptionPlan: planId,
|
||||
subscriptionStatus: status,
|
||||
subscriptionStatus: status as SubscriptionStatus,
|
||||
datePaid: new Date(),
|
||||
},
|
||||
prismaUserDelegate
|
||||
@ -159,7 +159,7 @@ async function handleSubscriptionUpdated(
|
||||
lemonSqueezyId,
|
||||
userId,
|
||||
subscriptionPlan: planId,
|
||||
subscriptionStatus: status,
|
||||
subscriptionStatus: status as SubscriptionStatus,
|
||||
...(status === 'active' && { datePaid: new Date() }),
|
||||
},
|
||||
prismaUserDelegate
|
||||
@ -180,7 +180,8 @@ async function handleSubscriptionCancelled(
|
||||
{
|
||||
lemonSqueezyId,
|
||||
userId,
|
||||
subscriptionStatus: 'cancel_at_period_end', // cancel_at_period_end is the Stripe equivalent of LemonSqueezy's cancelled
|
||||
// cancel_at_period_end is the Stripe equivalent of LemonSqueezy's cancelled
|
||||
subscriptionStatus: 'cancel_at_period_end' as SubscriptionStatus,
|
||||
},
|
||||
prismaUserDelegate
|
||||
);
|
||||
@ -200,7 +201,8 @@ async function handleSubscriptionExpired(
|
||||
{
|
||||
lemonSqueezyId,
|
||||
userId,
|
||||
subscriptionStatus: 'deleted', // deleted is the Stripe equivalent of LemonSqueezy's expired
|
||||
// deleted is the Stripe equivalent of LemonSqueezy's expired
|
||||
subscriptionStatus: SubscriptionStatus.Deleted,
|
||||
},
|
||||
prismaUserDelegate
|
||||
);
|
||||
|
@ -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 {
|
||||
|
@ -1,6 +1,12 @@
|
||||
import * as z from 'zod';
|
||||
import { requireNodeEnvVar } from '../server/utils';
|
||||
|
||||
export type SubscriptionStatus = 'past_due' | 'cancel_at_period_end' | 'active' | 'deleted';
|
||||
export enum SubscriptionStatus {
|
||||
PastDue = 'past_due',
|
||||
CancelAtPeriodEnd = 'cancel_at_period_end',
|
||||
Active = 'active',
|
||||
Deleted = 'deleted',
|
||||
}
|
||||
|
||||
export enum PaymentPlanId {
|
||||
Hobby = 'hobby',
|
||||
@ -9,7 +15,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;
|
||||
|
@ -4,7 +4,7 @@ import { type PrismaClient } from '@prisma/client';
|
||||
import express from 'express';
|
||||
import type { Stripe } from 'stripe';
|
||||
import { stripe } from './stripeClient';
|
||||
import { paymentPlans, PaymentPlanId, type SubscriptionStatus, type PaymentPlanEffect } from '../plans';
|
||||
import { paymentPlans, PaymentPlanId, SubscriptionStatus, type PaymentPlanEffect } from '../plans';
|
||||
import { updateUserStripePaymentDetails } from './paymentDetails';
|
||||
import { emailSender } from 'wasp/server/email';
|
||||
import { assertUnreachable } from '../../shared/utils';
|
||||
@ -170,10 +170,12 @@ export async function handleCustomerSubscriptionUpdated(
|
||||
|
||||
// There are other subscription statuses, such as `trialing` that we are not handling and simply ignore
|
||||
// If you'd like to handle more statuses, you can add more cases above. Make sure to update the `SubscriptionStatus` type in `payment/plans.ts` as well
|
||||
if (subscription.status === 'active') {
|
||||
subscriptionStatus = subscription.cancel_at_period_end ? 'cancel_at_period_end' : 'active';
|
||||
} else if (subscription.status === 'past_due') {
|
||||
subscriptionStatus = 'past_due';
|
||||
if (subscription.status === SubscriptionStatus.Active) {
|
||||
subscriptionStatus = subscription.cancel_at_period_end
|
||||
? SubscriptionStatus.CancelAtPeriodEnd
|
||||
: SubscriptionStatus.Active;
|
||||
} else if (subscription.status === SubscriptionStatus.PastDue) {
|
||||
subscriptionStatus = SubscriptionStatus.PastDue;
|
||||
}
|
||||
if (subscriptionStatus) {
|
||||
const user = await updateUserStripePaymentDetails(
|
||||
@ -199,7 +201,10 @@ export async function handleCustomerSubscriptionDeleted(
|
||||
prismaUserDelegate: PrismaClient['user']
|
||||
) {
|
||||
const userStripeId = subscription.customer;
|
||||
return updateUserStripePaymentDetails({ userStripeId, subscriptionStatus: 'deleted' }, prismaUserDelegate);
|
||||
return updateUserStripePaymentDetails(
|
||||
{ userStripeId, subscriptionStatus: SubscriptionStatus.Deleted },
|
||||
prismaUserDelegate
|
||||
);
|
||||
}
|
||||
|
||||
type SubscsriptionItems = z.infer<typeof subscriptionItemsSchema>;
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { type User } from 'wasp/entities';
|
||||
import { faker } from '@faker-js/faker';
|
||||
import type { PrismaClient } from '@prisma/client';
|
||||
import { getSubscriptionPaymentPlanIds, type SubscriptionStatus } from '../../payment/plans';
|
||||
import { getSubscriptionPaymentPlanIds, SubscriptionStatus } from '../../payment/plans';
|
||||
|
||||
type MockUserData = Omit<User, 'id'>;
|
||||
|
||||
@ -11,9 +11,7 @@ type MockUserData = Omit<User, 'id'>;
|
||||
* For more info see: https://wasp.sh/docs/data-model/backends#seeding-the-database
|
||||
*/
|
||||
export async function seedMockUsers(prismaClient: PrismaClient) {
|
||||
await Promise.all(generateMockUsersData(50).map((data) =>
|
||||
prismaClient.user.create({ data }))
|
||||
);
|
||||
await Promise.all(generateMockUsersData(50).map((data) => prismaClient.user.create({ data })));
|
||||
}
|
||||
|
||||
function generateMockUsersData(numOfUsers: number): MockUserData[] {
|
||||
@ -23,24 +21,26 @@ function generateMockUsersData(numOfUsers: number): MockUserData[] {
|
||||
function generateMockUserData(): MockUserData {
|
||||
const firstName = faker.person.firstName();
|
||||
const lastName = faker.person.lastName();
|
||||
const subscriptionStatus = faker.helpers.arrayElement<SubscriptionStatus | null>(['active', 'cancel_at_period_end', 'past_due', 'deleted', null]);
|
||||
const subscriptionStatus = faker.helpers.arrayElement<SubscriptionStatus | null>([
|
||||
...Object.values(SubscriptionStatus),
|
||||
null,
|
||||
]);
|
||||
const now = new Date();
|
||||
const createdAt = faker.date.past({ refDate: now });
|
||||
const lastActiveTimestamp = faker.date.between({ from: createdAt, to: now });
|
||||
const timePaid = faker.date.between({ from: createdAt, to: now });
|
||||
const credits = subscriptionStatus ? 0 : faker.number.int({ min: 0, max: 10 });
|
||||
const hasUserPaidOnStripe = !!subscriptionStatus || credits > 3
|
||||
const hasUserPaidOnStripe = !!subscriptionStatus || credits > 3;
|
||||
return {
|
||||
email: faker.internet.email({ firstName, lastName }),
|
||||
username: faker.internet.userName({ firstName, lastName }),
|
||||
createdAt,
|
||||
lastActiveTimestamp,
|
||||
isAdmin: false,
|
||||
sendNewsletter: false,
|
||||
credits,
|
||||
subscriptionStatus,
|
||||
lemonSqueezyCustomerPortalUrl: null,
|
||||
paymentProcessorUserId: hasUserPaidOnStripe ? `cus_test_${faker.string.uuid()}` : null,
|
||||
datePaid: hasUserPaidOnStripe ? faker.date.between({ from: createdAt, to: lastActiveTimestamp }) : null,
|
||||
datePaid: hasUserPaidOnStripe ? faker.date.between({ from: createdAt, to: timePaid }) : null,
|
||||
subscriptionPlan: subscriptionStatus ? faker.helpers.arrayElement(getSubscriptionPaymentPlanIds()) : null,
|
||||
};
|
||||
}
|
||||
|
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,5 +1,5 @@
|
||||
import type { User } from 'wasp/entities';
|
||||
import { type SubscriptionStatus, prettyPaymentPlanName, parsePaymentPlanId } from '../payment/plans';
|
||||
import { SubscriptionStatus, prettyPaymentPlanName, parsePaymentPlanId } from '../payment/plans';
|
||||
import { getCustomerPortalUrl, useQuery } from 'wasp/client/operations';
|
||||
import { Link as WaspRouterLink, routes } from 'wasp/client/router';
|
||||
import { logout } from 'wasp/client/auth';
|
||||
@ -9,20 +9,26 @@ export default function AccountPage({ user }: { user: User }) {
|
||||
<div className='mt-10 px-6'>
|
||||
<div className='overflow-hidden border border-gray-900/10 shadow-lg sm:rounded-lg mb-4 lg:m-8 dark:border-gray-100/10'>
|
||||
<div className='px-4 py-5 sm:px-6 lg:px-8'>
|
||||
<h3 className='text-base font-semibold leading-6 text-gray-900 dark:text-white'>Account Information</h3>
|
||||
<h3 className='text-base font-semibold leading-6 text-gray-900 dark:text-white'>
|
||||
Account Information
|
||||
</h3>
|
||||
</div>
|
||||
<div className='border-t border-gray-900/10 dark:border-gray-100/10 px-4 py-5 sm:p-0'>
|
||||
<dl className='sm:divide-y sm:divide-gray-900/10 sm:dark:divide-gray-100/10'>
|
||||
{!!user.email && (
|
||||
<div className='py-4 sm:grid sm:grid-cols-3 sm:gap-4 sm:py-5 sm:px-6'>
|
||||
<dt className='text-sm font-medium text-gray-500 dark:text-white'>Email address</dt>
|
||||
<dd className='mt-1 text-sm text-gray-900 dark:text-gray-400 sm:col-span-2 sm:mt-0'>{user.email}</dd>
|
||||
<dd className='mt-1 text-sm text-gray-900 dark:text-gray-400 sm:col-span-2 sm:mt-0'>
|
||||
{user.email}
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
{!!user.username && (
|
||||
<div className='py-4 sm:grid sm:grid-cols-3 sm:gap-4 sm:py-5 sm:px-6'>
|
||||
<dt className='text-sm font-medium text-gray-500 dark:text-white'>Username</dt>
|
||||
<dd className='mt-1 text-sm text-gray-900 dark:text-gray-400 sm:col-span-2 sm:mt-0'>{user.username}</dd>
|
||||
<dd className='mt-1 text-sm text-gray-900 dark:text-gray-400 sm:col-span-2 sm:mt-0'>
|
||||
{user.username}
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
<div className='py-4 sm:grid sm:grid-cols-3 sm:gap-4 sm:py-5 sm:px-6'>
|
||||
@ -36,7 +42,9 @@ export default function AccountPage({ user }: { user: User }) {
|
||||
</div>
|
||||
<div className='py-4 sm:grid sm:grid-cols-3 sm:gap-4 sm:py-5 sm:px-6'>
|
||||
<dt className='text-sm font-medium text-gray-500 dark:text-white'>About</dt>
|
||||
<dd className='mt-1 text-sm text-gray-900 dark:text-gray-400 sm:col-span-2 sm:mt-0'>I'm a cool customer.</dd>
|
||||
<dd className='mt-1 text-sm text-gray-900 dark:text-gray-400 sm:col-span-2 sm:mt-0'>
|
||||
I'm a cool customer.
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
@ -60,31 +68,52 @@ type UserCurrentPaymentPlanProps = {
|
||||
credits: number;
|
||||
};
|
||||
|
||||
function UserCurrentPaymentPlan({ subscriptionPlan, subscriptionStatus, datePaid, credits }: UserCurrentPaymentPlanProps) {
|
||||
function UserCurrentPaymentPlan({
|
||||
subscriptionPlan,
|
||||
subscriptionStatus,
|
||||
datePaid,
|
||||
credits,
|
||||
}: UserCurrentPaymentPlanProps) {
|
||||
if (subscriptionStatus && subscriptionPlan && datePaid) {
|
||||
return (
|
||||
<>
|
||||
<dd className='mt-1 text-sm text-gray-900 dark:text-gray-400 sm:col-span-1 sm:mt-0'>{getUserSubscriptionStatusDescription({ subscriptionPlan, subscriptionStatus, datePaid })}</dd>
|
||||
{subscriptionStatus !== 'deleted' ? <CustomerPortalButton /> : <BuyMoreButton />}
|
||||
<dd className='mt-1 text-sm text-gray-900 dark:text-gray-400 sm:col-span-1 sm:mt-0'>
|
||||
{getUserSubscriptionStatusDescription({ subscriptionPlan, subscriptionStatus, datePaid })}
|
||||
</dd>
|
||||
{subscriptionStatus !== SubscriptionStatus.Deleted ? <CustomerPortalButton /> : <BuyMoreButton />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<dd className='mt-1 text-sm text-gray-900 dark:text-gray-400 sm:col-span-1 sm:mt-0'>Credits remaining: {credits}</dd>
|
||||
<dd className='mt-1 text-sm text-gray-900 dark:text-gray-400 sm:col-span-1 sm:mt-0'>
|
||||
Credits remaining: {credits}
|
||||
</dd>
|
||||
<BuyMoreButton />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function getUserSubscriptionStatusDescription({ subscriptionPlan, subscriptionStatus, datePaid }: { subscriptionPlan: string; subscriptionStatus: SubscriptionStatus; datePaid: Date }) {
|
||||
function getUserSubscriptionStatusDescription({
|
||||
subscriptionPlan,
|
||||
subscriptionStatus,
|
||||
datePaid,
|
||||
}: {
|
||||
subscriptionPlan: string;
|
||||
subscriptionStatus: SubscriptionStatus;
|
||||
datePaid: Date;
|
||||
}) {
|
||||
const planName = prettyPaymentPlanName(parsePaymentPlanId(subscriptionPlan));
|
||||
const endOfBillingPeriod = prettyPrintEndOfBillingPeriod(datePaid);
|
||||
return prettyPrintStatus(planName, subscriptionStatus, endOfBillingPeriod);
|
||||
}
|
||||
|
||||
function prettyPrintStatus(planName: string, subscriptionStatus: SubscriptionStatus, endOfBillingPeriod: string): string {
|
||||
function prettyPrintStatus(
|
||||
planName: string,
|
||||
subscriptionStatus: SubscriptionStatus,
|
||||
endOfBillingPeriod: string
|
||||
): string {
|
||||
const statusToMessage: Record<SubscriptionStatus, string> = {
|
||||
active: `${planName}`,
|
||||
past_due: `Payment for your ${planName} plan is past due! Please update your subscription payment information.`,
|
||||
@ -107,7 +136,10 @@ function prettyPrintEndOfBillingPeriod(date: Date) {
|
||||
function BuyMoreButton() {
|
||||
return (
|
||||
<div className='ml-4 flex-shrink-0 sm:col-span-1 sm:mt-0'>
|
||||
<WaspRouterLink to={routes.PricingPageRoute.to} className='font-medium text-sm text-indigo-600 dark:text-indigo-400 hover:text-indigo-500'>
|
||||
<WaspRouterLink
|
||||
to={routes.PricingPageRoute.to}
|
||||
className='font-medium text-sm text-indigo-600 dark:text-indigo-400 hover:text-indigo-500'
|
||||
>
|
||||
Buy More/Upgrade
|
||||
</WaspRouterLink>
|
||||
</div>
|
||||
@ -115,7 +147,11 @@ function BuyMoreButton() {
|
||||
}
|
||||
|
||||
function CustomerPortalButton() {
|
||||
const { data: customerPortalUrl, isLoading: isCustomerPortalUrlLoading, error: customerPortalUrlError } = useQuery(getCustomerPortalUrl);
|
||||
const {
|
||||
data: customerPortalUrl,
|
||||
isLoading: isCustomerPortalUrlLoading,
|
||||
error: customerPortalUrlError,
|
||||
} = useQuery(getCustomerPortalUrl);
|
||||
|
||||
const handleClick = () => {
|
||||
if (customerPortalUrlError) {
|
||||
@ -131,7 +167,11 @@ function CustomerPortalButton() {
|
||||
|
||||
return (
|
||||
<div className='ml-4 flex-shrink-0 sm:col-span-1 sm:mt-0'>
|
||||
<button onClick={handleClick} disabled={isCustomerPortalUrlLoading} className='font-medium text-sm text-indigo-600 hover:text-indigo-500 dark:text-indigo-400 dark:hover:text-indigo-300'>
|
||||
<button
|
||||
onClick={handleClick}
|
||||
disabled={isCustomerPortalUrlLoading}
|
||||
className='font-medium text-sm text-indigo-600 hover:text-indigo-500 dark:text-indigo-400 dark:hover:text-indigo-300'
|
||||
>
|
||||
Manage Subscription
|
||||
</button>
|
||||
</div>
|
||||
|
@ -1,96 +1,100 @@
|
||||
import {
|
||||
type UpdateCurrentUserLastActiveTimestamp,
|
||||
type UpdateIsUserAdminById,
|
||||
type GetPaginatedUsers,
|
||||
} from 'wasp/server/operations';
|
||||
import * as z from 'zod';
|
||||
import { type UpdateIsUserAdminById, type GetPaginatedUsers } from 'wasp/server/operations';
|
||||
import { type User } from 'wasp/entities';
|
||||
import { HttpError } from 'wasp/server';
|
||||
import { type SubscriptionStatus } from '../payment/plans';
|
||||
import { HttpError, prisma } from 'wasp/server';
|
||||
import { SubscriptionStatus } from '../payment/plans';
|
||||
import { type Prisma } from '@prisma/client';
|
||||
import { ensureArgsSchemaOrThrowHttpError } from '../server/validation';
|
||||
|
||||
export const updateIsUserAdminById: UpdateIsUserAdminById<{ id: string; data: Pick<User, 'isAdmin'> }, User> = async (
|
||||
{ id, data },
|
||||
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);
|
||||
throw new HttpError(401, 'Only authenticated users are allowed to perform this operation');
|
||||
}
|
||||
|
||||
if (!context.user.isAdmin) {
|
||||
throw new HttpError(403);
|
||||
}
|
||||
|
||||
const updatedUser = await context.entities.User.update({
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
data: {
|
||||
isAdmin: data.isAdmin,
|
||||
},
|
||||
});
|
||||
|
||||
return updatedUser;
|
||||
};
|
||||
|
||||
export const updateCurrentUserLastActiveTimestamp: UpdateCurrentUserLastActiveTimestamp<Pick<User, 'lastActiveTimestamp'>, User> = async ({ lastActiveTimestamp }, context) => {
|
||||
if (!context.user) {
|
||||
throw new HttpError(401);
|
||||
throw new HttpError(403, 'Only admins are allowed to perform this operation');
|
||||
}
|
||||
|
||||
return context.entities.User.update({
|
||||
where: {
|
||||
id: context.user.id,
|
||||
},
|
||||
data: {lastActiveTimestamp},
|
||||
where: { id },
|
||||
data: { isAdmin },
|
||||
});
|
||||
};
|
||||
|
||||
type GetPaginatedUsersInput = {
|
||||
skip: number;
|
||||
cursor?: number | undefined;
|
||||
emailContains?: string;
|
||||
isAdmin?: boolean;
|
||||
subscriptionStatus?: SubscriptionStatus[];
|
||||
};
|
||||
type GetPaginatedUsersOutput = {
|
||||
users: Pick<User, 'id' | 'email' | 'username' | 'lastActiveTimestamp' | 'subscriptionStatus' | 'paymentProcessorUserId'>[];
|
||||
users: Pick<
|
||||
User,
|
||||
'id' | 'email' | 'username' | 'subscriptionStatus' | 'paymentProcessorUserId' | 'isAdmin'
|
||||
>[];
|
||||
totalPages: number;
|
||||
};
|
||||
|
||||
const getPaginatorArgsSchema = z.object({
|
||||
skipPages: z.number(),
|
||||
filter: z.object({
|
||||
emailContains: z.string().nonempty().optional(),
|
||||
isAdmin: z.boolean().optional(),
|
||||
subscriptionStatusIn: z.array(z.nativeEnum(SubscriptionStatus).nullable()).optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
type GetPaginatedUsersInput = z.infer<typeof getPaginatorArgsSchema>;
|
||||
|
||||
export const getPaginatedUsers: GetPaginatedUsers<GetPaginatedUsersInput, GetPaginatedUsersOutput> = async (
|
||||
args,
|
||||
rawArgs,
|
||||
context
|
||||
) => {
|
||||
if (!context.user?.isAdmin) {
|
||||
throw new HttpError(401);
|
||||
if (!context.user) {
|
||||
throw new HttpError(401, 'Only authenticated users are allowed to perform this operation');
|
||||
}
|
||||
|
||||
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
|
||||
if (!context.user.isAdmin) {
|
||||
throw new HttpError(403, 'Only admins are allowed to perform this operation');
|
||||
}
|
||||
|
||||
const queryResults = await context.entities.User.findMany({
|
||||
skip: args.skip,
|
||||
take: 10,
|
||||
const {
|
||||
skipPages,
|
||||
filter: { subscriptionStatusIn: subscriptionStatus, emailContains, isAdmin },
|
||||
} = ensureArgsSchemaOrThrowHttpError(getPaginatorArgsSchema, rawArgs);
|
||||
|
||||
const includeUnsubscribedUsers = !!subscriptionStatus?.some((status) => status === null);
|
||||
const desiredSubscriptionStatuses = subscriptionStatus?.filter((status) => status !== null);
|
||||
|
||||
const pageSize = 10;
|
||||
|
||||
const userPageQuery: Prisma.UserFindManyArgs = {
|
||||
skip: skipPages * pageSize,
|
||||
take: pageSize,
|
||||
where: {
|
||||
AND: [
|
||||
{
|
||||
email: {
|
||||
contains: args.emailContains || undefined,
|
||||
contains: emailContains,
|
||||
mode: 'insensitive',
|
||||
},
|
||||
isAdmin: args.isAdmin,
|
||||
isAdmin,
|
||||
},
|
||||
{
|
||||
OR: [
|
||||
{
|
||||
subscriptionStatus: {
|
||||
in: subscriptionStatusStrings,
|
||||
in: desiredSubscriptionStatuses,
|
||||
},
|
||||
},
|
||||
{
|
||||
subscriptionStatus: {
|
||||
equals: hasNotSubscribed,
|
||||
},
|
||||
subscriptionStatus: includeUnsubscribedUsers ? null : undefined,
|
||||
},
|
||||
],
|
||||
},
|
||||
@ -101,46 +105,22 @@ export const getPaginatedUsers: GetPaginatedUsers<GetPaginatedUsersInput, GetPag
|
||||
email: true,
|
||||
username: true,
|
||||
isAdmin: true,
|
||||
lastActiveTimestamp: true,
|
||||
subscriptionStatus: true,
|
||||
paymentProcessorUserId: true,
|
||||
},
|
||||
orderBy: {
|
||||
id: 'desc',
|
||||
username: 'asc',
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const totalUserCount = await context.entities.User.count({
|
||||
where: {
|
||||
AND: [
|
||||
{
|
||||
email: {
|
||||
contains: args.emailContains || undefined,
|
||||
mode: 'insensitive',
|
||||
},
|
||||
isAdmin: args.isAdmin,
|
||||
},
|
||||
{
|
||||
OR: [
|
||||
{
|
||||
subscriptionStatus: {
|
||||
in: subscriptionStatusStrings,
|
||||
},
|
||||
},
|
||||
{
|
||||
subscriptionStatus: {
|
||||
equals: hasNotSubscribed,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
const totalPages = Math.ceil(totalUserCount / 10);
|
||||
const [pageOfUsers, totalUsers] = await prisma.$transaction([
|
||||
context.entities.User.findMany(userPageQuery),
|
||||
context.entities.User.count({ where: userPageQuery.where }),
|
||||
]);
|
||||
const totalPages = Math.ceil(totalUsers / pageSize);
|
||||
|
||||
return {
|
||||
users: queryResults,
|
||||
users: pageOfUsers,
|
||||
totalPages,
|
||||
};
|
||||
};
|
||||
|
Loading…
x
Reference in New Issue
Block a user