Compare commits
17 Commits
d28b26629c
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fcc2eb7450 | ||
|
|
c641a3dd23 | ||
|
|
47ba203edc | ||
|
|
4d46174bbb | ||
|
|
0103c3fb8a | ||
|
|
2cd8d0b612 | ||
|
|
da31e21406 | ||
|
|
40b4515305 | ||
|
|
b321075293 | ||
|
|
92592e2baa | ||
|
|
386f6fa9fe | ||
|
|
db2e05bbf4 | ||
|
|
c6a515803b | ||
|
|
44c24c4abd | ||
|
|
0f8f64a8a2 | ||
|
|
84184f4568 | ||
|
|
c6634bfe5b |
@@ -14,3 +14,4 @@ SMTP_SECURE=false
|
||||
SMTP_USER=
|
||||
SMTP_PASS=
|
||||
SMTP_FROM=
|
||||
AUTH_LOGIN_CODE_DELIVERY=email
|
||||
|
||||
@@ -14,5 +14,6 @@ RUN npm ci && npx prisma generate
|
||||
|
||||
COPY src ./src
|
||||
COPY scripts ./scripts
|
||||
COPY data ./data
|
||||
|
||||
CMD ["sh", "-c", ". /app/scripts/load-vault-env.sh && npx prisma migrate deploy && node src/server.js"]
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Fregat Apollo Backend
|
||||
# Fregat Backend
|
||||
|
||||
GraphQL backend for Fregat client cabinet and manager cabinet.
|
||||
|
||||
|
||||
2363
data/catalog-import-2026-04-08.json
Normal file
2363
data/catalog-import-2026-04-08.json
Normal file
File diff suppressed because it is too large
Load Diff
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"name": "fregat-apollo-backend",
|
||||
"name": "fregat-backend",
|
||||
"version": "0.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "fregat-apollo-backend",
|
||||
"name": "fregat-backend",
|
||||
"version": "0.1.0",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "fregat-apollo-backend",
|
||||
"name": "fregat-backend",
|
||||
"version": "0.1.0",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
@@ -11,7 +11,8 @@
|
||||
"prisma:migrate": "prisma migrate deploy",
|
||||
"prisma:push": "prisma db push",
|
||||
"seed": "node scripts/seed.js",
|
||||
"seed:demo": "node scripts/seed-demo.js"
|
||||
"seed:demo": "node scripts/seed-demo.js",
|
||||
"import:catalog": "sh -c '. /app/scripts/load-vault-env.sh && node scripts/import-catalog.js'"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
|
||||
3
prisma/migrations/0010_add_product_tags/migration.sql
Normal file
3
prisma/migrations/0010_add_product_tags/migration.sql
Normal file
@@ -0,0 +1,3 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Product" ADD COLUMN "tags" TEXT[] DEFAULT ARRAY[]::TEXT[];
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "CatalogProductTypeSetting" (
|
||||
"id" TEXT NOT NULL,
|
||||
"productType" TEXT NOT NULL,
|
||||
"showQuantityPerBox" BOOLEAN NOT NULL DEFAULT false,
|
||||
"allowCustomLength" BOOLEAN NOT NULL DEFAULT false,
|
||||
"customLengthMinM" INTEGER,
|
||||
"customLengthMaxM" INTEGER,
|
||||
"customLengthStepM" INTEGER,
|
||||
"allowCustomSleeveBrand" BOOLEAN NOT NULL DEFAULT false,
|
||||
"allowCustomLabel" BOOLEAN NOT NULL DEFAULT false,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "CatalogProductTypeSetting_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "CatalogProductTypeSetting_productType_key" ON "CatalogProductTypeSetting"("productType");
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "CatalogProductTypeSetting" ADD COLUMN "colorOptions" TEXT[] DEFAULT ARRAY[]::TEXT[],
|
||||
ADD COLUMN "labelOptions" TEXT[] DEFAULT ARRAY[]::TEXT[],
|
||||
ADD COLUMN "lengthOptionsM" INTEGER[] DEFAULT ARRAY[]::INTEGER[],
|
||||
ADD COLUMN "sleeveOptions" TEXT[] DEFAULT ARRAY[]::TEXT[],
|
||||
ADD COLUMN "thicknessOptionsMicron" INTEGER[] DEFAULT ARRAY[]::INTEGER[],
|
||||
ADD COLUMN "widthOptionsMm" INTEGER[] DEFAULT ARRAY[]::INTEGER[];
|
||||
@@ -0,0 +1,3 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "User" ADD COLUMN "bonusProgramEnabled" BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
@@ -60,6 +60,7 @@ model User {
|
||||
email String @unique
|
||||
fullName String
|
||||
role UserRole
|
||||
bonusProgramEnabled Boolean @default(false)
|
||||
companyId String?
|
||||
company Company? @relation(fields: [companyId], references: [id])
|
||||
counterpartyProfile CounterpartyProfile?
|
||||
@@ -179,6 +180,7 @@ model Product {
|
||||
thicknessMicron Int?
|
||||
sleeveBrand String?
|
||||
quantityPerBox String?
|
||||
tags String[] @default([])
|
||||
description String?
|
||||
isCustomizable Boolean @default(false)
|
||||
isActive Boolean @default(true)
|
||||
@@ -189,6 +191,26 @@ model Product {
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model CatalogProductTypeSetting {
|
||||
id String @id @default(cuid())
|
||||
productType String @unique
|
||||
showQuantityPerBox Boolean @default(false)
|
||||
allowCustomLength Boolean @default(false)
|
||||
customLengthMinM Int?
|
||||
customLengthMaxM Int?
|
||||
customLengthStepM Int?
|
||||
allowCustomSleeveBrand Boolean @default(false)
|
||||
allowCustomLabel Boolean @default(false)
|
||||
widthOptionsMm Int[] @default([])
|
||||
lengthOptionsM Int[] @default([])
|
||||
thicknessOptionsMicron Int[] @default([])
|
||||
sleeveOptions String[] @default([])
|
||||
colorOptions String[] @default([])
|
||||
labelOptions String[] @default([])
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model Cart {
|
||||
id String @id @default(cuid())
|
||||
userId String @unique
|
||||
|
||||
105
scripts/import-catalog.js
Normal file
105
scripts/import-catalog.js
Normal file
@@ -0,0 +1,105 @@
|
||||
import 'dotenv/config';
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
import { prisma } from '../src/prisma-client.js';
|
||||
|
||||
const IMPORT_DESCRIPTION_PREFIX = 'Импорт каталога 2026-04-08: ';
|
||||
const dataset = JSON.parse(
|
||||
readFileSync(new URL('../data/catalog-import-2026-04-08.json', import.meta.url), 'utf8'),
|
||||
);
|
||||
|
||||
const warehouses = [
|
||||
{ code: 'MSK-01', name: 'Склад Москва' },
|
||||
{ code: 'SPB-01', name: 'Склад СПб' },
|
||||
];
|
||||
|
||||
function formatQuantity(value) {
|
||||
return Number(value ?? 0).toFixed(3);
|
||||
}
|
||||
|
||||
for (const warehouse of warehouses) {
|
||||
await prisma.warehouse.upsert({
|
||||
where: { code: warehouse.code },
|
||||
update: { name: warehouse.name },
|
||||
create: warehouse,
|
||||
});
|
||||
}
|
||||
|
||||
const warehouseIds = Object.fromEntries(
|
||||
await Promise.all(
|
||||
warehouses.map(async (warehouse) => {
|
||||
const persistedWarehouse = await prisma.warehouse.findUniqueOrThrow({
|
||||
where: { code: warehouse.code },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
return [warehouse.code, persistedWarehouse.id];
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
await prisma.product.updateMany({
|
||||
data: {
|
||||
isActive: false,
|
||||
},
|
||||
});
|
||||
|
||||
let importedCount = 0;
|
||||
|
||||
for (const item of dataset) {
|
||||
const product = await prisma.product.upsert({
|
||||
where: { sku: item.sku },
|
||||
update: {
|
||||
name: item.name,
|
||||
productType: item.productType,
|
||||
widthMm: item.widthMm,
|
||||
lengthM: item.lengthM,
|
||||
thicknessMicron: item.thicknessMicron,
|
||||
sleeveBrand: item.sleeveBrand,
|
||||
quantityPerBox: item.quantityPerBox,
|
||||
tags: item.tags,
|
||||
description: `${IMPORT_DESCRIPTION_PREFIX}${item.rawName}`,
|
||||
isCustomizable: false,
|
||||
isActive: true,
|
||||
},
|
||||
create: {
|
||||
sku: item.sku,
|
||||
name: item.name,
|
||||
productType: item.productType,
|
||||
widthMm: item.widthMm,
|
||||
lengthM: item.lengthM,
|
||||
thicknessMicron: item.thicknessMicron,
|
||||
sleeveBrand: item.sleeveBrand,
|
||||
quantityPerBox: item.quantityPerBox,
|
||||
tags: item.tags,
|
||||
description: `${IMPORT_DESCRIPTION_PREFIX}${item.rawName}`,
|
||||
isCustomizable: false,
|
||||
isActive: true,
|
||||
},
|
||||
});
|
||||
|
||||
for (const warehouse of warehouses) {
|
||||
await prisma.productStock.upsert({
|
||||
where: {
|
||||
productId_warehouseId: {
|
||||
productId: product.id,
|
||||
warehouseId: warehouseIds[warehouse.code],
|
||||
},
|
||||
},
|
||||
update: {
|
||||
availableQty: formatQuantity(item.balances[warehouse.code]),
|
||||
},
|
||||
create: {
|
||||
productId: product.id,
|
||||
warehouseId: warehouseIds[warehouse.code],
|
||||
availableQty: formatQuantity(item.balances[warehouse.code]),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
importedCount += 1;
|
||||
}
|
||||
|
||||
console.log(`Imported ${importedCount} catalog products from 2026-04-08 stock reports.`);
|
||||
await prisma.$disconnect();
|
||||
@@ -161,12 +161,14 @@ async function upsertClient(index) {
|
||||
update: {
|
||||
fullName: fullNameForIndex(index),
|
||||
role: 'CLIENT',
|
||||
bonusProgramEnabled: index % 2 === 1,
|
||||
companyId: company.id,
|
||||
},
|
||||
create: {
|
||||
email: buildClientEmail(index),
|
||||
fullName: fullNameForIndex(index),
|
||||
role: 'CLIENT',
|
||||
bonusProgramEnabled: index % 2 === 1,
|
||||
companyId: company.id,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -104,11 +104,16 @@ const manager = await prisma.user.upsert({
|
||||
|
||||
await prisma.user.upsert({
|
||||
where: { email: clientEmail },
|
||||
update: { fullName: 'Demo Client', companyId: company.id },
|
||||
update: {
|
||||
fullName: 'Demo Client',
|
||||
companyId: company.id,
|
||||
bonusProgramEnabled: true,
|
||||
},
|
||||
create: {
|
||||
email: clientEmail,
|
||||
fullName: 'Demo Client',
|
||||
role: 'CLIENT',
|
||||
bonusProgramEnabled: true,
|
||||
companyId: company.id,
|
||||
},
|
||||
});
|
||||
|
||||
58
src/auth.js
58
src/auth.js
@@ -6,6 +6,7 @@ const AUTH_TOKEN_TTL_SECONDS = Number(process.env.AUTH_TOKEN_TTL_SECONDS ?? 60 *
|
||||
const AUTH_LOGIN_CHALLENGE_TTL_SECONDS = Number(process.env.AUTH_LOGIN_CHALLENGE_TTL_SECONDS ?? 10 * 60);
|
||||
const AUTH_LOGIN_LINK_TTL_SECONDS = Number(process.env.AUTH_LOGIN_LINK_TTL_SECONDS ?? 5 * 60);
|
||||
const AUTH_MESSENGER_START_TTL_SECONDS = Number(process.env.AUTH_MESSENGER_START_TTL_SECONDS ?? 10 * 60);
|
||||
const BONUS_PROGRAM_LINK_TTL_SECONDS = Number(process.env.BONUS_PROGRAM_LINK_TTL_SECONDS ?? 7 * 24 * 60 * 60);
|
||||
const AUTH_STATIC_CODE = process.env.AUTH_STATIC_CODE || '123456';
|
||||
|
||||
const activeChallenges = new Map();
|
||||
@@ -179,7 +180,7 @@ export function verifyLoginChallengeCode({ challengeToken, code }) {
|
||||
};
|
||||
}
|
||||
|
||||
export function createMessengerStartSession({ channel, email, userId, redirectPath }) {
|
||||
export function createMessengerStartSession({ channel, email, userId, redirectPath, targetApp = 'MAIN' }) {
|
||||
purgeExpiredMessengerStartSessions();
|
||||
|
||||
const startToken = crypto.randomBytes(24).toString('base64url');
|
||||
@@ -189,6 +190,7 @@ export function createMessengerStartSession({ channel, email, userId, redirectPa
|
||||
email,
|
||||
userId,
|
||||
redirectPath,
|
||||
targetApp,
|
||||
expiresAt,
|
||||
});
|
||||
|
||||
@@ -212,6 +214,7 @@ export function consumeMessengerStartSession(startToken) {
|
||||
email: payload.email,
|
||||
userId: payload.userId,
|
||||
redirectPath: payload.redirectPath,
|
||||
targetApp: payload.targetApp || 'MAIN',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -237,6 +240,59 @@ export function issueTemporaryLoginToken({ userId, messengerConnection = null })
|
||||
};
|
||||
}
|
||||
|
||||
export function issueBonusProgramLinkToken({ userId }) {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const exp = now + BONUS_PROGRAM_LINK_TTL_SECONDS;
|
||||
const payload = {
|
||||
type: 'BONUS_PROGRAM_LINK',
|
||||
sub: userId,
|
||||
iat: now,
|
||||
exp,
|
||||
jti: crypto.randomUUID(),
|
||||
};
|
||||
|
||||
const payloadBase64 = Buffer.from(JSON.stringify(payload)).toString('base64url');
|
||||
const signature = sign(payloadBase64);
|
||||
|
||||
return {
|
||||
token: `${payloadBase64}.${signature}`,
|
||||
expiresAt: new Date(exp * 1000),
|
||||
};
|
||||
}
|
||||
|
||||
export function verifyBonusProgramLinkToken(token) {
|
||||
if (!token) {
|
||||
throw new Error('Bonus program token is required.');
|
||||
}
|
||||
|
||||
const parts = String(token).split('.');
|
||||
if (parts.length !== 2) {
|
||||
throw new Error('Bonus program token is invalid.');
|
||||
}
|
||||
|
||||
const [payloadBase64, signature] = parts;
|
||||
const expectedSignature = sign(payloadBase64);
|
||||
if (expectedSignature !== signature) {
|
||||
throw new Error('Bonus program token signature is invalid.');
|
||||
}
|
||||
|
||||
const payloadJson = Buffer.from(payloadBase64, 'base64url').toString('utf8');
|
||||
const payload = JSON.parse(payloadJson);
|
||||
if (payload.type !== 'BONUS_PROGRAM_LINK') {
|
||||
throw new Error('Bonus program token type is invalid.');
|
||||
}
|
||||
|
||||
const exp = Number(payload.exp);
|
||||
if (!Number.isFinite(exp) || exp <= Math.floor(Date.now() / 1000)) {
|
||||
throw new Error('Bonus program token has expired.');
|
||||
}
|
||||
|
||||
return {
|
||||
userId: String(payload.sub),
|
||||
expiresAt: new Date(exp * 1000),
|
||||
};
|
||||
}
|
||||
|
||||
export function consumeTemporaryLoginToken(loginToken) {
|
||||
purgeExpiredLoginTokens();
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import nodemailer from 'nodemailer';
|
||||
import { buildLoginCodeEmailTemplate } from './notification-templates.js';
|
||||
|
||||
let cachedTransporter = null;
|
||||
|
||||
@@ -54,16 +55,68 @@ function getTransporter() {
|
||||
return cachedTransporter;
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value ?? '')
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"');
|
||||
}
|
||||
|
||||
function normalizeBody(body) {
|
||||
if (Array.isArray(body)) {
|
||||
return body
|
||||
.map((line) => String(line ?? '').trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
const text = String(body ?? '').trim();
|
||||
return text ? [text] : [];
|
||||
}
|
||||
|
||||
function buildNotificationEmailText(body, buttonText, buttonUrl) {
|
||||
const lines = normalizeBody(body);
|
||||
if (buttonUrl) {
|
||||
lines.push(`${buttonText || 'Открыть'}: ${buttonUrl}`);
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function buildNotificationEmailHtml(body, buttonText, buttonUrl) {
|
||||
const paragraphs = normalizeBody(body)
|
||||
.map((line) => `<p>${escapeHtml(line)}</p>`)
|
||||
.join('');
|
||||
|
||||
if (!buttonUrl) {
|
||||
return paragraphs;
|
||||
}
|
||||
|
||||
return `${paragraphs}<p><a href="${escapeHtml(buttonUrl)}" style="display:inline-block;padding:12px 18px;border-radius:999px;background:#123824;color:#ffffff;text-decoration:none;font-weight:700;">${escapeHtml(buttonText || 'Открыть')}</a></p>`;
|
||||
}
|
||||
|
||||
export async function sendLoginCodeEmail({ to, code, expiresAt }) {
|
||||
const { from } = getSmtpConfig();
|
||||
const transporter = getTransporter();
|
||||
const expiresText = new Date(expiresAt).toLocaleString('ru-RU', { hour12: false });
|
||||
const template = buildLoginCodeEmailTemplate({ code, expiresAt });
|
||||
|
||||
await transporter.sendMail({
|
||||
from,
|
||||
to,
|
||||
subject: 'Код входа в личный кабинет Fregat',
|
||||
text: `Код входа: ${code}\nДействует до: ${expiresText}`,
|
||||
html: `<p>Код входа: <b>${code}</b></p><p>Действует до: ${expiresText}</p>`,
|
||||
subject: template.subject,
|
||||
text: template.text,
|
||||
html: template.html,
|
||||
});
|
||||
}
|
||||
|
||||
export async function sendNotificationEmail({ to, subject, body, buttonText = null, buttonUrl = null }) {
|
||||
const { from } = getSmtpConfig();
|
||||
const transporter = getTransporter();
|
||||
|
||||
await transporter.sendMail({
|
||||
from,
|
||||
to,
|
||||
subject,
|
||||
text: buildNotificationEmailText(body, buttonText, buttonUrl),
|
||||
html: buildNotificationEmailHtml(body, buttonText, buttonUrl),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -101,7 +101,7 @@ async function sendMaxMessage(channelId, message, options = {}) {
|
||||
body: JSON.stringify({
|
||||
channelId,
|
||||
text: message,
|
||||
source: 'fregat-apollo-backend',
|
||||
source: 'fregat-backend',
|
||||
...(options.buttonUrl
|
||||
? {
|
||||
button: {
|
||||
|
||||
307
src/notification-templates.js
Normal file
307
src/notification-templates.js
Normal file
@@ -0,0 +1,307 @@
|
||||
import { isManagerRole } from './access.js';
|
||||
|
||||
function splitBody(text) {
|
||||
return String(text ?? '')
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function createChannelPreview(channel, fields = {}) {
|
||||
return {
|
||||
channel,
|
||||
implemented: true,
|
||||
subject: fields.subject ?? null,
|
||||
body: fields.body ?? [],
|
||||
buttonText: fields.buttonText ?? null,
|
||||
buttonUrl: fields.buttonUrl ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildFrontendAppUrl(path) {
|
||||
const baseUrl = String(
|
||||
process.env.TELEGRAM_MINI_APP_URL ||
|
||||
process.env.WEB_FRONTEND_URL ||
|
||||
process.env.NUXT_PUBLIC_SITE_URL ||
|
||||
'',
|
||||
).trim().replace(/\/$/, '');
|
||||
|
||||
const normalizedPath = String(path || '').trim();
|
||||
if (!baseUrl || !normalizedPath.startsWith('/')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return `${baseUrl}${normalizedPath}`;
|
||||
}
|
||||
|
||||
export function buildUserOrderPath(orderId, role) {
|
||||
const normalizedOrderId = String(orderId || '').trim();
|
||||
if (!normalizedOrderId) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return isManagerRole(role)
|
||||
? `/client-orders/${normalizedOrderId}`
|
||||
: `/orders/${normalizedOrderId}`;
|
||||
}
|
||||
|
||||
export function buildBonusProgramPath(entry = 'bonus-message') {
|
||||
const normalizedEntry = String(entry || '').trim();
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (normalizedEntry) {
|
||||
params.set('entry', normalizedEntry);
|
||||
}
|
||||
|
||||
const query = params.toString();
|
||||
return query ? `/bonus-program?${query}` : '/bonus-program';
|
||||
}
|
||||
|
||||
export function buildBonusProgramUrl(entry = 'bonus-message') {
|
||||
const bonusBaseUrl = String(
|
||||
process.env.BONUS_FRONTEND_URL ||
|
||||
process.env.BONUS_PUBLIC_BASE_URL ||
|
||||
'',
|
||||
).trim().replace(/\/$/, '');
|
||||
|
||||
if (bonusBaseUrl) {
|
||||
const params = new URLSearchParams();
|
||||
const normalizedEntry = String(entry || '').trim();
|
||||
if (normalizedEntry) {
|
||||
params.set('entry', normalizedEntry);
|
||||
}
|
||||
|
||||
const query = params.toString();
|
||||
return query ? `${bonusBaseUrl}/?${query}` : `${bonusBaseUrl}/`;
|
||||
}
|
||||
|
||||
return buildFrontendAppUrl(buildBonusProgramPath(entry));
|
||||
}
|
||||
|
||||
export function buildLoginCodeEmailTemplate({ code, expiresAt }) {
|
||||
const expiresText = new Date(expiresAt).toLocaleString('ru-RU', { hour12: false });
|
||||
const body = [
|
||||
`Код входа: ${code}`,
|
||||
`Действует до: ${expiresText}`,
|
||||
];
|
||||
|
||||
return {
|
||||
subject: 'Код входа в личный кабинет Fregat',
|
||||
body,
|
||||
text: body.join('\n'),
|
||||
html: `<p>Код входа: <b>${code}</b></p><p>Действует до: ${expiresText}</p>`,
|
||||
};
|
||||
}
|
||||
|
||||
function formatOrderStatusLabel(status) {
|
||||
if (status === 'NEW' || status === 'MANAGER_PROCESSING') {
|
||||
return 'Заявка';
|
||||
}
|
||||
if (status === 'WAITING_DOUBLE_CONFIRM' || status === 'CONFIRMED') {
|
||||
return 'Предложение';
|
||||
}
|
||||
if (status === 'IN_PROGRESS') {
|
||||
return 'В работе';
|
||||
}
|
||||
if (status === 'COMPLETED') {
|
||||
return 'Завершен';
|
||||
}
|
||||
if (status === 'CLIENT_REJECTED' || status === 'MANAGER_REJECTED') {
|
||||
return 'Отклонен';
|
||||
}
|
||||
if (status === 'MANAGER_BLOCKED') {
|
||||
return 'Пауза';
|
||||
}
|
||||
return String(status || '').trim() || 'Статус обновлен';
|
||||
}
|
||||
|
||||
function formatWithdrawalStatusLabel(status) {
|
||||
if (status === 'APPROVED') {
|
||||
return 'принята';
|
||||
}
|
||||
if (status === 'REJECTED') {
|
||||
return 'не принята';
|
||||
}
|
||||
return 'обработана';
|
||||
}
|
||||
|
||||
export function buildMessengerLoginTemplate({ buttonUrl, expiresAt = null }) {
|
||||
const body = ['Для входа в личный кабинет перейдите по ссылке.'];
|
||||
if (expiresAt) {
|
||||
const expiresText = new Date(expiresAt).toLocaleString('ru-RU', { hour12: false });
|
||||
body.push(`Ссылка действует до: ${expiresText}`);
|
||||
}
|
||||
|
||||
return {
|
||||
message: body.join('\n'),
|
||||
buttonText: 'Открыть кабинет',
|
||||
buttonUrl,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildOrderStatusNotificationTemplate({ orderId, orderCode, status, note, role }) {
|
||||
void note;
|
||||
const body = [
|
||||
`Статус заказа изменен на «${formatOrderStatusLabel(status)}».`,
|
||||
];
|
||||
|
||||
return {
|
||||
subject: `Статус заказа ${orderCode} изменен`,
|
||||
body,
|
||||
message: body.join('\n'),
|
||||
buttonText: 'Открыть заказ',
|
||||
buttonUrl: buildFrontendAppUrl(buildUserOrderPath(orderId, role)),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildBonusCreditTemplate({ amount }) {
|
||||
const normalizedAmount = Number(amount);
|
||||
const body = [
|
||||
`Начислен бонус: ${Number.isFinite(normalizedAmount) ? normalizedAmount : amount}.`,
|
||||
];
|
||||
|
||||
return {
|
||||
subject: 'Начислен бонус',
|
||||
body,
|
||||
message: body.join('\n'),
|
||||
buttonText: 'Открыть бонусную программу',
|
||||
buttonUrl: buildBonusProgramUrl('balance'),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildAutoBonusNotificationTemplate({ amount }) {
|
||||
return buildBonusCreditTemplate({ amount });
|
||||
}
|
||||
|
||||
export function buildManualBonusNotificationTemplate({ amount }) {
|
||||
return buildBonusCreditTemplate({ amount });
|
||||
}
|
||||
|
||||
export function buildWithdrawalReviewNotificationTemplate({ status, reviewComment }) {
|
||||
void reviewComment;
|
||||
const body = [
|
||||
`Ваша заявка на выплату вознаграждения ${formatWithdrawalStatusLabel(status)}.`,
|
||||
];
|
||||
|
||||
return {
|
||||
subject: 'Заявка на выплату вознаграждения',
|
||||
body,
|
||||
message: body.join('\n'),
|
||||
buttonText: 'Открыть бонусную программу',
|
||||
buttonUrl: buildBonusProgramUrl('withdrawal-review'),
|
||||
};
|
||||
}
|
||||
|
||||
export function getNotificationTemplatesCatalog() {
|
||||
const loginTemplate = buildLoginCodeEmailTemplate({
|
||||
code: '123456',
|
||||
expiresAt: '2026-04-06T15:30:00.000Z',
|
||||
});
|
||||
const messengerLoginTemplate = buildMessengerLoginTemplate({
|
||||
buttonUrl: 'https://fregat.dsrptlab.com/login?login_token=demo-token',
|
||||
expiresAt: '2026-04-06T15:35:00.000Z',
|
||||
});
|
||||
const orderStatusTemplate = buildOrderStatusNotificationTemplate({
|
||||
orderId: 'demo-order-id',
|
||||
orderCode: 'FRG-2401',
|
||||
status: 'IN_PROGRESS',
|
||||
note: null,
|
||||
role: 'CLIENT',
|
||||
});
|
||||
const bonusTemplate = buildBonusCreditTemplate({
|
||||
amount: 1250,
|
||||
});
|
||||
const withdrawalReviewTemplate = buildWithdrawalReviewNotificationTemplate({
|
||||
status: 'APPROVED',
|
||||
reviewComment: null,
|
||||
});
|
||||
|
||||
return [
|
||||
{
|
||||
id: 'login-code-email',
|
||||
title: 'Код входа по email',
|
||||
channels: [
|
||||
createChannelPreview('EMAIL', {
|
||||
subject: loginTemplate.subject,
|
||||
body: loginTemplate.body,
|
||||
}),
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'messenger-login-confirmed',
|
||||
title: 'Привязка мессенджера',
|
||||
channels: [
|
||||
createChannelPreview('TELEGRAM', {
|
||||
body: splitBody(messengerLoginTemplate.message),
|
||||
buttonText: messengerLoginTemplate.buttonText,
|
||||
buttonUrl: messengerLoginTemplate.buttonUrl,
|
||||
}),
|
||||
createChannelPreview('MAX', {
|
||||
body: splitBody(messengerLoginTemplate.message),
|
||||
buttonText: messengerLoginTemplate.buttonText,
|
||||
buttonUrl: messengerLoginTemplate.buttonUrl,
|
||||
}),
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'order-status-update',
|
||||
title: 'Изменение статуса заказа',
|
||||
channels: [
|
||||
createChannelPreview('EMAIL', {
|
||||
subject: orderStatusTemplate.subject,
|
||||
body: orderStatusTemplate.body,
|
||||
}),
|
||||
createChannelPreview('TELEGRAM', {
|
||||
body: splitBody(orderStatusTemplate.message),
|
||||
buttonText: orderStatusTemplate.buttonText,
|
||||
buttonUrl: orderStatusTemplate.buttonUrl,
|
||||
}),
|
||||
createChannelPreview('MAX', {
|
||||
body: splitBody(orderStatusTemplate.message),
|
||||
buttonText: orderStatusTemplate.buttonText,
|
||||
buttonUrl: orderStatusTemplate.buttonUrl,
|
||||
}),
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'bonus-credit',
|
||||
title: 'Начислен бонус',
|
||||
channels: [
|
||||
createChannelPreview('EMAIL', {
|
||||
subject: bonusTemplate.subject,
|
||||
body: bonusTemplate.body,
|
||||
}),
|
||||
createChannelPreview('TELEGRAM', {
|
||||
body: splitBody(bonusTemplate.message),
|
||||
buttonText: bonusTemplate.buttonText,
|
||||
buttonUrl: bonusTemplate.buttonUrl,
|
||||
}),
|
||||
createChannelPreview('MAX', {
|
||||
body: splitBody(bonusTemplate.message),
|
||||
buttonText: bonusTemplate.buttonText,
|
||||
buttonUrl: bonusTemplate.buttonUrl,
|
||||
}),
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'reward-withdrawal-review',
|
||||
title: 'Заявка на выплату вознаграждения',
|
||||
channels: [
|
||||
createChannelPreview('EMAIL', {
|
||||
subject: withdrawalReviewTemplate.subject,
|
||||
body: withdrawalReviewTemplate.body,
|
||||
}),
|
||||
createChannelPreview('TELEGRAM', {
|
||||
body: splitBody(withdrawalReviewTemplate.message),
|
||||
buttonText: withdrawalReviewTemplate.buttonText,
|
||||
buttonUrl: withdrawalReviewTemplate.buttonUrl,
|
||||
}),
|
||||
createChannelPreview('MAX', {
|
||||
body: splitBody(withdrawalReviewTemplate.message),
|
||||
buttonText: withdrawalReviewTemplate.buttonText,
|
||||
buttonUrl: withdrawalReviewTemplate.buttonUrl,
|
||||
}),
|
||||
],
|
||||
},
|
||||
];
|
||||
}
|
||||
1086
src/resolvers.js
1086
src/resolvers.js
File diff suppressed because it is too large
Load Diff
@@ -63,6 +63,7 @@ type User {
|
||||
email: String!
|
||||
fullName: String!
|
||||
role: UserRole!
|
||||
bonusProgramEnabled: Boolean!
|
||||
company: Company
|
||||
}
|
||||
|
||||
@@ -142,6 +143,7 @@ type ManagerUser {
|
||||
email: String!
|
||||
fullName: String!
|
||||
role: UserRole!
|
||||
bonusProgramEnabled: Boolean!
|
||||
companyName: String
|
||||
inn: String
|
||||
createdAt: DateTime!
|
||||
@@ -178,6 +180,48 @@ type NotificationHistoryItem {
|
||||
orderId: ID
|
||||
}
|
||||
|
||||
type NotificationTemplateChannel {
|
||||
channel: LoginChannel!
|
||||
implemented: Boolean!
|
||||
subject: String
|
||||
body: [String!]!
|
||||
buttonText: String
|
||||
buttonUrl: String
|
||||
}
|
||||
|
||||
type NotificationTemplate {
|
||||
id: ID!
|
||||
title: String!
|
||||
channels: [NotificationTemplateChannel!]!
|
||||
}
|
||||
|
||||
type IntegrationSyncItem {
|
||||
id: ID!
|
||||
title: String!
|
||||
description: String!
|
||||
source: String!
|
||||
syncedCount: Int!
|
||||
lastSyncedAt: DateTime
|
||||
status: String!
|
||||
note: String!
|
||||
}
|
||||
|
||||
type IntegrationSyncDashboard {
|
||||
generatedAt: DateTime!
|
||||
lastActivityAt: DateTime
|
||||
totalOrders: Int!
|
||||
totalProducts: Int!
|
||||
totalClients: Int!
|
||||
items: [IntegrationSyncItem!]!
|
||||
}
|
||||
|
||||
type BonusProgramLink {
|
||||
userId: ID!
|
||||
token: String!
|
||||
url: String!
|
||||
expiresAt: DateTime!
|
||||
}
|
||||
|
||||
type Warehouse {
|
||||
id: ID!
|
||||
code: String!
|
||||
@@ -200,11 +244,29 @@ type Product {
|
||||
thicknessMicron: Int
|
||||
sleeveBrand: String
|
||||
quantityPerBox: String
|
||||
tags: [String!]!
|
||||
isCustomizable: Boolean!
|
||||
isActive: Boolean!
|
||||
availableInWarehouses: [ProductWarehouseBalance!]!
|
||||
}
|
||||
|
||||
type CatalogProductTypeSetting {
|
||||
productType: String!
|
||||
showQuantityPerBox: Boolean!
|
||||
allowCustomLength: Boolean!
|
||||
customLengthMinM: Int
|
||||
customLengthMaxM: Int
|
||||
customLengthStepM: Int
|
||||
allowCustomSleeveBrand: Boolean!
|
||||
allowCustomLabel: Boolean!
|
||||
widthOptionsMm: [Int!]!
|
||||
lengthOptionsM: [Int!]!
|
||||
thicknessOptionsMicron: [Int!]!
|
||||
sleeveOptions: [String!]!
|
||||
colorOptions: [String!]!
|
||||
labelOptions: [String!]!
|
||||
}
|
||||
|
||||
type CartItem {
|
||||
id: ID!
|
||||
productId: ID!
|
||||
@@ -322,6 +384,7 @@ type ManagerBonusBalance {
|
||||
email: String!
|
||||
fullName: String!
|
||||
companyName: String
|
||||
bonusProgramEnabled: Boolean!
|
||||
balance: Float!
|
||||
pendingWithdrawalAmount: Float!
|
||||
transactionsCount: Int!
|
||||
@@ -364,8 +427,11 @@ type Query {
|
||||
myDeliveryAddresses: [DeliveryAddress!]!
|
||||
myMessengerConnections: [MessengerConnection!]!
|
||||
myNotificationHistory(channel: MessengerType!, limit: Int = 50): [NotificationHistoryItem!]!
|
||||
notificationTemplates: [NotificationTemplate!]!
|
||||
integrationSyncDashboard: IntegrationSyncDashboard!
|
||||
managerNotificationHistory(userId: ID!, channel: MessengerType!, limit: Int = 50): [NotificationHistoryItem!]!
|
||||
clientProducts: [Product!]!
|
||||
catalogProductTypeSettings: [CatalogProductTypeSetting!]!
|
||||
order(id: ID!): Order
|
||||
myOrders: [Order!]!
|
||||
myCurrentOrders: [Order!]!
|
||||
@@ -446,6 +512,23 @@ input UpdateCartItemQuantityInput {
|
||||
quantity: Float!
|
||||
}
|
||||
|
||||
input UpsertCatalogProductTypeSettingInput {
|
||||
productType: String!
|
||||
showQuantityPerBox: Boolean!
|
||||
allowCustomLength: Boolean!
|
||||
customLengthMinM: Int
|
||||
customLengthMaxM: Int
|
||||
customLengthStepM: Int
|
||||
allowCustomSleeveBrand: Boolean!
|
||||
allowCustomLabel: Boolean!
|
||||
widthOptionsMm: [Int!]!
|
||||
lengthOptionsM: [Int!]!
|
||||
thicknessOptionsMicron: [Int!]!
|
||||
sleeveOptions: [String!]!
|
||||
colorOptions: [String!]!
|
||||
labelOptions: [String!]!
|
||||
}
|
||||
|
||||
input ReadyOrderItemInput {
|
||||
productId: ID!
|
||||
quantity: Float!
|
||||
@@ -467,17 +550,12 @@ input SetOrderOfferInput {
|
||||
orderId: ID!
|
||||
itemPrices: [OrderItemPriceInput!]!
|
||||
deliveryTerms: String!
|
||||
deliveryFee: Float!
|
||||
deliveryFee: Float
|
||||
}
|
||||
|
||||
input OrderItemPriceInput {
|
||||
itemId: ID!
|
||||
unitPrice: Float!
|
||||
}
|
||||
|
||||
input BlockOrderInput {
|
||||
orderId: ID!
|
||||
reason: String!
|
||||
unitPrice: Float
|
||||
}
|
||||
|
||||
input CreateReferralInput {
|
||||
@@ -512,7 +590,9 @@ type Mutation {
|
||||
createInvitation(input: CreateInvitationInput!): Invitation!
|
||||
acceptInvitation(input: AcceptInvitationInput!): User!
|
||||
connectMessenger(input: ConnectMessengerInput!): MessengerConnection!
|
||||
deleteMyMessengerConnection(connectionId: ID!): Boolean!
|
||||
upsertMyCounterpartyProfile(input: UpsertMyCounterpartyProfileInput!): CounterpartyProfile!
|
||||
upsertCatalogProductTypeSetting(input: UpsertCatalogProductTypeSettingInput!): CatalogProductTypeSetting!
|
||||
addProductToCart(productId: ID!): Cart!
|
||||
updateCartItemQuantity(input: UpdateCartItemQuantityInput!): Cart!
|
||||
removeCartItem(productId: ID!): Cart!
|
||||
@@ -526,13 +606,12 @@ type Mutation {
|
||||
submitReadyOrder(input: SubmitReadyOrderInput!): Order!
|
||||
submitCalculationOrder(input: SubmitCalculationOrderInput!): Order!
|
||||
managerSetOrderOffer(input: SetOrderOfferInput!): Order!
|
||||
managerSetOrderStatus(orderId: ID!, status: OrderStatus!): Order!
|
||||
clientReviewOrder(orderId: ID!, decision: Decision!): Order!
|
||||
managerFinalizeOrder(orderId: ID!, decision: Decision!): Order!
|
||||
blockOrder(input: BlockOrderInput!): Order!
|
||||
startOrderWork(orderId: ID!): Order!
|
||||
completeOrder(orderId: ID!): Order!
|
||||
|
||||
createReferral(input: CreateReferralInput!): ReferralLink!
|
||||
setClientBonusProgramEnabled(userId: ID!, enabled: Boolean!): ManagerUser!
|
||||
createBonusProgramLink(userId: ID!): BonusProgramLink!
|
||||
addBonusTransaction(input: AddBonusTransactionInput!): BonusTransaction!
|
||||
requestRewardWithdrawal(input: RequestRewardWithdrawalInput!): RewardWithdrawalRequest!
|
||||
reviewRewardWithdrawal(input: ReviewRewardWithdrawalInput!): RewardWithdrawalRequest!
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
hasMessengerStartSession,
|
||||
issueAccessToken,
|
||||
issueTemporaryLoginToken,
|
||||
verifyBonusProgramLinkToken,
|
||||
verifyAccessToken,
|
||||
} from './auth.js';
|
||||
import { canManagerAccessUser, isManagerRole } from './access.js';
|
||||
@@ -30,6 +31,7 @@ import {
|
||||
} from './messenger-connections.js';
|
||||
import { validateMaxMiniAppInitData } from './max-mini-app.js';
|
||||
import { sendMessengerMessage } from './messenger.js';
|
||||
import { buildMessengerLoginTemplate } from './notification-templates.js';
|
||||
import { prisma } from './prisma-client.js';
|
||||
import { resolvers } from './resolvers.js';
|
||||
import { telegramApi, telegramFileUrl } from './telegram.js';
|
||||
@@ -102,6 +104,10 @@ function normalizeRedirectPath(value) {
|
||||
return redirectPath;
|
||||
}
|
||||
|
||||
function normalizeTargetApp(value) {
|
||||
return String(value || '').trim().toUpperCase() === 'BONUS' ? 'BONUS' : 'MAIN';
|
||||
}
|
||||
|
||||
function presentTelegramMiniAppUser(user) {
|
||||
return presentMiniAppUser(user, 'Пользователь Telegram');
|
||||
}
|
||||
@@ -159,6 +165,7 @@ app.post('/auth/messenger-start', async (req, res) => {
|
||||
const email = authenticatedUser?.email?.trim().toLowerCase() || providedEmail;
|
||||
const userId = authenticatedUser?.id ?? null;
|
||||
const redirectPath = normalizeRedirectPath(req.body?.redirectPath);
|
||||
const targetApp = normalizeTargetApp(req.body?.targetApp);
|
||||
|
||||
if (!userId && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
||||
res.status(400).json({ error: 'A valid email is required.' });
|
||||
@@ -170,6 +177,7 @@ app.post('/auth/messenger-start', async (req, res) => {
|
||||
email,
|
||||
userId,
|
||||
redirectPath,
|
||||
targetApp,
|
||||
});
|
||||
|
||||
res.json({
|
||||
@@ -180,6 +188,43 @@ app.post('/auth/messenger-start', async (req, res) => {
|
||||
});
|
||||
});
|
||||
|
||||
app.post('/auth/bonus-program-start', async (req, res) => {
|
||||
const channel = String(req.body?.channel || 'TELEGRAM').toUpperCase();
|
||||
if (channel !== 'TELEGRAM') {
|
||||
res.status(400).json({ error: 'Only Telegram is supported for the bonus program.' });
|
||||
return;
|
||||
}
|
||||
|
||||
const token = String(req.body?.token || '').trim();
|
||||
if (!token) {
|
||||
res.status(400).json({ error: 'Bonus program token is required.' });
|
||||
return;
|
||||
}
|
||||
|
||||
let payload;
|
||||
try {
|
||||
payload = verifyBonusProgramLinkToken(token);
|
||||
} catch (error) {
|
||||
res.status(401).json({ error: error.message });
|
||||
return;
|
||||
}
|
||||
|
||||
const session = createMessengerStartSession({
|
||||
channel,
|
||||
email: '',
|
||||
userId: payload.userId,
|
||||
redirectPath: '/',
|
||||
targetApp: 'BONUS',
|
||||
});
|
||||
|
||||
res.json({
|
||||
ok: true,
|
||||
startToken: session.startToken,
|
||||
expiresAt: session.expiresAt.toISOString(),
|
||||
mode: 'login',
|
||||
});
|
||||
});
|
||||
|
||||
app.post('/auth/telegram-mini-app/session', async (req, res) => {
|
||||
let telegram;
|
||||
try {
|
||||
@@ -408,31 +453,49 @@ app.post('/bot/messenger-login', async (req, res) => {
|
||||
: normalizeMaxProfile(req.body?.profile)),
|
||||
},
|
||||
});
|
||||
const frontendUrl = (
|
||||
const mainFrontendUrl = (
|
||||
process.env.WEB_FRONTEND_URL ||
|
||||
process.env.NUXT_PUBLIC_SITE_URL ||
|
||||
'http://localhost:3000'
|
||||
).replace(/\/$/, '');
|
||||
const nextPath = startSession.redirectPath || (
|
||||
channel === 'TELEGRAM' || channel === 'MAX'
|
||||
? `/profile/notifications/success?connected=${channel.toLowerCase()}`
|
||||
: ''
|
||||
);
|
||||
const loginQuery = new URLSearchParams({
|
||||
login_token: login.loginToken,
|
||||
});
|
||||
if (nextPath) {
|
||||
loginQuery.set('next', nextPath);
|
||||
const bonusFrontendUrl = String(
|
||||
process.env.BONUS_FRONTEND_URL ||
|
||||
process.env.BONUS_PUBLIC_BASE_URL ||
|
||||
'',
|
||||
).trim().replace(/\/$/, '');
|
||||
const frontendUrl = startSession.targetApp === 'BONUS'
|
||||
? (bonusFrontendUrl || mainFrontendUrl)
|
||||
: mainFrontendUrl;
|
||||
|
||||
let loginUrl = `${frontendUrl}/login?login_token=${encodeURIComponent(login.loginToken)}`;
|
||||
if (startSession.targetApp === 'BONUS') {
|
||||
loginUrl = `${frontendUrl}/?login_token=${encodeURIComponent(login.loginToken)}`;
|
||||
} else {
|
||||
const nextPath = startSession.redirectPath || (
|
||||
channel === 'TELEGRAM' || channel === 'MAX'
|
||||
? `/profile/notifications/success?connected=${channel.toLowerCase()}`
|
||||
: ''
|
||||
);
|
||||
if (nextPath) {
|
||||
const loginQuery = new URLSearchParams({
|
||||
login_token: login.loginToken,
|
||||
next: nextPath,
|
||||
});
|
||||
loginUrl = `${frontendUrl}/login?${loginQuery.toString()}`;
|
||||
}
|
||||
}
|
||||
const loginUrl = `${frontendUrl}/login?${loginQuery.toString()}`;
|
||||
|
||||
if (!skipDispatch) {
|
||||
const template = buildMessengerLoginTemplate({
|
||||
buttonUrl: loginUrl,
|
||||
expiresAt: login.expiresAt,
|
||||
});
|
||||
const dispatch = await sendMessengerMessage({
|
||||
type: channel,
|
||||
channelId,
|
||||
message: 'Вход подтвержден. Нажмите кнопку, чтобы открыть личный кабинет.',
|
||||
buttonUrl: loginUrl,
|
||||
buttonText: 'Открыть кабинет',
|
||||
message: template.message,
|
||||
buttonUrl: template.buttonUrl,
|
||||
buttonText: template.buttonText,
|
||||
});
|
||||
|
||||
if (!dispatch.success) {
|
||||
@@ -522,7 +585,7 @@ app.use(
|
||||
|
||||
const port = Number(process.env.PORT ?? 4000);
|
||||
app.listen(port, () => {
|
||||
console.log(`apollo-backend running at http://localhost:${port}/graphql`);
|
||||
console.log(`backend running at http://localhost:${port}/graphql`);
|
||||
});
|
||||
|
||||
async function shutdown() {
|
||||
|
||||
Reference in New Issue
Block a user