В прошлой части я рассказывал как появилась идея стартапа, как найти потребности пользователей, как спроектировать продуктовые требования. Также я рассказал как сделал проектирование и разработку дизайна. Напомню что я разрабатываю приложение для sass платформы ecwid, платформа позволяет создать интернет-магазин в один клик. Я создаю приложение которое расширяет функционал платформы ecwid и приложение работает за месячную подписку ($11). Приложение делает публикации на страницу Instagram магазина.

В этой части я хочу рассказать как проектировал backend & frontend приложения.

Напомню что мы разрабатываем приложения для мерчанта, которое интегрируется в административную панель через iframe. Наше приложение должно иметь доступ к товарам, для того чтобы мерчант мог настроить маркетинговые кампании. Также приложение должно автоматически совершать публикации в Instagram.

Разработка frontend приложения

Когда мне предстоит разработать большое приложение полностью самому, то я начинаю с frontend приложения. Потому что высока вероятность что в ходе проектирования я мог что-то упустить, поэтому сначала я проектирую пользовательский интерфейс смотрю удобно ли им пользоваться, требуется ли поменять форму и логику и уже после этого начинаю проектирование бэкенда. Часто бывает то что было разработано в макетах не user friendly и пользователь просто не захочет пользоваться таким продуктом.

Базовая архитектура проекта

Для web приложения будем использовать стэк: ReactJS +TypeScript + Mobx. Я выбрал такой стэк поскольку хорошо знаю его. Выбрал React поскольку нам будет достаточно клиентского рендеринга у него большое комьюнити и я хорошо знаю его. И очень рекомендую использовать типизированные языки и это сильно спасает от вероятности совершить ошибку и по мере роста проекта вероятность ошибиться будет увеличиваться. Тут рекомендую выбирать тот стэк на котором вы чувствуете себя максимально комфортно. Давайте разобьём проект на слои, я здесь вижу 6 слоёв:

  • routing – реализация нашей навигации

  • models – централизованное хранилище mobx

  • pages – страницы нашего приложения

  • ui-kit – базовые компоненты

  • components – компоненты приложения

  • lib – вспомогательные классы и функции

Routing

Маршрутизатор позволяет создавать вложенные пути, но важно помнить, что дочерний элемент должен указывать полный путь к родительскому.

В первом элементе объекта я передаю компонент Layout, который реализует базовую структуру страницы. Внутри этого компонента я использую Outlet из пакета react-router-dom для передачи вложенных элементов.

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

Я создал RootStore для инициализации других моделей с помощью factory create models. Фабрика позволяет создавать модели с теми же требуемыми аргументами.

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

На основе пакета Axios мы создадим нашу собственную реализацию, в которую добавим необходимые заголовки и обработчики ошибок в случае ответа сервера 401.

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;

Настройка алиасов

Возможно, вы заметили, что я использую псевдонимы при импорте модулей. Давайте создадим псевдоним вместе.

В вашем tsconfig.json добавьте новый путь:

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

После этого установите пакет npm install @craco/craco --save и измените все скрипты в package.json

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

Окончательная версия архитектуры

Ниже вы можете ознакомиться с окончательной версией нашего интерфейсного проекта ReactJS на CodeSandbox.

Разработка ui-kit

В прошлой серии статей я не стал разрабатывать дизайн, поскольку у Ecwid есть свой css framework https://developers.ecwid.com/ecwid-css-framework/

Они предлагают в html документе прописать ссылки на css & js файлы. И дальше использовать html вёртску, но это неудобно, во-первых компоненты не адаптированы под React специфику, во-вторых не хочется каждый раз вставлять громоздкий код.

Давайте портируем компоненты на ReactJS стэк на примере сложных и популярных компонентов.

Checkbox

Так выглядит html разметка простого чекбокса, а что делать если мы например захотим сделать disabled состояние или увеличить размер?

Нам придётся добавить классы на disabled и на размер элемента

Приступим к созданию!

  1. Создадим в src директорию ui-kit, тут будут лежать все компоненты портированные из Ecwid CSS framework. Создадим директорию base – тут будут лежать базовые компоненты. Опишем наш компонент

  2. Переименуем все class на className и добавим закрывающиеся теги и типизируем все Props

Теперь подключим стили
Теперь подключим стили
<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>

Теперь мы можем импортировать компонент и переиспользовать логику чекбокса.

Разработка страниц для frontend приложения

В наших макетах у нас есть 4 страницы:

  • Dashboard – тут можем подключить Instagram аккаунт куда

  • Create campaign – состоит из 2 страниц

    • Выбор типа кампании

    • Форма создания кампании

  • Edit campaign – редактирование кампании

Dashboard page

Не буду останавливаться на вёрстке, а лучше разберу интеграцию подключения Instagram account в которые будет происходить публикация новых постов.

Для начала подключим SDK в самый конец нашей страницы. Вынесем appId в env (REACT_APP_FACEBOOK_APP_ID) переменную поскольку мы захотим управлять динамически переключаясь между продовым приложением и тестовым. После подключения в глобальном объекте window появится поле FB

<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>

Создадим компонент Dashboard.jsx, который будет состоять из 3 частей, шапка с подключением Instagram, ниже блок статуса нашего аккаунта, т.к. мы ограничены временем жизни токена, и если пользователь например поменяет пароль от аккаунта, то старый токен будет невалидным и нам нужно будет перепросить токен.

ВuseEffect у нашего сервера запросим подключенные аккаунты, после сервер нам вернёт аккаунты и если мы получим поле fbNeedToUpdate == true , то необходимо будет перезапросить токен.

Во втором useEffect будем дожидаться изменения поля fbNeedToUpdate в store. Если потребуется обновление то нужно получить статус авторизации через SDK и повторно запросить токен и для пользователя пройдёт всё незаметно.

Важно! Подключение Instagram аккаунта происходит через связь бизнес страницы Instagram & Facebook Page, поэтому в коде можно видеть упоминания

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

// Updating facebook token
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;
            setFacebookData(accessToken, userID);
            getPages();
        }
    });
};

Давайте теперь рассмотрим кейс, когда пользователь заходит впервые и у него нет подключённых страниц, создадим функцию авторизации и повесим его на button на событие onClick. Извлекаем токен и uderID и дальше сохраняем на нашем сервере.

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,
    });
};

И давайте закончим с вёрсткой.

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>
);

Логика редактирования и создания кампании

Если рассмотреть 2 эти страницы, то они отличаются тем, что в одной данные предзаполнены, на другой заполняются пользователем. И в двух этих формах мы ходим в разные эндпоинты для создания и редактирования.

Тут можно пойти 2 путями:

  1. Создать глупый компонент, который отображает только вёрстку и содержит только общую логику валидации

  2. Создать Higher-Order Component, который будет модифицировать поведение

Я выбрал первый вариант исполнения, он проще для понимания и отладки.

Создадим базовый компонент с вёрсткой и логикой извлечения переменных.

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

	// getting store product
  useEffect(() => {
      getProduct();
  }, []);

  // getting instagram pages
  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>
  )
  
}

Создадим компонент создания кампании.

  • в самом верху создадим Navbar, где будут кнопки сохранения и отмены создания компании

  • В функции onSubmit вызовем метод saveRandomCampaign для сохранения кампании, которая публикует случайный товар

    • и после успешного выполнения вызовем редирект на Dashboard page

  • Важно заложить позитивный UI когда пользователь кликает по кнопке мы запускаем отрисовку лоадера внутри, для этого извлечём переменную sendingForm

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);

Рассмотрим отличие формы редактирования кампании. Логика практически остаётся прежней

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);
Финальный результат
Финальный результат

Редактор поста

В самом верху у нас есть текстовый редактор в который пользователь может вставлять свои константы, такие как:

  • ссылка на товар

  • название товара

  • цена

Вторая фича этого редактора создание множества пресетов текста, для того чтобы разнообразить посты разными сообщениями.

Давайте опишем mobx хранилище нашего редактора

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;
    };
}

Опишем компонент редактора.

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;

Я бы хотел объяснить 2 вещи:

  • Вставка констант из SelectBox

  • Выбор готового шаблона из SelectBox

Вставка констант из SelectBox

У меня есть готовые константы, которые согласованы с базой данных на сервере. Эту JSON я передаю как options in SelectBox. Дальше когда пользователь выбирает нужную константу, мы определяем позицию и вставляет в эту позицию значение из поля value.

мы можем представить нашу форму как string массив, где у каждого символа есть свой символ. Для того чтобы получить позиции, воспользуемся функционалом $(textAreaRef.current).prop('selectionStart')

Далее извлечём значение редактора из стора и поделим строку на 2 части относительно индекса курсора в редакторе. Затем вызовем changeTemplate где конкатенируем начало строки с константой и концом строки.

"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"
      }
    ]

Выбор готового шаблона из SelectBox

В селектбокс передадим options с предзаполненными шаблонами. Где label – это крактое описание отображаемое в селекте, а value значение которое подставим.

Когда пользователь выбирает шаблон, мы подставляем значение поля value в редактор.

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 приложения

Для разработки backend я выбрал NestJS фреймворк с типизацией на TypeScript, реляционную базу данных PostgreSQL.

Всегда первым делом я узнаю список ролей, сущности приложения и действия которые могут делать пользователи. Об этом я писал в первой серии. Давайте освежим память:

Мерчант

Покупатель

Подключение несколько Instagram аккаунтов к магазину

Просмотр поста в Instagram аккаунте магазина

Создание/редактирование/удаление маркетинговой кампании.

Выбор категорий из которых будут извлекаться товары участвующие в кампании

Шаблонизация текстового описания поста. Пользователь может вставлять переменные в текст, например название магазина, название товара, ссылка на товар

Возможность создавать несколько пресетов текстовых описаний поста

Рандомный выбор текстового описания поста из пресетов

Превью поста

Генерация промокодов с временем окончания

Возможность наложения промокода на фотографию в посте

Установка частоты публикации кампании

Установка часового пояса

Проектирование ER Diagram

Это важный этап в создании backend приложения, поскольку всё вращается вокруг данных. Вся наша логика сервисы будут написаны для того чтобы обработать данные и при проектировании диаграммы мы должны убедиться что нам хватает данных и мы настроили корректные связи между таблицами.

Я всегда за порядок в БД, поэтому давайте обратимся к sqlstyle.guide и посмотрим как именовать таблицы (https://www.sqlstyle.guide/#tables):

  • Используйте собирательные имена или, что менее предпочтительно, форму множественного числа. Например, staff и employees (в порядке убывания предпочтения).

  • Не используйте описательные префиксы вида tbl_ и венгерскую нотацию в целом.

  • Не допускайте совпадений названия таблицы с названием любого из её столбцов.

  • По возможности избегайте объединения названий двух таблиц для построения таблицы отношений. Например, вместо названия cars_mechanics лучше подойдёт services.

Таблица stores

Это ключевая таблица вокруг которой будут строиться другие сущности. Важно не упустить каждое поле.

Итоговая таблица stores
Итоговая таблица stores

Поэтому нам важно знать: id магазина, локаль магазина, токены доступа через них происходит авторизация мерчанта в приложении, название магазина, валюта, даты создания, обновления и удаления записи об магазине у нас в базе.

Поскольку приложение работает через подписку, списание осуществляет сам Ecwid, то важно знать об дате начала подписки и её завершения + статус.

Таблица facebook_pages

Хочется разобрать таблицу подключенных facebook pages (подключение Instagram аккаунта осуществляется через Facebook Pages)

Нам важно знать token с помощью него мы сможем совершать действия от имени пользователя. фотография аккаунта, для того чтобы пользователь мог быстро понять что за аккаунт он выбрал и expires – дата истечения токена (токен может просрочиться и раньше, например когда пользователь поменяет пароль от аккаунта)

Таблицы для campaign

Основная таблица – это campaigns в которой хранится имя кампании, тип кампании (рандомная публикация товаров или публикация новинок), связь на какую страницу делаем публикацию, и признак активной и неактивной кампании. А также связь какому магазину принадлежит кампания.

Таблица templates – хранятся наборы текстов для поста. Достаточно примитивна, есть контент, и связь с кампанией.

Рассмотрим как мы храним информацию о выбранных категориях и продуктов, которые участвуют в публикации кампании. Мерчант может выбрать целые категории, которые участвуют в кампании. Поэтому у нас должна быть связь Many-to-Many, т.к. разные кампании могут ссылаться на одни и те же категории, как и разные категории могут относится к разным кампаниям мерчанта.

Дальше за каждой категорией стоит продукт, который будет публиковаться. Опять же связь должна быть Many to Many, т.к. за множество продуктов может относиться к множеству категорий и множество категорий может относиться к множеству продуктов.

В итоге получаем вот такую таблицу, с вспомогательными таблицами для реализации связи many to many.

Разберём как реализуются такие связи в NestJS.

  • Many-to-Many мы создаём поле categories и указываем в качестве источника CategoriesEntity[]

    • Воспользуемся декоратором @JoinTable() creates a junction table.

    • Воспользуемся декоратором @ManyToMany(() => CategoriesEntity) где в качестве связи укажем CategoriesEntity

ORM за нас создаст дополнительную таблицу и позаботится о логике перекрёстных указателей.

campaigns.entity.ts

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;
}

categories.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;
}

Получаем вот такую схему нашей базы данных.

Публикация кампаний

Для того чтобы реализовать публикацию кампаний, нам нужно реализовать крону, которая будет вызываться каждый час и сверять есть ли кампании для публикации. Для того чтобы синхронизировать бэкенд с приложением, в админке мерчанта я разрешил выбор только часа без указания минут, т.е. пользователь может выбрать только 24 часа из суток.

Давайте создадим новый модуль campaigns

nest generate module campaigns

И создадим файл campaigns.service.ts, внутри создадим метод async handleCron() {} импортируем import { Cron } from '@nestjs/schedule'; и инициализируем над методом декоратор, который будет вызывать метод handleCron каждый час на 50 минуте.

Вызов на 50 минуте, а не ровно на нулевой минуте нужен для того, чтобы успеть подготовить картинки для постов и загрузить их по сети на сервера facebook и затем одним скоупом опубликовать. Давайте рассмотрим какие варианты оптимизации могут существовать:

  1. Сделать отдельный сервис, который генерирует картинки для постов.

    1. Плюс такого подхода в том, что сервис можно масштабировать и делить на чанки кампании. Например всего нужно подготовить 100 фотографий, server 1 получает на вход первые 50, server 2 получается на вход последние 50.

  2. Можно например запускать процесс генерации заранее, например за час до предстоящей публикации. И сразу же загружать на сервер facebook и в момент запуска кампании осуществлять только запрос публикации поста, такой запрос в разы быстрее запроса для загрузки медиа-контента.

Можно в деталях ознакомиться как работает Task Scheduling в NestJS

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

Небольшое объяснение моей записи

* * * * * *
| | | | | |
| | | | | 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)

Генерация картинки для поста

Рассмотрим в каких случаях мне нужно генерировать фото.

Фото товара

У кампании есть скидка

Нужна генерация фото

Variant 1

Yes

Variant 2

Yes

Variant 3

Yes

Variant 4

No

Следовательно генерация фото необходима только в тех случаях когда создана кампания генерирующая промокод для товара и у товара нет фотографии. Следовательно когда у товара нет фото, нам необходимо создать картинку для поста и наложить название магазина и имя товара.

Для этого поставим зависимость canvas

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,
    };
  }
}

Рассмотрим метод формирования нужного соотношения сторон для картинки для этого воспользуемся статичным методом ProductPhoto.getScalableSize. Метод определяет соотношение сторон, соотношение не должно быть меньше 0.9 и не больше 1.9.

  • Если значение меньше 0.9, то создаём холст размером 900 на 1000 и вписываем картинку товара в эти размеры.

    • Например картинка имеет размеры width = 903 height = 4372. height делаем равной = 1000 максимальной высоте холста, а ширину уменьшаем пропорционально высоте.

  • Если соотношение сторон больше 1.9, то делаем всё наоборот ширину холста устанавливаем в 1080, а высоту в 568

  • Если соотношение сторон находится в пределах 0.9 ≤ x ≤ 1.9, то тогда нам нужно убедиться что картинка не больше максимальной ширины и высоты. Для этого определяем maxSize и minSize. Если minSize меньше MIN_WIDTH и maxSize меньше MIN_WIDTH, тогда установим ratio = MIN_WIDTH / minSize;

    • Иначе если maxSize > MAX_WIDTH, тогда ratio = 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,
  };
}

А также хочу раскрыть как рисовать multiline text. Для этого создадим приватный метод.

Где text - наш рисуемый текст, x,y - координанты расположения текста, lineHeight - высота одной строчки, fitWidth - макс. ширина текста

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);
  }

Публикация поста в Instagram

Последний этап - это опубликовать наш пост, у нас есть крона которая собирает кампании для публикации, у нас есть генератор фотографии поста, осталось только опубликовать.

Создадим сервис facebook-api.service.ts, который будет реализовывать всё необходимое API для работы с Instagram.

  • В методе createPhotoPost через запрос /v10.0/${pageId}/media создаём пост, где указываем картинку и текст поста

  • Через запрос /v10.0/${pageId}/media_publish публикуем этот пост

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;
  }
}

Итого

Мы рассмотрели на примере моего приложения как я создал front & back приложения из каких этапов оно состояло, разработали вместе самые сложные модули.

Как видно из примера в этом нет ничего сложного главное выработать план, понимать что хочет пользователь и двигаться в этом направлении. Если у вас возникнут вопросы, задавайте их в комментариях к этому посту я с удовольствием отвечу!

Комментарии (3)


  1. Misha163
    05.06.2024 07:36

    Круто, а где почитать первую часть?


    1. dalv_happy Автор
      05.06.2024 07:36

      Жду модерации от хабра, если комфортно можешь почитать мою статью на английском hackernoon: "Building and Launching a Tech Startup Solo: My Story of Turning an Idea Into a Successful Product"


    1. dalv_happy Автор
      05.06.2024 07:36

      Вот только что опубликовали первую часть: https://habr.com/ru/articles/819701/