Building a Booking System
Learn how to build your own appointment booking system with both user facing and admin functionality
In this tutorial we'll build a booking system with a user facing UI (see available slots and book appointments) and an admin dashboard (manage scheduled appointments and set availability). You will learn how to:
- Create API endpoints using Encore (both public and authenticated).
- Working with PostgreSQL databases using sqlc and pgx.
- Scrub sensitive user data from traces.
- Work with dates and times in Go.
- Authenticate requests using an auth handler.
- Send emails using a SendGrid integration.
The final result will look like this:
If you want to skip ahead you can view the final project here: https://github.com/encoredev/examples/tree/main/booking-system
1. Create your Encore application
Please note
To make it easier to follow along, we've laid out a trail of croissants to guide your way. Whenever you see a 🥐 it means there's something for you to do.
Make sure you have Docker installed and running, it is used by Encore to run PostgreSQL databases locally.
🥐 Create a new Encore application, using this tutorial project's starting-point branch. This gives you a ready-to-go frontend to use.
$ encore app create booking-system --example=github.com/encoredev/example-booking-system/tree/starting-point
🥐 Check that your frontend works:
$ cd booking-system$ encore run
Then visit http://localhost:4000/frontend/ to see the frontend. It won't function yet, since we haven't yet built the backend, so let's do just that!
When we're done we'll have a backend with this architecture:
2. Create booking service
Let's start by creating the functionality to view bookable slots.
With Encore you define a service by defining one or more APIs within a regular Go package. Encore recognizes this as a service, and uses the package name as the service name. When deploying, Encore will automatically provision the required infrastructure for each service.
We already have a Go package named booking
, let's turn that into an Encore service.
🥐 Inside the booking
folder, create a file named slots.go
.
$ touch booking/slots.go
🥐 Add an Encore API endpoint named GetBookableSlots
that takes a date as input. The endpoint will return a list of bookable slots from the supplied date and six days forward (so that we can show a week view calendar in the UI).
booking/slots.go// Service booking keeps track of bookable slots in the calendar.
package booking
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
"time"
)
const DefaultBookingDuration = 1 * time.Hour
type BookableSlot struct {
Start time.Time `json:"start"`
End time.Time `json:"end"`
}
type SlotsParams struct{}
type SlotsResponse struct{ Slots []BookableSlot }
//encore:api public method=GET path=/slots/:from
func GetBookableSlots(ctx context.Context, from string) (*SlotsResponse, error) {
fromDate, err := time.Parse("2006-01-02", from)
if err != nil {
return nil, err
}
const numDays = 7
var slots []BookableSlot
for i := 0; i < numDays; i++ {
date := fromDate.AddDate(0, 0, i)
daySlots, err := bookableSlotsForDay(date)
if err != nil {
return nil, err
}
slots = append(slots, daySlots...)
}
return &SlotsResponse{Slots: slots}, nil
}
func bookableSlotsForDay(date time.Time) ([]BookableSlot, error) {
// 09:00
availStartTime := pgtype.Time{
Valid: true,
Microseconds: int64(9*3600) * 1e6,
}
// 17:00
availEndTime := pgtype.Time{
Valid: true,
Microseconds: int64(17*3600) * 1e6,
}
availStart := date.Add(time.Duration(availStartTime.Microseconds) * time.Microsecond)
availEnd := date.Add(time.Duration(availEndTime.Microseconds) * time.Microsecond)
// Compute the bookable slots in this day, based on availability.
var slots []BookableSlot
start := availStart
for {
end := start.Add(DefaultBookingDuration)
if end.After(availEnd) {
break
}
slots = append(slots, BookableSlot{
Start: start,
End: end,
})
start = end
}
return slots, nil
}
The availability is currently hardcoded to be 09:00 - 17:00 for each day. Later we'll add the functionality to set it for each day of the week. We are also returning time slots that have already passed. Don't worry, we'll come back and fix it later on.
🥐 Let's try it! Open up the Local Development Dashboard running at http://localhost:9400 and try calling
the booking.GetBookableSlots
endpoint, passing in 2024-12-01
.
If you prefer to use the terminal instead run curl http://localhost:4000/slots/2024-12-01
in
a new terminal instead. Either way you should see the response:
{
"Slots": [
{
"start": "2024-12-01T09:00:00Z",
"end": "2024-12-01T10:00:00Z"
},
{
"start": "2024-12-01T10:00:00Z",
"end": "2024-12-01T11:00:00Z"
},
{
"start": "2024-12-01T11:00:00Z",
"end": "2024-12-01T12:00:00Z"
},
...
]
}
3. Book an appointment
Next, we want to make it possible to book an appointment. We'll need a database to store the bookings in. Encore makes it really simple to create and use databases (both for local and cloud environments), but for this example we will also make use of sqlc that will compile our SQL queries into type-safe Go code that we can use in our application.
🥐 Let's create a SQL database for our booking service and the required sqlc scaffolding. Create the following file structure:
/my-app
└── booking // booking service (a Go package)
├── db // (New) db related files (directory)
│ ├── migrations // (New) db migrations (directory)
│ │ └── 1_create_table.up.sql // (New) db migration schema
│ └── query.sql // (New) SQL queries
├── sqlc.yaml // (New) sqlc config file
├── slots.go // booking service code
└── helpers.go // booking service code
🥐 Naming of the database migration file is important, it must look something like: 1_<name>.up.sql
.
Add the following contents to the migration file:
booking/db/migrations/1_create_tables.up.sqlCREATE TABLE booking (
id BIGSERIAL PRIMARY KEY,
start_time TIMESTAMP NOT NULL,
end_time TIMESTAMP NOT NULL,
email TEXT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);
🥐 Next, install the sqlc library:
$ go install github.com/sqlc-dev/sqlc/cmd/sqlc@latest
🥐 Next, we need to configure sqlc. Add the following contents to sqlc.yaml
:
version: "2"
sql:
- engine: "postgresql"
queries: "db/query.sql"
schema: "./db/migrations"
gen:
go:
package: "db"
out: "db"
sql_package: "pgx/v5"
This instructs sqlc to generate Go code from the queries in db/query.sql
and models from the schemas in the db/migrations
folder.
🥐 Let's create our first SQL queries. Add the following contents to db/query.sql
:
-- name: InsertBooking :one
INSERT INTO booking (start_time, end_time, email)
VALUES ($1, $2, $3)
RETURNING *;
-- name: ListBookingsBetween :many
SELECT * FROM booking
WHERE start_time >= $1 AND end_time <= $2;
-- name: ListBookings :many
SELECT * FROM booking;
-- name: DeleteBooking :exec
DELETE FROM booking WHERE id = $1;
🥐 It's time for sqlc to shine! Run the following command in your terminal:
$ cd booking$ sqlc generate
Three files should now have been generated inside the db
folder: query.sql.go
, db.go
and models.go
. These files contain generated Go code and should not be manually edited. We will be adding more queries to db/query.sql
later and then re-run sqlc generate
to update the generated Go code.
Now let's create an endpoint that makes use of one of these queries.
🥐 Create booking/booking.go
with the contents:
booking/booking.gopackage booking
import (
"context"
"time"
"encore.app/booking/db"
"github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool"
"encore.dev/beta/errs"
"encore.dev/storage/sqldb"
)
var (
bookingDB = sqldb.NewDatabase("booking", sqldb.DatabaseConfig{
Migrations: "./db/migrations",
})
pgxdb = sqldb.Driver[*pgxpool.Pool](bookingDB)
query = db.New(pgxdb)
)
type Booking struct {
ID int64 `json:"id"`
Start time.Time `json:"start"`
End time.Time `json:"end"`
Email string `encore:"sensitive"`
}
type BookParams struct {
Start time.Time `json:"start"`
Email string `encore:"sensitive"`
}
//encore:api public method=POST path=/booking
func Book(ctx context.Context, p *BookParams) error {
eb := errs.B()
now := time.Now()
if p.Start.Before(now) {
return eb.Code(errs.InvalidArgument).Msg("start time must be in the future").Err()
}
tx, err := pgxdb.Begin(ctx)
if err != nil {
return eb.Cause(err).Code(errs.Unavailable).Msg("failed to start transaction").Err()
}
defer tx.Rollback(context.Background()) // committed explicitly on success
_, err = query.InsertBooking(ctx, db.InsertBookingParams{
StartTime: pgtype.Timestamp{Time: p.Start, Valid: true},
EndTime: pgtype.Timestamp{Time: p.Start.Add(DefaultBookingDuration), Valid: true},
Email: p.Email,
})
if err != nil {
return eb.Cause(err).Code(errs.Unavailable).Msg("failed to insert booking").Err()
}
if err := tx.Commit(ctx); err != nil {
return eb.Cause(err).Code(errs.Unavailable).Msg("failed to commit transaction").Err()
}
return nil
}
We are now using the generated type-safe query.InsertBooking
function to make the database operation.
Notice the encore:"sensitive"
tag on the Email
field. This tells Encore to scrub this field so that the data is not viewable in the traces for deployed environments. This is useful for fields that contain sensitive data such as email addresses, passwords, etc.
🥐 Restart encore run
to cause the database to be created, and then call the booking.Book
endpoint:
$ curl -X POST 'http://localhost:4000/booking' -d '{"start": "2024-12-11T09:00:00Z", "email": "[email protected]"}'
Congratulations, you have now booked your first appointment!
4. Authentication
To provide an admin dashboard for our booking system, we need to add authentication to our application so that we can have protected endpoints.
Keep in mind, in this tutorial we'll only include a very basic implementation.
🥐 Let's start by creating a new service named user
:
$ mkdir user$ touch user/auth.go
🥐 Add the following contents to user/auth.go
:
user/auth.go// Service user authenticates users.
package user
import (
"context"
"encore.dev/beta/auth"
"encore.dev/beta/errs"
)
type Data struct {
Email string
}
type AuthParams struct {
Authorization string `header:"Authorization"`
}
//encore:authhandler
func AuthHandler(ctx context.Context, p *AuthParams) (auth.UID, *Data, error) {
if p.Authorization != "" {
return "test", &Data{}, nil
}
return "", nil, errs.B().Code(errs.Unauthenticated).Msg("no auth header").Err()
}
This function is our auth handler. An Encore applications can designate a special function to handle authentication,
by defining a function and annotating it with //encore:authhandler
. This annotation tells Encore to run the function whenever an
incoming API call contains authentication data.
The auth handler is responsible for validating the incoming authentication data and returning an auth.UID
(a string type representing a user id).
The auth.UID
can be whatever you wish, but in practice it usually maps directly to the primary key stored in a user table (either defined in the Encore service or in an external service like Firebase or Okta).
In order to keep this example simple, we'll just approve any request containing a token that is not empty.
Next we will implement some of our auth endpoints and make use of our newly created auth handler.
5. Setting availability
Right now the availability is hardcoded to 9:00 - 17:00. Let's add the functionality to let our admin users customize this.
Let's start by adding another migration file, this time to create an availability
table.
🥐 Create a file called 2_add_availability.up.sql
inside the booking/db/migrations
folder. Add the following contents to that file:
booking/db/migrations/2_add_availability.up.sqlCREATE TABLE availability (
weekday SMALLINT NOT NULL PRIMARY KEY, -- Sunday=0, Monday=1, etc.
start_time TIME NULL, -- null indicates not available
end_time TIME NULL -- null indicates not available
);
-- Add some placeholder availability to get started
INSERT INTO availability (weekday, start_time, end_time) VALUES
(0, '09:30', '17:00'),
(1, '09:00', '17:00'),
(2, '09:00', '18:00'),
(3, '08:30', '18:00'),
(4, '09:00', '17:00'),
(5, '09:00', '17:00'),
(6, '09:30', '16:30');
🥐 We can now add two queries to booking/db/query.sql
so that we can store and retrieve availability:
booking/db/query.sql-- name: GetAvailability :many
SELECT * FROM availability
ORDER BY weekday;
-- name: UpdateAvailability :exec
INSERT INTO availability (weekday, start_time, end_time)
VALUES (@weekday, @start_time, @end_time)
ON CONFLICT (weekday) DO UPDATE
SET start_time = @start_time, end_time = @end_time;
🥐 Run sqlc generate
to update the generated Go code.
🥐 Create a new file in the booking
service named availability.go
:
$ touch booking/availability.go
🥐 Add the following to that file:
booking/availability.gopackage booking
import (
"context"
"errors"
"fmt"
"encore.app/booking/db"
"github.com/jackc/pgx/v5/pgtype"
"encore.dev/beta/errs"
"encore.dev/rlog"
)
type Availability struct {
Start *string `json:"start" encore:"optional"`
End *string `json:"end" encore:"optional"`
}
type GetAvailabilityResponse struct {
Availability []Availability
}
//encore:api public method=GET path=/availability
func GetAvailability(ctx context.Context) (*GetAvailabilityResponse, error) {
rows, err := query.GetAvailability(ctx)
if err != nil {
return nil, err
}
availability := make([]Availability, 7)
for _, row := range rows {
day := row.Weekday
if day < 0 || day > 6 {
rlog.Error("invalid week day in availability table", "row", row)
continue
}
// These never fail
start, _ := row.StartTime.TimeValue()
end, _ := row.EndTime.TimeValue()
availability[day] = Availability{
Start: timeToStr(start),
End: timeToStr(end),
}
}
return &GetAvailabilityResponse{Availability: availability}, nil
}
type SetAvailabilityParams struct {
Availability []Availability
}
//encore:api auth method=POST path=/availability
func SetAvailability(ctx context.Context, params SetAvailabilityParams) error {
eb := errs.B()
tx, err := pgxdb.Begin(ctx)
if err != nil {
return err
}
defer tx.Rollback(context.Background()) // committed explicitly on success
qry := query.WithTx(tx)
for weekday, a := range params.Availability {
if weekday > 6 {
return eb.Code(errs.InvalidArgument).Msgf("invalid weekday %d", weekday).Err()
}
start, err1 := strToTime(a.Start)
end, err2 := strToTime(a.End)
if err := errors.Join(err1, err2); err != nil {
return eb.Cause(err).Code(errs.InvalidArgument).Msg("invalid start/end time").Err()
} else if start.Valid != end.Valid {
return eb.Code(errs.InvalidArgument).Msg("both start/stop must be set, or both null").Err()
} else if start.Valid && start.Microseconds > end.Microseconds {
return eb.Code(errs.InvalidArgument).Msg("start must be before end").Err()
}
err = qry.UpdateAvailability(ctx, db.UpdateAvailabilityParams{
Weekday: int16(weekday),
StartTime: start,
EndTime: end,
})
if err != nil {
return eb.Cause(err).Code(errs.Unavailable).Msg("failed to update availability").Err()
}
}
err = tx.Commit(ctx)
return errs.WrapCode(err, errs.Unavailable, "failed to commit transaction")
}
This file contains two endpoints, a setter and a getter. The SetAvailability
endpoint is protected by the auth
middleware which means that the user must be authenticated in order to call it. The GetAvailability
endpoint is public and can be called without authentication.
🥐 Let's set the availability for each day of the week. Open the Development Dashboard at http://localhost:9400 and select the booking.SetAvailability
endpoint in the API Explorer. For the request body, paste the following:
{
"Availability": [{
"start": "09:30",
"end": "17:00"
},{
"start": "09:00",
"end": "17:00"
},{
"start": "09:00",
"end": "18:00"
},{
"start": "08:30",
"end": "18:00"
},{
"start": "09:00",
"end": "17:00"
},{
"start": "09:00",
"end": "17:00"
},{
"start": "09:30",
"end": "16:30"
}]
}
Please note
Don't leave the auth token empty, it will cause the auth handler to reject the request. You can use any value for the auth token.
Now try retrieving the availability by calling the booking.GetAvailability
endpoint through the API Explorer in the Development Dashboard.
🥐 Add the following functions inside the booking
package, and import the slices
package:
func listBookingsBetween(
ctx context.Context,
start, end time.Time,
) ([]*Booking, error) {
rows, err := query.ListBookingsBetween(ctx, db.ListBookingsBetweenParams{
StartTime: pgtype.Timestamp{Time: start, Valid: true},
EndTime: pgtype.Timestamp{Time: end, Valid: true},
})
if err != nil {
return nil, err
}
var bookings []*Booking
for _, row := range rows {
bookings = append(bookings, &Booking{
ID: row.ID,
Start: row.StartTime.Time,
End: row.EndTime.Time,
Email: row.Email,
})
}
return bookings, nil
}
func filterBookableSlots(
slots []BookableSlot,
now time.Time,
bookings []*Booking,
) []BookableSlot {
// Remove slots for which the start time has already passed.
slots = slices.DeleteFunc(slots, func(s BookableSlot) bool {
// Has the slot already passed?
if s.Start.Before(now) {
return true
}
// Is there a booking that overlaps with this slot?
for _, b := range bookings {
if b.Start.Before(s.End) && b.End.After(s.Start) {
return true
}
}
return false
})
return slots
}
We'll use these functions to figure out which slots are bookable, and which are not, to avoid double bookings.
🥐 Now we can update the Book
endpoint inside booking.go
and make use of these new functions:
booking/booking.go//encore:api public method=POST path=/booking
func Book(ctx context.Context, p *BookParams) error {
eb := errs.B()
now := time.Now()
if p.Start.Before(now) {
return eb.Code(errs.InvalidArgument).Msg("start time must be in the future").Err()
}
tx, err := pgxdb.Begin(ctx)
if err != nil {
return eb.Cause(err).Code(errs.Unavailable).Msg("failed to start transaction").Err()
}
defer tx.Rollback(context.Background()) // committed explicitly on success
// Get the bookings for this day.
startOfDay := time.Date(p.Start.Year(), p.Start.Month(), p.Start.Day(), 0, 0, 0, 0, p.Start.Location())
bookings, err := listBookingsBetween(ctx, startOfDay, startOfDay.AddDate(0, 0, 1))
if err != nil {
return eb.Cause(err).Code(errs.Unavailable).Msg("failed to list bookings").Err()
}
// Is this slot bookable?
slot := BookableSlot{Start: p.Start, End: p.Start.Add(DefaultBookingDuration)}
if len(filterBookableSlots([]BookableSlot{slot}, now, bookings)) == 0 {
return eb.Code(errs.InvalidArgument).Msg("slot is unavailable").Err()
}
_, err = query.InsertBooking(ctx, db.InsertBookingParams{
StartTime: pgtype.Timestamp{Time: p.Start, Valid: true},
EndTime: pgtype.Timestamp{Time: p.Start.Add(DefaultBookingDuration), Valid: true},
Email: p.Email,
})
if err != nil {
return eb.Cause(err).Code(errs.Unavailable).Msg("failed to insert booking").Err()
}
if err := tx.Commit(ctx); err != nil {
return eb.Cause(err).Code(errs.Unavailable).Msg("failed to commit transaction").Err()
}
return nil
}
🥐 Inside slots.go
, update the GetBookableSlots
endpoint and the bookableSlotsForDay
functions to look like this:
booking/slots.go//encore:api public method=GET path=/slots/:from
func GetBookableSlots(ctx context.Context, from string) (*SlotsResponse, error) {
fromDate, err := time.Parse("2006-01-02", from)
if err != nil {
return nil, err
}
availabilityResp, err := GetAvailability(ctx)
if err != nil {
return nil, err
}
availability := availabilityResp.Availability
const numDays = 7
var slots []BookableSlot
for i := 0; i < numDays; i++ {
date := fromDate.AddDate(0, 0, i)
weekday := int(date.Weekday())
if len(availability) <= weekday {
break
}
daySlots, err := bookableSlotsForDay(date, &availability[weekday])
if err != nil {
return nil, err
}
slots = append(slots, daySlots...)
}
// Get bookings for the next 7 days.
activeBookings, err := listBookingsBetween(ctx, fromDate, fromDate.AddDate(0, 0, numDays))
if err != nil {
return nil, err
}
slots = filterBookableSlots(slots, time.Now(), activeBookings)
return &SlotsResponse{Slots: slots}, nil
}
func bookableSlotsForDay(date time.Time, avail *Availability) ([]BookableSlot, error) {
if avail.Start == nil || avail.End == nil {
return nil, nil
}
availStartTime, err1 := strToTime(avail.Start)
availEndTime, err2 := strToTime(avail.End)
if err := errors.Join(err1, err2); err != nil {
return nil, err
}
availStart := date.Add(time.Duration(availStartTime.Microseconds) * time.Microsecond)
availEnd := date.Add(time.Duration(availEndTime.Microseconds) * time.Microsecond)
// Compute the bookable slots in this day, based on availability.
var slots []BookableSlot
start := availStart
for {
end := start.Add(DefaultBookingDuration)
if end.After(availEnd) {
break
}
slots = append(slots, BookableSlot{
Start: start,
End: end,
})
start = end
}
return slots, nil
}
6. Managing scheduled bookings
To display the scheduled bookings in the admin dashboard, we need to add the functionality to list all bookings. While we're at it, we'll also make it possible to delete bookings.
🥐 Add two new endpoints to booking/booking.go
:
booking/booking.gotype ListBookingsResponse struct {
Booking []*Booking `json:"bookings"`
}
//encore:api auth method=GET path=/booking
func ListBookings(ctx context.Context) (*ListBookingsResponse, error) {
rows, err := query.ListBookings(ctx)
if err != nil {
return nil, err
}
var bookings []*Booking
for _, row := range rows {
bookings = append(bookings, &Booking{
ID: row.ID,
Start: row.StartTime.Time,
End: row.EndTime.Time,
Email: row.Email,
})
}
return &ListBookingsResponse{Booking: bookings}, nil
}
//encore:api auth method=DELETE path=/booking/:id
func DeleteBooking(ctx context.Context, id int64) error {
return query.DeleteBooking(ctx, id)
}
That's it! We now have all the backend endpoints in place to be able to supply the frontend with data. 🎉
7. Running the React frontend
The frontend should now be working as expected.
🥐 Go to http://localhost:4000/frontend/ and try out your new booking system.
The frontend is built using React and Tailwind CSS. It uses Encore's ability to generate type-safe request clients. This means you don't need to manually keep the request/response objects in sync on the frontend. To generate a client:
$ encore gen client <APP_NAME> --output=./src/client.ts --env=<ENV_NAME>
While you're developing, you are going to want to run this command quite often (whenever you make a change to your endpoints) so having it as an npm
script is a good idea. Take a look at the scripts in the package.json
file:
{
...
"scripts": {
...
"gen": "encore gen client <Encore App ID> --output=./src/lib/client.ts --env=staging",
"gen:local": "encore gen client <Encore App ID> --output=./src/lib/client.ts --env=local"
},
}
For this frontend we use the request client together with TanStack Query. When building something a bit more complex, you will likely need to deal with caching, refetching, and data going stale. TanStack Query is a popular library that was built to solve exactly these problems and works great with the Encore request client.
See our the docs page about integrating with a web frontend to learn more.
8. Deploy to Encore's development cloud
Let's deploy the project to Encore's free development cloud.
Encore comes with built-in CI/CD, and the deployment process is as simple as a git push
.
(You can also integrate with GitHub to activate per Pull Request Preview Environments, learn more in the CI/CD docs.)
🥐 Now, let's deploy your app to Encore's free development cloud by running:
$ git add -A .$ git commit -m 'Initial commit'$ git push encore
Encore will now build and test your app, provision the needed infrastructure, and deploy your application to the cloud.
After triggering the deployment, you will see a URL where you can view its progress in the Encore Cloud dashboard. It will look something like: https://app.encore.cloud/$APP_ID/deploys/...
From there you can also see metrics, traces, link your app to a GitHub repo to get automatic deploys on new commits, and connect your own AWS or GCP account to use for production deployment.
🥐 When the deploy has finished, you can try out your booking system by going to https://staging-$APP_ID.encr.app/frontend/
.
You now have an Appointment Booking System running in the cloud, well done!
8. Sending confirmation emails using SendGrid
In order for the users to get a confirmation email when they book an appointment we need to add an email integration.
Conveniently for us, there is a ready to use SendGrid integration as an Encore Bit.
🥐 Follow the instructions to add the SendGrid integration to your project.
Next, we need to call our new sendgrid
service when an appointment is booked.
🥐 Add a call to sendgrid.Send
in the Book
endpoint:
booking/booking.go//encore:api public method=POST path=/booking
func Book(ctx context.Context, p *BookParams) error {
eb := errs.B()
now := time.Now()
if p.Start.Before(now) {
return eb.Code(errs.InvalidArgument).Msg("start time must be in the future").Err()
}
tx, err := pgxdb.Begin(ctx)
if err != nil {
return eb.Cause(err).Code(errs.Unavailable).Msg("failed to start transaction").Err()
}
defer tx.Rollback(context.Background()) // committed explicitly on success
// Get the bookings for this day.
startOfDay := time.Date(p.Start.Year(), p.Start.Month(), p.Start.Day(), 0, 0, 0, 0, p.Start.Location())
bookings, err := listBookingsBetween(ctx, startOfDay, startOfDay.AddDate(0, 0, 1))
if err != nil {
return eb.Cause(err).Code(errs.Unavailable).Msg("failed to list bookings").Err()
}
// Is this slot bookable?
slot := BookableSlot{Start: p.Start, End: p.Start.Add(DefaultBookingDuration)}
if len(filterBookableSlots([]BookableSlot{slot}, now, bookings)) == 0 {
return eb.Code(errs.InvalidArgument).Msg("slot is unavailable").Err()
}
_, err = query.InsertBooking(ctx, db.InsertBookingParams{
StartTime: pgtype.Timestamp{Time: p.Start, Valid: true},
EndTime: pgtype.Timestamp{Time: p.Start.Add(DefaultBookingDuration), Valid: true},
Email: p.Email,
})
if err != nil {
return eb.Cause(err).Code(errs.Unavailable).Msg("failed to insert booking").Err()
}
if err := tx.Commit(ctx); err != nil {
return eb.Cause(err).Code(errs.Unavailable).Msg("failed to commit transaction").Err()
}
// Send confirmation email using SendGrid
formattedTime := pgtype.Timestamp{Time: p.Start, Valid: true}.Time.Format("2006-01-02 15:04")
_, err = sendgrid.Send(ctx, &sendgrid.SendParams{
From: sendgrid.Address{
Name: "<your name>",
Email: "<your email>",
},
To: sendgrid.Address{
Email: p.Email,
},
Subject: "Booking Confirmation",
Text: "Thank you for your booking!\nWe look forward to seeing you soon at " + formattedTime,
Html: "",
})
if err != nil {
return err
}
return nil
}
Please note
The From
email used when sending emails needs to go through the SendGrid verification process before it can be used. You can read more about it here: https://sendgrid.com/docs/ui/sending-email/sender-verification/
The default behaviour of the SendGrid integration is to only send emails on production environments. You can create production environments through the Encore Cloud Dashboard.
9. Deploy your finished Booking System
Now you're ready to deploy your finished Booking System, complete with a SendGrid integration.
🥐 As before, deploying your app to the cloud is as simple as running:
$ git add -A .$ git commit -m 'Add sendgrid integration'$ git push encore
Celebrate with fireworks
Now that your app is running in the cloud, let's celebrate with some fireworks:
🥐 In the Cloud Dashboard, open the Command Menu by pressing Cmd + K (Mac) or Ctrl + K (Windows/Linux).
From here you can easily access all Cloud Dashboard features and for example jump straight to specific services in the Service Catalog or view Traces for specific endpoints.
🥐 Type fireworks
in the Command Menu and press enter. Sit back and enjoy the show!