Organized 'users' functionality vertically. (#226)

This commit is contained in:
Martin Šošić 2024-07-11 12:57:30 +02:00 committed by GitHub
parent 0e4e76ae88
commit a348a85660
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 220 additions and 213 deletions

View File

@ -84,7 +84,7 @@
},
},
}
@@ -96,7 +94,10 @@
@@ -345,7 +343,10 @@
email String? @unique
username String? @unique
lastActiveTimestamp DateTime @default(now())

View File

@ -20,10 +20,10 @@
+ footerNavigation,
+ testimonials,
+} from './contentSections';
import DropdownUser from '../components/DropdownUser';
import DropdownUser from '../../user/DropdownUser';
import { UserMenuItems } from '../../user/UserMenuItems';
-import { DocsUrl } from '../../common';
+import { DocsUrl, GithubUrl } from '../../common';
import { UserMenuItems } from '../components/UserMenuItems';
import DarkModeSwitcher from '../admin/components/DarkModeSwitcher';
export default function LandingPage() {

View File

@ -1,6 +1,6 @@
--- template/app/src/server/queries.ts
+++ opensaas-sh/app/src/server/queries.ts
@@ -110,6 +110,7 @@
--- template/app/src/user/operations.ts
+++ opensaas-sh/app/src/user/operations.ts
@@ -80,6 +80,7 @@
mode: 'insensitive',
},
isAdmin: args.isAdmin,
@ -8,7 +8,7 @@
},
{
OR: [
@@ -150,6 +151,7 @@
@@ -120,6 +121,7 @@
mode: 'insensitive',
},
isAdmin: args.isAdmin,

View File

@ -26,7 +26,7 @@ To control which pages require users to be authenticated to access them, you can
route AccountRoute { path: "/account", to: AccountPage }
page AccountPage {
authRequired: true,
component: import Account from "@src/client/app/AccountPage"
component: import Account from "@src/user/AccountPage"
}
```

View File

@ -64,9 +64,9 @@ Go to https://dashboard.stripe.com/test/settings/billing/portal in the Stripe Da
REACT_APP_STRIPE_CUSTOMER_PORTAL=<your-test-customer-portal-link>
```
Your Stripe customer portal link is imported into `src/client/app/AccountPage.tsx` and used to redirect users to the Stripe customer portal when they click the `Manage Subscription` button.
Your Stripe customer portal link is imported into `src/user/AccountPage.tsx` and used to redirect users to the Stripe customer portal when they click the `Manage Subscription` button.
```tsx title="src/client/app/AccountPage.tsx" {5} "import.meta.env.REACT_APP_STRIPE_CUSTOMER_PORTAL"
```tsx title="src/user/AccountPage.tsx" {5} "import.meta.env.REACT_APP_STRIPE_CUSTOMER_PORTAL"
function CustomerPortalButton() {
const handleClick = () => {
try {
@ -115,4 +115,4 @@ You should see a message like this:
> Ready! You are using Stripe API Version [2023-08-16]. Your webhook signing secret is whsec_8a... (^C to quit)
```
copy this secret to your `.env.server` file under `STRIPE_WEBHOOK_SECRET=`.
copy this secret to your `.env.server` file under `STRIPE_WEBHOOK_SECRET=`.

View File

@ -63,11 +63,12 @@ If you are using a version of the OpenSaaS template with Wasp `v0.11.x` or below
│   ├── server/ # Your server code (NodeJS) goes here.
│   ├── auth/ # All auth-related pages/components and logic.
│   ├── file-upload/ # Logic for uploading files to S3.
│   └── payment/ # Logic for handling Stripe payments and webhooks.
│   ├── payment/ # Logic for handling Stripe payments and webhooks.
│   └── user/ # Logic related to users and their accounts.
├── .env.server # Dev environment variables for your server code.
├── .env.client # Dev environment variables for your client code.
├── .prettierrc # Prettier configuration.
├── tailwind.config.js # TailwindCSS configuration.
├── tailwind.config.js # TailwindCSS configuration.
├── package.json
├── package-lock.json
@ -131,7 +132,7 @@ All you have to do is define your server-side functions in the `main.wasp` file,
  ├── workers # Functions that run in the background as Wasp Jobs, e.g. daily stats calculation.
  ├── actions.ts # Your server-side write/mutation functions.
   ├── queries.ts # Your server-side read functions.
   └── utils.ts
   └── utils.ts
```
## Main Features

View File

@ -85,29 +85,6 @@ app OpenSaaS {
},
}
entity User {=psl
id String @id @default(uuid())
createdAt DateTime @default(now())
email String? @unique
username String? @unique
lastActiveTimestamp DateTime @default(now())
isAdmin Boolean @default(false)
stripeId String? @unique
checkoutSessionId String?
subscriptionStatus String? // 'active', 'canceled', 'past_due', 'deleted'
subscriptionPlan String? // 'hobby', 'pro'
sendEmail Boolean @default(false)
datePaid DateTime?
credits Int @default(3)
gptResponses GptResponse[]
contactFormMessages ContactFormMessage[]
tasks Task[]
files File[]
psl=}
entity GptResponse {=psl
id String @id @default(uuid())
createdAt DateTime @default(now())
@ -218,12 +195,6 @@ page DemoAppPage {
component: import DemoAppPage from "@src/client/app/DemoAppPage"
}
route AccountRoute { path: "/account", to: AccountPage }
page AccountPage {
authRequired: true,
component: import Account from "@src/client/app/AccountPage"
}
//#region Admin Pages
route AdminRoute { path: "/admin", to: DashboardPage }
page DashboardPage {
@ -306,16 +277,6 @@ action updateTask {
entities: [Task]
}
action updateCurrentUser {
fn: import { updateCurrentUser } from "@src/server/actions.js",
entities: [User]
}
action updateUserById {
fn: import { updateUserById } from "@src/server/actions.js",
entities: [User]
}
query getGptResponses {
fn: import { getGptResponses } from "@src/server/queries.js",
entities: [User, GptResponse]
@ -331,11 +292,6 @@ query getDailyStats {
entities: [User, DailyStats]
}
query getPaginatedUsers {
fn: import { getPaginatedUsers } from "@src/server/queries.js",
entities: [User]
}
job emailChecker {
executor: PgBoss,
perform: {
@ -359,6 +315,52 @@ job dailyStatsJob {
entities: [User, DailyStats, Logs, PageViewSource]
}
//#region User
entity User {=psl
id String @id @default(uuid())
createdAt DateTime @default(now())
email String? @unique
username String? @unique
lastActiveTimestamp DateTime @default(now())
isAdmin Boolean @default(false)
stripeId String? @unique
checkoutSessionId String?
subscriptionStatus String? // 'active', 'canceled', 'past_due', 'deleted'
subscriptionPlan String? // 'hobby', 'pro'
sendEmail Boolean @default(false)
datePaid DateTime?
credits Int @default(3)
gptResponses GptResponse[]
contactFormMessages ContactFormMessage[]
tasks Task[]
files File[]
psl=}
route AccountRoute { path: "/account", to: AccountPage }
page AccountPage {
authRequired: true,
component: import Account from "@src/user/AccountPage"
}
query getPaginatedUsers {
fn: import { getPaginatedUsers } from "@src/user/operations",
entities: [User]
}
action updateCurrentUser {
fn: import { updateCurrentUser } from "@src/user/operations",
entities: [User]
}
action updateUserById {
fn: import { updateUserById } from "@src/user/operations",
entities: [User]
}
//#endregion
//#region Payment
route PricingPageRoute { path: "/pricing", to: PricingPage }
page PricingPage {
@ -384,7 +386,6 @@ api stripeWebhook {
}
//#endregion
//#region File Upload
route FileUploadRoute { path: "/file-upload", to: FileUploadPage }
page FileUploadPage {

View File

@ -1,7 +1,7 @@
import { type AuthUser } from 'wasp/auth/types';
import DarkModeSwitcher from './DarkModeSwitcher';
import MessageButton from './MessageButton';
import DropdownUser from '../../components/DropdownUser';
import DropdownUser from '../../../user/DropdownUser';
import { cn } from '../../cn';
const Header = (props: {

View File

@ -6,10 +6,10 @@ import { BiLogIn } from 'react-icons/bi';
import { AiFillCloseCircle } from 'react-icons/ai';
import { HiBars3 } from 'react-icons/hi2';
import logo from '../static/logo.png';
import DropdownUser from './DropdownUser';
import DropdownUser from '../../user/DropdownUser';
import { UserMenuItems } from '../../user/UserMenuItems';
import { DocsUrl, BlogUrl } from '../../common';
import DarkModeSwitcher from '../admin/components/DarkModeSwitcher';
import { UserMenuItems } from '../components/UserMenuItems';
const navigation = [
{ name: 'AI Scheduler (Demo App)', href: routes.DemoAppRoute.build() },

View File

@ -8,9 +8,9 @@ import { BiLogIn } from 'react-icons/bi';
import logo from '../static/logo.png';
import openSaasBanner from '../static/open-saas-banner.png';
import { features, navigation, faqs, footerNavigation, testimonials } from './contentSections';
import DropdownUser from '../components/DropdownUser';
import DropdownUser from '../../user/DropdownUser';
import { UserMenuItems } from '../../user/UserMenuItems';
import { DocsUrl } from '../../common';
import { UserMenuItems } from '../components/UserMenuItems';
import DarkModeSwitcher from '../admin/components/DarkModeSwitcher';
export default function LandingPage() {

View File

@ -2,8 +2,6 @@ import { type User, type Task } from 'wasp/entities';
import { HttpError } from 'wasp/server';
import {
type GenerateGptResponse,
type UpdateCurrentUser,
type UpdateUserById,
type CreateTask,
type DeleteTask,
type UpdateTask,
@ -19,8 +17,6 @@ function setupOpenAI() {
return new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
}
type GptPayload = {
hours: string;
};
@ -221,38 +217,3 @@ export const deleteTask: DeleteTask<Pick<Task, 'id'>, Task> = async ({ id }, con
return task;
};
export const updateUserById: UpdateUserById<{ id: string; data: Partial<User> }, User> = async (
{ id, data },
context
) => {
if (!context.user) {
throw new HttpError(401);
}
if (!context.user.isAdmin) {
throw new HttpError(403);
}
const updatedUser = await context.entities.User.update({
where: {
id,
},
data,
});
return updatedUser;
};
export const updateCurrentUser: UpdateCurrentUser<Partial<User>, User> = async (user, context) => {
if (!context.user) {
throw new HttpError(401);
}
return context.entities.User.update({
where: {
id: context.user.id,
},
data: user,
});
};

View File

@ -3,10 +3,8 @@ import { HttpError } from 'wasp/server';
import {
type GetGptResponses,
type GetDailyStats,
type GetPaginatedUsers,
type GetAllTasksByUser,
} from 'wasp/server/operations';
import { type SubscriptionStatus } from '../payment/plans';
type DailyStatsWithSources = DailyStats & {
sources: PageViewSource[];
@ -71,107 +69,3 @@ export const getDailyStats: GetDailyStats<void, DailyStatsValues> = async (_args
return { dailyStats, weeklyStats };
};
type GetPaginatedUsersInput = {
skip: number;
cursor?: number | undefined;
emailContains?: string;
isAdmin?: boolean;
subscriptionStatus?: SubscriptionStatus[];
};
type GetPaginatedUsersOutput = {
users: Pick<
User,
'id' | 'email' | 'username' | 'lastActiveTimestamp' | 'subscriptionStatus' | 'stripeId'
>[];
totalPages: number;
};
export const getPaginatedUsers: GetPaginatedUsers<GetPaginatedUsersInput, GetPaginatedUsersOutput> = async (
args,
context
) => {
if (!context.user?.isAdmin) {
throw new HttpError(401);
}
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 queryResults = await context.entities.User.findMany({
skip: args.skip,
take: 10,
where: {
AND: [
{
email: {
contains: args.emailContains || undefined,
mode: 'insensitive',
},
isAdmin: args.isAdmin,
},
{
OR: [
{
subscriptionStatus: {
in: subscriptionStatusStrings,
},
},
{
subscriptionStatus: {
equals: hasNotSubscribed,
},
},
],
},
],
},
select: {
id: true,
email: true,
username: true,
isAdmin: true,
lastActiveTimestamp: true,
subscriptionStatus: true,
stripeId: true,
},
orderBy: {
id: 'desc',
},
});
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);
return {
users: queryResults,
totalPages,
};
};

View File

@ -1,6 +1,9 @@
import type { User } from 'wasp/entities';
import type { SubscriptionStatus } from '../../payment/plans';
import { prettyPaymentPlanName, parsePaymentPlanId } from '../../payment/plans';
import {
type SubscriptionStatus,
prettyPaymentPlanName,
parsePaymentPlanId
} from '../payment/plans';
import { Link } from 'wasp/client/router';
import { logout } from 'wasp/client/auth';
import { z } from 'zod';

View File

@ -2,7 +2,7 @@ import { type User } from 'wasp/entities';
import { useEffect, useRef, useState } from 'react';
import { CgProfile } from 'react-icons/cg';
import { UserMenuItems } from './UserMenuItems';
import { cn } from '../cn';
import { cn } from '../client/cn';
const DropdownUser = ({ user }: { user: Partial<User> }) => {
const [dropdownOpen, setDropdownOpen] = useState(false);

View File

@ -3,7 +3,7 @@ import { type User } from 'wasp/entities';
import { logout } from 'wasp/client/auth';
import { MdOutlineSpaceDashboard } from 'react-icons/md';
import { TfiDashboard } from 'react-icons/tfi';
import { cn } from '../cn';
import { cn } from '../client/cn';
export const UserMenuItems = ({ user, setMobileMenuOpen }: { user?: Partial<User>; setMobileMenuOpen?: any }) => {
const path = window.location.pathname;

View File

@ -0,0 +1,147 @@
import {
type UpdateCurrentUser,
type UpdateUserById,
type GetPaginatedUsers,
} from 'wasp/server/operations';
import { type User } from 'wasp/entities';
import { HttpError } from 'wasp/server';
import { type SubscriptionStatus } from '../payment/plans';
export const updateUserById: UpdateUserById<{ id: string; data: Partial<User> }, User> = async (
{ id, data },
context
) => {
if (!context.user) {
throw new HttpError(401);
}
if (!context.user.isAdmin) {
throw new HttpError(403);
}
const updatedUser = await context.entities.User.update({
where: {
id,
},
data,
});
return updatedUser;
};
export const updateCurrentUser: UpdateCurrentUser<Partial<User>, User> = async (user, context) => {
if (!context.user) {
throw new HttpError(401);
}
return context.entities.User.update({
where: {
id: context.user.id,
},
data: user,
});
};
type GetPaginatedUsersInput = {
skip: number;
cursor?: number | undefined;
emailContains?: string;
isAdmin?: boolean;
subscriptionStatus?: SubscriptionStatus[];
};
type GetPaginatedUsersOutput = {
users: Pick<
User,
'id' | 'email' | 'username' | 'lastActiveTimestamp' | 'subscriptionStatus' | 'stripeId'
>[];
totalPages: number;
};
export const getPaginatedUsers: GetPaginatedUsers<GetPaginatedUsersInput, GetPaginatedUsersOutput> = async (
args,
context
) => {
if (!context.user?.isAdmin) {
throw new HttpError(401);
}
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 queryResults = await context.entities.User.findMany({
skip: args.skip,
take: 10,
where: {
AND: [
{
email: {
contains: args.emailContains || undefined,
mode: 'insensitive',
},
isAdmin: args.isAdmin,
},
{
OR: [
{
subscriptionStatus: {
in: subscriptionStatusStrings,
},
},
{
subscriptionStatus: {
equals: hasNotSubscribed,
},
},
],
},
],
},
select: {
id: true,
email: true,
username: true,
isAdmin: true,
lastActiveTimestamp: true,
subscriptionStatus: true,
stripeId: true,
},
orderBy: {
id: 'desc',
},
});
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);
return {
users: queryResults,
totalPages,
};
};