mirror of
https://github.com/wasp-lang/open-saas.git
synced 2025-03-29 11:12:19 +01:00
analytics
This commit is contained in:
parent
c894ed102c
commit
646e33f881
@ -3,8 +3,6 @@
|
||||
# If you use `wasp start db` then you DO NOT need to add a DATABASE_URL env variable here.
|
||||
# DATABASE_URL=
|
||||
|
||||
# TODO: replace the comments with links to the SaaS Template docs
|
||||
|
||||
# for testing, go to https://dashboard.stripe.com/test/apikeys and get a test stripe key that starts with "sk_test_..."
|
||||
STRIPE_KEY=sk_test_...
|
||||
# to create a test subscription, go to https://dashboard.stripe.com/test/products and click on + Add Product
|
||||
@ -13,6 +11,9 @@ PRO_SUBSCRIPTION_PRICE_ID=price_...
|
||||
# after downloading starting the stripe cli (https://stripe.com/docs/stripe-cli) with `stripe listen --forward-to localhost:3001/stripe-webhook` it will output your signing secret
|
||||
STRIPE_WEBHOOK_SECRET=whsec_...
|
||||
|
||||
# this needs to be a string at least 32 characters long
|
||||
JWT_SECRET=
|
||||
|
||||
# see our guide for setting up google auth: https://wasp-lang.dev/docs/auth/social-auth/google
|
||||
GOOGLE_CLIENT_ID=
|
||||
GOOGLE_CLIENT_SECRET=
|
||||
@ -26,4 +27,11 @@ SEND_EMAILS_IN_DEVELOPMENT=true
|
||||
OPENAI_API_KEY=
|
||||
|
||||
# (OPTIONAL) get your plausible api key at https://plausible.io/login
|
||||
PLAUSIBLE_API_KEY=
|
||||
PLAUSIBLE_API_KEY=
|
||||
|
||||
# (OPTIONAL) get your google service account key at https://console.cloud.google.com/iam-admin/serviceaccounts
|
||||
GOOGLE_ANALYTICS_CLIENT_EMAIL=
|
||||
# Make sure you convert the private key within the JSON file to base64 first with `echo -n "PRIVATE_KEY" | base64`. see the docs for more info.
|
||||
GOOGLE_ANALYTICS_PRIVATE_KEY=
|
||||
# You will find your Property ID in the Google Analytics dashboard. It will look like '987654321'
|
||||
GOOGLE_ANALYTICS_PROPERTY_ID=
|
3
.gitignore
vendored
3
.gitignore
vendored
@ -6,3 +6,6 @@ fly config/fly-client.toml
|
||||
fly config/fly-server.toml
|
||||
fly-client.toml
|
||||
fly-server.toml
|
||||
# TODO: change this before deploy
|
||||
# replace with your own Google Analytics service account json file
|
||||
saastemplate-381911-6dc3caae2204.json
|
||||
|
@ -59,7 +59,9 @@ export default defineConfig({
|
||||
},
|
||||
{
|
||||
label: 'General',
|
||||
items: [{ label: 'User Permissions', link: 'general/user-permissions/' }],
|
||||
items: [
|
||||
{ label: 'User Permissions', link: 'general/user-permissions/' },
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
|
@ -1,3 +1,53 @@
|
||||
---
|
||||
title: Analytics
|
||||
---
|
||||
This guide will show you how to integrate analytics for your app. You can choose between [Google Analytics](#google-analytics) and [Plausible](#plausible).
|
||||
|
||||
Google Analytics is free, but tends to be more cumbersome to use.
|
||||
|
||||
Plausible is an open-source, privacy-friendly alternative to Google Analytics. It's also easier to use than Google if you use their hosted service, which is a paid feature. But, it is completely free if you want to self-host it, although this comes with some additional setup steps.
|
||||
|
||||
## Plausible
|
||||
<!-- TODO add plausible -->
|
||||
*coming soon... until then, check out the [official documentation](https://plausible.io/docs)*
|
||||
|
||||
## Google Analytics
|
||||
|
||||
After you sign up for [Google analytics](https://analytics.google.com/), go to your `Admin` panel in the bottom of the left sidebar and then create a "Property" for your app.
|
||||
|
||||
Once you've completed the steps to create a new Property, some Installation Instructions will pop up. Select `install manually` and copy and paste the Google script tag into the `main.wasp` file's head section.
|
||||
|
||||
```js
|
||||
app SaaSTemplate {
|
||||
wasp: {
|
||||
version: "^0.11.6"
|
||||
},
|
||||
title: "My SaaS App",
|
||||
head: [
|
||||
"<your google analytics script tag here>",
|
||||
],
|
||||
//...
|
||||
```
|
||||
|
||||
Then, set up the Google Analytics API access by following these steps:
|
||||
|
||||
1. **Set up a Google Cloud project:** If you haven't already, start by setting up a project in the [Google Cloud Console](https://console.cloud.google.com/).
|
||||
|
||||
2. **Enable the Google Analytics API for your project:** Navigate to the "Library" in the Google Cloud Console and search for the "Google Analytics Data API" (for Google Analytics 4 properties) and enable it.
|
||||
|
||||
3. **Create credentials:** Now go to the "Credentials" tab within your Google Cloud project, click on `+ credentials`, and create a new service account key. First, give it a name. Then, under "Grant this service account access to project", choose `viewer`.
|
||||
|
||||
4. **Create Credentials:** When you go back to `Credentials` page, you should see a new service account listed under "Service Accounts". It will be a long email address to ends with `@your-project-id.iam.gserviceaccount.com`. Click on the service account name to go to the service account details page.
|
||||
|
||||
- Under “Keys” in the service account details page, click “Add Key” and choose `Create new key`.
|
||||
|
||||
- Select "JSON", then click “Create” to download your new service account’s JSON key file. Keep this file secure and don't add it to your git repo – it grants access to your Google Analytics data.
|
||||
5. **Update your Google Anayltics Settings:** Go back to your Google Analytics dashboard, and click on the `Admin` section in the left sidebar. Under `Property Settings > Property > Property Access Management` Add the service account email address (the one that ends with `@your-project-id.iam.gserviceaccount.com`) and give it `Viewer` permissions.
|
||||
|
||||
6. **Encode and add the environment variables:** Add the `client_email` and the `private_key` from your JSON Key file into your `.env.server` file. But be careful! Because Google uses a special PEM private key, you need to first convert the key to base64, otherwise you will run into errors parsing the key. To do this, in a terminal window, run the command below and paste the output into your `.env.server` file under the `GOOGLE_ANALYTICS_PRIVATE_KEY` variable:
|
||||
```sh
|
||||
echo -n "PRIVATE_KEY" | base64
|
||||
```
|
||||
|
||||
7. **Add your Google Analytics Property ID:** You will find the Property ID in your Google Analytics dashboard in the `Admin > Property > Property Settings > Property Details` section of your Google Analytics property (**not** your Google Cloud console). Add this 9-digit number to your `.env.server` file under the `GOOGLE_ANALYTICS_PROPERTY_ID` variable.
|
||||
|
||||
|
@ -1,5 +1,5 @@
|
||||
---
|
||||
title: Auth Setup
|
||||
title: Authentication
|
||||
---
|
||||
|
||||
Setting up your app's authentication is easy with Wasp. In fact, it's aready set up for your in the `main.wasp` file:
|
||||
@ -22,6 +22,6 @@ The great part is, by defining your auth config in the `main.wasp` file, not onl
|
||||
To learn more about using and customizing full-stack Auth with Wasp, including AuthUI, check out the [Wasp Auth docs](https://wasp-lang.dev/docs/auth/overview).
|
||||
|
||||
Since this template has Auth set up for you, you just need to fill in your API keys for your social Auth providers and your Email sender. Follow the integration guides here to do so:
|
||||
- [Google Auth](https://wasp-lang.dev/docs/auth/social-auth/google)
|
||||
- [GitHub Auth](https://wasp-lang.dev/docs/auth/social-auth/github)
|
||||
- [Google Auth](https://wasp-lang.dev/docs/auth/social-auth/google#3-creating-a-google-oauth-app)
|
||||
- [GitHub Auth](https://wasp-lang.dev/docs/auth/social-auth/github#3-creating-a-github-oauth-app)
|
||||
- [Email verified Auth](https://wasp-lang.dev/docs/auth/email)
|
@ -27,17 +27,21 @@ This will automatically redirect users to the login page if they are not logged
|
||||
If you want more fine-grained control over what users can access, there are two Wasp-specific options:
|
||||
1. When you define the `authRequired: true` property on the `page` definition, Wasp automatically passes the User object to the page component. Here you can check for certain user properties before authorizing access:
|
||||
|
||||
```tsx title="ExamplePage.tsx" {1}
|
||||
export default function Example({ user }: { user: User })
|
||||
```tsx title="ExamplePage.tsx" "{ user }: { user: User }"
|
||||
import type { User } from '@wasp/entities';
|
||||
|
||||
export default function Example({ user }: { user: User }) {
|
||||
|
||||
if (user.subscriptionStatus === 'past_due') {
|
||||
return (<span>Your subscription is past due. Please update your payment information.</span>)
|
||||
}
|
||||
if (user.subscriptionStatus === 'canceled') {
|
||||
return (<span>Your will susbscription end on 01.01.2024</span>)
|
||||
}
|
||||
if (user.subscriptionStatus === 'active') {
|
||||
return (<span>Thanks so much for your support!</span>)
|
||||
}
|
||||
|
||||
if (user.subscriptionStatus === 'past_due') {
|
||||
return (<span>Your subscription is past due. Please update your payment information.</span>)
|
||||
}
|
||||
if (user.subscriptionStatus === 'canceled') {
|
||||
return (<span>Your will susbscription end on 01.01.2024</span>)
|
||||
}
|
||||
if (user.subscriptionStatus === 'active') {
|
||||
return (<span>Thanks so much for your support!</span>)
|
||||
}
|
||||
```
|
||||
|
||||
|
@ -8,6 +8,10 @@ The simplest and quickest option is to take advantage of Wasp's one-command depl
|
||||
|
||||
Or if you prefer to deploy to a different provider, or your frontend and backend separately, you can follow any of the other deployment guides below.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Make sure you've got all your API keys and environment variables set up before you deploy. For example, in the [Stripe integration guide](/guides/stripe-integration), you set up your Stripe API keys using test keys and product ids. You'll need to get the production-ready keys and create actual Product IDs.
|
||||
|
||||
## Deploying to Fly.io
|
||||
|
||||
[Fly.io](https://fly.io) is a platform for running your apps globally. It's a great choice for deploying your SaaS app because it's free to get started, can host your entire full-stack app in one place, scales well, and has one-command deploy integration with Wasp.
|
||||
|
@ -1,8 +1,9 @@
|
||||
---
|
||||
title: Stripe Testing
|
||||
---
|
||||
This guide will show you how to test and try out your checkout, payments, and webhooks locally.
|
||||
|
||||
First, make sure you've set up your Stripe account for testing and local development. You can find the guide [here](/docs/guides/stripe-integration).
|
||||
First, make sure you've set up your Stripe account for local development. You can find the guide [here](/guides/stripe-integration).
|
||||
|
||||
## Testing Webhooks via the Stripe CLI
|
||||
|
||||
@ -40,12 +41,12 @@ The results of the event firing will be visible in the initial terminal window.
|
||||
|
||||
For more info on testing webhooks, check out https://stripe.com/docs/webhooks#test-webhook
|
||||
|
||||
## Testing Payments Webhooks via the Client
|
||||
## Testing Checkout and Payments via the Client
|
||||
|
||||
Make sure the **Stripe CLI is running** by following the steps above.
|
||||
You can then test the payment flow via the client by doing the following:
|
||||
|
||||
- Click on the "BUY NOW" button on the homepage. You should be redirected to the checkout page.
|
||||
- Click on a Buy button on the for any of the products on the homepage. You should be redirected to the checkout page.
|
||||
- Fill in the form with the following test credit card number `4242 4242 4242 4242` and any future date for the expiration date and any 3 digits for the CVC.
|
||||
|
||||
- Click on the "Pay" button. You should be redirected to the success page.
|
||||
@ -60,4 +61,8 @@ wasp db studio
|
||||
|
||||

|
||||
|
||||
- Navigate to `localhost:5555` and click on the `users` table. You should see `hasPaid`is true and `subscriptionStatus` is `active` for the user that just made the purchase.
|
||||
- Navigate to `localhost:5555` and click on the `users` table. You should see `hasPaid`is true and `subscriptionStatus` is `active` for the user that just made the purchase.
|
||||
|
||||
:::note
|
||||
If you want to learn more about how a user's payment status, subscription status, and subscription tier affect a user's priveledges within the app, check out the [User Permissions](/general/user-permissions) reference.
|
||||
:::
|
@ -56,18 +56,21 @@ At the root of our project, you will see the following folders and files:
|
||||
|
||||
### Wasp Config file
|
||||
|
||||
The `main.wasp` file is where you define your app structure. You should define everything if your app here first, and then Wasp will generate all the boilerplate code for you.
|
||||
The `main.wasp` file is where you define your app structure.
|
||||
|
||||
Here, we've already defined a number of things:
|
||||
In this template, we've already defined a number of things for you, including:
|
||||
- Auth
|
||||
- Routes and Pages
|
||||
- Prisma Database Models
|
||||
- Server Operations (read and write functions)
|
||||
- Operations (data read and write functions)
|
||||
- Background Jobs
|
||||
- Email Sending
|
||||
|
||||
By defining these things in the config file, Wasp continuously handles the boilerplate necessary with putting all these features together. You just need to focus on the business logic of your app.
|
||||
By defining these things in the config file, Wasp continuously handles the boilerplate necessary with putting all these features together. You just need to focus on the business logic of your app!
|
||||
|
||||
If you want to learn more about Wasp, check out the [Wasp docs](https://wasp-lang.dev/docs/).
|
||||
Wasp abstracts away some things that you would normally be used to doing during development. For exmaple, you may notice there's no `package.json` file at the root of the project.
|
||||
|
||||
That's why we highly suggest you get acquainted with Wasp. A great starting point is the intro tutorial in the [Wasp docs](https://wasp-lang.dev/docs/) which takes ~20 minutes.
|
||||
|
||||
### Client
|
||||
|
||||
@ -106,5 +109,4 @@ The `src/server` folder contains all the code that runs on the server. Wasp comp
|
||||
├── static # Assets that you need access to in your code, e.g. import logo from 'static/logo.png'
|
||||
├── types.ts # Main app component to wrap all child components. Useful for global state, navbars, etc.
|
||||
└── Main.css
|
||||
```sh
|
||||
|
||||
```
|
@ -26,17 +26,17 @@ cd // TODO ADD REPO NAME
|
||||
```
|
||||
|
||||
### Start your DB
|
||||
Make sure you have a Database connected and running. With Wasp, it's super easy:
|
||||
Before you start your app, you need to have a Postgres Database connected and running. With Wasp, that's super easy!
|
||||
|
||||
First, make sure you have Docker installed and running. If not, download and install it [here](https://www.docker.com/products/docker-desktop/)
|
||||
|
||||
From within the root of the projectn a new terminal window, run:
|
||||
With Docker running, open a new terminal window/tab and from within the root of the project, run:
|
||||
```sh
|
||||
wasp start db
|
||||
```
|
||||
This will start and connect your app to a Postgres database for you. No need to do anything else! 🤯
|
||||
|
||||
In a new terminal window, run:
|
||||
Then, in a new terminal window/tab, run:
|
||||
```sh
|
||||
wasp db migrate-dev
|
||||
```
|
||||
@ -48,7 +48,7 @@ wasp db studio
|
||||
```
|
||||
|
||||
### Start your app
|
||||
In a new terminal window, run:
|
||||
In a new terminal window/tab, run:
|
||||
```sh
|
||||
wasp start
|
||||
```
|
||||
|
@ -1,110 +0,0 @@
|
||||
---
|
||||
title: Introduction
|
||||
---
|
||||
|
||||
## Welcome to your new SaaS App!
|
||||
|
||||
// TODO: add a screenshot of the app
|
||||
|
||||
You've decided to build a SaaS app with this template. Great choice! 🎉
|
||||
|
||||
This template is:
|
||||
1. fully open-source
|
||||
2. completely free to use and distribute
|
||||
3. comes with a lot a ton of features out of the box!
|
||||
|
||||
Try it out here: // TODO: add link
|
||||
Check Out the Code: // TODO: add repo link
|
||||
|
||||
## What's inside?
|
||||
|
||||
The template itself is built on top of some very powerful tools and frameworks, including:
|
||||
- 🐝 [Wasp](https://wasp-lang.dev) - a full-stack React, NodeJS, Prisma framework with superpowers
|
||||
- 🚀 [Astro](https://starlight.astro.build/) - Astro's lightweight "Starlight" template for documentation and blog
|
||||
- 💸 [Stripe](https://stripe.com) - for products and payments
|
||||
- 🤖 [OpenAI](https://openai.com) - OpenAI API integrated into the app
|
||||
- 📧 [SendGrid](https://sendgrid.com), [MailGun](https://mailgun.com), or SMTP - for email sending
|
||||
- 💅 [TailwindCSS](https://tailwindcss.com) - for styling
|
||||
- 🧑💼 [TailAdmin](https://tailadmin.com/) - admin dashboard & components for TailwindCSS
|
||||
|
||||
Because we're using Wasp as the full-stack framework, we can leverage a lot of its features to build our SaaS in record time, including:
|
||||
- 🔐 [Full-stack Authentication](https://wasp-lang.dev/docs/auth/overview) - Email verified + social Auth in a few lines of code.
|
||||
- ⛑ [End-to-end Type Safety](https://wasp-lang.dev/docs/data-model/operations/overview) - Type your backend functions and get inferred types on the front-end automatically, without the need to install or configure any third-party libraries. Oh, and type-safe Links, too!
|
||||
- 🤖 [Jobs](https://wasp-lang.dev/docs/language/features#jobs) - Run cron jobs in the background or set up queues simply by defining a function in the config file.
|
||||
- 🚀 [One-command Deploy](https://wasp-lang.dev/docs/advanced/deployment/overview) - Easily deploy via the CLI to [Fly.io](https://fly.io), or to other provides like [Railway](https://railway.app) and [Netlify](https://netlify.com).
|
||||
|
||||
You also get access to Wasp's diverse, helpful community if you get stuck or need help.
|
||||
- 🤝 [Wasp Discord](https://discord.gg/aCamt5wCpS)
|
||||
|
||||
## Getting acquainted with the codebase
|
||||
|
||||
At the root of our project, you will see the following folders and files:
|
||||
```sh
|
||||
.
|
||||
├── .gitignore
|
||||
├── main.wasp # Wasp Config file. You define your app structure here.
|
||||
├── src
|
||||
│ ├── client # Your client code (JS/CSS/HTML) goes here.
|
||||
│ ├── server # Your server code (Node JS) goes here.
|
||||
│ ├── shared # Your shared (runtime independent) code goes here.
|
||||
│ └── .waspignore
|
||||
├── docs # Astro Starlight template for your documentation and blog.
|
||||
├── .env.server # Environment variables for your server code.
|
||||
├── .env.client # Environment variables for your client code.
|
||||
└── .wasproot
|
||||
```
|
||||
|
||||
### Wasp Config file
|
||||
|
||||
The `main.wasp` file is where you define your app structure. You should define everything if your app here first, and then Wasp will generate all the boilerplate code for you.
|
||||
|
||||
Here, we've already defined a number of things:
|
||||
- Auth
|
||||
- Routes and Pages
|
||||
- Prisma Database Models
|
||||
- Server Operations (read and write functions)
|
||||
- Background Jobs
|
||||
|
||||
By defining these things in the config file, Wasp continuously handles the boilerplate necessary with putting all these features together. You just need to focus on the business logic of your app.
|
||||
|
||||
If you want to learn more about Wasp, check out the [Wasp docs](https://wasp-lang.dev/docs/).
|
||||
|
||||
### Client
|
||||
|
||||
The `src/client` folder contains all the code that runs in the browser. It's a standard React app, with a few Wasp-specific things sprinkled in.
|
||||
|
||||
```sh
|
||||
.
|
||||
|
||||
└── client
|
||||
├── admin # Admin dashboard pages and components
|
||||
├── app # Your user-facing app that sits behind the login.
|
||||
├── auth # All auth-related pages and components.
|
||||
├── components # Your shared React components.
|
||||
├── hooks # Your shared React hooks.
|
||||
├── landing-page # Landing page related code
|
||||
├── public # Assets that are publicly accessible, e.g. www.yourdomain.com/banner.png
|
||||
├── static # Assets that you need access to in your code, e.g. import logo from 'static/logo.png'
|
||||
├── App.tsx # Main app component to wrap all child components. Useful for global state, navbars, etc.
|
||||
└── Main.css
|
||||
|
||||
```
|
||||
|
||||
### Server
|
||||
|
||||
The `src/server` folder contains all the code that runs on the server. Wasp compiles everything into a NodeJS server for you. All you have to do is define your server-side functions in the `main.wasp` file, write the logic in a function within `src/server` and Wasp will generate the boilerplate code for you.
|
||||
|
||||
```sh
|
||||
└── server
|
||||
├── auth # Some small auth-related functions to customize the auth flow.
|
||||
├── scripts # Scripts to run via Wasp, e.g. database seeding.
|
||||
├── webhooks # The webhook handler for Stripe.
|
||||
├── 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.
|
||||
├── stripeUtils.ts
|
||||
├── static # Assets that you need access to in your code, e.g. import logo from 'static/logo.png'
|
||||
├── types.ts # Main app component to wrap all child components. Useful for global state, navbars, etc.
|
||||
└── Main.css
|
||||
```sh
|
||||
|
16
main.wasp
16
main.wasp
@ -12,9 +12,12 @@ app SaaSTemplate {
|
||||
"<meta name='twitter:image:width' content='800' />",
|
||||
"<meta name='twitter:image:height' content='400' />",
|
||||
"<meta name='twitter:card' content='summary_large_image' />",
|
||||
"<script defer data-domain='localhost' src='https://plausible.apps.twoducks.dev/js/script.local.js'></script>"
|
||||
|
||||
// you can put your google analytics script here, too!
|
||||
// you can put your analytics scripts here, too!
|
||||
"<script defer data-domain='localhost' src='https://plausible.apps.twoducks.dev/js/script.js'></script>",
|
||||
// plausible has script extension `script.local.js` for local development
|
||||
"<script defer data-domain='localhost' src='https://plausible.apps.twoducks.dev/js/script.local.js'></script>",
|
||||
// google analytics automatically detects if you are in dev mode and
|
||||
"<!-- Google tag (gtag.js) --><script async src='https://www.googletagmanager.com/gtag/js?id=G-H3LSJCK95H'></script><script>window.dataLayer = window.dataLayer || [];function gtag(){dataLayer.push(arguments);}gtag('js', new Date());gtag('config', 'G-H3LSJCK95H');</script>"
|
||||
],
|
||||
// 🔐 Auth out of the box! https://wasp-lang.dev/docs/auth/overview
|
||||
auth: {
|
||||
@ -74,7 +77,8 @@ app SaaSTemplate {
|
||||
("react-apexcharts", "^1.4.1"),
|
||||
("apexcharts", "^3.41.0"),
|
||||
("headlessui", "^0.0.0"),
|
||||
("@faker-js/faker", "8.3.1")
|
||||
("@faker-js/faker", "8.3.1"),
|
||||
("@google-analytics/data", "4.1.0")
|
||||
],
|
||||
}
|
||||
|
||||
@ -151,7 +155,7 @@ psl=}
|
||||
|
||||
entity PageViewSource {=psl
|
||||
date DateTime @default(now())
|
||||
name String @unique
|
||||
name String
|
||||
visitors Int
|
||||
dailyStats DailyStats? @relation(fields: [dailyStatsId], references: [id])
|
||||
dailyStatsId Int?
|
||||
@ -345,7 +349,7 @@ job emailChecker {
|
||||
entities: [User]
|
||||
}
|
||||
|
||||
job dailyStats {
|
||||
job dailyStatsJob {
|
||||
executor: PgBoss,
|
||||
perform: {
|
||||
fn: import { calculateDailyStats } from "@server/workers/calculateDailyStats.js"
|
||||
|
@ -0,0 +1,2 @@
|
||||
-- DropIndex
|
||||
DROP INDEX "PageViewSource_name_key";
|
@ -1,12 +1,14 @@
|
||||
import type { DailyStats } from '@wasp/jobs/dailyStats';
|
||||
import type { DailyStatsJob } from '@wasp/jobs/dailyStatsJob';
|
||||
import type { DailyStats } from '@wasp/entities';
|
||||
import Stripe from 'stripe';
|
||||
import { getTotalPageViews, getPrevDayViewsChangePercent, getSources } from './analyticsUtils.js';
|
||||
// import { getDailyPageViews, getSources } from './plausibleAnalyticsUtils.js';
|
||||
import { getDailyPageViews, getSources } from './googleAnalyticsUtils.js';
|
||||
|
||||
const stripe = new Stripe(process.env.STRIPE_KEY!, {
|
||||
apiVersion: '2022-11-15', // TODO find out where this is in the Stripe dashboard and document
|
||||
});
|
||||
|
||||
export const calculateDailyStats: DailyStats<never, void> = async (_args, context) => {
|
||||
export const calculateDailyStats: DailyStatsJob<never, void> = async (_args, context) => {
|
||||
const nowUTC = new Date(Date.now());
|
||||
nowUTC.setUTCHours(0, 0, 0, 0);
|
||||
|
||||
@ -41,38 +43,55 @@ export const calculateDailyStats: DailyStats<never, void> = async (_args, contex
|
||||
userDelta -= yesterdaysStats.userCount;
|
||||
paidUserDelta -= yesterdaysStats.paidUserCount;
|
||||
}
|
||||
|
||||
const totalRevenue = await fetchTotalStripeRevenue();
|
||||
const { totalViews, prevDayViewsChangePercent } = await getDailyPageviews();
|
||||
|
||||
const newDailyStat = await context.entities.DailyStats.upsert({
|
||||
const totalRevenue = await fetchTotalStripeRevenue();
|
||||
const { totalViews, prevDayViewsChangePercent } = await getDailyPageViews();
|
||||
|
||||
let dailyStats = await context.entities.DailyStats.findUnique({
|
||||
where: {
|
||||
date: nowUTC,
|
||||
},
|
||||
create: {
|
||||
date: nowUTC,
|
||||
totalViews,
|
||||
prevDayViewsChangePercent: prevDayViewsChangePercent || '0',
|
||||
userCount,
|
||||
paidUserCount,
|
||||
userDelta,
|
||||
paidUserDelta,
|
||||
totalRevenue,
|
||||
},
|
||||
update: {
|
||||
totalViews,
|
||||
prevDayViewsChangePercent: prevDayViewsChangePercent || '0' ,
|
||||
userCount,
|
||||
paidUserCount,
|
||||
userDelta,
|
||||
paidUserDelta,
|
||||
totalRevenue,
|
||||
},
|
||||
});
|
||||
|
||||
if (!dailyStats) {
|
||||
console.log('No daily stat found for today, creating one...');
|
||||
dailyStats = await context.entities.DailyStats.create({
|
||||
data: {
|
||||
date: nowUTC,
|
||||
totalViews,
|
||||
prevDayViewsChangePercent,
|
||||
userCount,
|
||||
paidUserCount,
|
||||
userDelta,
|
||||
paidUserDelta,
|
||||
totalRevenue,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
console.log('Daily stat found for today, updating it...');
|
||||
dailyStats = await context.entities.DailyStats.update({
|
||||
where: {
|
||||
id: dailyStats.id,
|
||||
},
|
||||
data: {
|
||||
totalViews,
|
||||
prevDayViewsChangePercent,
|
||||
userCount,
|
||||
paidUserCount,
|
||||
userDelta,
|
||||
paidUserDelta,
|
||||
totalRevenue,
|
||||
},
|
||||
});
|
||||
}
|
||||
const sources = await getSources();
|
||||
|
||||
for (const source of sources) {
|
||||
console.log('source: ', source);
|
||||
let visitors = source.visitors;
|
||||
if (typeof source.visitors !== 'number') {
|
||||
visitors = parseInt(source.visitors);
|
||||
}
|
||||
await context.entities.PageViewSource.upsert({
|
||||
where: {
|
||||
date_name: {
|
||||
@ -83,17 +102,16 @@ export const calculateDailyStats: DailyStats<never, void> = async (_args, contex
|
||||
create: {
|
||||
date: nowUTC,
|
||||
name: source.source,
|
||||
visitors: source.visitors,
|
||||
dailyStatsId: newDailyStat.id,
|
||||
visitors,
|
||||
dailyStatsId: dailyStats.id,
|
||||
},
|
||||
update: {
|
||||
visitors: source.visitors,
|
||||
visitors,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
console.table({ newDailyStat })
|
||||
|
||||
console.table({ dailyStats })
|
||||
} catch (error: any) {
|
||||
console.error('Error calculating daily stats: ', error);
|
||||
await context.entities.Logs.create({
|
||||
@ -135,16 +153,6 @@ async function fetchTotalStripeRevenue() {
|
||||
}
|
||||
|
||||
// Revenue is in cents so we convert to dollars (or your main currency unit)
|
||||
const formattedRevenue = (totalRevenue / 100)
|
||||
const formattedRevenue = totalRevenue / 100;
|
||||
return formattedRevenue;
|
||||
}
|
||||
|
||||
async function getDailyPageviews() {
|
||||
const totalViews = await getTotalPageViews()
|
||||
const prevDayViewsChangePercent = await getPrevDayViewsChangePercent();
|
||||
|
||||
return {
|
||||
totalViews,
|
||||
prevDayViewsChangePercent,
|
||||
};
|
||||
}
|
137
src/server/workers/googleAnalyticsUtils.ts
Normal file
137
src/server/workers/googleAnalyticsUtils.ts
Normal file
@ -0,0 +1,137 @@
|
||||
import { BetaAnalyticsDataClient } from '@google-analytics/data';
|
||||
|
||||
const clientEmail = process.env.GOOGLE_ANALYTICS_CLIENT_EMAIL;
|
||||
// const privateKey =
|
||||
// '-----BEGIN PRIVATE KEY-----\nMIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQC0pLaSKNIJ4Lio\nTunlGK0T5a228onb8zrpF/i4TMlkHoRwtJFIj19Hvj6RiGRkNCP62x1AfFwiUVzd\nV8BuagsN+BwaxvW/gZS7JeDdB7W5G5w9wTmY75gWpVy034lUoTK1Oe+mpA1m9UG7\nXv/0iUcz88vubZWXDOlnYYm2EIVboONuKwJglaVzgvlNMkpZQHzomOwmCIs/6Ujr\ntOWL3ESUKgLSmLwv/NRwAYCa3DMQ3uYwKQT73ZlHgX8R4cqmvP2AwQIeBlM4pq4S\nhUsDPpFhHEbTBww4uLXaUETW8j26tAZKYq17NjoUnMx0eRW2kkEuOz9l8D/4297D\n9ARjpWRJAgMBAAECggEAAhE4fae176MAufxdNN/5axbfzA4ugbPg4rYBhKpsS5cF\n0PxgBUKOxpVoxeWXsIXgO4iyZwCFVWc09tuAOkNAaSKDv9KzEUP8Xb/rONxTuhCU\n0ygY7qUfSnMOAovkWHGX0PcexPtvo9P+spQ9vaCsje2mUc4zPg1JxoMZPlomDIxf\n38wPtTThJCwHc7uOg0BAof3vjF+zC6Vf1YYLiqlNhAC9E6aS8Np8CVXtpxRsJifn\nb5E6jBESK9PjrtPFdUtRcm7j2vohmLqCy+6ddwjwlCSxcpIky1QVlXNxmH/qkvKA\nK8vrLJlXeGuOb3ISsRrHSfY5J8fnFtol0Rec5Bag4QKBgQDjzl0xO6PnfuGRWs/d\nVfrZuuRmQvFx7kj9D04sC4NVwWuYFauh7RWt8nJwgbRdR3lHqTQ7JlUe4627y1PW\nOzffrbMY0Q3DiwLXj+mYbt2cfrHGqPxGYjeKJCMIvbHOzQ7NBacjtw/AflxsDqKx\n2Wzu//j0QISv2uH/zNESlsd2HQKBgQDLABT7uDMoxn4JIzEwqZTdzfASaSZQ1ppU\nbdm7Tug7hNVCU+orhgAa8k6ODCjH75uwdPrkstjzmQ303Zwxfw1llt1X8CICEqNR\nPS1Ptl+5H2wBcH8K96fFeGLO5CuWOmt/e1C19n3VJULQsh9jdj5yA2rfSI7DvfsC\nCnIjvKWfHQKBgQDR2WDJoIn9D0mVD5WZ68E33szVUud9rya3TukQ884ZKiMGJzhC\n4tZstYEsGJ9gqh2ToM/HiSkyWkPJUaU6HLT8rNLlknZeYmjMz/o8fCxTI/Z57WLv\nJzzIWT+Ypr3rpcPzozhzUwgEp6JVvCmtMYACrfPUdLaGFFjJvg/+Ur0NFQKBgGQ0\nMCdo72frQv+DrZ5VtzQdmamc2dsBc8DFULrS4nOuyA4rmeXOCXNDtF8NxXub3QAn\nXklRtyHXpTn/wj/0dUp2Q+BKmp7nUFKjniBA59NbnVbAjxV81gX1vOBfZNyNDc8p\nsdeASvDRqb+WjUPtdDmXUkPRbxdUSfjh6yGU1zRJAoGBALfUrbxg8t827HMa+xLH\nMvaBHqdQ6jhdjm51sOUfRBe2EOKQWug/Dnr+XlQKWN467JCWmKS0HfQY16BLIusQ\nUUc2dD+7jFTgtkvnSHvrbj+WX1Zl4tvkawxozu04Me83Fx2eYbxf0Ds5D4HiB04j\nn6O2YEStTVYy4ViSPGxTYKEI\n-----END PRIVATE KEY-----\n';
|
||||
const privateKey = Buffer.from(process.env.GOOGLE_ANALYTICS_PRIVATE_KEY!, 'base64').toString('utf-8');
|
||||
|
||||
const propertyId = process.env.GOOGLE_ANALYTICS_PROPERTY_ID;
|
||||
|
||||
const analyticsDataClient = new BetaAnalyticsDataClient({
|
||||
credentials: {
|
||||
client_email: clientEmail,
|
||||
private_key: privateKey,
|
||||
},
|
||||
});
|
||||
|
||||
export async function getSources() {
|
||||
const [response] = await analyticsDataClient.runReport({
|
||||
property: `properties/${propertyId}`,
|
||||
dateRanges: [
|
||||
{
|
||||
startDate: 'yesterday',
|
||||
endDate: 'today',
|
||||
},
|
||||
],
|
||||
// for a list of dimensions and metrics see https://developers.google.com/analytics/devguides/reporting/data/v1/api-schema
|
||||
// get total page views
|
||||
dimensions: [
|
||||
{
|
||||
name: 'pageReferrer',
|
||||
},
|
||||
],
|
||||
metrics: [
|
||||
{
|
||||
name: 'activeUsers',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
let activeUsersPerReferrer: any[] = [];
|
||||
if (response?.rows) {
|
||||
activeUsersPerReferrer = response.rows.map((row) => {
|
||||
if (row.dimensionValues && row.metricValues) {
|
||||
return {
|
||||
source: row.dimensionValues[0].value,
|
||||
visitors: row.metricValues[0].value,
|
||||
};
|
||||
}
|
||||
});
|
||||
} else {
|
||||
throw new Error('No response from Google Analytics');
|
||||
}
|
||||
|
||||
return activeUsersPerReferrer;
|
||||
}
|
||||
|
||||
export async function getDailyPageViews() {
|
||||
const totalViews = await getTotalPageViews();
|
||||
const prevDayViewsChangePercent = await getPrevDayViewsChangePercent();
|
||||
|
||||
return {
|
||||
totalViews,
|
||||
prevDayViewsChangePercent,
|
||||
};
|
||||
}
|
||||
|
||||
async function getTotalPageViews() {
|
||||
const [response] = await analyticsDataClient.runReport({
|
||||
property: `properties/${propertyId}`,
|
||||
dateRanges: [
|
||||
{
|
||||
startDate: '2020-01-01', // go back to earliest date of your app
|
||||
endDate: 'today',
|
||||
},
|
||||
],
|
||||
metrics: [
|
||||
{
|
||||
name: 'screenPageViews',
|
||||
},
|
||||
],
|
||||
});
|
||||
let totalViews = 0;
|
||||
if (response?.rows) {
|
||||
// @ts-ignore
|
||||
totalViews = parseInt(response.rows[0].metricValues[0].value);
|
||||
} else {
|
||||
throw new Error('No response from Google Analytics');
|
||||
}
|
||||
return totalViews;
|
||||
}
|
||||
|
||||
async function getPrevDayViewsChangePercent() {
|
||||
const [response] = await analyticsDataClient.runReport({
|
||||
property: `properties/${propertyId}`,
|
||||
|
||||
dateRanges: [
|
||||
{
|
||||
startDate: '2daysAgo',
|
||||
endDate: 'yesterday',
|
||||
},
|
||||
],
|
||||
dimensions: [
|
||||
{
|
||||
name: 'date',
|
||||
},
|
||||
],
|
||||
metrics: [
|
||||
{
|
||||
name: 'screenPageViews',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
let viewsFromYesterday;
|
||||
let viewsFromDayBeforeYesterday;
|
||||
|
||||
if (response?.rows && response.rows.length === 2) {
|
||||
// @ts-ignore
|
||||
viewsFromYesterday = response.rows[0].metricValues[0].value;
|
||||
// @ts-ignore
|
||||
viewsFromDayBeforeYesterday = response.rows[1].metricValues[0].value;
|
||||
|
||||
if (viewsFromYesterday && viewsFromDayBeforeYesterday) {
|
||||
viewsFromYesterday = parseInt(viewsFromYesterday);
|
||||
viewsFromDayBeforeYesterday = parseInt(viewsFromDayBeforeYesterday);
|
||||
if (viewsFromYesterday === 0 || viewsFromDayBeforeYesterday === 0) {
|
||||
console.log('Page views are zero, so no percentage change');
|
||||
return '0';
|
||||
}
|
||||
|
||||
const change = ((viewsFromYesterday - viewsFromDayBeforeYesterday) / viewsFromDayBeforeYesterday) * 100;
|
||||
return change.toFixed(2);
|
||||
}
|
||||
} else {
|
||||
return '0';
|
||||
}
|
||||
}
|
@ -16,13 +16,25 @@ type PageViewsResult = {
|
||||
};
|
||||
|
||||
type PageViewSourcesResult = {
|
||||
results: [{
|
||||
results: [
|
||||
{
|
||||
source: string;
|
||||
visitors: number;
|
||||
}];
|
||||
}
|
||||
];
|
||||
};
|
||||
|
||||
export async function getTotalPageViews() {
|
||||
export async function getDailyPageViews() {
|
||||
const totalViews = await getTotalPageViews();
|
||||
const prevDayViewsChangePercent = await getPrevDayViewsChangePercent();
|
||||
|
||||
return {
|
||||
totalViews,
|
||||
prevDayViewsChangePercent,
|
||||
};
|
||||
}
|
||||
|
||||
async function getTotalPageViews() {
|
||||
const response = await fetch(
|
||||
`${PLAUSIBLE_BASE_URL}/v1/stats/aggregate?site_id=${PLAUSIBLE_SITE_ID}&metrics=pageviews`,
|
||||
{
|
||||
@ -38,36 +50,31 @@ export async function getTotalPageViews() {
|
||||
return json.results.pageviews.value;
|
||||
}
|
||||
|
||||
export async function getPrevDayViewsChangePercent() {
|
||||
async function getPrevDayViewsChangePercent() {
|
||||
// Calculate today, yesterday, and the day before yesterday's dates
|
||||
const today = new Date();
|
||||
const yesterday = new Date(today.setDate(today.getDate() - 1)).toISOString().split('T')[0];
|
||||
const dayBeforeYesterday = new Date(new Date().setDate(new Date().getDate() - 2)).toISOString().split('T')[0];
|
||||
|
||||
try {
|
||||
// Fetch page views for yesterday and the day before yesterday
|
||||
const pageViewsYesterday = await getPageviewsForDate(yesterday);
|
||||
const pageViewsDayBeforeYesterday = await getPageviewsForDate(dayBeforeYesterday);
|
||||
// Fetch page views for yesterday and the day before yesterday
|
||||
const pageViewsYesterday = await getPageviewsForDate(yesterday);
|
||||
const pageViewsDayBeforeYesterday = await getPageviewsForDate(dayBeforeYesterday);
|
||||
|
||||
console.table({
|
||||
pageViewsYesterday,
|
||||
pageViewsDayBeforeYesterday,
|
||||
typeY: typeof pageViewsYesterday,
|
||||
typeDBY: typeof pageViewsDayBeforeYesterday,
|
||||
});
|
||||
console.table({
|
||||
pageViewsYesterday,
|
||||
pageViewsDayBeforeYesterday,
|
||||
typeY: typeof pageViewsYesterday,
|
||||
typeDBY: typeof pageViewsDayBeforeYesterday,
|
||||
});
|
||||
|
||||
let change = 0;
|
||||
if (pageViewsYesterday === 0 || pageViewsDayBeforeYesterday === 0) {
|
||||
console.log('Page views are zero, so no percentage change');
|
||||
} else {
|
||||
change = ((pageViewsYesterday - pageViewsDayBeforeYesterday) / pageViewsDayBeforeYesterday) * 100;
|
||||
}
|
||||
|
||||
console.log(`Daily change in page views percentage: ${change.toFixed(2)}%`);
|
||||
return change.toFixed(2);
|
||||
} catch (error) {
|
||||
console.error('Error calculating daily change percentage:', error);
|
||||
let change = 0;
|
||||
if (pageViewsYesterday === 0 || pageViewsDayBeforeYesterday === 0) {
|
||||
console.log('Page views are zero, so no percentage change');
|
||||
return '0';
|
||||
} else {
|
||||
change = ((pageViewsYesterday - pageViewsDayBeforeYesterday) / pageViewsDayBeforeYesterday) * 100;
|
||||
}
|
||||
return change.toFixed(2);
|
||||
}
|
||||
|
||||
async function getPageviewsForDate(date: string) {
|
||||
@ -92,6 +99,6 @@ export async function getSources() {
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! Status: ${response.status}`);
|
||||
}
|
||||
const data = await response.json() as PageViewSourcesResult;
|
||||
const data = (await response.json()) as PageViewSourcesResult;
|
||||
return data.results;
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user