Scaffold Apollo backend domain API
This commit is contained in:
9
.env.example
Normal file
9
.env.example
Normal 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
3
.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
node_modules
|
||||||
|
.env
|
||||||
|
.output
|
||||||
15
Dockerfile
Normal file
15
Dockerfile
Normal 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"]
|
||||||
33
README.md
33
README.md
@@ -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
3083
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
36
package.json
Normal file
36
package.json
Normal 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
13
prisma.config.ts
Normal 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
233
prisma/schema.prisma
Normal 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
34
scripts/load-vault-env.sh
Executable 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
119
scripts/seed.js
Normal 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
10
src/context.js
Normal 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
11
src/prisma-client.js
Normal 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
553
src/resolvers.js
Normal 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
44
src/scalars.js
Normal 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
294
src/schema.graphql
Normal 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
50
src/server.js
Normal 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);
|
||||||
Reference in New Issue
Block a user