diff --git a/prisma/migrations/20260222084710_location/migration.sql b/prisma/migrations/20260222084710_location/migration.sql new file mode 100644 index 00000000..b72b68a1 --- /dev/null +++ b/prisma/migrations/20260222084710_location/migration.sql @@ -0,0 +1,326 @@ +-- CreateEnum +CREATE TYPE "UserRole" AS ENUM ('ADMIN', 'AGENT', 'SELLER', 'BUYER', 'VIEWER', 'USER', 'VERIFIED_USER'); + +-- CreateEnum +CREATE TYPE "PropertyStatus" AS ENUM ('DRAFT', 'PENDING', 'APPROVED', 'LISTED', 'SOLD', 'REMOVED'); + +-- CreateEnum +CREATE TYPE "TransactionStatus" AS ENUM ('PENDING', 'PROCESSING', 'COMPLETED', 'FAILED', 'CANCELLED'); + +-- CreateEnum +CREATE TYPE "TransactionType" AS ENUM ('PURCHASE', 'TRANSFER', 'ESCROW', 'REFUND'); + +-- CreateEnum +CREATE TYPE "DocumentType" AS ENUM ('TITLE_DEED', 'OWNERSHIP_CERTIFICATE', 'INSPECTION_REPORT', 'APPRAISAL', 'INSURANCE', 'TAX_DOCUMENT', 'CONTRACT', 'IDENTITY', 'OTHER'); + +-- CreateEnum +CREATE TYPE "DocumentStatus" AS ENUM ('PENDING', 'VERIFIED', 'REJECTED', 'EXPIRED'); + +-- CreateTable +CREATE TABLE "users" ( + "id" TEXT NOT NULL, + "email" TEXT NOT NULL, + "wallet_address" TEXT, + "role" "UserRole" NOT NULL DEFAULT 'USER', + "role_id" TEXT, + "password" TEXT, + "is_verified" BOOLEAN NOT NULL DEFAULT false, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "users_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "properties" ( + "id" TEXT NOT NULL, + "title" TEXT NOT NULL, + "description" TEXT, + "location" TEXT NOT NULL, + "price" DECIMAL(65,30) NOT NULL, + "status" "PropertyStatus" NOT NULL DEFAULT 'DRAFT', + "owner_id" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + "estimated_value" DECIMAL(65,30), + "valuation_date" TIMESTAMP(3), + "valuation_confidence" DOUBLE PRECISION, + "valuation_source" TEXT, + "last_valuation_id" TEXT, + "bedrooms" INTEGER, + "bathrooms" INTEGER, + "square_footage" DECIMAL(65,30), + "year_built" INTEGER, + "property_type" TEXT, + "lot_size" DECIMAL(65,30), + "latitude" DOUBLE PRECISION, + "longitude" DOUBLE PRECISION, + + CONSTRAINT "properties_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "property_valuations" ( + "id" TEXT NOT NULL, + "property_id" TEXT NOT NULL, + "estimated_value" DECIMAL(65,30) NOT NULL, + "confidence_score" DOUBLE PRECISION NOT NULL, + "valuation_date" TIMESTAMP(3) NOT NULL, + "source" TEXT NOT NULL, + "market_trend" TEXT, + "features_used" JSONB, + "raw_data" JSONB, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "property_valuations_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "transactions" ( + "id" TEXT NOT NULL, + "from_address" TEXT NOT NULL, + "to_address" TEXT NOT NULL, + "amount" DECIMAL(65,30) NOT NULL, + "tx_hash" TEXT, + "status" "TransactionStatus" NOT NULL DEFAULT 'PENDING', + "type" "TransactionType" NOT NULL, + "property_id" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "transactions_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "roles" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "description" TEXT, + "level" INTEGER NOT NULL DEFAULT 0, + "is_system" BOOLEAN NOT NULL DEFAULT false, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "roles_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "permissions" ( + "id" TEXT NOT NULL, + "resource" TEXT NOT NULL, + "action" TEXT NOT NULL, + "description" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "permissions_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "role_permissions" ( + "id" TEXT NOT NULL, + "role_id" TEXT NOT NULL, + "permission_id" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "role_permissions_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "role_change_logs" ( + "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "role_id" TEXT NOT NULL, + "old_role_id" TEXT, + "changed_by" TEXT, + "reason" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "role_change_logs_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "audit_logs" ( + "id" TEXT NOT NULL, + "table_name" TEXT NOT NULL, + "operation" TEXT NOT NULL, + "old_data" JSONB, + "new_data" JSONB, + "user_id" TEXT, + "timestamp" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "audit_logs_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "system_logs" ( + "id" TEXT NOT NULL, + "log_level" TEXT NOT NULL, + "message" TEXT NOT NULL, + "context" TEXT, + "timestamp" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "system_logs_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "documents" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "type" "DocumentType" NOT NULL, + "status" "DocumentStatus" NOT NULL DEFAULT 'PENDING', + "file_url" TEXT NOT NULL, + "file_hash" TEXT, + "mime_type" TEXT, + "file_size" INTEGER, + "description" TEXT, + "property_id" TEXT, + "transaction_id" TEXT, + "uploaded_by_id" TEXT NOT NULL, + "verified_at" TIMESTAMP(3), + "expires_at" TIMESTAMP(3), + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "documents_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "api_keys" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "key" TEXT NOT NULL, + "key_prefix" TEXT NOT NULL, + "scopes" TEXT[], + "request_count" BIGINT NOT NULL DEFAULT 0, + "last_used_at" TIMESTAMP(3), + "is_active" BOOLEAN NOT NULL DEFAULT true, + "rate_limit" INTEGER, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "api_keys_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "users_email_key" ON "users"("email"); + +-- CreateIndex +CREATE UNIQUE INDEX "users_wallet_address_key" ON "users"("wallet_address"); + +-- CreateIndex +CREATE INDEX "users_email_idx" ON "users"("email"); + +-- CreateIndex +CREATE INDEX "users_wallet_address_idx" ON "users"("wallet_address"); + +-- CreateIndex +CREATE INDEX "users_role_idx" ON "users"("role"); + +-- CreateIndex +CREATE INDEX "users_created_at_idx" ON "users"("created_at"); + +-- CreateIndex +CREATE INDEX "properties_latitude_longitude_idx" ON "properties"("latitude", "longitude"); + +-- CreateIndex +CREATE INDEX "properties_owner_id_idx" ON "properties"("owner_id"); + +-- CreateIndex +CREATE INDEX "properties_status_idx" ON "properties"("status"); + +-- CreateIndex +CREATE INDEX "properties_created_at_idx" ON "properties"("created_at"); + +-- CreateIndex +CREATE INDEX "properties_location_idx" ON "properties"("location"); + +-- CreateIndex +CREATE INDEX "transactions_from_address_idx" ON "transactions"("from_address"); + +-- CreateIndex +CREATE INDEX "transactions_to_address_idx" ON "transactions"("to_address"); + +-- CreateIndex +CREATE INDEX "transactions_status_idx" ON "transactions"("status"); + +-- CreateIndex +CREATE INDEX "transactions_created_at_idx" ON "transactions"("created_at"); + +-- CreateIndex +CREATE INDEX "transactions_property_id_idx" ON "transactions"("property_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "roles_name_key" ON "roles"("name"); + +-- CreateIndex +CREATE UNIQUE INDEX "permissions_resource_action_key" ON "permissions"("resource", "action"); + +-- CreateIndex +CREATE UNIQUE INDEX "role_permissions_role_id_permission_id_key" ON "role_permissions"("role_id", "permission_id"); + +-- CreateIndex +CREATE INDEX "documents_property_id_idx" ON "documents"("property_id"); + +-- CreateIndex +CREATE INDEX "documents_transaction_id_idx" ON "documents"("transaction_id"); + +-- CreateIndex +CREATE INDEX "documents_uploaded_by_id_idx" ON "documents"("uploaded_by_id"); + +-- CreateIndex +CREATE INDEX "documents_type_idx" ON "documents"("type"); + +-- CreateIndex +CREATE INDEX "documents_status_idx" ON "documents"("status"); + +-- CreateIndex +CREATE INDEX "documents_created_at_idx" ON "documents"("created_at"); + +-- CreateIndex +CREATE UNIQUE INDEX "api_keys_key_key" ON "api_keys"("key"); + +-- CreateIndex +CREATE INDEX "api_keys_key_prefix_idx" ON "api_keys"("key_prefix"); + +-- CreateIndex +CREATE INDEX "api_keys_is_active_idx" ON "api_keys"("is_active"); + +-- CreateIndex +CREATE INDEX "api_keys_created_at_idx" ON "api_keys"("created_at"); + +-- AddForeignKey +ALTER TABLE "users" ADD CONSTRAINT "users_role_id_fkey" FOREIGN KEY ("role_id") REFERENCES "roles"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "properties" ADD CONSTRAINT "properties_owner_id_fkey" FOREIGN KEY ("owner_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "property_valuations" ADD CONSTRAINT "property_valuations_property_id_fkey" FOREIGN KEY ("property_id") REFERENCES "properties"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "transactions" ADD CONSTRAINT "transactions_property_id_fkey" FOREIGN KEY ("property_id") REFERENCES "properties"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "transactions" ADD CONSTRAINT "transactions_to_address_fkey" FOREIGN KEY ("to_address") REFERENCES "users"("wallet_address") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "role_permissions" ADD CONSTRAINT "role_permissions_role_id_fkey" FOREIGN KEY ("role_id") REFERENCES "roles"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "role_permissions" ADD CONSTRAINT "role_permissions_permission_id_fkey" FOREIGN KEY ("permission_id") REFERENCES "permissions"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "role_change_logs" ADD CONSTRAINT "role_change_logs_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "role_change_logs" ADD CONSTRAINT "role_change_logs_role_id_fkey" FOREIGN KEY ("role_id") REFERENCES "roles"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "documents" ADD CONSTRAINT "documents_property_id_fkey" FOREIGN KEY ("property_id") REFERENCES "properties"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "documents" ADD CONSTRAINT "documents_transaction_id_fkey" FOREIGN KEY ("transaction_id") REFERENCES "transactions"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "documents" ADD CONSTRAINT "documents_uploaded_by_id_fkey" FOREIGN KEY ("uploaded_by_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/20260222091519_add_search_logs/migration.sql b/prisma/migrations/20260222091519_add_search_logs/migration.sql new file mode 100644 index 00000000..8cd15503 --- /dev/null +++ b/prisma/migrations/20260222091519_add_search_logs/migration.sql @@ -0,0 +1,2 @@ +-- AlterEnum +ALTER TYPE "PropertyStatus" ADD VALUE 'PUBLISHED'; diff --git a/prisma/migrations/20260324073730_add_password_history/migration.sql b/prisma/migrations/20260324073730_add_password_history/migration.sql new file mode 100644 index 00000000..4ab617ca --- /dev/null +++ b/prisma/migrations/20260324073730_add_password_history/migration.sql @@ -0,0 +1,134 @@ +/* + Warnings: + + - Added the required column `buyerId` to the `transactions` table without a default value. This is not possible if the table is not empty. + - Added the required column `currency` to the `transactions` table without a default value. This is not possible if the table is not empty. + - Added the required column `sellerId` to the `transactions` table without a default value. This is not possible if the table is not empty. + +*/ +-- 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 "TransactionStatus" ADD VALUE 'ESCROW_FUNDED'; +ALTER TYPE "TransactionStatus" ADD VALUE 'BLOCKCHAIN_SUBMITTED'; +ALTER TYPE "TransactionStatus" ADD VALUE 'CONFIRMING'; +ALTER TYPE "TransactionStatus" ADD VALUE 'CONFIRMED'; +ALTER TYPE "TransactionStatus" ADD VALUE 'DISPUTED'; +ALTER TYPE "TransactionStatus" ADD VALUE 'REFUNDED'; + +-- AlterTable +ALTER TABLE "transactions" ADD COLUMN "blockNumber" INTEGER, +ADD COLUMN "blockchainHash" TEXT, +ADD COLUMN "buyerId" TEXT NOT NULL, +ADD COLUMN "confirmations" INTEGER NOT NULL DEFAULT 0, +ADD COLUMN "currency" TEXT NOT NULL, +ADD COLUMN "disputeReason" TEXT, +ADD COLUMN "escrowWallet" TEXT, +ADD COLUMN "gasFee" DECIMAL(65,30), +ADD COLUMN "platformFee" DECIMAL(65,30), +ADD COLUMN "sellerId" TEXT NOT NULL; + +-- AlterTable +ALTER TABLE "users" ADD COLUMN "avatar_url" TEXT, +ADD COLUMN "bio" TEXT, +ADD COLUMN "export_requested_at" TIMESTAMP(3), +ADD COLUMN "location" TEXT, +ADD COLUMN "preferences" JSONB, +ADD COLUMN "privacy_settings" JSONB; + +-- CreateTable +CREATE TABLE "user_activities" ( + "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "action" TEXT NOT NULL, + "metadata" JSONB, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "user_activities_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "user_relationships" ( + "id" TEXT NOT NULL, + "follower_id" TEXT NOT NULL, + "following_id" TEXT NOT NULL, + "status" TEXT NOT NULL DEFAULT 'active', + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "user_relationships_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "password_history" ( + "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "password_hash" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "password_history_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "user_activities_user_id_idx" ON "user_activities"("user_id"); + +-- CreateIndex +CREATE INDEX "user_activities_action_idx" ON "user_activities"("action"); + +-- CreateIndex +CREATE INDEX "user_activities_created_at_idx" ON "user_activities"("created_at"); + +-- CreateIndex +CREATE INDEX "user_relationships_follower_id_idx" ON "user_relationships"("follower_id"); + +-- CreateIndex +CREATE INDEX "user_relationships_following_id_idx" ON "user_relationships"("following_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "user_relationships_follower_id_following_id_key" ON "user_relationships"("follower_id", "following_id"); + +-- CreateIndex +CREATE INDEX "password_history_user_id_idx" ON "password_history"("user_id"); + +-- CreateIndex +CREATE INDEX "password_history_created_at_idx" ON "password_history"("created_at"); + +-- CreateIndex +CREATE INDEX "properties_price_idx" ON "properties"("price"); + +-- CreateIndex +CREATE INDEX "property_valuations_property_id_idx" ON "property_valuations"("property_id"); + +-- CreateIndex +CREATE INDEX "property_valuations_valuation_date_idx" ON "property_valuations"("valuation_date"); + +-- CreateIndex +CREATE INDEX "transactions_buyerId_idx" ON "transactions"("buyerId"); + +-- CreateIndex +CREATE INDEX "transactions_sellerId_idx" ON "transactions"("sellerId"); + +-- CreateIndex +CREATE INDEX "transactions_tx_hash_idx" ON "transactions"("tx_hash"); + +-- CreateIndex +CREATE INDEX "users_is_verified_idx" ON "users"("is_verified"); + +-- CreateIndex +CREATE INDEX "users_location_idx" ON "users"("location"); + +-- AddForeignKey +ALTER TABLE "user_activities" ADD CONSTRAINT "user_activities_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "user_relationships" ADD CONSTRAINT "user_relationships_follower_id_fkey" FOREIGN KEY ("follower_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "user_relationships" ADD CONSTRAINT "user_relationships_following_id_fkey" FOREIGN KEY ("following_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "password_history" ADD CONSTRAINT "password_history_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/20260324084550_add_api_key_rotation_analytics/migration.sql b/prisma/migrations/20260324084550_add_api_key_rotation_analytics/migration.sql new file mode 100644 index 00000000..61cd16e6 --- /dev/null +++ b/prisma/migrations/20260324084550_add_api_key_rotation_analytics/migration.sql @@ -0,0 +1,34 @@ +-- AlterTable +ALTER TABLE "api_keys" ADD COLUMN "key_version" INTEGER NOT NULL DEFAULT 1, +ADD COLUMN "last_rotated_at" TIMESTAMP(3), +ADD COLUMN "rotation_due_at" TIMESTAMP(3); + +-- CreateTable +CREATE TABLE "api_key_usage_logs" ( + "id" TEXT NOT NULL, + "api_key_id" TEXT NOT NULL, + "endpoint" TEXT NOT NULL, + "method" TEXT NOT NULL, + "status_code" INTEGER NOT NULL, + "response_time" INTEGER NOT NULL, + "ip_address" TEXT, + "user_agent" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "api_key_usage_logs_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "api_key_usage_logs_api_key_id_idx" ON "api_key_usage_logs"("api_key_id"); + +-- CreateIndex +CREATE INDEX "api_key_usage_logs_endpoint_idx" ON "api_key_usage_logs"("endpoint"); + +-- CreateIndex +CREATE INDEX "api_key_usage_logs_created_at_idx" ON "api_key_usage_logs"("created_at"); + +-- CreateIndex +CREATE INDEX "api_keys_rotation_due_at_idx" ON "api_keys"("rotation_due_at"); + +-- AddForeignKey +ALTER TABLE "api_key_usage_logs" ADD CONSTRAINT "api_key_usage_logs_api_key_id_fkey" FOREIGN KEY ("api_key_id") REFERENCES "api_keys"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/20260527000000_add_property_favorites/migration.sql b/prisma/migrations/20260527000000_add_property_favorites/migration.sql new file mode 100644 index 00000000..1ebf80b7 --- /dev/null +++ b/prisma/migrations/20260527000000_add_property_favorites/migration.sql @@ -0,0 +1,30 @@ +-- Migration: Add property_favorites table (TASK 1: Property Favorites) +-- Allows users to save/bookmark properties. + +CREATE TABLE "property_favorites" ( + "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "property_id" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "property_favorites_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "property_favorites_user_id_property_id_key" + ON "property_favorites" ("user_id", "property_id"); + +CREATE INDEX "property_favorites_user_id_created_at_idx" + ON "property_favorites" ("user_id", "created_at"); + +CREATE INDEX "property_favorites_property_id_idx" + ON "property_favorites" ("property_id"); + +ALTER TABLE "property_favorites" + ADD CONSTRAINT "property_favorites_user_id_fkey" + FOREIGN KEY ("user_id") REFERENCES "users" ("id") + ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "property_favorites" + ADD CONSTRAINT "property_favorites_property_id_fkey" + FOREIGN KEY ("property_id") REFERENCES "properties" ("id") + ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/20260527010000_add_property_views/migration.sql b/prisma/migrations/20260527010000_add_property_views/migration.sql new file mode 100644 index 00000000..00a0c855 --- /dev/null +++ b/prisma/migrations/20260527010000_add_property_views/migration.sql @@ -0,0 +1,40 @@ +-- Migration: Add property views tracking (TASK 2) +-- Adds raw view events table and a denormalized view_count counter on properties. + +ALTER TABLE "properties" + ADD COLUMN IF NOT EXISTS "view_count" INTEGER NOT NULL DEFAULT 0; + +CREATE INDEX IF NOT EXISTS "properties_view_count_idx" + ON "properties" ("view_count"); + +CREATE TABLE "property_views" ( + "id" TEXT NOT NULL, + "property_id" TEXT NOT NULL, + "user_id" TEXT, + "ip_address" TEXT, + "user_agent" TEXT, + "referrer" TEXT, + "session_id" TEXT, + "viewed_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "property_views_pkey" PRIMARY KEY ("id") +); + +CREATE INDEX "property_views_property_id_viewed_at_idx" + ON "property_views" ("property_id", "viewed_at"); + +CREATE INDEX "property_views_user_id_idx" + ON "property_views" ("user_id"); + +CREATE INDEX "property_views_ip_address_idx" + ON "property_views" ("ip_address"); + +ALTER TABLE "property_views" + ADD CONSTRAINT "property_views_property_id_fkey" + FOREIGN KEY ("property_id") REFERENCES "properties" ("id") + ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "property_views" + ADD CONSTRAINT "property_views_user_id_fkey" + FOREIGN KEY ("user_id") REFERENCES "users" ("id") + ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/prisma/migrations/20260527020000_add_neighborhoods/migration.sql b/prisma/migrations/20260527020000_add_neighborhoods/migration.sql new file mode 100644 index 00000000..68789148 --- /dev/null +++ b/prisma/migrations/20260527020000_add_neighborhoods/migration.sql @@ -0,0 +1,86 @@ +-- Migration: Add neighborhood data (TASK 4: school ratings, crime stats, amenities, walk score) + +CREATE TABLE "neighborhoods" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "city" TEXT NOT NULL, + "state" TEXT NOT NULL, + "country" TEXT NOT NULL DEFAULT 'USA', + "walk_score" INTEGER, + "transit_score" INTEGER, + "bike_score" INTEGER, + "crime_index" DOUBLE PRECISION, + "crime_rate" JSONB, + "school_rating" DOUBLE PRECISION, + "description" TEXT, + "metadata" JSONB, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "neighborhoods_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "neighborhoods_name_city_state_key" + ON "neighborhoods" ("name", "city", "state"); + +CREATE INDEX "neighborhoods_city_state_idx" + ON "neighborhoods" ("city", "state"); + +CREATE TABLE "neighborhood_schools" ( + "id" TEXT NOT NULL, + "neighborhood_id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "type" TEXT NOT NULL, + "rating" DOUBLE PRECISION NOT NULL, + "distance_miles" DOUBLE PRECISION, + "student_teacher_ratio" DOUBLE PRECISION, + "enrollment_count" INTEGER, + "url" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "neighborhood_schools_pkey" PRIMARY KEY ("id") +); + +CREATE INDEX "neighborhood_schools_neighborhood_id_idx" + ON "neighborhood_schools" ("neighborhood_id"); +CREATE INDEX "neighborhood_schools_type_idx" + ON "neighborhood_schools" ("type"); + +ALTER TABLE "neighborhood_schools" + ADD CONSTRAINT "neighborhood_schools_neighborhood_id_fkey" + FOREIGN KEY ("neighborhood_id") REFERENCES "neighborhoods" ("id") + ON DELETE CASCADE ON UPDATE CASCADE; + +CREATE TABLE "neighborhood_amenities" ( + "id" TEXT NOT NULL, + "neighborhood_id" TEXT NOT NULL, + "category" TEXT NOT NULL, + "name" TEXT NOT NULL, + "distance_miles" DOUBLE PRECISION, + "rating" DOUBLE PRECISION, + "address" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "neighborhood_amenities_pkey" PRIMARY KEY ("id") +); + +CREATE INDEX "neighborhood_amenities_neighborhood_id_idx" + ON "neighborhood_amenities" ("neighborhood_id"); +CREATE INDEX "neighborhood_amenities_category_idx" + ON "neighborhood_amenities" ("category"); + +ALTER TABLE "neighborhood_amenities" + ADD CONSTRAINT "neighborhood_amenities_neighborhood_id_fkey" + FOREIGN KEY ("neighborhood_id") REFERENCES "neighborhoods" ("id") + ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "properties" + ADD COLUMN IF NOT EXISTS "neighborhood_id" TEXT; + +CREATE INDEX IF NOT EXISTS "properties_neighborhood_id_idx" + ON "properties" ("neighborhood_id"); + +ALTER TABLE "properties" + ADD CONSTRAINT "properties_neighborhood_id_fkey" + FOREIGN KEY ("neighborhood_id") REFERENCES "neighborhoods" ("id") + ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/prisma/migrations/migration_lock.toml b/prisma/migrations/migration_lock.toml new file mode 100644 index 00000000..044d57cd --- /dev/null +++ b/prisma/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (e.g., Git) +provider = "postgresql" diff --git a/prisma/schema.prisma b/prisma/schema.prisma index b3aeae14..7bc7ac6a 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -225,6 +225,8 @@ model User { digestPreference DigestPreference? createdTaxStrategies TransactionTaxStrategy[] @relation("CreatedTransactionTaxStrategies") transactionHistory TransactionHistory[] + favorites PropertyFavorite[] + propertyViews PropertyView[] @@index([email]) @@index([role]) @@ -395,9 +397,11 @@ model Property { yearBuilt Int? @map("year_built") status PropertyStatus @default(DRAFT) ownerId String @map("owner_id") + neighborhoodId String? @map("neighborhood_id") latitude Float? longitude Float? features String[] // Array of features (pool, garage, etc.) + viewCount Int @default(0) @map("view_count") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") @@ -407,12 +411,17 @@ model Property { documents Document[] images PropertyImage[] fraudAlerts FraudAlert[] + favorites PropertyFavorite[] + views PropertyView[] + neighborhood Neighborhood? @relation(fields: [neighborhoodId], references: [id], onDelete: SetNull) @@index([ownerId]) @@index([status]) @@index([city, state]) @@index([price]) @@index([propertyType]) + @@index([viewCount]) + @@index([neighborhoodId]) @@map("properties") } diff --git a/src/app.module.ts b/src/app.module.ts index 1c510435..0aed21b5 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -27,6 +27,10 @@ import { TrackingModule } from './tracking/tracking.module'; import { NotificationsModule } from './notifications/notifications.module'; import { BlockchainModule } from './blockchain/blockchain.module'; import { TransactionsModule } from './transactions/transactions.module'; +import { FavoritesModule } from './favorites/favorites.module'; +import { PropertyViewsModule } from './property-views/property-views.module'; +import { PropertyComparisonModule } from './property-comparison/property-comparison.module'; +import { NeighborhoodsModule } from './neighborhoods/neighborhoods.module'; @Module({ imports: [ ConfigModule.forRoot({ @@ -64,6 +68,10 @@ import { TransactionsModule } from './transactions/transactions.module'; NotificationsModule, BlockchainModule, TransactionsModule, + FavoritesModule, + PropertyViewsModule, + PropertyComparisonModule, + NeighborhoodsModule, ], controllers: [AppController], diff --git a/src/favorites/dto/favorite.dto.ts b/src/favorites/dto/favorite.dto.ts new file mode 100644 index 00000000..01ee9e40 --- /dev/null +++ b/src/favorites/dto/favorite.dto.ts @@ -0,0 +1,17 @@ +import { IsInt, IsOptional, Max, Min } from 'class-validator'; +import { Type } from 'class-transformer'; + +export class ListFavoritesQueryDto { + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(0) + skip?: number; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(100) + take?: number; +} diff --git a/src/favorites/favorites.controller.ts b/src/favorites/favorites.controller.ts new file mode 100644 index 00000000..9edb2d7f --- /dev/null +++ b/src/favorites/favorites.controller.ts @@ -0,0 +1,97 @@ +import { + Controller, + Delete, + Get, + Param, + ParseUUIDPipe, + Post, + Query, + UseGuards, +} from '@nestjs/common'; +import { FavoritesService } from './favorites.service'; +import { ListFavoritesQueryDto } from './dto/favorite.dto'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { CurrentUser } from '../auth/decorators/current-user.decorator'; +import { AuthUserPayload } from '../auth/types/auth-user.type'; + +@Controller('favorites') +export class FavoritesController { + constructor(private readonly favoritesService: FavoritesService) {} + + /** + * Add a property to the current user's favorites. + */ + @UseGuards(JwtAuthGuard) + @Post(':propertyId') + add( + @Param('propertyId', new ParseUUIDPipe()) propertyId: string, + @CurrentUser() user: AuthUserPayload, + ) { + return this.favoritesService.addFavorite(user.sub, propertyId); + } + + /** + * Remove a property from the current user's favorites. + */ + @UseGuards(JwtAuthGuard) + @Delete(':propertyId') + remove( + @Param('propertyId', new ParseUUIDPipe()) propertyId: string, + @CurrentUser() user: AuthUserPayload, + ) { + return this.favoritesService.removeFavorite(user.sub, propertyId); + } + + /** + * List the current user's favorites (paginated). + */ + @UseGuards(JwtAuthGuard) + @Get() + list( + @CurrentUser() user: AuthUserPayload, + @Query() query: ListFavoritesQueryDto, + ) { + return this.favoritesService.listFavorites(user.sub, { + skip: query.skip, + take: query.take, + }); + } + + /** + * Total count of favorites for the current user. + */ + @UseGuards(JwtAuthGuard) + @Get('count') + async myCount(@CurrentUser() user: AuthUserPayload) { + const count = await this.favoritesService.getUserFavoriteCount(user.sub); + return { count }; + } + + /** + * Whether a property is currently favorited by the user. + */ + @UseGuards(JwtAuthGuard) + @Get(':propertyId/status') + async status( + @Param('propertyId', new ParseUUIDPipe()) propertyId: string, + @CurrentUser() user: AuthUserPayload, + ) { + const isFavorite = await this.favoritesService.isFavorite( + user.sub, + propertyId, + ); + return { isFavorite }; + } + + /** + * Public — total number of users that have favorited a property. + */ + @Get('property/:propertyId/count') + async propertyCount( + @Param('propertyId', new ParseUUIDPipe()) propertyId: string, + ) { + const count = + await this.favoritesService.getPropertyFavoriteCount(propertyId); + return { propertyId, count }; + } +} diff --git a/src/favorites/favorites.module.ts b/src/favorites/favorites.module.ts new file mode 100644 index 00000000..1690a4c2 --- /dev/null +++ b/src/favorites/favorites.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { FavoritesService } from './favorites.service'; +import { FavoritesController } from './favorites.controller'; +import { PrismaModule } from '../database/prisma.module'; +import { AuthModule } from '../auth/auth.module'; + +@Module({ + imports: [PrismaModule, AuthModule], + controllers: [FavoritesController], + providers: [FavoritesService], + exports: [FavoritesService], +}) +export class FavoritesModule {} diff --git a/src/favorites/favorites.service.ts b/src/favorites/favorites.service.ts new file mode 100644 index 00000000..6f4d9663 --- /dev/null +++ b/src/favorites/favorites.service.ts @@ -0,0 +1,136 @@ +import { + Injectable, + NotFoundException, + ConflictException, +} from '@nestjs/common'; +import { PrismaService } from '../database/prisma.service'; + +export interface ListFavoritesParams { + skip?: number; + take?: number; +} + +@Injectable() +export class FavoritesService { + constructor(private readonly prisma: PrismaService) {} + + /** + * Add a property to a user's favorites. Idempotent — returns the existing + * favorite when the user has already favorited the property. + */ + async addFavorite(userId: string, propertyId: string) { + const property = await this.prisma.property.findUnique({ + where: { id: propertyId }, + select: { id: true }, + }); + + if (!property) { + throw new NotFoundException(`Property ${propertyId} not found`); + } + + try { + return await this.prisma.propertyFavorite.create({ + data: { userId, propertyId }, + }); + } catch (err: unknown) { + // P2002 = unique constraint violation (already favorited) + if ( + typeof err === 'object' && + err !== null && + (err as { code?: string }).code === 'P2002' + ) { + const existing = await this.prisma.propertyFavorite.findUnique({ + where: { userId_propertyId: { userId, propertyId } }, + }); + if (existing) { + return existing; + } + throw new ConflictException('Property already in favorites'); + } + throw err; + } + } + + /** + * Remove a property from a user's favorites. + */ + async removeFavorite(userId: string, propertyId: string) { + const result = await this.prisma.propertyFavorite.deleteMany({ + where: { userId, propertyId }, + }); + + if (result.count === 0) { + throw new NotFoundException('Favorite not found'); + } + + return { success: true }; + } + + /** + * List favorites for a user with the embedded property details. + */ + async listFavorites(userId: string, params: ListFavoritesParams = {}) { + const skip = params.skip ?? 0; + const take = params.take ?? 20; + + const [items, total] = await this.prisma.$transaction([ + this.prisma.propertyFavorite.findMany({ + where: { userId }, + skip, + take, + orderBy: { createdAt: 'desc' }, + include: { + property: { + include: { + owner: { + select: { + id: true, + firstName: true, + lastName: true, + email: true, + }, + }, + }, + }, + }, + }), + this.prisma.propertyFavorite.count({ where: { userId } }), + ]); + + return { items, total, skip, take }; + } + + /** + * Total number of favorites saved by a user. + */ + async getUserFavoriteCount(userId: string): Promise { + return this.prisma.propertyFavorite.count({ where: { userId } }); + } + + /** + * Number of users that have favorited a property (popularity metric). + */ + async getPropertyFavoriteCount(propertyId: string): Promise { + const property = await this.prisma.property.findUnique({ + where: { id: propertyId }, + select: { id: true }, + }); + + if (!property) { + throw new NotFoundException(`Property ${propertyId} not found`); + } + + return this.prisma.propertyFavorite.count({ where: { propertyId } }); + } + + /** + * Whether a property is currently in a user's favorites. + */ + async isFavorite(userId: string, propertyId: string): Promise { + const favorite = await this.prisma.propertyFavorite.findUnique({ + where: { userId_propertyId: { userId, propertyId } }, + select: { id: true }, + }); + return favorite !== null; + } +} diff --git a/src/neighborhoods/dto/neighborhood.dto.ts b/src/neighborhoods/dto/neighborhood.dto.ts new file mode 100644 index 00000000..a0e6b3ce --- /dev/null +++ b/src/neighborhoods/dto/neighborhood.dto.ts @@ -0,0 +1,184 @@ +import { + IsArray, + IsInt, + IsNumber, + IsObject, + IsOptional, + IsString, + IsUUID, + Max, + Min, + ValidateNested, +} from 'class-validator'; +import { Type } from 'class-transformer'; + +const SCORE_MIN = 0; +const SCORE_MAX = 100; + +export class SchoolDto { + @IsString() + name!: string; + + /** ELEMENTARY | MIDDLE | HIGH | COLLEGE | PRIVATE | CHARTER */ + @IsString() + type!: string; + + @IsNumber() + @Min(0) + @Max(10) + rating!: number; + + @IsOptional() + @IsNumber() + @Min(0) + distanceMiles?: number; + + @IsOptional() + @IsNumber() + @Min(0) + studentTeacherRatio?: number; + + @IsOptional() + @IsInt() + @Min(0) + enrollmentCount?: number; + + @IsOptional() + @IsString() + url?: string; +} + +export class AmenityDto { + /** GROCERY | RESTAURANT | PARK | GYM | HOSPITAL | SCHOOL | TRANSIT | SHOPPING | etc. */ + @IsString() + category!: string; + + @IsString() + name!: string; + + @IsOptional() + @IsNumber() + @Min(0) + distanceMiles?: number; + + @IsOptional() + @IsNumber() + @Min(0) + @Max(5) + rating?: number; + + @IsOptional() + @IsString() + address?: string; +} + +export class CreateNeighborhoodDto { + @IsString() + name!: string; + + @IsString() + city!: string; + + @IsString() + state!: string; + + @IsOptional() + @IsString() + country?: string; + + @IsOptional() + @IsInt() + @Min(SCORE_MIN) + @Max(SCORE_MAX) + walkScore?: number; + + @IsOptional() + @IsInt() + @Min(SCORE_MIN) + @Max(SCORE_MAX) + transitScore?: number; + + @IsOptional() + @IsInt() + @Min(SCORE_MIN) + @Max(SCORE_MAX) + bikeScore?: number; + + /** 0-100, lower is better. */ + @IsOptional() + @IsNumber() + @Min(0) + @Max(100) + crimeIndex?: number; + + /** Free-form crime breakdown, e.g. { violent: 12.4, property: 33.0 }. */ + @IsOptional() + @IsObject() + crimeRate?: Record; + + @IsOptional() + @IsNumber() + @Min(0) + @Max(10) + schoolRating?: number; + + @IsOptional() + @IsString() + description?: string; + + @IsOptional() + @IsObject() + metadata?: Record; + + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => SchoolDto) + schools?: SchoolDto[]; + + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => AmenityDto) + amenities?: AmenityDto[]; +} + +export class UpdateNeighborhoodDto { + @IsOptional() @IsString() name?: string; + @IsOptional() @IsString() city?: string; + @IsOptional() @IsString() state?: string; + @IsOptional() @IsString() country?: string; + + @IsOptional() @IsInt() @Min(SCORE_MIN) @Max(SCORE_MAX) walkScore?: number; + @IsOptional() @IsInt() @Min(SCORE_MIN) @Max(SCORE_MAX) transitScore?: number; + @IsOptional() @IsInt() @Min(SCORE_MIN) @Max(SCORE_MAX) bikeScore?: number; + @IsOptional() @IsNumber() @Min(0) @Max(100) crimeIndex?: number; + + @IsOptional() @IsObject() crimeRate?: Record; + @IsOptional() @IsNumber() @Min(0) @Max(10) schoolRating?: number; + @IsOptional() @IsString() description?: string; + @IsOptional() @IsObject() metadata?: Record; +} + +export class ListNeighborhoodsQueryDto { + @IsOptional() @IsString() city?: string; + @IsOptional() @IsString() state?: string; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(0) + skip?: number; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(100) + take?: number; +} + +export class LinkPropertyDto { + @IsUUID() + neighborhoodId!: string; +} diff --git a/src/neighborhoods/neighborhoods.controller.ts b/src/neighborhoods/neighborhoods.controller.ts new file mode 100644 index 00000000..33f84b79 --- /dev/null +++ b/src/neighborhoods/neighborhoods.controller.ts @@ -0,0 +1,152 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + ParseUUIDPipe, + Patch, + Post, + Put, + Query, + UseGuards, +} from '@nestjs/common'; +import { NeighborhoodsService } from './neighborhoods.service'; +import { + AmenityDto, + CreateNeighborhoodDto, + LinkPropertyDto, + ListNeighborhoodsQueryDto, + SchoolDto, + UpdateNeighborhoodDto, +} from './dto/neighborhood.dto'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { RolesGuard } from '../auth/guards/roles.guard'; +import { Roles } from '../auth/decorators/roles.decorator'; +import { UserRole } from '../types/prisma.types'; + +@Controller('neighborhoods') +export class NeighborhoodsController { + constructor(private readonly service: NeighborhoodsService) {} + + // ----- Neighborhood CRUD ----- + + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.ADMIN) + @Post() + create(@Body() dto: CreateNeighborhoodDto) { + return this.service.create(dto); + } + + @Get() + list(@Query() query: ListNeighborhoodsQueryDto) { + return this.service.list(query); + } + + @Get(':id') + findOne(@Param('id', new ParseUUIDPipe()) id: string) { + return this.service.findOne(id); + } + + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.ADMIN) + @Put(':id') + update( + @Param('id', new ParseUUIDPipe()) id: string, + @Body() dto: UpdateNeighborhoodDto, + ) { + return this.service.update(id, dto); + } + + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.ADMIN) + @Delete(':id') + remove(@Param('id', new ParseUUIDPipe()) id: string) { + return this.service.remove(id); + } + + // ----- Schools subresource ----- + + @Get(':id/schools') + listSchools(@Param('id', new ParseUUIDPipe()) id: string) { + return this.service.listSchools(id); + } + + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.ADMIN) + @Post(':id/schools') + addSchool( + @Param('id', new ParseUUIDPipe()) id: string, + @Body() dto: SchoolDto, + ) { + return this.service.addSchool(id, dto); + } + + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.ADMIN) + @Delete(':id/schools/:schoolId') + removeSchool( + @Param('id', new ParseUUIDPipe()) id: string, + @Param('schoolId', new ParseUUIDPipe()) schoolId: string, + ) { + return this.service.removeSchool(id, schoolId); + } + + // ----- Amenities subresource ----- + + @Get(':id/amenities') + listAmenities( + @Param('id', new ParseUUIDPipe()) id: string, + @Query('category') category?: string, + ) { + return this.service.listAmenities(id, category); + } + + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.ADMIN) + @Post(':id/amenities') + addAmenity( + @Param('id', new ParseUUIDPipe()) id: string, + @Body() dto: AmenityDto, + ) { + return this.service.addAmenity(id, dto); + } + + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.ADMIN) + @Delete(':id/amenities/:amenityId') + removeAmenity( + @Param('id', new ParseUUIDPipe()) id: string, + @Param('amenityId', new ParseUUIDPipe()) amenityId: string, + ) { + return this.service.removeAmenity(id, amenityId); + } + + // ----- Property linkage ----- + + @Get('property/:propertyId') + getForProperty( + @Param('propertyId', new ParseUUIDPipe()) propertyId: string, + ) { + return this.service.getForProperty(propertyId); + } + + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.ADMIN, UserRole.AGENT) + @Patch('property/:propertyId') + linkProperty( + @Param('propertyId', new ParseUUIDPipe()) propertyId: string, + @Body() dto: LinkPropertyDto, + ) { + return this.service.linkProperty(propertyId, dto.neighborhoodId); + } + + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.ADMIN, UserRole.AGENT) + @Delete('property/:propertyId') + unlinkProperty( + @Param('propertyId', new ParseUUIDPipe()) propertyId: string, + ) { + return this.service.unlinkProperty(propertyId); + } +} diff --git a/src/neighborhoods/neighborhoods.module.ts b/src/neighborhoods/neighborhoods.module.ts new file mode 100644 index 00000000..2fc23c0d --- /dev/null +++ b/src/neighborhoods/neighborhoods.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { NeighborhoodsController } from './neighborhoods.controller'; +import { NeighborhoodsService } from './neighborhoods.service'; +import { PrismaModule } from '../database/prisma.module'; +import { AuthModule } from '../auth/auth.module'; + +@Module({ + imports: [PrismaModule, AuthModule], + controllers: [NeighborhoodsController], + providers: [NeighborhoodsService], + exports: [NeighborhoodsService], +}) +export class NeighborhoodsModule {} diff --git a/src/neighborhoods/neighborhoods.service.ts b/src/neighborhoods/neighborhoods.service.ts new file mode 100644 index 00000000..44eb0a3d --- /dev/null +++ b/src/neighborhoods/neighborhoods.service.ts @@ -0,0 +1,201 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { PrismaService } from '../database/prisma.service'; +import { + AmenityDto, + CreateNeighborhoodDto, + ListNeighborhoodsQueryDto, + SchoolDto, + UpdateNeighborhoodDto, +} from './dto/neighborhood.dto'; + +@Injectable() +export class NeighborhoodsService { + constructor(private readonly prisma: PrismaService) {} + + /** Create a neighborhood, optionally with embedded schools and amenities. */ + async create(dto: CreateNeighborhoodDto) { + const { schools, amenities, ...rest } = dto; + + return this.prisma.neighborhood.create({ + data: { + ...rest, + schools: schools && schools.length > 0 + ? { create: schools } + : undefined, + amenities: amenities && amenities.length > 0 + ? { create: amenities } + : undefined, + }, + include: { schools: true, amenities: true }, + }); + } + + /** Full neighborhood detail with schools, amenities, and property count. */ + async findOne(id: string) { + const neighborhood = await this.prisma.neighborhood.findUnique({ + where: { id }, + include: { + schools: { orderBy: { rating: 'desc' } }, + amenities: { orderBy: { distanceMiles: 'asc' } }, + _count: { select: { properties: true } }, + }, + }); + + if (!neighborhood) { + throw new NotFoundException(`Neighborhood ${id} not found`); + } + return neighborhood; + } + + async list(query: ListNeighborhoodsQueryDto) { + const skip = query.skip ?? 0; + const take = query.take ?? 20; + const where = { + ...(query.city ? { city: query.city } : {}), + ...(query.state ? { state: query.state } : {}), + }; + + const [items, total] = await this.prisma.$transaction([ + this.prisma.neighborhood.findMany({ + where, + skip, + take, + orderBy: [{ state: 'asc' }, { city: 'asc' }, { name: 'asc' }], + include: { + _count: { select: { schools: true, amenities: true, properties: true } }, + }, + }), + this.prisma.neighborhood.count({ where }), + ]); + + return { items, total, skip, take }; + } + + async update(id: string, dto: UpdateNeighborhoodDto) { + await this.assertExists(id); + return this.prisma.neighborhood.update({ + where: { id }, + data: dto, + include: { schools: true, amenities: true }, + }); + } + + async remove(id: string) { + await this.assertExists(id); + await this.prisma.neighborhood.delete({ where: { id } }); + return { success: true }; + } + + // ---------- Schools ---------- + + async addSchool(neighborhoodId: string, dto: SchoolDto) { + await this.assertExists(neighborhoodId); + return this.prisma.neighborhoodSchool.create({ + data: { ...dto, neighborhoodId }, + }); + } + + async removeSchool(neighborhoodId: string, schoolId: string) { + const result = await this.prisma.neighborhoodSchool.deleteMany({ + where: { id: schoolId, neighborhoodId }, + }); + if (result.count === 0) { + throw new NotFoundException('School not found in this neighborhood'); + } + return { success: true }; + } + + async listSchools(neighborhoodId: string) { + await this.assertExists(neighborhoodId); + return this.prisma.neighborhoodSchool.findMany({ + where: { neighborhoodId }, + orderBy: { rating: 'desc' }, + }); + } + + // ---------- Amenities ---------- + + async addAmenity(neighborhoodId: string, dto: AmenityDto) { + await this.assertExists(neighborhoodId); + return this.prisma.neighborhoodAmenity.create({ + data: { ...dto, neighborhoodId }, + }); + } + + async removeAmenity(neighborhoodId: string, amenityId: string) { + const result = await this.prisma.neighborhoodAmenity.deleteMany({ + where: { id: amenityId, neighborhoodId }, + }); + if (result.count === 0) { + throw new NotFoundException('Amenity not found in this neighborhood'); + } + return { success: true }; + } + + async listAmenities(neighborhoodId: string, category?: string) { + await this.assertExists(neighborhoodId); + return this.prisma.neighborhoodAmenity.findMany({ + where: { neighborhoodId, ...(category ? { category } : {}) }, + orderBy: [{ category: 'asc' }, { distanceMiles: 'asc' }], + }); + } + + // ---------- Property linkage ---------- + + /** Resolve and return neighborhood data for a given property. */ + async getForProperty(propertyId: string) { + const property = await this.prisma.property.findUnique({ + where: { id: propertyId }, + select: { id: true, neighborhoodId: true }, + }); + + if (!property) { + throw new NotFoundException(`Property ${propertyId} not found`); + } + if (!property.neighborhoodId) { + return null; + } + return this.findOne(property.neighborhoodId); + } + + async linkProperty(propertyId: string, neighborhoodId: string) { + await this.assertExists(neighborhoodId); + const property = await this.prisma.property.findUnique({ + where: { id: propertyId }, + select: { id: true }, + }); + if (!property) { + throw new NotFoundException(`Property ${propertyId} not found`); + } + return this.prisma.property.update({ + where: { id: propertyId }, + data: { neighborhoodId }, + select: { id: true, neighborhoodId: true }, + }); + } + + async unlinkProperty(propertyId: string) { + const property = await this.prisma.property.findUnique({ + where: { id: propertyId }, + select: { id: true }, + }); + if (!property) { + throw new NotFoundException(`Property ${propertyId} not found`); + } + return this.prisma.property.update({ + where: { id: propertyId }, + data: { neighborhoodId: null }, + select: { id: true, neighborhoodId: true }, + }); + } + + private async assertExists(id: string) { + const found = await this.prisma.neighborhood.findUnique({ + where: { id }, + select: { id: true }, + }); + if (!found) { + throw new NotFoundException(`Neighborhood ${id} not found`); + } + } +} diff --git a/src/property-comparison/dto/comparison.dto.ts b/src/property-comparison/dto/comparison.dto.ts new file mode 100644 index 00000000..93594bce --- /dev/null +++ b/src/property-comparison/dto/comparison.dto.ts @@ -0,0 +1,57 @@ +import { + ArrayMaxSize, + ArrayMinSize, + ArrayUnique, + IsArray, + IsUUID, +} from 'class-validator'; +import { Transform } from 'class-transformer'; + +const COMPARISON_MIN = 2; +const COMPARISON_MAX = 4; + +/** + * DTO for `GET /property-comparison?ids=uuid1,uuid2,...`. + * Accepts a comma-separated string and normalizes it into an array. + */ +export class CompareQueryDto { + @Transform(({ value }) => { + if (Array.isArray(value)) { + return value.flatMap((v: string) => String(v).split(',')); + } + return typeof value === 'string' + ? value + .split(',') + .map((v) => v.trim()) + .filter(Boolean) + : value; + }) + @IsArray() + @ArrayMinSize(COMPARISON_MIN, { + message: `At least ${COMPARISON_MIN} properties are required for comparison`, + }) + @ArrayMaxSize(COMPARISON_MAX, { + message: `A maximum of ${COMPARISON_MAX} properties can be compared at once`, + }) + @ArrayUnique({ message: 'Duplicate property IDs are not allowed' }) + @IsUUID('all', { each: true }) + ids!: string[]; +} + +/** + * DTO for `POST /property-comparison` with `{ ids: [...] }` body. + */ +export class CompareBodyDto { + @IsArray() + @ArrayMinSize(COMPARISON_MIN, { + message: `At least ${COMPARISON_MIN} properties are required for comparison`, + }) + @ArrayMaxSize(COMPARISON_MAX, { + message: `A maximum of ${COMPARISON_MAX} properties can be compared at once`, + }) + @ArrayUnique({ message: 'Duplicate property IDs are not allowed' }) + @IsUUID('all', { each: true }) + ids!: string[]; +} + +export const COMPARISON_LIMITS = { min: COMPARISON_MIN, max: COMPARISON_MAX }; diff --git a/src/property-comparison/property-comparison.controller.ts b/src/property-comparison/property-comparison.controller.ts new file mode 100644 index 00000000..59066264 --- /dev/null +++ b/src/property-comparison/property-comparison.controller.ts @@ -0,0 +1,28 @@ +import { Body, Controller, Get, Post, Query } from '@nestjs/common'; +import { PropertyComparisonService } from './property-comparison.service'; +import { CompareBodyDto, CompareQueryDto } from './dto/comparison.dto'; + +@Controller('property-comparison') +export class PropertyComparisonController { + constructor( + private readonly comparisonService: PropertyComparisonService, + ) {} + + /** + * Compare 2-4 properties via query string: + * GET /property-comparison?ids=uuid1,uuid2,uuid3 + */ + @Get() + compareGet(@Query() query: CompareQueryDto) { + return this.comparisonService.compare(query.ids); + } + + /** + * Compare 2-4 properties via JSON body: + * POST /property-comparison { "ids": ["uuid1", "uuid2", ...] } + */ + @Post() + comparePost(@Body() body: CompareBodyDto) { + return this.comparisonService.compare(body.ids); + } +} diff --git a/src/property-comparison/property-comparison.module.ts b/src/property-comparison/property-comparison.module.ts new file mode 100644 index 00000000..b679d518 --- /dev/null +++ b/src/property-comparison/property-comparison.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { PropertyComparisonController } from './property-comparison.controller'; +import { PropertyComparisonService } from './property-comparison.service'; +import { PrismaModule } from '../database/prisma.module'; + +@Module({ + imports: [PrismaModule], + controllers: [PropertyComparisonController], + providers: [PropertyComparisonService], + exports: [PropertyComparisonService], +}) +export class PropertyComparisonModule {} diff --git a/src/property-comparison/property-comparison.service.ts b/src/property-comparison/property-comparison.service.ts new file mode 100644 index 00000000..7023aa11 --- /dev/null +++ b/src/property-comparison/property-comparison.service.ts @@ -0,0 +1,177 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { Decimal } from '@prisma/client/runtime/library'; +import { PrismaService } from '../database/prisma.service'; + +/** Fields included in the side-by-side comparison view. */ +const COMPARABLE_FIELDS = [ + 'title', + 'address', + 'city', + 'state', + 'zipCode', + 'country', + 'price', + 'propertyType', + 'bedrooms', + 'bathrooms', + 'squareFeet', + 'lotSize', + 'yearBuilt', + 'status', + 'features', + 'latitude', + 'longitude', +] as const; + +type ComparableField = (typeof COMPARABLE_FIELDS)[number]; + +/** Numeric fields used to compute min/max highlights. */ +const NUMERIC_FIELDS: ReadonlySet = new Set([ + 'price', + 'bedrooms', + 'bathrooms', + 'squareFeet', + 'lotSize', + 'yearBuilt', +]); + +interface FieldRow { + field: ComparableField; + values: unknown[]; + allEqual: boolean; + min?: number | null; + max?: number | null; + bestIndex?: number | null; // index of property with min price / largest area, etc. + worstIndex?: number | null; +} + +@Injectable() +export class PropertyComparisonService { + constructor(private readonly prisma: PrismaService) {} + + /** + * Compare 2-4 properties side-by-side and highlight differing fields. + */ + async compare(ids: string[]) { + const properties = await this.prisma.property.findMany({ + where: { id: { in: ids } }, + include: { + owner: { + select: { + id: true, + firstName: true, + lastName: true, + email: true, + }, + }, + }, + }); + + // Validate all requested IDs exist. + if (properties.length !== ids.length) { + const found = new Set(properties.map((p) => p.id)); + const missing = ids.filter((id) => !found.has(id)); + throw new NotFoundException( + `Properties not found: ${missing.join(', ')}`, + ); + } + + // Preserve the order requested by the caller. + const ordered = ids.map((id) => properties.find((p) => p.id === id)!); + + const comparison: FieldRow[] = COMPARABLE_FIELDS.map((field) => + this.buildFieldRow(field, ordered), + ); + + const differingFields = comparison + .filter((row) => !row.allEqual) + .map((row) => row.field); + const commonFields = comparison + .filter((row) => row.allEqual) + .map((row) => row.field); + + return { + count: ordered.length, + properties: ordered, + comparison, + differingFields, + commonFields, + }; + } + + private buildFieldRow( + field: ComparableField, + properties: Array>, + ): FieldRow { + const rawValues = properties.map((p) => p[field]); + const normalizedValues = rawValues.map((v) => this.normalize(v)); + + const allEqual = normalizedValues.every((v, _i, arr) => + this.deepEqual(v, arr[0]), + ); + + const row: FieldRow = { + field, + values: normalizedValues, + allEqual, + }; + + if (NUMERIC_FIELDS.has(field)) { + const numerics = normalizedValues.map((v) => + typeof v === 'number' ? v : null, + ); + const present = numerics + .map((v, i) => ({ v, i })) + .filter((x): x is { v: number; i: number } => x.v !== null); + + if (present.length > 0) { + const minEntry = present.reduce((a, b) => (a.v <= b.v ? a : b)); + const maxEntry = present.reduce((a, b) => (a.v >= b.v ? a : b)); + row.min = minEntry.v; + row.max = maxEntry.v; + + // For price → lowest is "best". For everything else higher is better. + if (field === 'price') { + row.bestIndex = minEntry.i; + row.worstIndex = maxEntry.i; + } else { + row.bestIndex = maxEntry.i; + row.worstIndex = minEntry.i; + } + } else { + row.min = null; + row.max = null; + row.bestIndex = null; + row.worstIndex = null; + } + } + + return row; + } + + /** Convert Prisma `Decimal` to number; sort feature arrays for stable comparison. */ + private normalize(value: unknown): unknown { + if (value === null || value === undefined) { + return null; + } + if (value instanceof Decimal) { + return value.toNumber(); + } + if (Array.isArray(value)) { + return [...value].sort(); + } + return value; + } + + private deepEqual(a: unknown, b: unknown): boolean { + if (a === b) return true; + if (Array.isArray(a) && Array.isArray(b)) { + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) { + if (!this.deepEqual(a[i], b[i])) return false; + } + return true; + } + return false; + } +} diff --git a/src/property-views/dto/property-view.dto.ts b/src/property-views/dto/property-view.dto.ts new file mode 100644 index 00000000..e9391197 --- /dev/null +++ b/src/property-views/dto/property-view.dto.ts @@ -0,0 +1,46 @@ +import { IsInt, IsOptional, IsString, Max, Min } from 'class-validator'; +import { Type } from 'class-transformer'; + +export class RecordViewDto { + @IsOptional() + @IsString() + referrer?: string; + + @IsOptional() + @IsString() + sessionId?: string; +} + +export class ViewHistoryQueryDto { + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(0) + skip?: number; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(100) + take?: number; + + /** ISO date string — only return views at/after this timestamp. */ + @IsOptional() + @IsString() + since?: string; +} + +export class PopularPropertiesQueryDto { + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(50) + take?: number; + + /** ISO date string — compute popularity from views since this timestamp. */ + @IsOptional() + @IsString() + since?: string; +} diff --git a/src/property-views/guards/optional-jwt-auth.guard.ts b/src/property-views/guards/optional-jwt-auth.guard.ts new file mode 100644 index 00000000..1cda7ba5 --- /dev/null +++ b/src/property-views/guards/optional-jwt-auth.guard.ts @@ -0,0 +1,34 @@ +import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'; +import { AuthService } from '../../auth/auth.service'; + +/** + * Like JwtAuthGuard, but never blocks the request. If a valid Bearer token is + * present, the resolved user is attached to `request.authUser`; otherwise the + * request proceeds anonymously. + */ +@Injectable() +export class OptionalJwtAuthGuard implements CanActivate { + constructor(private readonly authService: AuthService) {} + + async canActivate(context: ExecutionContext): Promise { + const request = context.switchToHttp().getRequest(); + const header: string | undefined = request.headers?.authorization; + + if (!header) { + return true; + } + + const [scheme, token] = header.split(' '); + if (scheme !== 'Bearer' || !token) { + return true; + } + + try { + request.authUser = await this.authService.validateAccessToken(token); + request.accessToken = token; + } catch { + // Invalid token → treat as anonymous, do not throw + } + return true; + } +} diff --git a/src/property-views/property-views.controller.ts b/src/property-views/property-views.controller.ts new file mode 100644 index 00000000..a6b9949c --- /dev/null +++ b/src/property-views/property-views.controller.ts @@ -0,0 +1,126 @@ +import { + BadRequestException, + Body, + Controller, + Get, + Param, + ParseUUIDPipe, + Post, + Query, + Req, + UseGuards, +} from '@nestjs/common'; +import { Request } from 'express'; +import { PropertyViewsService } from './property-views.service'; +import { + PopularPropertiesQueryDto, + RecordViewDto, + ViewHistoryQueryDto, +} from './dto/property-view.dto'; +import { OptionalJwtAuthGuard } from './guards/optional-jwt-auth.guard'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { CurrentUser } from '../auth/decorators/current-user.decorator'; +import { AuthUserPayload } from '../auth/types/auth-user.type'; + +interface RequestWithAuth extends Request { + authUser?: AuthUserPayload; +} + +@Controller('property-views') +export class PropertyViewsController { + constructor(private readonly propertyViewsService: PropertyViewsService) {} + + /** + * Record a property view. Auth is optional — authenticated users are tracked + * by userId, anonymous viewers by IP address. + */ + @UseGuards(OptionalJwtAuthGuard) + @Post(':propertyId') + record( + @Param('propertyId', new ParseUUIDPipe()) propertyId: string, + @Body() body: RecordViewDto, + @Req() request: RequestWithAuth, + ) { + const ipAddress = this.getClientIp(request); + const userAgent = request.headers['user-agent'] ?? null; + const userId = request.authUser?.sub ?? null; + + return this.propertyViewsService.recordView(propertyId, { + userId, + ipAddress, + userAgent, + referrer: body.referrer ?? null, + sessionId: body.sessionId ?? null, + }); + } + + /** + * Total lifetime view count for a property. + */ + @Get(':propertyId/count') + async count(@Param('propertyId', new ParseUUIDPipe()) propertyId: string) { + const count = await this.propertyViewsService.getViewCount(propertyId); + return { propertyId, count }; + } + + /** + * Unique visitors (distinct authenticated users + distinct anonymous IPs). + */ + @Get(':propertyId/unique') + async unique( + @Param('propertyId', new ParseUUIDPipe()) propertyId: string, + @Query('since') since?: string, + ) { + const sinceDate = this.parseSince(since); + const result = await this.propertyViewsService.getUniqueVisitorCount( + propertyId, + sinceDate, + ); + return { propertyId, ...result }; + } + + /** + * Paginated view history for a property. Requires auth. + */ + @UseGuards(JwtAuthGuard) + @Get(':propertyId/history') + history( + @Param('propertyId', new ParseUUIDPipe()) propertyId: string, + @Query() query: ViewHistoryQueryDto, + @CurrentUser() _user: AuthUserPayload, + ) { + return this.propertyViewsService.getViewHistory(propertyId, { + skip: query.skip, + take: query.take, + since: this.parseSince(query.since), + }); + } + + /** + * Most-viewed properties (popular query). + */ + @Get('popular') + popular(@Query() query: PopularPropertiesQueryDto) { + return this.propertyViewsService.getPopularProperties({ + take: query.take, + since: this.parseSince(query.since), + }); + } + + private parseSince(since?: string): Date | undefined { + if (!since) return undefined; + const date = new Date(since); + if (Number.isNaN(date.getTime())) { + throw new BadRequestException('Invalid `since` timestamp'); + } + return date; + } + + private getClientIp(request: Request): string | null { + const forwarded = request.headers['x-forwarded-for']; + if (typeof forwarded === 'string' && forwarded.length > 0) { + return forwarded.split(',')[0].trim(); + } + return request.ip ?? request.socket?.remoteAddress ?? null; + } +} diff --git a/src/property-views/property-views.module.ts b/src/property-views/property-views.module.ts new file mode 100644 index 00000000..a869a9dc --- /dev/null +++ b/src/property-views/property-views.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { PropertyViewsController } from './property-views.controller'; +import { PropertyViewsService } from './property-views.service'; +import { PrismaModule } from '../database/prisma.module'; +import { AuthModule } from '../auth/auth.module'; + +@Module({ + imports: [PrismaModule, AuthModule], + controllers: [PropertyViewsController], + providers: [PropertyViewsService], + exports: [PropertyViewsService], +}) +export class PropertyViewsModule {} diff --git a/src/property-views/property-views.service.ts b/src/property-views/property-views.service.ts new file mode 100644 index 00000000..d5570d13 --- /dev/null +++ b/src/property-views/property-views.service.ts @@ -0,0 +1,215 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { PrismaService } from '../database/prisma.service'; + +export interface RecordViewInput { + userId?: string | null; + ipAddress?: string | null; + userAgent?: string | null; + referrer?: string | null; + sessionId?: string | null; +} + +export interface ViewHistoryParams { + skip?: number; + take?: number; + since?: Date; +} + +export interface PopularQueryParams { + take?: number; + since?: Date; +} + +@Injectable() +export class PropertyViewsService { + constructor(private readonly prisma: PrismaService) {} + + /** + * Record a view event and atomically increment the property's view counter. + */ + async recordView(propertyId: string, input: RecordViewInput) { + const property = await this.prisma.property.findUnique({ + where: { id: propertyId }, + select: { id: true }, + }); + + if (!property) { + throw new NotFoundException(`Property ${propertyId} not found`); + } + + const [view, updated] = await this.prisma.$transaction([ + this.prisma.propertyView.create({ + data: { + propertyId, + userId: input.userId ?? null, + ipAddress: input.ipAddress ?? null, + userAgent: input.userAgent ?? null, + referrer: input.referrer ?? null, + sessionId: input.sessionId ?? null, + }, + }), + this.prisma.property.update({ + where: { id: propertyId }, + data: { viewCount: { increment: 1 } }, + select: { id: true, viewCount: true }, + }), + ]); + + return { view, viewCount: updated.viewCount }; + } + + /** + * Total view count for a property (denormalized counter). + */ + async getViewCount(propertyId: string): Promise { + const property = await this.prisma.property.findUnique({ + where: { id: propertyId }, + select: { viewCount: true }, + }); + if (!property) { + throw new NotFoundException(`Property ${propertyId} not found`); + } + return property.viewCount; + } + + /** + * Unique visitor count = distinct authenticated users + distinct anonymous IPs. + * Optionally bounded by a `since` timestamp. + */ + async getUniqueVisitorCount( + propertyId: string, + since?: Date, + ): Promise<{ + total: number; + authenticatedUsers: number; + anonymousIps: number; + }> { + const baseWhere = { + propertyId, + ...(since ? { viewedAt: { gte: since } } : {}), + }; + + const [authGroups, anonGroups] = await Promise.all([ + this.prisma.propertyView.groupBy({ + by: ['userId'], + where: { ...baseWhere, userId: { not: null } }, + }), + this.prisma.propertyView.groupBy({ + by: ['ipAddress'], + where: { ...baseWhere, userId: null, ipAddress: { not: null } }, + }), + ]); + + const authenticatedUsers = authGroups.length; + const anonymousIps = anonGroups.length; + + return { + total: authenticatedUsers + anonymousIps, + authenticatedUsers, + anonymousIps, + }; + } + + /** + * Paginated raw view history for a property. + */ + async getViewHistory(propertyId: string, params: ViewHistoryParams = {}) { + const skip = params.skip ?? 0; + const take = params.take ?? 20; + const where = { + propertyId, + ...(params.since ? { viewedAt: { gte: params.since } } : {}), + }; + + const [items, total] = await this.prisma.$transaction([ + this.prisma.propertyView.findMany({ + where, + skip, + take, + orderBy: { viewedAt: 'desc' }, + include: { + user: { + select: { + id: true, + firstName: true, + lastName: true, + email: true, + }, + }, + }, + }), + this.prisma.propertyView.count({ where }), + ]); + + return { items, total, skip, take }; + } + + /** + * Most-viewed properties. When `since` is provided we aggregate raw events + * within the window; otherwise we use the denormalized lifetime counter. + */ + async getPopularProperties(params: PopularQueryParams = {}) { + const take = params.take ?? 10; + + if (params.since) { + const grouped = await this.prisma.propertyView.groupBy({ + by: ['propertyId'], + where: { viewedAt: { gte: params.since } }, + _count: { propertyId: true }, + orderBy: { _count: { propertyId: 'desc' } }, + take, + }); + + const ids = grouped.map((g) => g.propertyId); + if (ids.length === 0) { + return []; + } + + const properties = await this.prisma.property.findMany({ + where: { id: { in: ids } }, + include: { + owner: { + select: { + id: true, + firstName: true, + lastName: true, + email: true, + }, + }, + }, + }); + + const byId = new Map(properties.map((p) => [p.id, p])); + return grouped + .map((g) => { + const property = byId.get(g.propertyId); + if (!property) return null; + return { property, viewsInWindow: g._count.propertyId }; + }) + .filter( + (entry): entry is { property: (typeof properties)[number]; viewsInWindow: number } => + entry !== null, + ); + } + + const properties = await this.prisma.property.findMany({ + orderBy: { viewCount: 'desc' }, + take, + include: { + owner: { + select: { + id: true, + firstName: true, + lastName: true, + email: true, + }, + }, + }, + }); + + return properties.map((property) => ({ + property, + viewsInWindow: property.viewCount, + })); + } +} diff --git a/test/common/cache-invalidation.service.spec.ts b/test/common/cache-invalidation.service.spec.ts new file mode 100644 index 00000000..f879f194 --- /dev/null +++ b/test/common/cache-invalidation.service.spec.ts @@ -0,0 +1,354 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { ConfigService } from '@nestjs/config'; +import { CacheInvalidationService, InvalidationRule } from '../../src/common/cache/cache-invalidation.service'; +import { MultiLevelCacheService } from '../../src/common/cache/multi-level-cache.service'; +import { RedisService } from '../../src/common/services/redis.service'; + +describe('CacheInvalidationService', () => { + let service: CacheInvalidationService; + let cacheService: jest.Mocked; + let redisService: jest.Mocked; + let configService: jest.Mocked; + + beforeEach(async () => { + const mockCacheService = { + get: jest.fn(), + set: jest.fn(), + del: jest.fn(), + invalidateByPattern: jest.fn(), + invalidateByTag: jest.fn(), + invalidateWithCascade: jest.fn(), + }; + + const mockRedisService = { + get: jest.fn(), + setex: jest.fn(), + del: jest.fn(), + keys: jest.fn(), + sadd: jest.fn(), + smembers: jest.fn(), + ttl: jest.fn(), + }; + + const mockConfigService = { + get: jest.fn().mockImplementation((key: string, defaultValue?: any) => defaultValue), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + CacheInvalidationService, + { provide: MultiLevelCacheService, useValue: mockCacheService }, + { provide: RedisService, useValue: mockRedisService }, + { provide: ConfigService, useValue: mockConfigService }, + ], + }).compile(); + + service = module.get(CacheInvalidationService); + cacheService = module.get(MultiLevelCacheService); + redisService = module.get(RedisService); + configService = module.get(ConfigService); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('initialization', () => { + it('should initialize with default rules', () => { + const rules = service.getRules(); + expect(rules.length).toBeGreaterThan(0); + }); + }); + + describe('registerRule', () => { + it('should register a new invalidation rule', () => { + const rule: InvalidationRule = { + id: 'test-rule', + name: 'Test Rule', + description: 'Test description', + type: 'pattern', + target: 'test:*', + action: 'delete', + priority: 5, + enabled: true, + metadata: { + createdAt: new Date(), + lastExecuted: null, + executionCount: 0, + }, + }; + + service.registerRule(rule); + + expect(service.getRule('test-rule')).toEqual(rule); + }); + }); + + describe('unregisterRule', () => { + it('should unregister a rule', () => { + const rule: InvalidationRule = { + id: 'temp-rule', + name: 'Temp Rule', + description: 'Temp description', + type: 'pattern', + target: 'temp:*', + action: 'delete', + priority: 5, + enabled: true, + metadata: { + createdAt: new Date(), + lastExecuted: null, + executionCount: 0, + }, + }; + + service.registerRule(rule); + const result = service.unregisterRule('temp-rule'); + + expect(result).toBe(true); + expect(service.getRule('temp-rule')).toBeUndefined(); + }); + + it('should return false for non-existent rule', () => { + const result = service.unregisterRule('non-existent'); + expect(result).toBe(false); + }); + }); + + describe('setRuleEnabled', () => { + it('should enable/disable a rule', () => { + const rule: InvalidationRule = { + id: 'toggle-rule', + name: 'Toggle Rule', + description: 'Toggle description', + type: 'pattern', + target: 'toggle:*', + action: 'delete', + priority: 5, + enabled: true, + metadata: { + createdAt: new Date(), + lastExecuted: null, + executionCount: 0, + }, + }; + + service.registerRule(rule); + + let result = service.setRuleEnabled('toggle-rule', false); + expect(result).toBe(true); + expect(service.getRule('toggle-rule')?.enabled).toBe(false); + + result = service.setRuleEnabled('toggle-rule', true); + expect(result).toBe(true); + expect(service.getRule('toggle-rule')?.enabled).toBe(true); + }); + + it('should return false for non-existent rule', () => { + const result = service.setRuleEnabled('non-existent', false); + expect(result).toBe(false); + }); + }); + + describe('executeRule', () => { + it('should execute a pattern rule', async () => { + cacheService.invalidateByPattern.mockResolvedValue(5); + + const rule: InvalidationRule = { + id: 'pattern-rule', + name: 'Pattern Rule', + description: 'Pattern description', + type: 'pattern', + target: 'pattern:*', + action: 'delete', + priority: 5, + enabled: true, + metadata: { + createdAt: new Date(), + lastExecuted: null, + executionCount: 0, + }, + }; + + service.registerRule(rule); + const result = await service.executeRule('pattern-rule'); + + expect(result).toBe(5); + expect(cacheService.invalidateByPattern).toHaveBeenCalledWith('pattern:*'); + }); + + it('should skip disabled rules', async () => { + const rule: InvalidationRule = { + id: 'disabled-rule', + name: 'Disabled Rule', + description: 'Disabled description', + type: 'pattern', + target: 'disabled:*', + action: 'delete', + priority: 5, + enabled: false, + metadata: { + createdAt: new Date(), + lastExecuted: null, + executionCount: 0, + }, + }; + + service.registerRule(rule); + const result = await service.executeRule('disabled-rule'); + + expect(result).toBe(0); + expect(cacheService.invalidateByPattern).not.toHaveBeenCalled(); + }); + + it('should return 0 for non-existent rule', async () => { + const result = await service.executeRule('non-existent'); + expect(result).toBe(0); + }); + + it('should handle rule execution errors', async () => { + cacheService.invalidateByPattern.mockRejectedValue(new Error('Execution error')); + + const rule: InvalidationRule = { + id: 'error-rule', + name: 'Error Rule', + description: 'Error description', + type: 'pattern', + target: 'error:*', + action: 'delete', + priority: 5, + enabled: true, + metadata: { + createdAt: new Date(), + lastExecuted: null, + executionCount: 0, + }, + }; + + service.registerRule(rule); + const result = await service.executeRule('error-rule'); + + expect(result).toBe(0); + }); + }); + + describe('smartInvalidate', () => { + it('should invalidate based on entity type and change type', async () => { + cacheService.invalidateByPattern.mockResolvedValue(1); + + await service.smartInvalidate('property', '123', 'update'); + + expect(cacheService.invalidateByPattern).toHaveBeenCalled(); + }); + + it('should handle create change type', async () => { + cacheService.invalidateByPattern.mockResolvedValue(1); + + await service.smartInvalidate('user', '456', 'create'); + + expect(cacheService.invalidateByPattern).toHaveBeenCalledWith('user:*:list'); + expect(cacheService.invalidateByPattern).toHaveBeenCalledWith('user:active:*'); + }); + + it('should handle delete change type', async () => { + cacheService.invalidateByPattern.mockResolvedValue(1); + + await service.smartInvalidate('transaction', '789', 'delete'); + + expect(cacheService.invalidateByPattern).toHaveBeenCalledWith('transaction:789'); + expect(cacheService.invalidateByPattern).toHaveBeenCalledWith('transaction:*:list'); + expect(cacheService.invalidateByPattern).toHaveBeenCalledWith('balance:*'); + }); + }); + + describe('batchInvalidate', () => { + it('should batch invalidate multiple keys', async () => { + const keys = ['key1', 'key2', 'key3']; + + const result = await service.batchInvalidate(keys); + + expect(result.success).toBe(3); + expect(result.failed).toBe(0); + expect(cacheService.del).toHaveBeenCalledTimes(3); + }); + + it('should handle partial failures', async () => { + const keys = ['key1', 'key2', 'key3']; + cacheService.del + .mockResolvedValueOnce(undefined) + .mockRejectedValueOnce(new Error('Delete error')) + .mockResolvedValueOnce(undefined); + + const result = await service.batchInvalidate(keys); + + expect(result.success).toBe(2); + expect(result.failed).toBe(1); + }); + }); + + describe('invalidateWithCallback', () => { + it('should invalidate and refresh with callback', async () => { + const refreshCallback = jest.fn().mockResolvedValue({ data: 'refreshed' }); + + await service.invalidateWithCallback('test:key', refreshCallback); + + expect(cacheService.del).toHaveBeenCalledWith('test:key'); + expect(refreshCallback).toHaveBeenCalled(); + expect(cacheService.set).toHaveBeenCalledWith('test:key', { data: 'refreshed' }); + }); + + it('should invalidate without callback', async () => { + await service.invalidateWithCallback('test:key'); + + expect(cacheService.del).toHaveBeenCalledWith('test:key'); + expect(cacheService.set).not.toHaveBeenCalled(); + }); + + it('should handle refresh callback errors', async () => { + const refreshCallback = jest.fn().mockRejectedValue(new Error('Refresh error')); + + await service.invalidateWithCallback('test:key', refreshCallback); + + expect(cacheService.del).toHaveBeenCalledWith('test:key'); + expect(refreshCallback).toHaveBeenCalled(); + expect(cacheService.set).not.toHaveBeenCalled(); + }); + }); + + describe('getStats', () => { + it('should return invalidation statistics', () => { + const stats = service.getStats(); + + expect(stats).toHaveProperty('totalRules'); + expect(stats).toHaveProperty('activeRules'); + expect(stats).toHaveProperty('totalExecutions'); + expect(stats).toHaveProperty('successfulInvalidations'); + expect(stats).toHaveProperty('failedInvalidations'); + expect(stats).toHaveProperty('events'); + }); + }); + + describe('getRules', () => { + it('should return all rules', () => { + const rules = service.getRules(); + + expect(Array.isArray(rules)).toBe(true); + expect(rules.length).toBeGreaterThan(0); + }); + }); + + describe('getRule', () => { + it('should return a specific rule', () => { + const rule = service.getRule('rule-user-session-stale'); + + expect(rule).toBeDefined(); + expect(rule?.id).toBe('rule-user-session-stale'); + }); + + it('should return undefined for non-existent rule', () => { + const rule = service.getRule('non-existent'); + + expect(rule).toBeUndefined(); + }); + }); +}); diff --git a/test/common/cache-warming.service.spec.ts b/test/common/cache-warming.service.spec.ts new file mode 100644 index 00000000..6403a011 --- /dev/null +++ b/test/common/cache-warming.service.spec.ts @@ -0,0 +1,458 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { ConfigService } from '@nestjs/config'; +import { CacheWarmingService, WarmupTask, WarmupStrategy } from '../../src/common/cache/cache-warming.service'; +import { MultiLevelCacheService } from '../../src/common/cache/multi-level-cache.service'; + +describe('CacheWarmingService', () => { + let service: CacheWarmingService; + let cacheService: jest.Mocked; + let configService: jest.Mocked; + + beforeEach(async () => { + const mockCacheService = { + get: jest.fn(), + set: jest.fn(), + }; + + const mockConfigService = { + get: jest.fn().mockImplementation((key: string, defaultValue?: any) => defaultValue), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + CacheWarmingService, + { provide: MultiLevelCacheService, useValue: mockCacheService }, + { provide: ConfigService, useValue: mockConfigService }, + ], + }).compile(); + + service = module.get(CacheWarmingService); + cacheService = module.get(MultiLevelCacheService); + configService = module.get(ConfigService); + + await service.onModuleInit(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('onModuleInit', () => { + it('should initialize with default strategies', async () => { + const strategies = service.getStrategies(); + expect(strategies.length).toBeGreaterThan(0); + expect(strategies.some(s => s.name === 'user-data')).toBe(true); + expect(strategies.some(s => s.name === 'property-data')).toBe(true); + }); + }); + + describe('registerStrategy', () => { + it('should register a new strategy', () => { + const strategy: WarmupStrategy = { + name: 'test-strategy', + description: 'Test strategy', + tasks: [], + enabled: true, + }; + + service.registerStrategy(strategy); + + expect(service.getStrategy('test-strategy')).toEqual(strategy); + }); + }); + + describe('unregisterStrategy', () => { + it('should unregister a strategy', () => { + const strategy: WarmupStrategy = { + name: 'temp-strategy', + description: 'Temp strategy', + tasks: [], + enabled: true, + }; + + service.registerStrategy(strategy); + const result = service.unregisterStrategy('temp-strategy'); + + expect(result).toBe(true); + expect(service.getStrategy('temp-strategy')).toBeUndefined(); + }); + + it('should return false for non-existent strategy', () => { + const result = service.unregisterStrategy('non-existent'); + expect(result).toBe(false); + }); + }); + + describe('setStrategyEnabled', () => { + it('should enable/disable a strategy', () => { + const strategy: WarmupStrategy = { + name: 'toggle-strategy', + description: 'Toggle strategy', + tasks: [], + enabled: true, + }; + + service.registerStrategy(strategy); + + let result = service.setStrategyEnabled('toggle-strategy', false); + expect(result).toBe(true); + expect(service.getStrategy('toggle-strategy')?.enabled).toBe(false); + + result = service.setStrategyEnabled('toggle-strategy', true); + expect(result).toBe(true); + expect(service.getStrategy('toggle-strategy')?.enabled).toBe(true); + }); + + it('should return false for non-existent strategy', () => { + const result = service.setStrategyEnabled('non-existent', false); + expect(result).toBe(false); + }); + }); + + describe('executeStrategy', () => { + it('should execute a strategy and cache results', async () => { + const factory = jest.fn().mockResolvedValue({ data: 'test' }); + const strategy: WarmupStrategy = { + name: 'exec-strategy', + description: 'Exec strategy', + tasks: [ + { + key: 'test:key', + factory, + priority: 5, + }, + ], + enabled: true, + }; + + service.registerStrategy(strategy); + cacheService.get.mockResolvedValue(undefined); + + await service.executeStrategy('exec-strategy'); + + expect(factory).toHaveBeenCalled(); + expect(cacheService.set).toHaveBeenCalledWith( + 'test:key', + { data: 'test' }, + undefined, + ); + }); + + it('should skip already cached entries', async () => { + const factory = jest.fn().mockResolvedValue({ data: 'test' }); + const strategy: WarmupStrategy = { + name: 'skip-strategy', + description: 'Skip strategy', + tasks: [ + { + key: 'cached:key', + factory, + priority: 5, + }, + ], + enabled: true, + }; + + service.registerStrategy(strategy); + cacheService.get.mockResolvedValue({ data: 'existing' }); + + await service.executeStrategy('skip-strategy'); + + expect(factory).not.toHaveBeenCalled(); + expect(cacheService.set).not.toHaveBeenCalled(); + }); + + it('should skip disabled strategies', async () => { + const strategy: WarmupStrategy = { + name: 'disabled-strategy', + description: 'Disabled strategy', + tasks: [ + { + key: 'test:key', + factory: jest.fn(), + priority: 5, + }, + ], + enabled: false, + }; + + service.registerStrategy(strategy); + + await service.executeStrategy('disabled-strategy'); + + expect(cacheService.get).not.toHaveBeenCalled(); + }); + + it('should handle factory errors gracefully', async () => { + const factory = jest.fn().mockRejectedValue(new Error('Factory error')); + const strategy: WarmupStrategy = { + name: 'error-strategy', + description: 'Error strategy', + tasks: [ + { + key: 'error:key', + factory, + priority: 5, + }, + ], + enabled: true, + }; + + service.registerStrategy(strategy); + cacheService.get.mockResolvedValue(undefined); + + await service.executeStrategy('error-strategy'); + + expect(factory).toHaveBeenCalled(); + expect(cacheService.set).not.toHaveBeenCalled(); + }); + }); + + describe('executeAllStrategies', () => { + it('should execute all enabled strategies', async () => { + const mockSet = jest.fn(); + cacheService.set = mockSet; + + for (const s of service.getStrategies()) { + service.setStrategyEnabled(s.name, false); + } + + const strategy1: WarmupStrategy = { + name: 'strategy1', + description: 'Strategy 1', + tasks: [ + { + key: 'key1', + factory: jest.fn().mockResolvedValue('value1'), + priority: 5, + }, + ], + enabled: true, + }; + + const strategy2: WarmupStrategy = { + name: 'strategy2', + description: 'Strategy 2', + tasks: [ + { + key: 'key2', + factory: jest.fn().mockResolvedValue('value2'), + priority: 5, + }, + ], + enabled: true, + }; + + const disabledStrategy: WarmupStrategy = { + name: 'disabled', + description: 'Disabled', + tasks: [ + { + key: 'key3', + factory: jest.fn(), + priority: 5, + }, + ], + enabled: false, + }; + + service.registerStrategy(strategy1); + service.registerStrategy(strategy2); + service.registerStrategy(disabledStrategy); + + cacheService.get.mockResolvedValue(undefined); + + await service.executeAllStrategies(); + + expect(mockSet).toHaveBeenCalledTimes(2); + }); + }); + + describe('executeTask', () => { + it('should execute a single task', async () => { + const task: WarmupTask = { + key: 'single:key', + factory: jest.fn().mockResolvedValue({ data: 'test' }), + priority: 5, + }; + + cacheService.get.mockResolvedValue(undefined); + + const result = await service.executeTask(task); + + expect(result).toBe(true); + expect(cacheService.set).toHaveBeenCalled(); + }); + + it('should return false if condition not met', async () => { + const task: WarmupTask = { + key: 'conditional:key', + factory: jest.fn(), + priority: 5, + condition: () => false, + }; + + const result = await service.executeTask(task); + + expect(result).toBe(false); + expect(cacheService.set).not.toHaveBeenCalled(); + }); + + it('should return false if already cached', async () => { + const task: WarmupTask = { + key: 'cached:key', + factory: jest.fn(), + priority: 5, + }; + + cacheService.get.mockResolvedValue({ data: 'existing' }); + + const result = await service.executeTask(task); + + expect(result).toBe(false); + expect(cacheService.set).not.toHaveBeenCalled(); + }); + + it('should return false on factory error', async () => { + const task: WarmupTask = { + key: 'error:key', + factory: jest.fn().mockRejectedValue(new Error('Factory error')), + priority: 5, + }; + + cacheService.get.mockResolvedValue(undefined); + + const result = await service.executeTask(task); + + expect(result).toBe(false); + }); + }); + + describe('warmCache', () => { + it('should warm cache with multiple tasks', async () => { + const tasks: WarmupTask[] = [ + { + key: 'key1', + factory: jest.fn().mockResolvedValue('value1'), + priority: 5, + }, + { + key: 'key2', + factory: jest.fn().mockResolvedValue('value2'), + priority: 3, + }, + ]; + + cacheService.get.mockResolvedValue(undefined); + + const result = await service.warmCache(tasks); + + expect(result.completed).toBe(2); + expect(result.failed).toBe(0); + }); + + it('should sort tasks by priority', async () => { + const executionOrder: string[] = []; + + const tasks: WarmupTask[] = [ + { + key: 'low', + factory: jest.fn().mockImplementation(async () => { + executionOrder.push('low'); + return 'value'; + }), + priority: 1, + }, + { + key: 'high', + factory: jest.fn().mockImplementation(async () => { + executionOrder.push('high'); + return 'value'; + }), + priority: 10, + }, + ]; + + cacheService.get.mockResolvedValue(undefined); + + await service.warmCache(tasks); + + expect(executionOrder[0]).toBe('high'); + expect(executionOrder[1]).toBe('low'); + }); + }); + + describe('getStats', () => { + it('should return warming statistics', () => { + const stats = service.getStats(); + + expect(stats).toHaveProperty('totalTasks'); + expect(stats).toHaveProperty('completedTasks'); + expect(stats).toHaveProperty('failedTasks'); + expect(stats).toHaveProperty('skippedTasks'); + expect(stats).toHaveProperty('averageExecutionTime'); + }); + }); + + describe('resetStats', () => { + it('should reset all statistics', async () => { + const strategy: WarmupStrategy = { + name: 'stats-strategy', + description: 'Stats strategy', + tasks: [ + { + key: 'key1', + factory: jest.fn().mockResolvedValue('value'), + priority: 5, + }, + ], + enabled: true, + }; + + service.registerStrategy(strategy); + cacheService.get.mockResolvedValue(undefined); + + await service.executeStrategy('stats-strategy'); + + service.resetStats(); + + const stats = service.getStats(); + expect(stats.totalTasks).toBe(0); + expect(stats.completedTasks).toBe(0); + }); + }); + + describe('getStrategies', () => { + it('should return all registered strategies', () => { + const strategies = service.getStrategies(); + + expect(Array.isArray(strategies)).toBe(true); + expect(strategies.length).toBeGreaterThan(0); + }); + }); + + describe('getStrategy', () => { + it('should return a specific strategy', () => { + const strategy = service.getStrategy('user-data'); + + expect(strategy).toBeDefined(); + expect(strategy?.name).toBe('user-data'); + }); + + it('should return undefined for non-existent strategy', () => { + const strategy = service.getStrategy('non-existent'); + + expect(strategy).toBeUndefined(); + }); + }); + + describe('prewarmOnStartup', () => { + it('should prewarm critical strategies', async () => { + cacheService.get.mockResolvedValue(undefined); + + await service.prewarmOnStartup(); + + expect(cacheService.get).toHaveBeenCalled(); + }); + }); +}); diff --git a/test/common/multi-level-cache.service.spec.ts b/test/common/multi-level-cache.service.spec.ts new file mode 100644 index 00000000..25d5f1c8 --- /dev/null +++ b/test/common/multi-level-cache.service.spec.ts @@ -0,0 +1,350 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { ConfigService } from '@nestjs/config'; +import { MultiLevelCacheService, MultiLevelCacheOptions } from '../../src/common/cache/multi-level-cache.service'; +import { RedisService } from '../../src/common/services/redis.service'; + +describe('MultiLevelCacheService', () => { + let service: MultiLevelCacheService; + let redisService: jest.Mocked; + let configService: jest.Mocked; + + beforeEach(async () => { + const mockRedisService = { + get: jest.fn(), + setex: jest.fn(), + del: jest.fn(), + keys: jest.fn(), + sadd: jest.fn(), + smembers: jest.fn(), + flushdb: jest.fn(), + ttl: jest.fn(), + }; + + const mockConfigService = { + get: jest.fn((key: string, defaultValue?: any) => { + const config: Record = { + CACHE_L1_MAX_SIZE: 100, + CACHE_L1_TTL: 300, + CACHE_L2_TTL: 3600, + }; + return config[key] ?? defaultValue; + }), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + MultiLevelCacheService, + { provide: RedisService, useValue: mockRedisService }, + { provide: ConfigService, useValue: mockConfigService }, + ], + }).compile(); + + service = module.get(MultiLevelCacheService); + redisService = module.get(RedisService); + configService = module.get(ConfigService); + + await service.onModuleInit(); + }); + + afterEach(async () => { + await service.onModuleDestroy(); + jest.clearAllMocks(); + }); + + describe('get', () => { + it('should return value from L1 cache if present and not expired', async () => { + const key = 'test:key'; + const value = { data: 'test' }; + + await service.set(key, value); + + const result = await service.get(key); + + expect(result).toEqual(value); + expect(redisService.get).not.toHaveBeenCalled(); + }); + + it('should fetch from L2 cache if not in L1', async () => { + const key = 'test:key'; + const value = { data: 'test' }; + + redisService.get.mockResolvedValue(JSON.stringify({ + value, + tags: [], + version: 1, + timestamp: Date.now(), + })); + + const result = await service.get(key); + + expect(result).toEqual(value); + expect(redisService.get).toHaveBeenCalledWith(key); + }); + + it('should return undefined if not in any cache level', async () => { + const key = 'test:key'; + + redisService.get.mockResolvedValue(null); + + const result = await service.get(key); + + expect(result).toBeUndefined(); + }); + + it('should handle L2 cache errors gracefully', async () => { + const key = 'test:key'; + + redisService.get.mockRejectedValue(new Error('Redis error')); + + const result = await service.get(key); + + expect(result).toBeUndefined(); + }); + }); + + describe('set', () => { + it('should set value in both L1 and L2 cache', async () => { + const key = 'test:key'; + const value = { data: 'test' }; + const options: MultiLevelCacheOptions = { l1Ttl: 100, l2Ttl: 200 }; + + await service.set(key, value, options); + + const l1Result = await service.get(key); + expect(l1Result).toEqual(value); + + expect(redisService.setex).toHaveBeenCalledWith( + key, + 200, + expect.any(String), + ); + }); + + it('should use default TTLs when not specified', async () => { + const key = 'test:key'; + const value = { data: 'test' }; + + await service.set(key, value); + + expect(redisService.setex).toHaveBeenCalledWith( + key, + 3600, + expect.any(String), + ); + }); + + it('should store tags for invalidation', async () => { + const key = 'test:key'; + const value = { data: 'test' }; + const options: MultiLevelCacheOptions = { tags: ['tag1', 'tag2'] }; + + await service.set(key, value, options); + + expect(redisService.sadd).toHaveBeenCalledWith('tag:tag1', key); + expect(redisService.sadd).toHaveBeenCalledWith('tag:tag2', key); + }); + }); + + describe('del', () => { + it('should delete from both L1 and L2 cache', async () => { + const key = 'test:key'; + + await service.set(key, { data: 'test' }); + + await service.del(key); + + const l1Result = await service.get(key); + expect(l1Result).toBeUndefined(); + + expect(redisService.del).toHaveBeenCalledWith(key); + }); + }); + + describe('wrap', () => { + it('should return cached value if present', async () => { + const key = 'test:key'; + const value = { data: 'cached' }; + const factory = jest.fn().mockResolvedValue({ data: 'fresh' }); + + await service.set(key, value); + + const result = await service.wrap(key, factory); + + expect(result).toEqual(value); + expect(factory).not.toHaveBeenCalled(); + }); + + it('should call factory and cache result if not present', async () => { + const key = 'test:key'; + const value = { data: 'fresh' }; + const factory = jest.fn().mockResolvedValue(value); + + redisService.get.mockResolvedValue(null); + + const result = await service.wrap(key, factory); + + expect(result).toEqual(value); + expect(factory).toHaveBeenCalled(); + expect(redisService.setex).toHaveBeenCalled(); + }); + }); + + describe('invalidateByTag', () => { + it('should invalidate all entries with a given tag', async () => { + const tag = 'user'; + const keys = ['user:1', 'user:2']; + + redisService.smembers.mockResolvedValue(keys); + + const result = await service.invalidateByTag(tag); + + expect(result).toBe(2); + expect(redisService.del).toHaveBeenCalledWith('user:1'); + expect(redisService.del).toHaveBeenCalledWith('user:2'); + expect(redisService.del).toHaveBeenCalledWith(`tag:${tag}`); + }); + + it('should return 0 if no keys found for tag', async () => { + const tag = 'nonexistent'; + + redisService.smembers.mockResolvedValue([]); + + const result = await service.invalidateByTag(tag); + + expect(result).toBe(0); + }); + }); + + describe('invalidateByPattern', () => { + it('should invalidate entries matching pattern', async () => { + const pattern = 'user:*'; + const keys = ['user:1', 'user:2', 'user:3']; + + redisService.keys.mockResolvedValue(keys); + + const result = await service.invalidateByPattern(pattern); + + expect(result).toBe(3); + expect(redisService.keys).toHaveBeenCalledWith(pattern); + expect(redisService.del).toHaveBeenCalledTimes(3); + }); + + it('should handle L1 cache entries matching pattern', async () => { + const pattern = 'test:*'; + + await service.set('test:1', { data: 1 }); + await service.set('test:2', { data: 2 }); + await service.set('other:1', { data: 3 }); + + redisService.keys.mockResolvedValue([]); + + await service.invalidateByPattern(pattern); + + expect(await service.get('test:1')).toBeUndefined(); + expect(await service.get('test:2')).toBeUndefined(); + expect(await service.get('other:1')).toEqual({ data: 3 }); + }); + }); + + describe('getStats', () => { + it('should return cache statistics', () => { + const stats = service.getStats(); + + expect(stats).toHaveProperty('l1Hits'); + expect(stats).toHaveProperty('l1Misses'); + expect(stats).toHaveProperty('l2Hits'); + expect(stats).toHaveProperty('l2Misses'); + expect(stats).toHaveProperty('l1HitRate'); + expect(stats).toHaveProperty('l2HitRate'); + expect(stats).toHaveProperty('overallHitRate'); + }); + + it('should track hits and misses correctly', async () => { + const key = 'test:key'; + + service.resetStats(); + + redisService.get.mockResolvedValue(null); + await service.get(key); + + await service.set(key, { data: 'test' }); + + await service.get(key); + + const stats = service.getStats(); + expect(stats.l1Hits).toBe(1); + expect(stats.l1Misses).toBe(1); + }); + }); + + describe('resetStats', () => { + it('should reset all statistics', async () => { + await service.set('key1', 'value1'); + await service.get('key1'); + + service.resetStats(); + + const stats = service.getStats(); + expect(stats.l1Hits).toBe(0); + expect(stats.l1Misses).toBe(0); + expect(stats.l2Hits).toBe(0); + expect(stats.l2Misses).toBe(0); + }); + }); + + describe('clear', () => { + it('should clear both L1 and L2 cache', async () => { + await service.set('key1', 'value1'); + await service.set('key2', 'value2'); + + await service.clear(); + + expect(service.getL1Keys()).toHaveLength(0); + + expect(redisService.flushdb).toHaveBeenCalled(); + }); + }); + + describe('registerInvalidationPolicy', () => { + it('should register custom invalidation policy', () => { + const policy = { + type: 'pattern' as const, + value: 'custom:*', + cascade: true, + }; + + expect(() => service.registerInvalidationPolicy('custom', policy)).not.toThrow(); + }); + }); + + describe('incrementVersion', () => { + it('should increment version for existing entry', async () => { + const key = 'test:key'; + const value = { data: 'test' }; + + await service.set(key, value, { version: 1 }); + + redisService.get.mockResolvedValue(JSON.stringify({ + value, + tags: [], + version: 1, + timestamp: Date.now(), + })); + redisService.ttl.mockResolvedValue(3600); + + const newVersion = await service.incrementVersion(key); + + expect(newVersion).toBe(2); + }); + + it('should return 1 for non-existing entry', async () => { + const key = 'nonexistent:key'; + + redisService.get.mockResolvedValue(null); + + const version = await service.incrementVersion(key); + + expect(version).toBe(1); + }); + }); +});