Openai demo app vertical reorg (#234)

* organize demo ai app vertically

* Update main.wasp.diff

* Update guided-tour.md

* Leftover vertical org changes (#233)

* newsletter dir & leftovers

* update app_diff

* Update guided-tour.md

* Update guided-tour.md
This commit is contained in:
vincanger 2024-07-15 16:06:50 +02:00 committed by GitHub
parent c9d43586bb
commit f52bc42de1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 163 additions and 156 deletions

View File

@ -84,7 +84,7 @@
},
},
}
@@ -195,7 +193,10 @@
@@ -125,7 +123,10 @@
email String? @unique
username String? @unique
lastActiveTimestamp DateTime @default(now())

View File

@ -22,8 +22,8 @@
+} from './contentSections';
import DropdownUser from '../../user/DropdownUser';
import { UserMenuItems } from '../../user/UserMenuItems';
-import { DocsUrl } from '../../common';
+import { DocsUrl, GithubUrl } from '../../common';
-import { DocsUrl } from '../../shared/common';
+import { DocsUrl, GithubUrl } from '../../shared/common';
import DarkModeSwitcher from '../components/DarkModeSwitcher';
export default function LandingPage() {

View File

@ -1,11 +1,11 @@
--- template/app/src/client/landing-page/contentSections.ts
+++ opensaas-sh/app/src/client/landing-page/contentSections.ts
@@ -1,74 +1,126 @@
-import { DocsUrl, BlogUrl } from '../../common';
-import { DocsUrl, BlogUrl } from '../../shared/common';
-import daBoiAvatar from '../static/da-boi.png';
-import avatarPlaceholder from '../static/avatar-placeholder.png';
-import { routes } from 'wasp/client/router';
+import { DocsUrl, BlogUrl, GithubUrl } from '../../common';
+import { DocsUrl, BlogUrl, GithubUrl } from '../../shared/common';
export const navigation = [
{ name: 'Features', href: '#features' },

View File

@ -1,6 +1,7 @@
--- template/app/src/common.ts
+++ opensaas-sh/app/src/common.ts
--- template/app/src/shared/common.ts
+++ opensaas-sh/app/src/shared/common.ts
@@ -1,2 +1,3 @@
export const DocsUrl = 'https://docs.opensaas.sh';
export const BlogUrl = 'https://docs.opensaas.sh/blog';
+export const GithubUrl = 'https://github.com/wasp-lang/open-saas';
\ No newline at end of file

View File

@ -47,10 +47,14 @@ At the root of our project, you will see three folders:
`e2e-tests` contains the end-to-end tests using Playwright, which you can run to test your app's functionality.
### App File Structure
We've structured this full-stack app template vertically (by feature). That means that most directories within `app/src` contain both the React client code and NodeJS server code necessary for implementing its logic.
Let's check out what's in the `app` folder in more detail:
:::caution[v0.11 and below]
If you are using a version of the OpenSaaS template with Wasp `v0.11.x` or below, you may see a slightly different file structure. But don't worry, the vast majority of the code and features are the same! 😅
:::caution[v0.13 and below]
If you are using an older version of the OpenSaaS template with Wasp `v0.13.x` or below, you may see a slightly different file structure. But don't worry, the vast majority of the code and features are the same! 😅
:::
```sh
@ -59,11 +63,17 @@ If you are using a version of the OpenSaaS template with Wasp `v0.11.x` or below
├── .wasp/ # Output dir for Wasp. DON'T MODIFY THESE FILES!
├── public/ # Public assets dir, e.g. www.yourdomain.com/banner.png
├── src/ # Your code goes here.
│   ├── client/ # Your client code (React) goes here.
│   ├── server/ # Your server code (NodeJS) goes here.
│   ├── admin/ # Admin dashboard related pages and components.
│   ├── analytics/ # Logic and background jobs for processing analytics.
│   ├── auth/ # All auth-related pages/components and logic.
│   ├── client/ # Shared components, hooks, landing page, and other client code (React).
│   ├── demo-ai-app/ # Logic for the example AI-powered demo app.
│   ├── file-upload/ # Logic for uploading files to S3.
│   ├── messages # Logic for app user messages.
│   ├── newsletter/ # Logic for scheduled recurring newsletter sending.
│   ├── payment/ # Logic for handling Stripe payments and webhooks.
│   ├── server/ # Scripts, shared server utils, and other server-specific code (NodeJS).
│   ├── shared/ # Shared constants and util functions.
│   └── user/ # Logic related to users and their accounts.
├── .env.server # Dev environment variables for your server code.
├── .env.client # Dev environment variables for your client code.
@ -71,14 +81,9 @@ If you are using a version of the OpenSaaS template with Wasp `v0.11.x` or below
├── tailwind.config.js # TailwindCSS configuration.
├── package.json
├── package-lock.json
└── .wasproot
```
:::tip[File Structure]
Note that since Wasp v0.12, the `src` folder does not need to be organized between `client` and `server` code. You can organize your code however you like, e.g. by feature, but we've chosen to keep the traditional structure for this template.
:::
### The Wasp Config file
This template at its core is a Wasp project, where [Wasp](https://wasp-lang.dev) is a full-stack web app framework that lets you write your app in React, NodeJS, and Prisma and will manage the "boilerplatey" work for you, allowing you to just take care of the fun stuff!
@ -104,35 +109,31 @@ It's possible to learn Wasp's feature set simply through using this template, bu
### Client
The `src/client` folder contains the code that runs in the browser. It's a standard React app, with a few Wasp-specific things sprinkled in.
The `src/client` folder contains any additional client-side code that doesn't belong to a feature:
```sh
.
└── client
   ├── admin # Admin dashboard pages and components
  ├── app # Your user-facing app that sits behind the paywall/login.
  ├── components # Your shared React components.
  ├── hooks # Your shared React hooks.
   ├── landing-page # Landing page related code
   ├── static # Assets that you need access to in your code, e.g. import logo from 'static/logo.png'
   ├── App.tsx # Main app component to wrap all child components. Useful for global state, navbars, etc.
   └── Main.css
├── components # Your shared React components.
├── fonts # Extra fonts
├── hooks # Your shared React hooks.
├── icons # Your shared SVG icons.
├── landing-page # Landing page related code
├── static # Assets that you need access to in your code, e.g. import logo from 'static/logo.png'
├── App.tsx # Main app component to wrap all child components. Useful for global state, navbars, etc.
├── cn.ts # Helper function for dynamic and conditional Tailwind CSS classes.
└── Main.css
```
### Server
The `src/server` folder contains the code that runs on the server. Wasp compiles everything into a NodeJS server for you.
All you have to do is define your server-side functions in the `main.wasp` file, write the logic in a function within `src/server` and Wasp will generate the boilerplate code for you.
The `src/server` folder contains any additional server-side code that does not belong to a specific feature:
```sh
└── server
  ├── scripts # Scripts to run via Wasp, e.g. database seeding.
  ├── workers # Functions that run in the background as Wasp Jobs, e.g. daily stats calculation.
  ├── actions.ts # Your server-side write/mutation functions.
   ├── queries.ts # Your server-side read functions.
   └── utils.ts
├── scripts # Scripts to run via Wasp, e.g. database seeding.
└── utils.ts
```
## Main Features
@ -257,7 +258,7 @@ To do that, we've leveraged Wasp's [Jobs feature](https://wasp-lang.dev/docs/adv
job dailyStatsJob {
executor: PgBoss,
perform: {
fn: import { calculateDailyStats } from "@src/server/workers/calculateDailyStats.js"
fn: import { calculateDailyStats } from "@src/analytics/stats"
},
schedule: {
cron: "0 * * * *" // runs every hour

View File

@ -85,29 +85,6 @@ app OpenSaaS {
},
}
entity GptResponse {=psl
id String @id @default(uuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id])
userId String
content String
psl=}
entity Task {=psl
id String @id @default(uuid())
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id])
userId String
description String
time String @default("1")
isDone Boolean @default(false)
psl=}
route LandingPageRoute { path: "/", to: LandingPage }
page LandingPage {
component: import LandingPage from "@src/client/landing-page/LandingPage"
@ -140,53 +117,6 @@ page EmailVerificationPage {
}
//#endregion
route DemoAppRoute { path: "/demo-app", to: DemoAppPage }
page DemoAppPage {
authRequired: true,
component: import DemoAppPage from "@src/client/app/DemoAppPage"
}
action generateGptResponse {
fn: import { generateGptResponse } from "@src/server/actions.js",
entities: [User, Task, GptResponse]
}
action createTask {
fn: import { createTask } from "@src/server/actions.js",
entities: [Task]
}
action deleteTask {
fn: import { deleteTask } from "@src/server/actions.js",
entities: [Task]
}
action updateTask {
fn: import { updateTask } from "@src/server/actions.js",
entities: [Task]
}
query getGptResponses {
fn: import { getGptResponses } from "@src/server/queries.js",
entities: [User, GptResponse]
}
query getAllTasksByUser {
fn: import { getAllTasksByUser } from "@src/server/queries.js",
entities: [Task]
}
job emailChecker {
executor: PgBoss,
perform: {
fn: import { checkAndQueueEmails } from "@src/server/workers/checkAndQueueEmails.js"
},
schedule: {
cron: "0 7 * * 1" // at 7:00 am every Monday
},
entities: [User]
}
//#region User
entity User {=psl
id String @id @default(uuid())
@ -201,7 +131,7 @@ entity User {=psl
checkoutSessionId String?
subscriptionStatus String? // 'active', 'canceled', 'past_due', 'deleted'
subscriptionPlan String? // 'hobby', 'pro'
sendEmail Boolean @default(false)
sendNewsletter Boolean @default(false)
datePaid DateTime?
credits Int @default(3)
@ -233,6 +163,67 @@ action updateUserById {
}
//#endregion
//#region Demo AI App
route DemoAppRoute { path: "/demo-app", to: DemoAppPage }
page DemoAppPage {
authRequired: true,
component: import DemoAppPage from "@src/demo-ai-app/DemoAppPage"
}
action generateGptResponse {
fn: import { generateGptResponse } from "@src/demo-ai-app/operations",
entities: [User, Task, GptResponse]
}
action createTask {
fn: import { createTask } from "@src/demo-ai-app/operations",
entities: [Task]
}
action deleteTask {
fn: import { deleteTask } from "@src/demo-ai-app/operations",
entities: [Task]
}
action updateTask {
fn: import { updateTask } from "@src/demo-ai-app/operations",
entities: [Task]
}
query getGptResponses {
fn: import { getGptResponses } from "@src/demo-ai-app/operations",
entities: [User, GptResponse]
}
query getAllTasksByUser {
fn: import { getAllTasksByUser } from "@src/demo-ai-app/operations",
entities: [Task]
}
entity GptResponse {=psl
id String @id @default(uuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id])
userId String
content String
psl=}
entity Task {=psl
id String @id @default(uuid())
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id])
userId String
description String
time String @default("1")
isDone Boolean @default(false)
psl=}
//#endregion
//#region Payment
route PricingPageRoute { path: "/pricing", to: PricingPage }
page PricingPage {
@ -426,3 +417,16 @@ entity ContactFormMessage {=psl
repliedAt DateTime?
psl=}
//#endregion
//#region Newsletter
job sendNewsletter {
executor: PgBoss,
perform: {
fn: import { checkAndQueueNewsletterEmails } from "@src/newsletter/sendNewsletter"
},
schedule: {
cron: "0 7 * * 1" // at 7:00 am every Monday
},
entities: [User]
}
//#endregion

View File

@ -8,7 +8,7 @@ import { HiBars3 } from 'react-icons/hi2';
import logo from '../static/logo.png';
import DropdownUser from '../../user/DropdownUser';
import { UserMenuItems } from '../../user/UserMenuItems';
import { DocsUrl, BlogUrl } from '../../common';
import { DocsUrl, BlogUrl } from '../../shared/common';
import DarkModeSwitcher from './DarkModeSwitcher';
const navigation = [

View File

@ -10,7 +10,7 @@ import openSaasBanner from '../static/open-saas-banner.png';
import { features, navigation, faqs, footerNavigation, testimonials } from './contentSections';
import DropdownUser from '../../user/DropdownUser';
import { UserMenuItems } from '../../user/UserMenuItems';
import { DocsUrl } from '../../common';
import { DocsUrl } from '../../shared/common';
import DarkModeSwitcher from '../components/DarkModeSwitcher';
export default function LandingPage() {

View File

@ -1,4 +1,4 @@
import { DocsUrl, BlogUrl } from '../../common';
import { DocsUrl, BlogUrl } from '../../shared/common';
import daBoiAvatar from '../static/da-boi.png';
import avatarPlaceholder from '../static/avatar-placeholder.png';
import { routes } from 'wasp/client/router';

View File

@ -12,8 +12,8 @@ import {
import { useState, useMemo } from 'react';
import { CgSpinner } from 'react-icons/cg';
import { TiDelete } from 'react-icons/ti';
import type { GeneratedSchedule, MainTask, SubTask } from '../../gpt/schedule';
import { cn } from '../cn';
import type { GeneratedSchedule, MainTask, SubTask } from './schedule';
import { cn } from '../client/cn';
export default function DemoAppPage() {
return (

View File

@ -1,12 +1,7 @@
import { type User, type Task } from 'wasp/entities';
import type { Task, GptResponse } from 'wasp/entities';
import type { GenerateGptResponse, CreateTask, DeleteTask, UpdateTask, GetGptResponses, GetAllTasksByUser } from 'wasp/server/operations';
import { HttpError } from 'wasp/server';
import {
type GenerateGptResponse,
type CreateTask,
type DeleteTask,
type UpdateTask,
} from 'wasp/server/operations';
import { GeneratedSchedule } from '../gpt/schedule';
import { GeneratedSchedule } from './schedule';
import OpenAI from 'openai';
const openai = setupOpenAI();
@ -17,6 +12,7 @@ function setupOpenAI() {
return new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
}
//#region Actions
type GptPayload = {
hours: string;
};
@ -45,7 +41,12 @@ export const generateGptResponse: GenerateGptResponse<GptPayload, GeneratedSched
throw openai;
}
if (!context.user.credits && (!context.user.subscriptionStatus || context.user.subscriptionStatus === 'deleted' || context.user.subscriptionStatus === 'past_due')) {
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');
@ -217,3 +218,35 @@ export const deleteTask: DeleteTask<Pick<Task, 'id'>, Task> = async ({ id }, con
return task;
};
//#endregion
//#region Queries
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',
},
});
};
//#endregion

View File

@ -1,4 +1,4 @@
import { type EmailChecker } from 'wasp/server/jobs';
import { type SendNewsletter } from 'wasp/server/jobs';
import { type User } from 'wasp/entities';
import { emailSender } from 'wasp/server/email';
@ -22,7 +22,7 @@ const emailToSend: Email = {
};
// you could use this function to send newsletters, expiration notices, etc.
export const checkAndQueueEmails: EmailChecker<never, void> = async (_args, context) => {
export const checkAndQueueNewsletterEmails: SendNewsletter<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);
@ -32,7 +32,7 @@ export const checkAndQueueEmails: EmailChecker<never, void> = async (_args, cont
datePaid: {
equals: twoWeeksFromNow,
},
sendEmail: true,
sendNewsletter: true,
},
})) as User[];

View File

@ -7,7 +7,7 @@ import { stripe } from './stripeClient';
import { paymentPlans, PaymentPlanId, SubscriptionStatus } from '../plans';
import { updateUserStripePaymentDetails } from './paymentDetails';
import { emailSender } from 'wasp/server/email';
import { assertUnreachable } from '../../utils';
import { assertUnreachable } from '../../shared/utils';
import { requireNodeEnvVar } from '../../server/utils';
import { z } from 'zod';

View File

@ -1,32 +0,0 @@
import { type GptResponse, type Task } from 'wasp/entities';
import { HttpError } from 'wasp/server';
import { type GetGptResponses, type GetAllTasksByUser } from 'wasp/server/operations';
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',
},
});
};

View File

@ -35,7 +35,7 @@ function generateMockUserData(): MockUserData {
createdAt,
lastActiveTimestamp,
isAdmin: false,
sendEmail: false,
sendNewsletter: false,
credits,
subscriptionStatus,
stripeId: hasUserPaidOnStripe ? `cus_test_${faker.string.uuid()}` : null,