Add delivery addresses to profile and order flow

This commit is contained in:
Ruslan Bakiev
2026-04-03 10:25:19 +07:00
parent 3ee14d508c
commit f7fb45618d
4 changed files with 278 additions and 0 deletions

View File

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

View File

@@ -62,6 +62,9 @@ model User {
companyId String? companyId String?
company Company? @relation(fields: [companyId], references: [id]) company Company? @relation(fields: [companyId], references: [id])
counterpartyProfile CounterpartyProfile? counterpartyProfile CounterpartyProfile?
deliveryAddresses DeliveryAddress[] @relation("UserDeliveryAddresses")
defaultDeliveryAddressId String?
defaultDeliveryAddress DeliveryAddress? @relation("UserDefaultDeliveryAddress", fields: [defaultDeliveryAddressId], references: [id], onDelete: SetNull)
registrationRequests RegistrationRequest[] @relation("RegistrationRequester") registrationRequests RegistrationRequest[] @relation("RegistrationRequester")
reviewedRequests RegistrationRequest[] @relation("RegistrationReviewer") reviewedRequests RegistrationRequest[] @relation("RegistrationReviewer")
sentInvitations Invitation[] @relation("InvitationManager") sentInvitations Invitation[] @relation("InvitationManager")
@@ -79,6 +82,22 @@ model User {
updatedAt DateTime @updatedAt 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 { model CounterpartyProfile {
id String @id @default(cuid()) id String @id @default(cuid())
userId String @unique userId String @unique
@@ -182,6 +201,9 @@ model Order {
kind OrderKind kind OrderKind
customerId String customerId String
customer User @relation("OrderClient", fields: [customerId], references: [id]) customer User @relation("OrderClient", fields: [customerId], references: [id])
deliveryAddressId String?
deliveryAddressRef DeliveryAddress? @relation(fields: [deliveryAddressId], references: [id], onDelete: SetNull)
deliveryAddress String?
managerId String? managerId String?
manager User? @relation("OrderManager", fields: [managerId], references: [id]) manager User? @relation("OrderManager", fields: [managerId], references: [id])
status OrderStatus @default(NEW) status OrderStatus @default(NEW)

View File

@@ -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) { async function requireCompletedCounterpartyProfile(context, userId) {
const profile = await context.prisma.counterpartyProfile.findUnique({ const profile = await context.prisma.counterpartyProfile.findUnique({
where: { userId }, where: { userId },
@@ -213,6 +274,9 @@ export const resolvers = {
CounterpartyProfile: { CounterpartyProfile: {
isComplete: (profile) => isCounterpartyProfileComplete(profile), isComplete: (profile) => isCounterpartyProfileComplete(profile),
}, },
DeliveryAddress: {
isDefault: (address) => Boolean(address.isDefault),
},
Query: { Query: {
healthcheck: () => 'ok', 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) => { myMessengerConnections: async (_, __, context) => {
const user = requireUser(context); const user = requireUser(context);
return context.prisma.messengerConnection.findMany({ 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) => { connectMessenger: (_, { input }, context) => {
const user = requireUser(context); const user = requireUser(context);
return context.prisma.messengerConnection.upsert({ return context.prisma.messengerConnection.upsert({
@@ -587,6 +778,7 @@ export const resolvers = {
if (!input.items.length) { if (!input.items.length) {
throw new Error('Order must contain at least one item.'); 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 productIds = input.items.map((item) => item.productId);
const products = await context.prisma.product.findMany({ const products = await context.prisma.product.findMany({
@@ -604,6 +796,8 @@ export const resolvers = {
code: orderCode(), code: orderCode(),
kind: 'READY', kind: 'READY',
customerId: customer.id, customerId: customer.id,
deliveryAddressId: selectedAddress.id,
deliveryAddress: presentDeliveryAddress(selectedAddress),
status: 'NEW', status: 'NEW',
items: { items: {
create: input.items.map((item) => { create: input.items.map((item) => {
@@ -634,11 +828,14 @@ export const resolvers = {
submitCalculationOrder: async (_, { input }, context) => { submitCalculationOrder: async (_, { input }, context) => {
const customer = requireRole(context, 'CLIENT'); const customer = requireRole(context, 'CLIENT');
await requireCompletedCounterpartyProfile(context, customer.id); await requireCompletedCounterpartyProfile(context, customer.id);
const selectedAddress = await resolveSelectedDeliveryAddress(context, customer.id, input.deliveryAddressId);
const order = await context.prisma.order.create({ const order = await context.prisma.order.create({
data: { data: {
code: orderCode(), code: orderCode(),
kind: 'CALCULATION', kind: 'CALCULATION',
customerId: customer.id, customerId: customer.id,
deliveryAddressId: selectedAddress.id,
deliveryAddress: presentDeliveryAddress(selectedAddress),
status: 'NEW', status: 'NEW',
calculationPayload: input.parameters, calculationPayload: input.parameters,
items: { items: {

View File

@@ -86,6 +86,18 @@ type CounterpartyProfile {
updatedAt: DateTime! updatedAt: DateTime!
} }
type DeliveryAddress {
id: ID!
userId: ID!
label: String
address: String!
unrestrictedValue: String
fiasId: String
isDefault: Boolean!
createdAt: DateTime!
updatedAt: DateTime!
}
type AuthCodeRequestResult { type AuthCodeRequestResult {
challengeToken: String! challengeToken: String!
channel: LoginChannel! channel: LoginChannel!
@@ -191,6 +203,7 @@ type Order {
kind: OrderKind! kind: OrderKind!
status: OrderStatus! status: OrderStatus!
customerId: ID! customerId: ID!
deliveryAddress: String
managerId: ID managerId: ID
clientApproved: Boolean clientApproved: Boolean
managerApproved: Boolean managerApproved: Boolean
@@ -244,6 +257,7 @@ type Query {
healthcheck: String! healthcheck: String!
me: User me: User
myCounterpartyProfile: CounterpartyProfile myCounterpartyProfile: CounterpartyProfile
myDeliveryAddresses: [DeliveryAddress!]!
myMessengerConnections: [MessengerConnection!]! myMessengerConnections: [MessengerConnection!]!
myNotificationHistory(channel: MessengerType!, limit: Int = 50): [NotificationHistoryItem!]! myNotificationHistory(channel: MessengerType!, limit: Int = 50): [NotificationHistoryItem!]!
managerNotificationHistory(userId: ID!, channel: MessengerType!, limit: Int = 50): [NotificationHistoryItem!]! managerNotificationHistory(userId: ID!, channel: MessengerType!, limit: Int = 50): [NotificationHistoryItem!]!
@@ -310,6 +324,13 @@ input UpsertMyCounterpartyProfileInput {
signerBasis: String! signerBasis: String!
} }
input CreateMyDeliveryAddressInput {
label: String
address: String!
unrestrictedValue: String
fiasId: String
}
input ReadyOrderItemInput { input ReadyOrderItemInput {
productId: ID! productId: ID!
quantity: Float! quantity: Float!
@@ -317,12 +338,14 @@ input ReadyOrderItemInput {
input SubmitReadyOrderInput { input SubmitReadyOrderInput {
items: [ReadyOrderItemInput!]! items: [ReadyOrderItemInput!]!
deliveryAddressId: ID
} }
input SubmitCalculationOrderInput { input SubmitCalculationOrderInput {
productName: String! productName: String!
quantity: Float! quantity: Float!
parameters: JSON! parameters: JSON!
deliveryAddressId: ID
} }
input SetOrderOfferInput { input SetOrderOfferInput {
@@ -368,6 +391,9 @@ type Mutation {
acceptInvitation(input: AcceptInvitationInput!): User! acceptInvitation(input: AcceptInvitationInput!): User!
connectMessenger(input: ConnectMessengerInput!): MessengerConnection! connectMessenger(input: ConnectMessengerInput!): MessengerConnection!
upsertMyCounterpartyProfile(input: UpsertMyCounterpartyProfileInput!): CounterpartyProfile! upsertMyCounterpartyProfile(input: UpsertMyCounterpartyProfileInput!): CounterpartyProfile!
createMyDeliveryAddress(input: CreateMyDeliveryAddressInput!): DeliveryAddress!
setMyDefaultDeliveryAddress(addressId: ID!): DeliveryAddress!
deleteMyDeliveryAddress(addressId: ID!): Boolean!
sendTestMessengerMessage(type: MessengerType!, channelId: String, message: String): MessengerDispatchResult! sendTestMessengerMessage(type: MessengerType!, channelId: String, message: String): MessengerDispatchResult!
submitReadyOrder(input: SubmitReadyOrderInput!): Order! submitReadyOrder(input: SubmitReadyOrderInput!): Order!