diff --git a/src/config/aws.config.ts b/src/config/aws.config.ts
new file mode 100644
index 0000000..103a5d1
--- /dev/null
+++ b/src/config/aws.config.ts
@@ -0,0 +1,9 @@
+// aws.config.ts
+export default () => ({
+ aws: {
+ region: process.env.AWS_REGION,
+ bucket: process.env.AWS_S3_BUCKET,
+ accessKeyId: process.env.AWS_ACCESS_KEY,
+ secretAccessKey: process.env.AWS_SECRET_KEY,
+ },
+});
\ No newline at end of file
diff --git a/src/inventory/inventory.module.ts b/src/inventory/inventory.module.ts
index 72bca5a..e8eac55 100644
--- a/src/inventory/inventory.module.ts
+++ b/src/inventory/inventory.module.ts
@@ -2,6 +2,7 @@ import { Module, forwardRef } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { EventEmitterModule } from '@nestjs/event-emitter';
import { Listing } from '../listing/entities/listing.entity';
+import { ListingVariant } from '../listing/entities/listing-variant.entity';
import { Product } from '../entities/product.entity';
import { InventoryHistory } from './inventory-history.entity';
import { InventoryService } from './inventory.service';
@@ -9,7 +10,7 @@ import { InventoryController } from './inventory.controller';
import { NotificationsModule } from '../notifications/notifications.module';
@Module({
- imports: [
+ imports: [ListingVariant,
TypeOrmModule.forFeature([Listing, Product, InventoryHistory]),
forwardRef(() => NotificationsModule),
EventEmitterModule.forRoot(),
diff --git a/src/inventory/inventory.service.ts b/src/inventory/inventory.service.ts
index f0e7767..147e60d 100644
--- a/src/inventory/inventory.service.ts
+++ b/src/inventory/inventory.service.ts
@@ -9,6 +9,7 @@ import { InjectRepository } from '@nestjs/typeorm';
import { Repository, DataSource, EntityManager } from 'typeorm';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { Listing } from '../listing/entities/listing.entity';
+import { ListingVariant } from '../listing/entities/listing-variant.entity';
import {
InventoryHistory,
InventoryChangeType,
@@ -24,33 +25,124 @@ export class InventoryService {
@InjectRepository(Listing)
private readonly listingRepo: Repository
,
@InjectRepository(InventoryHistory)
- private readonly historyRepo: Repository,
+ private readonly hListingVariant)
+ private readonly variantRepo: Repository,
+ @InjectRepository(istoryRepo: Repository,
@InjectRepository(Product)
private readonly productRepo: Repository,
@Inject(forwardRef(() => NotificationsService))
private readonly notificationsService: NotificationsService,
private readonly dataSource: DataSource,
- private readonly eventEmitter: EventEmitter2,
- ) {}
+ private async syncListingAggregate(listing: Listing, manager: EntityManager) {
+ const variants = await manager.find(ListingVariant, {
+ where: { listingId: listing.id },
+ });
- async adjustInventory(
- listingId: string,
+ if (variants.length > 0) {
+ listing.quantity = variants.reduce((sum, v) => sum + (v.quantity ?? 0), 0);
+ listing.reserved = variants.reduce((sum, v) => sum + (v.reserved ?? 0), 0);
+ listing.available = variants.reduce((sum, v) => sum + (v.available ?? 0), 0);
+ listing.price = Math.min(...variants.map((v) => Number(v.price)));
+ listing.currency = variants[0].currency;
+ } else {
+ listing.available = listing.quantity - listing.reserved;
+ }
+
+ await manager.save(listing);
+ }
+
+ async adjustVariantInventory(
+ variantId: string,
userId: string,
change: number,
type: InventoryChangeType,
note?: string,
) {
return this.dataSource.transaction(async (manager) => {
+ const variant = await manager.findOne(ListingVariant, {
+ where: { id: variantId },
+ });
+
+ if (!variant) {
+ throw new NotFoundException('Variant not found');
+ }
+
+ variant.quantity += change;
+ variant.available = variant.quantity - variant.reserved;
+ if (variant.available < 0) {
+ throw new BadRequestException('Insufficient inventory for variant');
+ }
+
+ await manager.save(variant);
const listing = await manager.findOne(Listing, {
- where: { id: listingId },
+ where: { id: variant.listingId },
});
- if (!listing) throw new NotFoundException('Listing not found');
- listing.quantity += change;
- listing.available = listing.quantity - listing.reserved;
- if (listing.available < 0)
- throw new BadRequestException('Insufficient inventory');
- await manager.save(listing);
+ if (listing) {
+ await this.syncListingAggregate(listing, manager);
+ }
+
const history = manager.create(InventoryHistory, {
+ listingId: variant.listingId,
+ userId,
+ change,
+ type,
+ note,
+ });
+ await manager.save(history);
+
+ if (variant.available <= 5 && listing) {
+ await this.notifyLowStock(listing);
+ }
+
+ return variant;
+ });
+ }
+
+ private readonly eventEmitter: EventEmitter2,
+ ) {}
+
+ async adjustInventory(
+ listingId: string,
+ userlet listing: Listing | null = null;
+ let variant: ListingVariant | null = null;
+
+ if ('variantId' in item && item.variantId) {
+ variant = await manager.findOne(ListingVariant, {
+ where: { id: item.variantId },
+ });
+ if (!variant) {
+ throw new NotFoundException(`Variant ${item.variantId} not found`);
+ }
+ listing = await manager.findOne(Listing, {
+ where: { id: variant.listingId },
+ });
+ } else {
+ listing = await manager.findOne(Listing, {
+ where: { id: item.productId },
+ });
+ }
+
+ if (!listing) {
+ throw new NotFoundException(`Listing ${item.productId} not found`);
+ }
+
+ const available = variant ? variant.available : listing.available;
+ if (available < item.quantity) {
+ throw new BadRequestException(
+ `Not enough inventory for ${item.productName}. Available: ${available}, Requested: ${item.quantity}`,
+ );
+ }
+
+ if (variant) {
+ variant.reserved += item.quantity;
+ variant.available = variant.quantity - variant.reserved;
+ await manager.save(variant);
+ await this.syncListingAggregate(listing, manager);
+ } else {
+ listing.reserved += item.quantity;
+ listing.available = listing.quantity - listing.reserved;
+ await manager.save(listing);
+ }(InventoryHistory, {
listingId,
userId,
change,
diff --git a/src/listing/dto/create-listing.dto.ts b/src/listing/dto/create-listing.dto.ts
index fcb8720..d9a9e1c 100644
--- a/src/listing/dto/create-listing.dto.ts
+++ b/src/listing/dto/create-listing.dto.ts
@@ -1,3 +1,4 @@
+import { ListingVariantDto } from './listing-variant.dto';
import { Type } from 'class-transformer';
import { IsNotEmpty, IsNumber, IsString, Min } from 'class-validator';
@@ -24,4 +25,5 @@ export class CreateListingDto {
location: string;
expiresAt?: Date;
+ variants?: ListingVariantDto[];
}
diff --git a/src/listing/dto/listing-variant.dto.ts b/src/listing/dto/listing-variant.dto.ts
new file mode 100644
index 0000000..ec1e977
--- /dev/null
+++ b/src/listing/dto/listing-variant.dto.ts
@@ -0,0 +1,8 @@
+export class ListingVariantDto {
+ sku?: string;
+ attributes?: Record;
+ price: number;
+ currency?: string;
+ quantity?: number;
+ reserved?: number;
+}
diff --git a/src/listing/entities/listing-variant.entity.ts b/src/listing/entities/listing-variant.entity.ts
new file mode 100644
index 0000000..a43d465
--- /dev/null
+++ b/src/listing/entities/listing-variant.entity.ts
@@ -0,0 +1,58 @@
+import {
+ Entity,
+ PrimaryGeneratedColumn,
+ Column,
+ ManyToOne,
+ JoinColumn,
+ CreateDateColumn,
+ UpdateDateColumn,
+ DeleteDateColumn,
+ Index,
+} from 'typeorm';
+import { Listing } from './listing.entity';
+
+@Entity('listing_variants')
+export class ListingVariant {
+ @PrimaryGeneratedColumn('uuid')
+ id: string;
+
+ @Column('uuid')
+ @Index()
+ listingId: string;
+
+ @ManyToOne(() => Listing, (listing) => listing.variants, {
+ onDelete: 'CASCADE',
+ })
+ @JoinColumn({ name: 'listingId' })
+ listing: Listing;
+
+ @Column({ type: 'varchar', length: 255, nullable: true })
+ sku?: string;
+
+ @Column({ type: 'jsonb', nullable: true })
+ attributes?: Record;
+
+ @Column({ type: 'decimal', precision: 10, scale: 2 })
+ price: number;
+
+ @Column({ type: 'varchar', length: 3, default: 'USD' })
+ currency: string;
+
+ @Column({ type: 'int', default: 1 })
+ quantity: number;
+
+ @Column({ type: 'int', default: 0 })
+ reserved: number;
+
+ @Column({ type: 'int', default: 1 })
+ available: number;
+
+ @CreateDateColumn()
+ createdAt: Date;
+
+ @UpdateDateColumn()
+ updatedAt: Date;
+
+ @DeleteDateColumn()
+ deletedAt?: Date;
+}
diff --git a/src/listing/entities/listing.entity.ts b/src/listing/entities/listing.entity.ts
index 98dd5aa..375eb18 100644
--- a/src/listing/entities/listing.entity.ts
+++ b/src/listing/entities/listing.entity.ts
@@ -1,4 +1,5 @@
import { Users } from '../../users/users.entity';
+import { ListingVariant } from './listing-variant.entity';
import {
Entity,
PrimaryGeneratedColumn,
@@ -9,6 +10,7 @@ import {
UpdateDateColumn,
DeleteDateColumn,
ManyToMany,
+ OneToMany,
Index,
} from 'typeorm';
@@ -82,9 +84,50 @@ export class Listing {
@ManyToMany(() => Users, (user) => user.favoriteListings)
favoritedBy: Users[];
+ @OneToMany(() => ListingVariant, (variant) => variant.listing, {
+ cascade: true,
+ eager: true,
+ })
+ variants?: ListingVariant[];
+
@Column({ type: 'int', default: 0 })
views: number;
+ get aggregatedQuantity(): number {
+ if (!this.variants || this.variants.length === 0) {
+ return this.quantity;
+ }
+ return this.variants.reduce((sum, variant) => sum + variant.quantity, 0);
+ }
+
+ get aggregatedReserved(): number {
+ if (!this.variants || this.variants.length === 0) {
+ return this.reserved;
+ }
+ return this.variants.reduce((sum, variant) => sum + variant.reserved, 0);
+ }
+
+ get aggregatedAvailable(): number {
+ if (!this.variants || this.variants.length === 0) {
+ return this.available;
+ }
+ return this.variants.reduce((sum, variant) => sum + variant.available, 0);
+ }
+
+ get minVariantPrice(): number {
+ if (!this.variants || this.variants.length === 0) {
+ return Number(this.price);
+ }
+ return Math.min(...this.variants.map((variant) => Number(variant.price)));
+ }
+
+ get variantCurrency(): string {
+ if (!this.variants || this.variants.length === 0) {
+ return this.currency;
+ }
+ return this.variants[0].currency;
+ }
+
@Column({ default: false })
isFeatured: boolean;
diff --git a/src/listing/listing.module.ts b/src/listing/listing.module.ts
index 97c6d95..d050298 100644
--- a/src/listing/listing.module.ts
+++ b/src/listing/listing.module.ts
@@ -1,5 +1,6 @@
import { Module } from '@nestjs/common';
import { Listing } from './entities/listing.entity';
+import { ListingVariant } from './entities/listing-variant.entity';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ListingsController } from './listing.controller';
import { ListingsService } from './listing.service';
@@ -7,7 +8,7 @@ import { ConfigModule } from '@nestjs/config';
import { SearchModule } from '../search/search.module';
@Module({
- imports: [TypeOrmModule.forFeature([Listing]), ConfigModule, SearchModule],
+ imports: [TypeOrmModule.forFeature([Listing, ListingVariant]), ConfigModule, SearchModule],
controllers: [ListingsController],
providers: [ListingsService],
exports: [ListingsService],
diff --git a/src/listing/listing.service.ts b/src/listing/listing.service.ts
index 9a14a3e..e36a68a 100644
--- a/src/listing/listing.service.ts
+++ b/src/listing/listing.service.ts
@@ -5,6 +5,7 @@ import { createReadStream } from 'node:fs';
import { CreateListingDto } from './dto/create-listing.dto';
import { UpdateListingDto } from './dto/update-listing.dto';
import { Listing } from './entities/listing.entity';
+import { ListingVariant } from './entities/listing-variant.entity';
import { ConfigService } from '@nestjs/config';
import { SearchSyncService } from '../search/search-sync.service';
import { parse } from 'csv-parse';
@@ -37,28 +38,102 @@ export class ListingsService {
private readonly searchSyncService: SearchSyncService,
) {}
+ private buildVariant(dto: Partial): ListingVariant {
+ const variant = new ListingVariant();
+ variant.sku = dto.sku;
+ variant.attributes = dto.attributes;
+ variant.price = dto.price ?? 0;
+ variant.currency = dto.currency ?? 'USD';
+ variant.quantity = dto.quantity ?? 1;
+ variant.reserved = dto.reserved ?? 0;
+ variant.available = variant.quantity - variant.reserved;
+ if (variant.available < 0) {
+ variant.available = 0;
+ }
+ return variant;
+ }
+
+ private aggregateListing(listing: Listing): Listing {
+ if (listing.variants && listing.variants.length > 0) {
+ const totalQuantity = listing.variants.reduce(
+ (sum, variant) => sum + (variant.quantity ?? 0),
+ 0,
+ );
+ const totalReserved = listing.variants.reduce(
+ (sum, variant) => sum + (variant.reserved ?? 0),
+ 0,
+ );
+ const totalAvailable = listing.variants.reduce(
+ (sum, variant) => sum + (variant.available ?? 0),
+ 0,
+ );
+ const minPrice = Math.min(
+ ...listing.variants.map((variant) => Number(variant.price)),
+ );
+
+ listing.quantity = totalQuantity;
+ listing.reserved = totalReserved;
+ listing.available = totalAvailable;
+ listing.price = minPrice;
+ listing.currency = listing.variants[0].currency || 'USD';
+ } else {
+ // preserve fallback root-level values, and compute available
+ listing.available = Math.max(0, listing.quantity - listing.reserved);
+ }
+ return listing;
+ }
+
+ private prepareVariants(dto: CreateListingDto | UpdateListingDto): ListingVariant[] {
+ if (dto.variants && dto.variants.length > 0) {
+ return dto.variants.map((variantData) =>
+ this.buildVariant({
+ price: variantData.price,
+ currency: variantData.currency,
+ quantity: variantData.quantity ?? 1,
+ reserved: variantData.reserved ?? 0,
+ sku: variantData.sku,
+ attributes: variantData.attributes,
+ }),
+ );
+ }
+
+ // fallback single-variant behavior for legacy payloads
+ if (dto.price !== undefined) {
+ return [
+ this.buildVariant({
+ price: dto.price,
+ currency: dto.currency ?? 'USD',
+ quantity: dto.quantity ?? 1,
+ reserved: dto.reserved ?? 0,
+ }),
+ ];
+ }
+
+ return [];
+ }
+
async create(dto: CreateListingDto, userId: string) {
- const expiryDays = this.configService.get(
- 'LISTING_EXPIRY_DAYS',
- 30,
- );
+ const expiryDays = this.configService.get('LISTING_EXPIRY_DAYS', 30);
const now = new Date();
- const expiresAt = new Date(
- now.getTime() + expiryDays * 24 * 60 * 60 * 1000,
- );
+ const expiresAt = new Date(now.getTime() + expiryDays * 24 * 60 * 60 * 1000);
+
const listing = this.listingRepo.create({ ...dto, userId, expiresAt });
+ const variants = this.prepareVariants(dto);
+ if (variants.length > 0) {
+ listing.variants = variants;
+ this.aggregateListing(listing);
+ }
+
const saved = await this.listingRepo.save(listing);
// Index in search service
try {
await this.searchSyncService.syncSingleListing(saved, 'index');
} catch (error) {
- this.logger.warn(
- `Failed to index listing ${saved.id} in search: ${error.message}`,
- );
+ this.logger.warn(`Failed to index listing ${saved.id} in search: ${error.message}`);
}
- return saved;
+ return this.aggregateListing(saved);
}
async importFromCsv(
@@ -160,29 +235,39 @@ export class ListingsService {
}
async findOne(id: string) {
- const listing = await this.listingRepo.findOneBy({ id });
+ const listing = await this.listingRepo.findOne({
+ where: { id },
+ relations: ['variants'],
+ });
if (!listing) throw new NotFoundException('Listing not found');
+
// Increment views count
listing.views = (listing.views || 0) + 1;
await this.listingRepo.save(listing);
- return listing;
+
+ return this.aggregateListing(listing);
}
async update(id: string, dto: UpdateListingDto) {
const listing = await this.findOne(id);
+
+ if (dto.variants) {
+ listing.variants = this.prepareVariants(dto);
+ }
+
Object.assign(listing, dto);
+ this.aggregateListing(listing);
+
const saved = await this.listingRepo.save(listing);
// Update search index
try {
await this.searchSyncService.syncSingleListing(saved, 'update');
} catch (error) {
- this.logger.warn(
- `Failed to update listing ${saved.id} in search: ${error.message}`,
- );
+ this.logger.warn(`Failed to update listing ${saved.id} in search: ${error.message}`);
}
- return saved;
+ return this.aggregateListing(saved);
}
async delete(id: string) {
@@ -205,21 +290,18 @@ export class ListingsService {
const now = new Date();
const query = this.listingRepo
.createQueryBuilder('listing')
- .innerJoin('listing.user', 'user', 'user.deletedAt IS NULL')
+ .leftJoin('listing.user', 'user')
+ .leftJoinAndSelect('listing.variants', 'variant')
.where('listing.isActive = :isActive', { isActive: true })
.andWhere('listing.deletedAt IS NULL')
- .andWhere('(listing.expiresAt IS NULL OR listing.expiresAt > :now)', {
- now,
- });
+ .andWhere('(listing.expiresAt IS NULL OR listing.expiresAt > :now)', { now });
if (category) {
query.andWhere('listing.category = :category', { category });
}
if (location) {
- query.andWhere('listing.location ILIKE :location', {
- location: `%${location}%`,
- });
+ query.andWhere('listing.location ILIKE :location', { location: `%${location}%` });
}
if (minPrice !== undefined) {
@@ -231,19 +313,17 @@ export class ListingsService {
}
if (q) {
- query.andWhere(
- '(listing.title ILIKE :q OR listing.description ILIKE :q)',
- { q: `%${q}%` },
- );
+ query.andWhere('(listing.title ILIKE :q OR listing.description ILIKE :q)', { q: `%${q}%` });
}
- query.orderBy('listing.createdAt', 'DESC');
- query.take(take);
- query.skip(skip);
+ query.orderBy('listing.createdAt', 'DESC').take(take).skip(skip);
const [listings, total] = await query.getManyAndCount();
- return { listings, total };
+ return {
+ listings: listings.map((l) => this.aggregateListing(l)),
+ total,
+ };
}
async findAll(page?: number, limit?: number, category?: string) {
@@ -252,7 +332,8 @@ export class ListingsService {
const query = this.listingRepo
.createQueryBuilder('listing')
- .innerJoin('listing.user', 'user', 'user.deletedAt IS NULL')
+ .leftJoin('listing.user', 'user')
+ .leftJoinAndSelect('listing.variants', 'variant')
.where('listing.isActive = :isActive', { isActive: true })
.andWhere('listing.deletedAt IS NULL');
@@ -263,18 +344,26 @@ export class ListingsService {
query.orderBy('listing.createdAt', 'DESC').take(take).skip(skip);
const [listings, total] = await query.getManyAndCount();
- return { listings, total, page: page || 1, limit: take };
+ return {
+ listings: listings.map((l) => this.aggregateListing(l)),
+ total,
+ page: page || 1,
+ limit: take,
+ };
}
async findFeatured() {
- return this.listingRepo
+ const listings = await this.listingRepo
.createQueryBuilder('listing')
- .innerJoin('listing.user', 'user', 'user.deletedAt IS NULL')
+ .leftJoin('listing.user', 'user')
+ .leftJoinAndSelect('listing.variants', 'variant')
.where('listing.isActive = :isActive', { isActive: true })
.andWhere('listing.deletedAt IS NULL')
.orderBy('listing.createdAt', 'DESC')
.take(10)
.getMany();
+
+ return listings.map((l) => this.aggregateListing(l));
}
async remove(id: string) {
diff --git a/src/orders/entities/order.entity.ts b/src/orders/entities/order.entity.ts
index d214eea..df25e2b 100644
--- a/src/orders/entities/order.entity.ts
+++ b/src/orders/entities/order.entity.ts
@@ -42,6 +42,7 @@ export class Order {
@Column({ type: 'json', default: [] })
items: Array<{
productId: string;
+ variantId?: string;
productName: string;
quantity: number;
price: number;