Building a Cal.com Clone With Remix + Prisma + ZenStack

ymc9

ymc9

Posted on February 4, 2023

Building a Cal.com Clone With Remix + Prisma + ZenStack

If you're in the SaaS business or into trying out different web apps, you've likely heard of Cal.com. It's an fantastic product that helps you schedule meetings with clients more efficiently - simply set your availability, share your public booking link, and let people book when it works for both of you.

This post demonstrates how easy it is to make a simplified clone of Cal.com with a modern Javascript stack - Remix.run, Prisma, and ZenStack.

Key Takeaways

  • Use Remix's loader and action to easily build up UI that fetches and mutates data.
  • Use Prisma to build a concise and centralized model data.
  • Use ZenStack to secure your database access declaratively.

Features

Cal.com has many features. For simplicity, we will only replicate the essential part - event booking. The requirements are as follows:

  1. User signup and login
  2. Browse and manage existing bookings
  3. Invite other users to a booking
  4. Allow anonymous users to make bookings via public booking urls

App Homepage

About The Stack

Remix.run

Remix.run is a React-based full-stack framework that's both powerful and pleasant to use. A few really nice features:

  • File-based routing - just drop .tsx files in folders, no need to configure routing
  • Easy data loading and submitting - use simple conventional loader function for fetching and action function for mutation
  • Preconfigured "stacks" allow you to have a quick start without messing with tooling configurations

Overall Remix.run feels like a simpler and more elegantly designed alternative to Next.js for React developers.

Prisma

Prisma is the most loved ORM for Node.js development and a natural fit into a modern web development stack. It gives you:

  • An intuitive DSL for modeling your database schema
  • Fully type-safe CRUD APIs
  • Easy-to-use database migration flow

ZenStack

ZenStack is a power pack for Prisma and adds a powerful access control layer. It helps you to:

  • Express data access control rules succinctly inside your schema
  • Automatically generate data access APIs for frontend code
  • Easily share types between frontend and backend

Implementation

If you don't want to follow the detailed steps, you can also find the finished project on Gitpod:

Open in Gitpod

Scaffold the project

Scaffolding a new project is very easy thanks to create-remix. We'll use the "indie-stack" here, which uses a SQLite database - well suited for prototyping.

npx create-remix@latest --template remix-run/indie-stack cal-com-clone
Enter fullscreen mode Exit fullscreen mode

Choose the following options when prompted:

? TypeScript or JavaScript? TypeScript
? Do you want me to run `npm install`? Yes
Enter fullscreen mode Exit fullscreen mode

Now run npm run dev and verify that the scaffolding works. You should see a homepage with signup/signin functions already working.

Homepage

Try signing up for a new account and see if it works.

Configure ZenStack

Next, let's initialize our project to use ZenStack:

npx zenstack@latest init
Enter fullscreen mode Exit fullscreen mode

If everything worked, you should see a schema.zmodel file under the root of your project. Moving forward, we'll use this file to model data and access policies. Instead, the Prisma schema file prisma/schema.prisma will be automatically generated.

Also, install the VS Code extension for authoring the '.zmodel' schema.

Define data models

The scaffolded project already contains a sample Todo data model. Let's add our booking-related models to /schema.zmodel:


// /schema.zmodel

// Booking entity
model Booking {
  id           String   @id() @default(cuid())
  createdAt    DateTime @default(now())
  updatedAt    DateTime @updatedAt
  email        String
  notes        String
  startAt      DateTime
  duration     Int
  user         User     @relation(fields: [userId], references: [id], onDelete: Cascade)
  userId       String
  invitedUsers InvitedUser[]

  // allow everyone to create a booking, including anonymous users
  @@allow('create', true)

  // owner is allowed for full CRUD
  @@allow('all', auth() == user)

  // invited users can read the booking
  @@allow('read', invitedUsers?[user == auth()])
}

// Entity representing a User invited to a Booking
model InvitedUser {
  id          String @id() @default(cuid())
  user        User @relation(fields: [userId], references: [id], onDelete: Cascade)
  userId      String
  booking     Booking @relation(fields: [bookingId], references: [id], onDelete: Cascade)
  bookingId   String
  @@unique([bookingId, userId])

  // allow everyone to create an invitation, disallow create for self
  @@allow('create', auth() != null && user != auth())

  // booking's owner is allowed for full CRUD
  @@allow('all', booking.user == auth())
}

Enter fullscreen mode Exit fullscreen mode

Also, make an update to the User model to include references to Booking and InviteUser, and make it publicly readable.

// /schema.zmodel

model User {
  id    String @id @default(cuid())
  email String @unique
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
  password Password? @omit
  notes Note[]
  invitations InvitedUser[]
  bookings Booking[]

  // user profiles are publicly readable
  @@allow('read', true)
}

Enter fullscreen mode Exit fullscreen mode

Tips:

@@allow attribute is ZenStack's extension to Prisma for modeling access policies. All operations are, by default, denied unless explicitly opened. auth() is a built-in function for getting the current login user.

By expressing access policies explicitly in data schema, you don't need to write imperative code for them anymore. It's both time-saving and more robust.

Finally, run the following command to regenerate Prisma client and synchronize data models to the database schema.


npx zenstack generate
npx prisma db push

Enter fullscreen mode Exit fullscreen mode

Add server-side utilities

Let's now add a few server-side utilities that'll be used when building up the UI.

First, add a helper for getting an access-policy-aware Prisma client in /app/db.server.ts:

// /app/db.server.ts

import { withPresets } from '@zenstackhq/runtime';

export function getEnhancedPrisma(userId: string) {
    // withPresets configures a regular Prisma client for access policy checks
    return withPresets(prisma, { user: { id: userId } });
}
Enter fullscreen mode Exit fullscreen mode

Next, create /app/models/booking.server.ts file and add helpers for manipulating the Booking model:

// /app/models/booking.server.ts

import type { Booking, User } from '@prisma/client';
import { getEnhancedPrisma } from '~/db.server';
export type { Booking } from '@prisma/client';

// Gets a booking together with its owner and invited users
export function getBooking({
    id,
    userId,
}: Pick<Booking, 'id'> & {
    userId: User['id'];
}) {
    return getEnhancedPrisma(userId).booking.findFirst({
        where: { id },
        include: { user: true, invitedUsers: { include: { user: true } } },
    });
}

// Gets all booking items
export function getBookingItems({ userId }: { userId: User['id'] }) {
    return getEnhancedPrisma(userId).booking.findMany({
        orderBy: { updatedAt: 'desc' },
    });
}

// Creates a new booking
export function createBooking({
    userId,
    email,
    notes,
    startAt,
    duration,
}: Pick<Booking, 'email' | 'notes' | 'startAt' | 'duration'> & {
    userId: User['id'];
}) {
    return getEnhancedPrisma(userId).booking.create({
        data: {
            email,
            notes,
            startAt,
            duration,
            user: {
                connect: {
                    id: userId,
                },
            },
        },
    });
}

// Adds or removes an invitation of a booking
export function updateInvite({
    userId,
    bookingId,
    inviteUserId,
    add,
}: {
    userId: User['id'];
    bookingId: Booking['id'];
    inviteUserId: User['id'];
    add: boolean;
}) {
    return getEnhancedPrisma(userId).booking.update({
        where: { id: bookingId },
        include: { invitedUsers: true },
        data: {
            invitedUsers: add
                ? {
                      connectOrCreate: {
                          where: {
                              bookingId_userId: {
                                  bookingId,
                                  userId: inviteUserId,
                              },
                          },
                          create: {
                              user: {
                                  connect: { id: inviteUserId },
                              },
                          },
                      },
                  }
                : {
                      delete: {
                          bookingId_userId: {
                              bookingId,
                              userId: inviteUserId,
                          },
                      },
                  },
        },
    });
}

// Deletes a booking
export function deleteBooking({ id, userId }: Pick<Booking, 'id'> & { userId: User['id'] }) {
    return getEnhancedPrisma(userId).booking.delete({
        where: { id },
    });
}
Enter fullscreen mode Exit fullscreen mode

The code above is mostly standard Prisma programming, except that we used the getEnhancedPrisma to get an access-policy-aware Prisma client. We don't need to add any filter to check if the user has permission to perform an action anymore because, at runtime, our policies defined in schema.zmodel will guard the operations; e.g., even though we didn't filter anything in the getBookingItems function, still only the items that are supposed to be visible to the current user are returned.

Finally, add a getUsers helper in /app/models/user.server.ts:

// /app/models/user.server.ts

export async function getUsers() {
    return prisma.user.findMany({ select: { id: true, email: true } });
}
Enter fullscreen mode Exit fullscreen mode

Build the booking list page

With all the preparation work done, we can start to build up the UI part now. Create /app/routes/booking.tsx with the following content:

// /app/routes/booking.tsx

import type { LoaderArgs } from '@remix-run/node';
import { json } from '@remix-run/node';
import { Form, Link, NavLink, Outlet, useLoaderData } from '@remix-run/react';
import { useEffect, useState } from 'react';
import { getBookingItems } from '~/models/booking.server';
import type { User } from '~/models/user.server';
import { requireUserId } from '~/session.server';
import { useUser } from '~/utils';

export async function loader({ request }: LoaderArgs) {
    const userId = await requireUserId(request);
    const bookings = await getBookingItems({ userId });
    return json({ bookings });
}

function getBookingUrl(user: User) {
    const url = new URL(window.location.href);
    url.pathname = `/new`;
    url.search = `?uid=${user.id}`;
    return url.toString();
}

export default function BookingsPage() {
    const data = useLoaderData<typeof loader>();
    const user = useUser();
    const [bookingUrl, setBookingUrl] = useState('');

    useEffect(() => {
        setBookingUrl(getBookingUrl(user));
    }, [user]);

    return (
        <div className="flex h-full min-h-screen flex-col">
            <header className="flex items-center justify-between bg-slate-800 p-4 text-white">
                <h1 className="text-3xl font-bold">
                    <Link to=".">Bookings</Link>
                </h1>
                <p>{user.email}</p>
                <Form action="/logout" method="post">
                    <button
                        type="submit"
                        className="rounded bg-slate-600 py-2 px-4 text-blue-100 hover:bg-blue-500 active:bg-blue-600"
                    >
                        Logout
                    </button>
                </Form>
            </header>

            <main className="flex h-full bg-white">
                <div className="h-full w-1/3 border-r bg-gray-50">
                    <div className="p-8">
                        <h3 className="pb-1 font-semibold">Public url:</h3>
                        <p className="italic">{bookingUrl}</p>
                    </div>

                    <hr />

                    {data.bookings.length === 0 ? (
                        <p className="p-4">No bookings yet</p>
                    ) : (
                        <ol>
                            {data.bookings.map((booking) => (
                                <li key={booking.id}>
                                    <NavLink
                                        className={({ isActive }) =>
                                            `block border-b p-6 text-xl ${isActive ? 'bg-white' : ''}`
                                        }
                                        to={booking.id}
                                    >
                                        <div className="flex items-baseline justify-between">
                                            <span>🗓️ {booking.email}</span>
                                            <span className="ml-8 inline-block text-sm">
                                                {new Date(booking.startAt).toLocaleString()} ~ {booking.duration} min
                                            </span>
                                        </div>
                                    </NavLink>
                                </li>
                            ))}
                        </ol>
                    )}
                </div>

                <div className="flex-1 p-6">
                    <Outlet />
                </div>
            </main>
        </div>
    );
}
Enter fullscreen mode Exit fullscreen mode

Visit http://localhost:3000/booking, and you should see an empty list UI:

Empty booking list

The Public Url at the top is the URL you can share with anonymous users for creating bookings. We'll implement that page next.

Build the booking detail page

The booking detail page allows the owner to inspect a booking's details, delete it, or invite other users to it.

Create /app/routes/booking/index.tsx and /app/routes/booking/$bookingId.tsx.

// /app/routes/booking/index.tsx

export default function NoteIndexPage() {
    return <p>No booking selected. Select one on the left.</p>;
}
Enter fullscreen mode Exit fullscreen mode
// /app/routes/booking/$bookingId.tsx

import type { ActionArgs, LoaderArgs } from '@remix-run/node';
import { json, redirect } from '@remix-run/node';
import { Form, useFetcher, useLoaderData } from '@remix-run/react';
import invariant from 'tiny-invariant';
import { deleteBooking, getBooking, updateInvite } from '~/models/booking.server';
import { getUsers } from '~/models/user.server';
import { requireUserId } from '~/session.server';

export async function loader({ request, params }: LoaderArgs) {
    const userId = await requireUserId(request);
    invariant(params.bookingId, 'bookingId not found');

    const booking = await getBooking({ userId, id: params.bookingId });
    if (!booking) {
        throw new Response('Not Found', { status: 404 });
    }

    const users = await getUsers();

    return json({ booking, users });
}

async function updateInviteAction({ request }: ActionArgs) {
    const userId = await requireUserId(request);
    const formData = await request.formData();
    const bookingId = formData.get('bookingId') as string;
    const inviteUserId = formData.get('inviteUserId') as string;
    const add = formData.get('add') === 'true';

    try {
        await updateInvite({
            userId,
            bookingId,
            inviteUserId,
            add,
        });
        return json({ error: null, ok: true });
    } catch (error: any) {
        return json({ error: error.message, ok: false });
    }
}

async function deleteAction({ request, params }: ActionArgs) {
    const userId = await requireUserId(request);
    invariant(params.bookingId, 'bookingId not found');
    await deleteBooking({ userId, id: params.bookingId });
    return redirect('/booking');
}

export async function action(args: ActionArgs) {
    if (args.request.method === 'POST') {
        return updateInviteAction(args);
    } else if (args.request.method === 'DELETE') {
        return deleteAction(args);
    }
}

export default function BookingDetailsPage() {
    const data = useLoaderData<typeof loader>();
    const fetcher = useFetcher();

    function onChangeInvite(inviteUserId: string, add: boolean) {
        fetcher.submit(
            {
                bookingId: data.booking.id,
                inviteUserId,
                add: add.toString(),
            },
            { method: 'post' }
        );
    }

    return (
        <div>
            <h3 className="text-2xl font-bold">{data.booking.email}</h3>
            <div className="flex flex-col gap-4 py-6">
                <div>
                    <p className="font-semibold">Owner</p>
                    <p>{data.booking.user.email}</p>
                </div>
                <div>
                    <p className="font-semibold">Start At</p>
                    <p>{new Date(data.booking.startAt).toLocaleString()}</p>
                </div>
                <div>
                    <p className="font-semibold">Duration</p>
                    <p>{data.booking.duration} minutes</p>
                </div>
                <div>
                    <p className="font-semibold">Notes</p>
                    <p>{data.booking.notes}</p>
                </div>
                <div>
                    <p className="font-semibold">Invited Members</p>

                    <div className="flex flex-col gap-1">
                        {data.users
                            .filter((user) => user.id !== data.booking.user.id)
                            .map((user) => (
                                <label key={user.id} className="flex items-center">
                                    <span>{user.email}</span>
                                    <input
                                        type="checkbox"
                                        className="ml-2"
                                        checked={data.booking.invitedUsers.some((invite) => invite.userId === user.id)}
                                        onChange={(e) => onChangeInvite(user.id, e.currentTarget.checked)}
                                    />
                                </label>
                            ))}
                    </div>
                </div>
            </div>
            <hr className="my-4" />
            <Form method="delete">
                <button
                    type="submit"
                    className="rounded bg-blue-500  py-2 px-4 text-white hover:bg-blue-600 focus:bg-blue-400"
                >
                    Delete
                </button>
            </Form>
        </div>
    );
}
Enter fullscreen mode Exit fullscreen mode

Build the public booking page

Let's build up our public booking UI now.

First, install react-calendar package for a nice date-picker component:

npm install react-calendar
npm i --save-dev @types/react-calendar
Enter fullscreen mode Exit fullscreen mode

Next, create /app/routes/new.tsx with the following content:

// /app/routes/new.tsx

import type { ActionArgs, LoaderArgs, MetaFunction } from '@remix-run/node';
import { json, redirect } from '@remix-run/node';
import { Form, useActionData, useLoaderData } from '@remix-run/react';
import * as React from 'react';
import { createBooking } from '~/models/booking.server';
import { getUserById } from '~/models/user.server';
import Calendar from 'react-calendar';
import styles from 'react-calendar/dist/Calendar.css';

export function links() {
    return [{ rel: 'stylesheet', href: styles }];
}

export async function loader({ request }: LoaderArgs) {
    const url = new URL(request.url);
    const uid = url.searchParams.get('uid');
    if (!uid) {
        throw Error('Missing uid parameter');
    }
    const user = await getUserById(uid);
    return json({ user });
}

export async function action({ request }: ActionArgs) {
    const formData = await request.formData();
    const uid = formData.get('uid');
    const email = formData.get('email');
    const notes = formData.get('notes');
    const startAt = formData.get('startAt') as string;

    if (typeof uid !== 'string' || uid.length === 0) {
        return json({ errors: { email: null, notes: null } }, { status: 400 });
    }

    if (typeof email !== 'string' || email.length === 0) {
        return json({ errors: { email: 'Email is required', notes: null } }, { status: 400 });
    }

    if (typeof notes !== 'string' || notes.length === 0) {
        return json({ errors: { email: null, notes: 'Notes is required' } }, { status: 400 });
    }

    await createBooking({
        email,
        notes,
        userId: uid,
        startAt: new Date(startAt),
        duration: 30,
    });

    return redirect(`/thankyou`);
}

export const meta: MetaFunction = () => {
    return {
        title: 'Create Booking',
    };
};

export default function NewBookingPage() {
    const data = useLoaderData<typeof loader>();
    const actionData = useActionData<typeof action>();
    const emailRef = React.useRef<HTMLInputElement>(null);
    const notesRef = React.useRef<HTMLTextAreaElement>(null);
    const [date, setDate] = React.useState(new Date());
    const [hour, setHour] = React.useState('8');
    const [duration, setDuration] = React.useState(30);
    const [startAt, setStartAt] = React.useState(new Date().toISOString());

    function updateStartAt(date: Date, hour: number) {
        const d = new Date(date);
        d.setHours(hour);
        d.setMinutes(0);
        d.setSeconds(0);
        d.setMilliseconds(0);
        setStartAt(d.toISOString());
        console.log(d.toISOString());
    }

    function onDateChange(date: Date) {
        setDate(date);
        updateStartAt(date, parseInt(hour));
    }

    function onHourChange(hour: string) {
        console.log(hour);
        setHour(hour);
        updateStartAt(date, parseInt(hour));
    }

    React.useEffect(() => {
        if (actionData?.errors?.email) {
            emailRef.current?.focus();
        } else if (actionData?.errors?.notes) {
            notesRef.current?.focus();
        }
    }, [actionData]);

    return (
        <div className="mx-auto max-w-5xl py-16">
            <h1 className="pb-4 text-center text-3xl font-semibold">Create a booking</h1>
            <p className="pb-4 text-center">Booking request will be sent to {data.user?.email}</p>
            <Form
                method="post"
                style={{
                    display: 'flex',
                    flexDirection: 'column',
                    gap: 8,
                    width: '100%',
                }}
            >
                <div>
                    <label className="flex w-full flex-col gap-1">
                        <span>Email: </span>
                        <input
                            ref={emailRef}
                            name="email"
                            type="email"
                            className="flex-1 rounded-md border-2 border-blue-500 px-3 text-lg leading-loose"
                        />
                    </label>
                    {actionData?.errors?.email && (
                        <div className="pt-1 text-red-700" id="email-error">
                            {actionData.errors.email}
                        </div>
                    )}
                </div>
                <div>
                    <label className="flex w-full flex-col gap-1">
                        <span>Notes: </span>
                        <textarea
                            ref={notesRef}
                            name="notes"
                            rows={8}
                            className="w-full flex-1 rounded-md border-2 border-blue-500 py-2 px-3 text-lg leading-6"
                        />
                    </label>
                    {actionData?.errors?.notes && (
                        <div className="pt-1 text-red-700" id="notes-error">
                            {actionData.errors.notes}
                        </div>
                    )}
                </div>
                <div className="flex flex-row gap-4">
                    <div>
                        <label className="flex w-full flex-col gap-1">
                            <span>Date: </span>
                            <Calendar
                                className="rounded-lg border-2 border-blue-500 p-4"
                                value={date}
                                onChange={onDateChange}
                            />
                        </label>
                    </div>
                    <div className="flex flex-grow flex-col gap-2">
                        <label className="flex w-full flex-col gap-1">
                            <span>Start At: </span>
                            <select
                                name="startTime"
                                className="rounded-lg border-2 border-blue-500 p-2"
                                value={hour}
                                onChange={(e) => onHourChange(e.currentTarget.value)}
                            >
                                {[...Array(24).keys()].map((i) => (
                                    <option key={i} value={`${i}`}>
                                        {String(i).padStart(2, '0')}:00
                                    </option>
                                ))}
                            </select>
                        </label>
                        <label className="flex w-full flex-col gap-1">
                            <span>Duration: {duration} minutes</span>
                            <input
                                name="duration"
                                type="range"
                                min="15"
                                max="120"
                                step="15"
                                className="rounded-lg border-2 border-blue-500 p-2"
                                value={duration}
                                onChange={(e) => setDuration(Number(e.target.value))}
                            />
                        </label>
                    </div>
                </div>
                <input type="hidden" value={data.user?.id} name="uid" />
                <input type="hidden" value={startAt} name="startAt" />
                <div className="text-right">
                    <button
                        type="submit"
                        className="rounded bg-blue-500 py-2 px-4 text-white hover:bg-blue-600 focus:bg-blue-400"
                    >
                        Send Request
                    </button>
                </div>
            </Form>
        </div>
    );
}
Enter fullscreen mode Exit fullscreen mode

Finally, add a simple "Thank you" page to redirect the user after a booking is created - /app/routes/thankyou.tsx:

// /app/routes/thankyou.tsx

export default function ThankYouPage() {
    return (
        <div className="flex h-screen w-screen flex-col items-center justify-center">
            <h1 className="pb-6 text-3xl font-semibold">Thank you for your booking!</h1>
            <p className="text-lg">We'll get in touch soon.</p>
        </div>
    );
}
Enter fullscreen mode Exit fullscreen mode

Now in an incognito browser tab, visit the Public Url as mentioned in the previous step and create a booking.

Test out the invitation feature

Get back to your original browser tab and refresh the page. You should see the new booking request in the list. Clicking it should open its details view:

Booking list and details

Next, sign up a few more users and invite some of them into the booking. When a user is invited to a booking, they should see it in the list. However, if they try to make any mutation, like deleting it or inviting other users, an error will be thrown, because our access policies only allow "read" operation for invited users. Of course, in a real app, you should hide the actions from the UI.

Conclusion

Congratulations 🎉! You've got a basic version of an event scheduling app up and running now. You can also play with the finished code on Gitpod:

Open in Gitpod

Even though we only implemented the basic features of Cal.com, I hope this post convinces you that a modern stack like Remix.run + Prisma + ZenStack can greatly simplify the development of a web app.

Is it something you'll consider for the next project?

💖 💪 🙅 🚩
ymc9
ymc9

Posted on February 4, 2023

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related