Compare commits

9 Commits

Author SHA1 Message Date
Ruslan Bakiev
fcc2eb7450 Add client bonus access flag 2026-05-16 17:16:31 +07:00
Ruslan Bakiev
c641a3dd23 Fix login code delivery mode 2026-05-16 09:23:40 +07:00
Ruslan Bakiev
47ba203edc Rename Apollo backend service to backend 2026-05-14 14:06:43 +07:00
Ruslan Bakiev
4d46174bbb Fix duplicate catalog option migration 2026-04-09 17:22:27 +07:00
Ruslan Bakiev
0103c3fb8a Add catalog option sets 2026-04-09 17:10:52 +07:00
Ruslan Bakiev
2cd8d0b612 Add catalog product type settings 2026-04-09 16:03:32 +07:00
Ruslan Bakiev
da31e21406 Bundle catalog import data 2026-04-09 14:32:36 +07:00
Ruslan Bakiev
40b4515305 Add tagged catalog import 2026-04-09 14:14:10 +07:00
Ruslan Bakiev
b321075293 Add standalone bonus program auth flow 2026-04-07 10:47:44 +07:00
20 changed files with 3134 additions and 103 deletions

View File

@@ -14,3 +14,4 @@ SMTP_SECURE=false
SMTP_USER= SMTP_USER=
SMTP_PASS= SMTP_PASS=
SMTP_FROM= SMTP_FROM=
AUTH_LOGIN_CODE_DELIVERY=email

View File

@@ -14,5 +14,6 @@ RUN npm ci && npx prisma generate
COPY src ./src COPY src ./src
COPY scripts ./scripts COPY scripts ./scripts
COPY data ./data
CMD ["sh", "-c", ". /app/scripts/load-vault-env.sh && npx prisma migrate deploy && node src/server.js"] CMD ["sh", "-c", ". /app/scripts/load-vault-env.sh && npx prisma migrate deploy && node src/server.js"]

View File

@@ -1,4 +1,4 @@
# Fregat Apollo Backend # Fregat Backend
GraphQL backend for Fregat client cabinet and manager cabinet. GraphQL backend for Fregat client cabinet and manager cabinet.

File diff suppressed because it is too large Load Diff

4
package-lock.json generated
View File

@@ -1,11 +1,11 @@
{ {
"name": "fregat-apollo-backend", "name": "fregat-backend",
"version": "0.1.0", "version": "0.1.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "fregat-apollo-backend", "name": "fregat-backend",
"version": "0.1.0", "version": "0.1.0",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {

View File

@@ -1,5 +1,5 @@
{ {
"name": "fregat-apollo-backend", "name": "fregat-backend",
"version": "0.1.0", "version": "0.1.0",
"description": "", "description": "",
"main": "index.js", "main": "index.js",
@@ -11,7 +11,8 @@
"prisma:migrate": "prisma migrate deploy", "prisma:migrate": "prisma migrate deploy",
"prisma:push": "prisma db push", "prisma:push": "prisma db push",
"seed": "node scripts/seed.js", "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": [], "keywords": [],
"author": "", "author": "",

View File

@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "Product" ADD COLUMN "tags" TEXT[] DEFAULT ARRAY[]::TEXT[];

View File

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

View File

@@ -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[];

View File

@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "User" ADD COLUMN "bonusProgramEnabled" BOOLEAN NOT NULL DEFAULT false;

View File

@@ -60,6 +60,7 @@ model User {
email String @unique email String @unique
fullName String fullName String
role UserRole role UserRole
bonusProgramEnabled Boolean @default(false)
companyId String? companyId String?
company Company? @relation(fields: [companyId], references: [id]) company Company? @relation(fields: [companyId], references: [id])
counterpartyProfile CounterpartyProfile? counterpartyProfile CounterpartyProfile?
@@ -179,6 +180,7 @@ model Product {
thicknessMicron Int? thicknessMicron Int?
sleeveBrand String? sleeveBrand String?
quantityPerBox String? quantityPerBox String?
tags String[] @default([])
description String? description String?
isCustomizable Boolean @default(false) isCustomizable Boolean @default(false)
isActive Boolean @default(true) isActive Boolean @default(true)
@@ -189,6 +191,26 @@ model Product {
updatedAt DateTime @updatedAt 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 { model Cart {
id String @id @default(cuid()) id String @id @default(cuid())
userId String @unique userId String @unique

105
scripts/import-catalog.js Normal file
View 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();

View File

@@ -161,12 +161,14 @@ async function upsertClient(index) {
update: { update: {
fullName: fullNameForIndex(index), fullName: fullNameForIndex(index),
role: 'CLIENT', role: 'CLIENT',
bonusProgramEnabled: index % 2 === 1,
companyId: company.id, companyId: company.id,
}, },
create: { create: {
email: buildClientEmail(index), email: buildClientEmail(index),
fullName: fullNameForIndex(index), fullName: fullNameForIndex(index),
role: 'CLIENT', role: 'CLIENT',
bonusProgramEnabled: index % 2 === 1,
companyId: company.id, companyId: company.id,
}, },
}); });

View File

@@ -104,11 +104,16 @@ const manager = await prisma.user.upsert({
await prisma.user.upsert({ await prisma.user.upsert({
where: { email: clientEmail }, where: { email: clientEmail },
update: { fullName: 'Demo Client', companyId: company.id }, update: {
fullName: 'Demo Client',
companyId: company.id,
bonusProgramEnabled: true,
},
create: { create: {
email: clientEmail, email: clientEmail,
fullName: 'Demo Client', fullName: 'Demo Client',
role: 'CLIENT', role: 'CLIENT',
bonusProgramEnabled: true,
companyId: company.id, companyId: company.id,
}, },
}); });

View File

@@ -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_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_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 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 AUTH_STATIC_CODE = process.env.AUTH_STATIC_CODE || '123456';
const activeChallenges = new Map(); 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(); purgeExpiredMessengerStartSessions();
const startToken = crypto.randomBytes(24).toString('base64url'); const startToken = crypto.randomBytes(24).toString('base64url');
@@ -189,6 +190,7 @@ export function createMessengerStartSession({ channel, email, userId, redirectPa
email, email,
userId, userId,
redirectPath, redirectPath,
targetApp,
expiresAt, expiresAt,
}); });
@@ -212,6 +214,7 @@ export function consumeMessengerStartSession(startToken) {
email: payload.email, email: payload.email,
userId: payload.userId, userId: payload.userId,
redirectPath: payload.redirectPath, 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) { export function consumeTemporaryLoginToken(loginToken) {
purgeExpiredLoginTokens(); purgeExpiredLoginTokens();

View File

@@ -101,7 +101,7 @@ async function sendMaxMessage(channelId, message, options = {}) {
body: JSON.stringify({ body: JSON.stringify({
channelId, channelId,
text: message, text: message,
source: 'fregat-apollo-backend', source: 'fregat-backend',
...(options.buttonUrl ...(options.buttonUrl
? { ? {
button: { button: {

View File

@@ -57,6 +57,27 @@ export function buildBonusProgramPath(entry = 'bonus-message') {
return query ? `/bonus-program?${query}` : '/bonus-program'; 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 }) { export function buildLoginCodeEmailTemplate({ code, expiresAt }) {
const expiresText = new Date(expiresAt).toLocaleString('ru-RU', { hour12: false }); const expiresText = new Date(expiresAt).toLocaleString('ru-RU', { hour12: false });
const body = [ const body = [
@@ -144,7 +165,7 @@ export function buildBonusCreditTemplate({ amount }) {
body, body,
message: body.join('\n'), message: body.join('\n'),
buttonText: 'Открыть бонусную программу', buttonText: 'Открыть бонусную программу',
buttonUrl: buildFrontendAppUrl(buildBonusProgramPath('balance')), buttonUrl: buildBonusProgramUrl('balance'),
}; };
} }
@@ -167,7 +188,7 @@ export function buildWithdrawalReviewNotificationTemplate({ status, reviewCommen
body, body,
message: body.join('\n'), message: body.join('\n'),
buttonText: 'Открыть бонусную программу', buttonText: 'Открыть бонусную программу',
buttonUrl: buildFrontendAppUrl(buildBonusProgramPath('withdrawal-review')), buttonUrl: buildBonusProgramUrl('withdrawal-review'),
}; };
} }

View File

@@ -4,6 +4,7 @@ import {
consumeTemporaryLoginToken, consumeTemporaryLoginToken,
createLoginChallenge, createLoginChallenge,
getStaticAuthCode, getStaticAuthCode,
issueBonusProgramLinkToken,
issueAccessToken, issueAccessToken,
maskAuthDestination, maskAuthDestination,
verifyLoginChallengeCode, verifyLoginChallengeCode,
@@ -29,6 +30,16 @@ import { fetchTelegramConnectionProfile } from './telegram.js';
const ACTIVE_ORDER_STATUSES = ['NEW', 'MANAGER_PROCESSING', 'WAITING_DOUBLE_CONFIRM', 'CONFIRMED', 'IN_PROGRESS']; const ACTIVE_ORDER_STATUSES = ['NEW', 'MANAGER_PROCESSING', 'WAITING_DOUBLE_CONFIRM', 'CONFIRMED', 'IN_PROGRESS'];
function getLoginCodeDeliveryMode() {
const mode = String(process.env.AUTH_LOGIN_CODE_DELIVERY ?? 'email')
.trim()
.toLowerCase();
if (mode !== 'email' && mode !== 'static') {
throw new Error('AUTH_LOGIN_CODE_DELIVERY must be either "email" or "static".');
}
return mode;
}
function toFloat(value) { function toFloat(value) {
return value == null ? null : Number(value); return value == null ? null : Number(value);
} }
@@ -50,6 +61,30 @@ function latestDate(...values) {
return new Date(Math.max(...timestamps)); return new Date(Math.max(...timestamps));
} }
function buildBonusProgramLinkUrl(token) {
const baseUrl = String(
process.env.BONUS_FRONTEND_URL ||
process.env.BONUS_PUBLIC_BASE_URL ||
'',
).trim().replace(/\/$/, '');
if (baseUrl) {
return `${baseUrl}/?bonus_token=${encodeURIComponent(token)}`;
}
const fallbackBaseUrl = String(
process.env.WEB_FRONTEND_URL ||
process.env.NUXT_PUBLIC_SITE_URL ||
'',
).trim().replace(/\/$/, '');
if (!fallbackBaseUrl) {
return `/?bonus_token=${encodeURIComponent(token)}`;
}
return `${fallbackBaseUrl}/bonus-program?bonus_token=${encodeURIComponent(token)}`;
}
function requireUser(context) { function requireUser(context) {
if (!context.user) { if (!context.user) {
throw new Error('Authentication required.'); throw new Error('Authentication required.');
@@ -113,10 +148,61 @@ function mapManagerReferralLink(link) {
}; };
} }
const managerUserInclude = {
messengerConnections: {
where: {
type: 'TELEGRAM',
isActive: true,
},
orderBy: { createdAt: 'desc' },
take: 1,
},
counterpartyProfile: {
select: {
companyName: true,
inn: true,
},
},
clientOrders: {
select: {
createdAt: true,
},
orderBy: { createdAt: 'desc' },
take: 1,
},
_count: {
select: {
clientOrders: true,
},
},
};
function mapManagerUser(user) {
return {
id: user.id,
email: user.email,
fullName: user.fullName,
role: user.role,
bonusProgramEnabled: user.bonusProgramEnabled,
companyName: user.counterpartyProfile?.companyName ?? null,
inn: user.counterpartyProfile?.inn ?? null,
createdAt: user.createdAt,
orderCount: user._count.clientOrders,
lastOrderAt: user.clientOrders[0]?.createdAt ?? null,
telegramConnection: user.messengerConnections[0] ?? null,
};
}
async function createReferralBonusTransaction(prisma, order) { async function createReferralBonusTransaction(prisma, order) {
const referralLink = await prisma.referralLink.findFirst({ const referralLink = await prisma.referralLink.findFirst({
where: { where: {
refereeId: order.customerId, refereeId: order.customerId,
referrer: {
bonusProgramEnabled: true,
},
referee: {
bonusProgramEnabled: true,
},
}, },
include: { include: {
referrer: { referrer: {
@@ -295,20 +381,6 @@ function invitationToken() {
return crypto.randomBytes(24).toString('hex'); return crypto.randomBytes(24).toString('hex');
} }
function buildDefaultFullName(email) {
const localPart = email.split('@')[0]?.trim();
if (!localPart) {
return 'Новый пользователь';
}
return localPart
.replace(/[._-]+/g, ' ')
.split(' ')
.filter(Boolean)
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(' ');
}
function normalizeText(value) { function normalizeText(value) {
return String(value ?? '').trim(); return String(value ?? '').trim();
} }
@@ -327,6 +399,19 @@ function normalizeQuantityValue(value) {
return Math.floor(normalized); return Math.floor(normalized);
} }
function normalizeOptionalPositiveInt(value) {
if (value == null || value === '') {
return null;
}
const normalized = Number(value);
if (!Number.isInteger(normalized) || normalized <= 0) {
throw new Error('Catalog setting values must be positive integers.');
}
return normalized;
}
function isCounterpartyProfileComplete(profile) { function isCounterpartyProfileComplete(profile) {
if (!profile) { if (!profile) {
return false; return false;
@@ -393,6 +478,174 @@ function defaultCartParameters(product) {
}; };
} }
function presentCatalogProductTypeSetting(setting) {
return {
productType: setting.productType,
showQuantityPerBox: Boolean(setting.showQuantityPerBox),
allowCustomLength: Boolean(setting.allowCustomLength),
customLengthMinM: setting.customLengthMinM ?? null,
customLengthMaxM: setting.customLengthMaxM ?? null,
customLengthStepM: setting.customLengthStepM ?? null,
allowCustomSleeveBrand: Boolean(setting.allowCustomSleeveBrand),
allowCustomLabel: Boolean(setting.allowCustomLabel),
widthOptionsMm: [...(setting.widthOptionsMm ?? [])],
lengthOptionsM: [...(setting.lengthOptionsM ?? [])],
thicknessOptionsMicron: [...(setting.thicknessOptionsMicron ?? [])],
sleeveOptions: [...(setting.sleeveOptions ?? [])],
colorOptions: [...(setting.colorOptions ?? [])],
labelOptions: [...(setting.labelOptions ?? [])],
};
}
function normalizePositiveIntList(values) {
return [...new Set((values ?? [])
.map((value) => normalizeOptionalPositiveInt(value))
.filter((value) => value !== null))]
.sort((a, b) => a - b);
}
function normalizeTextList(values) {
return [...new Set((values ?? [])
.map((value) => normalizeText(value))
.filter(Boolean))]
.sort((a, b) => a.localeCompare(b, 'ru'));
}
function collectProductTypeOptionDefaults(products) {
const colorOptions = [];
const labelOptions = [];
for (const product of products) {
for (const tag of product.tags ?? []) {
const normalizedTag = normalizeText(tag);
if (!normalizedTag) {
continue;
}
if (['прозрачный', 'коричневый', 'белый', 'черный', 'желтый', 'зеленый', 'красный', 'синий', 'оранжевый', 'красно-белый'].includes(normalizedTag)) {
colorOptions.push(normalizedTag);
}
if (['хрупкое', 'подарок', 'акция'].includes(normalizedTag)) {
labelOptions.push(normalizedTag);
}
}
}
return {
widthOptionsMm: normalizePositiveIntList(products.map((product) => product.widthMm)),
lengthOptionsM: normalizePositiveIntList(products.map((product) => product.lengthM)),
thicknessOptionsMicron: normalizePositiveIntList(products.map((product) => product.thicknessMicron)),
sleeveOptions: normalizeTextList(products.map((product) => product.sleeveBrand)),
colorOptions: normalizeTextList(colorOptions),
labelOptions: normalizeTextList(labelOptions),
};
}
function normalizeCatalogProductTypeSettingInput(input) {
const productType = normalizeText(input.productType);
if (!productType) {
throw new Error('Product type is required.');
}
const allowCustomLength = Boolean(input.allowCustomLength);
const customLengthMinM = allowCustomLength ? normalizeOptionalPositiveInt(input.customLengthMinM) : null;
const customLengthMaxM = allowCustomLength ? normalizeOptionalPositiveInt(input.customLengthMaxM) : null;
const customLengthStepM = allowCustomLength ? normalizeOptionalPositiveInt(input.customLengthStepM) : null;
if (allowCustomLength) {
if (customLengthMinM == null || customLengthMaxM == null || customLengthStepM == null) {
throw new Error('Length customization requires min, max, and step values.');
}
if (customLengthMinM > customLengthMaxM) {
throw new Error('Length min must be less than or equal to max.');
}
}
return {
productType,
showQuantityPerBox: Boolean(input.showQuantityPerBox),
allowCustomLength,
customLengthMinM,
customLengthMaxM,
customLengthStepM,
allowCustomSleeveBrand: Boolean(input.allowCustomSleeveBrand),
allowCustomLabel: Boolean(input.allowCustomLabel),
widthOptionsMm: normalizePositiveIntList(input.widthOptionsMm),
lengthOptionsM: normalizePositiveIntList(input.lengthOptionsM),
thicknessOptionsMicron: normalizePositiveIntList(input.thicknessOptionsMicron),
sleeveOptions: normalizeTextList(input.sleeveOptions),
colorOptions: normalizeTextList(input.colorOptions),
labelOptions: normalizeTextList(input.labelOptions),
};
}
async function listCatalogProductTypeSettings(prisma) {
const [products, persistedSettings] = await Promise.all([
prisma.product.findMany({
where: {
isActive: true,
NOT: {
productType: null,
},
},
select: {
productType: true,
widthMm: true,
lengthM: true,
thicknessMicron: true,
sleeveBrand: true,
tags: true,
},
orderBy: [{ productType: 'asc' }, { name: 'asc' }],
}),
prisma.catalogProductTypeSetting.findMany({
orderBy: { productType: 'asc' },
}),
]);
const productsByType = new Map();
for (const product of products) {
const productType = normalizeText(product.productType);
if (!productType) {
continue;
}
const existing = productsByType.get(productType);
if (existing) {
existing.push(product);
} else {
productsByType.set(productType, [product]);
}
}
const productTypes = new Set([
...productsByType.keys(),
...persistedSettings.map((item) => item.productType),
]);
const settingsByType = new Map(persistedSettings.map((item) => [item.productType, item]));
return [...productTypes]
.sort((a, b) => a.localeCompare(b, 'ru'))
.map((productType) => {
const persisted = settingsByType.get(productType);
const fallbackOptions = collectProductTypeOptionDefaults(productsByType.get(productType) ?? []);
return presentCatalogProductTypeSetting(persisted ?? {
productType,
showQuantityPerBox: false,
allowCustomLength: false,
customLengthMinM: null,
customLengthMaxM: null,
customLengthStepM: null,
allowCustomSleeveBrand: false,
allowCustomLabel: false,
...fallbackOptions,
});
});
}
const cartInclude = { const cartInclude = {
items: { items: {
orderBy: [{ updatedAt: 'desc' }, { createdAt: 'desc' }], orderBy: [{ updatedAt: 'desc' }, { createdAt: 'desc' }],
@@ -839,6 +1092,8 @@ export const resolvers = {
orderBy: { name: 'asc' }, orderBy: { name: 'asc' },
}), }),
catalogProductTypeSettings: async (_, __, context) => listCatalogProductTypeSettings(context.prisma),
order: async (_, { id }, context) => { order: async (_, { id }, context) => {
const user = requireUser(context); const user = requireUser(context);
const order = await context.prisma.order.findUnique({ const order = await context.prisma.order.findUnique({
@@ -895,49 +1150,11 @@ export const resolvers = {
const managedUsersWhere = await getManagedClientUserWhere(context.prisma, manager); const managedUsersWhere = await getManagedClientUserWhere(context.prisma, manager);
const users = await context.prisma.user.findMany({ const users = await context.prisma.user.findMany({
where: managedUsersWhere, where: managedUsersWhere,
include: { include: managerUserInclude,
messengerConnections: {
where: {
type: 'TELEGRAM',
isActive: true,
},
orderBy: { createdAt: 'desc' },
take: 1,
},
counterpartyProfile: {
select: {
companyName: true,
inn: true,
},
},
clientOrders: {
select: {
createdAt: true,
},
orderBy: { createdAt: 'desc' },
take: 1,
},
_count: {
select: {
clientOrders: true,
},
},
},
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: 'desc' },
}); });
return users.map((user) => ({ return users.map(mapManagerUser);
id: user.id,
email: user.email,
fullName: user.fullName,
role: user.role,
companyName: user.counterpartyProfile?.companyName ?? null,
inn: user.counterpartyProfile?.inn ?? null,
createdAt: user.createdAt,
orderCount: user._count.clientOrders,
lastOrderAt: user.clientOrders[0]?.createdAt ?? null,
telegramConnection: user.messengerConnections[0] ?? null,
}));
}, },
managerOrders: async (_, { status, customerId }, context) => { managerOrders: async (_, { status, customerId }, context) => {
@@ -993,7 +1210,11 @@ export const resolvers = {
const [users, transactionsAgg, pendingWithdrawalsAgg] = await Promise.all([ const [users, transactionsAgg, pendingWithdrawalsAgg] = await Promise.all([
context.prisma.user.findMany({ context.prisma.user.findMany({
where: managedUsersWhere, where: {
...managedUsersWhere,
role: 'CLIENT',
bonusProgramEnabled: true,
},
include: { include: {
counterpartyProfile: { counterpartyProfile: {
select: { select: {
@@ -1037,6 +1258,7 @@ export const resolvers = {
email: user.email, email: user.email,
fullName: user.fullName, fullName: user.fullName,
companyName: user.counterpartyProfile?.companyName ?? null, companyName: user.counterpartyProfile?.companyName ?? null,
bonusProgramEnabled: user.bonusProgramEnabled,
balance: (tx?.balance ?? 0) - pendingWithdrawalAmount, balance: (tx?.balance ?? 0) - pendingWithdrawalAmount,
pendingWithdrawalAmount, pendingWithdrawalAmount,
transactionsCount: tx?.transactionsCount ?? 0, transactionsCount: tx?.transactionsCount ?? 0,
@@ -1124,7 +1346,11 @@ export const resolvers = {
const managedUsersWhere = await getManagedClientUserWhere(context.prisma, manager); const managedUsersWhere = await getManagedClientUserWhere(context.prisma, manager);
const users = await context.prisma.user.findMany({ const users = await context.prisma.user.findMany({
where: managedUsersWhere, where: {
...managedUsersWhere,
role: 'CLIENT',
bonusProgramEnabled: true,
},
select: { select: {
id: true, id: true,
email: true, email: true,
@@ -1227,19 +1453,24 @@ export const resolvers = {
}, },
}, },
}); });
if (!user) {
throw new Error('User is not invited to the cabinet.');
}
const challenge = createLoginChallenge({ const challenge = createLoginChallenge({
userId: user?.id ?? null, userId: user.id,
channel: input.channel, channel: input.channel,
destination, destination,
}); });
const code = getStaticAuthCode(); const code = getStaticAuthCode();
await sendLoginCodeEmail({ if (getLoginCodeDeliveryMode() === 'email') {
to: destination, await sendLoginCodeEmail({
code, to: destination,
expiresAt: challenge.expiresAt, code,
}); expiresAt: challenge.expiresAt,
});
}
return { return {
challengeToken: challenge.challengeToken, challengeToken: challenge.challengeToken,
@@ -1255,24 +1486,9 @@ export const resolvers = {
code: input.code, code: input.code,
}); });
let user = challenge.userId const user = await context.prisma.user.findUnique({
? await context.prisma.user.findUnique({ where: { id: challenge.userId },
where: { id: challenge.userId }, });
})
: null;
if (!user && challenge.channel === 'EMAIL') {
const email = String(challenge.destination).trim().toLowerCase();
user = await context.prisma.user.upsert({
where: { email },
update: {},
create: {
email,
fullName: buildDefaultFullName(email),
role: 'CLIENT',
},
});
}
if (!user) { if (!user) {
throw new Error('User is not available for this login challenge.'); throw new Error('User is not available for this login challenge.');
@@ -1415,6 +1631,19 @@ export const resolvers = {
}); });
}, },
upsertCatalogProductTypeSetting: async (_, { input }, context) => {
requireManagerAccess(context);
const payload = normalizeCatalogProductTypeSettingInput(input);
const persisted = await context.prisma.catalogProductTypeSetting.upsert({
where: { productType: payload.productType },
update: payload,
create: payload,
});
return presentCatalogProductTypeSetting(persisted);
},
addProductToCart: async (_, { productId }, context) => { addProductToCart: async (_, { productId }, context) => {
const user = requireUser(context); const user = requireUser(context);
const normalizedProductId = normalizeText(productId); const normalizedProductId = normalizeText(productId);
@@ -2017,6 +2246,7 @@ export const resolvers = {
select: { select: {
id: true, id: true,
role: true, role: true,
bonusProgramEnabled: true,
}, },
}); });
@@ -2028,6 +2258,10 @@ export const resolvers = {
throw new Error('Referral links can only be created between client accounts.'); throw new Error('Referral links can only be created between client accounts.');
} }
if (users.some((user) => !user.bonusProgramEnabled)) {
throw new Error('Bonus program must be enabled for both selected clients.');
}
const existingRefereeLink = await context.prisma.referralLink.findFirst({ const existingRefereeLink = await context.prisma.referralLink.findFirst({
where: { where: {
refereeId: refereeUserId, refereeId: refereeUserId,
@@ -2052,9 +2286,85 @@ export const resolvers = {
}); });
}, },
setClientBonusProgramEnabled: async (_, { userId, enabled }, context) => {
const manager = requireManagerAccess(context);
await assertManagerCanAccessUser(context.prisma, manager, userId);
const existingUser = await context.prisma.user.findUnique({
where: { id: userId },
select: {
role: true,
},
});
if (!existingUser) {
throw new Error('User was not found.');
}
if (existingUser.role !== 'CLIENT') {
throw new Error('Bonus program can only be configured for client accounts.');
}
const user = await context.prisma.user.update({
where: { id: userId },
data: {
bonusProgramEnabled: enabled,
},
include: managerUserInclude,
});
return mapManagerUser(user);
},
createBonusProgramLink: async (_, { userId }, context) => {
const manager = requireManagerAccess(context);
await assertManagerCanAccessUser(context.prisma, manager, userId);
const user = await context.prisma.user.findUnique({
where: { id: userId },
select: {
role: true,
bonusProgramEnabled: true,
},
});
if (!user) {
throw new Error('User was not found.');
}
if (user.role !== 'CLIENT' || !user.bonusProgramEnabled) {
throw new Error('Bonus program is not enabled for this client.');
}
const issued = issueBonusProgramLinkToken({ userId });
return {
userId,
token: issued.token,
url: buildBonusProgramLinkUrl(issued.token),
expiresAt: issued.expiresAt,
};
},
addBonusTransaction: async (_, { input }, context) => { addBonusTransaction: async (_, { input }, context) => {
const manager = requireManagerAccess(context); const manager = requireManagerAccess(context);
await assertManagerCanAccessUser(context.prisma, manager, input.userId); await assertManagerCanAccessUser(context.prisma, manager, input.userId);
const bonusUser = await context.prisma.user.findUnique({
where: { id: input.userId },
select: {
role: true,
bonusProgramEnabled: true,
},
});
if (!bonusUser) {
throw new Error('User was not found.');
}
if (bonusUser.role !== 'CLIENT' || !bonusUser.bonusProgramEnabled) {
throw new Error('Bonus program is not enabled for this client.');
}
const transaction = await context.prisma.bonusTransaction.create({ const transaction = await context.prisma.bonusTransaction.create({
data: { data: {
userId: input.userId, userId: input.userId,
@@ -2098,6 +2408,10 @@ export const resolvers = {
requestRewardWithdrawal: (_, { input }, context) => { requestRewardWithdrawal: (_, { input }, context) => {
const client = requireUser(context); const client = requireUser(context);
if (!client.bonusProgramEnabled) {
throw new Error('Bonus program is not enabled for this client.');
}
if (input.amount < 100) { if (input.amount < 100) {
throw new Error('Minimum withdrawal amount is 100.'); throw new Error('Minimum withdrawal amount is 100.');
} }

View File

@@ -63,6 +63,7 @@ type User {
email: String! email: String!
fullName: String! fullName: String!
role: UserRole! role: UserRole!
bonusProgramEnabled: Boolean!
company: Company company: Company
} }
@@ -142,6 +143,7 @@ type ManagerUser {
email: String! email: String!
fullName: String! fullName: String!
role: UserRole! role: UserRole!
bonusProgramEnabled: Boolean!
companyName: String companyName: String
inn: String inn: String
createdAt: DateTime! createdAt: DateTime!
@@ -213,6 +215,13 @@ type IntegrationSyncDashboard {
items: [IntegrationSyncItem!]! items: [IntegrationSyncItem!]!
} }
type BonusProgramLink {
userId: ID!
token: String!
url: String!
expiresAt: DateTime!
}
type Warehouse { type Warehouse {
id: ID! id: ID!
code: String! code: String!
@@ -235,11 +244,29 @@ type Product {
thicknessMicron: Int thicknessMicron: Int
sleeveBrand: String sleeveBrand: String
quantityPerBox: String quantityPerBox: String
tags: [String!]!
isCustomizable: Boolean! isCustomizable: Boolean!
isActive: Boolean! isActive: Boolean!
availableInWarehouses: [ProductWarehouseBalance!]! 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 { type CartItem {
id: ID! id: ID!
productId: ID! productId: ID!
@@ -357,6 +384,7 @@ type ManagerBonusBalance {
email: String! email: String!
fullName: String! fullName: String!
companyName: String companyName: String
bonusProgramEnabled: Boolean!
balance: Float! balance: Float!
pendingWithdrawalAmount: Float! pendingWithdrawalAmount: Float!
transactionsCount: Int! transactionsCount: Int!
@@ -403,6 +431,7 @@ type Query {
integrationSyncDashboard: IntegrationSyncDashboard! integrationSyncDashboard: IntegrationSyncDashboard!
managerNotificationHistory(userId: ID!, channel: MessengerType!, limit: Int = 50): [NotificationHistoryItem!]! managerNotificationHistory(userId: ID!, channel: MessengerType!, limit: Int = 50): [NotificationHistoryItem!]!
clientProducts: [Product!]! clientProducts: [Product!]!
catalogProductTypeSettings: [CatalogProductTypeSetting!]!
order(id: ID!): Order order(id: ID!): Order
myOrders: [Order!]! myOrders: [Order!]!
myCurrentOrders: [Order!]! myCurrentOrders: [Order!]!
@@ -483,6 +512,23 @@ input UpdateCartItemQuantityInput {
quantity: Float! 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 { input ReadyOrderItemInput {
productId: ID! productId: ID!
quantity: Float! quantity: Float!
@@ -546,6 +592,7 @@ type Mutation {
connectMessenger(input: ConnectMessengerInput!): MessengerConnection! connectMessenger(input: ConnectMessengerInput!): MessengerConnection!
deleteMyMessengerConnection(connectionId: ID!): Boolean! deleteMyMessengerConnection(connectionId: ID!): Boolean!
upsertMyCounterpartyProfile(input: UpsertMyCounterpartyProfileInput!): CounterpartyProfile! upsertMyCounterpartyProfile(input: UpsertMyCounterpartyProfileInput!): CounterpartyProfile!
upsertCatalogProductTypeSetting(input: UpsertCatalogProductTypeSettingInput!): CatalogProductTypeSetting!
addProductToCart(productId: ID!): Cart! addProductToCart(productId: ID!): Cart!
updateCartItemQuantity(input: UpdateCartItemQuantityInput!): Cart! updateCartItemQuantity(input: UpdateCartItemQuantityInput!): Cart!
removeCartItem(productId: ID!): Cart! removeCartItem(productId: ID!): Cart!
@@ -563,6 +610,8 @@ type Mutation {
clientReviewOrder(orderId: ID!, decision: Decision!): Order! clientReviewOrder(orderId: ID!, decision: Decision!): Order!
createReferral(input: CreateReferralInput!): ReferralLink! createReferral(input: CreateReferralInput!): ReferralLink!
setClientBonusProgramEnabled(userId: ID!, enabled: Boolean!): ManagerUser!
createBonusProgramLink(userId: ID!): BonusProgramLink!
addBonusTransaction(input: AddBonusTransactionInput!): BonusTransaction! addBonusTransaction(input: AddBonusTransactionInput!): BonusTransaction!
requestRewardWithdrawal(input: RequestRewardWithdrawalInput!): RewardWithdrawalRequest! requestRewardWithdrawal(input: RequestRewardWithdrawalInput!): RewardWithdrawalRequest!
reviewRewardWithdrawal(input: ReviewRewardWithdrawalInput!): RewardWithdrawalRequest! reviewRewardWithdrawal(input: ReviewRewardWithdrawalInput!): RewardWithdrawalRequest!

View File

@@ -17,6 +17,7 @@ import {
hasMessengerStartSession, hasMessengerStartSession,
issueAccessToken, issueAccessToken,
issueTemporaryLoginToken, issueTemporaryLoginToken,
verifyBonusProgramLinkToken,
verifyAccessToken, verifyAccessToken,
} from './auth.js'; } from './auth.js';
import { canManagerAccessUser, isManagerRole } from './access.js'; import { canManagerAccessUser, isManagerRole } from './access.js';
@@ -103,6 +104,10 @@ function normalizeRedirectPath(value) {
return redirectPath; return redirectPath;
} }
function normalizeTargetApp(value) {
return String(value || '').trim().toUpperCase() === 'BONUS' ? 'BONUS' : 'MAIN';
}
function presentTelegramMiniAppUser(user) { function presentTelegramMiniAppUser(user) {
return presentMiniAppUser(user, 'Пользователь Telegram'); return presentMiniAppUser(user, 'Пользователь Telegram');
} }
@@ -160,6 +165,7 @@ app.post('/auth/messenger-start', async (req, res) => {
const email = authenticatedUser?.email?.trim().toLowerCase() || providedEmail; const email = authenticatedUser?.email?.trim().toLowerCase() || providedEmail;
const userId = authenticatedUser?.id ?? null; const userId = authenticatedUser?.id ?? null;
const redirectPath = normalizeRedirectPath(req.body?.redirectPath); const redirectPath = normalizeRedirectPath(req.body?.redirectPath);
const targetApp = normalizeTargetApp(req.body?.targetApp);
if (!userId && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { if (!userId && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
res.status(400).json({ error: 'A valid email is required.' }); res.status(400).json({ error: 'A valid email is required.' });
@@ -171,6 +177,7 @@ app.post('/auth/messenger-start', async (req, res) => {
email, email,
userId, userId,
redirectPath, redirectPath,
targetApp,
}); });
res.json({ res.json({
@@ -181,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) => { app.post('/auth/telegram-mini-app/session', async (req, res) => {
let telegram; let telegram;
try { try {
@@ -409,23 +453,37 @@ app.post('/bot/messenger-login', async (req, res) => {
: normalizeMaxProfile(req.body?.profile)), : normalizeMaxProfile(req.body?.profile)),
}, },
}); });
const frontendUrl = ( const mainFrontendUrl = (
process.env.WEB_FRONTEND_URL || process.env.WEB_FRONTEND_URL ||
process.env.NUXT_PUBLIC_SITE_URL || process.env.NUXT_PUBLIC_SITE_URL ||
'http://localhost:3000' 'http://localhost:3000'
).replace(/\/$/, ''); ).replace(/\/$/, '');
const nextPath = startSession.redirectPath || ( const bonusFrontendUrl = String(
channel === 'TELEGRAM' || channel === 'MAX' process.env.BONUS_FRONTEND_URL ||
? `/profile/notifications/success?connected=${channel.toLowerCase()}` process.env.BONUS_PUBLIC_BASE_URL ||
: '' '',
); ).trim().replace(/\/$/, '');
const loginQuery = new URLSearchParams({ const frontendUrl = startSession.targetApp === 'BONUS'
login_token: login.loginToken, ? (bonusFrontendUrl || mainFrontendUrl)
}); : mainFrontendUrl;
if (nextPath) {
loginQuery.set('next', nextPath); 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) { if (!skipDispatch) {
const template = buildMessengerLoginTemplate({ const template = buildMessengerLoginTemplate({
@@ -527,7 +585,7 @@ app.use(
const port = Number(process.env.PORT ?? 4000); const port = Number(process.env.PORT ?? 4000);
app.listen(port, () => { 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() { async function shutdown() {