In this tutorial, you'll learn how to:
- Build a Bidding system with SMS notifications.
- Handle time dependent jobs in a safe manner
- [TRICK 1]: Building a web application super quickly with Blitz.js
- [TRICK 2]: Bid status notifications using Byteflow
So let's get started!
Byteflow:Integrate SMS Messaging in minutes 💬
Just a quick background about us. Byteflow is a Twilio alternative that does not require a 15+ business day verification period to start sending SMS messages.
The tools
Before we get into this tutorial, let's look at the tools we will be using:
Blitz.js: Blitz.js is a react meta-framework. It builds on top of Next.js, providing a "Rails-like" experience where the backend and frontend are tightly integrated.
Prisma: Prisma is an ORM that offers tight integration with Typescript. The Prisma schema is defined in a separate Prisma file and is converted into TS types auto-magically, making it super easy to use.
Quirrel: Quirrel is a simple open-source system for job scheduling that we will use to schedule jobs to run when the auction is complete.
Setting up the project
First, we need to install Blitz.js.
npm:
npm install -g blitz
Yarn:
yarn global add blitz
Then create a new project by running the following:
blitz new bidding-site
Then select the following options:
Pick a new project's language › Typescript
Pick your new app template › Full
Install dependencies? › (Pick what you prefer for me, that is Yarn)
Pick a form library › React Hook Form
Once you do this, you will get a simple application with a full DB, basic authentication, and everything we need.
Before we continue let's start a new dev server by running:
npm run dev
or for Yarn:
yarn dev
This will start the dev server at http://localhost:3000
Scaffolding the DB
We will be using Prisma as our ORM to interface with our database. Start by opening the db/schema.prisma
file and add the following value to the User
model:
bids Bid[]
Then create these new models:
model Product {
id Int @id @default(autoincrement())
createdAt DateTime @default(now())
name String
bids Bid[]
}
model Bid {
id Int @id @default(autoincrement())
bidValueUSD Int
Product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
productId Int
User User @relation(fields: [userId], references: [id])
userId Int
}
Here we have:
- A Bid Model that represents an individual bid on a product
- A Product Model representing a product that users can place bids on.
Products
Then run:
blitz g all Product
This will scaffold mutations and queries for products and generate a page for creating, deleting, viewing, and updating products.
Blitz.js does a pretty good job of scaffolding, but it's not perfect, so we will need to make a few changes.
First update src/products/schema.ts
to:
import { z } from "zod";
export const CreateProductSchema = z.object({
name: z.string()
});
export const UpdateProductSchema = z.object({
id: z.number(),
});
export const DeleteProductSchema = z.object({
id: z.number(),
});
Here we have updated it so that when creating a product, it takes in the name of the product.
Then update src/products/components/ProductForm.tsx
to:
import React from "react";
import { Form, FormProps } from "src/core/components/Form";
import { LabeledTextField } from "src/core/components/LabeledTextField";
import { z } from "zod";
export { FORM_ERROR } from "src/core/components/Form";
export function ProductForm<S extends z.ZodType<any, any>>(
props: FormProps<S>
) {
return (
<Form<S> {...props}>
<LabeledTextField name="name" label="Name" placeholder="Diamond watch" />
</Form>
);
}
Here we have added an input field for the name of the product.
After you create an account, you can then create a product by going to http://localhost:3000/products/new
Bids
Now that you can create products let's create the bidding system.
Start by running:
blitz g crud Bid
This will scaffold out CRUD mutations and queries for bids.
Then update src/bids/schemas.ts
to:
import { z } from "zod";
export const CreateBidSchema = z.object({
bidValueUSD: z.number(),
productId: z.number()
});
export const UpdateBidSchema = z.object({
id: z.number(),
});
export const DeleteBidSchema = z.object({
id: z.number(),
});
When creating a new bid, we have updated the schema to include the bidValueUSD
and the productId
.
Then update src/bids/mutations/createBid.ts
to:
import { resolver } from "@blitzjs/rpc"
import db from "db"
import { CreateBidSchema } from "../schemas"
export default resolver.pipe(
resolver.zod(CreateBidSchema),
resolver.authorize(),
async (input, ctx) => {
const bid = await db.bid.create({
data: {
userId: ctx.session.userId,
productId: input.productId,
bidValueUSD: input.bidValueUSD,
},
})
return bid
}
)
Then create a new file called bids/components/CreateBidForm.tsx
and add the following:
import { AuthenticationError, PromiseReturnType } from "blitz"
import Link from "next/link"
import { LabeledTextField } from "src/core/components/LabeledTextField"
import { Form, FORM_ERROR } from "src/core/components/Form"
import login from "src/auth/mutations/login"
import { Login } from "src/auth/schemas"
import { useMutation } from "@blitzjs/rpc"
import { Routes } from "@blitzjs/next"
import createBid from "../mutations/createBid"
import { z } from "zod"
type CreateBidFormProps = {
onSuccess?: () => void
productId: number
currentMaxBid?: number
}
export const CreateBidForm = (props: CreateBidFormProps) => {
const [createBidMutation] = useMutation(createBid)
return (
<div>
<h2>Create Bid</h2>
<Form
submitText= "Create Bid"
schema={z.object({
value: z.string(),
})}
onSubmit={async (values) => {
try {
if (props.currentMaxBid != undefined) {
if (parseInt(values.value) <= props.currentMaxBid) {
return { [FORM_ERROR]: "Your bid must be greater than the current highest bid"}
}
}
await createBidMutation({
bidValueUSD: parseInt(values.value),
productId: props.productId,
})
props.onSuccess?.()
} catch (error: any) {
return {
[FORM_ERROR]:
"Sorry, we had an unexpected error. Please try again. - " + error.toString(),
}
}
}}
>
<LabeledTextField name="value" label="Bid Value" placeholder="100" />
</Form>
</div>
)
}
export default CreateBidForm
This is a pretty simple form component that takes in the value for the bid and then triggers the createBidMutation
when submitted.
Now let's update the resolver inside of products/queries/getProduct.ts
to:
export default resolver.pipe(resolver.zod(GetProduct), resolver.authorize(), async ({ id }) => {
const product = await db.product.findFirst({ where: { id }, include: { bids: true } })
if (!product) throw new NotFoundError()
return product
})
Here we have just told to prisma to include bids when getting the product so we can show them on the product page.
Then updates pages/products/[productId.tsx]
to:
import { Suspense, useEffect, useState } from "react"
import { Routes } from "@blitzjs/next"
import Head from "next/head"
import Link from "next/link"
import { useRouter } from "next/router"
import { useQuery, useMutation } from "@blitzjs/rpc"
import { useParam } from "@blitzjs/next"
import Layout from "src/core/layouts/Layout"
import getProduct from "src/products/queries/getProduct"
import deleteProduct from "src/products/mutations/deleteProduct"
import createBid from "../../bids/mutations/createBid"
import { Bid } from ".prisma/client"
import Form from "../../core/components/Form"
import CreateBidForm from "../../bids/components/CreateBidForm"
export const Product = () => {
const router = useRouter()
const productId = useParam("productId", "number")
const [deleteProductMutation] = useMutation(deleteProduct)
const [product, { refetch }] = useQuery(getProduct, { id: productId })
const [sortedBids, setSortedBids] = useState<Bid[]>([])
useEffect(() => {
setSortedBids(product.bids.sort((a, b) => b.bidValueUSD - a.bidValueUSD))
}, [product.bids])
return (
<>
<Head>
<title>Product - {product.name}</title>
</Head>
<div>
<h1>{product.name}</h1>
{product.bids.length == 0 ? <p>No bids</p> : <p>Top Bid: {sortedBids[0]?.bidValueUSD}</p>}
<hr />
<CreateBidForm
currentMaxBid={product.bids[0]?.bidValueUSD}
onSuccess={() => refetch()}
productId={product.id}
/>
<hr />
<Link href={Routes.EditProductPage({ productId: product.id })}>Edit</Link>
<button
type= "button"
onClick={async () => {
if (window.confirm("This will be deleted")) {
await deleteProductMutation({ id: product.id })
await router.push(Routes.ProductsPage())
}
}}
style={{ marginLeft: "0.5rem" }}
>
Delete
</button>
</div>
</>
)
}
const ShowProductPage = () => {
return (
<div>
<p>
<Link href={Routes.ProductsPage()}>Products</Link>
</p>
<Suspense fallback={<div>Loading...</div>}>
<Product />
</Suspense>
</div>
)
}
ShowProductPage.authenticate = true
ShowProductPage.getLayout = (page) => <Layout>{page}</Layout>
export default ShowProductPage
The changes here are pretty simple. We sort the bids returned from the getProduct
query so the biggest bid is first. Then we show the largest bid. We also add the CreateBidForm
so users can create new bids on the product.
You should now be able to create a product and place bids on it.
Ending Auctions
Auctions should end at some point, so let's start by adding the following to the Product
model in the Prisma schema:
endDate DateTime
So it looks like this:
model Product {
id Int @id @default(autoincrement())
createdAt DateTime @default(now())
name String
bids Bid[]
endDate DateTime
}
Then run the following to migrate the database:
blitz prisma migrate reset && blitz prisma migrate dev
Then install react-date-picker
with:
npm i react-date-picker
Or, if you are using Yarn:
yarn add react-date-picker
Then update products/components/ProductForm.tsx
to:
import React, { useState } from "react"
import { LabeledTextField } from "src/core/components/LabeledTextField"
import { z } from "zod"
import DatePicker from "react-date-picker"
import "react-date-picker/dist/DatePicker.css"
import "react-calendar/dist/Calendar.css"
import { Form, FORM_ERROR } from "src/core/components/Form"
interface FormResult {
name: string
date: Date
}
export function ProductForm<S extends z.ZodType<any, any>>({
onSubmit,
}: {
onSubmit: (data: FormResult) => void
}) {
const [value, onChange] = useState<Date | null>(null)
return (
<Form
schema={z.object({ name: z.string() })}
submitText= "Submit"
onSubmit={async (x) => {
console.log("HELLO!")
if (value == null)
return {
[FORM_ERROR]: "Please select an end date",
}
onSubmit({
name: x.name,
date: value,
})
}}
>
<LabeledTextField name="name" label="Name" placeholder="Diamond watch" />
<label>Date:</label>
<DatePicker
minDate={new Date()} //Don't allow the user to select any dates before now
onChange={(x) => onChange(x)}
value={value}
/>
</Form>
)
}
Then update the CreateProductSchema
in products/schemas.ts
to:
export const CreateProductSchema = z.object({
name: z.string(),
date: z.date(),
})
Then update products/mutations/createProduct.ts
to:
import { resolver } from "@blitzjs/rpc"
import db from "db"
import { CreateProductSchema } from "../schemas"
export default resolver.pipe(
resolver.zod(CreateProductSchema),
resolver.authorize(),
async (input) => {
const product = await db.product.create({
data: {
endDate: input.date,
name: input.name,
},
})
return product
}
)
Here we have updated the resolver to add the date to our Product
model in the DB.
Then install date-fns
by running:
npm i date-fns
Or, if you are using Yarn:
yarn add date-fns
Now let's show the date the auction ends on the product page. Open pages/products/[productId].tsx
and add below the product title:
<p>Auction ending in: {formatDistanceToNow(product.endDate, { addSuffix: true })}</p>
So it looks like:
import { Suspense, useEffect, useState } from "react"
import { Routes } from "@blitzjs/next"
import Head from "next/head"
import Link from "next/link"
import { useRouter } from "next/router"
import { useQuery, useMutation } from "@blitzjs/rpc"
import { useParam } from "@blitzjs/next"
import Layout from "src/core/layouts/Layout"
import getProduct from "src/products/queries/getProduct"
import deleteProduct from "src/products/mutations/deleteProduct"
import { Bid } from ".prisma/client"
import { formatDistanceToNow } from "date-fns"
import CreateBidForm from "../../bids/components/CreateBidForm"
export const Product = () => {
const router = useRouter()
const productId = useParam("productId", "number")
const [deleteProductMutation] = useMutation(deleteProduct)
const [product, { refetch }] = useQuery(getProduct, { id: productId })
const [sortedBids, setSortedBids] = useState<Bid[]>([])
useEffect(() => {
console.log(product)
setSortedBids(product.bids.sort((a, b) => b.bidValueUSD - a.bidValueUSD))
}, [product.bids])
return (
<>
<Head>
<title>Product - {product.name}</title>
</Head>
<div>
<h1>{product.name}</h1>
<p>Auction ending in: {formatDistanceToNow(product.endDate, { addSuffix: true })}</p>
{product.bids.length == 0 ? <p>No bids</p> : <p>Top Bid: {sortedBids[0]?.bidValueUSD}</p>}
<hr />
<CreateBidForm
currentMaxBid={product.bids[0]?.bidValueUSD}
onSuccess={() => refetch()}
productId={product.id}
/>
<hr />
<Link href={Routes.EditProductPage({ productId: product.id })}>Edit</Link>
<button
type= "button"
onClick={async () => {
if (window.confirm("This will be deleted")) {
await deleteProductMutation({ id: product.id })
await router.push(Routes.ProductsPage())
}
}}
style={{ marginLeft: "0.5rem" }}
>
Delete
</button>
</div>
</>
)
}
const ShowProductPage = () => {
return (
<div>
<p>
<Link href={Routes.ProductsPage()}>Products</Link>
</p>
<Suspense fallback={<div>Loading...</div>}>
<Product />
</Suspense>
</div>
)
}
ShowProductPage.authenticate = true
ShowProductPage.getLayout = (page) => <Layout>{page}</Layout>
export default ShowProductPage
Now if you go to the product page, you will be able to see the auction end date.
Now that we have the date in the DB and a decent UI, we need to remove the product from the DB and award it to the highest bidder when the auction ends.
To do that, we will use Quirrel
, a simple open-source system for job scheduling. You can add it to your Blitz app by running:
npm i quirrel concurrently
Or for Yarn:
yarn add quirrel concurrently
Now update the package.json
dev command to:
"dev": "concurrently --raw \"blitz dev\" 'quirrel'",
This makes it so that when you run yarn dev
(or npm run dev
), it will run a local quirrel
server along with blitz.js. At this point, you should stop your current dev server and restart it with npm run dev
or yarn dev
.
Then create the following file src/pages/api/queues/auctionEnd.ts
and add the following:
import { Queue } from "quirrel/next-pages"
import db from "../../../../db"
export default Queue("api/queues/auctionEnd", async (productId: number) => {
const product = await db.product.delete({
where: {
id: productId,
},
include: {
bids: true,
},
})
// Sort bids so the highest value ones are first
let sortedBids = product.bids.sort((a, b) => b.bidValueUSD - a.bidValueUSD)
// TODO: Message the winner of the bid
})
This is a simple stub job that we will extend later. Right now, it just deletes the product when the auction ends.
Now let's make it run when the auction ends. To do that, add the following to the createProduct
mutation resolver:
await auctionEnd.enqueue(product.id, {
runAt: input.date, // Run when the auction ends
id: nanoid(), // The ID representing the job for future reference
})
So it looks like this:
import { resolver } from "@blitzjs/rpc"
import db from "db"
import { CreateProductSchema } from "../schemas"
import { nanoid } from "nanoid"
import auctionEnd from "../../pages/api/queues/auctionEnd"
export default resolver.pipe(
resolver.zod(CreateProductSchema),
resolver.authorize(),
async (input) => {
const product = await db.product.create({
data: {
endDate: input.date,
name: input.name,
},
})
await auctionEnd.enqueue(product.id, {
runAt: input.date, // Run when the auction ends
id: nanoid(), // The ID representing the job for future reference
})
return product
}
)
Notifications
First, let's make the user input their phone number at signup so we can message them todo that update src/auth/components/SignupForm.tsx
to:
import { LabeledTextField } from "src/core/components/LabeledTextField"
import { Form, FORM_ERROR } from "src/core/components/Form"
import signup from "src/auth/mutations/signup"
import { Signup } from "src/auth/schemas"
import { useMutation } from "@blitzjs/rpc"
type SignupFormProps = {
onSuccess?: () => void
}
export const SignupForm = (props: SignupFormProps) => {
const [signupMutation] = useMutation(signup)
return (
<div>
<h1>Create an Account</h1>
<Form
submitText= "Create Account"
schema={Signup}
initialValues={{ email:" ", password:" "}}
onSubmit={async (values) => {
try {
await signupMutation(values)
props.onSuccess?.()
} catch (error: any) {
if (error.code === "P2002" && error.meta?.target?.includes("email")) {
// This error comes from Prisma
} else {
return { [FORM_ERROR]: error.toString() }
}
}
}}
>
<LabeledTextField name="email" label="Email" placeholder="Email" />
<LabeledTextField name="phoneNumber" label="Phone Number" placeholder="+11234567890" />
<LabeledTextField name="password" label="Password" placeholder="Password" type="password" />
</Form>
</div>
)
}
export default SignupForm
Here we have added a new LabeledTextField
to take in the phone number. Then update the Signup
zod schema in src/auth/schemas.ts
to:
export const Signup = z.object({
email,
password,
phoneNumber: z.string()
})
Then add the following to the User model in db/schema.prisma
:
phoneNumber String
Then run:
blitz prisma migrate reset && blitz prisma migrate dev
(Note: this will remove everything currently in the DB)
Then update the resolver in src/auth/mutations/signup.ts
to:
export default resolver.pipe(
resolver.zod(Signup),
async ({ email, password, phoneNumber }, ctx) => {
const hashedPassword = await SecurePassword.hash(password.trim())
const user = await db.user.create({
data: {
email: email.toLowerCase().trim(),
hashedPassword,
role: "USER",
phoneNumber: phoneNumber,
},
select: { id: true, name: true, email: true, role: true },
})
await ctx.session.$create({ userId: user.id, role: user.role as Role })
return user
}
)
Here we have added the phoneNumber
to the db.user.create
call.
Now let's expand the auctionEnd
job to message the bid winner. To do that, let's first install the Byteflow SDK:
npm i @byteflow-inc/sdk
Or for Yarn:
yarn add @byteflow-inc/sdk
(Side note: You can use Twilio here if you prefer but will have to wait 15+ business days (1+ month real-time) to start sending messages)
Now create the file integrations/byteflow.ts
and add:
import { ByteFlow } from "@byteflow-inc/sdk"
// NOTE: In a real deployment, make sure to store this API Key // in an environment variable instead
const sdk = new ByteFlow("<YOUR_API_KEY_HERE>")
export default sdk;
You can get a Byteflow API key by creating a free account here
Now let's update src/pages/api/queues/auctionEnd.ts
to:
import { Queue } from "quirrel/next-pages"
import db from "../../../../db"
export default Queue("api/queues/auctionEnd", async (productId: number) => {
const product = await db.product.delete({
where: {
id: productId,
},
include: {
bids: {
select: {
bidValueUSD: true,
User: {
select: {
phoneNumber: true,
},
},
},
},
},
})
// Sort bids so the highest value ones are first
let sortedBids = product.bids.sort((a, b) => b.bidValueUSD - a.bidValueUSD)
// Message the winner of bid
if (sortedBids[0]?.User.phoneNumber == undefined)
throw new Error("Could not get phoneNumber for user!")
await sdk.sendMessage({
message_content: `You have won the auction for: ${product.name} 🎉`,
destination_number: sortedBids[0]?.User.phoneNumber,
})
})
Now let's add a notification for when you get outbid. Update the resolver in src/bids/mutations/createBid.ts
to:
export default resolver.pipe(
resolver.zod(CreateBidSchema),
resolver.authorize(),
async (input, ctx) => {
const bidsForProduct = await db.bid.findMany({
where: {
productId: input.productId,
},
select: {
bidValueUSD: true,
User: {
select: {
phoneNumber: true,
},
},
},
})
let sortedBids = bidsForProduct.sort((a, b) => b.bidValueUSD - a.bidValueUSD)
let currentHighestBidPhoneNumber = sortedBids[0]?.User.phoneNumber
if (currentHighestBidPhoneNumber != undefined) {
await sdk.sendMessage({
message_content: `You have been outbid. The new highest bid is $${input.bidValueUSD}`,
destination_number: currentHighestBidPhoneNumber,
})
}
const bid = await db.bid.create({
data: {
userId: ctx.session.userId,
productId: input.productId,
bidValueUSD: input.bidValueUSD,
},
})
return bid
}
)
Here we have updated the resolver to get the current highest bid and, if it exists, send them a message to tell them they have been outbid.
Conclusion
In this article, we built an Ebay clone that lets users create products, bid on products, and get notifications. There is plenty of stuff you can do to expand on this, such as adding styling maybe by making your own component library (tutorial on that) or by using something more cookie cutter like MUI. I hope you found this tutorial useful!
Top comments (0)