mirror of
https://github.com/wasp-lang/open-saas.git
synced 2025-11-24 07:47:02 +01:00
All in dirs (#154)
* Split the project into template and opensaas-sh (demo app (diff) + docs). * fix
This commit is contained in:
347
template/app/src/server/actions.ts
Normal file
347
template/app/src/server/actions.ts
Normal 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,
|
||||
});
|
||||
};
|
||||
19
template/app/src/server/auth/email.ts
Normal file
19
template/app/src/server/auth/email.ts
Normal 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>
|
||||
`,
|
||||
});
|
||||
35
template/app/src/server/auth/setUsername.ts
Normal file
35
template/app/src/server/auth/setUsername.ts
Normal 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
|
||||
};
|
||||
}
|
||||
39
template/app/src/server/file-upload/s3Utils.ts
Normal file
39
template/app/src/server/file-upload/s3Utils.ts
Normal 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 });
|
||||
}
|
||||
63
template/app/src/server/payments/stripeUtils.ts
Normal file
63
template/app/src/server/payments/stripeUtils.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
203
template/app/src/server/queries.ts
Normal file
203
template/app/src/server/queries.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
49
template/app/src/server/scripts/usersSeed.ts
Normal file
49
template/app/src/server/scripts/usersSeed.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
194
template/app/src/server/webhooks/stripe.ts
Normal file
194
template/app/src/server/webhooks/stripe.ts
Normal 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;
|
||||
};
|
||||
152
template/app/src/server/workers/calculateDailyStats.ts
Normal file
152
template/app/src/server/workers/calculateDailyStats.ts
Normal 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;
|
||||
}
|
||||
54
template/app/src/server/workers/checkAndQueueEmails.ts
Normal file
54
template/app/src/server/workers/checkAndQueueEmails.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
};
|
||||
141
template/app/src/server/workers/googleAnalyticsUtils.ts
Normal file
141
template/app/src/server/workers/googleAnalyticsUtils.ts
Normal 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';
|
||||
}
|
||||
}
|
||||
103
template/app/src/server/workers/plausibleAnalyticsUtils.ts
Normal file
103
template/app/src/server/workers/plausibleAnalyticsUtils.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user