Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions src/config/aws.config.ts
Original file line number Diff line number Diff line change
@@ -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,
},
});
3 changes: 2 additions & 1 deletion src/inventory/inventory.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,15 @@ 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';
import { InventoryController } from './inventory.controller';
import { NotificationsModule } from '../notifications/notifications.module';

@Module({
imports: [
imports: [ListingVariant,
TypeOrmModule.forFeature([Listing, Product, InventoryHistory]),
forwardRef(() => NotificationsModule),
EventEmitterModule.forRoot(),
Expand Down
116 changes: 104 additions & 12 deletions src/inventory/inventory.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -24,33 +25,124 @@ export class InventoryService {
@InjectRepository(Listing)
private readonly listingRepo: Repository<Listing>,
@InjectRepository(InventoryHistory)
private readonly historyRepo: Repository<InventoryHistory>,
private readonly hListingVariant)
private readonly variantRepo: Repository<ListingVariant>,
@InjectRepository(istoryRepo: Repository<InventoryHistory>,
@InjectRepository(Product)
private readonly productRepo: Repository<Product>,
@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,
Expand Down
2 changes: 2 additions & 0 deletions src/listing/dto/create-listing.dto.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { ListingVariantDto } from './listing-variant.dto';
import { Type } from 'class-transformer';
import { IsNotEmpty, IsNumber, IsString, Min } from 'class-validator';

Expand All @@ -24,4 +25,5 @@ export class CreateListingDto {
location: string;

expiresAt?: Date;
variants?: ListingVariantDto[];
}
8 changes: 8 additions & 0 deletions src/listing/dto/listing-variant.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export class ListingVariantDto {
sku?: string;
attributes?: Record<string, any>;
price: number;
currency?: string;
quantity?: number;
reserved?: number;
}
58 changes: 58 additions & 0 deletions src/listing/entities/listing-variant.entity.ts
Original file line number Diff line number Diff line change
@@ -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<string, any>;

@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;
}
43 changes: 43 additions & 0 deletions src/listing/entities/listing.entity.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Users } from '../../users/users.entity';
import { ListingVariant } from './listing-variant.entity';
import {
Entity,
PrimaryGeneratedColumn,
Expand All @@ -9,6 +10,7 @@ import {
UpdateDateColumn,
DeleteDateColumn,
ManyToMany,
OneToMany,
Index,
} from 'typeorm';

Expand Down Expand Up @@ -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;

Expand Down
3 changes: 2 additions & 1 deletion src/listing/listing.module.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
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';
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],
Expand Down
Loading
Loading