Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Encore.go SaaS Starter Template #202

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions encore-saas-template/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
/.encore
encore.gen.go
encore.gen.cue
/encore.gen
# Logs
logs
*.log
npm-debug.log*

# Runtime data
pids
*.pid
*.seed

# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov

# Coverage directory used by tools like istanbul
coverage

# nyc test coverage
.nyc_output

# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
.grunt

# node-waf configuration
.lock-wscript

# Compiled binary addons (http://nodejs.org/api/addons.html)
build/Release

# Dependency directories
node_modules
jspm_packages

# Optional npm cache directory
.npm

# Optional REPL history
.node_repl_history
.next

.cursorignore
.cursorrules
160 changes: 160 additions & 0 deletions encore-saas-template/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
# EncoreKit: Encore SaaS Template

## Features

- Landing page with feature promotion
- Pricing page (/pricing) which connects to Stripe Checkout
- Dashboard pages with CRUD operations to modify user
- Admin role in firebase claims (admins sees activity)
- Subscription management with Stripe Customer Portal
- Authentication with firebase
- Activity logging system for any user events

## Tech stack

- Frontend Framework: [Next.js](https://nextjs.org/)
- Backend Framework [Encore.go](https://encore.dev/go)

- ORM: [gorm go](https://gorm.io/)
- Payments: [Stripe](https://stripe.com/)
- UI Library: [shadcn/ui](https://ui.shadcn.com/)

## Developing locally

When you have [installed Encore](https://encore.dev/docs/install), you can create a new Encore application and clone this example with this command.

```bash
encore app create my-app-name --example=encore-saas-template
```

## Running locally

### Backend

Running the backend requires the following scripts:

```bash
encore run # This has to be run to setup the postgres docker db and volume for later steps
```

```bash
pnpm i
```

Seeding the users in Firebase and Postgres. Find service-account.json in Firebase Console > Project Setting > Service Accounts > **Generate new private key**. (note: run script setup-firebase-and-users:clean to remove users)

```bash
pnpm setup-firebase-and-users --service-account "/path/to/service-account.json"
```
#### Listen to the webhook to get subscription events
Set Stripe secrets and setup a stripe webhook (requires [Stripe CLI](https://docs.stripe.com/stripe-cli) is installed)

This command also runs stripe listen to forward stripe webhooks to our backend locally.

```bash
pnpm run setup-stripe
```

### Frontend

```bash
cd frontend
pnpm i
pnpm gen:local # generate a client for the frontend to encores cli with
pnpm dev
```

## Testing Payments

To test Stripe payments, use the following test card details:

Card Number: 4242 4242 4242 4242
Expiration: Any future date
CVC: Any 3-digit number

## Local Development Dashboard

While `encore run` is running, open <http://localhost:9400/> to access Encore's [local developer dashboard](https://encore.dev/docs/observability/dev-dash).

Here you can see API docs, make requests in the API explorer, and view traces of the responses.

## Deploying to Encore

Deploy your application to a staging environment in Encore's free development cloud:

```bash
git add .
git commit -m "first commit"
git push encore
```

### Allow Vercel domain to access Encore

Modify encore.app to look like:

```
{
"id": "<encore-app-id>",
"global_cors": {
"allow_origins_with_credentials": [
"http://127.0.0.1:3000",
"http://localhost:3000",
"https://<vercel-app-name>.vercel.app"
]
}
}
```

Then head over to the [Cloud Dashboard](https://app.encore.dev) to monitor your deployment and find your production URL.

From there you can also connect your own AWS or GCP account to use for deployment.

## Deploying to Vercel

1. Push your code to a GitHub repository.
2. Connect your repository to Vercel and deploy it.
3. Follow the Vercel deployment process, which will guide you through setting up your project.
4. **Remember to setup the env variables in Vercel**

### Allow Vercel domain to access Firebase authentication

1. Go to [Firebase Console](https://console.firebase.google.com/)
2. Go to Authentication -> Settings
3. Add the vercel domain to authorized domains.

# Getting ready for production

## Stripe

### Set up a production Stripe webhook

1. Go to the Stripe Dashboard and create a new webhook for your production environment.
2. Set the endpoint URL to your production API route (e.g., `https://yourdomain.com/api/stripe/webhook`).
3. Select the events you want to listen for (e.g., `checkout.session.completed`, `customer.subscription.updated`).

### Configure Stripe secrets

```bash
encore secret set --type production StripeSecretKey
encore secret set --type production StripeWebhookSecret
encore secret set --type production CallbackURL
```

## Encore

Setup a production environment in [Encore's cloud dashboard](https://app.encore.cloud) and link to the branch of choice.
Then on the production branch:

```bash
git commit encore
```

### Secrets required in Encore

```bash
encore secret set --type production FirebasePrivateKey < "/path/to/service-account.json"
```

## Vercel

Remember to setup the environment variables required in Vercel.
86 changes: 86 additions & 0 deletions encore-saas-template/backend/activity/activity.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
package activity

import (
"context"
"time"

"encore.app/backend/user"
"encore.dev/beta/errs"
"encore.dev/rlog"
"github.com/google/uuid"
)

type ActivityResponse struct {
ID string `json:"id"`
UserID string `json:"user_id"`
Event string `json:"event"`
CreatedAt time.Time `json:"created_at"`
}

type ActivitiesResponse struct {
Activities []*ActivityResponse `json:"activities"`
}

type FilterActivitiesRequest struct {
Offset int `json:"offset"`
Limit int `json:"limit"`
}

type CreateActivityRequest struct {
UserID string `json:"user_id"`
Event string `json:"event"`
}

//encore:api auth method=GET path=/v1/activities tag:admin
func (s *Service) GetActivities(ctx context.Context, p *FilterActivitiesRequest) (*ActivitiesResponse, error) {
eb := errs.B()

offset := p.Offset
limit := p.Limit

if offset < 0 {
eb = eb.Code(errs.InvalidArgument).Msg("offset must be greater than 0")
}

if limit < 0 {
eb = eb.Code(errs.InvalidArgument).Msg("limit must be greater than 0")
}

activities := make([]*Activity, 0)
err := s.db.Find(&Activity{}).Offset(offset).Limit(limit).Find(&activities).Error
if err != nil {
return nil, eb.Cause(err).Code(errs.Internal).Msg("failed to get activities").Err()
}

activitiesResponse := make([]*ActivityResponse, 0)
for _, activity := range activities {
activitiesResponse = append(activitiesResponse, &ActivityResponse{
ID: activity.ID,
UserID: activity.UserID,
Event: activity.Event,
CreatedAt: activity.CreatedAt,
})
}

return &ActivitiesResponse{
Activities: activitiesResponse,
}, nil
}

func (s *Service) HandleSignupEvents(ctx context.Context, p *user.SignupEvent) error {
eb := errs.B()
rlog.Info("signup event", "user_id", p.UserID)

activity := Activity{
ID: uuid.NewString(),
UserID: p.UserID,
Event: "signup",
CreatedAt: time.Now(),
}

if err := s.db.Create(&activity).Error; err != nil {
return eb.Cause(err).Code(errs.Internal).Msg("failed to create activity").Err()
}

return nil
}
24 changes: 24 additions & 0 deletions encore-saas-template/backend/activity/middleware.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package activity

import (
"errors"

a "encore.app/backend/auth"
"encore.dev/beta/auth"
"encore.dev/beta/errs"
"encore.dev/middleware"
)

// ValidateAdmin validates the roles of the user.
//
//encore:middleware target=tag:admin
func ValidateAdmin(req middleware.Request, next middleware.Next) middleware.Response {
userData := auth.Data().(*a.UserData)

if userData.Role != "admin" {
err := errs.WrapCode(errors.New("permission denied"), errs.PermissionDenied, "user is not an admin")
return middleware.Response{Err: err}
}

return next(req)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
CREATE TABLE activities (
id TEXT PRIMARY KEY,
user_id VARCHAR(255) NOT NULL,
event VARCHAR(255) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
44 changes: 44 additions & 0 deletions encore-saas-template/backend/activity/service.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package activity

import (
"time"

"encore.app/backend/user"
"encore.dev/pubsub"
"encore.dev/storage/sqldb"
"gorm.io/driver/postgres"
"gorm.io/gorm"
)

type Activity struct {
ID string `gorm:"primaryKey;type:text" json:"id"`
UserID string `gorm:"not null;type:text" json:"user_id"`
Event string `gorm:"not null;type:text" json:"event"`
CreatedAt time.Time `gorm:"not null;type:timestamp" json:"created_at"`
}

//encore:service
type Service struct {
db *gorm.DB
}

var db = sqldb.NewDatabase("activities", sqldb.DatabaseConfig{
Migrations: "./migrations",
})

func initService() (*Service, error) {
db, err := gorm.Open(postgres.New(postgres.Config{
Conn: db.Stdlib(),
}))
if err != nil {
return nil, err
}
return &Service{db: db}, nil
}

var _ = pubsub.NewSubscription(
user.Signups, "signups",
pubsub.SubscriptionConfig[*user.SignupEvent]{
Handler: pubsub.MethodHandler((*Service).HandleSignupEvents),
},
)
Loading