Scaffold Apollo backend domain API

This commit is contained in:
Ruslan Bakiev
2026-03-30 21:41:06 +07:00
parent 498b800bc4
commit 6a6c6bfdbb
16 changed files with 4539 additions and 1 deletions

9
.env.example Normal file
View File

@@ -0,0 +1,9 @@
PORT=4000
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/fregat
VAULT_ENABLED=auto
VAULT_ADDR=
VAULT_TOKEN=
VAULT_KV_MOUNT=secret
VAULT_SHARED_PATH=
VAULT_PROJECT_PATH=

3
.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
node_modules
.env
.output

15
Dockerfile Normal file
View File

@@ -0,0 +1,15 @@
FROM node:22-bookworm-slim
WORKDIR /app
RUN apt-get update -y && apt-get install -y --no-install-recommends openssl curl jq ca-certificates && rm -rf /var/lib/apt/lists/*
COPY package*.json ./
COPY prisma ./prisma
COPY prisma.config.ts ./
RUN npm ci && npx prisma generate
COPY src ./src
COPY scripts ./scripts
CMD ["sh", "-c", ". /app/scripts/load-vault-env.sh && npx prisma migrate deploy && node src/server.js"]

View File

@@ -1,2 +1,33 @@
# apollo-backend # Fregat Apollo Backend
GraphQL backend for Fregat client cabinet and manager cabinet.
## Main capabilities
- Client self-registration requests and manager review flow.
- Invite-based registration flow.
- Catalog and stock by warehouses.
- Ready-order and calculation-order flows.
- Dual approval flow (client + manager), blocking and lifecycle statuses.
- Referral links, bonus transactions and withdrawal requests.
## Local run
```bash
cp .env.example .env
npm ci
npm run prisma:generate
npm run prisma:push
npm run seed
npm run dev
```
GraphQL endpoint: `http://localhost:4000/graphql`
## Auth model (temporary)
Pass `x-user-id` header with an existing user id.
## Deploy
Container entrypoint loads env from Vault and then applies `prisma migrate deploy` before start.

3083
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

36
package.json Normal file
View File

@@ -0,0 +1,36 @@
{
"name": "fregat-apollo-backend",
"version": "0.1.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"start": "node src/server.js",
"dev": "node --watch src/server.js",
"prisma:generate": "prisma generate",
"prisma:migrate": "prisma migrate deploy",
"prisma:push": "prisma db push",
"seed": "node scripts/seed.js"
},
"keywords": [],
"author": "",
"license": "ISC",
"private": "true",
"type": "module",
"dependencies": {
"@apollo/server": "^5.5.0",
"@as-integrations/express5": "^1.1.2",
"@prisma/adapter-pg": "^7.6.0",
"@prisma/client": "^7.6.0",
"body-parser": "^2.2.2",
"cors": "^2.8.6",
"dotenv": "^17.3.1",
"express": "^5.2.1",
"graphql": "^16.13.2",
"pg": "^8.20.0",
"zod": "^4.3.6"
},
"devDependencies": {
"prisma": "^7.6.0"
}
}

13
prisma.config.ts Normal file
View File

@@ -0,0 +1,13 @@
import 'dotenv/config';
import { defineConfig, env } from 'prisma/config';
export default defineConfig({
schema: 'prisma/schema.prisma',
migrations: {
path: 'prisma/migrations',
},
datasource: {
url: env('DATABASE_URL'),
},
});

233
prisma/schema.prisma Normal file
View File

@@ -0,0 +1,233 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
}
enum UserRole {
CLIENT
MANAGER
}
enum RegistrationStatus {
PENDING
APPROVED
REJECTED
}
enum MessengerType {
TELEGRAM
MAX
}
enum OrderKind {
READY
CALCULATION
}
enum OrderStatus {
NEW
MANAGER_PROCESSING
WAITING_DOUBLE_CONFIRM
CLIENT_REJECTED
MANAGER_REJECTED
MANAGER_BLOCKED
CONFIRMED
IN_PROGRESS
COMPLETED
}
enum WithdrawalStatus {
PENDING
APPROVED
REJECTED
}
model Company {
id String @id @default(cuid())
name String @unique
inn String? @unique
users User[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model User {
id String @id @default(cuid())
email String @unique
fullName String
role UserRole
companyId String?
company Company? @relation(fields: [companyId], references: [id])
registrationRequests RegistrationRequest[] @relation("RegistrationRequester")
reviewedRequests RegistrationRequest[] @relation("RegistrationReviewer")
sentInvitations Invitation[] @relation("InvitationManager")
acceptedInvitations Invitation[] @relation("InvitationAcceptor")
messengerConnections MessengerConnection[]
clientOrders Order[] @relation("OrderClient")
managerOrders Order[] @relation("OrderManager")
orderStatusEvents OrderStatusEvent[]
referralAsReferrer ReferralLink[] @relation("ReferralReferrer")
referralAsReferee ReferralLink[] @relation("ReferralReferee")
bonusTransactions BonusTransaction[]
withdrawalRequests RewardWithdrawalRequest[] @relation("WithdrawalRequester")
reviewedWithdrawals RewardWithdrawalRequest[] @relation("WithdrawalReviewer")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model RegistrationRequest {
id String @id @default(cuid())
companyName String
inn String?
contactName String
email String
status RegistrationStatus @default(PENDING)
rejectionReason String?
requesterId String?
requester User? @relation("RegistrationRequester", fields: [requesterId], references: [id])
reviewedById String?
reviewedBy User? @relation("RegistrationReviewer", fields: [reviewedById], references: [id])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Invitation {
id String @id @default(cuid())
token String @unique
email String
companyName String
managerId String
manager User @relation("InvitationManager", fields: [managerId], references: [id])
acceptedById String?
acceptedBy User? @relation("InvitationAcceptor", fields: [acceptedById], references: [id])
expiresAt DateTime
acceptedAt DateTime?
createdAt DateTime @default(now())
}
model MessengerConnection {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id])
type MessengerType
channelId String
isActive Boolean @default(true)
createdAt DateTime @default(now())
@@unique([userId, type, channelId])
}
model Product {
id String @id @default(cuid())
sku String @unique
name String
description String?
isCustomizable Boolean @default(false)
isActive Boolean @default(true)
inventory ProductStock[]
orderItems OrderItem[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Warehouse {
id String @id @default(cuid())
code String @unique
name String
inventory ProductStock[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model ProductStock {
id String @id @default(cuid())
productId String
product Product @relation(fields: [productId], references: [id])
warehouseId String
warehouse Warehouse @relation(fields: [warehouseId], references: [id])
availableQty Decimal @db.Decimal(14, 3)
updatedAt DateTime @updatedAt
@@unique([productId, warehouseId])
}
model Order {
id String @id @default(cuid())
code String @unique
kind OrderKind
customerId String
customer User @relation("OrderClient", fields: [customerId], references: [id])
managerId String?
manager User? @relation("OrderManager", fields: [managerId], references: [id])
status OrderStatus @default(NEW)
clientApproved Boolean?
managerApproved Boolean?
blockReason String?
deliveryTerms String?
deliveryFee Decimal? @db.Decimal(14, 2)
totalPrice Decimal? @db.Decimal(14, 2)
calculationPayload Json?
items OrderItem[]
history OrderStatusEvent[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model OrderItem {
id String @id @default(cuid())
orderId String
order Order @relation(fields: [orderId], references: [id])
productId String?
product Product? @relation(fields: [productId], references: [id])
productName String
quantity Decimal @db.Decimal(14, 3)
createdAt DateTime @default(now())
}
model OrderStatusEvent {
id String @id @default(cuid())
orderId String
order Order @relation(fields: [orderId], references: [id])
status OrderStatus
actorUserId String
actorUser User @relation(fields: [actorUserId], references: [id])
note String?
createdAt DateTime @default(now())
}
model ReferralLink {
id String @id @default(cuid())
referrerId String
referrer User @relation("ReferralReferrer", fields: [referrerId], references: [id])
refereeId String
referee User @relation("ReferralReferee", fields: [refereeId], references: [id])
createdAt DateTime @default(now())
@@unique([referrerId, refereeId])
}
model BonusTransaction {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id])
amount Decimal @db.Decimal(14, 2)
reason String
orderId String?
createdAt DateTime @default(now())
}
model RewardWithdrawalRequest {
id String @id @default(cuid())
requesterId String
requester User @relation("WithdrawalRequester", fields: [requesterId], references: [id])
amount Decimal @db.Decimal(14, 2)
status WithdrawalStatus @default(PENDING)
reviewedById String?
reviewedBy User? @relation("WithdrawalReviewer", fields: [reviewedById], references: [id])
reviewComment String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}

34
scripts/load-vault-env.sh Executable file
View File

@@ -0,0 +1,34 @@
#!/bin/sh
set -eu
if [ "${VAULT_ENABLED:-auto}" = "false" ] || [ "${VAULT_ENABLED:-auto}" = "0" ]; then
exit 0
fi
if [ -z "${VAULT_ADDR:-}" ] || [ -z "${VAULT_TOKEN:-}" ]; then
if [ "${VAULT_ENABLED:-auto}" = "true" ] || [ "${VAULT_ENABLED:-auto}" = "1" ]; then
echo "VAULT_ENABLED=true but VAULT_ADDR/VAULT_TOKEN are not set" >&2
exit 1
fi
exit 0
fi
if ! command -v curl >/dev/null 2>&1 || ! command -v jq >/dev/null 2>&1; then
echo "Vault bootstrap requires curl and jq." >&2
exit 1
fi
kv_mount="${VAULT_KV_MOUNT:-secret}"
load_path() {
path="$1"
[ -z "$path" ] && return 0
payload="$(curl -fsS -H "X-Vault-Token: ${VAULT_TOKEN}" "${VAULT_ADDR%/}/v1/${kv_mount}/data/${path}")"
echo "$payload" | jq -r '.data.data // {} | to_entries[] | "\(.key)=\(.value|tostring)"' | while IFS='=' read -r k v; do
export "$k=$v"
done
}
load_path "${VAULT_SHARED_PATH:-}"
load_path "${VAULT_PROJECT_PATH:-}"

119
scripts/seed.js Normal file
View File

@@ -0,0 +1,119 @@
import 'dotenv/config';
import { prisma } from '../src/prisma-client.js';
const managerEmail = 'manager@fregat.local';
const clientEmail = 'client@fregat.local';
const company = await prisma.company.upsert({
where: { inn: '7701000000' },
update: {},
create: {
name: 'Fregat Client LLC',
inn: '7701000000',
},
});
const manager = await prisma.user.upsert({
where: { email: managerEmail },
update: { fullName: 'Default Manager' },
create: {
email: managerEmail,
fullName: 'Default Manager',
role: 'MANAGER',
},
});
await prisma.user.upsert({
where: { email: clientEmail },
update: { fullName: 'Demo Client', companyId: company.id },
create: {
email: clientEmail,
fullName: 'Demo Client',
role: 'CLIENT',
companyId: company.id,
},
});
const warehouseMain = await prisma.warehouse.upsert({
where: { code: 'MSK-01' },
update: { name: 'Main warehouse' },
create: { code: 'MSK-01', name: 'Main warehouse' },
});
const warehouseReserve = await prisma.warehouse.upsert({
where: { code: 'SPB-01' },
update: { name: 'Reserve warehouse' },
create: { code: 'SPB-01', name: 'Reserve warehouse' },
});
const products = [
{
sku: 'FILM-100',
name: 'Пленка прозрачная 100мкм',
description: 'Готовая продукция для стандартной упаковки',
isCustomizable: false,
qtyMain: 1250,
qtyReserve: 420,
},
{
sku: 'FILM-200-COLOR',
name: 'Пленка цветная 200мкм',
description: 'Продукция с дополнительной окраской',
isCustomizable: true,
qtyMain: 860,
qtyReserve: 230,
},
];
for (const product of products) {
const dbProduct = await prisma.product.upsert({
where: { sku: product.sku },
update: {
name: product.name,
description: product.description,
isCustomizable: product.isCustomizable,
isActive: true,
},
create: {
sku: product.sku,
name: product.name,
description: product.description,
isCustomizable: product.isCustomizable,
isActive: true,
},
});
await prisma.productStock.upsert({
where: {
productId_warehouseId: {
productId: dbProduct.id,
warehouseId: warehouseMain.id,
},
},
update: { availableQty: product.qtyMain },
create: {
productId: dbProduct.id,
warehouseId: warehouseMain.id,
availableQty: product.qtyMain,
},
});
await prisma.productStock.upsert({
where: {
productId_warehouseId: {
productId: dbProduct.id,
warehouseId: warehouseReserve.id,
},
},
update: { availableQty: product.qtyReserve },
create: {
productId: dbProduct.id,
warehouseId: warehouseReserve.id,
availableQty: product.qtyReserve,
},
});
}
console.log('Seed complete. Use manager header x-user-id:', manager.id);
await prisma.$disconnect();

10
src/context.js Normal file
View File

@@ -0,0 +1,10 @@
import { prisma } from './prisma-client.js';
export async function buildContext(req) {
const userId = req.headers['x-user-id'];
const user = userId
? await prisma.user.findUnique({ where: { id: String(userId) } })
: null;
return { prisma, user };
}

11
src/prisma-client.js Normal file
View File

@@ -0,0 +1,11 @@
import { PrismaPg } from '@prisma/adapter-pg';
import { PrismaClient } from '@prisma/client';
const connectionString = process.env.DATABASE_URL;
if (!connectionString) {
throw new Error('DATABASE_URL is required for Prisma connection');
}
const adapter = new PrismaPg({ connectionString });
export const prisma = new PrismaClient({ adapter });

553
src/resolvers.js Normal file
View File

@@ -0,0 +1,553 @@
import crypto from 'node:crypto';
import { dateTimeScalar, jsonScalar } from './scalars.js';
const ACTIVE_ORDER_STATUSES = ['NEW', 'MANAGER_PROCESSING', 'WAITING_DOUBLE_CONFIRM', 'CONFIRMED', 'IN_PROGRESS'];
function toFloat(value) {
return value == null ? null : Number(value);
}
function requireUser(context) {
if (!context.user) {
throw new Error('Authentication required. Pass x-user-id header.');
}
return context.user;
}
function requireRole(context, role) {
const user = requireUser(context);
if (user.role !== role) {
throw new Error(`Only ${role} can perform this operation.`);
}
return user;
}
async function appendOrderEvent(prisma, orderId, status, actorUserId, note = null) {
return prisma.orderStatusEvent.create({
data: {
orderId,
status,
actorUserId,
note,
},
});
}
function orderCode() {
return `FR-${Date.now()}-${crypto.randomInt(1000, 9999)}`;
}
function invitationToken() {
return crypto.randomBytes(24).toString('hex');
}
export const resolvers = {
DateTime: dateTimeScalar,
JSON: jsonScalar,
Query: {
healthcheck: () => 'ok',
me: (_, __, context) => context.user,
clientProducts: (_, __, context) =>
context.prisma.product.findMany({
where: { isActive: true },
include: {
inventory: {
include: { warehouse: true },
},
},
orderBy: { name: 'asc' },
}),
myOrders: (_, __, context) => {
const user = requireUser(context);
return context.prisma.order.findMany({
where: user.role === 'MANAGER' ? { managerId: user.id } : { customerId: user.id },
include: {
items: true,
history: { orderBy: { createdAt: 'desc' } },
},
orderBy: { createdAt: 'desc' },
});
},
myCurrentOrders: (_, __, context) => {
const user = requireUser(context);
return context.prisma.order.findMany({
where: {
...(user.role === 'MANAGER' ? { managerId: user.id } : { customerId: user.id }),
status: { in: ACTIVE_ORDER_STATUSES },
},
include: {
items: true,
history: { orderBy: { createdAt: 'desc' } },
},
orderBy: { createdAt: 'desc' },
});
},
managerOrders: (_, { status }, context) => {
const manager = requireRole(context, 'MANAGER');
return context.prisma.order.findMany({
where: {
managerId: manager.id,
...(status ? { status } : {}),
},
include: {
items: true,
history: { orderBy: { createdAt: 'desc' } },
},
orderBy: { createdAt: 'desc' },
});
},
registrationRequests: (_, { status }, context) => {
requireRole(context, 'MANAGER');
return context.prisma.registrationRequest.findMany({
where: status ? { status } : undefined,
orderBy: { createdAt: 'desc' },
});
},
referralStats: async (_, __, context) => {
const user = requireUser(context);
const [links, transactions, pendingWithdrawals] = await Promise.all([
context.prisma.referralLink.count({ where: { referrerId: user.id } }),
context.prisma.bonusTransaction.findMany({
where: { userId: user.id },
orderBy: { createdAt: 'desc' },
}),
context.prisma.rewardWithdrawalRequest.findMany({
where: {
requesterId: user.id,
status: 'PENDING',
},
orderBy: { createdAt: 'desc' },
}),
]);
const txSum = transactions.reduce((acc, tx) => acc + Number(tx.amount), 0);
const pendingSum = pendingWithdrawals.reduce((acc, tx) => acc + Number(tx.amount), 0);
return {
referrerId: user.id,
availableBalance: txSum - pendingSum,
referralsCount: links,
transactions,
pendingWithdrawals,
};
},
},
Mutation: {
registerSelf: (_, { input }, context) =>
context.prisma.registrationRequest.create({
data: {
companyName: input.companyName,
inn: input.inn,
contactName: input.contactName,
email: input.email,
status: 'PENDING',
},
}),
reviewRegistrationRequest: async (_, { input }, context) => {
const manager = requireRole(context, 'MANAGER');
return context.prisma.registrationRequest.update({
where: { id: input.requestId },
data: {
status: input.decision === 'APPROVE' ? 'APPROVED' : 'REJECTED',
rejectionReason: input.decision === 'REJECT' ? input.rejectionReason ?? 'Rejected by manager' : null,
reviewedById: manager.id,
},
});
},
createInvitation: async (_, { input }, context) => {
const manager = requireRole(context, 'MANAGER');
const expiresInDays = input.expiresInDays > 0 ? input.expiresInDays : 7;
const expiresAt = new Date(Date.now() + expiresInDays * 24 * 60 * 60 * 1000);
return context.prisma.invitation.create({
data: {
token: invitationToken(),
email: input.email,
companyName: input.companyName,
managerId: manager.id,
expiresAt,
},
});
},
acceptInvitation: async (_, { input }, context) => {
const invitation = await context.prisma.invitation.findUnique({ where: { token: input.token } });
if (!invitation) {
throw new Error('Invitation token is invalid.');
}
if (invitation.acceptedAt) {
throw new Error('Invitation has already been used.');
}
if (invitation.expiresAt < new Date()) {
throw new Error('Invitation has expired.');
}
const company = await context.prisma.company.upsert({
where: { name: invitation.companyName },
update: {},
create: { name: invitation.companyName },
});
const user = await context.prisma.user.create({
data: {
email: invitation.email,
fullName: input.fullName,
role: 'CLIENT',
companyId: company.id,
},
});
await context.prisma.invitation.update({
where: { id: invitation.id },
data: {
acceptedById: user.id,
acceptedAt: new Date(),
},
});
return user;
},
connectMessenger: (_, { input }, context) => {
const user = requireUser(context);
return context.prisma.messengerConnection.upsert({
where: {
userId_type_channelId: {
userId: user.id,
type: input.type,
channelId: input.channelId,
},
},
update: { isActive: true },
create: {
userId: user.id,
type: input.type,
channelId: input.channelId,
},
});
},
submitReadyOrder: async (_, { input }, context) => {
const customer = requireRole(context, 'CLIENT');
if (!input.items.length) {
throw new Error('Order must contain at least one item.');
}
const productIds = input.items.map((item) => item.productId);
const products = await context.prisma.product.findMany({
where: { id: { in: productIds }, isActive: true },
});
if (products.length !== input.items.length) {
throw new Error('Some products are invalid or inactive.');
}
const productMap = new Map(products.map((product) => [product.id, product]));
const order = await context.prisma.order.create({
data: {
code: orderCode(),
kind: 'READY',
customerId: customer.id,
status: 'NEW',
items: {
create: input.items.map((item) => {
const product = productMap.get(item.productId);
return {
productId: item.productId,
productName: product.name,
quantity: item.quantity,
};
}),
},
},
include: {
items: true,
history: true,
},
});
await appendOrderEvent(context.prisma, order.id, 'NEW', customer.id, 'Ready order created by client');
return context.prisma.order.findUnique({
where: { id: order.id },
include: { items: true, history: { orderBy: { createdAt: 'desc' } } },
});
},
submitCalculationOrder: async (_, { input }, context) => {
const customer = requireRole(context, 'CLIENT');
const order = await context.prisma.order.create({
data: {
code: orderCode(),
kind: 'CALCULATION',
customerId: customer.id,
status: 'NEW',
calculationPayload: input.parameters,
items: {
create: [
{
productName: input.productName,
quantity: input.quantity,
},
],
},
},
include: {
items: true,
history: true,
},
});
await appendOrderEvent(context.prisma, order.id, 'NEW', customer.id, 'Calculation request created by client');
return context.prisma.order.findUnique({
where: { id: order.id },
include: { items: true, history: { orderBy: { createdAt: 'desc' } } },
});
},
managerSetOrderOffer: async (_, { input }, context) => {
const manager = requireRole(context, 'MANAGER');
const order = await context.prisma.order.update({
where: { id: input.orderId },
data: {
managerId: manager.id,
status: 'WAITING_DOUBLE_CONFIRM',
deliveryTerms: input.deliveryTerms,
deliveryFee: input.deliveryFee,
totalPrice: input.totalPrice,
},
});
await appendOrderEvent(context.prisma, order.id, 'WAITING_DOUBLE_CONFIRM', manager.id, 'Offer is published by manager');
return context.prisma.order.findUnique({
where: { id: order.id },
include: { items: true, history: { orderBy: { createdAt: 'desc' } } },
});
},
clientReviewOrder: async (_, { orderId, decision }, context) => {
const customer = requireRole(context, 'CLIENT');
const order = await context.prisma.order.findUnique({ where: { id: orderId } });
if (!order || order.customerId !== customer.id) {
throw new Error('Order is not available for this client.');
}
const status = decision === 'REJECT'
? 'CLIENT_REJECTED'
: order.managerApproved
? 'CONFIRMED'
: 'WAITING_DOUBLE_CONFIRM';
const updated = await context.prisma.order.update({
where: { id: orderId },
data: {
clientApproved: decision === 'APPROVE',
status,
},
});
await appendOrderEvent(
context.prisma,
updated.id,
status,
customer.id,
decision === 'APPROVE' ? 'Client approved offer' : 'Client rejected offer',
);
return context.prisma.order.findUnique({
where: { id: updated.id },
include: { items: true, history: { orderBy: { createdAt: 'desc' } } },
});
},
managerFinalizeOrder: async (_, { orderId, decision }, context) => {
const manager = requireRole(context, 'MANAGER');
const order = await context.prisma.order.findUnique({ where: { id: orderId } });
if (!order) {
throw new Error('Order was not found.');
}
const status = decision === 'REJECT'
? 'MANAGER_REJECTED'
: order.clientApproved
? 'CONFIRMED'
: 'WAITING_DOUBLE_CONFIRM';
const updated = await context.prisma.order.update({
where: { id: orderId },
data: {
managerId: manager.id,
managerApproved: decision === 'APPROVE',
status,
},
});
await appendOrderEvent(
context.prisma,
updated.id,
status,
manager.id,
decision === 'APPROVE' ? 'Manager approved order' : 'Manager rejected order',
);
return context.prisma.order.findUnique({
where: { id: updated.id },
include: { items: true, history: { orderBy: { createdAt: 'desc' } } },
});
},
blockOrder: async (_, { input }, context) => {
const manager = requireRole(context, 'MANAGER');
const updated = await context.prisma.order.update({
where: { id: input.orderId },
data: {
managerId: manager.id,
status: 'MANAGER_BLOCKED',
blockReason: input.reason,
},
});
await appendOrderEvent(context.prisma, updated.id, 'MANAGER_BLOCKED', manager.id, input.reason);
return context.prisma.order.findUnique({
where: { id: updated.id },
include: { items: true, history: { orderBy: { createdAt: 'desc' } } },
});
},
startOrderWork: async (_, { orderId }, context) => {
const manager = requireRole(context, 'MANAGER');
const order = await context.prisma.order.findUnique({ where: { id: orderId } });
if (!order || order.status !== 'CONFIRMED') {
throw new Error('Only confirmed order can be started.');
}
const updated = await context.prisma.order.update({
where: { id: orderId },
data: {
managerId: manager.id,
status: 'IN_PROGRESS',
},
});
await appendOrderEvent(context.prisma, updated.id, 'IN_PROGRESS', manager.id, 'Order moved to in-progress');
return context.prisma.order.findUnique({
where: { id: updated.id },
include: { items: true, history: { orderBy: { createdAt: 'desc' } } },
});
},
completeOrder: async (_, { orderId }, context) => {
const manager = requireRole(context, 'MANAGER');
const order = await context.prisma.order.findUnique({ where: { id: orderId } });
if (!order || order.status !== 'IN_PROGRESS') {
throw new Error('Only in-progress order can be completed.');
}
const updated = await context.prisma.order.update({
where: { id: orderId },
data: {
managerId: manager.id,
status: 'COMPLETED',
},
});
await appendOrderEvent(context.prisma, updated.id, 'COMPLETED', manager.id, 'Order completed');
return context.prisma.order.findUnique({
where: { id: updated.id },
include: { items: true, history: { orderBy: { createdAt: 'desc' } } },
});
},
createReferral: (_, { input }, context) => {
const manager = requireRole(context, 'MANAGER');
return context.prisma.referralLink.create({
data: {
referrerId: manager.id,
refereeId: input.refereeUserId,
},
});
},
addBonusTransaction: (_, { input }, context) => {
requireRole(context, 'MANAGER');
return context.prisma.bonusTransaction.create({
data: {
userId: input.userId,
amount: input.amount,
reason: input.reason,
orderId: input.orderId,
},
});
},
requestRewardWithdrawal: (_, { input }, context) => {
const client = requireRole(context, 'CLIENT');
if (input.amount < 100) {
throw new Error('Minimum withdrawal amount is 100.');
}
return context.prisma.rewardWithdrawalRequest.create({
data: {
requesterId: client.id,
amount: input.amount,
},
});
},
reviewRewardWithdrawal: (_, { input }, context) => {
const manager = requireRole(context, 'MANAGER');
return context.prisma.rewardWithdrawalRequest.update({
where: { id: input.withdrawalId },
data: {
reviewedById: manager.id,
status: input.decision === 'APPROVE' ? 'APPROVED' : 'REJECTED',
reviewComment: input.reviewComment,
},
});
},
},
Product: {
availableInWarehouses: (product) =>
product.inventory.map((stock) => ({
warehouse: stock.warehouse,
availableQty: toFloat(stock.availableQty),
})),
},
Order: {
deliveryFee: (order) => toFloat(order.deliveryFee),
totalPrice: (order) => toFloat(order.totalPrice),
},
OrderItem: {
quantity: (item) => toFloat(item.quantity),
},
BonusTransaction: {
amount: (tx) => toFloat(tx.amount),
},
RewardWithdrawalRequest: {
amount: (tx) => toFloat(tx.amount),
},
};

44
src/scalars.js Normal file
View File

@@ -0,0 +1,44 @@
import { GraphQLError, GraphQLScalarType, Kind } from 'graphql';
function parseJsonLiteral(ast) {
if (ast.kind === Kind.STRING) return ast.value;
if (ast.kind === Kind.BOOLEAN) return ast.value;
if (ast.kind === Kind.INT || ast.kind === Kind.FLOAT) return Number(ast.value);
if (ast.kind === Kind.NULL) return null;
if (ast.kind === Kind.OBJECT) {
return Object.fromEntries(ast.fields.map((field) => [field.name.value, parseJsonLiteral(field.value)]));
}
if (ast.kind === Kind.LIST) {
return ast.values.map(parseJsonLiteral);
}
throw new GraphQLError('Unsupported JSON literal');
}
export const dateTimeScalar = new GraphQLScalarType({
name: 'DateTime',
serialize(value) {
return new Date(value).toISOString();
},
parseValue(value) {
return new Date(value);
},
parseLiteral(ast) {
if (ast.kind !== Kind.STRING) {
throw new GraphQLError('DateTime must be a string');
}
return new Date(ast.value);
},
});
export const jsonScalar = new GraphQLScalarType({
name: 'JSON',
serialize(value) {
return value;
},
parseValue(value) {
return value;
},
parseLiteral(ast) {
return parseJsonLiteral(ast);
},
});

294
src/schema.graphql Normal file
View File

@@ -0,0 +1,294 @@
scalar DateTime
scalar JSON
enum UserRole {
CLIENT
MANAGER
}
enum MessengerType {
TELEGRAM
MAX
}
enum RegistrationStatus {
PENDING
APPROVED
REJECTED
}
enum OrderKind {
READY
CALCULATION
}
enum OrderStatus {
NEW
MANAGER_PROCESSING
WAITING_DOUBLE_CONFIRM
CLIENT_REJECTED
MANAGER_REJECTED
MANAGER_BLOCKED
CONFIRMED
IN_PROGRESS
COMPLETED
}
enum WithdrawalStatus {
PENDING
APPROVED
REJECTED
}
enum Decision {
APPROVE
REJECT
}
type Company {
id: ID!
name: String!
inn: String
}
type User {
id: ID!
email: String!
fullName: String!
role: UserRole!
company: Company
}
type Invitation {
id: ID!
token: String!
email: String!
companyName: String!
managerId: ID!
acceptedById: ID
expiresAt: DateTime!
acceptedAt: DateTime
createdAt: DateTime!
}
type RegistrationRequest {
id: ID!
companyName: String!
inn: String
contactName: String!
email: String!
status: RegistrationStatus!
rejectionReason: String
reviewedById: ID
createdAt: DateTime!
updatedAt: DateTime!
}
type MessengerConnection {
id: ID!
userId: ID!
type: MessengerType!
channelId: String!
isActive: Boolean!
}
type Warehouse {
id: ID!
code: String!
name: String!
}
type ProductWarehouseBalance {
warehouse: Warehouse!
availableQty: Float!
}
type Product {
id: ID!
sku: String!
name: String!
description: String
isCustomizable: Boolean!
isActive: Boolean!
availableInWarehouses: [ProductWarehouseBalance!]!
}
type OrderItem {
id: ID!
productId: ID
productName: String!
quantity: Float!
}
type OrderStatusEvent {
id: ID!
status: OrderStatus!
actorUserId: ID!
note: String
createdAt: DateTime!
}
type Order {
id: ID!
code: String!
kind: OrderKind!
status: OrderStatus!
customerId: ID!
managerId: ID
clientApproved: Boolean
managerApproved: Boolean
blockReason: String
deliveryTerms: String
deliveryFee: Float
totalPrice: Float
calculationPayload: JSON
items: [OrderItem!]!
history: [OrderStatusEvent!]!
createdAt: DateTime!
updatedAt: DateTime!
}
type ReferralLink {
id: ID!
referrerId: ID!
refereeId: ID!
createdAt: DateTime!
}
type BonusTransaction {
id: ID!
userId: ID!
amount: Float!
reason: String!
orderId: ID
createdAt: DateTime!
}
type RewardWithdrawalRequest {
id: ID!
requesterId: ID!
amount: Float!
status: WithdrawalStatus!
reviewedById: ID
reviewComment: String
createdAt: DateTime!
updatedAt: DateTime!
}
type ReferralStats {
referrerId: ID!
availableBalance: Float!
referralsCount: Int!
transactions: [BonusTransaction!]!
pendingWithdrawals: [RewardWithdrawalRequest!]!
}
type Query {
healthcheck: String!
me: User
clientProducts: [Product!]!
myOrders: [Order!]!
myCurrentOrders: [Order!]!
managerOrders(status: OrderStatus): [Order!]!
registrationRequests(status: RegistrationStatus): [RegistrationRequest!]!
referralStats: ReferralStats!
}
input RegisterSelfInput {
companyName: String!
inn: String
contactName: String!
email: String!
}
input ReviewRegistrationRequestInput {
requestId: ID!
decision: Decision!
rejectionReason: String
}
input CreateInvitationInput {
email: String!
companyName: String!
expiresInDays: Int = 7
}
input AcceptInvitationInput {
token: String!
fullName: String!
}
input ConnectMessengerInput {
type: MessengerType!
channelId: String!
}
input ReadyOrderItemInput {
productId: ID!
quantity: Float!
}
input SubmitReadyOrderInput {
items: [ReadyOrderItemInput!]!
}
input SubmitCalculationOrderInput {
productName: String!
quantity: Float!
parameters: JSON!
}
input SetOrderOfferInput {
orderId: ID!
deliveryTerms: String!
deliveryFee: Float!
totalPrice: Float!
}
input BlockOrderInput {
orderId: ID!
reason: String!
}
input CreateReferralInput {
refereeUserId: ID!
}
input AddBonusTransactionInput {
userId: ID!
amount: Float!
reason: String!
orderId: ID
}
input RequestRewardWithdrawalInput {
amount: Float!
}
input ReviewRewardWithdrawalInput {
withdrawalId: ID!
decision: Decision!
reviewComment: String
}
type Mutation {
registerSelf(input: RegisterSelfInput!): RegistrationRequest!
reviewRegistrationRequest(input: ReviewRegistrationRequestInput!): RegistrationRequest!
createInvitation(input: CreateInvitationInput!): Invitation!
acceptInvitation(input: AcceptInvitationInput!): User!
connectMessenger(input: ConnectMessengerInput!): MessengerConnection!
submitReadyOrder(input: SubmitReadyOrderInput!): Order!
submitCalculationOrder(input: SubmitCalculationOrderInput!): Order!
managerSetOrderOffer(input: SetOrderOfferInput!): Order!
clientReviewOrder(orderId: ID!, decision: Decision!): Order!
managerFinalizeOrder(orderId: ID!, decision: Decision!): Order!
blockOrder(input: BlockOrderInput!): Order!
startOrderWork(orderId: ID!): Order!
completeOrder(orderId: ID!): Order!
createReferral(input: CreateReferralInput!): ReferralLink!
addBonusTransaction(input: AddBonusTransactionInput!): BonusTransaction!
requestRewardWithdrawal(input: RequestRewardWithdrawalInput!): RewardWithdrawalRequest!
reviewRewardWithdrawal(input: ReviewRewardWithdrawalInput!): RewardWithdrawalRequest!
}

50
src/server.js Normal file
View File

@@ -0,0 +1,50 @@
import 'dotenv/config';
import { readFileSync } from 'node:fs';
import bodyParser from 'body-parser';
import cors from 'cors';
import express from 'express';
import { ApolloServer } from '@apollo/server';
import { expressMiddleware } from '@as-integrations/express5';
import { buildContext } from './context.js';
import { prisma } from './prisma-client.js';
import { resolvers } from './resolvers.js';
const typeDefs = readFileSync(new URL('./schema.graphql', import.meta.url), 'utf8');
const server = new ApolloServer({
typeDefs,
resolvers,
});
await server.start();
const app = express();
app.use(cors());
app.use(bodyParser.json({ limit: '1mb' }));
app.get('/healthz', (_, res) => {
res.json({ status: 'ok' });
});
app.use(
'/graphql',
expressMiddleware(server, {
context: async ({ req }) => buildContext(req),
}),
);
const port = Number(process.env.PORT ?? 4000);
app.listen(port, () => {
console.log(`apollo-backend running at http://localhost:${port}/graphql`);
});
async function shutdown() {
await server.stop();
await prisma.$disconnect();
}
process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);