diff --git a/.env.server.example b/.env.server.example index 58df3fe..66dcfff 100644 --- a/.env.server.example +++ b/.env.server.example @@ -1,4 +1,6 @@ -# NOTE: if you setup your DB using `wasp start db` then you DO NOT need to add a DATABASE_URL env. +# NOTE: you can let Wasp set up your Postgres DB by running `wasp start db` in a separate terminal window. +# then, in a new terminal window, run `wasp db migrate-dev` and finally `wasp start`. +# If you use `wasp start db` then you DO NOT need to add a DATABASE_URL env variable here. # DATABASE_URL= # for testing, go to https://dashboard.stripe.com/test/apikeys and get a test stripe key that starts with "sk_test_..." @@ -10,10 +12,10 @@ SUBSCRIPTION_PRICE_ID= GOOGLE_CLIENT_ID= GOOGLE_CLIENT_SECRET= -# (OPTIONAL) get your openai api key at https://platform.openai.com/account -OPENAI_API_KEY= - # get your sendgrid api key at https://app.sendgrid.com/settings/api_keys SENDGRID_API_KEY= # if not explicitly set to true, emails be logged to console but not actually sent SEND_EMAILS_IN_DEVELOPMENT=true + +# (OPTIONAL) get your openai api key at https://platform.openai.com/account +OPENAI_API_KEY= \ No newline at end of file diff --git a/main.wasp b/main.wasp index d42fa01..54df322 100644 --- a/main.wasp +++ b/main.wasp @@ -5,9 +5,13 @@ app SaaSTemplate { title: "My SaaS App", head: [ "", - "", + "", // TODO change url "", "", + "", // TODO change url and image + "", + "", + "", // you can put your google analytics script here, too! ], // 🔐 Auth out of the box! https://wasp-lang.dev/docs/auth/overview @@ -38,7 +42,11 @@ app SaaSTemplate { onAuthFailedRedirectTo: "/", }, db: { - system: PostgreSQL + system: PostgreSQL, + seeds: [ + import { devSeedUsers } from "@server/scripts/usersSeed.js", + + ] }, client: { rootComponent: import App from "@client/App", @@ -48,7 +56,7 @@ app SaaSTemplate { defaultFrom: { name: "SaaS App", // make sure this address is the same you registered your SendGrid or MailGun account with! - email: "vince@wasp-lang.dev" + email: "vince@wasp-lang.dev" // TODO change to generic email before pushing to github }, }, dependencies: [ @@ -66,7 +74,7 @@ app SaaSTemplate { ("react-apexcharts", "^1.4.1"), ("apexcharts", "^3.41.0"), ("headlessui", "^0.0.0"), - + ("@faker-js/faker", "8.3.1") ], } @@ -84,7 +92,6 @@ entity User {=psl isEmailVerified Boolean @default(false) emailVerificationSentAt DateTime? passwordResetSentAt DateTime? - referrer String @default("unknown") stripeId String? checkoutSessionId String? hasPaid Boolean @default(false) @@ -95,6 +102,8 @@ entity User {=psl relatedObject RelatedObject[] externalAuthAssociations SocialLogin[] contactFormMessages ContactFormMessage[] + referrer Referrer? @relation(fields: [referrerId], references: [id]) + referrerId Int? psl=} entity SocialLogin {=psl @@ -138,6 +147,20 @@ entity DailyStats {=psl totalProfit Int @default(0) psl=} +entity Referrer {=psl + id Int @id @default(autoincrement()) + name String @default("unknown") @unique + count Int @default(0) + users User[] +psl=} + +entity Logs {=psl + id Int @id @default(autoincrement()) + createdAt DateTime @default(now()) + message String + level String +psl=} + /* 📡 These are the Wasp Routes (You can protect them easily w/ 'authRequired: true'); * https://wasp-lang.dev/docs/tutorial/pages */ @@ -174,24 +197,24 @@ page EmailVerificationPage { route GptRoute { path: "/gpt", to: GptPage } page GptPage { - component: import GptPage from "@client/GptPage" + component: import GptPage from "@client/app/GptPage" } route PricingRoute { path: "/pricing", to: PricingPage } page PricingPage { - component: import Pricing from "@client/PricingPage" + component: import Pricing from "@client/app/PricingPage" } route AccountRoute { path: "/account", to: AccountPage } page AccountPage { authRequired: true, - component: import Account from "@client/AccountPage" + component: import Account from "@client/app/AccountPage" } route CheckoutRoute { path: "/checkout", to: CheckoutPage } page CheckoutPage { authRequired: true, - component: import Checkout from "@client/CheckoutPage" + component: import Checkout from "@client/app/CheckoutPage" } route AdminRoute { path: "/admin", to: DashboardPage } @@ -269,11 +292,26 @@ action stripePayment { // entities: [User] // } -action updateUser { - fn: import { updateUser } from "@server/actions.js", +action updateCurrentUser { + fn: import { updateCurrentUser } from "@server/actions.js", entities: [User] } +action updateUserById { + fn: import { updateUserById } from "@server/actions.js", + entities: [User] +} + +action saveReferrer { + fn: import { saveReferrer } from "@server/actions.js", + entities: [Referrer] +} + +action UpdateUserReferrer { + fn: import { updateUserReferrer } from "@server/actions.js", + entities: [User, Referrer] +} + // 📚 Queries query getRelatedObjects { @@ -286,6 +324,16 @@ query getDailyStats { entities: [User, DailyStats] } +query getReferrerStats { + fn: import { getReferrerStats } from "@server/queries.js", + entities: [User, Referrer] +} + +query getPaginatedUsers { + fn: import { getPaginatedUsers } from "@server/queries.js", + entities: [User] +} + /* * 📡 These are custom Wasp API Endpoints. Use them for callbacks, webhooks, etc. * https://wasp-lang.dev/docs/advanced/apis @@ -318,7 +366,7 @@ job dailyStats { fn: import { calculateDailyStats } from "@server/workers/calculateDailyStats.js" }, schedule: { - cron: "0 * * * *" // every hour + cron: "* * * * *" // }, - entities: [User, DailyStats] + entities: [User, DailyStats, Logs] } diff --git a/migrations/20231115134355_logs/migration.sql b/migrations/20231115134355_logs/migration.sql new file mode 100644 index 0000000..fc655a1 --- /dev/null +++ b/migrations/20231115134355_logs/migration.sql @@ -0,0 +1,9 @@ +-- CreateTable +CREATE TABLE "Logs" ( + "id" SERIAL NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "message" TEXT NOT NULL, + "level" TEXT NOT NULL, + + CONSTRAINT "Logs_pkey" PRIMARY KEY ("id") +); diff --git a/migrations/20231115161051_referrer_object/migration.sql b/migrations/20231115161051_referrer_object/migration.sql new file mode 100644 index 0000000..c66eea3 --- /dev/null +++ b/migrations/20231115161051_referrer_object/migration.sql @@ -0,0 +1,24 @@ +/* + Warnings: + + - You are about to drop the column `referrer` on the `User` table. All the data in the column will be lost. + +*/ +-- AlterTable +ALTER TABLE "User" DROP COLUMN "referrer"; + +-- CreateTable +CREATE TABLE "Referrer" ( + "id" SERIAL NOT NULL, + "referrer" TEXT NOT NULL DEFAULT 'unknown', + "count" INTEGER NOT NULL DEFAULT 0, + "userId" INTEGER NOT NULL, + + CONSTRAINT "Referrer_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "Referrer_userId_key" ON "Referrer"("userId"); + +-- AddForeignKey +ALTER TABLE "Referrer" ADD CONSTRAINT "Referrer_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/migrations/20231115161149_unique_referrer_name/migration.sql b/migrations/20231115161149_unique_referrer_name/migration.sql new file mode 100644 index 0000000..8a6097b --- /dev/null +++ b/migrations/20231115161149_unique_referrer_name/migration.sql @@ -0,0 +1,8 @@ +/* + Warnings: + + - A unique constraint covering the columns `[referrer]` on the table `Referrer` will be added. If there are existing duplicate values, this will fail. + +*/ +-- CreateIndex +CREATE UNIQUE INDEX "Referrer_referrer_key" ON "Referrer"("referrer"); diff --git a/migrations/20231115161339_referrer_again/migration.sql b/migrations/20231115161339_referrer_again/migration.sql new file mode 100644 index 0000000..67c26f6 --- /dev/null +++ b/migrations/20231115161339_referrer_again/migration.sql @@ -0,0 +1,20 @@ +/* + Warnings: + + - You are about to drop the column `userId` on the `Referrer` table. All the data in the column will be lost. + +*/ +-- DropForeignKey +ALTER TABLE "Referrer" DROP CONSTRAINT "Referrer_userId_fkey"; + +-- DropIndex +DROP INDEX "Referrer_userId_key"; + +-- AlterTable +ALTER TABLE "Referrer" DROP COLUMN "userId"; + +-- AlterTable +ALTER TABLE "User" ADD COLUMN "referrerId" INTEGER; + +-- AddForeignKey +ALTER TABLE "User" ADD CONSTRAINT "User_referrerId_fkey" FOREIGN KEY ("referrerId") REFERENCES "Referrer"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/migrations/20231115161543_referrer_name/migration.sql b/migrations/20231115161543_referrer_name/migration.sql new file mode 100644 index 0000000..cdabebb --- /dev/null +++ b/migrations/20231115161543_referrer_name/migration.sql @@ -0,0 +1,16 @@ +/* + Warnings: + + - You are about to drop the column `referrer` on the `Referrer` table. All the data in the column will be lost. + - A unique constraint covering the columns `[name]` on the table `Referrer` will be added. If there are existing duplicate values, this will fail. + +*/ +-- DropIndex +DROP INDEX "Referrer_referrer_key"; + +-- AlterTable +ALTER TABLE "Referrer" DROP COLUMN "referrer", +ADD COLUMN "name" TEXT NOT NULL DEFAULT 'unknown'; + +-- CreateIndex +CREATE UNIQUE INDEX "Referrer_name_key" ON "Referrer"("name"); diff --git a/src/client/App.tsx b/src/client/App.tsx index aba110f..8314479 100644 --- a/src/client/App.tsx +++ b/src/client/App.tsx @@ -1,10 +1,12 @@ import './Main.css'; -import NavBar from './NavBar'; +import AppNavBar from './components/AppNavBar'; import { useMemo, useEffect, ReactNode } from 'react'; import { useLocation } from 'react-router-dom'; import { useReferrer, UNKOWN_REFERRER } from './hooks/useReferrer'; import useAuth from '@wasp/auth/useAuth'; -import updateUser from '@wasp/actions/updateUser.js'; +import updateCurrentUser from '@wasp/actions/updateCurrentUser'; // TODO fix +import updateUserReferrer from '@wasp/actions/UpdateUserReferrer'; +import saveReferrer from '@wasp/actions/saveReferrer'; /** * use this component to wrap all child components @@ -16,7 +18,7 @@ export default function App({ children }: { children: ReactNode }) { const [referrer, setReferrer] = useReferrer(); const shouldDisplayAppNavBar = useMemo(() => { - return location.pathname !== '/'; + return location.pathname !== '/' && location.pathname !== '/login' && location.pathname !== '/signup'; }, [location]); const isAdminDashboard = useMemo(() => { @@ -28,14 +30,27 @@ export default function App({ children }: { children: ReactNode }) { const lastSeenAt = new Date(user.lastActiveTimestamp); const today = new Date(); if (lastSeenAt.getDate() === today.getDate()) return; - updateUser({ lastActiveTimestamp: today }); + updateCurrentUser({ lastActiveTimestamp: today }); } }, [user]); useEffect(() => { - if (user && referrer && referrer !== UNKOWN_REFERRER) { - updateUser({ referrer }); - setReferrer(null); + if (referrer && referrer.ref !== UNKOWN_REFERRER && !referrer.isSavedInDB) { + saveReferrer({ name: referrer.ref }); + setReferrer({ + ...referrer, + isSavedInDB: true, + }); + } + }, [referrer]); + + useEffect(() => { + if (user && referrer && !referrer.isSavedToUser && referrer.ref !== UNKOWN_REFERRER) { + updateUserReferrer({ name: referrer.ref }); + setReferrer({ + ...referrer, + isSavedToUser: true, + }); } }, [user, referrer]); @@ -45,7 +60,7 @@ export default function App({ children }: { children: ReactNode }) { <>{children} ) : ( <> - {shouldDisplayAppNavBar && } + {shouldDisplayAppNavBar && }
{children}
)} diff --git a/src/client/MainPage.tsx b/src/client/MainPage.tsx deleted file mode 100644 index 681e5e8..0000000 --- a/src/client/MainPage.tsx +++ /dev/null @@ -1,153 +0,0 @@ -export default function MainPage() { - return ( -
-
-
-
-
-

SaaS Template

- -

- for the PERN stack -

-

- Postgres/Prisma, Express, React, Node -

- -

- Hey 🧙‍♂️! This template will help you get a SaaS App up and running in no time. It's got: -

-
    -
  • Stripe integration
  • -
  • Authentication w/ Google
  • -
  • OpenAI GPT API configuration
  • -
  • Managed Server-Side Routes
  • -
  • Tailwind styling
  • -
  • Client-side Caching
  • -
  • - One-command{' '} - - Deploy 🚀 - -
  • -
-

- Make sure to check out the README.md file and add your env variables before - you begin -

-
- Made with Wasp   {' = }'} - - Read the Wasp Docs - -
-
-
-
-
-
-
- -
-
-
-
- ); -} diff --git a/src/client/admin/common/types.ts b/src/client/admin/common/types.ts index 58748c9..eeb3d81 100644 --- a/src/client/admin/common/types.ts +++ b/src/client/admin/common/types.ts @@ -1,3 +1,3 @@ import { DailyStats } from "@wasp/entities"; -export type DailyStatsProps = { dailyStats?: DailyStats; weeklyStats?:DailyStats[], isLoading?: Boolean } \ No newline at end of file +export type DailyStatsProps = { dailyStats?: DailyStats; weeklyStats?:DailyStats[], isLoading?: boolean } \ No newline at end of file diff --git a/src/client/admin/components/Header.tsx b/src/client/admin/components/Header.tsx index 2571d5a..24189af 100644 --- a/src/client/admin/components/Header.tsx +++ b/src/client/admin/components/Header.tsx @@ -1,9 +1,6 @@ -import { useEffect, useState } from 'react'; -import { Link } from 'react-router-dom'; -import Logo from '../images/logo/logo-icon.svg'; import DarkModeSwitcher from './DarkModeSwitcher'; import DropdownMessage from './DropdownMessage'; -import DropdownUser from '../../common/DropdownUser'; +import DropdownUser from '../../components/DropdownUser'; import type { User } from '@wasp/entities' const Header = (props: { @@ -11,18 +8,11 @@ const Header = (props: { setSidebarOpen: (arg0: boolean) => void; user?: Omit | null | undefined; }) => { - // const [username, setUsername] = useState(undefined) - // useEffect(() => { - // if (props.user) { - // setUsername(props.user?.email?.split('@')[0]) - // } - // }, [props.user]) - return (
-
+
- {/* */} + {/* */} // TODO check mobile views {/* */} - - Logo -
-
-
-
- - - -
-
-
+ {/*
+
*/}
    @@ -118,7 +72,7 @@ const Header = (props: {
{/* */} - + {/* */}
diff --git a/src/client/admin/components/ReferrerTable.tsx b/src/client/admin/components/ReferrerTable.tsx index cd8009b..994755e 100644 --- a/src/client/admin/components/ReferrerTable.tsx +++ b/src/client/admin/components/ReferrerTable.tsx @@ -1,173 +1,54 @@ -import BrandOne from '../images/brand/brand-01.svg'; -import BrandTwo from '../images/brand/brand-02.svg'; -import BrandThree from '../images/brand/brand-03.svg'; -import BrandFour from '../images/brand/brand-04.svg'; -import BrandFive from '../images/brand/brand-05.svg'; +import { useQuery } from '@wasp/queries'; +import getReferrerStats from '@wasp/queries/getReferrerStats'; +// We're using a simple, in-house analytics system that tracks referrers and page views. +// You could instead set up Google Analytics or Plausible and use their API for more detailed stats. const ReferrerTable = () => { + const { data: referrers, isLoading: isReferrersLoading, error: referrersError } = useQuery(getReferrerStats); + return ( -
-

- Top Channels -

+
+ +

Top Referrers

-
-
-
-
- Source -
+
+
+
+
Source
-
-
- Visitors -
+
+
Visitors
-
-
- Revenues -
+
+
Conversion
+ % of visitors that register
-
-
- Sales -
-
-
-
- Conversion -
+
+
Sales
-
-
-
- Brand + {referrers && + referrers.length > 0 && + referrers.map((ref) => ( +
+
+

{ref.name}

+
+ +
+

{ref.count}

+
+ +
+

{ref.users.length > 0 ? Math.round((ref.users.length / ref.count)*100) : '0'}%

+
+ +
+

--

+
-

Google

-
- -
-

3.5K

-
- -
-

$5,768

-
- -
-

590

-
- -
-

4.8%

-
-
- -
-
-
- Brand -
-

- Twitter -

-
- -
-

2.2K

-
- -
-

$4,635

-
- -
-

467

-
- -
-

4.3%

-
-
- -
-
-
- Brand -
-

Github

-
- -
-

2.1K

-
- -
-

$4,290

-
- -
-

420

-
- -
-

3.7%

-
-
- -
-
-
- Brand -
-

Vimeo

-
- -
-

1.5K

-
- -
-

$3,580

-
- -
-

389

-
- -
-

2.5%

-
-
- -
-
-
- Brand -
-

- Facebook -

-
- -
-

1.2K

-
- -
-

$2,740

-
- -
-

230

-
- -
-

1.9%

-
-
+ ))}
); diff --git a/src/client/admin/components/Sidebar.tsx b/src/client/admin/components/Sidebar.tsx index ca28b0d..6790fa1 100644 --- a/src/client/admin/components/Sidebar.tsx +++ b/src/client/admin/components/Sidebar.tsx @@ -58,7 +58,7 @@ const Sidebar = ({ sidebarOpen, setSidebarOpen }: SidebarProps) => { return (
diff --git a/src/client/common/DropdownUser.tsx b/src/client/components/DropdownUser.tsx similarity index 89% rename from src/client/common/DropdownUser.tsx rename to src/client/components/DropdownUser.tsx index d442763..4798b40 100644 --- a/src/client/common/DropdownUser.tsx +++ b/src/client/components/DropdownUser.tsx @@ -1,25 +1,25 @@ import { useEffect, useRef, useState } from 'react'; -import { Link } from 'react-router-dom'; +import { Link } from 'react-router-dom'; // TODO change all Links to wasp router links import { CgProfile } from 'react-icons/cg'; import { MdOutlineSpaceDashboard } from 'react-icons/md'; -import { TfiDashboard } from 'react-icons/tfi' +import { TfiDashboard } from 'react-icons/tfi'; +import logout from '@wasp/auth/logout'; -const DropdownUser = ({username} : {username: string | undefined}) => { +const DropdownUser = ({ username, isUserAdmin }: { username: string | undefined, isUserAdmin: boolean }) => { const [dropdownOpen, setDropdownOpen] = useState(false); const trigger = useRef(null); const dropdown = useRef(null); + const toggleDropdown = () => setDropdownOpen((prev) => !prev); + // close on click outside useEffect(() => { const clickHandler = ({ target }: MouseEvent) => { - if (!dropdown.current) return; - if ( - !dropdownOpen || - dropdown.current.contains(target) || - trigger.current.contains(target) - ) + if (!dropdown.current) return + if (!dropdownOpen || dropdown.current.contains(target) || trigger.current.contains(target)) { return; + } setDropdownOpen(false); }; document.addEventListener('click', clickHandler); @@ -38,11 +38,10 @@ const DropdownUser = ({username} : {username: string | undefined}) => { return (
- setDropdownOpen(!dropdownOpen)} + onClick={toggleDropdown} className='flex items-center gap-4 duration-300 ease-in-out text-gray-900 hover:text-yellow-500' - to='#' > {username} @@ -63,36 +62,25 @@ const DropdownUser = ({username} : {username: string | undefined}) => { fill='' /> - + {/* */}
setDropdownOpen(true)} - onBlur={() => setDropdownOpen(false)} className={`absolute right-0 mt-4 flex w-62.5 flex-col rounded-sm border border-stroke bg-white shadow-default dark:border-strokedark dark:bg-boxdark ${ dropdownOpen === true ? 'block' : 'hidden' }`} > -
    +
    • App
    • -
    • - - - Admin Dashboard - -
    • {
    -
diff --git a/src/client/static/gptsaastemplate.png b/src/client/public/gptsaastemplate.png similarity index 100% rename from src/client/static/gptsaastemplate.png rename to src/client/public/gptsaastemplate.png diff --git a/src/server/actions.ts b/src/server/actions.ts index 321ada4..4c29020 100644 --- a/src/server/actions.ts +++ b/src/server/actions.ts @@ -3,7 +3,7 @@ import HttpError from '@wasp/core/HttpError.js'; import type { RelatedObject, User } from '@wasp/entities'; import type { GenerateGptResponse, StripePayment } from '@wasp/actions/types'; import type { StripePaymentResult, OpenAIResponse } from './types'; -import { UpdateUser } from '@wasp/actions/types'; +import { UpdateCurrentUser, SaveReferrer, UpdateUserReferrer, UpdateUserById } from '@wasp/actions/types'; import Stripe from 'stripe'; @@ -149,16 +149,77 @@ export const generateGptResponse: GenerateGptResponse throw new HttpError(500, 'Something went wrong'); }; -export const updateUser: UpdateUser, User> = async (user, context) => { +export const updateUserById: UpdateUserById<{ id: number; data: Partial }, 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, + }); + + console.log('updated user', updatedUser.id) + + return updatedUser; +} + +export const updateCurrentUser: UpdateCurrentUser, User> = async (user, context) => { + if (!context.user) { + throw new HttpError(401); + } + + console.log('updating user', user); + return context.entities.User.update({ where: { id: context.user.id, }, - data: user + data: user, + }); +}; + + +export const saveReferrer: SaveReferrer<{ name: string }, void> = async ({ name }, context) => { + await context.entities.Referrer.upsert({ + where: { + name, + }, + create: { + name, + count: 1, + }, + update: { + count: { + increment: 1, + }, + }, }); } +export const updateUserReferrer: UpdateUserReferrer<{ name: string }, void> = async ({ name }, context) => { + if (!context.user) { + throw new HttpError(401); + } + await context.entities.User.update({ + where: { + id: context.user.id, + }, + data: { + referrer: { + connect: { + name, + }, + }, + }, + }); +} diff --git a/src/server/queries.ts b/src/server/queries.ts index 06203c6..d77bfff 100644 --- a/src/server/queries.ts +++ b/src/server/queries.ts @@ -1,6 +1,6 @@ import HttpError from '@wasp/core/HttpError.js'; -import type { DailyStats, RelatedObject } from '@wasp/entities'; -import type { GetRelatedObjects, GetDailyStats } from '@wasp/queries/types'; +import type { DailyStats, RelatedObject, Referrer, User } from '@wasp/entities'; +import type { GetRelatedObjects, GetDailyStats, GetReferrerStats, GetPaginatedUsers } from '@wasp/queries/types'; type DailyStatsValues = { dailyStats: DailyStats; @@ -37,5 +37,96 @@ export const getDailyStats: GetDailyStats = async (_args take: 7, }); - return {dailyStats, weeklyStats}; -} \ No newline at end of file + return { dailyStats, weeklyStats }; +}; + +type ReferrerWithSanitizedUsers = Referrer & { + users: Pick[]; +}; + +export const getReferrerStats: GetReferrerStats = async (args, context) => { + const referrers = await context.entities.Referrer.findMany({ + include: { + users: true, + }, + }); + + return referrers.map((referrer) => ({ + ...referrer, + users: referrer.users.map((user) => ({ + id: user.id, + email: user.email, + hasPaid: user.hasPaid, + subscriptionStatus: user.subscriptionStatus, + })), + })); +}; + +type GetPaginatedUsersInput = { + skip: number; + cursor?: number | undefined; + emailContains?: string; + subscriptionStatus?: string[] +}; +type GetPaginatedUsersOutput = { + users: Pick[]; + totalPages: number; +}; + +export const getPaginatedUsers: GetPaginatedUsers = async ( + args, + context +) => { + + let hasPaid = undefined + if (!!args.subscriptionStatus && args.subscriptionStatus.includes('hasPaid')) { + hasPaid = true + } + + let subscriptionStatus = args.subscriptionStatus?.filter((status) => status !== 'hasPaid') + subscriptionStatus = subscriptionStatus?.length ? subscriptionStatus : undefined + + const queryResults = await context.entities.User.findMany({ + skip: args.skip, + take: 10, + where: { + email: { + contains: args.emailContains || undefined, + mode: 'insensitive', + }, + hasPaid, + subscriptionStatus: { + in: subscriptionStatus || undefined, + }, + }, + select: { + id: true, + email: true, + lastActiveTimestamp: true, + hasPaid: true, + subscriptionStatus: true, + stripeId: true, + }, + orderBy: { + id: 'desc', + }, + }); + + const totalUserCount = await context.entities.User.count({ + where: { + email: { + contains: args.emailContains || undefined, + }, + hasPaid, + subscriptionStatus: { + in: subscriptionStatus || undefined, + }, + }, + }); + const totalPages = Math.ceil(totalUserCount / 10); + + return { + users: queryResults, + totalPages + }; +}; diff --git a/src/server/scripts/usersSeed.ts b/src/server/scripts/usersSeed.ts new file mode 100644 index 0000000..ed6a10f --- /dev/null +++ b/src/server/scripts/usersSeed.ts @@ -0,0 +1,72 @@ +import { faker } from '@faker-js/faker'; +import type { PrismaClient } from '@prisma/client'; +import type { User, Referrer } from '@wasp/entities'; + +// in a terminal window run `wasp db seed` to seed your dev database with this data + +const referrerArr: Referrer[] = [ + { + id: 1, + name: 'product-hunt', + count: 27, + }, + { + id: 2, + name: 'twitter', + count: 26, + }, + { + id: 3, + name: 'linkedin', + count: 25, + }, +]; + +let prevUserId = 0; +export function createRandomUser(): Partial { + const user: Partial = { + id: ++prevUserId, + email: faker.internet.email(), + password: faker.internet.password({ + length: 12, + prefix: 'Aa1!' + }), + createdAt: faker.date.between({ from: new Date('2023-01-01'), to: new Date() }), + lastActiveTimestamp: faker.date.recent(), + isAdmin: false, + isEmailVerified: faker.helpers.arrayElement([true, false]), + stripeId: `cus_${faker.string.uuid()}`, + hasPaid: faker.helpers.arrayElement([true, false]), + sendEmail: false, + subscriptionStatus: faker.helpers.arrayElement(['active', 'canceled', 'past_due']), + datePaid: faker.date.recent(), + credits: faker.number.int({ min: 0, max: 3 }), + referrerId: faker.number.int({ min: 1, max: 3 }), + }; + return user; +} + +const USERS: Partial[] = faker.helpers.multiple(createRandomUser, { + count: 50, +}); + +export async function devSeedUsers(prismaClient: PrismaClient) { + try { + await Promise.all( + referrerArr.map(async (referrer) => { + await prismaClient.referrer.create({ + data: referrer, + }); + }) + ); + await Promise.all( + USERS.map(async (user) => { + await prismaClient.user.create({ + data: user, + }); + }) + ); + } catch (error) { + console.error(error); + } +} diff --git a/src/server/workers/calculateDailyStats.ts b/src/server/workers/calculateDailyStats.ts index 0e63788..3b7e079 100644 --- a/src/server/workers/calculateDailyStats.ts +++ b/src/server/workers/calculateDailyStats.ts @@ -6,14 +6,20 @@ const stripe = new Stripe(process.env.STRIPE_KEY!, { }); export const calculateDailyStats: DailyStats = async (_args, context) => { - const currentDate = new Date(); - const yesterdaysDate = new Date(new Date().setDate(currentDate.getDate() - 1)); + const nowUTC = new Date(Date.now()); + nowUTC.setUTCHours(0, 0, 0, 0); + + const yesterdayUTC = new Date(nowUTC); + yesterdayUTC.setUTCDate(yesterdayUTC.getUTCDate() - 1); + + console.log('yesterdayUTC: ', yesterdayUTC); + console.log('nowUTC: ', nowUTC); try { const yesterdaysStats = await context.entities.DailyStats.findFirst({ where: { date: { - equals: yesterdaysDate, + equals: yesterdayUTC, }, }, }); @@ -33,16 +39,16 @@ export const calculateDailyStats: DailyStats = async (_args, contex if (yesterdaysStats) { userDelta -= yesterdaysStats.userCount; paidUserDelta -= yesterdaysStats.paidUserCount; - } + } const newRunningTotal = await calculateTotalRevenue(context); await context.entities.DailyStats.upsert({ where: { - date: currentDate, + date: nowUTC, }, create: { - date: currentDate, + date: nowUTC, userCount, paidUserCount, userDelta, @@ -57,24 +63,39 @@ export const calculateDailyStats: DailyStats = async (_args, contex totalRevenue: newRunningTotal, }, }); - } catch (error) { + + await context.entities.Logs.create({ + data: { + message: `Daily stats calculated for ${nowUTC.toDateString()}`, + level: 'job-info', + }, + }); + } catch (error: any) { console.error('Error calculating daily stats: ', error); + await context.entities.Logs.create({ + data: { + message: `Error calculating daily stats: ${error?.message}`, + level: 'job-error', + }, + }); } }; async function fetchDailyStripeRevenue() { - const startOfDay = new Date(); - startOfDay.setHours(0, 0, 0, 0); // Sets to beginning of day - const startOfDayTimestamp = Math.floor(startOfDay.getTime() / 1000); // Convert to Unix timestamp in seconds + const startOfDayUTC = new Date(Date.now()); + startOfDayUTC.setHours(0, 0, 0, 0); // Sets to beginning of day + const startOfDayTimestamp = Math.floor(startOfDayUTC.getTime() / 1000); // Convert to Unix timestamp in seconds - const endOfDay = new Date(); - endOfDay.setHours(23, 59, 59, 999); // Sets to end of day - const endOfDayTimestamp = Math.floor(endOfDay.getTime() / 1000); // Convert to Unix timestamp in seconds + const endOfDayUTC = new Date(); + endOfDayUTC.setHours(23, 59, 59, 999); // Sets to end of day + const endOfDayTimestamp = Math.floor(endOfDayUTC.getTime() / 1000); // Convert to Unix timestamp in seconds let nextPageCursor = undefined; const allPayments = [] as Stripe.Invoice[]; while (true) { + // Stripe allows searching for invoices by date range via their Query Language + // If there are more than 100 invoices in a day, we need to paginate through them const params = { query: `created>=${startOfDayTimestamp} AND created<=${endOfDayTimestamp} AND status:"paid"`, limit: 100, @@ -106,13 +127,22 @@ async function calculateTotalRevenue(context: any) { const revenueInCents = await fetchDailyStripeRevenue(); const revenueInDollars = revenueInCents / 100; + + // we use UTC time to avoid issues with local timezones + const nowUTC = new Date(Date.now()); - const lastTotalEntry = await context.entities.DailyStats.find({ + // Set the time component to midnight in UTC + // This way we can pass the Date object directly to Prisma + // without having to convert it to a string + nowUTC.setUTCHours(0, 0, 0, 0); + + // Get yesterday's date by subtracting one day + const yesterdayUTC = new Date(nowUTC); + yesterdayUTC.setUTCDate(yesterdayUTC.getUTCDate() - 1); + + const lastTotalEntry = await context.entities.DailyStats.findUnique({ where: { - // date is yesterday - date: { - equals: new Date(new Date().setDate(new Date().getDate() - 1)), - }, + date: yesterdayUTC, // Pass the Date object directly, not as a string }, });