Store a customer address list for physical catalog requests.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-26 11:12:10 +03:30
co-authored by Cursor
parent d0122ab08b
commit 7ee78b070a
11 changed files with 547 additions and 7 deletions
@@ -0,0 +1,13 @@
CREATE TABLE "CustomerAddress" (
"id" TEXT NOT NULL,
"customerId" TEXT NOT NULL,
"label" TEXT,
"text" TEXT NOT NULL,
"isDefault" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "CustomerAddress_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "CustomerAddress_customerId_idx" ON "CustomerAddress"("customerId");
ALTER TABLE "CustomerAddress" ADD CONSTRAINT "CustomerAddress_customerId_fkey" FOREIGN KEY ("customerId") REFERENCES "Customer"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+14
View File
@@ -276,6 +276,7 @@ model Customer {
stockNotifyRequests StockNotifyRequest[]
productSuggestions ProductSuggestion[]
customerBrands CustomerBrand[]
addresses CustomerAddress[]
@@index([companyId])
@@index([appRole])
@@ -284,6 +285,19 @@ model Customer {
@@index([levelDefId])
}
model CustomerAddress {
id String @id @default(cuid())
customerId String
label String?
text String
isDefault Boolean @default(false)
createdAt DateTime @default(now())
customer Customer @relation(fields: [customerId], references: [id], onDelete: Cascade)
@@index([customerId])
}
model AdminUser {
id String @id @default(cuid())
fullName String
@@ -0,0 +1,13 @@
CREATE TABLE "CustomerAddress" (
"id" TEXT NOT NULL,
"customerId" TEXT NOT NULL,
"label" TEXT,
"text" TEXT NOT NULL,
"isDefault" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "CustomerAddress_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "CustomerAddress_customerId_idx" ON "CustomerAddress"("customerId");
ALTER TABLE "CustomerAddress" ADD CONSTRAINT "CustomerAddress_customerId_fkey" FOREIGN KEY ("customerId") REFERENCES "Customer"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+14
View File
@@ -276,6 +276,7 @@ model Customer {
stockNotifyRequests StockNotifyRequest[]
productSuggestions ProductSuggestion[]
customerBrands CustomerBrand[]
addresses CustomerAddress[]
@@index([companyId])
@@index([appRole])
@@ -284,6 +285,19 @@ model Customer {
@@index([levelDefId])
}
model CustomerAddress {
id String @id @default(cuid())
customerId String
label String?
text String
isDefault Boolean @default(false)
createdAt DateTime @default(now())
customer Customer @relation(fields: [customerId], references: [id], onDelete: Cascade)
@@index([customerId])
}
model AdminUser {
id String @id @default(cuid())
fullName String
+12 -3
View File
File diff suppressed because one or more lines are too long
+306 -4
View File
File diff suppressed because one or more lines are too long
+32
View File
@@ -0,0 +1,32 @@
import { Body, Controller, Delete, Get, Param, Post } from '@nestjs/common';
import { AuthType } from '../common/decorators/auth.decorators';
import { CurrentUser } from '../common/decorators/current-user.decorator';
import type { AuthPrincipal } from '../common/decorators/current-user.decorator';
import { AddressesService } from './addresses.service';
import { CreateAddressDto } from './dto/address.dto';
@Controller('addresses')
@AuthType('customer')
export class AddressesController {
constructor(private readonly addresses: AddressesService) {}
@Get()
list(@CurrentUser() user: AuthPrincipal) {
return this.addresses.list(user.id);
}
@Post()
create(@CurrentUser() user: AuthPrincipal, @Body() dto: CreateAddressDto) {
return this.addresses.create(user.id, dto.text, dto.label);
}
@Post(':id/default')
makeDefault(@CurrentUser() user: AuthPrincipal, @Param('id') id: string) {
return this.addresses.makeDefault(user.id, id);
}
@Delete(':id')
remove(@CurrentUser() user: AuthPrincipal, @Param('id') id: string) {
return this.addresses.remove(user.id, id);
}
}
+9
View File
@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { AddressesController } from './addresses.controller';
import { AddressesService } from './addresses.service';
@Module({
controllers: [AddressesController],
providers: [AddressesService],
})
export class AddressesModule {}
+119
View File
@@ -0,0 +1,119 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
@Injectable()
export class AddressesService {
constructor(private readonly prisma: PrismaService) {}
async list(customerId: string) {
const rows = await this.prisma.customerAddress.findMany({
where: { customerId },
orderBy: [{ isDefault: 'desc' }, { createdAt: 'asc' }],
});
if (rows.length) return rows.map(mapAddress);
const legacy = await this.legacyAddress(customerId);
if (!legacy) return [];
const created = await this.prisma.customerAddress.create({
data: { customerId, text: legacy, isDefault: true },
});
return [mapAddress(created)];
}
async create(customerId: string, text: string, label?: string) {
const trimmed = text.trim();
const count = await this.prisma.customerAddress.count({ where: { customerId } });
const row = await this.prisma.customerAddress.create({
data: {
customerId,
text: trimmed,
label: label?.trim() || null,
isDefault: count === 0,
},
});
if (row.isDefault) await this.syncLegacy(customerId, trimmed);
return mapAddress(row);
}
async makeDefault(customerId: string, id: string) {
const row = await this.own(customerId, id);
await this.prisma.$transaction([
this.prisma.customerAddress.updateMany({
where: { customerId },
data: { isDefault: false },
}),
this.prisma.customerAddress.update({
where: { id: row.id },
data: { isDefault: true },
}),
]);
await this.syncLegacy(customerId, row.text);
return this.list(customerId);
}
async remove(customerId: string, id: string) {
const row = await this.own(customerId, id);
await this.prisma.customerAddress.delete({ where: { id: row.id } });
if (row.isDefault) {
const next = await this.prisma.customerAddress.findFirst({
where: { customerId },
orderBy: { createdAt: 'asc' },
});
if (next) {
await this.prisma.customerAddress.update({
where: { id: next.id },
data: { isDefault: true },
});
await this.syncLegacy(customerId, next.text);
} else {
await this.syncLegacy(customerId, null);
}
}
return this.list(customerId);
}
private async own(customerId: string, id: string) {
const row = await this.prisma.customerAddress.findFirst({
where: { id, customerId },
});
if (!row) throw new NotFoundException('آدرس پیدا نشد');
return row;
}
private async legacyAddress(customerId: string): Promise<string | null> {
try {
const customer = await this.prisma.customer.findUnique({
where: { id: customerId },
select: { address: true },
});
return customer?.address?.trim() || null;
} catch {
return null;
}
}
private async syncLegacy(customerId: string, text: string | null) {
try {
await this.prisma.customer.update({
where: { id: customerId },
data: { address: text },
});
} catch {
/* ستون قدیمی آدرس روی بعضی دیتابیس‌ها هنوز نیست */
}
}
}
function mapAddress(row: {
id: string;
label: string | null;
text: string;
isDefault: boolean;
}) {
return {
id: row.id,
label: row.label,
text: row.text,
isDefault: row.isDefault,
};
}
+13
View File
@@ -0,0 +1,13 @@
import { IsOptional, IsString, MaxLength, MinLength } from 'class-validator';
export class CreateAddressDto {
@IsString()
@MinLength(3)
@MaxLength(500)
text!: string;
@IsOptional()
@IsString()
@MaxLength(40)
label?: string;
}
+2
View File
@@ -25,6 +25,7 @@ import { LevelsModule } from './levels/levels.module';
import { SuggestionsModule } from './suggestions/suggestions.module';
import { StockNotifyModule } from './stock-notify/stock-notify.module';
import { BannersModule } from './banners/banners.module';
import { AddressesModule } from './addresses/addresses.module';
@Module({
imports: [
@@ -52,6 +53,7 @@ import { BannersModule } from './banners/banners.module';
SuggestionsModule,
StockNotifyModule,
BannersModule,
AddressesModule,
],
providers: [
{