All in dirs (#154)

* Split the project into template and opensaas-sh (demo app (diff) + docs).

* fix
This commit is contained in:
Martin Šošić
2024-06-04 13:24:32 +02:00
committed by GitHub
parent 496480509a
commit 04553cd60c
265 changed files with 22081 additions and 70 deletions

View File

@@ -0,0 +1,347 @@
import { type User, type Task, type File } from 'wasp/entities';
import { HttpError } from 'wasp/server';
import {
type GenerateGptResponse,
type StripePayment,
type UpdateCurrentUser,
type UpdateUserById,
type CreateTask,
type DeleteTask,
type UpdateTask,
type CreateFile,
} from 'wasp/server/operations';
import Stripe from 'stripe';
import type { GeneratedSchedule, StripePaymentResult } from '../shared/types';
import { fetchStripeCustomer, createStripeCheckoutSession } from './payments/stripeUtils.js';
import { TierIds } from '../shared/constants.js';
import { getUploadFileSignedURLFromS3 } from './file-upload/s3Utils.js';
import OpenAI from 'openai';
const openai = setupOpenAI();
function setupOpenAI() {
if (!process.env.OPENAI_API_KEY) {
return new HttpError(500, 'OpenAI API key is not set');
}
return new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
}
export const stripePayment: StripePayment<string, StripePaymentResult> = async (tier, context) => {
if (!context.user) {
throw new HttpError(401);
}
const userEmail = context.user.email;
if (!userEmail) {
throw new HttpError(
403,
'User needs an email to make a payment. If using the usernameAndPassword Auth method, switch to an Auth method that provides an email.'
);
}
let priceId;
if (tier === TierIds.HOBBY) {
priceId = process.env.HOBBY_SUBSCRIPTION_PRICE_ID!;
} else if (tier === TierIds.PRO) {
priceId = process.env.PRO_SUBSCRIPTION_PRICE_ID!;
} else if (tier === TierIds.CREDITS) {
priceId = process.env.CREDITS_PRICE_ID!;
} else {
throw new HttpError(404, 'Invalid tier');
}
let customer: Stripe.Customer | undefined;
let session: Stripe.Checkout.Session | undefined;
try {
customer = await fetchStripeCustomer(userEmail);
if (!customer) {
throw new HttpError(500, 'Error fetching customer');
}
session = await createStripeCheckoutSession({
priceId,
customerId: customer.id,
mode: tier === TierIds.CREDITS ? 'payment' : 'subscription',
});
if (!session) {
throw new HttpError(500, 'Error creating session');
}
} catch (error: any) {
const statusCode = error.statusCode || 500;
const errorMessage = error.message || 'Internal server error';
throw new HttpError(statusCode, errorMessage);
}
const updatedUser = await context.entities.User.update({
where: {
id: context.user.id,
},
data: {
checkoutSessionId: session.id,
stripeId: customer.id,
},
});
return {
sessionUrl: session.url,
sessionId: session.id,
};
};
type GptPayload = {
hours: string;
};
export const generateGptResponse: GenerateGptResponse<GptPayload, GeneratedSchedule> = async ({ hours }, context) => {
if (!context.user) {
throw new HttpError(401);
}
const tasks = await context.entities.Task.findMany({
where: {
user: {
id: context.user.id,
},
},
});
const parsedTasks = tasks.map(({ description, time }) => ({
description,
time,
}));
try {
// check if openai is initialized correctly with the API key
if (openai instanceof Error) {
throw openai;
}
if (!context.user.credits && (!context.user.subscriptionStatus || context.user.subscriptionStatus === 'deleted' || context.user.subscriptionStatus === 'past_due')) {
throw new HttpError(402, 'User has not paid or is out of credits');
} else if (context.user.credits && !context.user.subscriptionStatus) {
console.log('decrementing credits');
await context.entities.User.update({
where: { id: context.user.id },
data: {
credits: {
decrement: 1,
},
},
});
}
const completion = await openai.chat.completions.create({
model: 'gpt-3.5-turbo', // you can use any model here, e.g. 'gpt-3.5-turbo', 'gpt-4', etc.
messages: [
{
role: 'system',
content:
'you are an expert daily planner. you will be given a list of main tasks and an estimated time to complete each task. You will also receive the total amount of hours to be worked that day. Your job is to return a detailed plan of how to achieve those tasks by breaking each task down into at least 3 subtasks each. MAKE SURE TO ALWAYS CREATE AT LEAST 3 SUBTASKS FOR EACH MAIN TASK PROVIDED BY THE USER! YOU WILL BE REWARDED IF YOU DO.',
},
{
role: 'user',
content: `I will work ${hours} hours today. Here are the tasks I have to complete: ${JSON.stringify(
parsedTasks
)}. Please help me plan my day by breaking the tasks down into actionable subtasks with time and priority status.`,
},
],
tools: [
{
type: 'function',
function: {
name: 'parseTodaysSchedule',
description: 'parses the days tasks and returns a schedule',
parameters: {
type: 'object',
properties: {
mainTasks: {
type: 'array',
description: 'Name of main tasks provided by user, ordered by priority',
items: {
type: 'object',
properties: {
name: {
type: 'string',
description: 'Name of main task provided by user',
},
priority: {
type: 'string',
enum: ['low', 'medium', 'high'],
description: 'task priority',
},
},
},
},
subtasks: {
type: 'array',
items: {
type: 'object',
properties: {
description: {
type: 'string',
description:
'detailed breakdown and description of sub-task related to main task. e.g., "Prepare your learning session by first reading through the documentation"',
},
time: {
type: 'number',
description: 'time allocated for a given subtask in hours, e.g. 0.5',
},
mainTaskName: {
type: 'string',
description: 'name of main task related to subtask',
},
},
},
},
},
required: ['mainTasks', 'subtasks', 'time', 'priority'],
},
},
},
],
tool_choice: {
type: 'function',
function: {
name: 'parseTodaysSchedule',
},
},
temperature: 1,
});
const gptArgs = completion?.choices[0]?.message?.tool_calls?.[0]?.function.arguments;
if (!gptArgs) {
throw new HttpError(500, 'Bad response from OpenAI');
}
console.log('gpt function call arguments: ', gptArgs);
await context.entities.GptResponse.create({
data: {
user: { connect: { id: context.user.id } },
content: JSON.stringify(gptArgs),
},
});
return JSON.parse(gptArgs);
} catch (error: any) {
if (!context.user.subscriptionStatus && error?.statusCode != 402) {
await context.entities.User.update({
where: { id: context.user.id },
data: {
credits: {
increment: 1,
},
},
});
}
console.error(error);
const statusCode = error.statusCode || 500;
const errorMessage = error.message || 'Internal server error';
throw new HttpError(statusCode, errorMessage);
}
};
export const createTask: CreateTask<Pick<Task, 'description'>, Task> = async ({ description }, context) => {
if (!context.user) {
throw new HttpError(401);
}
const task = await context.entities.Task.create({
data: {
description,
user: { connect: { id: context.user.id } },
},
});
return task;
};
export const updateTask: UpdateTask<Partial<Task>, Task> = async ({ id, isDone, time }, context) => {
if (!context.user) {
throw new HttpError(401);
}
const task = await context.entities.Task.update({
where: {
id,
},
data: {
isDone,
time,
},
});
return task;
};
export const deleteTask: DeleteTask<Pick<Task, 'id'>, Task> = async ({ id }, context) => {
if (!context.user) {
throw new HttpError(401);
}
const task = await context.entities.Task.delete({
where: {
id,
},
});
return task;
};
export const updateUserById: UpdateUserById<{ id: number; 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;
};
type fileArgs = {
fileType: string;
name: string;
};
export const createFile: CreateFile<fileArgs, File> = async ({ fileType, name }, context) => {
if (!context.user) {
throw new HttpError(401);
}
const userInfo = context.user.id.toString();
const { uploadUrl, key } = await getUploadFileSignedURLFromS3({ fileType, userInfo });
return await context.entities.File.create({
data: {
name,
key,
uploadUrl,
type: fileType,
user: { connect: { id: context.user.id } },
},
});
};
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

@@ -0,0 +1,19 @@
import { type GetVerificationEmailContentFn, type GetPasswordResetEmailContentFn } from "wasp/server/auth";
export const getVerificationEmailContent: GetVerificationEmailContentFn = ({ verificationLink }) => ({
subject: 'Verify your email',
text: `Click the link below to verify your email: ${verificationLink}`,
html: `
<p>Click the link below to verify your email</p>
<a href="${verificationLink}">Verify email</a>
`,
});
export const getPasswordResetEmailContent: GetPasswordResetEmailContentFn = ({ passwordResetLink }) => ({
subject: 'Password reset',
text: `Click the link below to reset your password: ${passwordResetLink}`,
html: `
<p>Click the link below to reset your password</p>
<a href="${passwordResetLink}">Reset password</a>
`,
});

View File

@@ -0,0 +1,35 @@
import { defineUserSignupFields } from 'wasp/auth/providers/types';
const adminEmails = process.env.ADMIN_EMAILS?.split(',') || [];
export const getEmailUserFields = defineUserSignupFields({
username: (data: any) => data.email,
isAdmin: (data: any) => adminEmails.includes(data.email),
email: (data: any) => data.email,
});
export const getGitHubUserFields = defineUserSignupFields({
// NOTE: if we don't want to access users' emails, we can use scope ["user:read"]
// instead of ["user"] and access args.profile.username instead
email: (data: any) => data.profile.emails[0].email,
username: (data: any) => data.profile.login,
isAdmin: (data: any) => adminEmails.includes(data.profile.emails[0].email),
});
export function getGitHubAuthConfig() {
return {
scopes: ['user'],
};
}
export const getGoogleUserFields = defineUserSignupFields({
email: (data: any) => data.profile.email,
username: (data: any) => data.profile.name,
isAdmin: (data: any) => adminEmails.includes(data.profile.email),
});
export function getGoogleAuthConfig() {
return {
scopes: ['profile', 'email'], // must include at least 'profile' for Google
};
}

View File

@@ -0,0 +1,39 @@
import { randomUUID } from 'crypto';
import { S3Client } from '@aws-sdk/client-s3';
import { GetObjectCommand, PutObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
const s3Client = new S3Client({
region: process.env.AWS_S3_REGION,
credentials: {
accessKeyId: process.env.AWS_S3_IAM_ACCESS_KEY!,
secretAccessKey: process.env.AWS_S3_IAM_SECRET_KEY!,
},
});
type S3Upload = {
fileType: string;
userInfo: 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 = {
Bucket: process.env.AWS_S3_FILES_BUCKET,
Key: key,
};
const command = new GetObjectCommand(s3Params);
return await getSignedUrl(s3Client, command, { expiresIn: 3600 });
}

View File

@@ -0,0 +1,63 @@
import Stripe from 'stripe';
import { HttpError } from 'wasp/server';
const stripe = new Stripe(process.env.STRIPE_KEY!, {
apiVersion: '2022-11-15',
});
// WASP_WEB_CLIENT_URL will be set up by Wasp when deploying to production: https://wasp-lang.dev/docs/deploying
const DOMAIN = process.env.WASP_WEB_CLIENT_URL || 'http://localhost:3000';
export async function fetchStripeCustomer(customerEmail: string) {
let customer: Stripe.Customer;
try {
const stripeCustomers = await stripe.customers.list({
email: customerEmail,
});
if (!stripeCustomers.data.length) {
console.log('creating customer');
customer = await stripe.customers.create({
email: customerEmail,
});
} else {
console.log('using existing customer');
customer = stripeCustomers.data[0];
}
return customer;
} catch (error: any) {
console.error(error.message);
throw error;
}
}
export async function createStripeCheckoutSession({
priceId,
customerId,
mode,
}: {
priceId: string;
customerId: string;
mode: 'subscription' | 'payment';
}) {
try {
return await stripe.checkout.sessions.create({
line_items: [
{
price: priceId,
quantity: 1,
},
],
mode: mode,
success_url: `${DOMAIN}/checkout?success=true`,
cancel_url: `${DOMAIN}/checkout?canceled=true`,
automatic_tax: { enabled: true },
customer_update: {
address: 'auto',
},
customer: customerId,
});
} catch (error: any) {
console.error(error.message);
throw error;
}
}

View File

@@ -0,0 +1,203 @@
import { type DailyStats, type GptResponse, type User, type PageViewSource, type Task, type File } from 'wasp/entities';
import { HttpError } from 'wasp/server';
import {
type GetGptResponses,
type GetDailyStats,
type GetPaginatedUsers,
type GetAllTasksByUser,
type GetAllFilesByUser,
type GetDownloadFileSignedURL,
} from 'wasp/server/operations';
import { getDownloadFileSignedURLFromS3 } from './file-upload/s3Utils.js';
import { type SubscriptionStatusOptions } from '../shared/types.js';
type DailyStatsWithSources = DailyStats & {
sources: PageViewSource[];
};
type DailyStatsValues = {
dailyStats: DailyStatsWithSources;
weeklyStats: DailyStatsWithSources[];
};
export const getGptResponses: GetGptResponses<void, GptResponse[]> = async (args, context) => {
if (!context.user) {
throw new HttpError(401);
}
return context.entities.GptResponse.findMany({
where: {
user: {
id: context.user.id,
},
},
});
};
export const getAllTasksByUser: GetAllTasksByUser<void, Task[]> = async (_args, context) => {
if (!context.user) {
throw new HttpError(401);
}
return context.entities.Task.findMany({
where: {
user: {
id: context.user.id,
},
},
orderBy: {
createdAt: 'desc',
},
});
};
export const getAllFilesByUser: GetAllFilesByUser<void, File[]> = async (_args, context) => {
if (!context.user) {
throw new HttpError(401);
}
return context.entities.File.findMany({
where: {
user: {
id: context.user.id,
},
},
orderBy: {
createdAt: 'desc',
},
});
};
export const getDownloadFileSignedURL: GetDownloadFileSignedURL<{ key: string }, string> = async (
{ key },
_context
) => {
return await getDownloadFileSignedURLFromS3({ key });
};
export const getDailyStats: GetDailyStats<void, DailyStatsValues> = async (_args, context) => {
if (!context.user?.isAdmin) {
throw new HttpError(401);
}
const dailyStats = await context.entities.DailyStats.findFirstOrThrow({
orderBy: {
date: 'desc',
},
include: {
sources: true,
},
});
const weeklyStats = await context.entities.DailyStats.findMany({
orderBy: {
date: 'desc',
},
take: 7,
include: {
sources: true,
},
});
return { dailyStats, weeklyStats };
};
type GetPaginatedUsersInput = {
skip: number;
cursor?: number | undefined;
emailContains?: string;
isAdmin?: boolean;
subscriptionStatus?: SubscriptionStatusOptions[];
};
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

@@ -0,0 +1,49 @@
import { type User } from 'wasp/entities';
import { faker } from '@faker-js/faker';
import type { PrismaClient } from '@prisma/client';
import { TierIds } from '../../shared/constants.js';
// in a terminal window run `wasp db seed` to seed your dev database with mock user data
export function createRandomUser() {
const firstName = faker.person.firstName();
const lastName = faker.person.lastName();
const user: Omit<User, 'id'> = {
email: faker.internet.email({
firstName,
lastName,
}),
username: faker.internet.userName({
firstName,
lastName,
}),
createdAt: faker.date.between({ from: new Date('2023-01-01'), to: new Date() }),
lastActiveTimestamp: faker.date.recent(),
isAdmin: false,
stripeId: `cus_${faker.string.uuid()}`,
sendEmail: false,
subscriptionStatus: faker.helpers.arrayElement(['active', 'canceled', 'past_due', 'deleted', null]),
datePaid: faker.date.recent(),
credits: faker.number.int({ min: 0, max: 3 }),
checkoutSessionId: null,
subscriptionTier: faker.helpers.arrayElement([TierIds.HOBBY, TierIds.PRO]),
};
return user;
}
const USERS: Omit<User, 'id'>[] = faker.helpers.multiple(createRandomUser, {
count: 50,
});
export async function devSeedUsers(prismaClient: PrismaClient) {
try {
await Promise.all(
USERS.map(async (user) => {
await prismaClient.user.create({
data: user,
});
})
);
} catch (error) {
console.error(error);
}
}

View File

@@ -0,0 +1,194 @@
import { emailSender } from 'wasp/server/email';
import { type MiddlewareConfigFn } from 'wasp/server';
import { type StripeWebhook } from 'wasp/server/api';
import express from 'express';
import { TierIds } from '../../shared/constants.js';
import Stripe from 'stripe';
// make sure the api version matches the version in the Stripe dashboard
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 stripeWebhook: StripeWebhook = async (request, response, context) => {
const sig = request.headers['stripe-signature'] as string;
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(request.body, sig, process.env.STRIPE_WEBHOOK_SECRET!);
// console.table({sig: 'stripe webhook signature verified', type: event.type})
} catch (err: any) {
console.log(err.message);
return response.status(400).send(`Webhook Error: ${err.message}`);
}
try {
if (event.type === 'checkout.session.completed') {
console.log('Checkout session completed');
const session = event.data.object as Stripe.Checkout.Session;
const userStripeId = session.customer as string;
if (!userStripeId) {
console.log('No userStripeId in session');
return response.status(400).send(`Webhook Error: No userStripeId in session`);
}
const { line_items } = await stripe.checkout.sessions.retrieve(session.id, {
expand: ['line_items'],
});
/**
* here are your products, both subscriptions and one-time payments.
* make sure to configure them in the Stripe dashboard first!
* see: https://docs.opensaas.sh/guides/stripe-integration/
*/
if (line_items?.data[0]?.price?.id === process.env.HOBBY_SUBSCRIPTION_PRICE_ID) {
console.log('Hobby subscription purchased');
await context.entities.User.updateMany({
where: {
stripeId: userStripeId,
},
data: {
datePaid: new Date(),
subscriptionTier: TierIds.HOBBY,
},
});
} else if (line_items?.data[0]?.price?.id === process.env.PRO_SUBSCRIPTION_PRICE_ID) {
console.log('Pro subscription purchased');
await context.entities.User.updateMany({
where: {
stripeId: userStripeId,
},
data: {
datePaid: new Date(),
subscriptionTier: TierIds.PRO,
},
});
} else if (line_items?.data[0]?.price?.id === process.env.CREDITS_PRICE_ID) {
console.log('Credits purchased');
await context.entities.User.updateMany({
where: {
stripeId: userStripeId,
},
data: {
credits: {
increment: 10,
},
datePaid: new Date(),
},
});
} else {
response.status(404).send('Invalid product');
}
} else if (event.type === 'invoice.paid') {
const invoice = event.data.object as Stripe.Invoice;
const userStripeId = invoice.customer as string;
const periodStart = new Date(invoice.period_start * 1000);
await context.entities.User.updateMany({
where: {
stripeId: userStripeId,
},
data: {
datePaid: periodStart,
},
});
} else if (event.type === 'customer.subscription.updated') {
const subscription = event.data.object as Stripe.Subscription;
const userStripeId = subscription.customer as string;
if (subscription.status === 'active') {
console.log('Subscription active ', userStripeId);
await context.entities.User.updateMany({
where: {
stripeId: userStripeId,
},
data: {
subscriptionStatus: 'active',
},
});
}
/**
* you'll want to make a check on the front end to see if the subscription is past due
* and then prompt the user to update their payment method
* this is useful if the user's card expires or is canceled and automatic subscription renewal fails
*/
if (subscription.status === 'past_due') {
console.log('Subscription past due for user: ', userStripeId);
await context.entities.User.updateMany({
where: {
stripeId: userStripeId,
},
data: {
subscriptionStatus: 'past_due',
},
});
}
/**
* Stripe will send a subscription.updated event when a subscription is canceled
* but the subscription is still active until the end of the period.
* So we check if cancel_at_period_end is true and send an email to the customer.
* https://stripe.com/docs/billing/subscriptions/cancel#events
*/
if (subscription.cancel_at_period_end) {
console.log('Subscription canceled at period end for user: ', userStripeId);
let customer = await context.entities.User.findFirst({
where: {
stripeId: userStripeId,
},
select: {
id: true,
email: true,
},
});
if (customer) {
await context.entities.User.update({
where: {
id: customer.id,
},
data: {
subscriptionStatus: 'canceled',
},
});
if (customer.email) {
await emailSender.send({
to: customer.email,
subject: 'We hate to see you go :(',
text: 'We hate to see you go. Here is a sweet offer...',
html: 'We hate to see you go. Here is a sweet offer...',
});
}
}
}
} else if (event.type === 'customer.subscription.deleted') {
const subscription = event.data.object as Stripe.Subscription;
const userStripeId = subscription.customer as string;
/**
* Stripe will send then finally send a subscription.deleted event when subscription period ends
* https://stripe.com/docs/billing/subscriptions/cancel#events
*/
console.log('Subscription deleted/ended for user: ', userStripeId);
await context.entities.User.updateMany({
where: {
stripeId: userStripeId,
},
data: {
subscriptionStatus: 'deleted',
},
});
} else {
console.log(`Unhandled event type ${event.type}`);
}
response.json({ received: true });
} catch (err: any) {
response.status(400).send(`Webhook Error: ${err?.message}`);
}
};
// This allows us to override Wasp's defaults and parse the raw body of the request from Stripe to verify the signature
export const stripeMiddlewareFn: MiddlewareConfigFn = (middlewareConfig) => {
middlewareConfig.delete('express.json');
middlewareConfig.set('express.raw', express.raw({ type: 'application/json' }));
return middlewareConfig;
};

View File

@@ -0,0 +1,152 @@
import { type DailyStatsJob } from 'wasp/server/jobs';
import Stripe from 'stripe';
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: DailyStatsJob<never, void> = async (_args, context) => {
const nowUTC = new Date(Date.now());
nowUTC.setUTCHours(0, 0, 0, 0);
const yesterdayUTC = new Date(nowUTC);
yesterdayUTC.setUTCDate(yesterdayUTC.getUTCDate() - 1);
try {
const yesterdaysStats = await context.entities.DailyStats.findFirst({
where: {
date: {
equals: yesterdayUTC,
},
},
});
const userCount = await context.entities.User.count({});
// users can have paid but canceled subscriptions which terminate at the end of the period
// we don't want to count those users as current paying users
const paidUserCount = await context.entities.User.count({
where: {
subscriptionStatus: 'active',
},
});
let userDelta = userCount;
let paidUserDelta = paidUserCount;
if (yesterdaysStats) {
userDelta -= yesterdaysStats.userCount;
paidUserDelta -= yesterdaysStats.paidUserCount;
}
const totalRevenue = await fetchTotalStripeRevenue();
const { totalViews, prevDayViewsChangePercent } = await getDailyPageViews();
let dailyStats = await context.entities.DailyStats.findUnique({
where: {
date: nowUTC,
},
});
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) {
let visitors = source.visitors;
if (typeof source.visitors !== 'number') {
visitors = parseInt(source.visitors);
}
await context.entities.PageViewSource.upsert({
where: {
date_name: {
date: nowUTC,
name: source.source,
},
},
create: {
date: nowUTC,
name: source.source,
visitors,
dailyStatsId: dailyStats.id,
},
update: {
visitors,
},
});
}
console.table({ dailyStats });
} 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 fetchTotalStripeRevenue() {
let totalRevenue = 0;
let params: Stripe.BalanceTransactionListParams = {
limit: 100,
// created: {
// gte: startTimestamp,
// lt: endTimestamp
// },
type: 'charge',
};
let hasMore = true;
while (hasMore) {
const balanceTransactions = await stripe.balanceTransactions.list(params);
for (const transaction of balanceTransactions.data) {
if (transaction.type === 'charge') {
totalRevenue += transaction.amount;
}
}
if (balanceTransactions.has_more) {
// Set the starting point for the next iteration to the last object fetched
params.starting_after = balanceTransactions.data[balanceTransactions.data.length - 1].id;
} else {
hasMore = false;
}
}
// Revenue is in cents so we convert to dollars (or your main currency unit)
const formattedRevenue = totalRevenue / 100;
return formattedRevenue;
}

View File

@@ -0,0 +1,54 @@
import { type EmailChecker } from 'wasp/server/jobs';
import { type User } from 'wasp/entities';
import { emailSender } from 'wasp/server/email';
import { type Email } from 'wasp/server/email/core/types'; // TODO fix after it gets fixed in wasp :)
const emailToSend: Email = {
to: '',
subject: 'The SaaS App Newsletter',
text: 'Hey There! \n\nThis is just a newsletter that sends automatically via cron jobs',
html: `<html lang="en">
<head>
<meta charset="UTF-8">
<title>SaaS App Newsletter</title>
</head>
<body>
<p>Hey There!</p>
<p>This is just a newsletter that sends automatically via cron jobs</p>
</body>
</html>`,
};
// you could use this function to send newsletters, expiration notices, etc.
export const checkAndQueueEmails: EmailChecker<never, void> = async (_args, context) => {
// e.g. you could send an offer email 2 weeks before their subscription expires
const currentDate = new Date();
const twoWeeksFromNow = new Date(currentDate.getTime() + 14 * 24 * 60 * 60 * 1000);
const users = (await context.entities.User.findMany({
where: {
datePaid: {
equals: twoWeeksFromNow,
},
sendEmail: true,
},
})) as User[];
if (users.length === 0) {
return;
}
await Promise.allSettled(
users.map(async (user) => {
if (user.email) {
try {
emailToSend.to = user.email;
await emailSender.send(emailToSend);
} catch (error) {
console.error('Error sending notice to user: ', user.id, error);
}
}
})
);
};

View File

@@ -0,0 +1,141 @@
import { BetaAnalyticsDataClient } from '@google-analytics/data';
const CLIENT_EMAIL = process.env.GOOGLE_ANALYTICS_CLIENT_EMAIL;
const PRIVATE_KEY = Buffer.from(process.env.GOOGLE_ANALYTICS_PRIVATE_KEY!, 'base64').toString('utf-8');
const PROPERTY_ID = process.env.GOOGLE_ANALYTICS_PROPERTY_ID;
const analyticsDataClient = new BetaAnalyticsDataClient({
credentials: {
client_email: CLIENT_EMAIL,
private_key: PRIVATE_KEY,
},
});
export async function getSources() {
const [response] = await analyticsDataClient.runReport({
property: `properties/${PROPERTY_ID}`,
dateRanges: [
{
startDate: '2020-01-01',
endDate: 'today',
},
],
// for a list of dimensions and metrics see https://developers.google.com/analytics/devguides/reporting/data/v1/api-schema
dimensions: [
{
name: 'source',
},
],
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/${PROPERTY_ID}`,
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/${PROPERTY_ID}`,
dateRanges: [
{
startDate: '2daysAgo',
endDate: 'yesterday',
},
],
orderBys: [
{
dimension: {
dimensionName: 'date',
},
desc: true,
},
],
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) {
return '0';
}
console.table({ viewsFromYesterday, viewsFromDayBeforeYesterday });
const change = ((viewsFromYesterday - viewsFromDayBeforeYesterday) / viewsFromDayBeforeYesterday) * 100;
return change.toFixed(0);
}
} else {
return '0';
}
}

View File

@@ -0,0 +1,103 @@
const PLAUSIBLE_API_KEY = process.env.PLAUSIBLE_API_KEY!;
const PLAUSIBLE_SITE_ID = process.env.PLAUSIBLE_SITE_ID!;
const PLAUSIBLE_BASE_URL = process.env.PLAUSIBLE_BASE_URL;
const headers = {
'Content-Type': 'application/json',
Authorization: `Bearer ${PLAUSIBLE_API_KEY}`,
};
type PageViewsResult = {
results: {
[key: string]: {
value: number;
};
};
};
type PageViewSourcesResult = {
results: [
{
source: string;
visitors: number;
}
];
};
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`,
{
method: 'GET',
headers: headers,
}
);
if (!response.ok) {
throw new Error(`HTTP error! Status: ${response.status}`);
}
const json = (await response.json()) as PageViewsResult;
return json.results.pageviews.value;
}
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];
// 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,
});
let change = 0;
if (pageViewsYesterday === 0 || pageViewsDayBeforeYesterday === 0) {
return '0';
} else {
change = ((pageViewsYesterday - pageViewsDayBeforeYesterday) / pageViewsDayBeforeYesterday) * 100;
}
return change.toFixed(0);
}
async function getPageviewsForDate(date: string) {
const url = `${PLAUSIBLE_BASE_URL}/v1/stats/aggregate?site_id=${PLAUSIBLE_SITE_ID}&period=day&date=${date}&metrics=pageviews`;
const response = await fetch(url, {
method: 'GET',
headers: headers,
});
if (!response.ok) {
throw new Error(`HTTP error! Status: ${response.status}`);
}
const data = (await response.json()) as PageViewsResult;
return data.results.pageviews.value;
}
export async function getSources() {
const url = `${PLAUSIBLE_BASE_URL}/v1/stats/breakdown?site_id=${PLAUSIBLE_SITE_ID}&property=visit:source&metrics=visitors`;
const response = await fetch(url, {
method: 'GET',
headers: headers,
});
if (!response.ok) {
throw new Error(`HTTP error! Status: ${response.status}`);
}
const data = (await response.json()) as PageViewSourcesResult;
return data.results;
}