В прошлой части я рассказывал как появилась идея стартапа, как найти потребности пользователей, как спроектировать продуктовые требования. Также я рассказал как сделал проектирование и разработку дизайна. Напомню что я разрабатываю приложение для 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.
Всегда первым делом я узнаю список ролей, сущности приложения и действия которые могут делать пользователи. Об этом я писал в первой серии. Давайте освежим память:
Мерчант |
Покупатель |
---|---|
Подключение несколько Instagram аккаунтов к магазину |
Просмотр поста в Instagram аккаунте магазина |
Создание/редактирование/удаление маркетинговой кампании. |
|
Выбор категорий из которых будут извлекаться товары участвующие в кампании |
|
Шаблонизация текстового описания поста. Пользователь может вставлять переменные в текст, например название магазина, название товара, ссылка на товар |
|
Возможность создавать несколько пресетов текстовых описаний поста |
|
Рандомный выбор текстового описания поста из пресетов |
|
Превью поста |
|
Генерация промокодов с временем окончания |
|
Возможность наложения промокода на фотографию в посте |
|
Установка частоты публикации кампании |
|
Установка часового пояса |
Проектирование 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 приложения из каких этапов оно состояло, разработали вместе самые сложные модули.
Как видно из примера в этом нет ничего сложного главное выработать план, понимать что хочет пользователь и двигаться в этом направлении. Если у вас возникнут вопросы, задавайте их в комментариях к этому посту я с удовольствием отвечу!
Misha163
Круто, а где почитать первую часть?
dalv_happy Автор
Жду модерации от хабра, если комфортно можешь почитать мою статью на английском hackernoon: "Building and Launching a Tech Startup Solo: My Story of Turning an Idea Into a Successful Product"
dalv_happy Автор
Вот только что опубликовали первую часть: https://habr.com/ru/articles/819701/