From f7fb45618d39807e3f21a0c63ea5aff51e8cfb60 Mon Sep 17 00:00:00 2001 From: Ruslan Bakiev <572431+veikab@users.noreply.github.com> Date: Fri, 3 Apr 2026 10:25:19 +0700 Subject: [PATCH] Add delivery addresses to profile and order flow --- .../0003_delivery_addresses/migration.sql | 33 +++ prisma/schema.prisma | 22 ++ src/resolvers.js | 197 ++++++++++++++++++ src/schema.graphql | 26 +++ 4 files changed, 278 insertions(+) create mode 100644 prisma/migrations/0003_delivery_addresses/migration.sql diff --git a/prisma/migrations/0003_delivery_addresses/migration.sql b/prisma/migrations/0003_delivery_addresses/migration.sql new file mode 100644 index 0000000..a52bb17 --- /dev/null +++ b/prisma/migrations/0003_delivery_addresses/migration.sql @@ -0,0 +1,33 @@ +-- AlterTable +ALTER TABLE "User" ADD COLUMN "defaultDeliveryAddressId" TEXT; + +-- AlterTable +ALTER TABLE "Order" ADD COLUMN "deliveryAddress" TEXT, +ADD COLUMN "deliveryAddressId" TEXT; + +-- CreateTable +CREATE TABLE "DeliveryAddress" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "label" TEXT, + "address" TEXT NOT NULL, + "unrestrictedValue" TEXT, + "fiasId" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "DeliveryAddress_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "DeliveryAddress_userId_idx" ON "DeliveryAddress"("userId"); + +-- AddForeignKey +ALTER TABLE "User" ADD CONSTRAINT "User_defaultDeliveryAddressId_fkey" FOREIGN KEY ("defaultDeliveryAddressId") REFERENCES "DeliveryAddress"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "DeliveryAddress" ADD CONSTRAINT "DeliveryAddress_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Order" ADD CONSTRAINT "Order_deliveryAddressId_fkey" FOREIGN KEY ("deliveryAddressId") REFERENCES "DeliveryAddress"("id") ON DELETE SET NULL ON UPDATE CASCADE; + diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 0ac9f52..09e9f30 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -62,6 +62,9 @@ model User { companyId String? company Company? @relation(fields: [companyId], references: [id]) counterpartyProfile CounterpartyProfile? + deliveryAddresses DeliveryAddress[] @relation("UserDeliveryAddresses") + defaultDeliveryAddressId String? + defaultDeliveryAddress DeliveryAddress? @relation("UserDefaultDeliveryAddress", fields: [defaultDeliveryAddressId], references: [id], onDelete: SetNull) registrationRequests RegistrationRequest[] @relation("RegistrationRequester") reviewedRequests RegistrationRequest[] @relation("RegistrationReviewer") sentInvitations Invitation[] @relation("InvitationManager") @@ -79,6 +82,22 @@ model User { updatedAt DateTime @updatedAt } +model DeliveryAddress { + id String @id @default(cuid()) + userId String + user User @relation("UserDeliveryAddresses", fields: [userId], references: [id], onDelete: Cascade) + label String? + address String + unrestrictedValue String? + fiasId String? + defaultForUsers User[] @relation("UserDefaultDeliveryAddress") + orders Order[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([userId]) +} + model CounterpartyProfile { id String @id @default(cuid()) userId String @unique @@ -182,6 +201,9 @@ model Order { kind OrderKind customerId String customer User @relation("OrderClient", fields: [customerId], references: [id]) + deliveryAddressId String? + deliveryAddressRef DeliveryAddress? @relation(fields: [deliveryAddressId], references: [id], onDelete: SetNull) + deliveryAddress String? managerId String? manager User? @relation("OrderManager", fields: [managerId], references: [id]) status OrderStatus @default(NEW) diff --git a/src/resolvers.js b/src/resolvers.js index a06d4b1..2715fbe 100644 --- a/src/resolvers.js +++ b/src/resolvers.js @@ -113,6 +113,67 @@ function toCounterpartyProfileInputData(input) { }; } +function toDeliveryAddressInputData(input) { + return { + label: normalizeOptionalText(input.label), + address: normalizeText(input.address), + unrestrictedValue: normalizeOptionalText(input.unrestrictedValue), + fiasId: normalizeOptionalText(input.fiasId), + }; +} + +function presentDeliveryAddress(address) { + return address.unrestrictedValue || address.address; +} + +function withDeliveryAddressDefaultFlag(address, defaultDeliveryAddressId) { + return { + ...address, + isDefault: address.id === defaultDeliveryAddressId, + }; +} + +async function resolveSelectedDeliveryAddress(context, userId, deliveryAddressId) { + const normalizedAddressId = normalizeOptionalText(deliveryAddressId); + + if (normalizedAddressId) { + const selected = await context.prisma.deliveryAddress.findFirst({ + where: { + id: normalizedAddressId, + userId, + }, + }); + + if (!selected) { + throw new Error('Delivery address is not available for this user.'); + } + + return selected; + } + + const user = await context.prisma.user.findUnique({ + where: { id: userId }, + select: { defaultDeliveryAddressId: true }, + }); + + if (!user?.defaultDeliveryAddressId) { + throw new Error('Delivery address is not selected. Add address in profile first.'); + } + + const fallbackAddress = await context.prisma.deliveryAddress.findFirst({ + where: { + id: user.defaultDeliveryAddressId, + userId, + }, + }); + + if (!fallbackAddress) { + throw new Error('Default delivery address was not found. Select another one in profile.'); + } + + return fallbackAddress; +} + async function requireCompletedCounterpartyProfile(context, userId) { const profile = await context.prisma.counterpartyProfile.findUnique({ where: { userId }, @@ -213,6 +274,9 @@ export const resolvers = { CounterpartyProfile: { isComplete: (profile) => isCounterpartyProfileComplete(profile), }, + DeliveryAddress: { + isDefault: (address) => Boolean(address.isDefault), + }, Query: { healthcheck: () => 'ok', @@ -226,6 +290,22 @@ export const resolvers = { }); }, + myDeliveryAddresses: async (_, __, context) => { + const user = requireUser(context); + const [account, addresses] = await Promise.all([ + context.prisma.user.findUnique({ + where: { id: user.id }, + select: { defaultDeliveryAddressId: true }, + }), + context.prisma.deliveryAddress.findMany({ + where: { userId: user.id }, + orderBy: [{ updatedAt: 'desc' }, { createdAt: 'desc' }], + }), + ]); + + return addresses.map((address) => withDeliveryAddressDefaultFlag(address, account?.defaultDeliveryAddressId ?? null)); + }, + myMessengerConnections: async (_, __, context) => { const user = requireUser(context); return context.prisma.messengerConnection.findMany({ @@ -527,6 +607,117 @@ export const resolvers = { }); }, + createMyDeliveryAddress: async (_, { input }, context) => { + const user = requireUser(context); + const payload = toDeliveryAddressInputData(input); + + if (!payload.address) { + throw new Error('Delivery address is required.'); + } + + const created = await context.prisma.$transaction(async (tx) => { + const account = await tx.user.findUnique({ + where: { id: user.id }, + select: { defaultDeliveryAddressId: true }, + }); + + const address = await tx.deliveryAddress.create({ + data: { + userId: user.id, + ...payload, + }, + }); + + if (!account?.defaultDeliveryAddressId) { + await tx.user.update({ + where: { id: user.id }, + data: { defaultDeliveryAddressId: address.id }, + }); + return withDeliveryAddressDefaultFlag(address, address.id); + } + + return withDeliveryAddressDefaultFlag(address, account.defaultDeliveryAddressId); + }); + + return created; + }, + + setMyDefaultDeliveryAddress: async (_, { addressId }, context) => { + const user = requireUser(context); + const normalizedAddressId = normalizeText(addressId); + if (!normalizedAddressId) { + throw new Error('Delivery address id is required.'); + } + + const address = await context.prisma.deliveryAddress.findFirst({ + where: { + id: normalizedAddressId, + userId: user.id, + }, + }); + + if (!address) { + throw new Error('Delivery address is not available for this user.'); + } + + await context.prisma.user.update({ + where: { id: user.id }, + data: { + defaultDeliveryAddressId: address.id, + }, + }); + + return withDeliveryAddressDefaultFlag(address, address.id); + }, + + deleteMyDeliveryAddress: async (_, { addressId }, context) => { + const user = requireUser(context); + const normalizedAddressId = normalizeText(addressId); + if (!normalizedAddressId) { + throw new Error('Delivery address id is required.'); + } + + const address = await context.prisma.deliveryAddress.findFirst({ + where: { + id: normalizedAddressId, + userId: user.id, + }, + }); + + if (!address) { + throw new Error('Delivery address is not available for this user.'); + } + + await context.prisma.$transaction(async (tx) => { + const account = await tx.user.findUnique({ + where: { id: user.id }, + select: { defaultDeliveryAddressId: true }, + }); + + await tx.deliveryAddress.delete({ + where: { id: address.id }, + }); + + if (account?.defaultDeliveryAddressId !== address.id) { + return; + } + + const nextDefault = await tx.deliveryAddress.findFirst({ + where: { userId: user.id }, + orderBy: [{ updatedAt: 'desc' }, { createdAt: 'desc' }], + }); + + await tx.user.update({ + where: { id: user.id }, + data: { + defaultDeliveryAddressId: nextDefault?.id ?? null, + }, + }); + }); + + return true; + }, + connectMessenger: (_, { input }, context) => { const user = requireUser(context); return context.prisma.messengerConnection.upsert({ @@ -587,6 +778,7 @@ export const resolvers = { if (!input.items.length) { throw new Error('Order must contain at least one item.'); } + const selectedAddress = await resolveSelectedDeliveryAddress(context, customer.id, input.deliveryAddressId); const productIds = input.items.map((item) => item.productId); const products = await context.prisma.product.findMany({ @@ -604,6 +796,8 @@ export const resolvers = { code: orderCode(), kind: 'READY', customerId: customer.id, + deliveryAddressId: selectedAddress.id, + deliveryAddress: presentDeliveryAddress(selectedAddress), status: 'NEW', items: { create: input.items.map((item) => { @@ -634,11 +828,14 @@ export const resolvers = { submitCalculationOrder: async (_, { input }, context) => { const customer = requireRole(context, 'CLIENT'); await requireCompletedCounterpartyProfile(context, customer.id); + const selectedAddress = await resolveSelectedDeliveryAddress(context, customer.id, input.deliveryAddressId); const order = await context.prisma.order.create({ data: { code: orderCode(), kind: 'CALCULATION', customerId: customer.id, + deliveryAddressId: selectedAddress.id, + deliveryAddress: presentDeliveryAddress(selectedAddress), status: 'NEW', calculationPayload: input.parameters, items: { diff --git a/src/schema.graphql b/src/schema.graphql index 2511af2..6339163 100644 --- a/src/schema.graphql +++ b/src/schema.graphql @@ -86,6 +86,18 @@ type CounterpartyProfile { updatedAt: DateTime! } +type DeliveryAddress { + id: ID! + userId: ID! + label: String + address: String! + unrestrictedValue: String + fiasId: String + isDefault: Boolean! + createdAt: DateTime! + updatedAt: DateTime! +} + type AuthCodeRequestResult { challengeToken: String! channel: LoginChannel! @@ -191,6 +203,7 @@ type Order { kind: OrderKind! status: OrderStatus! customerId: ID! + deliveryAddress: String managerId: ID clientApproved: Boolean managerApproved: Boolean @@ -244,6 +257,7 @@ type Query { healthcheck: String! me: User myCounterpartyProfile: CounterpartyProfile + myDeliveryAddresses: [DeliveryAddress!]! myMessengerConnections: [MessengerConnection!]! myNotificationHistory(channel: MessengerType!, limit: Int = 50): [NotificationHistoryItem!]! managerNotificationHistory(userId: ID!, channel: MessengerType!, limit: Int = 50): [NotificationHistoryItem!]! @@ -310,6 +324,13 @@ input UpsertMyCounterpartyProfileInput { signerBasis: String! } +input CreateMyDeliveryAddressInput { + label: String + address: String! + unrestrictedValue: String + fiasId: String +} + input ReadyOrderItemInput { productId: ID! quantity: Float! @@ -317,12 +338,14 @@ input ReadyOrderItemInput { input SubmitReadyOrderInput { items: [ReadyOrderItemInput!]! + deliveryAddressId: ID } input SubmitCalculationOrderInput { productName: String! quantity: Float! parameters: JSON! + deliveryAddressId: ID } input SetOrderOfferInput { @@ -368,6 +391,9 @@ type Mutation { acceptInvitation(input: AcceptInvitationInput!): User! connectMessenger(input: ConnectMessengerInput!): MessengerConnection! upsertMyCounterpartyProfile(input: UpsertMyCounterpartyProfileInput!): CounterpartyProfile! + createMyDeliveryAddress(input: CreateMyDeliveryAddressInput!): DeliveryAddress! + setMyDefaultDeliveryAddress(addressId: ID!): DeliveryAddress! + deleteMyDeliveryAddress(addressId: ID!): Boolean! sendTestMessengerMessage(type: MessengerType!, channelId: String, message: String): MessengerDispatchResult! submitReadyOrder(input: SubmitReadyOrderInput!): Order!