paint-brush
My Experience with Creating Frontend and Backend Applications for My Startupby@snezhkinv
152 reads

My Experience with Creating Frontend and Backend Applications for My Startup

by Vladislav SnezhkinApril 29th, 2024
Read on Terminal Reader
Read this story w/o Javascript
tldt arrow

Too Long; Didn't Read

In this article, I share my journey of developing both the backend and frontend of an application. I delve into the challenges encountered, detailing how I overcame technical roadblocks and refined my coding skills. Besides sharing my process and solutions, I aim to inspire readers. Developing an app from scratch isn't a smooth journey but don't let the fear of obstacles deter you from creating. Remember, every challenge is a chance to learn and grow.
featured image - My Experience with Creating Frontend and Backend Applications for My Startup
Vladislav Snezhkin HackerNoon profile picture

In the previous part (Building and Launching a Tech Startup Solo), I talked about how to identify user needs and translate them into product requirements. I also shared how I developed the design.


In this part, I want to talk about how I developed the backend and frontend applications.


Let me remind you that we are developing applications for a merchant that integrates into the administrative panel through an iframe. Our application must have access to products so that the merchant can set up marketing campaigns. The application should also automatically post to Instagram.


Frontend application development

When I have to develop a large application completely on my own, I start with the front-end application. There is a high chance that I might miss something during the design process, so I first design the user interface, check whether it is convenient to use, whether it is necessary to change the form and logic, and only then I start designing the backend. It often happens that what was designed in the layouts is not user-friendly, and the user simply does not want to use such a product.

The basic architecture of the project

For the web application, we will use the following stack: ReactJS + TypeScript + Mobx. I chose this stack because I am well-versed in it. I chose React because client-side rendering will be sufficient for us; it has a large community, and I know it well. I highly recommend using typed languages, as they significantly reduce the likelihood of errors, which will increase as the project grows. It is advisable to choose a stack in which you feel most comfortable.


Let's break down the project into layers. I see six layers here:

  • routing — implementation of our navigation.
  • models — centralised storage of Mobx.
  • api – network Request Manager
  • pages — pages of our application.
  • ui-kit — basic components.
  • components — application components.
  • lib — auxiliary classes and functions.


Routing

The router allows you to create nested paths, but it is important to remember that the child element should specify the entire path of the parents.


In the first element of the object, I pass the Layout component that implements the basic structure of the page. Inside this component, I use the Outlet from the react-router-dom package in order to pass the nested elements.

import React from "react";
import { createBrowserRouter, RouterProvider } from "react-router-dom";
import Dashboard from "@/pages/Dashboard";
import CreateCampaign from "@/pages/Campaign/CreateCampaign";
import EditCampaign from "@/pages/Campaign/EditCampaign";
import Layout from "@/components/Layout";

const router = createBrowserRouter([
	{
		path: "/",
		element: <Layout />,
		children: [
			{ path: "/", element: <Dashboard /> },
			{
				path: "/campaign",
				element: null,
				children: [
					{ path: "/campaign/create", element: <CreateCampaign /> },
					{ path: "/campaign/edit/:id", element: <EditCampaign /> },
				],
			},
		],
	},
]);

export default function Router() {
	return <RouterProvider router={router} />;
}

Storage

I created RootStore to initialize other models via factory create models. The factory allows the creation of models with the same required arguments.

import { createContext } from "react";
import Campaign from "./campaign";
import Dashboard from "./dashboard";
import Api from "@/api";

export interface ModelInterface {
	[key: string]: any;
}

interface ModelConstructor {
	new (context: RootStore): ModelInterface;
}

function createModel<T>(
	ctor: ModelConstructor,
	context: RootStore
): T {
	return new ctor(context) as T;
}

export class RootStore {
	api: Api;
	campaign: Campaign;
	dashboard: Dashboard;

	constructor(api: Api) {
		this.api = api;

		this.campaign = createModel<Campaign>(Campaign, this);
		this.dashboard = createModel<Dashboard>(Dashboard, this);
	}
}
const api = new Api({
	ecwidStore: { payload: "c2bh2nmjkkoa2" },
});
export const store = new RootStore(api);
export type StoreType = RootStore | Record<string, never>;
export const StoreContext = createContext<StoreType>({});

API

Based on the Axios package, we will create our own implementation where we will add the necessary headers and error handlers in the 401 server response case.

import axios, {AxiosInstance} from "axios";

import {endpointsInitFactory} from "./endpoints";

type InitialType = {
	ecwidStore: {
		payload: string
	}
}

class Api {
	endpoints
	axios: AxiosInstance
	constructor(initial: InitialType) {
		this.axios = axios.create({
			baseURL: process.env.REACT_APP_BASE_URL,
			headers: {
				"Content-Type": "application/json",
				"ecwid-payload": initial.ecwidStore.payload,
			},
		});

		this.endpoints = endpointsInitFactory(this.axios)
	}
}

export default Api;


Setting aliases

You may have noticed that I use alias names when importing modules. Let's set up an alias together.

In your tsconfig.json add a new path:

{
    "compilerOptions": {
        "paths": {
            "@/*": ["./src/*"]
        },
    },
}

After that install package npm install @craco/craco --save and change all scripts in package.json

  "scripts": {
    "start": "craco start",
    "build": "craco build",
    "test": "craco test",
    "eject": "craco eject"
  },

The final version of the architecture

You can check out the final version of our frontend ReactJS project on CodeSandbox below.


UI kit development

In the previous article, I decided not to design the interface because Ecwid has its own CSS framework.

They suggest including links to CSS and JS files in the HTML document and then using HTML. However, this approach is inconvenient for several reasons: firstly, the components are not adapted to React specifics, and secondly, we do not want to insert bulky code every time.

Let's port the components to the ReactJS stack using the example of complex and popular components.


Checkbox

This is how the HTML markup of a simple checkbox looks like. But what if we want to, for example, disable the checkbox or increase its size?

Example of ecwid componentsWe will have to replace «class» with «className», add changing the component state through «onChange», support the «disabled» and «size» states of the element.

Let's get started with the creation!

  1. Create the ui-kit directory in the src directory, where all the components are ported from the Ecwid CSS framework. Inside, we will create a base directory for storing basic components.
  2. Rename all class attributes to className. Also, we will add closing tags and type all Props.
  3. Let's describe our component.
import React, {useCallback} from 'react';
import cn from 'classnames'

type OuterProps = {
    checked: boolean
    onChange: (value: boolean) => void
    label: string
    size?: 'micro' | 'large'
    disabled?: boolean
}

export default function Checkbox({checked, onChange, label, size, disabled}: OuterProps) {
    const handleChange = useCallback(() => {
        onChange(!checked)
    }, [checked, onChange])


    const sizeClassName = size && `custom-checkbox--${size}`

    return (
        <div className={cn('custom-checkbox', disabled && 'custom-checkbox--disabled', sizeClassName)}>
            <label>
                <input type="checkbox" checked={checked} onChange={handleChange} tabIndex={0} disabled={disabled} className="custom-checkbox__input"/>
                <span className="custom-checkbox__label"></span>
                <span className="custom-checkbox__text">{label}</span>
            </label>
        </div>
    );
}


Now we will connect styles.

<head>
  <link rel="stylesheet" href="https://d35z3p2poghz10.cloudfront.net/ecwid-sdk/css/1.3.19/ecwid-app-ui.css"/>
</head>

<body>
  
  <div>Some content</div>

  <script type="text/javascript" src="https://d35z3p2poghz10.cloudfront.net/ecwid-sdk/css/1.3.19/ecwid-app-ui.min.js"></script>
</body>


Checkbox after connecting style


Now, we can import the component and reuse the checkbox logic.


Developing pages for a web application

In our layouts, we have four pages:

  • Dashboard – here, we can connect an Instagram account to
  • Create campaign – consists of 2 pages
    • Campaign type selection
    • Campaign creation form
  • Edit campaign – edit the campaign


Dashboard page

I will not stop at the HTML but rather analyze the integration of the Instagram account connection into which new posts will be published.

First, we'll connect the SDK to the end of our page. Let's put the appID in the env (REACT_APP_FACEBOOK_APP_ID) variable because we want to control dynamically switching between the production application and the test application. After connecting, the FB field will appear in the global window object.

<script>
    window.fbAsyncInit = function() {
        FB.init({
            appId      : '%REACT_APP_FACEBOOK_APP_ID%',
            cookie     : true,
            xfbml      : true,
            version    : 'v9.0'
        });

        FB.AppEvents.logPageView();

    };

    (function(d, s, id){
        var js, fjs = d.getElementsByTagName(s)[0];
        if (d.getElementById(id)) {return;}
        js = d.createElement(s); js.id = id;
        js.src = "https://connect.facebook.net/en_US/sdk.js";
        fjs.parentNode.insertBefore(js, fjs);
    }(document, 'script', 'facebook-jssdk'));
</script>


Let's create a Dashboard.jsx component that will consist of three parts:


  • an Instagram connection header;

  • a status block for our account. Since we are limited by the lifetime of the token, if the user changes the account password, for example, the old token will become invalid, and we will need to request a new one;

  • list of campaigns


In our useEffect, we will request connected accounts from the server. After that, the server will return the accounts, and if we receive the field fbNeedToUpdate == true, we will need to request the token again.


In the second useEffect, we will wait for the fbNeedToUpdate field to change in the store. If an update is required, we need to get the authorization status through the SDK and request the token again, and the whole process will go unnoticed by the user.


Important! Instagram account is connected via connection between the Instagram Business Page & Facebook Page, so you can see mentions in the code

// getting Facebook Page status from our server
useEffect(() => {
    getSavePages();
}, [])

// Updating facebook token via Facebook SDK
useEffect(() => {
    if (fbNeedToUpdate !== null && fbNeedToUpdate) {
        getFBLoginStatus();
    }
}, [fbNeedToUpdate]);

const getFBLoginStatus = () => {
    window.FB.getLoginStatus((response) => {
        console.log('Good to see you, ', response);
        const {status, authResponse} = response;
        setFbLoginStatus(status);
        if (status === 'connected') {
            const {accessToken, userID} = authResponse;
            // set facebook information to server
            setFacebookData(accessToken, userID);
            // get all connected Facebook pages
            getPages();
        }
    });
};


Now, let's look at the case when the user goes in for the first time and does not have any connected pages. Create an authorization function and hang it on the button for the onClick event. We extract the token and the userID and then save it on our server.

In the scope, we request basic account information, permission to publish posts, and permission to access users' Facebook pages.

const loginInst = () => {
    window.FB.login((response) => {
        if (response.status === 'connected') {
            const {accessToken, userID} = response.authResponse;
            setFacebookData(accessToken, userID);
            getPages();
        }
    }, {
        scope: 'instagram_basic, instagram_content_publish, pages_show_list, pages_read_engagement',
        auth_type: 'rerequest',
        return_scopes: true,
    });
};

Finish HTML

return (
    <div className="my-3">
        <ConnectSocialNetwork
            loading={loading}
            icon={<img src={instagramLogo} alt="logo facebook"/>}
            title={pages.length > 0 ? t('connected.title') : t('connect.title')}
            text={pages.length > 0 ? t('connected.text') : t('connect.text')}
            pages={pages}
            rightContainer={(
                <>
                    <Button
                        label={pages.length > 0 ? t('connected.btn') : t('connect.btn')}
                        onClick={onLogin}
                        loading={loading}
                    />
                    <Button
                        label={t('helpConnectBtn')}
                        color="link"
                        icon={<InfoIcon/>}
                        size="small"
                        className="ml-1"
                        onClick={getHelp}
                    />
                </>
            )}
        />
        {fbNeedToUpdate && (
            <Alert
                modal
                type="error"
                title={t('expired.title')}
                description={(
                    <Button label={t('expired.btn')} onClick={onLogin}/>
                )}
            />
        )}
    </div>
);


The logic of editing and creating a campaign

If we consider these two pages (editing and creating a campaign), they differ in that in one, the data is pre-filled, and in the other, it is filled in by the user. And in these two forms, we go to different endpoints to create and edit.


There are two ways here:

  1. Create a simple component that displays only the layout and contains only the general validation logic

  2. Create a Higher-Order Component that will modify the behavior


I chose the first version as it is easier to understand and debug.

Let's create a basic component with a layout and logic for extracting variables.


const CampaignForm = () => {
  const {
      campaignStore: {
          getProducts,
          // import all variables for our form
      },
      dashboardStore: {
          getSavePages
      }
  } = useStore();

	// getting store products
  useEffect(() => {
      getProducts();
  }, []);

  // getting instagram accounts
  useEffect(() => {
      getSavePages();
  }, []);

  const Errors = () => {
      if (typeof errors === 'string') {
          return errors;
      }

      return (
          <ul>
              {errors.map((error, i) => (
                  <li key={i}>{error}</li>
              ))}
          </ul>
      );
  }
  
  return (
	  <form>
		  <Errors/>
		  {/*fields*/}
	  </form>
  )
  
}


Let's create a campaign creation component.

  • at the very top, we will create a Navbar where there will be buttons to save and cancel the creation of a company
    • In the onSubmit function, we will call the saveRandomCampaign method to save a campaign that publishes a random product.
      • and after successful execution, we will call a redirect to the Dashboard page
  • It is important to lay a positive UI when the user clicks on the button; we start rendering the loader inside, and for this, we extract the sendingForm variable
import React from 'react';
import {observer} from "mobx-react-lite";
import {useHistory} from "react-router-dom";
import {useTranslation} from "react-i18next";


import Button from "../../components/Button/Button";
import Navbar from "../../components/Navbar/Navbar";
import CampaignForm from "./CampaignForm";
import Label from "../../components/Label/Label";
import {useStore} from "../../store";


const CreateRandomCampaign = () => {
    const history = useHistory();
    const {t} = useTranslation('campaigns');
    const {
        campaignStore: {
            saveRandomCampaign, sendingForm
        }
    } = useStore();

    const onSubmit = () => {
        saveRandomCampaign()
            .then(() => history.push('/'));
    };

    return (
        <div className="mt-2">
            <Navbar
                title={<>
                    <span className="mx-1">
                    {t('randomForm.createTitle')}
                    </span>
                    <Label label="Random"/>
                </>}
                actions={
                    <>
                        <Button label={t('form.save')} loading={sendingForm} onClick={onSubmit}/>
                        <span className="mr-2"/>
                        <Button label={t('form.cancel')} color="link" onClick={() => history.push('/')}/>
                    </>
                }
            />
            <CampaignForm/>
        </div>
    );
};
export default observer(CreateRandomCampaign);


Let's look at the difference between the campaign editing form. The logic practically remains the same.

import React, {useEffect} from 'react';
import {observer} from "mobx-react-lite";
import {useHistory, useParams} from "react-router-dom";
import {useTranslation} from "react-i18next";
import {toJS} from "mobx";

import Button from "../../components/Button/Button";
import Navbar from "../../components/Navbar/Navbar";
import CampaignForm from "./CampaignForm";
import Label from "../../components/Label/Label";
import {useStore} from "../../store";


const UpdateRandomCampaign = () => {
    const history = useHistory();
    let {id} = useParams();

    const {t} = useTranslation('campaigns');
    const {
        campaignStore: {
            getCampaign, updateRandomCampaign, sendingForm
        },
        dashboardStore: {
            activeCampaigns
        }
    } = useStore();

    useEffect(() => {
        getCampaign(id);
    }, []);

    const onSubmit = () => {
        updateRandomCampaign(id)
            .then(() => history.push('/'));
    };

    return (
        <div className="mt-2">
            <Navbar
                title={
                    <>
                        <span className="mx-1">
                            {t('randomForm.editTitle')}
                        </span>
                        <Label label="Random"/>
                    </>
                }
                actions={
                    <>
                        <Button label={t('form.update')} loading={sendingForm} onClick={onSubmit}/>
                        <span className="mr-2"/>
                        <Button label={t('form.cancel')} color="link" onClick={() => history.push('/')}/>
                    </>
                }
            />
            <CampaignForm/>
        </div>
    );
};
export default observer(UpdateRandomCampaign);



Post Editor

At the top, we have a text editor where the user can insert his constants, such as:

  • link to the product
  • product name
  • price

The second feature of this editor is the creation of a variety of text presets in order to diversify posts with different messages.

Let's describe our editor's mobx storage.


import {action, makeAutoObservable, makeObservable, toJS} from "mobx";

import randomInteger from '../utils/random';

class CampaignStore {
	// array of templates
    templates = [""];
    activeTemplate = 0;
    
    constructor({api}) {
        this.api = api;

        makeObservable(this, {
            addTemplate: action,
            removeTemplate: action,
            setActiveTemplate: action,
            changeTemplate: action,
        });
    }
    
    // add new template when user click add button
    addTemplate = () => {
		    // create a copy of templates
        const templates = this.templates.slice();
        templates.push('');
        this.templates = templates;
        // change active template in the form
        this.setActiveTemplate(this.templates.length - 1);
    };
    
    // remove template by index when user click trash icon button
    removeTemplate = (index) => {
        if (this.templates.length > 1) {
            const templates = this.templates.slice();
            templates.splice(index, 1);
            this.templates = templates;
            this.setActiveTemplate((index - 1) % templates.length);
        }
    };
    
    // set active template when user choose template
    setActiveTemplate = (index) => {
        this.activeTemplate = index;
    };
    
    
    // change content inside editor for choosable template
    changeTemplate = (value) => {
        let templates = this.templates.slice();
        templates[this.activeTemplate] = value;
        this.templates = templates;
    };
}


Let’s develop post-editor components

import React, {useCallback, useMemo, useRef, useState} from 'react';
import $ from 'jquery';
import PropTypes from 'prop-types';

import './styles/post-editor.scss';
import {ReactComponent as CloseIcon} from './assets/cancel.svg';
import {ReactComponent as ArrowIcon} from './assets/arrow.svg';
import Button from "../Button/Button";
import selectTemplates from "../../store/template/templates";
import {useTranslation} from "react-i18next";
import Skeleton from "react-loading-skeleton";

const PostEditor = (
    {
        disabled, changeTemplate, templates,
        addTemplate,
        removeTemplate,
        activeTemplate,
        setActiveTemplate, loading
    }
) => {
    const textAreaRef = useRef();
    const {t} = useTranslation('campaigns');
    
    // when user want to add constant we should determinate insert position
    const insertConstant = (constant) => {
		    // getting cursor index 
        const cursorPos = $(textAreaRef.current).prop('selectionStart');
        const value = templates[activeTemplate];
        const textBefore = value.substring(0, cursorPos);
        const textAfter = value.substring(cursorPos, value.length);
        changeTemplate(textBefore + constant + textAfter);
    };

		// showing the skeletons while content is loading
    if (loading) {
        return (
            <div className="fieldset">
                <div className="fieldset__title">{t('form.contentLabel')}</div>
                <div className="d-flex flex-wrap align-items-center">
                    <Skeleton width={32} height={32} className="mr-2 mb-1"/>
                    <Skeleton width={100} height={18}/>
                </div>
                <Skeleton width="100%" height={178}/>
            </div>
        );
    }

    return (
        <div className="fieldset">
            <div className="d-flex flex-wrap">
			          {/* render carousel btns with remove btn */}
                {templates.map((template, index) => (
                    <div key={template + index}>
                        <Button 
				                        label={index + 1} 
				                        disabled={index === activeTemplate} 
                                size="small"
                                color="default"
                                onClick={() => setActiveTemplate(index)}
                        />
                        <div onClick={() => removeTemplate(index)}>
                            <CloseIcon width={8} height={8}/>
                        </div>
                    </div>
                ))}
                {/* render add template btn */}
                <Button label={t('form.addContentTemplate')} icon size="small" color="link" onClick={addTemplate}/>
            </div>
            <div className="postEditorWrap">
		            {/* selecting a pre-filled template */}
		            <SelectBox
                    onChange={onChange}
                    label={t('form.selectTemplate')}
                    options={selectTemplates}
                />
                {/* selecting constants */}
                <SelectBox
                    onChange={insertConstant}
                    label={t('form.insertConstant')}
                    options={t('form.constants', {returnObjects: true})}
                />
                
                <textarea 
		                rows={8} 
		                className="postEditor" 
		                ref={textAreaRef} 
		                onChange={(e) => onChange(e.target.value)}
		                value={templates[activeTemplate]}
                />
            </div>
        </div>
    );
}

export default PostEditor;


I would like to explain two things:

  • Inserting constants from the SelectBox
  • Selecting a ready-made template from the SelectBox


Inserting constants from the SelectBox

I have ready-made constants that are consistent with the database on the server. I pass this JSON as options in SelectBox. Then, when the user selects the desired constant, we determine the position and insert the value from the value field into this position.

We can represent our form as a string array, where each character has its own position. In order to get positions, use the $(textAreaRef.current).prop('selectionStart')


Next, extract the value of the editor from the stack and divide the string into two parts relative to the cursor index in the editor. Then we call changeTemplate where we concatenate the beginning and end of the string with a constant.

"constants": [
      {
        "value": "{PRODUCT_LINK}",
        "label": "{PRODUCT_LINK} - A direct link to the product"
      },
      {
        "value": "{PRODUCT_TITLE}",
        "label": "{PRODUCT_TITLE} - The product title"
      },
      {
        "value": "{STORE_NAME}",
        "label": "{STORE_NAME} - The store name"
      },
      {
        "value": "{PRICE}",
        "label": "{PRICE} - The product price"
      },
      {
        "value": "{DISCOUNT_AMOUNT}",
        "label": "{DISCOUNT_AMOUNT} - The product discount amount"
      },
      {
        "value": "{DISCOUNT_CODE}",
        "label": "{DISCOUNT_CODE} - The product discount code"
      }
    ]


Selecting a ready-made template from the SelectBox

We will pass options with pre-filled templates to the selectbox. Where the label is the short description displayed in the selector, and the value is the value that we substitute.

When the user selects a template, we insert the value of the value field into the editor.

const templates = [
//    1
    {
        label: "😍 {PRODUCT_TITLE} 😍...", 
        value: `😍 {PRODUCT_TITLE} 😍
starting at {PRICE}

Shop Now 👉👉 {PRODUCT_LINK}`
    },

//    2
    {
        label: "💎 {PRODUCT_TITLE} 💎...", 
        value: `💎 {PRODUCT_TITLE} 💎 

Shop {VENDOR_NAME} Today 👉 {PRODUCT_LINK}`
    }
]


Backend application development

To develop the backend, I chose the NestJS framework with TypeScript typing, a PostgreSQL relational database.

The first thing I always do is find out the list of roles, the essence of the application, and the actions that users can do. I wrote about this in the first episode. Let's refresh our memory.


ER Diagram Design

This is an important step in creating a backend application, as everything revolves around data. All our logic services will be written in order to process the data, and when designing the diagram, we need to make sure that we have enough data and that we have set up the correct relationships between the tables.

I am always in favor of the order in the database, so let's turn to sqlstyle.guide and let's see how to name the tables (https://www.sqlstyle.guide/#tables):

  • Use a collective name or, less ideally, a plural form. For example (in order of preference) staff and employees.
  • Do not prefix with tbl or any other such descriptive prefix or Hungarian notation.
  • Never give a table the same name as one of its columns and vice versa.
  • Avoid, where possible, concatenating two table names together to create the name of a relationship table. Rather than cars_mechanics prefer services.


Table of stores

This is the key table around which other entities will be built. It is important not to miss every field.

Therefore, it is important for us to know the store ID, the store locale, access tokens through which the merchant is authorized in the application, the store name, currency, dates of creation, updating, and deletion of the store record in our database.

Since the application works through a subscription, the charge is carried out by Ecwid itself; it is important to know about the subscription start date and its completion + status.


Table of stores

***Table of facebook_pages ***

Instagram Facebook Facebook Pages I would like to analyze the table of connected Facebook pages (the Instagram account is connected via Facebook Pages)

It is important for us to know the token with which we can perform actions on behalf of the user. A photo of the account so that the user can quickly understand what kind of account he has chosen and expires – the expiration date of the token (the token may expire earlier, for example, when the user changes the password from the account)


Tables for campaign

The main table is campaigns, which stores the name of the campaign, the type of campaign (random publication of goods or publication of new products), the link to which page we publish, and the sign of an active and inactive campaign. And the link to the store to which the campaign belongs.

The templates table stores sets of texts for a post. It is quite primitive, there is content, and a connection with the company.


Let's look at how we store information about the selected categories and products that participate in the campaign's publication. The merchant can select entire categories that participate in the campaign. So, we must have a Many-to-Many relationship because many campaigns can refer to the same categories, just as many categories can relate to the same merchant campaigns.

Next, behind each category is the product that will be published. Again, the relationship should be Many to Many because for many products, it can relate to many categories, and many categories can relate to many products.


As a result, we get this table, with auxiliary tables for implementing the many-to-many relationship.


Let's see how such connections are implemented in NestJS.

  • For Many-to-Many connection, we create the categories field and specify CategoriesEntity[] as the source
    • Let's use the decorator @JoinTable() creates a junction table.
    • Let's use the decorator @ManyToMany(() => CategoriesEntity) where we specify CategoriesEntity as a link

ORM will create an additional table for us and take care of the logic of the cross-pointers.

import {
  Entity,
  Column,
  PrimaryGeneratedColumn,
  OneToMany,
  OneToOne,
  JoinColumn,
  ManyToOne,
  CreateDateColumn,
  UpdateDateColumn,
  ManyToMany,
  JoinTable,
} from 'typeorm';

import { TemplatesEntity } from './templates/templates.entity';
import { CategoriesEntity } from '../categories/categories.entity';
import { DiscountsEntity } from './discounts/discounts.entity';
import { DatesEntity } from './dates/dates.entity';
import { CampaignsHistoryEntity } from './campaigns-history/campaigns-history.entity';
import { StoresEntity } from '../stores/stores.entity';
import { FacebookPagesEntity } from '../facebook-pages/facebook-pages.entity';

@Entity('campaigns')
export class CampaignsEntity {
  @PrimaryGeneratedColumn()
  id: number;

  @Column('text')
  type: string;

  @Column('text')
  name: string;

  @Column('bool')
  active: boolean;

  @OneToMany(() => TemplatesEntity, (templates) => templates.campaign)
  templates: TemplatesEntity[];

  @ManyToMany(() => CategoriesEntity)
  @JoinTable()
  categories: CategoriesEntity[];

  @OneToMany(() => DatesEntity, (categories) => categories.campaign)
  dates: DatesEntity[];

  @OneToMany(
    () => CampaignsHistoryEntity,
    (campaignsHistory) => campaignsHistory.campaign,
  )
  campaignsHistory: CampaignsHistoryEntity[];

  @ManyToOne(() => StoresEntity, (store) => store.campaigns)
  store: StoresEntity;

  @OneToOne(() => DiscountsEntity)
  @JoinColumn()
  discount: DiscountsEntity;

  @ManyToOne(
    () => FacebookPagesEntity,
    (facebookPage) => facebookPage.campaigns,
  )
  facebookPage: FacebookPagesEntity;

  @CreateDateColumn({ type: 'timestamp' })
  createdAt: Date;

  @UpdateDateColumn({ type: 'timestamp' })
  updatedAt: Date;
}

campaigns.entity.ts


import {
  Entity,
  Column,
  PrimaryGeneratedColumn,
  CreateDateColumn,
  UpdateDateColumn,
  ManyToOne,
  JoinColumn,
  ManyToMany,
  JoinTable,
  OneToMany,
} from 'typeorm';
import { StoresEntity } from '../stores/stores.entity';
import { ProductsEntity } from '../products/products.entity';

@Entity('categories')
export class CategoriesEntity {
  @PrimaryGeneratedColumn()
  id: number;

  @Column('bigint')
  ecwidCategoryId: number;

  @Column('text', { nullable: true })
  thumbnailUrl: string;

  @Column('text')
  name: string;

  @Column('boolean')
  enabled: boolean;

  @Column('integer', { nullable: true })
  productCount: number;

  @ManyToOne(() => StoresEntity)
  @JoinColumn()
  store: StoresEntity;

  @ManyToMany(() => ProductsEntity, (products) => products.categories)
  products: ProductsEntity[];

  @ManyToOne((type) => CategoriesEntity, (category) => category.children)
  parent: CategoriesEntity;

  @OneToMany((type) => CategoriesEntity, (category) => category.parent)
  children: CategoriesEntity[];

  @CreateDateColumn({ type: 'timestamp' })
  createdAt: Date;

  @UpdateDateColumn({ type: 'timestamp' })
  updatedAt: Date;
}

сategories.entity.ts


import {
  Entity,
  Column,
  PrimaryGeneratedColumn,
  CreateDateColumn,
  UpdateDateColumn,
  ManyToMany,
  JoinTable,
  ManyToOne,
  JoinColumn,
} from 'typeorm';

import { CategoriesEntity } from '../categories/categories.entity';
import { StoresEntity } from '../stores/stores.entity';

@Entity('products')
export class ProductsEntity {
  @PrimaryGeneratedColumn()
  id: number;

  @Column('bigint')
  productEcwidId: number;

  @Column('text')
  price: string;

  @Column('text')
  name: string;

  @Column('text')
  url: string;

  @Column('text', { nullable: true })
  hdThumbnailUrl: string;

  @Column('text', { nullable: true })
  originalImageUrl: string;

  @Column('boolean')
  enabled: boolean;

  @ManyToMany(() => CategoriesEntity, (category) => category.products)
  @JoinTable()
  categories: CategoriesEntity[];

  @ManyToOne(() => StoresEntity)
  @JoinColumn()
  store: StoresEntity;

  @CreateDateColumn({ type: 'timestamp' })
  createdAt: Date;

  @UpdateDateColumn({ type: 'timestamp' })
  updatedAt: Date;
}

products.entity.ts


Finally, we get this ER diagram of our database.

ER Diagram

Publishing campaigns

In order to implement the publication of campaigns, we need to implement a cron job that will be called every hour to check if there are any campaigns for publication. To synchronize the backend with the application, in the merchant's admin panel, I allowed the selection of only the hour without specifying minutes, i.e., the user can only choose 24 hours out of the day.

Сreate a new module called campaigns.

nest generate module campaigns


And create a file campaigns.service.ts, inside let's create a method async handleCron() {} import import { Cron } from '@nestjs/schedule'; and initialize a decorator above the method, which will call the handleCron method every hour at the 50th minute.


The call at the 50th minute, and not exactly at the zero minute, is necessary in order to have time to prepare images for posts and upload them over the network to Facebook servers, and then publish them all at once. Let's consider what optimization options might exist:

  1. Create a separate service that generates images for posts.

    1. The advantage of this approach is that the service can be scaled and divided into chunks of campaigns. For example, you need to prepare 100 photos in total, server 1 gets the first 50, server 2 gets the last 50. 2.
  2. Also, you can start the generation process beforehand, for example, an hour before the upcoming publication. And upload them to the Facebook server immediately and at the moment of launching the campaign, only make a post-publication request, which is much faster than a request for media content upload.


You can get a detailed understanding of how Task Scheduling works in NestJS.

@Cron('0 55 */1 * * *')
async handleCron() {
  this.logger.debug('Called every 50 minutes');
}

A little explanation of my entry.

* * * * * *
| | | | | |
| | | | | day of week (skip it)
| | | | months (skip it)
| | | day of month (skip it)
| | hours (I set the step each hour)
| minutes (I set the call at 55 minutes)
seconds (skip it)


Image generation for the post

Let's consider in which cases I need to generate a photo.


Product has photo?

Campaign has discount?

Do we need photo generation?

Variant 1

Yes

Variant 2

Yes

Variant 3

Yes

Variant 4

No

Therefore, photo generation is necessary only in cases when a campaign has been created that generates a promo code for the product and the product does not have a photo. So when the product does not have a photo, we need to create a picture for the post and overlay the name of the store and the name of the product.

To do this, set the canvas dependency.

import { createCanvas, loadImage } from 'canvas';
import * as fs from 'fs';
import { v4 as uuidv4 } from 'uuid';

export class ProductPhoto {
  private canvas = null;
  private ctx = null;
  private readonly MAX_WIDTH = 700;
  private readonly MAX_HEIGHT = 875;

  constructor(
    private photoSrc,
    private title,
    private promocode,
    private descriprion,
    private storeName,
    private productName,
  ) {
  }

  async generatePhoto() {
    const photoImg = await loadImage(
      this.photoSrc || './public/assets/empty-photo.jpg',
    );

    const {
      width,
      height,
      canvasWidth,
      canvasHeight,
    } = ProductPhoto.getScalableSize(photoImg.width, photoImg.height);
    
    const { x, y } = ProductPhoto.centerImageInCanvas(
      width,
      height,
      canvasWidth,
      canvasHeight,
    );

    this.createCanvas(canvasWidth, canvasHeight);
    this.ctx.drawImage(photoImg, x, y, width, height);
    this.ctx.quality = 'best';
    this.ctx.patternQuality = 'best';
    this.ctx.textDrawingMode = 'path';

    if (!this.photoSrc) {
      await this.createEmptyPhoto();
    }

    this.ctx.textAlign = 'left';
    if (this.descriprion || this.promocode) {
      await this.drawCoupon();
      await this.drawDescription();
    }

    const dir = 'image-post';
    const dirPublic = `./public/${dir}`;
    const file = `/${uuidv4()}.jpeg`;

    if (!fs.existsSync(dirPublic)) {
      await fs.mkdirSync(dirPublic, { recursive: true });
    }
    const out = fs.createWriteStream(dirPublic + file);
    console.log('creating');
    const stream = await this.canvas.createJPEGStream({ quality: 1 });
    await stream.pipe(out);
    await out.on('finish', () => console.log('The JPEG file was created.'));
    console.log('end');
    return {
      url: process.env.DOMAIN + dir + file,
      path: dirPublic + file,
    };
  }
}

Let's consider the method of fixing the desired screen size for an image in order to use the static method of ProductPhoto.getScalableSize. The method determines the aspect ratio, the ratio should be more than 0.9 and less than 1.9.


  • If the value is less than 0.9, then we create a canvas measuring 900px x 1000px and fit the picture of the product into these dimensions.
    • The example map contains dimensions width = 903px, height = 4372px. the height, made equal to = 1000px of maximum height, is usually measured horizontally.
  • If the aspect ratio is greater than 1.9, then we do the opposite, set the canvas width to 1080px and the height to 568px
  • If the corresponding level is within 0.9 ≤ x ≤ 1.9, then we need to ensure that the map is no more than the maximum width and height. To do this, select the maximum and minimum sizes. If the minimum size is MIN_WIDTH and the maximum size is MIN_WIDTH, then the set ratio is = MIN_WIDTH / minSize;
    • Precisely because maxSize > MAX_WIDTH, then the ratio is = MAX_WIDTH / maxSize;


private static getScalableSize(width, height) {
  const MIN_RATIO = 0.9,
    MAX_RATIO = 1.9;
  const MIN_WIDTH = 600,
    MAX_WIDTH = 1080;

  let canvasWidth: number, canvasHeight: number;

  const ratio = width / height;
  // Example: 903 / 4372 = 0.2
  if (ratio < MIN_RATIO) {
    canvasWidth = 900;
    canvasHeight = 1000;

    width = (canvasHeight * width) / height;
    height = canvasHeight;
  } else if (ratio > MAX_RATIO) {
    // Example: 1080 / 437 = 2.47
    canvasWidth = 1080;
    canvasHeight = 568;

    height = (canvasWidth * height) / width;
    width = canvasWidth;
  } else {
    const maxSize = Math.max(width, height);
    const minSize = Math.max(width, height);
    let ratio = 1;
    if (minSize < MIN_WIDTH || maxSize < MIN_WIDTH) {
      ratio = MIN_WIDTH / minSize;
    } else if (maxSize > MAX_WIDTH) {
      ratio = MAX_WIDTH / maxSize;
    }
    width *= ratio;
    height *= ratio;
    canvasWidth = width;
    canvasHeight = height;
  }

  return {
    width,
    height,
    canvasWidth,
    canvasHeight,
  };
}


I also want to show you how to draw multiline text. To do this, create a private method.

Where text is our text to be drawn, x,y are the coordinates of the text location, lineHeight is the height of one line, fitWidth is the maximum width of the text.

private drawMultilineText(text, x, y, lineHeight, fitWidth) {
    fitWidth = fitWidth || 0;

    if (fitWidth <= 0) {
      this.ctx.fillText(text, x, y);
      return;
    }
    let words = text.split(' ');
    let currentLine = 0;
    let idx = 1;
    while (words.length > 0 && idx <= words.length) {
      const str = words.slice(0, idx).join(' ');
      const w = this.ctx.measureText(str).width;
      if (w > fitWidth) {
        if (idx == 1) {
          idx = 2;
        }
        this.ctx.fillText(
          words.slice(0, idx - 1).join(' '),
          x,
          y + lineHeight * currentLine,
        );
        currentLine++;
        words = words.splice(idx - 1);
        idx = 1;
      } else {
        idx++;
      }
    }
    if (idx > 0)
      this.ctx.fillText(words.join(' '), x, y + lineHeight * currentLine);
  }


Publish a post on Instagram

The last stage is to publish our post, we have a cron task that collects campaigns for publication, we have a photo generator of the post, it remains only to publish.

Create service facebook-api.service.ts, which will implement all the necessary API to work with Instagram.

  • In the createPhotoPost method, using the query /v10.0/${pageId}/media, we create a post where we specify the image and text of the post
  • Using the request /v10.0/${pageId}/media_publish we publish this post
import { HttpException, HttpService, Inject, Injectable } from '@nestjs/common';
import { catchError } from 'rxjs/operators';
import * as FormData from 'form-data';
import { Logger as LoggerW } from 'winston';

@Injectable()
export class FacebookApiService {
  constructor(
    @Inject('winston')
    private readonly loggerW: LoggerW,
    private httpService: HttpService,
  ) {
  }

  public async createPhotoPost(pageId, message, photoUrl, token) {
    const response = await this.httpService
      .post(
        `https://graph.facebook.com/v10.0/${pageId}/media`,
        {},
        {
          params: {
            access_token: token,
            caption: message,
            image_url: photoUrl,
          },
        },
      )
      .pipe(
        catchError((e) => {
          console.log(e);
          this.loggerW.error(e.response.data);
          throw new HttpException(e.response.data, e.response.status);
        }),
      )
      .toPromise();

    const postRes = await this.httpService
      .post(
        `https://graph.facebook.com/v10.0/${pageId}/media_publish`,
        {},
        {
          params: {
            access_token: token,
            creation_id: response.data?.id,
          },
        },
      )
      .pipe(
        catchError((e) => {
          console.log(e);
          this.loggerW.error(e.response.data);
          throw new HttpException(e.response.data, e.response.status);
        }),
      )
      .toPromise();

    console.log(postRes.data);

    return postRes.data;
  }
}


Summary

Using the example of my application, we looked at how I created the front & back applications, which stages they consisted of, and developed together the most complex modules.

As you can see from the example, there is nothing complicated in this, the main thing is to develop a plan, understand what the user wants and move in this direction. If you have any questions, ask them in the comments to this post, and I will be happy to answer!