Jacob Bruck

Business Owner | Coder | Arborist

April 13, 2023

Finding the Perfect Time Card Solution: Twilio, Next.js, and Clockify

Posted on April 13, 2023  •  9 minutes  • 1732 words

Trial and error is a crucial part of any process. In my business I tried multiple time tracking solutions. Eventually I found Clockify which checked off every box I need. I want to tell you how you can take it one step further using Twilio, Next.js, and Clockify’s API. We’ll also talk about the benefits of using Clockify’s API.

You can find the accompanying code for this article here

The Search for the Perfect Solution

Finding a time card solution that meets all of your specific requirements can be challenging. It’s important to find a product that offers API access, affordability, simplicity, multiple user support, and offline functionality. While many products exist, not all of them may check all of your boxes.

There are multiple solutions for time card management that I have tried myself and had my employees try, but nothing really worked until I found Clockify. Clockify has two features that really stand out for my needs, The mobile app has offline functionality, and its api allows me to integrate it with stripe and my infrastructure.

After an extensive search, I finally came across Clockify, which fulfills most of my requirements and even offers additional features. The offline capability of the app has proven to be extremely valuable, particularly due to the limited phone coverage in my location and the remote areas we often work in.

Clockify has a fantastic mobile app. It has everything I need as an owner. But At this point I have been through several cycles of employees using the app and its adoption has been inconsistent for a variety of reasons. The biggest issue for me was employees miss-categorizing their entries. The two solutions I came up with were to improve my training process, but also I can simplify the process by removing the mobile app entirely.

I decided that by leveraging the Twilio and Clockify APIS that I could allow my employees to clock-in with a text message. This powerful combination allowed us to create a streamlined time card system that prioritizes simplicity. Instead of relying on a graphical user interface, our employees can conveniently clock in and out by sending a straightforward text message. This tailored approach ensures ease of use and addresses the challenges we faced with employee adoption of the Clockify app.

Our SMS-based time tracking system has been met with excellent adoption and feedback from our employees. By implementing this system, we have successfully streamlined the process, eliminating the complexities that were associated with the Clockify app. The straightforward nature of our solution has resonated well with our team, ensuring that clock-ins and clock-outs are effortless and efficient.

Moreover, this system has also saved me valuable time when I check my time sheets. The confidence in the accuracy of their recorded time has been instilled within our employees, providing them peace of mind.

In addition to the existing functionality, we have made further improvements to the codebase. One notable addition is the integration of a GPT command, allowing employees to interact with Chat GPT through text messages. While the functionality is currently limited, it provides an avenue for employees to seek assistance and obtain information when needed. We are continuously working on expanding this feature to enhance its capabilities and provide even more value to our team.

Building the Custom Time Card Solution

Our custom solution involves using the Twilio SMS API to create a communication gateway that recognizes phone numbers from our MongoDB employee database. When an employee sends a text message to the gateway, the system checks if the phone number is associated with an employee. If so, it allows the employee to clock in or out and updates the Clockify API accordingly.

If the phone number is not found in the employee database, the system searches for the number in the Stripe customer database. If a match is found, the message is forwarded to the business owner and saved in the customer’s MongoDB entry.

Finally, if the phone number is not found in either database, the message is forwarded to the business owner and saved in a separate SMS message collection in MongoDB.


// Set up a Twilio webhook to handle incoming SMS messages

export default async function gateway(
  req: NextApiRequest,
  res: NextApiResponse
) {
  if (req.method === "POST") {
    console.log(req.body);
    const { url, headers, body } = req;
    const { From, Body } = body;
    const twilioSignature = headers["x-twilio-signature"] as string;
    const Fullurl =
      process.env.VERCEL_ENV == "development"
        ? process.env.TESTING_URL //for simplicy sake you may want to eithr pass develpment by default or put in the ngrok url for the signature check
        : `${ROOT_URL}${url}`;

//This section will verify that the request is coming from twilio
    if (!Fullurl || typeof twilioSignature != "string") {
      return res.status(403).end();
    }
        if (
        //this function verifies that the incoming request comes from Twilio
  !checkSignature(authToken, Fullurl, prepareParams(req), twilioSignature)
    ) {
      return res.status(403).json({
        error: "Invalid request signature",
         });

    }
    try {//Here we use a function that determines who is calling
      const senderType = await getSenderType(From);
      const handlers: { [key: string]: (body: string, from: string) => void } =
        {
          owner: handleOwnerMessage,
          supervisor: handleDoubleEmployee,
          generalEmployee: handleGeneralEmployee,,
          unknown: handleUnknown,
        };

      if (handlers[senderType]) {
        await handlers[senderType](Body, From);
      } else {
         return res.status(200).send("Message received, but called unknown, message dropped");
      }
     
      return res.status(200).send("Message received");
    } catch (error) {
      console.error(error);
      return res.status(500).send("Internal Server Error");
    }
  }
}

I want to be able to distinguish between owners, supervisors, employees, and customers so I use this function to look up the incoming phone number

async function getSenderType(From: string) {
  const { db } = await connectToDatabase();
  try {
    let senderType = "unknown";
    const EmployeeLookup = await db
      .collection("employees")
      .findOne({ phone: From });

    if (EmployeeLookup) {
      if (From === EmployeeLookup.phone) {
        switch (EmployeeLookup.role) {
          case "admin":
            senderType = "admin";
            break;
          case "supervisor":
            senderType = "supervisor";
            break;
          default:
            senderType = "generalEmployee";
            break;
        }
      }
    } else {
      senderType = "unknown";
    }
    return senderType;
  } catch (error) {
    console.error("Error while getting sender type: ", error);
    return "unknown";
  }
}

Once the sender has been verified and the role has been established lets process the test message.

Looking at back the code block


try {
      const senderType = await getSenderType(From);
      const handlers: { [key: string]: (body: string, from: string) => void } =
        {
          supervisor: handleDoubleEmployee,
          generalEmployee: handleGeneralEmployee,,
          unknown: handleUnknown,
        };

      if (handlers[senderType]) {
        await handlers[senderType](Body, From);
      } else {
         return res.status(200).send("Message received, but called unknown, message dropped");
      }
     
      return res.status(200).send("Message received");

These commands translate to the message handlers that are exported from these files.

export const handleGeneralEmployee = async (Body: string, From: string) => {
  switch (Body.toLowerCase()) {
    case "in":
      await startClock(From);

      await sendText("clocked In", From);

      break;

    case "out":
      await stopClock(From);

      await sendText("clocked Out", From);

      break;

    default:
      await sendText("msg not received. Commands are: \n in \n out", From);

      break;
  }
};

Saving Time Card Data

When we look at the code I use to save the time cards we see that I do not update Clockify until the employee clocks out. That saves me time having to clean up or update information in Clockify. I achieve this by setting the time an employee clocks in and saves it to the database.



export const updateEmployee = async (

  filter: UpdateFilter<{ [key: string]: any }>,

  updateDoc: { [key: string]: any },

  options: UpdateOptions

) => {

  const { db } = await connectToDatabase();

  try {

    const clean = sanitize(updateDoc);

    //returns matched count

    const update = { $set: clean };

    const updateEmployee = await db

      .collection("employees")

      .updateOne(filter, update, options);

    console.log("update Info", updateEmployee);

    return updateEmployee.matchedCount;

  } catch (err) {

    const errorMessage = err instanceof Error ? err.message : "Unknown error";

    // On error, log and return the error message.

    console.log(`❌ update error: ${errorMessage}`);

    return { status: 400, msg: `update error: ${errorMessage}` };

  }

};

Interacting With Clockify

I wrote a fetcher function to interact with the Clockify API. And Yes I did ask ChatGPT to help improve the function.

export default async function Fetcher({

  dest,

  method,

  payload,

  workspace,

}: {

  dest: string;

  method: string;

  payload?: object;

  workspace?: string;

}) {

  // Validate the method

  const allowedMethods = ["GET", "POST", "PUT", "PATCH", "DELETE"];

  if (!allowedMethods.includes(method.toUpperCase())) {

    throw new Error(

      "Invalid method: Must be one of GET, POST, PUT, PATCH, or DELETE."

    );

  }

  const workspaceID = process.env.CLOCKSPACE;

  const url = `https://api.clockify.me/api/v1/workspaces/${workspaceID}/${dest}`;



  const fetchOptions: RequestInit = {

    method: method,

    headers: {

      "content-type": "application/json",

      "X-Api-Key": process.env.CLOCKIFY as string,

    },

  };



  // Add payload to the fetch options if the method requires it

  if (["POST", "PUT", "PATCH"].includes(method.toUpperCase())) {

    fetchOptions.body = JSON.stringify(payload);

  }



  const response = await fetch(url, fetchOptions);

  console.log("Clockify Fetcher Response status:", fetchOptions);

  if (!response.ok) {

    const responseText = await response.text();

    console.log("Clockify Fetcher Response status:", response.status);

    console.log("Clockify Fetcher Response text:", responseText);

    console.log(response);

    throw new Error(

      `CLOCKIFY Fetcher Error: ${response.status} - ${responseText}`

    );

  }



  return response.json();

}

Clocking Out

The fetcher function gets used when users clock out. The code takes information from the database and calls Clockify’s route /user/${user.clockifyId}/time-entries

export const stopClock = async (phone: string) => {

  const query = { phone: phone };

  const options = {};

  let removeJobMSG;

  const { db } = await connectToDatabase();

  const user = await FindEmployee(query, options, db);



  const formattedDate = setCurrentDateAndTime();



  const dest = `/user/${user.clockifyId}/time-entries`;

  const method = "POST";

  console.log("DATE:  ", user.activeJob.start);

  console.log("StartDATE:  ", formattedDate);

  const payload = {

    billable: false,

    start: user.activeJob.start,

    end: formattedDate,

    projectId: user.activeJob.projectId,

  };



  const stopClock = await Fetcher({

    dest: dest,

    method: method,



    payload: payload,

  });

  console.log("Payload", payload);

  if ((stopClock.status = 201)) {

    const filter = {

      _id: user._id,

    };



    const update = {

      $unset: { activeJob: null },

    };

    removeJobMSG = await db

      .collection("employees")

      .updateOne(filter, update, options);

  }

  return { stopClock, removeJobMSG };

};

Conclusion

The code in this project as always is a work in progress. We’ve been able to work out a working version to test out in the field. In the next post I’ll talk about our implementation and testing, in the meantime please check out the repository and see what you can run with the code.

Our journey to find the perfect time card solution is not over and it continues to be filled with trial and error, but we constantly improve and build find more efficient and flexible solutions to add to the robust stack of solutions that we use to systematize our business and deliver a premium product.

By sharing our experience, we hope to inspire others to explore the potential of these technologies and create custom solutions tailored to their unique business needs.