Compare commits

8 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
18 changed files with 2937 additions and 88 deletions

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
# Fregat Apollo Backend
# Fregat Backend
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",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "fregat-apollo-backend",
"name": "fregat-backend",
"version": "0.1.0",
"license": "ISC",
"dependencies": {

View File

@@ -1,5 +1,5 @@
{
"name": "fregat-apollo-backend",
"name": "fregat-backend",
"version": "0.1.0",
"description": "",
"main": "index.js",
@@ -11,7 +11,8 @@
"prisma:migrate": "prisma migrate deploy",
"prisma:push": "prisma db push",
"seed": "node scripts/seed.js",
"seed:demo": "node scripts/seed-demo.js"
"seed:demo": "node scripts/seed-demo.js",
"import:catalog": "sh -c '. /app/scripts/load-vault-env.sh && node scripts/import-catalog.js'"
},
"keywords": [],
"author": "",

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
fullName String
role UserRole
bonusProgramEnabled Boolean @default(false)
companyId String?
company Company? @relation(fields: [companyId], references: [id])
counterpartyProfile CounterpartyProfile?
@@ -179,6 +180,7 @@ model Product {
thicknessMicron Int?
sleeveBrand String?
quantityPerBox String?
tags String[] @default([])
description String?
isCustomizable Boolean @default(false)
isActive Boolean @default(true)
@@ -189,6 +191,26 @@ model Product {
updatedAt DateTime @updatedAt
}
model CatalogProductTypeSetting {
id String @id @default(cuid())
productType String @unique
showQuantityPerBox Boolean @default(false)
allowCustomLength Boolean @default(false)
customLengthMinM Int?
customLengthMaxM Int?
customLengthStepM Int?
allowCustomSleeveBrand Boolean @default(false)
allowCustomLabel Boolean @default(false)
widthOptionsMm Int[] @default([])
lengthOptionsM Int[] @default([])
thicknessOptionsMicron Int[] @default([])
sleeveOptions String[] @default([])
colorOptions String[] @default([])
labelOptions String[] @default([])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Cart {
id String @id @default(cuid())
userId String @unique

105
scripts/import-catalog.js Normal file
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: {
fullName: fullNameForIndex(index),
role: 'CLIENT',
bonusProgramEnabled: index % 2 === 1,
companyId: company.id,
},
create: {
email: buildClientEmail(index),
fullName: fullNameForIndex(index),
role: 'CLIENT',
bonusProgramEnabled: index % 2 === 1,
companyId: company.id,
},
});

View File

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

View File

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

View File

@@ -30,6 +30,16 @@ import { fetchTelegramConnectionProfile } from './telegram.js';
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) {
return value == null ? null : Number(value);
}
@@ -138,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) {
const referralLink = await prisma.referralLink.findFirst({
where: {
refereeId: order.customerId,
referrer: {
bonusProgramEnabled: true,
},
referee: {
bonusProgramEnabled: true,
},
},
include: {
referrer: {
@@ -320,20 +381,6 @@ function invitationToken() {
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) {
return String(value ?? '').trim();
}
@@ -352,6 +399,19 @@ function normalizeQuantityValue(value) {
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) {
if (!profile) {
return false;
@@ -418,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 = {
items: {
orderBy: [{ updatedAt: 'desc' }, { createdAt: 'desc' }],
@@ -864,6 +1092,8 @@ export const resolvers = {
orderBy: { name: 'asc' },
}),
catalogProductTypeSettings: async (_, __, context) => listCatalogProductTypeSettings(context.prisma),
order: async (_, { id }, context) => {
const user = requireUser(context);
const order = await context.prisma.order.findUnique({
@@ -920,49 +1150,11 @@ export const resolvers = {
const managedUsersWhere = await getManagedClientUserWhere(context.prisma, manager);
const users = await context.prisma.user.findMany({
where: managedUsersWhere,
include: {
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,
},
},
},
include: managerUserInclude,
orderBy: { createdAt: 'desc' },
});
return users.map((user) => ({
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,
}));
return users.map(mapManagerUser);
},
managerOrders: async (_, { status, customerId }, context) => {
@@ -1018,7 +1210,11 @@ export const resolvers = {
const [users, transactionsAgg, pendingWithdrawalsAgg] = await Promise.all([
context.prisma.user.findMany({
where: managedUsersWhere,
where: {
...managedUsersWhere,
role: 'CLIENT',
bonusProgramEnabled: true,
},
include: {
counterpartyProfile: {
select: {
@@ -1062,6 +1258,7 @@ export const resolvers = {
email: user.email,
fullName: user.fullName,
companyName: user.counterpartyProfile?.companyName ?? null,
bonusProgramEnabled: user.bonusProgramEnabled,
balance: (tx?.balance ?? 0) - pendingWithdrawalAmount,
pendingWithdrawalAmount,
transactionsCount: tx?.transactionsCount ?? 0,
@@ -1149,7 +1346,11 @@ export const resolvers = {
const managedUsersWhere = await getManagedClientUserWhere(context.prisma, manager);
const users = await context.prisma.user.findMany({
where: managedUsersWhere,
where: {
...managedUsersWhere,
role: 'CLIENT',
bonusProgramEnabled: true,
},
select: {
id: true,
email: true,
@@ -1252,19 +1453,24 @@ export const resolvers = {
},
},
});
if (!user) {
throw new Error('User is not invited to the cabinet.');
}
const challenge = createLoginChallenge({
userId: user?.id ?? null,
userId: user.id,
channel: input.channel,
destination,
});
const code = getStaticAuthCode();
if (getLoginCodeDeliveryMode() === 'email') {
await sendLoginCodeEmail({
to: destination,
code,
expiresAt: challenge.expiresAt,
});
}
return {
challengeToken: challenge.challengeToken,
@@ -1280,24 +1486,9 @@ export const resolvers = {
code: input.code,
});
let user = challenge.userId
? await context.prisma.user.findUnique({
const user = await context.prisma.user.findUnique({
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) {
throw new Error('User is not available for this login challenge.');
@@ -1440,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) => {
const user = requireUser(context);
const normalizedProductId = normalizeText(productId);
@@ -2042,6 +2246,7 @@ export const resolvers = {
select: {
id: true,
role: true,
bonusProgramEnabled: true,
},
});
@@ -2053,6 +2258,10 @@ export const resolvers = {
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({
where: {
refereeId: refereeUserId,
@@ -2077,10 +2286,56 @@ 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 {
@@ -2094,6 +2349,22 @@ export const resolvers = {
addBonusTransaction: async (_, { input }, context) => {
const manager = requireManagerAccess(context);
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({
data: {
userId: input.userId,
@@ -2137,6 +2408,10 @@ export const resolvers = {
requestRewardWithdrawal: (_, { input }, context) => {
const client = requireUser(context);
if (!client.bonusProgramEnabled) {
throw new Error('Bonus program is not enabled for this client.');
}
if (input.amount < 100) {
throw new Error('Minimum withdrawal amount is 100.');
}

View File

@@ -63,6 +63,7 @@ type User {
email: String!
fullName: String!
role: UserRole!
bonusProgramEnabled: Boolean!
company: Company
}
@@ -142,6 +143,7 @@ type ManagerUser {
email: String!
fullName: String!
role: UserRole!
bonusProgramEnabled: Boolean!
companyName: String
inn: String
createdAt: DateTime!
@@ -242,11 +244,29 @@ type Product {
thicknessMicron: Int
sleeveBrand: String
quantityPerBox: String
tags: [String!]!
isCustomizable: Boolean!
isActive: Boolean!
availableInWarehouses: [ProductWarehouseBalance!]!
}
type CatalogProductTypeSetting {
productType: String!
showQuantityPerBox: Boolean!
allowCustomLength: Boolean!
customLengthMinM: Int
customLengthMaxM: Int
customLengthStepM: Int
allowCustomSleeveBrand: Boolean!
allowCustomLabel: Boolean!
widthOptionsMm: [Int!]!
lengthOptionsM: [Int!]!
thicknessOptionsMicron: [Int!]!
sleeveOptions: [String!]!
colorOptions: [String!]!
labelOptions: [String!]!
}
type CartItem {
id: ID!
productId: ID!
@@ -364,6 +384,7 @@ type ManagerBonusBalance {
email: String!
fullName: String!
companyName: String
bonusProgramEnabled: Boolean!
balance: Float!
pendingWithdrawalAmount: Float!
transactionsCount: Int!
@@ -410,6 +431,7 @@ type Query {
integrationSyncDashboard: IntegrationSyncDashboard!
managerNotificationHistory(userId: ID!, channel: MessengerType!, limit: Int = 50): [NotificationHistoryItem!]!
clientProducts: [Product!]!
catalogProductTypeSettings: [CatalogProductTypeSetting!]!
order(id: ID!): Order
myOrders: [Order!]!
myCurrentOrders: [Order!]!
@@ -490,6 +512,23 @@ input UpdateCartItemQuantityInput {
quantity: Float!
}
input UpsertCatalogProductTypeSettingInput {
productType: String!
showQuantityPerBox: Boolean!
allowCustomLength: Boolean!
customLengthMinM: Int
customLengthMaxM: Int
customLengthStepM: Int
allowCustomSleeveBrand: Boolean!
allowCustomLabel: Boolean!
widthOptionsMm: [Int!]!
lengthOptionsM: [Int!]!
thicknessOptionsMicron: [Int!]!
sleeveOptions: [String!]!
colorOptions: [String!]!
labelOptions: [String!]!
}
input ReadyOrderItemInput {
productId: ID!
quantity: Float!
@@ -553,6 +592,7 @@ type Mutation {
connectMessenger(input: ConnectMessengerInput!): MessengerConnection!
deleteMyMessengerConnection(connectionId: ID!): Boolean!
upsertMyCounterpartyProfile(input: UpsertMyCounterpartyProfileInput!): CounterpartyProfile!
upsertCatalogProductTypeSetting(input: UpsertCatalogProductTypeSettingInput!): CatalogProductTypeSetting!
addProductToCart(productId: ID!): Cart!
updateCartItemQuantity(input: UpdateCartItemQuantityInput!): Cart!
removeCartItem(productId: ID!): Cart!
@@ -570,6 +610,7 @@ type Mutation {
clientReviewOrder(orderId: ID!, decision: Decision!): Order!
createReferral(input: CreateReferralInput!): ReferralLink!
setClientBonusProgramEnabled(userId: ID!, enabled: Boolean!): ManagerUser!
createBonusProgramLink(userId: ID!): BonusProgramLink!
addBonusTransaction(input: AddBonusTransactionInput!): BonusTransaction!
requestRewardWithdrawal(input: RequestRewardWithdrawalInput!): RewardWithdrawalRequest!

View File

@@ -585,7 +585,7 @@ app.use(
const port = Number(process.env.PORT ?? 4000);
app.listen(port, () => {
console.log(`apollo-backend running at http://localhost:${port}/graphql`);
console.log(`backend running at http://localhost:${port}/graphql`);
});
async function shutdown() {