Add rental system: listings, bookings, payments, payouts, reviews

Full rental marketplace with 6 categories (apartment, house, car, motorcycle, bicycle, ebike).
Booking workflow: create → confirm → pay → active → complete → payout.
Landlord dashboard, admin moderation, availability calendar, Stripe Connect payouts.
14 QA bugs found and fixed including validator schemas, API response types, HTTP methods.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
delta-lynx-89e8
2026-02-22 15:33:29 -08:00
parent 8961fa701a
commit dbbbbd26f4
90 changed files with 11052 additions and 124 deletions

View File

@@ -0,0 +1,173 @@
-- CreateEnum
CREATE TYPE "UserRole" AS ENUM ('USER', 'MODERATOR', 'ADMIN', 'SUPER_ADMIN');
-- CreateEnum
CREATE TYPE "ReportReason" AS ENUM ('SPAM', 'INAPPROPRIATE', 'SCAM', 'COUNTERFEIT', 'PROHIBITED_ITEM', 'HARASSMENT', 'OTHER');
-- CreateEnum
CREATE TYPE "ReportStatus" AS ENUM ('OPEN', 'REVIEWING', 'RESOLVED', 'DISMISSED');
-- CreateEnum
CREATE TYPE "ReportTargetType" AS ENUM ('LISTING', 'USER');
-- CreateEnum
CREATE TYPE "SubscriptionTier" AS ENUM ('BASIC', 'PRO', 'BUSINESS');
-- CreateEnum
CREATE TYPE "SubscriptionStatus" AS ENUM ('ACTIVE', 'CANCELLED', 'EXPIRED', 'PAST_DUE');
-- CreateEnum
CREATE TYPE "PaymentType" AS ENUM ('LISTING_FEE', 'COMMISSION', 'PROMOTION', 'SUBSCRIPTION');
-- CreateEnum
CREATE TYPE "ModerationAction" AS ENUM ('APPROVED', 'REJECTED', 'WARNING', 'BAN', 'UNBAN', 'LISTING_DELETED', 'LISTING_FEATURED');
-- AlterEnum
ALTER TYPE "ListingStatus" ADD VALUE 'PENDING_REVIEW';
-- AlterEnum
-- This migration adds more than one value to an enum.
-- With PostgreSQL versions 11 and earlier, this is not possible
-- in a single migration. This can be worked around by creating
-- multiple migrations, each migration adding only one value to
-- the enum.
ALTER TYPE "NotificationType" ADD VALUE 'LISTING_APPROVED';
ALTER TYPE "NotificationType" ADD VALUE 'LISTING_REJECTED';
ALTER TYPE "NotificationType" ADD VALUE 'MODERATION_WARNING';
ALTER TYPE "NotificationType" ADD VALUE 'ACCOUNT_BANNED';
ALTER TYPE "NotificationType" ADD VALUE 'ACCOUNT_UNBANNED';
ALTER TYPE "NotificationType" ADD VALUE 'REPORT_RESOLVED';
-- AlterTable
ALTER TABLE "Listing" ADD COLUMN "isFeatured" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "rejectionReason" TEXT,
ADD COLUMN "reviewedAt" TIMESTAMP(3),
ADD COLUMN "reviewedBy" TEXT;
-- AlterTable
ALTER TABLE "Payment" ADD COLUMN "description" TEXT,
ADD COLUMN "type" "PaymentType" NOT NULL DEFAULT 'LISTING_FEE';
-- AlterTable
ALTER TABLE "User" ADD COLUMN "banReason" TEXT,
ADD COLUMN "bannedAt" TIMESTAMP(3),
ADD COLUMN "bannedBy" TEXT,
ADD COLUMN "isBanned" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "role" "UserRole" NOT NULL DEFAULT 'USER';
-- CreateTable
CREATE TABLE "Report" (
"id" TEXT NOT NULL,
"reporterId" TEXT NOT NULL,
"targetType" "ReportTargetType" NOT NULL,
"targetId" TEXT NOT NULL,
"reason" "ReportReason" NOT NULL,
"description" TEXT,
"status" "ReportStatus" NOT NULL DEFAULT 'OPEN',
"resolvedBy" TEXT,
"resolution" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Report_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "PlatformConfig" (
"id" TEXT NOT NULL,
"listingFee" DOUBLE PRECISION NOT NULL DEFAULT 5.00,
"commissionPercent" DOUBLE PRECISION NOT NULL DEFAULT 5.0,
"autoApprove" BOOLEAN NOT NULL DEFAULT true,
"maxImagesPerListing" INTEGER NOT NULL DEFAULT 6,
"maxListingsFreeTier" INTEGER NOT NULL DEFAULT 5,
"proPrice" DOUBLE PRECISION NOT NULL DEFAULT 9.99,
"businessPrice" DOUBLE PRECISION NOT NULL DEFAULT 29.99,
"promotionDayPrice" DOUBLE PRECISION NOT NULL DEFAULT 2.99,
"blockedKeywords" TEXT[] DEFAULT ARRAY[]::TEXT[],
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "PlatformConfig_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Subscription" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"tier" "SubscriptionTier" NOT NULL DEFAULT 'BASIC',
"status" "SubscriptionStatus" NOT NULL DEFAULT 'ACTIVE',
"stripeSubscriptionId" TEXT,
"currentPeriodEnd" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Subscription_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "PromotedListing" (
"id" TEXT NOT NULL,
"listingId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"startDate" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"endDate" TIMESTAMP(3) NOT NULL,
"amountPaid" DOUBLE PRECISION NOT NULL,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "PromotedListing_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ModerationLog" (
"id" TEXT NOT NULL,
"moderatorId" TEXT NOT NULL,
"targetUserId" TEXT,
"targetListingId" TEXT,
"action" "ModerationAction" NOT NULL,
"reason" TEXT,
"details" JSONB,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ModerationLog_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "Report_status_idx" ON "Report"("status");
-- CreateIndex
CREATE INDEX "Report_targetType_targetId_idx" ON "Report"("targetType", "targetId");
-- CreateIndex
CREATE UNIQUE INDEX "Subscription_userId_key" ON "Subscription"("userId");
-- CreateIndex
CREATE UNIQUE INDEX "Subscription_stripeSubscriptionId_key" ON "Subscription"("stripeSubscriptionId");
-- CreateIndex
CREATE UNIQUE INDEX "PromotedListing_listingId_key" ON "PromotedListing"("listingId");
-- CreateIndex
CREATE INDEX "PromotedListing_isActive_endDate_idx" ON "PromotedListing"("isActive", "endDate");
-- CreateIndex
CREATE INDEX "ModerationLog_moderatorId_idx" ON "ModerationLog"("moderatorId");
-- CreateIndex
CREATE INDEX "ModerationLog_createdAt_idx" ON "ModerationLog"("createdAt");
-- AddForeignKey
ALTER TABLE "Report" ADD CONSTRAINT "Report_reporterId_fkey" FOREIGN KEY ("reporterId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Subscription" ADD CONSTRAINT "Subscription_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "PromotedListing" ADD CONSTRAINT "PromotedListing_listingId_fkey" FOREIGN KEY ("listingId") REFERENCES "Listing"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "PromotedListing" ADD CONSTRAINT "PromotedListing_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ModerationLog" ADD CONSTRAINT "ModerationLog_moderatorId_fkey" FOREIGN KEY ("moderatorId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -0,0 +1,336 @@
-- CreateEnum
CREATE TYPE "RentalCategory" AS ENUM ('APARTMENT', 'HOUSE', 'CAR', 'MOTORCYCLE', 'BICYCLE', 'EBIKE');
-- CreateEnum
CREATE TYPE "RentalPeriodType" AS ENUM ('DAILY', 'MONTHLY');
-- CreateEnum
CREATE TYPE "RentalListingStatus" AS ENUM ('DRAFT', 'PENDING_REVIEW', 'ACTIVE', 'PAUSED', 'DELETED');
-- CreateEnum
CREATE TYPE "BookingStatus" AS ENUM ('PENDING', 'CONFIRMED', 'ACTIVE', 'COMPLETED', 'CANCELLED_BY_TENANT', 'CANCELLED_BY_LANDLORD', 'REJECTED', 'EXPIRED');
-- CreateEnum
CREATE TYPE "PayoutStatus" AS ENUM ('PENDING', 'PROCESSING', 'COMPLETED', 'FAILED');
-- CreateEnum
CREATE TYPE "CancellationPolicy" AS ENUM ('FLEXIBLE', 'MODERATE', 'STRICT');
-- AlterEnum
-- This migration adds more than one value to an enum.
-- With PostgreSQL versions 11 and earlier, this is not possible
-- in a single migration. This can be worked around by creating
-- multiple migrations, each migration adding only one value to
-- the enum.
ALTER TYPE "NotificationType" ADD VALUE 'BOOKING_REQUEST';
ALTER TYPE "NotificationType" ADD VALUE 'BOOKING_CONFIRMED';
ALTER TYPE "NotificationType" ADD VALUE 'BOOKING_REJECTED';
ALTER TYPE "NotificationType" ADD VALUE 'BOOKING_CANCELLED';
ALTER TYPE "NotificationType" ADD VALUE 'BOOKING_STARTED';
ALTER TYPE "NotificationType" ADD VALUE 'BOOKING_COMPLETED';
ALTER TYPE "NotificationType" ADD VALUE 'RENTAL_REVIEW';
ALTER TYPE "NotificationType" ADD VALUE 'PAYOUT_SENT';
ALTER TYPE "NotificationType" ADD VALUE 'PAYOUT_FAILED';
-- AlterEnum
-- This migration adds more than one value to an enum.
-- With PostgreSQL versions 11 and earlier, this is not possible
-- in a single migration. This can be worked around by creating
-- multiple migrations, each migration adding only one value to
-- the enum.
ALTER TYPE "PaymentType" ADD VALUE 'RENTAL_BOOKING';
ALTER TYPE "PaymentType" ADD VALUE 'RENTAL_COMMISSION';
ALTER TYPE "PaymentType" ADD VALUE 'RENTAL_DEPOSIT';
ALTER TYPE "PaymentType" ADD VALUE 'RENTAL_DEPOSIT_REFUND';
ALTER TYPE "PaymentType" ADD VALUE 'RENTAL_PAYOUT';
-- AlterTable
ALTER TABLE "Conversation" ADD COLUMN "rentalListingId" TEXT;
-- AlterTable
ALTER TABLE "PlatformConfig" ADD COLUMN "bookingExpiryHours" INTEGER NOT NULL DEFAULT 48,
ADD COLUMN "maxRentalImagesPerListing" INTEGER NOT NULL DEFAULT 10,
ADD COLUMN "rentalAutoApprove" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "rentalCommissionPercent" DOUBLE PRECISION NOT NULL DEFAULT 10.0,
ADD COLUMN "rentalPromotionDayPrice" DOUBLE PRECISION NOT NULL DEFAULT 3.99;
-- AlterTable
ALTER TABLE "User" ADD COLUMN "isLandlord" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "landlordVerified" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "stripeAccountId" TEXT;
-- CreateTable
CREATE TABLE "RentalListing" (
"id" TEXT NOT NULL,
"title" TEXT NOT NULL,
"description" TEXT NOT NULL,
"category" "RentalCategory" NOT NULL,
"location" TEXT NOT NULL,
"dailyPrice" DOUBLE PRECISION,
"monthlyPrice" DOUBLE PRECISION,
"depositAmount" DOUBLE PRECISION,
"details" JSONB,
"amenities" TEXT[] DEFAULT ARRAY[]::TEXT[],
"rules" TEXT[] DEFAULT ARRAY[]::TEXT[],
"cancellationPolicy" "CancellationPolicy" NOT NULL DEFAULT 'FLEXIBLE',
"minDays" INTEGER,
"maxDays" INTEGER,
"minMonths" INTEGER,
"maxMonths" INTEGER,
"status" "RentalListingStatus" NOT NULL DEFAULT 'DRAFT',
"viewCount" INTEGER NOT NULL DEFAULT 0,
"isFeatured" BOOLEAN NOT NULL DEFAULT false,
"isVerified" BOOLEAN NOT NULL DEFAULT false,
"rejectionReason" TEXT,
"reviewedBy" TEXT,
"reviewedAt" TIMESTAMP(3),
"landlordId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "RentalListing_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "RentalImage" (
"id" TEXT NOT NULL,
"url" TEXT NOT NULL,
"order" INTEGER NOT NULL DEFAULT 0,
"rentalListingId" TEXT NOT NULL,
CONSTRAINT "RentalImage_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "AvailabilityBlock" (
"id" TEXT NOT NULL,
"rentalListingId" TEXT NOT NULL,
"startDate" TIMESTAMP(3) NOT NULL,
"endDate" TIMESTAMP(3) NOT NULL,
"isBlocked" BOOLEAN NOT NULL DEFAULT true,
"reason" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "AvailabilityBlock_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Booking" (
"id" TEXT NOT NULL,
"rentalListingId" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"landlordId" TEXT NOT NULL,
"periodType" "RentalPeriodType" NOT NULL,
"startDate" TIMESTAMP(3) NOT NULL,
"endDate" TIMESTAMP(3) NOT NULL,
"pricePerPeriod" DOUBLE PRECISION NOT NULL,
"totalPeriods" INTEGER NOT NULL,
"subtotal" DOUBLE PRECISION NOT NULL,
"commissionRate" DOUBLE PRECISION NOT NULL DEFAULT 10.0,
"commissionAmount" DOUBLE PRECISION NOT NULL,
"depositAmount" DOUBLE PRECISION NOT NULL DEFAULT 0,
"totalAmount" DOUBLE PRECISION NOT NULL,
"status" "BookingStatus" NOT NULL DEFAULT 'PENDING',
"message" TEXT,
"rejectionReason" TEXT,
"cancellationReason" TEXT,
"stripePaymentIntentId" TEXT,
"expiresAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Booking_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "BookingPayment" (
"id" TEXT NOT NULL,
"bookingId" TEXT NOT NULL,
"stripePaymentId" TEXT,
"amount" DOUBLE PRECISION NOT NULL,
"type" "PaymentType" NOT NULL,
"status" "PaymentStatus" NOT NULL DEFAULT 'PENDING',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "BookingPayment_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Payout" (
"id" TEXT NOT NULL,
"bookingId" TEXT NOT NULL,
"landlordId" TEXT NOT NULL,
"grossAmount" DOUBLE PRECISION NOT NULL,
"commissionAmount" DOUBLE PRECISION NOT NULL,
"netAmount" DOUBLE PRECISION NOT NULL,
"status" "PayoutStatus" NOT NULL DEFAULT 'PENDING',
"stripeTransferId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Payout_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "RentalReview" (
"id" TEXT NOT NULL,
"bookingId" TEXT NOT NULL,
"rentalListingId" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"landlordId" TEXT NOT NULL,
"rating" INTEGER NOT NULL,
"comment" TEXT,
"landlordResponse" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "RentalReview_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "RentalFavorite" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"rentalListingId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "RentalFavorite_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "PromotedRental" (
"id" TEXT NOT NULL,
"rentalListingId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"startDate" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"endDate" TIMESTAMP(3) NOT NULL,
"amountPaid" DOUBLE PRECISION NOT NULL,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "PromotedRental_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "RentalListing_landlordId_idx" ON "RentalListing"("landlordId");
-- CreateIndex
CREATE INDEX "RentalListing_category_idx" ON "RentalListing"("category");
-- CreateIndex
CREATE INDEX "RentalListing_status_idx" ON "RentalListing"("status");
-- CreateIndex
CREATE INDEX "RentalListing_createdAt_idx" ON "RentalListing"("createdAt");
-- CreateIndex
CREATE INDEX "RentalImage_rentalListingId_idx" ON "RentalImage"("rentalListingId");
-- CreateIndex
CREATE INDEX "AvailabilityBlock_rentalListingId_idx" ON "AvailabilityBlock"("rentalListingId");
-- CreateIndex
CREATE INDEX "AvailabilityBlock_startDate_endDate_idx" ON "AvailabilityBlock"("startDate", "endDate");
-- CreateIndex
CREATE INDEX "Booking_rentalListingId_idx" ON "Booking"("rentalListingId");
-- CreateIndex
CREATE INDEX "Booking_tenantId_idx" ON "Booking"("tenantId");
-- CreateIndex
CREATE INDEX "Booking_landlordId_idx" ON "Booking"("landlordId");
-- CreateIndex
CREATE INDEX "Booking_status_idx" ON "Booking"("status");
-- CreateIndex
CREATE UNIQUE INDEX "BookingPayment_stripePaymentId_key" ON "BookingPayment"("stripePaymentId");
-- CreateIndex
CREATE INDEX "BookingPayment_bookingId_idx" ON "BookingPayment"("bookingId");
-- CreateIndex
CREATE UNIQUE INDEX "Payout_bookingId_key" ON "Payout"("bookingId");
-- CreateIndex
CREATE INDEX "Payout_landlordId_idx" ON "Payout"("landlordId");
-- CreateIndex
CREATE INDEX "Payout_status_idx" ON "Payout"("status");
-- CreateIndex
CREATE UNIQUE INDEX "RentalReview_bookingId_key" ON "RentalReview"("bookingId");
-- CreateIndex
CREATE INDEX "RentalReview_rentalListingId_idx" ON "RentalReview"("rentalListingId");
-- CreateIndex
CREATE INDEX "RentalReview_landlordId_idx" ON "RentalReview"("landlordId");
-- CreateIndex
CREATE UNIQUE INDEX "RentalFavorite_userId_rentalListingId_key" ON "RentalFavorite"("userId", "rentalListingId");
-- CreateIndex
CREATE UNIQUE INDEX "PromotedRental_rentalListingId_key" ON "PromotedRental"("rentalListingId");
-- CreateIndex
CREATE INDEX "PromotedRental_isActive_endDate_idx" ON "PromotedRental"("isActive", "endDate");
-- AddForeignKey
ALTER TABLE "Conversation" ADD CONSTRAINT "Conversation_rentalListingId_fkey" FOREIGN KEY ("rentalListingId") REFERENCES "RentalListing"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "RentalListing" ADD CONSTRAINT "RentalListing_landlordId_fkey" FOREIGN KEY ("landlordId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "RentalImage" ADD CONSTRAINT "RentalImage_rentalListingId_fkey" FOREIGN KEY ("rentalListingId") REFERENCES "RentalListing"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AvailabilityBlock" ADD CONSTRAINT "AvailabilityBlock_rentalListingId_fkey" FOREIGN KEY ("rentalListingId") REFERENCES "RentalListing"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Booking" ADD CONSTRAINT "Booking_rentalListingId_fkey" FOREIGN KEY ("rentalListingId") REFERENCES "RentalListing"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Booking" ADD CONSTRAINT "Booking_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Booking" ADD CONSTRAINT "Booking_landlordId_fkey" FOREIGN KEY ("landlordId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "BookingPayment" ADD CONSTRAINT "BookingPayment_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Payout" ADD CONSTRAINT "Payout_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Payout" ADD CONSTRAINT "Payout_landlordId_fkey" FOREIGN KEY ("landlordId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "RentalReview" ADD CONSTRAINT "RentalReview_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "RentalReview" ADD CONSTRAINT "RentalReview_rentalListingId_fkey" FOREIGN KEY ("rentalListingId") REFERENCES "RentalListing"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "RentalReview" ADD CONSTRAINT "RentalReview_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "RentalReview" ADD CONSTRAINT "RentalReview_landlordId_fkey" FOREIGN KEY ("landlordId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "RentalFavorite" ADD CONSTRAINT "RentalFavorite_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "RentalFavorite" ADD CONSTRAINT "RentalFavorite_rentalListingId_fkey" FOREIGN KEY ("rentalListingId") REFERENCES "RentalListing"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "PromotedRental" ADD CONSTRAINT "PromotedRental_rentalListingId_fkey" FOREIGN KEY ("rentalListingId") REFERENCES "RentalListing"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "PromotedRental" ADD CONSTRAINT "PromotedRental_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -29,6 +29,7 @@ enum ListingCondition {
enum ListingStatus {
DRAFT
PENDING_REVIEW
ACTIVE
SOLD
DELETED
@@ -50,6 +51,21 @@ enum NotificationType {
ITEM_SOLD
NEW_MESSAGE
ITEM_FAVORITED
LISTING_APPROVED
LISTING_REJECTED
MODERATION_WARNING
ACCOUNT_BANNED
ACCOUNT_UNBANNED
REPORT_RESOLVED
BOOKING_REQUEST
BOOKING_CONFIRMED
BOOKING_REJECTED
BOOKING_CANCELLED
BOOKING_STARTED
BOOKING_COMPLETED
RENTAL_REVIEW
PAYOUT_SENT
PAYOUT_FAILED
}
enum PaymentStatus {
@@ -59,6 +75,120 @@ enum PaymentStatus {
REFUNDED
}
enum UserRole {
USER
MODERATOR
ADMIN
SUPER_ADMIN
}
enum ReportReason {
SPAM
INAPPROPRIATE
SCAM
COUNTERFEIT
PROHIBITED_ITEM
HARASSMENT
OTHER
}
enum ReportStatus {
OPEN
REVIEWING
RESOLVED
DISMISSED
}
enum ReportTargetType {
LISTING
USER
}
enum SubscriptionTier {
BASIC
PRO
BUSINESS
}
enum SubscriptionStatus {
ACTIVE
CANCELLED
EXPIRED
PAST_DUE
}
enum PaymentType {
LISTING_FEE
COMMISSION
PROMOTION
SUBSCRIPTION
RENTAL_BOOKING
RENTAL_COMMISSION
RENTAL_DEPOSIT
RENTAL_DEPOSIT_REFUND
RENTAL_PAYOUT
}
enum ModerationAction {
APPROVED
REJECTED
WARNING
BAN
UNBAN
LISTING_DELETED
LISTING_FEATURED
}
// ── Rental enums ──────────────────────────────────────────────────
enum RentalCategory {
APARTMENT
HOUSE
CAR
MOTORCYCLE
BICYCLE
EBIKE
}
enum RentalPeriodType {
DAILY
MONTHLY
}
enum RentalListingStatus {
DRAFT
PENDING_REVIEW
ACTIVE
PAUSED
DELETED
}
enum BookingStatus {
PENDING
CONFIRMED
ACTIVE
COMPLETED
CANCELLED_BY_TENANT
CANCELLED_BY_LANDLORD
REJECTED
EXPIRED
}
enum PayoutStatus {
PENDING
PROCESSING
COMPLETED
FAILED
}
enum CancellationPolicy {
FLEXIBLE
MODERATE
STRICT
}
// ── Models ────────────────────────────────────────────────────────
model User {
id String @id @default(cuid())
email String @unique
@@ -71,6 +201,11 @@ model User {
bio String?
rating Float @default(0)
ratingCount Int @default(0)
role UserRole @default(USER)
isBanned Boolean @default(false)
banReason String?
bannedAt DateTime?
bannedBy String?
showEmail Boolean @default(false)
showPhone Boolean @default(true)
showLocation Boolean @default(true)
@@ -86,6 +221,9 @@ model User {
marketingEmail Boolean @default(false)
resetToken String? @unique
resetTokenExpiry DateTime?
isLandlord Boolean @default(false)
landlordVerified Boolean @default(false)
stripeAccountId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -102,6 +240,20 @@ model User {
payments Payment[]
blockedUsers BlockedUser[] @relation("Blocker")
blockedBy BlockedUser[] @relation("Blocked")
reports Report[]
subscription Subscription?
promotedListings PromotedListing[]
moderationLogs ModerationLog[]
// Rental relations
rentalListings RentalListing[]
tenantBookings Booking[] @relation("TenantBookings")
landlordBookings Booking[] @relation("LandlordBookings")
payouts Payout[]
rentalReviews RentalReview[] @relation("TenantReviews")
landlordReviews RentalReview[] @relation("LandlordReviews")
rentalFavorites RentalFavorite[]
promotedRentals PromotedRental[]
}
model Session {
@@ -119,19 +271,23 @@ model Session {
}
model Listing {
id String @id @default(cuid())
title String
description String
price Float
obo Boolean @default(false)
category Category
condition ListingCondition
status ListingStatus @default(DRAFT)
location String
viewCount Int @default(0)
sellerId String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
id String @id @default(cuid())
title String
description String
price Float
obo Boolean @default(false)
category Category
condition ListingCondition
status ListingStatus @default(DRAFT)
location String
viewCount Int @default(0)
isFeatured Boolean @default(false)
rejectionReason String?
reviewedBy String?
reviewedAt DateTime?
sellerId String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
seller User @relation(fields: [sellerId], references: [id], onDelete: Cascade)
images ListingImage[]
@@ -139,6 +295,7 @@ model Listing {
conversations Conversation[]
favorites Favorite[]
payments Payment[]
promotedListing PromotedListing?
@@index([sellerId])
@@index([category])
@@ -182,17 +339,19 @@ model Offer {
}
model Conversation {
id String @id @default(cuid())
user1Id String
user2Id String
listingId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
id String @id @default(cuid())
user1Id String
user2Id String
listingId String?
rentalListingId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user1 User @relation("User1Conversations", fields: [user1Id], references: [id], onDelete: Cascade)
user2 User @relation("User2Conversations", fields: [user2Id], references: [id], onDelete: Cascade)
listing Listing? @relation(fields: [listingId], references: [id], onDelete: SetNull)
messages Message[]
user1 User @relation("User1Conversations", fields: [user1Id], references: [id], onDelete: Cascade)
user2 User @relation("User2Conversations", fields: [user2Id], references: [id], onDelete: Cascade)
listing Listing? @relation(fields: [listingId], references: [id], onDelete: SetNull)
rentalListing RentalListing? @relation(fields: [rentalListingId], references: [id], onDelete: SetNull)
messages Message[]
@@unique([user1Id, user2Id, listingId])
@@index([user1Id])
@@ -250,6 +409,8 @@ model Payment {
stripePaymentId String? @unique
amount Float
status PaymentStatus @default(PENDING)
type PaymentType @default(LISTING_FEE)
description String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -271,3 +432,276 @@ model BlockedUser {
@@unique([blockerId, blockedId])
}
model Report {
id String @id @default(cuid())
reporterId String
targetType ReportTargetType
targetId String
reason ReportReason
description String?
status ReportStatus @default(OPEN)
resolvedBy String?
resolution String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
reporter User @relation(fields: [reporterId], references: [id], onDelete: Cascade)
@@index([status])
@@index([targetType, targetId])
}
model PlatformConfig {
id String @id @default(cuid())
listingFee Float @default(5.00)
commissionPercent Float @default(5.0)
autoApprove Boolean @default(true)
maxImagesPerListing Int @default(6)
maxListingsFreeTier Int @default(5)
proPrice Float @default(9.99)
businessPrice Float @default(29.99)
promotionDayPrice Float @default(2.99)
blockedKeywords String[] @default([])
rentalCommissionPercent Float @default(10.0)
rentalAutoApprove Boolean @default(false)
maxRentalImagesPerListing Int @default(10)
bookingExpiryHours Int @default(48)
rentalPromotionDayPrice Float @default(3.99)
updatedAt DateTime @updatedAt
}
model Subscription {
id String @id @default(cuid())
userId String @unique
tier SubscriptionTier @default(BASIC)
status SubscriptionStatus @default(ACTIVE)
stripeSubscriptionId String? @unique
currentPeriodEnd DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
model PromotedListing {
id String @id @default(cuid())
listingId String @unique
userId String
startDate DateTime @default(now())
endDate DateTime
amountPaid Float
isActive Boolean @default(true)
createdAt DateTime @default(now())
listing Listing @relation(fields: [listingId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([isActive, endDate])
}
model ModerationLog {
id String @id @default(cuid())
moderatorId String
targetUserId String?
targetListingId String?
action ModerationAction
reason String?
details Json?
createdAt DateTime @default(now())
moderator User @relation(fields: [moderatorId], references: [id], onDelete: Cascade)
@@index([moderatorId])
@@index([createdAt])
}
// ── Rental Models ─────────────────────────────────────────────────
model RentalListing {
id String @id @default(cuid())
title String
description String
category RentalCategory
location String
dailyPrice Float?
monthlyPrice Float?
depositAmount Float?
details Json?
amenities String[] @default([])
rules String[] @default([])
cancellationPolicy CancellationPolicy @default(FLEXIBLE)
minDays Int?
maxDays Int?
minMonths Int?
maxMonths Int?
status RentalListingStatus @default(DRAFT)
viewCount Int @default(0)
isFeatured Boolean @default(false)
isVerified Boolean @default(false)
rejectionReason String?
reviewedBy String?
reviewedAt DateTime?
landlordId String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
landlord User @relation(fields: [landlordId], references: [id], onDelete: Cascade)
images RentalImage[]
availabilityBlocks AvailabilityBlock[]
bookings Booking[]
reviews RentalReview[]
favorites RentalFavorite[]
promotedRental PromotedRental?
conversations Conversation[]
@@index([landlordId])
@@index([category])
@@index([status])
@@index([createdAt])
}
model RentalImage {
id String @id @default(cuid())
url String
order Int @default(0)
rentalListingId String
rentalListing RentalListing @relation(fields: [rentalListingId], references: [id], onDelete: Cascade)
@@index([rentalListingId])
}
model AvailabilityBlock {
id String @id @default(cuid())
rentalListingId String
startDate DateTime
endDate DateTime
isBlocked Boolean @default(true)
reason String?
createdAt DateTime @default(now())
rentalListing RentalListing @relation(fields: [rentalListingId], references: [id], onDelete: Cascade)
@@index([rentalListingId])
@@index([startDate, endDate])
}
model Booking {
id String @id @default(cuid())
rentalListingId String
tenantId String
landlordId String
periodType RentalPeriodType
startDate DateTime
endDate DateTime
pricePerPeriod Float
totalPeriods Int
subtotal Float
commissionRate Float @default(10.0)
commissionAmount Float
depositAmount Float @default(0)
totalAmount Float
status BookingStatus @default(PENDING)
message String?
rejectionReason String?
cancellationReason String?
stripePaymentIntentId String?
expiresAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
rentalListing RentalListing @relation(fields: [rentalListingId], references: [id], onDelete: Cascade)
tenant User @relation("TenantBookings", fields: [tenantId], references: [id], onDelete: Cascade)
landlord User @relation("LandlordBookings", fields: [landlordId], references: [id], onDelete: Cascade)
payments BookingPayment[]
payout Payout?
review RentalReview?
@@index([rentalListingId])
@@index([tenantId])
@@index([landlordId])
@@index([status])
}
model BookingPayment {
id String @id @default(cuid())
bookingId String
stripePaymentId String? @unique
amount Float
type PaymentType
status PaymentStatus @default(PENDING)
createdAt DateTime @default(now())
booking Booking @relation(fields: [bookingId], references: [id], onDelete: Cascade)
@@index([bookingId])
}
model Payout {
id String @id @default(cuid())
bookingId String @unique
landlordId String
grossAmount Float
commissionAmount Float
netAmount Float
status PayoutStatus @default(PENDING)
stripeTransferId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
booking Booking @relation(fields: [bookingId], references: [id], onDelete: Cascade)
landlord User @relation(fields: [landlordId], references: [id], onDelete: Cascade)
@@index([landlordId])
@@index([status])
}
model RentalReview {
id String @id @default(cuid())
bookingId String @unique
rentalListingId String
tenantId String
landlordId String
rating Int
comment String?
landlordResponse String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
booking Booking @relation(fields: [bookingId], references: [id], onDelete: Cascade)
rentalListing RentalListing @relation(fields: [rentalListingId], references: [id], onDelete: Cascade)
tenant User @relation("TenantReviews", fields: [tenantId], references: [id], onDelete: Cascade)
landlord User @relation("LandlordReviews", fields: [landlordId], references: [id], onDelete: Cascade)
@@index([rentalListingId])
@@index([landlordId])
}
model RentalFavorite {
id String @id @default(cuid())
userId String
rentalListingId String
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
rentalListing RentalListing @relation(fields: [rentalListingId], references: [id], onDelete: Cascade)
@@unique([userId, rentalListingId])
}
model PromotedRental {
id String @id @default(cuid())
rentalListingId String @unique
userId String
startDate DateTime @default(now())
endDate DateTime
amountPaid Float
isActive Boolean @default(true)
createdAt DateTime @default(now())
rentalListing RentalListing @relation(fields: [rentalListingId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([isActive, endDate])
}

View File

@@ -11,6 +11,20 @@ async function main() {
// ── Clear existing data (reverse dependency order) ──────────────────
await prisma.$transaction([
prisma.promotedRental.deleteMany(),
prisma.rentalFavorite.deleteMany(),
prisma.rentalReview.deleteMany(),
prisma.payout.deleteMany(),
prisma.bookingPayment.deleteMany(),
prisma.booking.deleteMany(),
prisma.availabilityBlock.deleteMany(),
prisma.rentalImage.deleteMany(),
prisma.rentalListing.deleteMany(),
prisma.moderationLog.deleteMany(),
prisma.promotedListing.deleteMany(),
prisma.subscription.deleteMany(),
prisma.report.deleteMany(),
prisma.platformConfig.deleteMany(),
prisma.message.deleteMany(),
prisma.conversation.deleteMany(),
prisma.notification.deleteMany(),
@@ -780,6 +794,310 @@ async function main() {
console.log('Created 1 blocked user entry.');
// ── Platform Config ───────────────────────────────────────────────
await prisma.platformConfig.deleteMany();
await prisma.platformConfig.create({
data: {
listingFee: 5.00,
commissionPercent: 5.0,
autoApprove: true,
maxImagesPerListing: 6,
maxListingsFreeTier: 5,
proPrice: 9.99,
businessPrice: 29.99,
promotionDayPrice: 2.99,
blockedKeywords: ['illegal', 'drugs', 'weapons'],
rentalCommissionPercent: 10.0,
rentalAutoApprove: false,
maxRentalImagesPerListing: 10,
bookingExpiryHours: 48,
rentalPromotionDayPrice: 3.99,
},
});
console.log('Created PlatformConfig.');
// ── Assign roles to test users ────────────────────────────────────
await prisma.user.update({ where: { id: 'user-alice' }, data: { role: 'SUPER_ADMIN' } });
await prisma.user.update({ where: { id: 'user-bob' }, data: { role: 'ADMIN' } });
await prisma.user.update({ where: { id: 'user-carol' }, data: { role: 'MODERATOR' } });
console.log('Assigned roles: alice=SUPER_ADMIN, bob=ADMIN, carol=MODERATOR.');
// ── Rental Listings ─────────────────────────────────────────────────
// Make david a landlord
await prisma.user.update({ where: { id: 'user-david' }, data: { isLandlord: true, landlordVerified: true } });
// Make eva a landlord too
await prisma.user.update({ where: { id: 'user-eva' }, data: { isLandlord: true } });
const rentalListingsData = [
{
id: 'rental-01',
title: 'Modern Downtown Apartment — 2BR',
description: 'Spacious 2-bedroom apartment in the heart of downtown Chicago. Fully furnished with modern appliances, high-speed WiFi, and stunning city views from the 15th floor. Walking distance to restaurants, shops, and public transit.',
category: 'APARTMENT' as const,
location: 'Chicago, IL',
dailyPrice: 120.00,
monthlyPrice: 2800.00,
depositAmount: 500.00,
amenities: ['WiFi', 'Air Conditioning', 'Washer/Dryer', 'Parking', 'Gym', 'Elevator'],
rules: ['No smoking', 'No pets', 'Quiet hours 10PM-8AM'],
cancellationPolicy: 'MODERATE' as const,
minDays: 2,
maxDays: 90,
minMonths: 1,
maxMonths: 12,
status: 'ACTIVE' as const,
viewCount: 234,
isVerified: true,
landlordId: 'user-david',
},
{
id: 'rental-02',
title: 'Cozy Lake House with Private Dock',
description: 'Beautiful 3-bedroom lake house with private dock and boat access. Perfect for families or groups. Includes kayaks, paddleboards, and fishing equipment. Surrounded by nature with hiking trails nearby.',
category: 'HOUSE' as const,
location: 'Lake Geneva, WI',
dailyPrice: 250.00,
monthlyPrice: 5000.00,
depositAmount: 1000.00,
amenities: ['WiFi', 'Fireplace', 'BBQ', 'Boat Dock', 'Kayaks', 'Hot Tub'],
rules: ['No parties', 'No smoking indoors', 'Pets allowed with deposit'],
cancellationPolicy: 'STRICT' as const,
minDays: 3,
maxDays: 30,
status: 'ACTIVE' as const,
viewCount: 189,
landlordId: 'user-david',
},
{
id: 'rental-03',
title: 'Tesla Model 3 — Daily/Monthly Rental',
description: 'Clean 2024 Tesla Model 3 Long Range in Pearl White. Autopilot included. Full charge gives 350+ miles range. Insurance included in daily rate. Must be 25+ with valid license.',
category: 'CAR' as const,
location: 'Seattle, WA',
dailyPrice: 85.00,
monthlyPrice: 1800.00,
depositAmount: 500.00,
details: { year: 2024, make: 'Tesla', model: 'Model 3', color: 'Pearl White', mileage: 12000 },
amenities: ['Autopilot', 'Premium Audio', 'Heated Seats', 'Phone Charger'],
rules: ['No smoking', 'Must be 25+', 'Valid license required', 'Return fully charged'],
cancellationPolicy: 'FLEXIBLE' as const,
minDays: 1,
maxDays: 60,
status: 'ACTIVE' as const,
viewCount: 156,
landlordId: 'user-eva',
},
{
id: 'rental-04',
title: 'Harley-Davidson Sportster 883',
description: 'Classic Harley-Davidson Sportster 883 in Vivid Black. Perfect for weekend rides or road trips. Helmet included. Motorcycle license required.',
category: 'MOTORCYCLE' as const,
location: 'Portland, OR',
dailyPrice: 65.00,
depositAmount: 300.00,
amenities: ['Helmet Included', 'Saddlebags', 'Phone Mount'],
rules: ['Motorcycle license required', 'Must be 21+', 'No off-road use'],
cancellationPolicy: 'MODERATE' as const,
minDays: 1,
maxDays: 14,
status: 'ACTIVE' as const,
viewCount: 98,
landlordId: 'user-eva',
},
{
id: 'rental-05',
title: 'Trek City Bicycle — Daily Rental',
description: 'Comfortable Trek city bicycle perfect for exploring Portland. Includes lock, lights, and basket. Helmet available on request.',
category: 'BICYCLE' as const,
location: 'Portland, OR',
dailyPrice: 15.00,
amenities: ['Lock', 'Lights', 'Basket', 'Helmet Available'],
rules: ['Return by 8PM', 'Lock when unattended'],
cancellationPolicy: 'FLEXIBLE' as const,
minDays: 1,
maxDays: 7,
status: 'ACTIVE' as const,
viewCount: 67,
landlordId: 'user-eva',
},
{
id: 'rental-06',
title: 'VanMoof S5 Electric Bike',
description: 'Premium VanMoof S5 e-bike with boost button. Range up to 90 miles. Built-in anti-theft. Perfect for daily commuting or weekend exploring.',
category: 'EBIKE' as const,
location: 'San Francisco, CA',
dailyPrice: 35.00,
monthlyPrice: 600.00,
depositAmount: 200.00,
amenities: ['Anti-theft', 'Boost Button', 'Phone Mount', 'Lock'],
rules: ['Must wear helmet', 'Return charged', 'No off-road'],
cancellationPolicy: 'FLEXIBLE' as const,
minDays: 1,
maxDays: 30,
status: 'ACTIVE' as const,
viewCount: 112,
landlordId: 'user-david',
},
{
id: 'rental-07',
title: 'Luxury Penthouse — Pending Review',
description: 'Stunning penthouse apartment with panoramic views. 3 bedrooms, chef kitchen, private terrace. Under review.',
category: 'APARTMENT' as const,
location: 'Los Angeles, CA',
dailyPrice: 450.00,
monthlyPrice: 9000.00,
depositAmount: 2000.00,
amenities: ['Pool', 'Gym', 'Concierge', 'Parking', 'Terrace'],
rules: ['No parties', 'No smoking'],
cancellationPolicy: 'STRICT' as const,
status: 'PENDING_REVIEW' as const,
viewCount: 0,
landlordId: 'user-david',
},
];
const rentalListings = await prisma.$transaction(
rentalListingsData.map((r) => prisma.rentalListing.create({ data: r }))
);
console.log(`Created ${rentalListings.length} rental listings.`);
// ── Rental Images ─────────────────────────────────────────────────
const rentalImageRecords = [
{ rentalListingId: 'rental-01', url: '/uploads/placeholder-r1.jpg', order: 0 },
{ rentalListingId: 'rental-01', url: '/uploads/placeholder-r2.jpg', order: 1 },
{ rentalListingId: 'rental-02', url: '/uploads/placeholder-r3.jpg', order: 0 },
{ rentalListingId: 'rental-02', url: '/uploads/placeholder-r4.jpg', order: 1 },
{ rentalListingId: 'rental-03', url: '/uploads/placeholder-r5.jpg', order: 0 },
{ rentalListingId: 'rental-04', url: '/uploads/placeholder-r6.jpg', order: 0 },
{ rentalListingId: 'rental-05', url: '/uploads/placeholder-r7.jpg', order: 0 },
{ rentalListingId: 'rental-06', url: '/uploads/placeholder-r8.jpg', order: 0 },
];
await prisma.$transaction(
rentalImageRecords.map((img) => prisma.rentalImage.create({ data: img }))
);
console.log(`Created ${rentalImageRecords.length} rental images.`);
// ── Bookings ──────────────────────────────────────────────────────
const bookingsData = [
{
id: 'booking-01',
rentalListingId: 'rental-01',
tenantId: 'user-eva',
landlordId: 'user-david',
periodType: 'DAILY' as const,
startDate: daysAgo(-5),
endDate: daysAgo(-2),
pricePerPeriod: 120,
totalPeriods: 3,
subtotal: 360,
commissionRate: 10,
commissionAmount: 36,
depositAmount: 500,
totalAmount: 860,
status: 'COMPLETED' as const,
createdAt: daysAgo(10),
},
{
id: 'booking-02',
rentalListingId: 'rental-03',
tenantId: 'user-alice',
landlordId: 'user-eva',
periodType: 'DAILY' as const,
startDate: daysAgo(2),
endDate: daysAgo(-5),
pricePerPeriod: 85,
totalPeriods: 7,
subtotal: 595,
commissionRate: 10,
commissionAmount: 59.5,
depositAmount: 500,
totalAmount: 1095,
status: 'ACTIVE' as const,
createdAt: daysAgo(5),
},
{
id: 'booking-03',
rentalListingId: 'rental-02',
tenantId: 'user-bob',
landlordId: 'user-david',
periodType: 'DAILY' as const,
startDate: daysAgo(-10),
endDate: daysAgo(-7),
pricePerPeriod: 250,
totalPeriods: 3,
subtotal: 750,
commissionRate: 10,
commissionAmount: 75,
depositAmount: 1000,
totalAmount: 1750,
status: 'CONFIRMED' as const,
createdAt: daysAgo(3),
},
{
id: 'booking-04',
rentalListingId: 'rental-05',
tenantId: 'user-carol',
landlordId: 'user-eva',
periodType: 'DAILY' as const,
startDate: daysAgo(-14),
endDate: daysAgo(-12),
pricePerPeriod: 15,
totalPeriods: 2,
subtotal: 30,
commissionRate: 10,
commissionAmount: 3,
depositAmount: 0,
totalAmount: 30,
status: 'PENDING' as const,
expiresAt: daysAgo(-12),
createdAt: daysAgo(1),
},
];
const bookings = await prisma.$transaction(
bookingsData.map((b) => prisma.booking.create({ data: b }))
);
console.log(`Created ${bookings.length} bookings.`);
// ── Payouts for completed bookings ────────────────────────────────
await prisma.payout.create({
data: {
bookingId: 'booking-01',
landlordId: 'user-david',
grossAmount: 360,
commissionAmount: 36,
netAmount: 324,
status: 'COMPLETED',
},
});
console.log('Created 1 payout.');
// ── Rental Reviews ────────────────────────────────────────────────
await prisma.rentalReview.create({
data: {
bookingId: 'booking-01',
rentalListingId: 'rental-01',
tenantId: 'user-eva',
landlordId: 'user-david',
rating: 5,
comment: 'Amazing apartment! Super clean, great location, and David was an excellent host. The views were even better than the photos. Would definitely stay again.',
landlordResponse: 'Thank you Eva! You were a wonderful guest. Welcome back anytime!',
},
});
console.log('Created 1 rental review.');
// ── Rental Favorites ──────────────────────────────────────────────
await prisma.$transaction([
prisma.rentalFavorite.create({ data: { userId: 'user-alice', rentalListingId: 'rental-01' } }),
prisma.rentalFavorite.create({ data: { userId: 'user-alice', rentalListingId: 'rental-02' } }),
prisma.rentalFavorite.create({ data: { userId: 'user-bob', rentalListingId: 'rental-03' } }),
prisma.rentalFavorite.create({ data: { userId: 'user-carol', rentalListingId: 'rental-06' } }),
]);
console.log('Created 4 rental favorites.');
console.log('\nSeed completed successfully!');
}

View File

@@ -18,6 +18,15 @@ import notificationRoutes from './routes/notification.js';
import paymentRoutes from './routes/payment.js';
import locationRoutes from './routes/location.js';
import miscRoutes from './routes/misc.js';
import reportRoutes from './routes/report.js';
import subscriptionRoutes from './routes/subscription.js';
import promotionRoutes from './routes/promotion.js';
import adminRoutes from './routes/admin/index.js';
import rentalRoutes from './routes/rental.js';
import bookingRoutes from './routes/booking.js';
import rentalPaymentRoutes from './routes/rental-payment.js';
import payoutRoutes from './routes/payout.js';
import rentalReviewRoutes from './routes/rental-review.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -35,10 +44,11 @@ app.use(cookieParser());
// Stripe webhook needs raw body
app.use('/api/payments/webhook', express.raw({ type: 'application/json' }));
app.use('/api/rental-payments/webhook', express.raw({ type: 'application/json' }));
app.use(express.json());
// Rate limiting
const authLimiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 20 });
const authLimiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 50 });
app.use('/api/auth/login', authLimiter);
app.use('/api/auth/register', authLimiter);
@@ -55,6 +65,15 @@ app.use('/api/notifications', notificationRoutes);
app.use('/api/payments', paymentRoutes);
app.use('/api/location', locationRoutes);
app.use('/api', miscRoutes);
app.use('/api/reports', reportRoutes);
app.use('/api/subscriptions', subscriptionRoutes);
app.use('/api/promotions', promotionRoutes);
app.use('/api/admin', adminRoutes);
app.use('/api/rentals', rentalRoutes);
app.use('/api/bookings', bookingRoutes);
app.use('/api/rental-payments', rentalPaymentRoutes);
app.use('/api/payouts', payoutRoutes);
app.use('/api/rental-reviews', rentalReviewRoutes);
// Health check
app.get('/api/health', (_req, res) => {

View File

@@ -5,6 +5,7 @@ declare global {
namespace Express {
interface Request {
userId?: string;
userRole?: string;
}
}
}

View File

@@ -0,0 +1,24 @@
import type { Request, Response, NextFunction } from 'express';
import { prisma } from '../config/database.js';
export async function checkBanned(req: Request, res: Response, next: NextFunction): Promise<void> {
if (!req.userId) {
next();
return;
}
const user = await prisma.user.findUnique({
where: { id: req.userId },
select: { isBanned: true, banReason: true },
});
if (user?.isBanned) {
res.status(403).json({
message: 'Account suspended',
reason: user.banReason || 'Your account has been suspended. Contact support for more information.',
});
return;
}
next();
}

View File

@@ -0,0 +1,47 @@
import type { Request, Response, NextFunction } from 'express';
import { prisma } from '../config/database.js';
type Role = 'USER' | 'MODERATOR' | 'ADMIN' | 'SUPER_ADMIN';
const ROLE_HIERARCHY: Record<Role, number> = {
USER: 0,
MODERATOR: 1,
ADMIN: 2,
SUPER_ADMIN: 3,
};
export function requireRole(...roles: Role[]) {
return async (req: Request, res: Response, next: NextFunction): Promise<void> => {
if (!req.userId) {
res.status(401).json({ message: 'Authentication required' });
return;
}
const user = await prisma.user.findUnique({
where: { id: req.userId },
select: { role: true, isBanned: true },
});
if (!user) {
res.status(401).json({ message: 'User not found' });
return;
}
if (user.isBanned) {
res.status(403).json({ message: 'Account is suspended' });
return;
}
if (!roles.includes(user.role as Role)) {
res.status(403).json({ message: 'Insufficient permissions' });
return;
}
(req as any).userRole = user.role;
next();
};
}
export const requireModerator = requireRole('MODERATOR', 'ADMIN', 'SUPER_ADMIN');
export const requireAdmin = requireRole('ADMIN', 'SUPER_ADMIN');
export const requireSuperAdmin = requireRole('SUPER_ADMIN');

View File

@@ -0,0 +1,38 @@
import { Router } from 'express';
import { prisma } from '../../config/database.js';
import { requireModerator } from '../../middleware/requireRole.js';
const router = Router();
// --- List all bookings ---
router.get('/', requireModerator, async (req, res, next) => {
try {
const { page = '1', pageSize = '20', status } = req.query;
const skip = (parseInt(page as string) - 1) * parseInt(pageSize as string);
const take = parseInt(pageSize as string);
const where: Record<string, unknown> = {};
if (status) where.status = status;
const [data, total] = await Promise.all([
prisma.booking.findMany({
where,
include: {
rentalListing: { select: { id: true, title: true, category: true, location: true, cancellationPolicy: true, images: { take: 1, orderBy: { order: 'asc' as const } } } },
tenant: { select: { id: true, fullName: true, email: true } },
landlord: { select: { id: true, fullName: true, email: true } },
payout: true,
},
skip, take,
orderBy: { createdAt: 'desc' },
}),
prisma.booking.count({ where }),
]);
res.json({ data, total, page: parseInt(page as string), pageSize: take, totalPages: Math.ceil(total / take) });
} catch (error) {
next(error);
}
});
export default router;

View File

@@ -0,0 +1,30 @@
import { Router } from 'express';
import { authenticate } from '../../middleware/auth.js';
import statsRouter from './stats.js';
import usersRouter from './users.js';
import listingsRouter from './listings.js';
import reportsRouter from './reports.js';
import moderationRouter from './moderation.js';
import paymentsRouter from './payments.js';
import settingsRouter from './settings.js';
import rentalRouter from './rentals.js';
import bookingsRouter from './bookings.js';
import rentalPayoutsRouter from './rental-payouts.js';
const router = Router();
// All admin routes require authentication
router.use(authenticate);
router.use('/stats', statsRouter);
router.use('/users', usersRouter);
router.use('/listings', listingsRouter);
router.use('/reports', reportsRouter);
router.use('/moderation', moderationRouter);
router.use('/payments', paymentsRouter);
router.use('/settings', settingsRouter);
router.use('/rentals', rentalRouter);
router.use('/bookings', bookingsRouter);
router.use('/rental-payouts', rentalPayoutsRouter);
export default router;

View File

@@ -0,0 +1,187 @@
import { Router } from 'express';
import { prisma } from '../../config/database.js';
import { requireModerator, requireAdmin } from '../../middleware/requireRole.js';
import { validate } from '../../middleware/validate.js';
import { rejectListingSchema } from '../../validators/admin.js';
import { AppError } from '../../middleware/errorHandler.js';
const router = Router();
// GET /api/admin/listings - All listings
router.get('/', requireModerator, async (req, res, next) => {
try {
const { page = '1', pageSize = '20', status, category, search } = req.query;
const skip = (parseInt(page as string) - 1) * parseInt(pageSize as string);
const take = parseInt(pageSize as string);
const where: any = {};
if (status) where.status = status;
if (category) where.category = category;
if (search) {
where.OR = [
{ title: { contains: search as string, mode: 'insensitive' } },
{ description: { contains: search as string, mode: 'insensitive' } },
];
}
const [listings, total] = await Promise.all([
prisma.listing.findMany({
where,
select: {
id: true, title: true, price: true, category: true, condition: true,
status: true, isFeatured: true, createdAt: true, viewCount: true,
seller: { select: { id: true, fullName: true, avatar: true } },
images: { take: 1, orderBy: { order: 'asc' } },
_count: { select: { offers: true, favorites: true } },
},
skip,
take,
orderBy: { createdAt: 'desc' },
}),
prisma.listing.count({ where }),
]);
res.json({
data: listings,
total,
page: parseInt(page as string),
pageSize: take,
totalPages: Math.ceil(total / take),
});
} catch (error) {
next(error);
}
});
// POST /api/admin/listings/:id/approve
router.post('/:id/approve', requireModerator, async (req, res, next) => {
try {
const listing = await prisma.listing.findUnique({ where: { id: req.params.id } });
if (!listing) throw new AppError(404, 'Listing not found');
if (listing.status !== 'PENDING_REVIEW') throw new AppError(400, 'Listing is not pending review');
const updated = await prisma.listing.update({
where: { id: req.params.id },
data: { status: 'ACTIVE', reviewedBy: req.userId, reviewedAt: new Date() },
});
await prisma.moderationLog.create({
data: {
moderatorId: req.userId!,
targetListingId: req.params.id,
action: 'APPROVED',
reason: 'Listing approved',
},
});
await prisma.notification.create({
data: {
userId: listing.sellerId,
type: 'LISTING_APPROVED',
title: 'Listing Approved',
body: `Your listing "${listing.title}" has been approved and is now live.`,
data: { listingId: listing.id },
},
});
res.json(updated);
} catch (error) {
next(error);
}
});
// POST /api/admin/listings/:id/reject
router.post('/:id/reject', requireModerator, validate(rejectListingSchema), async (req, res, next) => {
try {
const listing = await prisma.listing.findUnique({ where: { id: req.params.id } });
if (!listing) throw new AppError(404, 'Listing not found');
const updated = await prisma.listing.update({
where: { id: req.params.id },
data: {
status: 'DELETED',
rejectionReason: req.body.reason,
reviewedBy: req.userId,
reviewedAt: new Date(),
},
});
await prisma.moderationLog.create({
data: {
moderatorId: req.userId!,
targetListingId: req.params.id,
action: 'REJECTED',
reason: req.body.reason,
},
});
await prisma.notification.create({
data: {
userId: listing.sellerId,
type: 'LISTING_REJECTED',
title: 'Listing Rejected',
body: `Your listing "${listing.title}" was rejected. Reason: ${req.body.reason}`,
data: { listingId: listing.id },
},
});
res.json(updated);
} catch (error) {
next(error);
}
});
// DELETE /api/admin/listings/:id - Force delete
router.delete('/:id', requireAdmin, async (req, res, next) => {
try {
const listing = await prisma.listing.findUnique({ where: { id: req.params.id } });
if (!listing) throw new AppError(404, 'Listing not found');
await prisma.listing.update({
where: { id: req.params.id },
data: { status: 'DELETED' },
});
await prisma.moderationLog.create({
data: {
moderatorId: req.userId!,
targetListingId: req.params.id,
targetUserId: listing.sellerId,
action: 'LISTING_DELETED',
reason: 'Force deleted by admin',
},
});
res.json({ message: 'Listing deleted' });
} catch (error) {
next(error);
}
});
// POST /api/admin/listings/:id/feature - Toggle featured
router.post('/:id/feature', requireAdmin, async (req, res, next) => {
try {
const listing = await prisma.listing.findUnique({ where: { id: req.params.id } });
if (!listing) throw new AppError(404, 'Listing not found');
const updated = await prisma.listing.update({
where: { id: req.params.id },
data: { isFeatured: !listing.isFeatured },
});
await prisma.moderationLog.create({
data: {
moderatorId: req.userId!,
targetListingId: req.params.id,
action: 'LISTING_FEATURED',
reason: updated.isFeatured ? 'Listing featured' : 'Listing unfeatured',
},
});
res.json(updated);
} catch (error) {
next(error);
}
});
export default router;

View File

@@ -0,0 +1,73 @@
import { Router } from 'express';
import { prisma } from '../../config/database.js';
import { requireModerator, requireAdmin } from '../../middleware/requireRole.js';
const router = Router();
// GET /api/admin/moderation/queue - Pending review listings
router.get('/queue', requireModerator, async (req, res, next) => {
try {
const { page = '1', pageSize = '20' } = req.query;
const skip = (parseInt(page as string) - 1) * parseInt(pageSize as string);
const take = parseInt(pageSize as string);
const [listings, total] = await Promise.all([
prisma.listing.findMany({
where: { status: 'PENDING_REVIEW' },
select: {
id: true, title: true, description: true, price: true, category: true,
condition: true, location: true, createdAt: true,
seller: { select: { id: true, fullName: true, avatar: true, email: true } },
images: { orderBy: { order: 'asc' } },
},
skip,
take,
orderBy: { createdAt: 'asc' },
}),
prisma.listing.count({ where: { status: 'PENDING_REVIEW' } }),
]);
res.json({
data: listings,
total,
page: parseInt(page as string),
pageSize: take,
totalPages: Math.ceil(total / take),
});
} catch (error) {
next(error);
}
});
// GET /api/admin/moderation/logs - Moderation history
router.get('/logs', requireAdmin, async (req, res, next) => {
try {
const { page = '1', pageSize = '20' } = req.query;
const skip = (parseInt(page as string) - 1) * parseInt(pageSize as string);
const take = parseInt(pageSize as string);
const [logs, total] = await Promise.all([
prisma.moderationLog.findMany({
include: {
moderator: { select: { id: true, fullName: true, avatar: true } },
},
skip,
take,
orderBy: { createdAt: 'desc' },
}),
prisma.moderationLog.count(),
]);
res.json({
data: logs,
total,
page: parseInt(page as string),
pageSize: take,
totalPages: Math.ceil(total / take),
});
} catch (error) {
next(error);
}
});
export default router;

View File

@@ -0,0 +1,65 @@
import { Router } from 'express';
import { prisma } from '../../config/database.js';
import { requireAdmin } from '../../middleware/requireRole.js';
const router = Router();
// GET /api/admin/payments - All payments
router.get('/', requireAdmin, async (req, res, next) => {
try {
const { page = '1', pageSize = '20', type, status } = req.query;
const skip = (parseInt(page as string) - 1) * parseInt(pageSize as string);
const take = parseInt(pageSize as string);
const where: any = {};
if (type) where.type = type;
if (status) where.status = status;
const [payments, total] = await Promise.all([
prisma.payment.findMany({
where,
include: {
user: { select: { id: true, fullName: true, avatar: true } },
listing: { select: { id: true, title: true } },
},
skip,
take,
orderBy: { createdAt: 'desc' },
}),
prisma.payment.count({ where }),
]);
res.json({
data: payments,
total,
page: parseInt(page as string),
pageSize: take,
totalPages: Math.ceil(total / take),
});
} catch (error) {
next(error);
}
});
// GET /api/admin/payments/revenue - Revenue breakdown
router.get('/revenue', requireAdmin, async (_req, res, next) => {
try {
const [listingFees, commissions, promotions, subscriptions] = await Promise.all([
prisma.payment.aggregate({ where: { type: 'LISTING_FEE', status: 'COMPLETED' }, _sum: { amount: true }, _count: true }),
prisma.payment.aggregate({ where: { type: 'COMMISSION', status: 'COMPLETED' }, _sum: { amount: true }, _count: true }),
prisma.payment.aggregate({ where: { type: 'PROMOTION', status: 'COMPLETED' }, _sum: { amount: true }, _count: true }),
prisma.payment.aggregate({ where: { type: 'SUBSCRIPTION', status: 'COMPLETED' }, _sum: { amount: true }, _count: true }),
]);
res.json({
listingFees: { total: listingFees._sum.amount || 0, count: listingFees._count },
commissions: { total: commissions._sum.amount || 0, count: commissions._count },
promotions: { total: promotions._sum.amount || 0, count: promotions._count },
subscriptions: { total: subscriptions._sum.amount || 0, count: subscriptions._count },
});
} catch (error) {
next(error);
}
});
export default router;

View File

@@ -0,0 +1,109 @@
import { Router } from 'express';
import Stripe from 'stripe';
import { prisma } from '../../config/database.js';
import { requireAdmin } from '../../middleware/requireRole.js';
import { env } from '../../config/env.js';
import { AppError } from '../../middleware/errorHandler.js';
const router = Router();
const stripe = env.STRIPE_SECRET_KEY ? new Stripe(env.STRIPE_SECRET_KEY) : null;
// --- List all payouts ---
router.get('/', requireAdmin, async (req, res, next) => {
try {
const { page = '1', pageSize = '20', status } = req.query;
const skip = (parseInt(page as string) - 1) * parseInt(pageSize as string);
const take = parseInt(pageSize as string);
const where: Record<string, unknown> = {};
if (status) where.status = status;
const [data, total] = await Promise.all([
prisma.payout.findMany({
where,
include: {
booking: {
select: {
id: true,
rentalListing: { select: { id: true, title: true } },
tenant: { select: { id: true, fullName: true } },
},
},
landlord: { select: { id: true, fullName: true, email: true, stripeAccountId: true } },
},
skip, take,
orderBy: { createdAt: 'desc' },
}),
prisma.payout.count({ where }),
]);
res.json({ data, total, page: parseInt(page as string), pageSize: take, totalPages: Math.ceil(total / take) });
} catch (error) {
next(error);
}
});
// --- Retry failed payout ---
router.patch('/:id/retry', requireAdmin, async (req, res, next) => {
try {
const payout = await prisma.payout.findUnique({
where: { id: req.params.id },
include: { landlord: true },
});
if (!payout) throw new AppError(404, 'Payout not found');
if (payout.status !== 'FAILED' && payout.status !== 'PENDING') {
throw new AppError(400, 'Can only retry failed or pending payouts');
}
if (!payout.landlord.stripeAccountId) {
throw new AppError(400, 'Landlord has no Stripe Connect account');
}
if (stripe) {
try {
const transfer = await stripe.transfers.create({
amount: Math.round(payout.netAmount * 100),
currency: 'usd',
destination: payout.landlord.stripeAccountId,
metadata: { payoutId: payout.id, bookingId: payout.bookingId },
});
await prisma.payout.update({
where: { id: payout.id },
data: { status: 'COMPLETED', stripeTransferId: transfer.id },
});
// Notify landlord
await prisma.notification.create({
data: {
userId: payout.landlordId,
type: 'PAYOUT_SENT',
title: 'Payout Sent',
body: `Your payout of $${payout.netAmount.toFixed(2)} has been sent.`,
data: { payoutId: payout.id },
},
});
} catch {
await prisma.payout.update({ where: { id: payout.id }, data: { status: 'FAILED' } });
await prisma.notification.create({
data: {
userId: payout.landlordId,
type: 'PAYOUT_FAILED',
title: 'Payout Failed',
body: `Your payout of $${payout.netAmount.toFixed(2)} failed. Our team is investigating.`,
data: { payoutId: payout.id },
},
});
}
}
const updated = await prisma.payout.findUnique({ where: { id: payout.id } });
res.json(updated);
} catch (error) {
next(error);
}
});
export default router;

View File

@@ -0,0 +1,150 @@
import { Router } from 'express';
import { prisma } from '../../config/database.js';
import { requireModerator, requireAdmin } from '../../middleware/requireRole.js';
import { AppError } from '../../middleware/errorHandler.js';
const router = Router();
// --- List all rentals ---
router.get('/', requireModerator, async (req, res, next) => {
try {
const { page = '1', pageSize = '20', status, category, search } = req.query;
const skip = (parseInt(page as string) - 1) * parseInt(pageSize as string);
const take = parseInt(pageSize as string);
const where: Record<string, unknown> = {};
if (status) where.status = status;
if (category) where.category = category;
if (search) {
where.OR = [
{ title: { contains: search as string, mode: 'insensitive' } },
{ location: { contains: search as string, mode: 'insensitive' } },
];
}
const [data, total] = await Promise.all([
prisma.rentalListing.findMany({
where,
select: {
id: true, title: true, category: true, location: true, status: true,
dailyPrice: true, monthlyPrice: true, viewCount: true, isFeatured: true, isVerified: true,
createdAt: true,
landlord: { select: { id: true, fullName: true, email: true } },
images: { take: 1, orderBy: { order: 'asc' } },
_count: { select: { bookings: true, reviews: true } },
},
skip, take,
orderBy: { createdAt: 'desc' },
}),
prisma.rentalListing.count({ where }),
]);
res.json({ data, total, page: parseInt(page as string), pageSize: take, totalPages: Math.ceil(total / take) });
} catch (error) {
next(error);
}
});
// --- Approve rental ---
router.patch('/:id/approve', requireModerator, async (req, res, next) => {
try {
const rental = await prisma.rentalListing.findUnique({ where: { id: req.params.id } });
if (!rental) throw new AppError(404, 'Rental not found');
if (rental.status !== 'PENDING_REVIEW') throw new AppError(400, 'Rental is not pending review');
const updated = await prisma.rentalListing.update({
where: { id: req.params.id },
data: { status: 'ACTIVE', reviewedBy: req.userId, reviewedAt: new Date() },
});
// Notify landlord
await prisma.notification.create({
data: {
userId: rental.landlordId,
type: 'LISTING_APPROVED',
title: 'Rental Approved',
body: `Your rental "${rental.title}" has been approved and is now live!`,
data: { rentalListingId: rental.id },
},
});
res.json(updated);
} catch (error) {
next(error);
}
});
// --- Reject rental ---
router.patch('/:id/reject', requireModerator, async (req, res, next) => {
try {
const { reason } = req.body;
const rental = await prisma.rentalListing.findUnique({ where: { id: req.params.id } });
if (!rental) throw new AppError(404, 'Rental not found');
if (rental.status !== 'PENDING_REVIEW') throw new AppError(400, 'Rental is not pending review');
const updated = await prisma.rentalListing.update({
where: { id: req.params.id },
data: { status: 'DRAFT', rejectionReason: reason, reviewedBy: req.userId, reviewedAt: new Date() },
});
await prisma.notification.create({
data: {
userId: rental.landlordId,
type: 'LISTING_REJECTED',
title: 'Rental Rejected',
body: `Your rental "${rental.title}" was rejected. Reason: ${reason || 'Not specified'}`,
data: { rentalListingId: rental.id },
},
});
res.json(updated);
} catch (error) {
next(error);
}
});
// --- Force delete rental (admin) ---
router.delete('/:id', requireAdmin, async (req, res, next) => {
try {
const rental = await prisma.rentalListing.findUnique({ where: { id: req.params.id } });
if (!rental) throw new AppError(404, 'Rental not found');
await prisma.rentalListing.update({ where: { id: req.params.id }, data: { status: 'DELETED' } });
res.json({ message: 'Rental deleted' });
} catch (error) {
next(error);
}
});
// --- Rental stats ---
router.get('/stats', requireModerator, async (req, res, next) => {
try {
const [totalRentals, activeRentals, pendingRentals, totalBookings, activeBookings, completedBookings, totalPayouts, pendingPayouts] = await Promise.all([
prisma.rentalListing.count({ where: { status: { not: 'DELETED' } } }),
prisma.rentalListing.count({ where: { status: 'ACTIVE' } }),
prisma.rentalListing.count({ where: { status: 'PENDING_REVIEW' } }),
prisma.booking.count(),
prisma.booking.count({ where: { status: { in: ['CONFIRMED', 'ACTIVE'] } } }),
prisma.booking.count({ where: { status: 'COMPLETED' } }),
prisma.payout.count(),
prisma.payout.count({ where: { status: 'PENDING' } }),
]);
const payoutAgg = await prisma.payout.aggregate({
where: { status: 'COMPLETED' },
_sum: { commissionAmount: true, netAmount: true },
});
res.json({
totalRentals, activeRentals, pendingRentals,
totalBookings, activeBookings, completedBookings,
totalPayouts, pendingPayouts,
totalRentalRevenue: payoutAgg._sum.commissionAmount || 0,
totalPaidOut: payoutAgg._sum.netAmount || 0,
});
} catch (error) {
next(error);
}
});
export default router;

View File

@@ -0,0 +1,107 @@
import { Router } from 'express';
import { prisma } from '../../config/database.js';
import { requireModerator } from '../../middleware/requireRole.js';
import { validate } from '../../middleware/validate.js';
import { resolveReportSchema } from '../../validators/admin.js';
import { AppError } from '../../middleware/errorHandler.js';
const router = Router();
// GET /api/admin/reports
router.get('/', requireModerator, async (req, res, next) => {
try {
const { page = '1', pageSize = '20', status, targetType } = req.query;
const skip = (parseInt(page as string) - 1) * parseInt(pageSize as string);
const take = parseInt(pageSize as string);
const where: any = {};
if (status) where.status = status;
if (targetType) where.targetType = targetType;
const [reports, total] = await Promise.all([
prisma.report.findMany({
where,
include: {
reporter: { select: { id: true, fullName: true, avatar: true } },
},
skip,
take,
orderBy: { createdAt: 'desc' },
}),
prisma.report.count({ where }),
]);
res.json({
data: reports,
total,
page: parseInt(page as string),
pageSize: take,
totalPages: Math.ceil(total / take),
});
} catch (error) {
next(error);
}
});
// GET /api/admin/reports/:id
router.get('/:id', requireModerator, async (req, res, next) => {
try {
const report = await prisma.report.findUnique({
where: { id: req.params.id },
include: {
reporter: { select: { id: true, fullName: true, avatar: true, email: true } },
},
});
if (!report) throw new AppError(404, 'Report not found');
let target: any = null;
if (report.targetType === 'LISTING') {
target = await prisma.listing.findUnique({
where: { id: report.targetId },
select: { id: true, title: true, status: true, seller: { select: { id: true, fullName: true } } },
});
} else {
target = await prisma.user.findUnique({
where: { id: report.targetId },
select: { id: true, fullName: true, email: true, isBanned: true },
});
}
res.json({ report, target });
} catch (error) {
next(error);
}
});
// PATCH /api/admin/reports/:id
router.patch('/:id', requireModerator, validate(resolveReportSchema), async (req, res, next) => {
try {
const report = await prisma.report.findUnique({ where: { id: req.params.id } });
if (!report) throw new AppError(404, 'Report not found');
const updated = await prisma.report.update({
where: { id: req.params.id },
data: {
status: req.body.status,
resolution: req.body.resolution,
resolvedBy: req.userId,
},
});
await prisma.notification.create({
data: {
userId: report.reporterId,
type: 'REPORT_RESOLVED',
title: 'Report Updated',
body: `Your report has been ${req.body.status.toLowerCase()}.`,
data: { reportId: report.id },
},
});
res.json(updated);
} catch (error) {
next(error);
}
});
export default router;

View File

@@ -0,0 +1,43 @@
import { Router } from 'express';
import { prisma } from '../../config/database.js';
import { requireAdmin, requireSuperAdmin } from '../../middleware/requireRole.js';
import { validate } from '../../middleware/validate.js';
import { updateSettingsSchema } from '../../validators/admin.js';
import { invalidateConfigCache } from '../../utils/moderation.js';
const router = Router();
// GET /api/admin/settings
router.get('/', requireAdmin, async (_req, res, next) => {
try {
let config = await prisma.platformConfig.findFirst();
if (!config) {
config = await prisma.platformConfig.create({ data: {} });
}
res.json(config);
} catch (error) {
next(error);
}
});
// PATCH /api/admin/settings
router.patch('/', requireSuperAdmin, validate(updateSettingsSchema), async (req, res, next) => {
try {
let config = await prisma.platformConfig.findFirst();
if (!config) {
config = await prisma.platformConfig.create({ data: {} });
}
const updated = await prisma.platformConfig.update({
where: { id: config.id },
data: req.body,
});
invalidateConfigCache();
res.json(updated);
} catch (error) {
next(error);
}
});
export default router;

View File

@@ -0,0 +1,83 @@
import { Router } from 'express';
import { prisma } from '../../config/database.js';
import { requireModerator, requireAdmin } from '../../middleware/requireRole.js';
const router = Router();
// GET /api/admin/stats - General stats
router.get('/', requireModerator, async (_req, res, next) => {
try {
const [totalUsers, totalListings, activeListings, pendingListings, totalOffers, totalPayments, activeToday] = await Promise.all([
prisma.user.count(),
prisma.listing.count(),
prisma.listing.count({ where: { status: 'ACTIVE' } }),
prisma.listing.count({ where: { status: 'PENDING_REVIEW' } }),
prisma.offer.count(),
prisma.payment.aggregate({ where: { status: 'COMPLETED' }, _sum: { amount: true } }),
prisma.user.count({ where: { updatedAt: { gte: new Date(Date.now() - 24 * 60 * 60 * 1000) } } }),
]);
res.json({
totalUsers,
totalListings,
activeListings,
pendingListings,
totalOffers,
totalRevenue: totalPayments._sum.amount || 0,
activeToday,
});
} catch (error) {
next(error);
}
});
// GET /api/admin/stats/revenue - Revenue over time
router.get('/revenue', requireAdmin, async (req, res, next) => {
try {
const { period = 'daily' } = req.query;
const days = period === 'monthly' ? 365 : period === 'weekly' ? 90 : 30;
const since = new Date(Date.now() - days * 24 * 60 * 60 * 1000);
const payments = await prisma.payment.findMany({
where: { status: 'COMPLETED', createdAt: { gte: since } },
select: { amount: true, type: true, createdAt: true },
orderBy: { createdAt: 'asc' },
});
res.json(payments);
} catch (error) {
next(error);
}
});
// GET /api/admin/stats/users - User growth
router.get('/users', requireAdmin, async (_req, res, next) => {
try {
const since = new Date(Date.now() - 90 * 24 * 60 * 60 * 1000);
const users = await prisma.user.findMany({
where: { createdAt: { gte: since } },
select: { createdAt: true },
orderBy: { createdAt: 'asc' },
});
res.json(users);
} catch (error) {
next(error);
}
});
// GET /api/admin/stats/listings - Listing activity
router.get('/listings', requireModerator, async (_req, res, next) => {
try {
const since = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
const listings = await prisma.listing.findMany({
where: { createdAt: { gte: since } },
select: { createdAt: true, status: true },
orderBy: { createdAt: 'asc' },
});
res.json(listings);
} catch (error) {
next(error);
}
});
export default router;

View File

@@ -0,0 +1,195 @@
import { Router } from 'express';
import { prisma } from '../../config/database.js';
import { requireModerator, requireAdmin, requireSuperAdmin } from '../../middleware/requireRole.js';
import { validate } from '../../middleware/validate.js';
import { banUserSchema, changeRoleSchema } from '../../validators/admin.js';
import { AppError } from '../../middleware/errorHandler.js';
const router = Router();
// GET /api/admin/users - List users
router.get('/', requireModerator, async (req, res, next) => {
try {
const { page = '1', pageSize = '20', search, role, status } = req.query;
const skip = (parseInt(page as string) - 1) * parseInt(pageSize as string);
const take = parseInt(pageSize as string);
const where: any = {};
if (search) {
where.OR = [
{ fullName: { contains: search as string, mode: 'insensitive' } },
{ email: { contains: search as string, mode: 'insensitive' } },
];
}
if (role) where.role = role;
if (status === 'banned') where.isBanned = true;
if (status === 'active') where.isBanned = false;
const [users, total] = await Promise.all([
prisma.user.findMany({
where,
select: {
id: true, email: true, fullName: true, nickname: true, avatar: true,
role: true, isBanned: true, banReason: true, createdAt: true, location: true,
_count: { select: { listings: true, sentOffers: true, reports: true } },
},
skip,
take,
orderBy: { createdAt: 'desc' },
}),
prisma.user.count({ where }),
]);
res.json({
data: users,
total,
page: parseInt(page as string),
pageSize: take,
totalPages: Math.ceil(total / take),
});
} catch (error) {
next(error);
}
});
// GET /api/admin/users/:id - User detail
router.get('/:id', requireModerator, async (req, res, next) => {
try {
const user = await prisma.user.findUnique({
where: { id: req.params.id },
select: {
id: true, email: true, fullName: true, nickname: true, avatar: true,
phone: true, location: true, bio: true, rating: true, ratingCount: true,
role: true, isBanned: true, banReason: true, bannedAt: true, bannedBy: true,
createdAt: true, updatedAt: true,
_count: { select: { listings: true, sentOffers: true, receivedOffers: true, reports: true } },
},
});
if (!user) throw new AppError(404, 'User not found');
const moderationLogs = await prisma.moderationLog.findMany({
where: { OR: [{ targetUserId: req.params.id }, { moderatorId: req.params.id }] },
orderBy: { createdAt: 'desc' },
take: 20,
include: { moderator: { select: { id: true, fullName: true } } },
});
res.json({ user, moderationLogs });
} catch (error) {
next(error);
}
});
// PATCH /api/admin/users/:id/role - Change role
router.patch('/:id/role', requireSuperAdmin, validate(changeRoleSchema), async (req, res, next) => {
try {
if (req.params.id === req.userId) {
throw new AppError(400, 'Cannot change your own role');
}
const user = await prisma.user.update({
where: { id: req.params.id },
data: { role: req.body.role },
select: { id: true, fullName: true, role: true },
});
await prisma.moderationLog.create({
data: {
moderatorId: req.userId!,
targetUserId: req.params.id,
action: 'WARNING',
reason: `Role changed to ${req.body.role}`,
},
});
res.json(user);
} catch (error) {
next(error);
}
});
// POST /api/admin/users/:id/ban - Ban user
router.post('/:id/ban', requireAdmin, validate(banUserSchema), async (req, res, next) => {
try {
if (req.params.id === req.userId) {
throw new AppError(400, 'Cannot ban yourself');
}
const target = await prisma.user.findUnique({ where: { id: req.params.id }, select: { role: true } });
if (!target) throw new AppError(404, 'User not found');
if (target.role === 'SUPER_ADMIN') throw new AppError(403, 'Cannot ban a super admin');
const user = await prisma.user.update({
where: { id: req.params.id },
data: {
isBanned: true,
banReason: req.body.reason,
bannedAt: new Date(),
bannedBy: req.userId,
},
select: { id: true, fullName: true, isBanned: true, banReason: true },
});
await prisma.moderationLog.create({
data: {
moderatorId: req.userId!,
targetUserId: req.params.id,
action: 'BAN',
reason: req.body.reason,
},
});
await prisma.notification.create({
data: {
userId: req.params.id,
type: 'ACCOUNT_BANNED',
title: 'Account Suspended',
body: `Your account has been suspended. Reason: ${req.body.reason}`,
},
});
res.json(user);
} catch (error) {
next(error);
}
});
// POST /api/admin/users/:id/unban - Unban user
router.post('/:id/unban', requireAdmin, async (req, res, next) => {
try {
const user = await prisma.user.update({
where: { id: req.params.id },
data: {
isBanned: false,
banReason: null,
bannedAt: null,
bannedBy: null,
},
select: { id: true, fullName: true, isBanned: true },
});
await prisma.moderationLog.create({
data: {
moderatorId: req.userId!,
targetUserId: req.params.id,
action: 'UNBAN',
reason: 'Account unbanned',
},
});
await prisma.notification.create({
data: {
userId: req.params.id,
type: 'ACCOUNT_UNBANNED',
title: 'Account Restored',
body: 'Your account has been restored. You can now use the platform again.',
},
});
res.json(user);
} catch (error) {
next(error);
}
});
export default router;

View File

@@ -20,7 +20,7 @@ router.post('/register', validate(registerSchema), async (req, res, next) => {
const passwordHash = await hashPassword(password);
const user = await prisma.user.create({
data: { fullName, email, passwordHash },
select: { id: true, email: true, fullName: true, nickname: true, avatar: true, phone: true, location: true, bio: true, rating: true, showEmail: true, showPhone: true, showLocation: true, createdAt: true },
select: { id: true, email: true, fullName: true, nickname: true, avatar: true, phone: true, location: true, bio: true, rating: true, showEmail: true, showPhone: true, showLocation: true, role: true, createdAt: true },
});
const accessToken = generateAccessToken(user.id);
@@ -56,6 +56,7 @@ router.post('/login', validate(loginSchema), async (req, res, next) => {
const fullUser = await prisma.user.findUnique({ where: { email } });
if (!fullUser) throw new AppError(401, 'Invalid email or password');
if (!fullUser.isActive) throw new AppError(403, 'Account is disabled');
if (fullUser.isBanned) throw new AppError(403, `Account suspended: ${fullUser.banReason || 'Contact support for details'}`);
const valid = await comparePassword(password, fullUser.passwordHash);
if (!valid) throw new AppError(401, 'Invalid email or password');
@@ -82,7 +83,7 @@ router.post('/login', validate(loginSchema), async (req, res, next) => {
const user = await prisma.user.findUnique({
where: { id: fullUser.id },
select: { id: true, email: true, fullName: true, nickname: true, avatar: true, phone: true, location: true, bio: true, rating: true, showEmail: true, showPhone: true, showLocation: true, createdAt: true },
select: { id: true, email: true, fullName: true, nickname: true, avatar: true, phone: true, location: true, bio: true, rating: true, showEmail: true, showPhone: true, showLocation: true, role: true, createdAt: true },
});
res.json({ user, accessToken });
} catch (error) {
@@ -127,7 +128,7 @@ router.get('/me', authenticate, async (req, res, next) => {
try {
const user = await prisma.user.findUnique({
where: { id: req.userId },
select: { id: true, email: true, fullName: true, nickname: true, avatar: true, phone: true, location: true, bio: true, rating: true, showEmail: true, showPhone: true, showLocation: true, createdAt: true },
select: { id: true, email: true, fullName: true, nickname: true, avatar: true, phone: true, location: true, bio: true, rating: true, showEmail: true, showPhone: true, showLocation: true, role: true, createdAt: true },
});
if (!user) throw new AppError(404, 'User not found');
res.json({ user });

View File

@@ -0,0 +1,357 @@
import { Router } from 'express';
import { prisma } from '../config/database.js';
import { authenticate } from '../middleware/auth.js';
import { validate } from '../middleware/validate.js';
import { createBookingSchema, rejectBookingSchema, cancelBookingSchema } from '../validators/booking.js';
import { AppError } from '../middleware/errorHandler.js';
import { checkAvailability, calculateCancellationRefund, autoTransitionBooking } from '../utils/rental.js';
import { getPlatformConfig } from '../utils/moderation.js';
const router = Router();
const bookingInclude = {
rentalListing: {
select: {
id: true, title: true, category: true, location: true, cancellationPolicy: true,
images: { take: 1, orderBy: { order: 'asc' as const } },
},
},
tenant: { select: { id: true, fullName: true, nickname: true, avatar: true } },
landlord: { select: { id: true, fullName: true, nickname: true, avatar: true } },
payout: true,
review: true,
};
// --- List bookings ---
router.get('/', authenticate, async (req, res, next) => {
try {
const { role = 'tenant', status } = req.query;
const where: Record<string, unknown> = role === 'landlord'
? { landlordId: req.userId }
: { tenantId: req.userId };
if (status && typeof status === 'string') {
where.status = status;
}
const bookings = await prisma.booking.findMany({
where,
include: bookingInclude,
orderBy: { createdAt: 'desc' },
});
// Auto-transition stale bookings
const updated = await Promise.all(bookings.map(b => autoTransitionBooking(b)));
res.json(updated);
} catch (error) {
next(error);
}
});
// --- Get single booking ---
router.get('/:id', authenticate, async (req, res, next) => {
try {
const booking = await prisma.booking.findUnique({
where: { id: req.params.id },
include: bookingInclude,
});
if (!booking) throw new AppError(404, 'Booking not found');
if (booking.tenantId !== req.userId && booking.landlordId !== req.userId) {
throw new AppError(403, 'Not authorized');
}
const transitioned = await autoTransitionBooking(booking);
res.json(transitioned);
} catch (error) {
next(error);
}
});
// --- Create booking request ---
router.post('/', authenticate, validate(createBookingSchema), async (req, res, next) => {
try {
const { rentalListingId, periodType, startDate: startStr, endDate: endStr, message } = req.body;
const rental = await prisma.rentalListing.findUnique({ where: { id: rentalListingId } });
if (!rental) throw new AppError(404, 'Rental not found');
if (rental.status !== 'ACTIVE') throw new AppError(400, 'Rental is not active');
if (rental.landlordId === req.userId) throw new AppError(400, 'Cannot book your own rental');
const startDate = new Date(startStr);
const endDate = new Date(endStr);
if (startDate >= endDate) throw new AppError(400, 'End date must be after start date');
if (startDate < new Date()) throw new AppError(400, 'Start date must be in the future');
// Check price exists for period type
if (periodType === 'DAILY' && !rental.dailyPrice) throw new AppError(400, 'Daily rental not available');
if (periodType === 'MONTHLY' && !rental.monthlyPrice) throw new AppError(400, 'Monthly rental not available');
// Check availability
const available = await checkAvailability(rentalListingId, startDate, endDate);
if (!available) throw new AppError(409, 'Selected dates are not available');
// Calculate pricing
const pricePerPeriod = periodType === 'DAILY' ? rental.dailyPrice! : rental.monthlyPrice!;
let totalPeriods: number;
if (periodType === 'DAILY') {
totalPeriods = Math.ceil((endDate.getTime() - startDate.getTime()) / (24 * 60 * 60 * 1000));
} else {
totalPeriods = Math.ceil((endDate.getTime() - startDate.getTime()) / (30 * 24 * 60 * 60 * 1000));
}
if (totalPeriods < 1) totalPeriods = 1;
// Min/max checks
if (periodType === 'DAILY') {
if (rental.minDays && totalPeriods < rental.minDays) throw new AppError(400, `Minimum ${rental.minDays} days required`);
if (rental.maxDays && totalPeriods > rental.maxDays) throw new AppError(400, `Maximum ${rental.maxDays} days allowed`);
} else {
if (rental.minMonths && totalPeriods < rental.minMonths) throw new AppError(400, `Minimum ${rental.minMonths} months required`);
if (rental.maxMonths && totalPeriods > rental.maxMonths) throw new AppError(400, `Maximum ${rental.maxMonths} months allowed`);
}
const config = await getPlatformConfig();
const subtotal = pricePerPeriod * totalPeriods;
const commissionRate = config.rentalCommissionPercent;
const commissionAmount = subtotal * (commissionRate / 100);
const depositAmount = rental.depositAmount || 0;
const totalAmount = subtotal + depositAmount;
const booking = await prisma.booking.create({
data: {
rentalListingId,
tenantId: req.userId!,
landlordId: rental.landlordId,
periodType,
startDate,
endDate,
pricePerPeriod,
totalPeriods,
subtotal,
commissionRate,
commissionAmount,
depositAmount,
totalAmount,
message,
expiresAt: new Date(Date.now() + config.bookingExpiryHours * 60 * 60 * 1000),
},
include: bookingInclude,
});
// Notify landlord
const tenant = await prisma.user.findUnique({ where: { id: req.userId }, select: { fullName: true } });
const notification = await prisma.notification.create({
data: {
userId: rental.landlordId,
type: 'BOOKING_REQUEST',
title: 'New Booking Request',
body: `${tenant?.fullName || 'Someone'} requested to book "${rental.title}"`,
data: { bookingId: booking.id, rentalListingId },
},
});
const io = req.app.get('io');
if (io) {
io.to(`user:${rental.landlordId}`).emit('new_notification', notification);
}
res.status(201).json(booking);
} catch (error) {
next(error);
}
});
// --- Confirm booking (landlord) ---
router.patch('/:id/confirm', authenticate, async (req, res, next) => {
try {
const booking = await prisma.booking.findUnique({ where: { id: req.params.id } });
if (!booking) throw new AppError(404, 'Booking not found');
if (booking.landlordId !== req.userId) throw new AppError(403, 'Not authorized');
if (booking.status !== 'PENDING') throw new AppError(400, 'Can only confirm pending bookings');
// Check if expired
if (booking.expiresAt && booking.expiresAt < new Date()) {
await prisma.booking.update({ where: { id: booking.id }, data: { status: 'EXPIRED' } });
throw new AppError(400, 'Booking has expired');
}
const updated = await prisma.booking.update({
where: { id: req.params.id },
data: { status: 'CONFIRMED' },
include: bookingInclude,
});
// Notify tenant
const notification = await prisma.notification.create({
data: {
userId: booking.tenantId,
type: 'BOOKING_CONFIRMED',
title: 'Booking Confirmed',
body: `Your booking has been confirmed! Please proceed with payment.`,
data: { bookingId: booking.id },
},
});
const io = req.app.get('io');
if (io) {
io.to(`user:${booking.tenantId}`).emit('new_notification', notification);
}
res.json(updated);
} catch (error) {
next(error);
}
});
// --- Reject booking (landlord) ---
router.patch('/:id/reject', authenticate, validate(rejectBookingSchema), async (req, res, next) => {
try {
const booking = await prisma.booking.findUnique({ where: { id: req.params.id } });
if (!booking) throw new AppError(404, 'Booking not found');
if (booking.landlordId !== req.userId) throw new AppError(403, 'Not authorized');
if (booking.status !== 'PENDING') throw new AppError(400, 'Can only reject pending bookings');
const updated = await prisma.booking.update({
where: { id: req.params.id },
data: { status: 'REJECTED', rejectionReason: req.body.reason },
include: bookingInclude,
});
const notification = await prisma.notification.create({
data: {
userId: booking.tenantId,
type: 'BOOKING_REJECTED',
title: 'Booking Rejected',
body: `Your booking was rejected. Reason: ${req.body.reason}`,
data: { bookingId: booking.id },
},
});
const io = req.app.get('io');
if (io) {
io.to(`user:${booking.tenantId}`).emit('new_notification', notification);
}
res.json(updated);
} catch (error) {
next(error);
}
});
// --- Cancel booking (either side) ---
router.patch('/:id/cancel', authenticate, validate(cancelBookingSchema), async (req, res, next) => {
try {
const booking = await prisma.booking.findUnique({
where: { id: req.params.id },
include: { rentalListing: true },
});
if (!booking) throw new AppError(404, 'Booking not found');
if (booking.tenantId !== req.userId && booking.landlordId !== req.userId) {
throw new AppError(403, 'Not authorized');
}
const isTenant = booking.tenantId === req.userId;
const cancelStatus = isTenant ? 'CANCELLED_BY_TENANT' : 'CANCELLED_BY_LANDLORD';
if (!['PENDING', 'CONFIRMED', 'ACTIVE'].includes(booking.status)) {
throw new AppError(400, 'Cannot cancel this booking');
}
// Calculate refund if already paid
let refundAmount = 0;
let depositRefund = booking.depositAmount;
if (booking.status === 'CONFIRMED' || booking.status === 'ACTIVE') {
const refund = calculateCancellationRefund(
booking.rentalListing.cancellationPolicy,
booking.startDate,
booking.subtotal,
booking.depositAmount,
);
refundAmount = refund.refundAmount;
depositRefund = refund.depositRefund;
}
const updated = await prisma.booking.update({
where: { id: req.params.id },
data: {
status: cancelStatus as any,
cancellationReason: req.body.reason,
},
include: bookingInclude,
});
// Notify other party
const recipientId = isTenant ? booking.landlordId : booking.tenantId;
const notification = await prisma.notification.create({
data: {
userId: recipientId,
type: 'BOOKING_CANCELLED',
title: 'Booking Cancelled',
body: `A booking has been cancelled. Reason: ${req.body.reason}`,
data: { bookingId: booking.id, refundAmount, depositRefund },
},
});
const io = req.app.get('io');
if (io) {
io.to(`user:${recipientId}`).emit('new_notification', notification);
}
res.json({ ...updated, refundAmount, depositRefund });
} catch (error) {
next(error);
}
});
// --- Complete booking (landlord) ---
router.patch('/:id/complete', authenticate, async (req, res, next) => {
try {
const booking = await prisma.booking.findUnique({ where: { id: req.params.id } });
if (!booking) throw new AppError(404, 'Booking not found');
if (booking.landlordId !== req.userId) throw new AppError(403, 'Not authorized');
if (booking.status !== 'ACTIVE' && booking.status !== 'CONFIRMED') {
throw new AppError(400, 'Can only complete active/confirmed bookings');
}
const updated = await prisma.booking.update({
where: { id: req.params.id },
data: { status: 'COMPLETED' },
include: bookingInclude,
});
// Create payout
const netAmount = booking.subtotal - booking.commissionAmount;
await prisma.payout.create({
data: {
bookingId: booking.id,
landlordId: booking.landlordId,
grossAmount: booking.subtotal,
commissionAmount: booking.commissionAmount,
netAmount,
status: 'PENDING',
},
});
// Notify tenant
const notification = await prisma.notification.create({
data: {
userId: booking.tenantId,
type: 'BOOKING_COMPLETED',
title: 'Booking Completed',
body: 'Your booking has been completed. Please leave a review!',
data: { bookingId: booking.id },
},
});
const io = req.app.get('io');
if (io) {
io.to(`user:${booking.tenantId}`).emit('new_notification', notification);
}
res.json(updated);
} catch (error) {
next(error);
}
});
export default router;

View File

@@ -6,12 +6,14 @@ import { upload } from '../middleware/upload.js';
import { createListingSchema, updateListingSchema } from '../validators/listing.js';
import { AppError } from '../middleware/errorHandler.js';
import { getBlockedUserIds } from '../utils/blocked.js';
import { getPlatformConfig, checkBlockedKeywords } from '../utils/moderation.js';
const router = Router();
const listingSelect = {
id: true, title: true, description: true, price: true, obo: true,
category: true, condition: true, status: true, location: true, viewCount: true,
isFeatured: true,
createdAt: true, updatedAt: true, sellerId: true,
seller: { select: { id: true, fullName: true, nickname: true, avatar: true, rating: true, location: true, createdAt: true, showEmail: true, showPhone: true, showLocation: true } },
images: { orderBy: { order: 'asc' as const } },
@@ -214,8 +216,12 @@ router.get('/:id', optionalAuth, async (req, res, next) => {
// --- Create listing ---
router.post('/', authenticate, validate(createListingSchema), async (req, res, next) => {
try {
const config = await getPlatformConfig();
const textToCheck = `${req.body.title} ${req.body.description}`;
const blockedWord = checkBlockedKeywords(textToCheck, config.blockedKeywords);
const listing = await prisma.listing.create({
data: { ...req.body, sellerId: req.userId!, status: 'DRAFT' },
data: { ...req.body, sellerId: req.userId!, status: blockedWord ? 'PENDING_REVIEW' : 'DRAFT' },
select: listingSelect,
});
res.status(201).json(listing);
@@ -250,11 +256,16 @@ router.post('/:id/activate', authenticate, async (req, res, next) => {
const existing = await prisma.listing.findUnique({ where: { id: req.params.id } });
if (!existing) throw new AppError(404, 'Listing not found');
if (existing.sellerId !== req.userId) throw new AppError(403, 'Not authorized');
if (existing.status !== 'DRAFT') throw new AppError(400, 'Listing is not in draft status');
if (existing.status !== 'DRAFT' && existing.status !== 'PENDING_REVIEW') throw new AppError(400, 'Listing cannot be activated');
const config = await getPlatformConfig();
const textToCheck = `${existing.title} ${existing.description}`;
const blockedWord = checkBlockedKeywords(textToCheck, config.blockedKeywords);
const newStatus = (!config.autoApprove || blockedWord) ? 'PENDING_REVIEW' : 'ACTIVE';
const listing = await prisma.listing.update({
where: { id: req.params.id },
data: { status: 'ACTIVE' },
data: { status: newStatus },
select: listingSelect,
});
res.json(listing);

View File

@@ -198,6 +198,26 @@ router.patch('/:id', authenticate, validate(respondOfferSchema), async (req, res
where: { listingId: existing.listingId, id: { not: existing.id }, status: 'PENDING' },
data: { status: 'DECLINED' },
});
// Create commission payment
try {
const { getPlatformConfig } = await import('../utils/moderation.js');
const config = await getPlatformConfig();
const saleAmount = existing.status === 'COUNTERED' ? (existing.counterAmount || existing.amount) : existing.amount;
const commission = saleAmount * (config.commissionPercent / 100);
if (commission > 0) {
await prisma.payment.create({
data: {
userId: existing.sellerId,
listingId: existing.listingId,
amount: commission,
status: 'COMPLETED',
type: 'COMMISSION',
description: `${config.commissionPercent}% commission on sale`,
},
});
}
} catch {}
}
const recipientId = existing.buyerId === req.userId ? existing.sellerId : existing.buyerId;

View File

@@ -4,6 +4,7 @@ import { prisma } from '../config/database.js';
import { authenticate } from '../middleware/auth.js';
import { env } from '../config/env.js';
import { AppError } from '../middleware/errorHandler.js';
import { getPlatformConfig } from '../utils/moderation.js';
const router = Router();
@@ -63,8 +64,11 @@ router.post('/create-intent', authenticate, async (req, res, next) => {
});
if (existingPayment) throw new AppError(400, 'Listing already paid for');
const config = await getPlatformConfig();
const feeInCents = Math.round(config.listingFee * 100);
const paymentIntent = await stripe.paymentIntents.create({
amount: 500,
amount: feeInCents,
currency: 'usd',
metadata: { listingId, userId: req.userId! },
});
@@ -74,8 +78,9 @@ router.post('/create-intent', authenticate, async (req, res, next) => {
userId: req.userId!,
listingId,
stripePaymentId: paymentIntent.id,
amount: 5,
amount: config.listingFee,
status: 'PENDING',
type: 'LISTING_FEE',
},
});

116
server/src/routes/payout.ts Normal file
View File

@@ -0,0 +1,116 @@
import { Router } from 'express';
import Stripe from 'stripe';
import { prisma } from '../config/database.js';
import { authenticate } from '../middleware/auth.js';
import { env } from '../config/env.js';
import { AppError } from '../middleware/errorHandler.js';
const router = Router();
const stripe = env.STRIPE_SECRET_KEY ? new Stripe(env.STRIPE_SECRET_KEY) : null;
// --- List payouts ---
router.get('/', authenticate, async (req, res, next) => {
try {
const payouts = await prisma.payout.findMany({
where: { landlordId: req.userId },
include: {
booking: {
select: {
id: true,
rentalListing: { select: { id: true, title: true } },
tenant: { select: { id: true, fullName: true } },
startDate: true, endDate: true,
},
},
},
orderBy: { createdAt: 'desc' },
});
res.json(payouts);
} catch (error) {
next(error);
}
});
// --- Get payout details ---
router.get('/:id', authenticate, async (req, res, next) => {
try {
const payout = await prisma.payout.findUnique({
where: { id: req.params.id },
include: {
booking: {
select: {
id: true,
rentalListing: { select: { id: true, title: true } },
tenant: { select: { id: true, fullName: true } },
startDate: true, endDate: true, totalAmount: true, subtotal: true,
},
},
},
});
if (!payout) throw new AppError(404, 'Payout not found');
if (payout.landlordId !== req.userId) throw new AppError(403, 'Not authorized');
res.json(payout);
} catch (error) {
next(error);
}
});
// --- Setup Stripe Connect account ---
router.post('/setup-account', authenticate, async (req, res, next) => {
try {
if (!stripe) throw new AppError(500, 'Stripe not configured');
const user = await prisma.user.findUnique({ where: { id: req.userId } });
if (!user) throw new AppError(404, 'User not found');
let accountId = user.stripeAccountId;
if (!accountId) {
const account = await stripe.accounts.create({
type: 'express',
email: user.email,
metadata: { userId: user.id },
});
accountId = account.id;
await prisma.user.update({ where: { id: req.userId }, data: { stripeAccountId: accountId } });
}
const accountLink = await stripe.accountLinks.create({
account: accountId,
refresh_url: `${env.CLIENT_URL}/landlord/payouts`,
return_url: `${env.CLIENT_URL}/landlord/payouts`,
type: 'account_onboarding',
});
res.json({ url: accountLink.url });
} catch (error) {
next(error);
}
});
// --- Check Stripe Connect account status ---
router.get('/account-status', authenticate, async (req, res, next) => {
try {
if (!stripe) throw new AppError(500, 'Stripe not configured');
const user = await prisma.user.findUnique({ where: { id: req.userId } });
if (!user?.stripeAccountId) {
return res.json({ connected: false, detailsSubmitted: false, chargesEnabled: false, payoutsEnabled: false });
}
const account = await stripe.accounts.retrieve(user.stripeAccountId);
res.json({
connected: true,
detailsSubmitted: account.details_submitted,
chargesEnabled: account.charges_enabled,
payoutsEnabled: account.payouts_enabled,
});
} catch (error) {
next(error);
}
});
export default router;

View File

@@ -0,0 +1,72 @@
import { Router } from 'express';
import { prisma } from '../config/database.js';
import { authenticate } from '../middleware/auth.js';
import { getPlatformConfig } from '../utils/moderation.js';
import { AppError } from '../middleware/errorHandler.js';
const router = Router();
// POST /api/listings/:id/promote
router.post('/:id/promote', authenticate, async (req, res, next) => {
try {
const { days } = req.body;
if (!days || days < 1 || days > 30) {
throw new AppError(400, 'Days must be between 1 and 30');
}
const listing = await prisma.listing.findUnique({ where: { id: req.params.id } });
if (!listing) throw new AppError(404, 'Listing not found');
if (listing.sellerId !== req.userId) throw new AppError(403, 'Not authorized');
if (listing.status !== 'ACTIVE') throw new AppError(400, 'Listing must be active');
const config = await getPlatformConfig();
const amountPaid = config.promotionDayPrice * days;
const promotion = await prisma.promotedListing.upsert({
where: { listingId: req.params.id },
update: {
endDate: new Date(Date.now() + days * 24 * 60 * 60 * 1000),
amountPaid: { increment: amountPaid },
isActive: true,
},
create: {
listingId: req.params.id,
userId: req.userId!,
endDate: new Date(Date.now() + days * 24 * 60 * 60 * 1000),
amountPaid,
isActive: true,
},
});
// Record payment
await prisma.payment.create({
data: {
userId: req.userId!,
listingId: req.params.id,
amount: amountPaid,
status: 'COMPLETED',
type: 'PROMOTION',
description: `${days}-day listing promotion`,
},
});
res.json(promotion);
} catch (error) {
next(error);
}
});
// GET /api/listings/:id/promotion
router.get('/:id/promotion', authenticate, async (req, res, next) => {
try {
const promotion = await prisma.promotedListing.findUnique({
where: { listingId: req.params.id },
});
res.json(promotion || { isActive: false });
} catch (error) {
next(error);
}
});
export default router;

View File

@@ -0,0 +1,127 @@
import { Router } from 'express';
import Stripe from 'stripe';
import { prisma } from '../config/database.js';
import { authenticate } from '../middleware/auth.js';
import { env } from '../config/env.js';
import { AppError } from '../middleware/errorHandler.js';
const router = Router();
const stripe = env.STRIPE_SECRET_KEY ? new Stripe(env.STRIPE_SECRET_KEY) : null;
// --- Create payment intent for confirmed booking ---
router.post('/create-intent', authenticate, async (req, res, next) => {
try {
const { bookingId } = req.body;
if (!stripe) throw new AppError(500, 'Stripe not configured');
if (!bookingId) throw new AppError(400, 'Booking ID required');
const booking = await prisma.booking.findUnique({ where: { id: bookingId } });
if (!booking) throw new AppError(404, 'Booking not found');
if (booking.tenantId !== req.userId) throw new AppError(403, 'Not authorized');
if (booking.status !== 'CONFIRMED') throw new AppError(400, 'Booking must be confirmed before payment');
// Check if already paid
const existingPayment = await prisma.bookingPayment.findFirst({
where: { bookingId, status: 'COMPLETED', type: 'RENTAL_BOOKING' },
});
if (existingPayment) throw new AppError(400, 'Booking already paid');
const amountInCents = Math.round(booking.totalAmount * 100);
const paymentIntent = await stripe.paymentIntents.create({
amount: amountInCents,
currency: 'usd',
metadata: { bookingId, tenantId: req.userId!, landlordId: booking.landlordId },
});
await prisma.bookingPayment.create({
data: {
bookingId,
stripePaymentId: paymentIntent.id,
amount: booking.totalAmount,
type: 'RENTAL_BOOKING',
status: 'PENDING',
},
});
await prisma.booking.update({
where: { id: bookingId },
data: { stripePaymentIntentId: paymentIntent.id },
});
res.json({ clientSecret: paymentIntent.client_secret });
} catch (error) {
next(error);
}
});
// --- Stripe webhook ---
router.post('/webhook', async (req, res, next) => {
try {
if (!stripe) throw new AppError(500, 'Stripe not configured');
const sig = req.headers['stripe-signature'] as string;
const event = stripe.webhooks.constructEvent(req.body, sig, env.STRIPE_WEBHOOK_SECRET);
if (event.type === 'payment_intent.succeeded') {
const paymentIntent = event.data.object;
const { bookingId } = paymentIntent.metadata;
if (bookingId) {
await prisma.bookingPayment.updateMany({
where: { stripePaymentId: paymentIntent.id },
data: { status: 'COMPLETED' },
});
// Move booking to ACTIVE if start date has passed, otherwise keep CONFIRMED
const booking = await prisma.booking.findUnique({ where: { id: bookingId } });
if (booking && booking.status === 'CONFIRMED') {
const newStatus = booking.startDate <= new Date() ? 'ACTIVE' : 'CONFIRMED';
await prisma.booking.update({ where: { id: bookingId }, data: { status: newStatus } });
}
}
} else if (event.type === 'payment_intent.payment_failed') {
const paymentIntent = event.data.object;
await prisma.bookingPayment.updateMany({
where: { stripePaymentId: paymentIntent.id },
data: { status: 'FAILED' },
});
}
res.json({ received: true });
} catch (error) {
next(error);
}
});
// --- Payment history ---
router.get('/history', authenticate, async (req, res, next) => {
try {
const payments = await prisma.bookingPayment.findMany({
where: {
booking: {
OR: [
{ tenantId: req.userId },
{ landlordId: req.userId },
],
},
},
include: {
booking: {
select: {
id: true,
rentalListing: { select: { id: true, title: true, images: { take: 1, orderBy: { order: 'asc' } } } },
},
},
},
orderBy: { createdAt: 'desc' },
});
res.json(payments);
} catch (error) {
next(error);
}
});
export default router;

View File

@@ -0,0 +1,103 @@
import { Router } from 'express';
import { prisma } from '../config/database.js';
import { authenticate } from '../middleware/auth.js';
import { validate } from '../middleware/validate.js';
import { createReviewSchema, respondReviewSchema } from '../validators/rental-review.js';
import { AppError } from '../middleware/errorHandler.js';
const router = Router();
// --- Create review (tenant only, on completed booking) ---
router.post('/', authenticate, validate(createReviewSchema), async (req, res, next) => {
try {
const { bookingId, rating, comment } = req.body;
const booking = await prisma.booking.findUnique({
where: { id: bookingId },
include: { review: true },
});
if (!booking) throw new AppError(404, 'Booking not found');
if (booking.tenantId !== req.userId) throw new AppError(403, 'Not authorized');
if (booking.status !== 'COMPLETED') throw new AppError(400, 'Can only review completed bookings');
if (booking.review) throw new AppError(409, 'Review already exists for this booking');
const review = await prisma.rentalReview.create({
data: {
bookingId,
rentalListingId: booking.rentalListingId,
tenantId: req.userId!,
landlordId: booking.landlordId,
rating,
comment,
},
include: {
tenant: { select: { id: true, fullName: true, avatar: true } },
},
});
// Notify landlord
const notification = await prisma.notification.create({
data: {
userId: booking.landlordId,
type: 'RENTAL_REVIEW',
title: 'New Review',
body: `You received a ${rating}-star review`,
data: { reviewId: review.id, bookingId },
},
});
const io = req.app.get('io');
if (io) {
io.to(`user:${booking.landlordId}`).emit('new_notification', notification);
}
res.status(201).json(review);
} catch (error) {
next(error);
}
});
// --- Landlord respond to review ---
router.patch('/:id/respond', authenticate, validate(respondReviewSchema), async (req, res, next) => {
try {
const review = await prisma.rentalReview.findUnique({ where: { id: req.params.id } });
if (!review) throw new AppError(404, 'Review not found');
if (review.landlordId !== req.userId) throw new AppError(403, 'Not authorized');
const updated = await prisma.rentalReview.update({
where: { id: req.params.id },
data: { landlordResponse: req.body.response },
include: {
tenant: { select: { id: true, fullName: true, avatar: true } },
},
});
res.json(updated);
} catch (error) {
next(error);
}
});
// --- Get all reviews for a landlord ---
router.get('/landlord/:id', async (req, res, next) => {
try {
const reviews = await prisma.rentalReview.findMany({
where: { landlordId: req.params.id },
include: {
tenant: { select: { id: true, fullName: true, avatar: true } },
rentalListing: { select: { id: true, title: true } },
},
orderBy: { createdAt: 'desc' },
});
const avgRating = reviews.length > 0
? reviews.reduce((sum, r) => sum + r.rating, 0) / reviews.length
: 0;
res.json({ reviews, avgRating, totalReviews: reviews.length });
} catch (error) {
next(error);
}
});
export default router;

406
server/src/routes/rental.ts Normal file
View File

@@ -0,0 +1,406 @@
import { Router } from 'express';
import { prisma } from '../config/database.js';
import { authenticate, optionalAuth } from '../middleware/auth.js';
import { validate } from '../middleware/validate.js';
import { upload } from '../middleware/upload.js';
import { createRentalSchema, updateRentalSchema } from '../validators/rental.js';
import { AppError } from '../middleware/errorHandler.js';
import { getPlatformConfig, checkBlockedKeywords } from '../utils/moderation.js';
import { checkAvailability } from '../utils/rental.js';
const router = Router();
const rentalSelect = {
id: true, title: true, description: true, category: true, location: true,
dailyPrice: true, monthlyPrice: true, depositAmount: true, details: true,
amenities: true, rules: true, cancellationPolicy: true,
minDays: true, maxDays: true, minMonths: true, maxMonths: true,
status: true, viewCount: true, isFeatured: true, isVerified: true,
rejectionReason: true,
createdAt: true, updatedAt: true, landlordId: true,
landlord: { select: { id: true, fullName: true, nickname: true, avatar: true, rating: true, location: true, createdAt: true, landlordVerified: true, showEmail: true, showPhone: true, showLocation: true } },
images: { orderBy: { order: 'asc' as const } },
_count: { select: { favorites: true, bookings: true, reviews: true } },
};
// --- My rental listings ---
router.get('/mine', authenticate, async (req, res, next) => {
try {
const { status } = req.query;
const where: Record<string, unknown> = { landlordId: req.userId };
if (status && typeof status === 'string') {
where.status = status;
} else {
where.status = { not: 'DELETED' };
}
const listings = await prisma.rentalListing.findMany({
where,
select: rentalSelect,
orderBy: { createdAt: 'desc' },
});
res.json(listings);
} catch (error) {
next(error);
}
});
// --- Favorites list ---
router.get('/favorites', authenticate, async (req, res, next) => {
try {
const { page = '1', pageSize = '20' } = req.query;
const skip = (parseInt(page as string) - 1) * parseInt(pageSize as string);
const take = parseInt(pageSize as string);
const [favorites, total] = await Promise.all([
prisma.rentalFavorite.findMany({
where: { userId: req.userId! },
include: { rentalListing: { select: rentalSelect } },
orderBy: { createdAt: 'desc' },
skip, take,
}),
prisma.rentalFavorite.count({ where: { userId: req.userId! } }),
]);
const data = favorites
.filter(f => f.rentalListing.status === 'ACTIVE')
.map(f => ({ ...f.rentalListing, isFavorited: true }));
res.json({ data, total, page: parseInt(page as string), pageSize: take, totalPages: Math.ceil(total / take) });
} catch (error) {
next(error);
}
});
// --- Search/list active rentals ---
router.get('/', optionalAuth, async (req, res, next) => {
try {
const { page = '1', pageSize = '20', category, search, sort = 'newest', priceMin, priceMax, periodType, location } = req.query;
const skip = (parseInt(page as string) - 1) * parseInt(pageSize as string);
const take = parseInt(pageSize as string);
const where: Record<string, unknown> = { status: 'ACTIVE' };
if (category) where.category = category;
if (location && typeof location === 'string') {
where.location = { contains: location, mode: 'insensitive' };
}
if (search) {
where.OR = [
{ title: { contains: search as string, mode: 'insensitive' } },
{ description: { contains: search as string, mode: 'insensitive' } },
{ location: { contains: search as string, mode: 'insensitive' } },
];
}
// Price filters
if (periodType === 'DAILY' || priceMin || priceMax) {
const priceField = periodType === 'MONTHLY' ? 'monthlyPrice' : 'dailyPrice';
const priceFilter: Record<string, unknown> = {};
if (priceMin) priceFilter.gte = parseFloat(priceMin as string);
if (priceMax) priceFilter.lte = parseFloat(priceMax as string);
if (Object.keys(priceFilter).length > 0) {
where[priceField] = priceFilter;
}
}
const orderBy = sort === 'price_asc' ? { dailyPrice: 'asc' as const }
: sort === 'price_desc' ? { dailyPrice: 'desc' as const }
: sort === 'popular' ? { viewCount: 'desc' as const }
: { createdAt: 'desc' as const };
const [data, total] = await Promise.all([
prisma.rentalListing.findMany({ where, select: rentalSelect, skip, take, orderBy }),
prisma.rentalListing.count({ where }),
]);
let favIds: Set<string> = new Set();
if (req.userId) {
const favs = await prisma.rentalFavorite.findMany({
where: { userId: req.userId, rentalListingId: { in: data.map(l => l.id) } },
select: { rentalListingId: true },
});
favIds = new Set(favs.map(f => f.rentalListingId));
}
const listings = data.map(l => ({ ...l, isFavorited: favIds.has(l.id) }));
res.json({ data: listings, total, page: parseInt(page as string), pageSize: take, totalPages: Math.ceil(total / take) });
} catch (error) {
next(error);
}
});
// --- Get single rental ---
router.get('/:id', optionalAuth, async (req, res, next) => {
try {
const rental = await prisma.rentalListing.findUnique({
where: { id: req.params.id },
select: {
...rentalSelect,
landlord: { select: { id: true, email: true, fullName: true, nickname: true, avatar: true, phone: true, location: true, bio: true, rating: true, landlordVerified: true, showEmail: true, showPhone: true, showLocation: true, createdAt: true } },
reviews: {
select: {
id: true, rating: true, comment: true, landlordResponse: true, createdAt: true,
tenant: { select: { id: true, fullName: true, avatar: true } },
},
orderBy: { createdAt: 'desc' as const },
take: 20,
},
},
});
if (!rental || rental.status === 'DELETED') throw new AppError(404, 'Rental not found');
await prisma.rentalListing.update({ where: { id: req.params.id }, data: { viewCount: { increment: 1 } } });
let isFavorited = false;
if (req.userId) {
const fav = await prisma.rentalFavorite.findUnique({
where: { userId_rentalListingId: { userId: req.userId, rentalListingId: rental.id } },
});
isFavorited = !!fav;
}
// Average rating
const avgRating = rental.reviews.length > 0
? rental.reviews.reduce((sum, r) => sum + r.rating, 0) / rental.reviews.length
: 0;
// Privacy
const landlord: Record<string, unknown> = { ...rental.landlord };
if (!rental.landlord.showEmail) delete landlord.email;
if (!rental.landlord.showPhone) delete landlord.phone;
if (!rental.landlord.showLocation) delete landlord.location;
res.json({ ...rental, landlord, isFavorited, avgRating });
} catch (error) {
next(error);
}
});
// --- Create rental ---
router.post('/', authenticate, validate(createRentalSchema), async (req, res, next) => {
try {
const config = await getPlatformConfig();
const textToCheck = `${req.body.title} ${req.body.description}`;
const blockedWord = checkBlockedKeywords(textToCheck, config.blockedKeywords);
// Set user as landlord
await prisma.user.update({ where: { id: req.userId }, data: { isLandlord: true } });
const rental = await prisma.rentalListing.create({
data: { ...req.body, landlordId: req.userId!, status: blockedWord ? 'PENDING_REVIEW' : 'DRAFT' },
select: rentalSelect,
});
res.status(201).json(rental);
} catch (error) {
next(error);
}
});
// --- Update rental ---
router.put('/:id', authenticate, validate(updateRentalSchema), async (req, res, next) => {
try {
const existing = await prisma.rentalListing.findUnique({ where: { id: req.params.id } });
if (!existing) throw new AppError(404, 'Rental not found');
if (existing.landlordId !== req.userId) throw new AppError(403, 'Not authorized');
if (existing.status === 'DELETED') throw new AppError(400, 'Cannot edit a deleted rental');
const rental = await prisma.rentalListing.update({
where: { id: req.params.id },
data: req.body,
select: rentalSelect,
});
res.json(rental);
} catch (error) {
next(error);
}
});
// --- Delete rental (soft) ---
router.delete('/:id', authenticate, async (req, res, next) => {
try {
const existing = await prisma.rentalListing.findUnique({ where: { id: req.params.id } });
if (!existing) throw new AppError(404, 'Rental not found');
if (existing.landlordId !== req.userId) throw new AppError(403, 'Not authorized');
await prisma.rentalListing.update({ where: { id: req.params.id }, data: { status: 'DELETED' } });
res.json({ message: 'Rental deleted' });
} catch (error) {
next(error);
}
});
// --- Activate / submit for review ---
router.post('/:id/activate', authenticate, async (req, res, next) => {
try {
const existing = await prisma.rentalListing.findUnique({ where: { id: req.params.id } });
if (!existing) throw new AppError(404, 'Rental not found');
if (existing.landlordId !== req.userId) throw new AppError(403, 'Not authorized');
if (existing.status !== 'DRAFT' && existing.status !== 'PAUSED' && existing.status !== 'PENDING_REVIEW') {
throw new AppError(400, 'Rental cannot be activated from current status');
}
const config = await getPlatformConfig();
const textToCheck = `${existing.title} ${existing.description}`;
const blockedWord = checkBlockedKeywords(textToCheck, config.blockedKeywords);
const newStatus = (!config.rentalAutoApprove || blockedWord) ? 'PENDING_REVIEW' : 'ACTIVE';
const rental = await prisma.rentalListing.update({
where: { id: req.params.id },
data: { status: newStatus },
select: rentalSelect,
});
res.json(rental);
} catch (error) {
next(error);
}
});
// --- Pause rental ---
router.post('/:id/pause', authenticate, async (req, res, next) => {
try {
const existing = await prisma.rentalListing.findUnique({ where: { id: req.params.id } });
if (!existing) throw new AppError(404, 'Rental not found');
if (existing.landlordId !== req.userId) throw new AppError(403, 'Not authorized');
if (existing.status !== 'ACTIVE') throw new AppError(400, 'Can only pause active rentals');
const rental = await prisma.rentalListing.update({
where: { id: req.params.id },
data: { status: 'PAUSED' },
select: rentalSelect,
});
res.json(rental);
} catch (error) {
next(error);
}
});
// --- Upload images ---
router.post('/:id/images', authenticate, upload.array('images', 10), async (req, res, next) => {
try {
const existing = await prisma.rentalListing.findUnique({ where: { id: req.params.id } });
if (!existing) throw new AppError(404, 'Rental not found');
if (existing.landlordId !== req.userId) throw new AppError(403, 'Not authorized');
const files = req.files as Express.Multer.File[];
if (!files?.length) throw new AppError(400, 'No files uploaded');
const existingImages = await prisma.rentalImage.count({ where: { rentalListingId: req.params.id } });
const images = await Promise.all(
files.map((file, i) =>
prisma.rentalImage.create({
data: {
url: `/uploads/${file.filename}`,
order: existingImages + i,
rentalListingId: req.params.id!,
},
})
)
);
res.status(201).json(images);
} catch (error) {
next(error);
}
});
// --- Toggle favorite ---
router.post('/:id/favorite', authenticate, async (req, res, next) => {
try {
const rental = await prisma.rentalListing.findUnique({ where: { id: req.params.id } });
if (!rental) throw new AppError(404, 'Rental not found');
const existing = await prisma.rentalFavorite.findUnique({
where: { userId_rentalListingId: { userId: req.userId!, rentalListingId: req.params.id! } },
});
if (existing) {
await prisma.rentalFavorite.delete({ where: { id: existing.id } });
res.json({ isFavorited: false });
} else {
await prisma.rentalFavorite.create({ data: { userId: req.userId!, rentalListingId: req.params.id! } });
res.json({ isFavorited: true });
}
} catch (error) {
next(error);
}
});
// --- Availability ---
router.get('/:id/availability', async (req, res, next) => {
try {
const { start, end } = req.query;
const startDate = start ? new Date(start as string) : new Date();
const endDate = end ? new Date(end as string) : new Date(Date.now() + 90 * 24 * 60 * 60 * 1000);
const [blocks, bookings] = await Promise.all([
prisma.availabilityBlock.findMany({
where: {
rentalListingId: req.params.id,
OR: [
{ startDate: { lte: endDate }, endDate: { gte: startDate } },
],
},
orderBy: { startDate: 'asc' },
}),
prisma.booking.findMany({
where: {
rentalListingId: req.params.id,
status: { in: ['CONFIRMED', 'ACTIVE'] },
startDate: { lte: endDate },
endDate: { gte: startDate },
},
select: { id: true, startDate: true, endDate: true, status: true },
orderBy: { startDate: 'asc' },
}),
]);
res.json({ blocks, bookings });
} catch (error) {
next(error);
}
});
router.post('/:id/availability', authenticate, async (req, res, next) => {
try {
const existing = await prisma.rentalListing.findUnique({ where: { id: req.params.id } });
if (!existing) throw new AppError(404, 'Rental not found');
if (existing.landlordId !== req.userId) throw new AppError(403, 'Not authorized');
const { startDate, endDate, reason } = req.body;
if (!startDate || !endDate) throw new AppError(400, 'Start and end dates required');
const block = await prisma.availabilityBlock.create({
data: {
rentalListingId: req.params.id!,
startDate: new Date(startDate),
endDate: new Date(endDate),
isBlocked: true,
reason,
},
});
res.status(201).json(block);
} catch (error) {
next(error);
}
});
router.delete('/:id/availability/:blockId', authenticate, async (req, res, next) => {
try {
const existing = await prisma.rentalListing.findUnique({ where: { id: req.params.id } });
if (!existing) throw new AppError(404, 'Rental not found');
if (existing.landlordId !== req.userId) throw new AppError(403, 'Not authorized');
const block = await prisma.availabilityBlock.findUnique({ where: { id: req.params.blockId } });
if (!block || block.rentalListingId !== req.params.id) throw new AppError(404, 'Block not found');
await prisma.availabilityBlock.delete({ where: { id: req.params.blockId } });
res.json({ message: 'Block deleted' });
} catch (error) {
next(error);
}
});
export default router;

View File

@@ -0,0 +1,45 @@
import { Router } from 'express';
import { prisma } from '../config/database.js';
import { authenticate } from '../middleware/auth.js';
import { validate } from '../middleware/validate.js';
import { createReportSchema } from '../validators/report.js';
const router = Router();
// POST /api/reports - Create report
router.post('/', authenticate, validate(createReportSchema), async (req, res, next) => {
try {
const { targetType, targetId, reason, description } = req.body;
// Verify target exists
if (targetType === 'LISTING') {
const listing = await prisma.listing.findUnique({ where: { id: targetId } });
if (!listing) {
res.status(404).json({ message: 'Listing not found' });
return;
}
} else {
const user = await prisma.user.findUnique({ where: { id: targetId } });
if (!user) {
res.status(404).json({ message: 'User not found' });
return;
}
}
const report = await prisma.report.create({
data: {
reporterId: req.userId!,
targetType,
targetId,
reason,
description,
},
});
res.status(201).json(report);
} catch (error) {
next(error);
}
});
export default router;

View File

@@ -0,0 +1,76 @@
import { Router } from 'express';
import { prisma } from '../config/database.js';
import { authenticate } from '../middleware/auth.js';
const router = Router();
// GET /api/subscriptions/current
router.get('/current', authenticate, async (req, res, next) => {
try {
const subscription = await prisma.subscription.findUnique({
where: { userId: req.userId! },
});
res.json(subscription || { tier: 'BASIC', status: 'ACTIVE', userId: req.userId });
} catch (error) {
next(error);
}
});
// POST /api/subscriptions/create
router.post('/create', authenticate, async (req, res, next) => {
try {
const { tier } = req.body;
if (!tier || !['PRO', 'BUSINESS'].includes(tier)) {
res.status(400).json({ message: 'Invalid tier' });
return;
}
const existing = await prisma.subscription.findUnique({ where: { userId: req.userId! } });
if (existing && existing.status === 'ACTIVE' && existing.tier !== 'BASIC') {
res.status(400).json({ message: 'Already have an active subscription' });
return;
}
const subscription = await prisma.subscription.upsert({
where: { userId: req.userId! },
update: {
tier,
status: 'ACTIVE',
currentPeriodEnd: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
},
create: {
userId: req.userId!,
tier,
status: 'ACTIVE',
currentPeriodEnd: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
},
});
res.json(subscription);
} catch (error) {
next(error);
}
});
// POST /api/subscriptions/cancel
router.post('/cancel', authenticate, async (req, res, next) => {
try {
const subscription = await prisma.subscription.findUnique({ where: { userId: req.userId! } });
if (!subscription) {
res.status(404).json({ message: 'No subscription found' });
return;
}
const updated = await prisma.subscription.update({
where: { userId: req.userId! },
data: { status: 'CANCELLED' },
});
res.json(updated);
} catch (error) {
next(error);
}
});
export default router;

View File

@@ -0,0 +1,82 @@
import { prisma } from '../config/database.js';
interface PlatformConfigCache {
data: {
blockedKeywords: string[];
autoApprove: boolean;
listingFee: number;
commissionPercent: number;
maxImagesPerListing: number;
maxListingsFreeTier: number;
promotionDayPrice: number;
rentalCommissionPercent: number;
rentalAutoApprove: boolean;
maxRentalImagesPerListing: number;
bookingExpiryHours: number;
rentalPromotionDayPrice: number;
} | null;
timestamp: number;
}
const cache: PlatformConfigCache = { data: null, timestamp: 0 };
const CACHE_TTL = 60 * 1000; // 60 seconds
export async function getPlatformConfig() {
const now = Date.now();
if (cache.data && now - cache.timestamp < CACHE_TTL) {
return cache.data;
}
const config = await prisma.platformConfig.findFirst();
if (!config) {
const defaults = {
blockedKeywords: [] as string[],
autoApprove: true,
listingFee: 5.0,
commissionPercent: 5.0,
maxImagesPerListing: 6,
maxListingsFreeTier: 5,
promotionDayPrice: 2.99,
rentalCommissionPercent: 10.0,
rentalAutoApprove: false,
maxRentalImagesPerListing: 10,
bookingExpiryHours: 48,
rentalPromotionDayPrice: 3.99,
};
cache.data = defaults;
cache.timestamp = now;
return defaults;
}
cache.data = {
blockedKeywords: config.blockedKeywords,
autoApprove: config.autoApprove,
listingFee: config.listingFee,
commissionPercent: config.commissionPercent,
maxImagesPerListing: config.maxImagesPerListing,
maxListingsFreeTier: config.maxListingsFreeTier,
promotionDayPrice: config.promotionDayPrice,
rentalCommissionPercent: config.rentalCommissionPercent,
rentalAutoApprove: config.rentalAutoApprove,
maxRentalImagesPerListing: config.maxRentalImagesPerListing,
bookingExpiryHours: config.bookingExpiryHours,
rentalPromotionDayPrice: config.rentalPromotionDayPrice,
};
cache.timestamp = now;
return cache.data;
}
export function invalidateConfigCache() {
cache.data = null;
cache.timestamp = 0;
}
export function checkBlockedKeywords(text: string, blockedKeywords: string[]): string | null {
const lowerText = text.toLowerCase();
for (const keyword of blockedKeywords) {
if (keyword && lowerText.includes(keyword.toLowerCase())) {
return keyword;
}
}
return null;
}

134
server/src/utils/rental.ts Normal file
View File

@@ -0,0 +1,134 @@
import { prisma } from '../config/database.js';
import type { Booking, CancellationPolicy, BookingStatus } from '@prisma/client';
/**
* Checks whether a rental listing is available for the given date range.
* Returns true if no blocked AvailabilityBlock and no CONFIRMED/ACTIVE booking overlaps.
*/
export async function checkAvailability(
rentalListingId: string,
startDate: Date,
endDate: Date
): Promise<boolean> {
const [blockedCount, bookingCount] = await Promise.all([
prisma.availabilityBlock.count({
where: {
rentalListingId,
isBlocked: true,
startDate: { lt: endDate },
endDate: { gt: startDate },
},
}),
prisma.booking.count({
where: {
rentalListingId,
status: { in: ['CONFIRMED', 'ACTIVE'] },
startDate: { lt: endDate },
endDate: { gt: startDate },
},
}),
]);
return blockedCount === 0 && bookingCount === 0;
}
/**
* Calculates cancellation refund amounts based on the listing's cancellation policy.
*
* - FLEXIBLE: >7 days = 100%, 1-7 days = 100%, <24h = 50%. Deposit always returned.
* - MODERATE: >7 days = 100%, 1-7 days = 50%, <24h = 0%. Deposit always returned.
* - STRICT: >7 days = 50%, 1-7 days = 0%, <24h = 0%. Deposit always returned.
*/
export function calculateCancellationRefund(
policy: CancellationPolicy,
startDate: Date,
subtotal: number,
depositAmount: number
): { refundAmount: number; depositRefund: number } {
const now = new Date();
const msUntilStart = startDate.getTime() - now.getTime();
const hoursUntilStart = msUntilStart / (1000 * 60 * 60);
const daysUntilStart = hoursUntilStart / 24;
let refundPercent: number;
switch (policy) {
case 'FLEXIBLE':
if (hoursUntilStart < 24) {
refundPercent = 50;
} else {
refundPercent = 100;
}
break;
case 'MODERATE':
if (hoursUntilStart < 24) {
refundPercent = 0;
} else if (daysUntilStart <= 7) {
refundPercent = 50;
} else {
refundPercent = 100;
}
break;
case 'STRICT':
if (daysUntilStart > 7) {
refundPercent = 50;
} else {
refundPercent = 0;
}
break;
}
const refundAmount = Math.round((subtotal * refundPercent) / 100 * 100) / 100;
const depositRefund = depositAmount;
return { refundAmount, depositRefund };
}
/**
* Calculates the price breakdown for a booking.
*/
export function calculateBookingPrice(
pricePerPeriod: number,
totalPeriods: number,
commissionRate: number,
depositAmount: number
): { subtotal: number; commissionAmount: number; totalAmount: number } {
const subtotal = Math.round(pricePerPeriod * totalPeriods * 100) / 100;
const commissionAmount = Math.round(subtotal * (commissionRate / 100) * 100) / 100;
const totalAmount = Math.round((subtotal + commissionAmount + depositAmount) * 100) / 100;
return { subtotal, commissionAmount, totalAmount };
}
/**
* Performs lazy status transitions on a booking:
*
* - PENDING + expiresAt < now -> EXPIRED
* - CONFIRMED + startDate <= now -> ACTIVE
* - ACTIVE + endDate <= now -> COMPLETED
*
* Returns the updated booking if a transition occurred, or the original booking otherwise.
*/
export async function autoTransitionBooking(booking: Booking): Promise<Booking> {
const now = new Date();
let newStatus: BookingStatus | null = null;
if (booking.status === 'PENDING' && booking.expiresAt && booking.expiresAt < now) {
newStatus = 'EXPIRED';
} else if (booking.status === 'CONFIRMED' && booking.startDate <= now) {
newStatus = 'ACTIVE';
} else if (booking.status === 'ACTIVE' && booking.endDate <= now) {
newStatus = 'COMPLETED';
}
if (newStatus) {
return prisma.booking.update({
where: { id: booking.id },
data: { status: newStatus },
});
}
return booking;
}

View File

@@ -0,0 +1,35 @@
import { z } from 'zod';
export const banUserSchema = z.object({
reason: z.string().min(1, 'Ban reason is required').max(500),
});
export const changeRoleSchema = z.object({
role: z.enum(['USER', 'MODERATOR', 'ADMIN', 'SUPER_ADMIN']),
});
export const rejectListingSchema = z.object({
reason: z.string().min(1, 'Rejection reason is required').max(500),
});
export const resolveReportSchema = z.object({
status: z.enum(['RESOLVED', 'DISMISSED']),
resolution: z.string().max(500).optional(),
});
export const updateSettingsSchema = z.object({
listingFee: z.number().min(0).optional(),
commissionPercent: z.number().min(0).max(100).optional(),
autoApprove: z.boolean().optional(),
maxImagesPerListing: z.number().int().min(1).max(20).optional(),
maxListingsFreeTier: z.number().int().min(1).optional(),
proPrice: z.number().min(0).optional(),
businessPrice: z.number().min(0).optional(),
promotionDayPrice: z.number().min(0).optional(),
blockedKeywords: z.array(z.string()).optional(),
rentalCommissionPercent: z.number().min(0).max(100).optional(),
rentalAutoApprove: z.boolean().optional(),
maxRentalImagesPerListing: z.number().int().min(1).max(30).optional(),
bookingExpiryHours: z.number().int().min(1).max(720).optional(),
rentalPromotionDayPrice: z.number().min(0).optional(),
});

View File

@@ -0,0 +1,17 @@
import { z } from 'zod';
export const createBookingSchema = z.object({
rentalListingId: z.string().min(1),
periodType: z.enum(['DAILY', 'MONTHLY']),
startDate: z.string().datetime(),
endDate: z.string().datetime(),
message: z.string().max(1000).optional(),
});
export const rejectBookingSchema = z.object({
reason: z.string().min(1).max(500),
});
export const cancelBookingSchema = z.object({
reason: z.string().min(1).max(500),
});

View File

@@ -0,0 +1,11 @@
import { z } from 'zod';
export const createReviewSchema = z.object({
bookingId: z.string().min(1),
rating: z.number().int().min(1).max(5),
comment: z.string().max(2000).optional(),
});
export const respondReviewSchema = z.object({
response: z.string().min(1).max(1000),
});

View File

@@ -0,0 +1,39 @@
import { z } from 'zod';
export const createRentalSchema = z.object({
title: z.string().min(3).max(100),
description: z.string().min(10).max(5000),
category: z.enum(['APARTMENT', 'HOUSE', 'CAR', 'MOTORCYCLE', 'BICYCLE', 'EBIKE']),
location: z.string().min(1).max(200),
dailyPrice: z.number().positive().optional(),
monthlyPrice: z.number().positive().optional(),
depositAmount: z.number().min(0).optional(),
details: z.record(z.any()).optional(),
amenities: z.array(z.string()).optional(),
rules: z.array(z.string()).optional(),
cancellationPolicy: z.enum(['FLEXIBLE', 'MODERATE', 'STRICT']).optional(),
minDays: z.number().int().positive().optional(),
maxDays: z.number().int().positive().optional(),
minMonths: z.number().int().positive().optional(),
maxMonths: z.number().int().positive().optional(),
}).refine(data => data.dailyPrice || data.monthlyPrice, {
message: 'At least one price (daily or monthly) is required',
});
export const updateRentalSchema = z.object({
title: z.string().min(3).max(100).optional(),
description: z.string().min(10).max(5000).optional(),
category: z.enum(['APARTMENT', 'HOUSE', 'CAR', 'MOTORCYCLE', 'BICYCLE', 'EBIKE']).optional(),
location: z.string().min(1).max(200).optional(),
dailyPrice: z.number().positive().nullable().optional(),
monthlyPrice: z.number().positive().nullable().optional(),
depositAmount: z.number().min(0).nullable().optional(),
details: z.record(z.any()).optional(),
amenities: z.array(z.string()).optional(),
rules: z.array(z.string()).optional(),
cancellationPolicy: z.enum(['FLEXIBLE', 'MODERATE', 'STRICT']).optional(),
minDays: z.number().int().positive().nullable().optional(),
maxDays: z.number().int().positive().nullable().optional(),
minMonths: z.number().int().positive().nullable().optional(),
maxMonths: z.number().int().positive().nullable().optional(),
});

View File

@@ -0,0 +1,8 @@
import { z } from 'zod';
export const createReportSchema = z.object({
targetType: z.enum(['LISTING', 'USER']),
targetId: z.string().min(1),
reason: z.enum(['SPAM', 'INAPPROPRIATE', 'SCAM', 'COUNTERFEIT', 'PROHIBITED_ITEM', 'HARASSMENT', 'OTHER']),
description: z.string().max(1000).optional(),
});