Мой опыт создания frontend и backend приложений для моего стартапа

от автора

В прошлой части я рассказывал как появилась идея стартапа, как найти потребности пользователей, как спроектировать продуктовые требования. Также я рассказал как сделал проектирование и разработку дизайна. Напомню что я разрабатываю приложение для 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.

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

Проектирование 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 приложения из каких этапов оно состояло, разработали вместе самые сложные модули.

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


ссылка на оригинал статьи https://habr.com/ru/articles/819489/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *