From 3ee14d508c388e8af85dfd92c6bea52265cfb2e6 Mon Sep 17 00:00:00 2001 From: Ruslan Bakiev <572431+veikab@users.noreply.github.com> Date: Thu, 2 Apr 2026 16:46:36 +0700 Subject: [PATCH] feat(profile): add counterparty profile and enforce it for order creation --- .../0002_counterparty_profile/migration.sql | 29 +++++++ prisma/schema.prisma | 22 +++++ src/resolvers.js | 86 +++++++++++++++++++ src/schema.graphql | 39 +++++++++ 4 files changed, 176 insertions(+) create mode 100644 prisma/migrations/0002_counterparty_profile/migration.sql diff --git a/prisma/migrations/0002_counterparty_profile/migration.sql b/prisma/migrations/0002_counterparty_profile/migration.sql new file mode 100644 index 0000000..6ca2957 --- /dev/null +++ b/prisma/migrations/0002_counterparty_profile/migration.sql @@ -0,0 +1,29 @@ +-- CreateTable +CREATE TABLE "CounterpartyProfile" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "companyName" TEXT NOT NULL, + "companyFullName" TEXT NOT NULL, + "inn" TEXT NOT NULL, + "kpp" TEXT, + "ogrn" TEXT, + "legalAddress" TEXT NOT NULL, + "bankName" TEXT NOT NULL, + "bik" TEXT NOT NULL, + "correspondentAccount" TEXT NOT NULL, + "checkingAccount" TEXT NOT NULL, + "signerFullName" TEXT NOT NULL, + "signerPosition" TEXT NOT NULL, + "signerBasis" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "CounterpartyProfile_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "CounterpartyProfile_userId_key" ON "CounterpartyProfile"("userId"); + +-- AddForeignKey +ALTER TABLE "CounterpartyProfile" ADD CONSTRAINT "CounterpartyProfile_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + diff --git a/prisma/schema.prisma b/prisma/schema.prisma index faab7a7..0ac9f52 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -61,6 +61,7 @@ model User { role UserRole companyId String? company Company? @relation(fields: [companyId], references: [id]) + counterpartyProfile CounterpartyProfile? registrationRequests RegistrationRequest[] @relation("RegistrationRequester") reviewedRequests RegistrationRequest[] @relation("RegistrationReviewer") sentInvitations Invitation[] @relation("InvitationManager") @@ -78,6 +79,27 @@ model User { updatedAt DateTime @updatedAt } +model CounterpartyProfile { + id String @id @default(cuid()) + userId String @unique + user User @relation(fields: [userId], references: [id]) + companyName String + companyFullName String + inn String + kpp String? + ogrn String? + legalAddress String + bankName String + bik String + correspondentAccount String + checkingAccount String + signerFullName String + signerPosition String + signerBasis String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + model RegistrationRequest { id String @id @default(cuid()) companyName String diff --git a/src/resolvers.js b/src/resolvers.js index 5e831b7..a06d4b1 100644 --- a/src/resolvers.js +++ b/src/resolvers.js @@ -66,6 +66,62 @@ function buildDefaultFullName(email) { .join(' '); } +function normalizeText(value) { + return String(value ?? '').trim(); +} + +function normalizeOptionalText(value) { + const normalized = normalizeText(value); + return normalized ? normalized : null; +} + +function isCounterpartyProfileComplete(profile) { + if (!profile) { + return false; + } + + return Boolean( + normalizeText(profile.companyName) && + normalizeText(profile.companyFullName) && + normalizeText(profile.inn) && + normalizeText(profile.legalAddress) && + normalizeText(profile.bankName) && + normalizeText(profile.bik) && + normalizeText(profile.correspondentAccount) && + normalizeText(profile.checkingAccount) && + normalizeText(profile.signerFullName) && + normalizeText(profile.signerPosition) && + normalizeText(profile.signerBasis), + ); +} + +function toCounterpartyProfileInputData(input) { + return { + companyName: normalizeText(input.companyName), + companyFullName: normalizeText(input.companyFullName), + inn: normalizeText(input.inn), + kpp: normalizeOptionalText(input.kpp), + ogrn: normalizeOptionalText(input.ogrn), + legalAddress: normalizeText(input.legalAddress), + bankName: normalizeText(input.bankName), + bik: normalizeText(input.bik), + correspondentAccount: normalizeText(input.correspondentAccount), + checkingAccount: normalizeText(input.checkingAccount), + signerFullName: normalizeText(input.signerFullName), + signerPosition: normalizeText(input.signerPosition), + signerBasis: normalizeText(input.signerBasis), + }; +} + +async function requireCompletedCounterpartyProfile(context, userId) { + const profile = await context.prisma.counterpartyProfile.findUnique({ + where: { userId }, + }); + if (!isCounterpartyProfileComplete(profile)) { + throw new Error('Counterparty profile is incomplete. Fill profile before placing an order.'); + } +} + function formatOrderStatusMessage(order, status, note) { const suffix = note ? `\nКомментарий: ${note}` : ''; return `Заказ ${order.code} изменил статус: ${status}.${suffix}`; @@ -154,12 +210,22 @@ async function collectNotificationHistory(context, userId, channel, limit) { export const resolvers = { DateTime: dateTimeScalar, JSON: jsonScalar, + CounterpartyProfile: { + isComplete: (profile) => isCounterpartyProfileComplete(profile), + }, Query: { healthcheck: () => 'ok', me: (_, __, context) => context.user, + myCounterpartyProfile: async (_, __, context) => { + const user = requireUser(context); + return context.prisma.counterpartyProfile.findUnique({ + where: { userId: user.id }, + }); + }, + myMessengerConnections: async (_, __, context) => { const user = requireUser(context); return context.prisma.messengerConnection.findMany({ @@ -443,6 +509,24 @@ export const resolvers = { return user; }, + upsertMyCounterpartyProfile: async (_, { input }, context) => { + const user = requireUser(context); + const payload = toCounterpartyProfileInputData(input); + + if (!isCounterpartyProfileComplete(payload)) { + throw new Error('Counterparty profile is incomplete. Fill all required fields.'); + } + + return context.prisma.counterpartyProfile.upsert({ + where: { userId: user.id }, + update: payload, + create: { + userId: user.id, + ...payload, + }, + }); + }, + connectMessenger: (_, { input }, context) => { const user = requireUser(context); return context.prisma.messengerConnection.upsert({ @@ -499,6 +583,7 @@ export const resolvers = { submitReadyOrder: async (_, { input }, context) => { const customer = requireRole(context, 'CLIENT'); + await requireCompletedCounterpartyProfile(context, customer.id); if (!input.items.length) { throw new Error('Order must contain at least one item.'); } @@ -548,6 +633,7 @@ export const resolvers = { submitCalculationOrder: async (_, { input }, context) => { const customer = requireRole(context, 'CLIENT'); + await requireCompletedCounterpartyProfile(context, customer.id); const order = await context.prisma.order.create({ data: { code: orderCode(), diff --git a/src/schema.graphql b/src/schema.graphql index a832894..2511af2 100644 --- a/src/schema.graphql +++ b/src/schema.graphql @@ -65,6 +65,27 @@ type User { company: Company } +type CounterpartyProfile { + id: ID! + userId: ID! + companyName: String! + companyFullName: String! + inn: String! + kpp: String + ogrn: String + legalAddress: String! + bankName: String! + bik: String! + correspondentAccount: String! + checkingAccount: String! + signerFullName: String! + signerPosition: String! + signerBasis: String! + isComplete: Boolean! + createdAt: DateTime! + updatedAt: DateTime! +} + type AuthCodeRequestResult { challengeToken: String! channel: LoginChannel! @@ -222,6 +243,7 @@ type ReferralStats { type Query { healthcheck: String! me: User + myCounterpartyProfile: CounterpartyProfile myMessengerConnections: [MessengerConnection!]! myNotificationHistory(channel: MessengerType!, limit: Int = 50): [NotificationHistoryItem!]! managerNotificationHistory(userId: ID!, channel: MessengerType!, limit: Int = 50): [NotificationHistoryItem!]! @@ -272,6 +294,22 @@ input ConnectMessengerInput { channelId: String! } +input UpsertMyCounterpartyProfileInput { + companyName: String! + companyFullName: String! + inn: String! + kpp: String + ogrn: String + legalAddress: String! + bankName: String! + bik: String! + correspondentAccount: String! + checkingAccount: String! + signerFullName: String! + signerPosition: String! + signerBasis: String! +} + input ReadyOrderItemInput { productId: ID! quantity: Float! @@ -329,6 +367,7 @@ type Mutation { createInvitation(input: CreateInvitationInput!): Invitation! acceptInvitation(input: AcceptInvitationInput!): User! connectMessenger(input: ConnectMessengerInput!): MessengerConnection! + upsertMyCounterpartyProfile(input: UpsertMyCounterpartyProfileInput!): CounterpartyProfile! sendTestMessengerMessage(type: MessengerType!, channelId: String, message: String): MessengerDispatchResult! submitReadyOrder(input: SubmitReadyOrderInput!): Order!