Crafting Memora: Harnessing Grafbase to Treasure Timeless Memories

Crafting Memora: Harnessing Grafbase to Treasure Timeless Memories

·

11 min read

In the digital age, memories are fleeting. We capture moments, only to forget them amidst the deluge of daily content. Enter "Memora" — an app designed to treasure and revisit cherished memories. Imagine creating a digital time capsule: sealing a precious memory today and scheduling it to resurface in the future. But Memora isn't just for solo reminiscing. Its unique "group capsule" feature allows shared memories to be unlocked jointly, ensuring moments are relived in unison with loved ones. From intimate anniversaries to milestone celebrations, Memora transforms the way we reminisce, making every memory a treasure waiting to be rediscovered. Leveraging Grafbase's robust capabilities, I've crafted the prototype of Memora. But this is just the beginning. The horizon promises more features, refinements, and enhancements, ensuring that Memora continues to evolve and resonate even more deeply with its users.

Demo :

In the demo, you can see the basic flow

  • A user initiates by crafting a Capsule, supplementing it with a caption and choosing from a range of media options: audio, video, or image. They can further personalize the experience by adding members who are granted access to view this capsule. Additionally, a location constraint can be set, requiring users to be within a 50km radius of the designated spot to access the capsule. The cherry on top? Users can schedule a specific time for the Capsule's grand unveiling.

  • If the designated time for the capsule hasn't arrived, it remains locked and inaccessible. Additionally, even if the time is right but you're not at the specified location set during its creation, the capsule will remain sealed.

  • Upon the scheduled moment of revelation, the capsule's content becomes visible on the user's feed. If other members were linked to that particular capsule, they, too, are treated to this memory on their respective feeds.

I'm thrilled to take you behind the scenes of how Memora is been built.

Grafbase Setup

Grafbase allows developers to deploy Graphql APIs faster. Grafbase isn't just a platform; it's a powerhouse of potential. Whether it's integrating diverse data sources through resolvers and connectors, finetuning edge caching, establishing authentication and permission protocols, or enabling serverless search, Grafbase has it all covered.

But the magic doesn't stop there. With the Grafbase CLI, you're granted the freedom to operate locally, ensuring that development is a breeze. Moreover, every Git branch comes equipped with its own exclusive preview deployment. This not only simplifies testing but also fosters seamless collaboration, ensuring that your project's progression is both smooth and efficient.

For Memora, the schema is very simple. I used the Grafbase typescript sdk for this project.

import { g, auth, config } from "@grafbase/sdk";

const location = g
  .model("Location", {
    address: g.string(),
    lat: g.float(),
    lng: g.float(),
  })
  .search();

const capsule = g
  .model("Capsule", {
    caption: g.string().default(""),
    content: g.string().optional(),
    members: g.string().list(),
    location: g.relation(location),
    availableAt: g.string(),
    cron: g.string(),
    tapCount: g.int().default(0), //Amount of people tapped to open [used for group capsules]
  })
  .search();

const post = g
  .model("Post", {
    caption: g.string().default(""),
    content: g.string().optional(),
    sub: g.string(),
  })
  .search();

const user = g
  .model("User", {
    sub: g.string().unique(),
    email: g.email().unique(),
  })
  .search();


const provider = auth.JWT({
  issuer: g.env('SUPABASE_URL'),
  secret: g.env('SUPABASE_JWT_SECRET')
})

export default config({
  schema: g,

  auth: {
    providers: [provider],
    rules: (rules) => {
      rules.private()
    }
  }
});

Which then is turned into the Graphql schema if you run Grafbase locally (under ".grafbase/generated/schemas")

Grafbase makes it super easy to add authentication. For Supabase , I simply add the Supabase project URL and JWT secret which you can get from your Supabase project setting (Settings->API->JWT settings).

Grafbase automatically creates all queries and mutations required to perform CRUD operations on Models. Another amazing thing about Grafbase is you can spin up your api locally and test it before deploying.

Flutter with Grafbase

To begin with, let's look at the implementation of creating a user in the Grafbase default database. I am using Flutter as my client. To create a User as declared using the User model in the Grafbase config, we need to do a few setup steps.

  • Get Grafbase URL and Key: If you want to test it locally simply copy and paste the local URL that printed out in the console when you run npx grafbase dev

  • Install dotenv and http package for Flutter.

The createUser function will look like this :

 Future createUser(String accessToken, String sub, String email) async {
    try {
      String mutation = '''mutation UserCreate {
          userCreate(input: {
            email:"$email",
            sub : "$sub"
          }) {
            user {
              sub
              email
            }
          }
        }
    ''';

      Map<String, dynamic> variables = {
        "sub": sub,
        "email": email,
      };

      await sendRequest(mutation, variables, accessToken);
    } catch (e) {
      log(e.toString());
    }
  }

In this function, we are hitting the automatically generated endpoint for the User model for which sub and email are passed as input.

sendRequest function looks like this :

Future sendRequest(String query, Map variables, String accessToken) async {

    final String grafbase_key =
        dotenv.env['grafbase_key']!; // Replace this with your API key

    final String url =
        dotenv.env['grafbase_url']!; // Replace this with your GraphQL API URL
    final response = await http.post(
      Uri.parse(url),
      headers: {
        "Content-Type": "application/json",
        "x-api-key": grafbase_key,
        "Authorization": "Bearer $accessToken"
        // Add any other headers if necessary
      },
      body: jsonEncode({
        "query": query,
        "variables": variables,
      }),
    );
    //Process your response
}

Note that you need to pass the x-api-key in the header. In the body pass "query" which will be your Graphql query string and also the variables that you need to pass as input to the query. I am passing the Authorization header because I need it for certain resolvers.

Similarly, you can call any query or mutation created on Grafbase.

Custom mutation with Resolvers

All the resolvers for grafbase have to be under grafbase/resolvers folder.

Grafbase makes it super easy to add your custom logic by adding resolvers. For Memora , I plan to extend it and send users email notifications as the capsule availability date comes closer. For this, I want a one-time cron job. With Grafbase it's super easy to test this locally which makes it so much better. Let's see how.

Write mutation in the config file :

g.mutation("schedulePost", {
  args: {
    mediaURL: g.string().optional(),
    caption: g.string().optional(),
    cron: g.string(),
    timezone: g.string(),
    expiresAt: g.string().optional().default("0"),
  },
  returns: g.string(),
  resolver: "posts/schedulePost",
});

The resolver field points to the file that has the resolver handler.

Cron job scheduling with Grafbase

I am going to use cron-job-org for my cron jobs. It allows us to add headers and body to our cron job and also mention the expiry date for our cron jobs so that it runs only once. That is exactly what we need. You can read more about it here.

In short, we schedule a cron job at the exact time the capsule is scheduled by the user. The cron job is fired at that time in the future and sends the user the email notification we need. The cron's expiry date will be one hour after its scheduled time so that it runs only once and then gracefully retires.

The "grafbase/resolvers/posts/schedulePost.ts" file looks like this :

import { format, addHours } from "date-fns";
import jwt from "@tsndr/cloudflare-worker-jwt";
import base64url from "base64url";



type CronObj = {
  minute: string;
  hour: string;
  day: string;
  month: string;
  weekday: string;
};

export default async function Resolver(root, args, { request }) {
  try {
    const {
      mediaURL,
      caption,
      cron,
      timezone,
      expiresAt,
    } = args;

    const cronObj = strToCronObj(cron);

    const { headers } = request;

    const authorization = headers["authorization"];
    const token = authorization.split("Bearer ").pop();

    const isValid = await jwt.verify(token, "secret");

    // Check for validity
    if (!isValid) {
            //handle invalid token
    }

    // Decoding token
    const decoded = jwt.decode(token);
    const sub = decoded.payload.sub;

    const payload = {
      job: {
        enabled: true,
        url: "Your email smpt server",
        requestMethod: 1,
        saveResponses: true,
        extendedData: {
          headers: {
            Authorization: "Bearer " + token,
            "Content-Type": "application/json",
            "x-api-key": process.env.GB_KEY,
          },
          body: JSON.stringify({ "body":"params" }),
        },

        schedule: {
          timezone: timezone,
          expiresAt: expiresAt,
          hours: [cronObj["hour"]],
          mdays: [cronObj["day"]],
          minutes: [cronObj["minute"]],
          months: [cronObj["month"]],
          wdays: [cronObj["weekday"]],
        },
      },
    };

    const response = await fetch(`https://api.cron-job.org/jobs`, {
      method: "PUT",
      headers: {
        "Content-Type": "application/json",
        Authorization: "Bearer " + process.env.CRON_API_KEY,
      },
      body: JSON.stringify(payload),
    });
    if (!response.ok) {
      throw new Error("Network response was not ok");
    }
    const data = await response.json();
    return JSON.stringify(data);
  } catch (e) {

    console.log(e);
    throw Error(e);
  }
}

Note that we need to consider the user's timezone so that the cron job is fired at the right time.

The strToCronObj function looks like so :

function strToCronObj(str: string): CronObj {
  const [minute, hour, day, month, weekday] = str.split(" ");
  return {
    minute,
    hour,
    day,
    month,
    weekday,
  };
}

It converts the cron expression into CronObj which we have declared at the start of the file.

The same way you can write custom resolvers and add your logic, connect your data and connect to other APIs and tools through Grafbase.

Grafbase Insights

This section aims to highlight important nuances while working with Grafbase.

  1. Branch-Specific Credentials: Each branch in Grafbase possesses its own unique API key and URL. If you switch between branches, ensure that you update both the API key and URL accordingly. Failing to do so will result in an "Unauthorized Error". On the project dashboard, the "Connect" button displays your main branch's URL and key. To determine the key for alternative branches, simply navigate to the desired branch and initiate the Pathfinder. The headers will reveal the associated key.

  2. Cloudflare Glitches: Experiencing Cloudflare-related issues when operating Grafbase dev? A straightforward solution is to upgrade your Grafbase CLI to version 0.31.0 or later.

  3. Attribute Ordering Restrictions: As of the current date (15th August 2023), sorting is exclusively available based on the "createdAt" attribute of your models. However, rest assured, the dedicated Grafbase team is actively working to enhance this feature.

  4. Environmental Variable Constraints: Any environmental variable prefixed with GRAFBASE_ is not permissible.

  5. Environmental Variables After Deployment: When uploading your project, don't forget to refresh the environment variables in Grafbase's project settings. Note that the .env file is solely functional when launching a local Grafbase API server.

Memora's Blueprint

The comprehensive codebase for Memora, along with its intertwined Grafbase configurations, is readily available for your perusal. While this article has been curated to spotlight the most crucial and beneficial aspects to aid developers in their journey with Grafbase, a deeper dive into the complete code offers an expansive understanding of the project's intricacies.

Simplifying CRUD Operations with Grafbase in Flutter

During my project, I frequently relied on the P

athfinder to execute "readAll" and "deleteMany" queries and mutations. Seeking to streamline my workflow and expedite development, I devised a straightforward solution, at least until the Grafbase team introduces a more robust one. This Flutter application seamlessly enables "readAll", "deleteOne", and "deleteMany" operations on your Grafbase models.

It's worth noting that automatically generated Grafbase queries and mutations follow a specific pattern. For instance, the "readAll" query takes the form of modelNameCollection. I've leveraged this consistency to craft a dynamic query and mutation generator.

Let's take readAll as an example.

String generateReadAllQuery(String modelName, List<String> fields) {
  return '''
        query GetAll${modelName}s {
            ${modelName.toLowerCase()}Collection (
                first: 100, 
                orderBy: {
                    createdAt:DESC
                }
            ) {
                edges {
                    node {
                        ${fields.join("\n")}
                    }
                }
            }
        }
    ''';
}

This function takes in the modelName and then fields that you want to be returned from the readAll query. Note that , this doesn't work if you have nested fields.

Now, I need to use this query and send it to Grafbase. We can do this using the following function :

Future<Map<String, dynamic>> sendRequest(String query, String suffix,
    String modelName, Map<String, dynamic> variables,
    [Map<String, dynamic> headers = const {}]) async {
  final grafbaseKey = dotenv.env['GRAFBASE_KEY']!;
  final url = dotenv.env['GRAFBASE_URL']!;

  final response = await http.post(
    Uri.parse(url),
    headers: {
      "Content-Type": "application/json",
      "x-api-key": grafbaseKey,
      ...headers
      // Add any other headers if necessary
    },
    body: jsonEncode({
      "query": query,
      "variables": variables,
    }),
  );

  final responseBody = response.body;

  //handle your logic
}

This is similar to what we saw before, except this one takes a suffix, modelName , variable and optionally headers.

The suffix for readAll query would be "collection". Let's see how we can put these functions to use to make it more clear.

Future<List<Map<String, dynamic>>> handleReadAll(
    String modelName, List<String> fields) async {
  final query = generateReadAllQuery(modelName, fields);
  final res = await sendRequest(query, GrafbaseSuffix.collection, modelName, {});
  final data = res["${modelName.toLowerCase()}Collection"]["edges"];

  // loop through data and then get every node
  final List<Map<String, dynamic>> nodes = [];
  for (var i = 0; i < data.length; i++) {
    final node = data[i]["node"];
    nodes.add(node);
  }

  return nodes;
}

Note that we parse the query dynamically as well. Here GrafbaseSuffix.collection is just a const String which is equal to "collection".

class GrafbaseSuffix{
  static String collection = "Collection";
  static String delete= "Delete";
}

How can one utilize these functions without the constant manual adjustments to the modelName and fields? My solution involves an abstract class that every model must inherit. As we delve deeper, the rationale behind this approach will unfold more clearly.

abstract class GrafbaseModel {
  String get modelName; // To get the name of the model
  Map<String, Type>
      get fields; // Map containing field names and their corresponding Dart types
  List<String> selectedIds = [];


  Future<List<Map<String, dynamic>>> readAll(
   ) async {
       return handleReadAll(modelName, fields.keys.toList());
    }


  Future<List<dynamic>> deleteMany(
   ) async {
       return handleDeleteMany(modelName, selectedIds);
    }

}

In my project, I have a UserModel. I am going to write a UserModel class that extends GrafbaseModel for the same.

class UserModel extends GrafbaseModel {
  @override
  String get modelName => 'User'; 

  @override
  Map<String, Type> get fields => {
        'id': int,

        'email': String,
        'sub': String,
        'createdAt': DateTime,
        'updatedAt': DateTime,
      };
}

The last step is to include this model in the list at the bottom of the file

List<GrafbaseModel> models = [
  UserModel(),
  CapsuleModel(),
  //Other models
];

With this, I can use Flutter's DataTable to build rows and columns, handle state for which model is selected and which rows are selected(by using the selectedIds list).

The end result looks like this:)

Don't mind the red flashes 📸 . You're welcome to clone and utilize this project from the provided repository. Its design is readily adaptable to suit your needs.

A heartfelt thank you to Grafbase & Hashnode for organizing this hackathon. I'm grateful for the opportunity to contribute and work on this project 🌸

Did you find this article valuable?

Support Ash Vic by becoming a sponsor. Any amount is appreciated!