feat(listings): R2.3 featured listings entitlement + admin promote + search filter (TEC-2754)

- Add Plan.featuredListingsQuota (Int?) with per-tier seed (FREE=0, AGENT_PRO=5, INVESTOR=10, ENTERPRISE unlimited) and migration 20260418000000_add_featured_listings_quota
- Wire featured_listings_promoted metric into CheckQuotaHandler METRIC_TO_PLAN_FIELD so QuotaGuard honors the new quota
- Add PromoteFeaturedListingCommand + handler (entitlement-based, no payment): verifies ownership/agent, checks quota, extends featuredUntil, meters usage
- Add POST /listings/:id/promote endpoint gated by @RequireQuota('featured_listings_promoted') + QuotaGuard
- Add AdminFeatureListingCommand + handler with LISTING_FEATURED / LISTING_UNFEATURED audit log entries (new AdminAction enum values) and transactional write
- Add POST /admin/moderation/listings/:id/feature endpoint (ADMIN-only) with reason + duration
- Expose featured?: boolean filter on SearchPropertiesDto -> isFeatured:=1|0 Typesense filter in SearchPropertiesHandler
- Unit tests: 8 for PromoteFeaturedListingHandler, 6 for AdminFeatureListingHandler, 3 for search featured filter

Keeps existing pay-per-feature FeatureListingHandler intact for backward compatibility.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Ho Ngoc Hai
2026-04-18 15:18:04 +07:00
parent 580eb2a261
commit 5731577fa9
21 changed files with 755 additions and 13 deletions

View File

@@ -23,6 +23,7 @@ export const PLANS = [
maxSavedSearches: 5,
maxAnalyticsQueries: 0,
maxMediaUploads: 5,
featuredListingsQuota: 0,
features: {
basicSearch: true,
listingPost: true,
@@ -42,6 +43,7 @@ export const PLANS = [
maxSavedSearches: 30,
maxAnalyticsQueries: 100,
maxMediaUploads: 150,
featuredListingsQuota: 5,
features: {
basicSearch: true,
listingPost: true,
@@ -63,6 +65,7 @@ export const PLANS = [
maxSavedSearches: 100,
maxAnalyticsQueries: 500,
maxMediaUploads: 60,
featuredListingsQuota: 10,
features: {
basicSearch: true,
listingPost: true,
@@ -85,6 +88,7 @@ export const PLANS = [
maxSavedSearches: null,
maxAnalyticsQueries: null,
maxMediaUploads: null,
featuredListingsQuota: null,
features: {
basicSearch: true,
listingPost: true,
@@ -119,6 +123,7 @@ async function seedPlans() {
maxSavedSearches: plan.maxSavedSearches,
maxAnalyticsQueries: plan.maxAnalyticsQueries,
maxMediaUploads: plan.maxMediaUploads,
featuredListingsQuota: plan.featuredListingsQuota,
features: plan.features,
},
create: plan,