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

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