Skip to content
Draft
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
59 changes: 59 additions & 0 deletions prisma/migrations/20251129030447/migration.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
-- DropIndex
DROP INDEX "Category_storeId_slug_idx";

-- DropIndex
DROP INDEX "Category_slug_idx";

-- DropIndex
DROP INDEX "Customer_email_storeId_idx";

-- DropIndex
DROP INDEX "Customer_storeId_email_idx";

-- DropIndex
DROP INDEX "Order_customerId_createdAt_idx";

-- DropIndex
DROP INDEX "Order_paymentStatus_idx";

-- DropIndex
DROP INDEX "Order_orderNumber_idx";

-- DropIndex
DROP INDEX "ProductAttribute_name_idx";

-- RedefineTables
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_InventoryLog" (
"id" TEXT NOT NULL PRIMARY KEY,
"storeId" TEXT NOT NULL,
"productId" TEXT NOT NULL,
"variantId" TEXT,
"orderId" TEXT,
"previousQty" INTEGER NOT NULL,
"newQty" INTEGER NOT NULL,
"changeQty" INTEGER NOT NULL,
"reason" TEXT NOT NULL,
"note" TEXT,
"userId" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "InventoryLog_storeId_fkey" FOREIGN KEY ("storeId") REFERENCES "Store" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "InventoryLog_productId_fkey" FOREIGN KEY ("productId") REFERENCES "Product" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "InventoryLog_variantId_fkey" FOREIGN KEY ("variantId") REFERENCES "ProductVariant" ("id") ON DELETE SET NULL ON UPDATE CASCADE,
CONSTRAINT "InventoryLog_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
INSERT INTO "new_InventoryLog" ("changeQty", "createdAt", "id", "newQty", "note", "previousQty", "productId", "reason", "storeId", "userId") SELECT "changeQty", "createdAt", "id", "newQty", "note", "previousQty", "productId", "reason", "storeId", "userId" FROM "InventoryLog";
DROP TABLE "InventoryLog";
ALTER TABLE "new_InventoryLog" RENAME TO "InventoryLog";
CREATE INDEX "InventoryLog_storeId_productId_createdAt_idx" ON "InventoryLog"("storeId", "productId", "createdAt");
CREATE INDEX "InventoryLog_productId_createdAt_idx" ON "InventoryLog"("productId", "createdAt");
CREATE INDEX "InventoryLog_variantId_createdAt_idx" ON "InventoryLog"("variantId", "createdAt");
CREATE INDEX "InventoryLog_orderId_idx" ON "InventoryLog"("orderId");
CREATE INDEX "InventoryLog_userId_createdAt_idx" ON "InventoryLog"("userId", "createdAt");
CREATE INDEX "InventoryLog_reason_idx" ON "InventoryLog"("reason");
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;

-- CreateIndex
CREATE INDEX "Order_storeId_customerId_createdAt_idx" ON "Order"("storeId", "customerId", "createdAt");
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
-- CreateTable
CREATE TABLE "inventory_reservations" (
"id" TEXT NOT NULL PRIMARY KEY,
"storeId" TEXT NOT NULL,
"productId" TEXT NOT NULL,
"variantId" TEXT,
"quantity" INTEGER NOT NULL,
"status" TEXT NOT NULL DEFAULT 'ACTIVE',
"cartId" TEXT,
"orderId" TEXT,
"expiresAt" DATETIME NOT NULL,
"extendedAt" DATETIME,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "inventory_reservations_storeId_fkey" FOREIGN KEY ("storeId") REFERENCES "Store" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "inventory_reservations_productId_fkey" FOREIGN KEY ("productId") REFERENCES "Product" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "inventory_reservations_variantId_fkey" FOREIGN KEY ("variantId") REFERENCES "ProductVariant" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);

-- CreateIndex
CREATE INDEX "inventory_reservations_storeId_productId_idx" ON "inventory_reservations"("storeId", "productId");

-- CreateIndex
CREATE INDEX "inventory_reservations_storeId_variantId_idx" ON "inventory_reservations"("storeId", "variantId");

-- CreateIndex
CREATE INDEX "inventory_reservations_expiresAt_idx" ON "inventory_reservations"("expiresAt");

-- CreateIndex
CREATE INDEX "inventory_reservations_status_idx" ON "inventory_reservations"("status");

-- CreateIndex
CREATE INDEX "inventory_reservations_cartId_idx" ON "inventory_reservations"("cartId");

-- CreateIndex
CREATE INDEX "inventory_reservations_orderId_idx" ON "inventory_reservations"("orderId");
38 changes: 38 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,13 @@ enum SubscriptionStatus {
PAUSED
}

enum ReservationStatus {
ACTIVE
CONSUMED
EXPIRED
RELEASED
}

// Store model (E-commerce tenant - extends Organization)
model Store {
id String @id @default(cuid())
Expand Down Expand Up @@ -272,6 +279,7 @@ model Store {
attributes ProductAttribute[]
auditLogs AuditLog[]
inventoryLogs InventoryLog[] @relation("StoreInventoryLogs")
inventoryReservations InventoryReservation[]

createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
Expand Down Expand Up @@ -333,6 +341,7 @@ model Product {
attributes ProductAttributeValue[]
reviews Review[]
inventoryLogs InventoryLog[] @relation("InventoryLogs")
inventoryReservations InventoryReservation[]

createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
Expand Down Expand Up @@ -372,6 +381,7 @@ model ProductVariant {

orderItems OrderItem[]
inventoryLogs InventoryLog[] @relation("VariantInventoryLogs")
inventoryReservations InventoryReservation[]

createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
Expand Down Expand Up @@ -675,4 +685,32 @@ model AuditLog {
@@index([userId, createdAt])
@@index([entityType, entityId, createdAt])
@@map("audit_logs")
}

// Inventory Reservation model (for cart hold / soft allocation)
model InventoryReservation {
id String @id @default(cuid())
storeId String
store Store @relation(fields: [storeId], references: [id], onDelete: Cascade)
productId String
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
variantId String?
variant ProductVariant? @relation(fields: [variantId], references: [id], onDelete: Cascade)
quantity Int
status ReservationStatus @default(ACTIVE)
cartId String? // Cart session reference
orderId String? // Set when consumed by order
expiresAt DateTime // When reservation expires (default 15 min TTL)
extendedAt DateTime? // Set when reservation is extended (only once allowed)

createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

@@index([storeId, productId])
@@index([storeId, variantId])
@@index([expiresAt])
@@index([status])
@@index([cartId])
@@index([orderId])
@@map("inventory_reservations")
}
109 changes: 109 additions & 0 deletions src/app/api/inventory/extend/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
// src/app/api/inventory/extend/route.ts
// Inventory Reservation Extension API Endpoint
// POST /api/inventory/extend - Extend reservation (once per reservation)

import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth/next';
import { authOptions } from '@/lib/auth';
import { InventoryReservationService, MAX_EXTENSION_MINUTES } from '@/lib/services/inventory-reservation.service';
import { z } from 'zod';

const extendSchema = z.object({
storeId: z.string().cuid(),
reservationId: z.string().cuid(),
extensionMinutes: z.number().int().positive().max(MAX_EXTENSION_MINUTES).default(MAX_EXTENSION_MINUTES),
});

// POST /api/inventory/extend - Extend a reservation
export async function POST(request: NextRequest) {
try {
const session = await getServerSession(authOptions);

if (!session?.user) {
return NextResponse.json(
{ error: 'Unauthorized' },
{ status: 401 }
);
}

const body = await request.json();
const validatedData = extendSchema.parse(body);

const { storeId, reservationId, extensionMinutes } = validatedData;

// Verify store membership to prevent cross-tenant access
const { prisma } = await import('@/lib/prisma');
const membership = await prisma.membership.findFirst({
where: {
userId: session.user.id,
organization: {
store: {
id: storeId
}
}
}
});

if (!membership) {
return NextResponse.json(
{ error: 'Forbidden: You do not have access to this store' },
{ status: 403 }
);
}

const reservationService = InventoryReservationService.getInstance();

try {
const result = await reservationService.extendReservation(
reservationId,
storeId,
extensionMinutes
);

if (!result) {
return NextResponse.json(
{ error: 'Reservation not found or already expired' },
{ status: 404 }
);
}

return NextResponse.json({
success: true,
message: 'Reservation extended successfully',
reservation: result,
});
} catch (extendError) {
// Handle "already extended" error
if (extendError instanceof Error && extendError.message.includes('only be extended once')) {
return NextResponse.json(
{
error: 'Extension failed',
details: extendError.message,
},
{ status: 409 } // Conflict
);
}
throw extendError;
}
} catch (error) {
console.error('POST /api/inventory/extend error:', error);

if (error instanceof z.ZodError) {
return NextResponse.json(
{
error: 'Validation error',
details: error.issues,
},
{ status: 400 }
);
}

return NextResponse.json(
{
error: 'Failed to extend reservation',
details: error instanceof Error ? error.message : 'Unknown error',
},
{ status: 500 }
);
}
}
116 changes: 116 additions & 0 deletions src/app/api/inventory/release/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
// src/app/api/inventory/release/route.ts
// Inventory Release API Endpoint
// POST /api/inventory/release - Release reservations manually

import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth/next';
import { authOptions } from '@/lib/auth';
import { InventoryReservationService } from '@/lib/services/inventory-reservation.service';
import { z } from 'zod';

const releaseSchema = z.object({
storeId: z.string().cuid(),
// Either reservationId or cartId must be provided
reservationId: z.string().cuid().optional(),
cartId: z.string().optional(),
}).refine(
(data) => data.reservationId || data.cartId,
{ message: 'Either reservationId or cartId must be provided' }
);

// POST /api/inventory/release - Release reservations
export async function POST(request: NextRequest) {
try {
const session = await getServerSession(authOptions);

if (!session?.user) {
return NextResponse.json(
{ error: 'Unauthorized' },
{ status: 401 }
);
}

const body = await request.json();
const validatedData = releaseSchema.parse(body);

const { storeId, reservationId, cartId } = validatedData;

// Verify store membership to prevent cross-tenant access
const { prisma } = await import('@/lib/prisma');
const membership = await prisma.membership.findFirst({
where: {
userId: session.user.id,
organization: {
store: {
id: storeId
}
}
}
});

if (!membership) {
return NextResponse.json(
{ error: 'Forbidden: You do not have access to this store' },
{ status: 403 }
);
}

const reservationService = InventoryReservationService.getInstance();

if (reservationId) {
// Release single reservation
const released = await reservationService.releaseReservation(reservationId, storeId);

if (!released) {
return NextResponse.json(
{ error: 'Reservation not found or already released' },
{ status: 404 }
);
}

return NextResponse.json({
success: true,
message: 'Reservation released successfully',
reservationId,
});
} else if (cartId) {
// Release all reservations for cart
const count = await reservationService.releaseCartReservations(cartId, storeId);

return NextResponse.json({
success: true,
message: count > 0
? `${count} reservation(s) released successfully`
: 'No active reservations found for this cart',
releasedCount: count,
cartId,
});
}

// This should never happen due to zod validation
return NextResponse.json(
{ error: 'Invalid request' },
{ status: 400 }
);
} catch (error) {
console.error('POST /api/inventory/release error:', error);

if (error instanceof z.ZodError) {
return NextResponse.json(
{
error: 'Validation error',
details: error.issues,
},
{ status: 400 }
);
}

return NextResponse.json(
{
error: 'Failed to release reservations',
details: error instanceof Error ? error.message : 'Unknown error',
},
{ status: 500 }
);
}
}
Loading