mirror of
https://github.com/wasp-lang/open-saas.git
synced 2025-04-09 20:39:02 +02:00
update for rc
This commit is contained in:
parent
70f7ce062b
commit
5101652e72
20
README.md
20
README.md
@ -3,25 +3,17 @@
|
||||
<img src='src/client/static/gptsaastemplate.png' width='700px'/>
|
||||
|
||||
## Running it locally
|
||||
After cloning this repo, you can run it locally by following these steps:
|
||||
Before you being, install [Wasp](https://wasp-lang.dev) by running `curl -sSL https://get.wasp-lang.dev/installer.sh | sh` in your terminal.
|
||||
ou have two options to run this template locally:
|
||||
|
||||
1. Install [Wasp](https://wasp-lang.dev) by running `curl -sSL https://get.wasp-lang.dev/installer.sh | sh` in your terminal.
|
||||
1. Run `wasp new <project-name> -t saas`. This will clone and set up the repo in the background.
|
||||
2. Create a `.env.server` file in the root of the project
|
||||
3. Copy the `env.example` file contents to `.env.server` and fill in your API keys
|
||||
4. Make sure you have a Database connected and running. Here are two quick options:
|
||||
- Provision a Postgres database on [Railway](https://railway.app), go to settings and copy the `connection url`. Past it as `DATABASE_URL=<your-postgres-connection-url>` into your `env.server` file.
|
||||
- or you can spin up a Postgres docker container with this command:
|
||||
```shell
|
||||
docker run \
|
||||
--rm \
|
||||
--publish 5432:5432 \
|
||||
-v my-app-data:/var/lib/postgresql/data \
|
||||
--env POSTGRES_PASSWORD=devpass1234 \
|
||||
postgres
|
||||
```
|
||||
and then paste `DATABASE_URL=postgresql://postgres:devpass1234@localhost:5432/postgres` into your `env.server` file
|
||||
- run `wasp start db` if you have Docker installed (if not, on MacOS run `brew install docker-machine docker`). This will start a Postgres database for you. No need to do anything else! 🤯
|
||||
- or provision a Postgres database on [Railway](https://railway.app), go to settings and copy the `connection url`. Past it as `DATABASE_URL=<your-postgres-connection-url>` into your `env.server` file.
|
||||
5. Run `wasp db migrate-dev`
|
||||
6. Run `wasp start`
|
||||
6. Run `wasp start`. This will install all dependencies and start the client and server for you :)
|
||||
7. Go to `localhost:3000` in your browser (your NodeJS server will be running on port `3001`)
|
||||
8. Install the Wasp extension for VSCode to get the best DX
|
||||
9. Check the files for comments containing specific instructions
|
||||
|
@ -8,4 +8,6 @@ GOOGLE_CLIENT_SECRET=
|
||||
|
||||
OPENAI_API_KEY=
|
||||
|
||||
SENDGRID_API_KEY=
|
||||
SENDGRID_API_KEY=
|
||||
# if not excplicitly set to true, emails be logged to console but not actually sent
|
||||
SEND_EMAILS_IN_DEVELOPMENT=true
|
36
main.wasp
36
main.wasp
@ -1,6 +1,6 @@
|
||||
app SaaSTemplate {
|
||||
wasp: {
|
||||
version: "^0.9.0"
|
||||
version: "^0.10.0"
|
||||
},
|
||||
title: "My SaaS App",
|
||||
head: [
|
||||
@ -8,7 +8,7 @@ app SaaSTemplate {
|
||||
"<meta property='og:url' content='https://mySaaSapp.com' />",
|
||||
"<meta property='og:description' content='I made a SaaS App. Buy my stuff.' />",
|
||||
"<meta property='og:image' content='src/client/static/image.png' />",
|
||||
// put your google analytics script here, too!
|
||||
// you can put your google analytics script here, too!
|
||||
],
|
||||
// 🔐 Auth out of the box! https://wasp-lang.dev/docs/language/features#authentication--authorization
|
||||
auth: {
|
||||
@ -25,19 +25,23 @@ app SaaSTemplate {
|
||||
db: {
|
||||
system: PostgreSQL
|
||||
},
|
||||
server: {
|
||||
setupFn: import serverSetup from "@server/serverSetup.js"
|
||||
},
|
||||
client: {
|
||||
rootComponent: import App from "@client/App",
|
||||
},
|
||||
emailSender: {
|
||||
provider: SendGrid,
|
||||
defaultFrom: {
|
||||
name: "MySaaSApp",
|
||||
// make sure this address is the same you registered your SendGrid account with!
|
||||
email: "email@mysaasapp.com"
|
||||
},
|
||||
},
|
||||
dependencies: [
|
||||
("@headlessui/react", "1.7.13"),
|
||||
("@tailwindcss/forms", "^0.5.3"),
|
||||
("@tailwindcss/typography", "^0.5.7"),
|
||||
("react-hook-form", "7.43.1"),
|
||||
("react-icons", "4.8.0"),
|
||||
("@sendgrid/mail", "7.7.0"),
|
||||
("request-ip", "3.3.0"),
|
||||
("@types/request-ip", "0.0.37"),
|
||||
("node-fetch", "3.3.0"),
|
||||
@ -52,9 +56,7 @@ app SaaSTemplate {
|
||||
|
||||
entity User {=psl
|
||||
id Int @id @default(autoincrement())
|
||||
username String @unique
|
||||
email String @unique
|
||||
password String
|
||||
stripeId String?
|
||||
checkoutSessionId String?
|
||||
hasPaid Boolean @default(false)
|
||||
@ -155,6 +157,16 @@ query getRelatedObjects {
|
||||
entities: [User, RelatedObject]
|
||||
}
|
||||
|
||||
/*
|
||||
* 📡 These are custom Wasp API Endpoints. Use them for callbacks, webhooks, etc.
|
||||
*/
|
||||
|
||||
api stripeWebhook {
|
||||
fn: import { stripeWebhook } from "@server/webhooks.js",
|
||||
entities: [User],
|
||||
httpRoute: (POST, "/stripe-webhook")
|
||||
}
|
||||
|
||||
/* 🕵️♂️ These are the Wasp Cron Jobs. Use them to set up recurring tasks and/or queues:
|
||||
* https://wasp-lang.dev/docs/language/features#jobs
|
||||
*/
|
||||
@ -169,11 +181,3 @@ job emailChecker {
|
||||
},
|
||||
entities: [User]
|
||||
}
|
||||
|
||||
job emailSender {
|
||||
executor: PgBoss,
|
||||
perform: {
|
||||
fn: import { sendGrid } from "@server/workers/sendGrid.js"
|
||||
},
|
||||
entities: [User]
|
||||
}
|
13
migrations/20230407100445_drop_userpass/migration.sql
Normal file
13
migrations/20230407100445_drop_userpass/migration.sql
Normal file
@ -0,0 +1,13 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- You are about to drop the column `password` on the `User` table. All the data in the column will be lost.
|
||||
- You are about to drop the column `username` on the `User` table. All the data in the column will be lost.
|
||||
|
||||
*/
|
||||
-- DropIndex
|
||||
DROP INDEX "User_username_key";
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "User" DROP COLUMN "password",
|
||||
DROP COLUMN "username";
|
@ -1,10 +1,7 @@
|
||||
import HttpError from '@wasp/core/HttpError.js';
|
||||
import fetch from 'node-fetch';
|
||||
import type { RelatedObject, User } from '@wasp/entities';
|
||||
import type {
|
||||
GenerateGptResponse,
|
||||
StripePayment,
|
||||
} from '@wasp/actions/types';
|
||||
import HttpError from '@wasp/core/HttpError.js';
|
||||
import type { RelatedObject } from '@wasp/entities';
|
||||
import type { GenerateGptResponse, StripePayment } from '@wasp/actions/types';
|
||||
import type { StripePaymentResult, OpenAIResponse } from './types';
|
||||
import Stripe from 'stripe';
|
||||
|
||||
@ -101,7 +98,6 @@ export const generateGptResponse: GenerateGptResponse<GptPayload, RelatedObject>
|
||||
temperature: Number(temperature),
|
||||
};
|
||||
|
||||
|
||||
try {
|
||||
if (!context.user.hasPaid && !context.user.credits) {
|
||||
throw new HttpError(402, 'User has not paid or is out of credits');
|
||||
@ -117,7 +113,7 @@ export const generateGptResponse: GenerateGptResponse<GptPayload, RelatedObject>
|
||||
});
|
||||
}
|
||||
|
||||
console.log('fetching', payload)
|
||||
console.log('fetching', payload);
|
||||
const response = await fetch('https://api.openai.com/v1/chat/completions', {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@ -127,15 +123,14 @@ export const generateGptResponse: GenerateGptResponse<GptPayload, RelatedObject>
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
const json = (await response.json()) as OpenAIResponse
|
||||
console.log('response json', json)
|
||||
const json = (await response.json()) as OpenAIResponse;
|
||||
console.log('response json', json);
|
||||
return context.entities.RelatedObject.create({
|
||||
data: {
|
||||
content: json?.choices[0].message.content,
|
||||
user: { connect: { id: context.user.id } },
|
||||
},
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
if (!context.user.hasPaid) {
|
||||
await context.entities.User.update({
|
||||
@ -153,4 +148,4 @@ export const generateGptResponse: GenerateGptResponse<GptPayload, RelatedObject>
|
||||
return new Promise((resolve, reject) => {
|
||||
reject(new HttpError(500, 'Something went wrong'));
|
||||
});
|
||||
};
|
||||
};
|
||||
|
@ -2,9 +2,8 @@
|
||||
|
||||
export async function getUserFields(_context: unknown, args: any) {
|
||||
console.log('args', args.profile)
|
||||
const username = args.profile.emails[0].value
|
||||
const email = args.profile.emails[0].value
|
||||
return { username, email };
|
||||
return { email };
|
||||
}
|
||||
|
||||
export function config() {
|
||||
|
@ -1,7 +1,7 @@
|
||||
import type { ServerSetupFnContext } from '@wasp/types';
|
||||
import { StripeWebhook } from '@wasp/apis/types';
|
||||
import { emailSender } from '@wasp/email/index.js';
|
||||
|
||||
import Stripe from 'stripe';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { emailSender } from '@wasp/jobs/emailSender.js';
|
||||
import requestIp from 'request-ip';
|
||||
|
||||
export const STRIPE_WEBHOOK_IPS = [
|
||||
@ -19,43 +19,28 @@ export const STRIPE_WEBHOOK_IPS = [
|
||||
'54.187.216.72',
|
||||
];
|
||||
|
||||
export const prisma = new PrismaClient();
|
||||
|
||||
const stripe = new Stripe(process.env.STRIPE_KEY!, {
|
||||
apiVersion: '2022-11-15',
|
||||
});
|
||||
|
||||
/** 🪝 Server Setup
|
||||
* This is a custom API endpoint that is used to handle Stripe webhooks.
|
||||
* Wasp will setup all the other endpoints for you automatically
|
||||
* based on your queries and actions in the main.wasp file 🎉
|
||||
*/
|
||||
export const stripeWebhook: StripeWebhook = async (request, response, context) => {
|
||||
response.set('Access-Control-Allow-Origin', '*'); // Example of modifying headers to override Wasp default CORS middleware.
|
||||
|
||||
export default async function ({ app, server }: ServerSetupFnContext) {
|
||||
// this just tests that the sendgrid worker is working correctly
|
||||
// it can be removed here after sendgrid is properly configured
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
const detectedIp = requestIp.getClientIp(request) as string;
|
||||
const isStripeIP = STRIPE_WEBHOOK_IPS.includes(detectedIp);
|
||||
|
||||
// await emailSender.submit({
|
||||
// to: 'your@email.com',
|
||||
// subject: 'Test',
|
||||
// text: 'Test',
|
||||
// html: 'Test',
|
||||
// });
|
||||
|
||||
app.post('/stripe-webhook', async (request, response) => {
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
const detectedIp = requestIp.getClientIp(request) as string;
|
||||
const isStripeIP = STRIPE_WEBHOOK_IPS.includes(detectedIp);
|
||||
|
||||
if (!isStripeIP) {
|
||||
console.log('IP address not from Stripe: ', detectedIp);
|
||||
return response.status(403).json({ received: false });
|
||||
}
|
||||
if (!isStripeIP) {
|
||||
console.log('IP address not from Stripe: ', detectedIp);
|
||||
return response.status(403).json({ received: false });
|
||||
}
|
||||
}
|
||||
|
||||
let event: Stripe.Event = request.body;
|
||||
let userStripeId: string | null = null;
|
||||
// console.log('event', event)
|
||||
let event: Stripe.Event;
|
||||
let userStripeId: string | null = null;
|
||||
|
||||
try {
|
||||
event = request.body;
|
||||
|
||||
if (event.type === 'invoice.paid') {
|
||||
const charge = event.data.object as Stripe.Invoice;
|
||||
@ -63,7 +48,7 @@ export default async function ({ app, server }: ServerSetupFnContext) {
|
||||
|
||||
if (charge.amount_paid === 999) {
|
||||
console.log('Subscription purchased: ', charge.amount_paid);
|
||||
await prisma.user.updateMany({
|
||||
await context.entities.User.updateMany({
|
||||
where: {
|
||||
stripeId: userStripeId,
|
||||
},
|
||||
@ -75,7 +60,7 @@ export default async function ({ app, server }: ServerSetupFnContext) {
|
||||
|
||||
if (charge.amount_paid === 295) {
|
||||
console.log('Credits purchased: ', charge.amount_paid);
|
||||
await prisma.user.updateMany({
|
||||
await context.entities.User.updateMany({
|
||||
where: {
|
||||
stripeId: userStripeId,
|
||||
},
|
||||
@ -91,7 +76,7 @@ export default async function ({ app, server }: ServerSetupFnContext) {
|
||||
userStripeId = subscription.customer as string;
|
||||
|
||||
if (subscription.cancel_at_period_end) {
|
||||
const customerEmail = await prisma.user.findFirst({
|
||||
const customerEmail = await context.entities.User.findFirst({
|
||||
where: {
|
||||
stripeId: userStripeId,
|
||||
},
|
||||
@ -101,7 +86,7 @@ export default async function ({ app, server }: ServerSetupFnContext) {
|
||||
});
|
||||
|
||||
if (customerEmail) {
|
||||
await emailSender.submit({
|
||||
await emailSender.send({
|
||||
to: customerEmail.email,
|
||||
subject: 'We hate to see you go :(',
|
||||
text: 'We hate to see you go. Here is a sweet offer...',
|
||||
@ -114,7 +99,7 @@ export default async function ({ app, server }: ServerSetupFnContext) {
|
||||
userStripeId = subscription.customer as string;
|
||||
|
||||
console.log('Subscription canceled');
|
||||
await prisma.user.updateMany({
|
||||
await context.entities.User.updateMany({
|
||||
where: {
|
||||
stripeId: userStripeId,
|
||||
},
|
||||
@ -126,7 +111,8 @@ export default async function ({ app, server }: ServerSetupFnContext) {
|
||||
console.log(`Unhandled event type ${event.type}`);
|
||||
}
|
||||
|
||||
// Return a 200 response to acknowledge receipt of the event
|
||||
response.json({ received: true });
|
||||
});
|
||||
}
|
||||
} catch (err: any) {
|
||||
response.status(400).send(`Webhook Error: ${err?.message}`);
|
||||
}
|
||||
};
|
@ -1,5 +1,7 @@
|
||||
import { emailSender } from '@wasp/jobs/emailSender.js';
|
||||
import type { Email } from './sendGrid';
|
||||
import { emailSender } from '@wasp/email/index.js'
|
||||
|
||||
import type { Email } from '@wasp/email/core/types';
|
||||
import type { User } from '@wasp/entities'
|
||||
import type { Context } from '../types';
|
||||
|
||||
const emailToSend: Email = {
|
||||
@ -19,7 +21,10 @@ const emailToSend: Email = {
|
||||
</html>`,
|
||||
};
|
||||
|
||||
// you could use this function to send newsletters, expiration notices, etc.
|
||||
export async function checkAndQueueEmails(_args: unknown, context: 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);
|
||||
|
||||
@ -32,7 +37,7 @@ export async function checkAndQueueEmails(_args: unknown, context: Context) {
|
||||
},
|
||||
sendEmail: true,
|
||||
},
|
||||
});
|
||||
}) as User[];
|
||||
|
||||
console.log('Sending expiration notices to users: ', users.length);
|
||||
|
||||
@ -45,7 +50,7 @@ export async function checkAndQueueEmails(_args: unknown, context: Context) {
|
||||
if (user.email) {
|
||||
try {
|
||||
emailToSend.to = user.email;
|
||||
await emailSender.submit(emailToSend);
|
||||
await emailSender.send(emailToSend);
|
||||
} catch (error) {
|
||||
console.error('Error sending expiration notice to user: ', user.id, error);
|
||||
}
|
||||
|
@ -1,47 +0,0 @@
|
||||
import SendGrid from '@sendgrid/mail';
|
||||
|
||||
export type Email = {
|
||||
from?: EmailFromField;
|
||||
to: string;
|
||||
subject: string;
|
||||
text: string;
|
||||
html: string;
|
||||
};
|
||||
|
||||
export type EmailFromField = {
|
||||
name?: string;
|
||||
email: string;
|
||||
};
|
||||
|
||||
export type SendGridProvider = {
|
||||
type: 'sendgrid';
|
||||
apiKey: string;
|
||||
};
|
||||
|
||||
SendGrid.setApiKey(process.env.SENDGRID_API_KEY!);
|
||||
|
||||
const MyAppName: string = 'MyAppName';
|
||||
const MyEmail: string = 'email@saasapp.com';
|
||||
|
||||
export async function sendGrid(email: Email, context: any) {
|
||||
const fromField = {
|
||||
name: MyAppName,
|
||||
email: MyEmail,
|
||||
};
|
||||
|
||||
try {
|
||||
const sentEmail = await SendGrid.send({
|
||||
from: {
|
||||
email: fromField.email,
|
||||
name: fromField.name,
|
||||
},
|
||||
to: email.to,
|
||||
subject: email.subject,
|
||||
text: email.text,
|
||||
html: email.html,
|
||||
});
|
||||
console.log('Email sent: ', sentEmail);
|
||||
} catch (error) {
|
||||
console.error('Error sending email: ', error);
|
||||
}
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user