В прошлой части я рассказывал как появилась идея стартапа, как найти потребности пользователей, как спроектировать продуктовые требования. Также я рассказал как сделал проектирование и разработку дизайна. Напомню что я разрабатываю приложение для 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 и на размер элемента
Приступим к созданию!
-
Создадим в src директорию ui-kit, тут будут лежать все компоненты портированные из Ecwid CSS framework. Создадим директорию base – тут будут лежать базовые компоненты. Опишем наш компонент
-
Переименуем все 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 путями:
-
Создать глупый компонент, который отображает только вёрстку и содержит только общую логику валидации
-
Создать 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
Это ключевая таблица вокруг которой будут строиться другие сущности. Важно не упустить каждое поле.
Поэтому нам важно знать: 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 и затем одним скоупом опубликовать. Давайте рассмотрим какие варианты оптимизации могут существовать:
-
Сделать отдельный сервис, который генерирует картинки для постов.
-
Плюс такого подхода в том, что сервис можно масштабировать и делить на чанки кампании. Например всего нужно подготовить 100 фотографий, server 1 получает на вход первые 50, server 2 получается на вход последние 50.
-
-
Можно например запускать процесс генерации заранее, например за час до предстоящей публикации. И сразу же загружать на сервер 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/
Добавить комментарий