Compare commits
9 Commits
92592e2baa
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fcc2eb7450 | ||
|
|
c641a3dd23 | ||
|
|
47ba203edc | ||
|
|
4d46174bbb | ||
|
|
0103c3fb8a | ||
|
|
2cd8d0b612 | ||
|
|
da31e21406 | ||
|
|
40b4515305 | ||
|
|
b321075293 |
@@ -14,3 +14,4 @@ SMTP_SECURE=false
|
|||||||
SMTP_USER=
|
SMTP_USER=
|
||||||
SMTP_PASS=
|
SMTP_PASS=
|
||||||
SMTP_FROM=
|
SMTP_FROM=
|
||||||
|
AUTH_LOGIN_CODE_DELIVERY=email
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
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",
|
"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": {
|
||||||
|
|||||||
@@ -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": "",
|
||||||
|
|||||||
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
|
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
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: {
|
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,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
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_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();
|
||||||
|
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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'),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
460
src/resolvers.js
460
src/resolvers.js
@@ -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();
|
||||||
|
if (getLoginCodeDeliveryMode() === 'email') {
|
||||||
await sendLoginCodeEmail({
|
await sendLoginCodeEmail({
|
||||||
to: destination,
|
to: destination,
|
||||||
code,
|
code,
|
||||||
expiresAt: challenge.expiresAt,
|
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.');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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!
|
||||||
|
|||||||
@@ -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 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 || (
|
const nextPath = startSession.redirectPath || (
|
||||||
channel === 'TELEGRAM' || channel === 'MAX'
|
channel === 'TELEGRAM' || channel === 'MAX'
|
||||||
? `/profile/notifications/success?connected=${channel.toLowerCase()}`
|
? `/profile/notifications/success?connected=${channel.toLowerCase()}`
|
||||||
: ''
|
: ''
|
||||||
);
|
);
|
||||||
|
if (nextPath) {
|
||||||
const loginQuery = new URLSearchParams({
|
const loginQuery = new URLSearchParams({
|
||||||
login_token: login.loginToken,
|
login_token: login.loginToken,
|
||||||
|
next: nextPath,
|
||||||
});
|
});
|
||||||
if (nextPath) {
|
loginUrl = `${frontendUrl}/login?${loginQuery.toString()}`;
|
||||||
loginQuery.set('next', nextPath);
|
}
|
||||||
}
|
}
|
||||||
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() {
|
||||||
|
|||||||
Reference in New Issue
Block a user