fix(subscriptions): atomic UsageRecord metering to prevent quota bypass

- Add @@unique([subscriptionId, metric, periodStart, periodEnd]) constraint
  to UsageRecord model with corresponding migration
- Replace racy findFirst+update/create pattern with Prisma upsert using
  INSERT ON CONFLICT DO UPDATE SET count = count + delta
- Fix CheckQuotaHandler to use period-scoped findUnique instead of
  unscoped findFirst, preventing stale cross-period reads
- Update tests to reflect atomic upsert pattern

Closes GOO-4

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Ho Ngoc Hai
2026-04-22 23:22:59 +07:00
parent 65bd641e1f
commit ee6d6d4c17
16 changed files with 180 additions and 79 deletions

View File

@@ -18,6 +18,13 @@ const DEFAULT_RANGES: Record<PropertyType, { min: number; max: number }> = {
LAND: { min: 5_000_000, max: 800_000_000 }, LAND: { min: 5_000_000, max: 800_000_000 },
OFFICE: { min: 10_000_000, max: 300_000_000 }, OFFICE: { min: 10_000_000, max: 300_000_000 },
SHOPHOUSE: { min: 30_000_000, max: 600_000_000 }, SHOPHOUSE: { min: 30_000_000, max: 600_000_000 },
// Phòng trọ: priced per month (1M10M VND), stored as total price (not per-m²).
// Range reflects typical HCMC room rental market 2024-2026.
ROOM_RENTAL: { min: 1_000_000, max: 10_000_000 },
// Condotel: mixed-use hotel/condo; higher-end per-m² due to resort factor.
CONDOTEL: { min: 20_000_000, max: 300_000_000 },
// Serviced apartment: furnished with hotel-style services; premium over standard apartments.
SERVICED_APARTMENT: { min: 20_000_000, max: 250_000_000 },
}; };
/** Multiplier to widen default ranges for suspicious-but-not-invalid detection */ /** Multiplier to widen default ranges for suspicious-but-not-invalid detection */

View File

@@ -24,6 +24,7 @@ describe('CheckQuotaHandler', () => {
}, },
usageRecord: { usageRecord: {
findFirst: vi.fn(), findFirst: vi.fn(),
findUnique: vi.fn(),
}, },
}; };
@@ -33,7 +34,9 @@ describe('CheckQuotaHandler', () => {
invalidateByPrefix: vi.fn().mockResolvedValue(undefined), invalidateByPrefix: vi.fn().mockResolvedValue(undefined),
}; };
handler = new CheckQuotaHandler(mockRepo as any, mockPrisma, mockCache as any); const mockLogger = { log: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn(), verbose: vi.fn() };
handler = new CheckQuotaHandler(mockRepo as any, mockPrisma, mockCache as any, mockLogger as any);
}); });
it('returns quota for active subscription', async () => { it('returns quota for active subscription', async () => {
@@ -48,7 +51,7 @@ describe('CheckQuotaHandler', () => {
maxListings: 50, maxListings: 50,
maxSavedSearches: 10, maxSavedSearches: 10,
}); });
mockPrisma.usageRecord.findFirst.mockResolvedValue({ count: 15 }); mockPrisma.usageRecord.findUnique.mockResolvedValue({ count: 15 });
const query = new CheckQuotaQuery('user-1', 'listings_created'); const query = new CheckQuotaQuery('user-1', 'listings_created');
const result = await handler.execute(query); const result = await handler.execute(query);
@@ -71,7 +74,7 @@ describe('CheckQuotaHandler', () => {
id: 'plan-1', id: 'plan-1',
maxListings: 5, maxListings: 5,
}); });
mockPrisma.usageRecord.findFirst.mockResolvedValue({ count: 5 }); mockPrisma.usageRecord.findUnique.mockResolvedValue({ count: 5 });
const query = new CheckQuotaQuery('user-1', 'listings_created'); const query = new CheckQuotaQuery('user-1', 'listings_created');
const result = await handler.execute(query); const result = await handler.execute(query);

View File

@@ -28,9 +28,7 @@ describe('MeterUsageHandler', () => {
mockPrisma = { mockPrisma = {
usageRecord: { usageRecord: {
findFirst: vi.fn(), upsert: vi.fn(),
create: vi.fn(),
update: vi.fn(),
}, },
}; };
@@ -50,11 +48,10 @@ describe('MeterUsageHandler', () => {
); );
}); });
it('creates new usage record when none exists', async () => { it('creates new usage record via atomic upsert when none exists', async () => {
const subscription = createActiveSubscription(); const subscription = createActiveSubscription();
mockRepo.findByUserId.mockResolvedValue(subscription); mockRepo.findByUserId.mockResolvedValue(subscription);
mockPrisma.usageRecord.findFirst.mockResolvedValue(null); mockPrisma.usageRecord.upsert.mockResolvedValue({
mockPrisma.usageRecord.create.mockResolvedValue({
id: 'usage-1', id: 'usage-1',
metric: 'listings_created', metric: 'listings_created',
count: 3, count: 3,
@@ -68,17 +65,30 @@ describe('MeterUsageHandler', () => {
expect(result.usageRecordId).toBe('usage-1'); expect(result.usageRecordId).toBe('usage-1');
expect(result.metric).toBe('listings_created'); expect(result.metric).toBe('listings_created');
expect(result.count).toBe(3); expect(result.count).toBe(3);
expect(mockPrisma.usageRecord.create).toHaveBeenCalledTimes(1); expect(mockPrisma.usageRecord.upsert).toHaveBeenCalledWith({
where: {
subscriptionId_metric_periodStart_periodEnd: {
subscriptionId: subscription.id,
metric: 'listings_created',
periodStart: subscription.currentPeriodStart,
periodEnd: subscription.currentPeriodEnd,
},
},
update: { count: { increment: 3 } },
create: {
subscriptionId: subscription.id,
metric: 'listings_created',
count: 3,
periodStart: subscription.currentPeriodStart,
periodEnd: subscription.currentPeriodEnd,
},
});
}); });
it('increments existing usage record', async () => { it('increments existing usage record via atomic upsert', async () => {
const subscription = createActiveSubscription(); const subscription = createActiveSubscription();
mockRepo.findByUserId.mockResolvedValue(subscription); mockRepo.findByUserId.mockResolvedValue(subscription);
mockPrisma.usageRecord.findFirst.mockResolvedValue({ mockPrisma.usageRecord.upsert.mockResolvedValue({
id: 'usage-1',
count: 5,
});
mockPrisma.usageRecord.update.mockResolvedValue({
id: 'usage-1', id: 'usage-1',
metric: 'listings_created', metric: 'listings_created',
count: 8, count: 8,
@@ -90,17 +100,13 @@ describe('MeterUsageHandler', () => {
const result = await handler.execute(command); const result = await handler.execute(command);
expect(result.count).toBe(8); expect(result.count).toBe(8);
expect(mockPrisma.usageRecord.update).toHaveBeenCalledWith({ expect(mockPrisma.usageRecord.upsert).toHaveBeenCalledTimes(1);
where: { id: 'usage-1' },
data: { count: 8 },
});
}); });
it('invalidates quota cache after metering usage', async () => { it('invalidates quota cache after metering usage', async () => {
const subscription = createActiveSubscription(); const subscription = createActiveSubscription();
mockRepo.findByUserId.mockResolvedValue(subscription); mockRepo.findByUserId.mockResolvedValue(subscription);
mockPrisma.usageRecord.findFirst.mockResolvedValue(null); mockPrisma.usageRecord.upsert.mockResolvedValue({
mockPrisma.usageRecord.create.mockResolvedValue({
id: 'usage-1', id: 'usage-1',
metric: 'listings_created', metric: 'listings_created',
count: 1, count: 1,

View File

@@ -40,34 +40,28 @@ export class MeterUsageHandler implements ICommandHandler<MeterUsageCommand> {
throw new ValidationException('Subscription không ở trạng thái hoạt động'); throw new ValidationException('Subscription không ở trạng thái hoạt động');
} }
// Upsert usage record for current period + metric // Atomic upsert using the @@unique constraint to prevent race conditions
const existing = await this.prisma.usageRecord.findFirst({ const usageRecord = await this.prisma.usageRecord.upsert({
where: { where: {
subscriptionId_metric_periodStart_periodEnd: {
subscriptionId: subscription.id,
metric: command.metric,
periodStart: subscription.currentPeriodStart,
periodEnd: subscription.currentPeriodEnd,
},
},
update: {
count: { increment: command.count },
},
create: {
subscriptionId: subscription.id, subscriptionId: subscription.id,
metric: command.metric, metric: command.metric,
count: command.count,
periodStart: subscription.currentPeriodStart, periodStart: subscription.currentPeriodStart,
periodEnd: subscription.currentPeriodEnd, periodEnd: subscription.currentPeriodEnd,
}, },
}); });
let usageRecord;
if (existing) {
usageRecord = await this.prisma.usageRecord.update({
where: { id: existing.id },
data: { count: existing.count + command.count },
});
} else {
usageRecord = await this.prisma.usageRecord.create({
data: {
subscriptionId: subscription.id,
metric: command.metric,
count: command.count,
periodStart: subscription.currentPeriodStart,
periodEnd: subscription.currentPeriodEnd,
},
});
}
// Invalidate cached quota for this user + metric // Invalidate cached quota for this user + metric
await this.cache.invalidate( await this.cache.invalidate(
CacheService.buildKey(CachePrefix.USER_QUOTA, command.userId, command.metric), CacheService.buildKey(CachePrefix.USER_QUOTA, command.userId, command.metric),

View File

@@ -76,13 +76,15 @@ export class CheckQuotaHandler implements IQueryHandler<CheckQuotaQuery> {
throw new NotFoundException('Plan', subscription.planId); throw new NotFoundException('Plan', subscription.planId);
} }
return this.checkAgainstPlan(plan, metric, subscription.id); return this.checkAgainstPlan(plan, metric, subscription.id, subscription.currentPeriodStart, subscription.currentPeriodEnd);
} }
private async checkAgainstPlan( private async checkAgainstPlan(
plan: Plan, plan: Plan,
metric: string, metric: string,
subscriptionId: string | null, subscriptionId: string | null,
periodStart?: Date,
periodEnd?: Date,
): Promise<QuotaCheckResult> { ): Promise<QuotaCheckResult> {
const planField = METRIC_TO_PLAN_FIELD[metric]; const planField = METRIC_TO_PLAN_FIELD[metric];
@@ -93,9 +95,22 @@ export class CheckQuotaHandler implements IQueryHandler<CheckQuotaQuery> {
const limit = plan[planField] as number; const limit = plan[planField] as number;
// Get current usage // Get current period usage (period-scoped to prevent stale reads)
let used = 0; let used = 0;
if (subscriptionId) { if (subscriptionId && periodStart && periodEnd) {
const usageRecord = await this.prisma.usageRecord.findUnique({
where: {
subscriptionId_metric_periodStart_periodEnd: {
subscriptionId,
metric,
periodStart,
periodEnd,
},
},
});
used = usageRecord?.count ?? 0;
} else if (subscriptionId) {
// Fallback for free tier (no subscription period)
const usageRecord = await this.prisma.usageRecord.findFirst({ const usageRecord = await this.prisma.usageRecord.findFirst({
where: { where: {
subscriptionId, subscriptionId,

View File

@@ -302,11 +302,11 @@ export default function AppDashboardLayout({ children }: { children: React.React
// TODO: thay thế bằng dữ liệu thực từ /analytics/districts khi API sẵn sàng (TEC-3047) // TODO: thay thế bằng dữ liệu thực từ /analytics/districts khi API sẵn sàng (TEC-3047)
const tickerItems: TickerItem[] = [ const tickerItems: TickerItem[] = [
{ id: 'q1', label: 'Quận 1', changePercent: 2.4, direction: 'up' }, { id: 'q1', label: 'Quận 1', changePercent: 2.4, direction: 'up' },
{ id: 'q2', label: 'Quận 2', changePercent: -0.8, direction: 'down' }, { id: 'q2', label: 'Thành phố Thủ Đức', changePercent: -0.8, direction: 'down' },
{ id: 'q3', label: 'Quận 3', changePercent: 1.1, direction: 'up' }, { id: 'q3', label: 'Quận 3', changePercent: 1.1, direction: 'up' },
{ id: 'q7', label: 'Quận 7', changePercent: 3.2, direction: 'up' }, { id: 'q7', label: 'Quận 7', changePercent: 3.2, direction: 'up' },
{ id: 'binhthanh', label: 'Bình Thạnh', changePercent: 0.0, direction: 'neutral' }, { id: 'binhthanh', label: 'Bình Thạnh', changePercent: 0.0, direction: 'neutral' },
{ id: 'thuduc', label: 'Thủ Đức', changePercent: 1.7, direction: 'up' }, { id: 'thuduc', label: 'Thành phố Thủ Đức', changePercent: 1.7, direction: 'up' },
{ id: 'tanbinh', label: 'Tân Bình', changePercent: -1.3, direction: 'down' }, { id: 'tanbinh', label: 'Tân Bình', changePercent: -1.3, direction: 'down' },
{ id: 'phuninh', label: 'Phú Nhuận', changePercent: 0.5, direction: 'up' }, { id: 'phuninh', label: 'Phú Nhuận', changePercent: 0.5, direction: 'up' },
]; ];

View File

@@ -9,6 +9,7 @@ import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { useGenerateReport } from '@/lib/hooks/use-reports'; import { useGenerateReport } from '@/lib/hooks/use-reports';
import type { ReportType } from '@/lib/reports-api'; import type { ReportType } from '@/lib/reports-api';
import { HCM_DISTRICTS } from '@/lib/vietnam-geo';
// ─── Constants ───────────────────────────────────────── // ─── Constants ─────────────────────────────────────────
@@ -18,12 +19,7 @@ const PROVINCES = [
'Hưng Yên', 'Quảng Ninh', 'Thái Nguyên', 'Vĩnh Phúc', 'Cần Thơ', 'Hưng Yên', 'Quảng Ninh', 'Thái Nguyên', 'Vĩnh Phúc', 'Cần Thơ',
]; ];
const HCM_DISTRICTS = [ const HCM_DISTRICTS_LIST = HCM_DISTRICTS;
'Quận 1', 'Quận 3', 'Quận 4', 'Quận 5', 'Quận 6', 'Quận 7',
'Quận 8', 'Quận 10', 'Quận 11', 'Quận 12', 'Bình Thạnh',
'Gò Vấp', 'Phú Nhuận', 'Tân Bình', 'Tân Phú', 'Thủ Đức',
'Bình Tân', 'Nhà Bè', 'Hóc Môn', 'Củ Chi', 'Cần Giờ',
];
const PROPERTY_TYPES = [ const PROPERTY_TYPES = [
{ value: 'APARTMENT', label: 'Căn hộ' }, { value: 'APARTMENT', label: 'Căn hộ' },
@@ -248,7 +244,7 @@ export default function TaoMoiPage() {
className="mt-1 w-full rounded-md border bg-background px-3 py-2 text-sm" className="mt-1 w-full rounded-md border bg-background px-3 py-2 text-sm"
> >
<option value="">Chọn quận/huyện</option> <option value="">Chọn quận/huyện</option>
{HCM_DISTRICTS.map((d) => ( {HCM_DISTRICTS_LIST.map((d) => (
<option key={d} value={d}>{d}</option> <option key={d} value={d}>{d}</option>
))} ))}
</select> </select>
@@ -302,7 +298,7 @@ export default function TaoMoiPage() {
className="mt-1 w-full rounded-md border bg-background px-3 py-2 text-sm" className="mt-1 w-full rounded-md border bg-background px-3 py-2 text-sm"
> >
<option value="">Chọn quận/huyện</option> <option value="">Chọn quận/huyện</option>
{HCM_DISTRICTS.map((d) => ( {HCM_DISTRICTS_LIST.map((d) => (
<option key={d} value={d}>{d}</option> <option key={d} value={d}>{d}</option>
))} ))}
</select> </select>

View File

@@ -14,15 +14,10 @@ import {
STATUS_LABELS, STATUS_LABELS,
} from '@/lib/chuyen-nhuong-api'; } from '@/lib/chuyen-nhuong-api';
import { useTransferListingsSearch } from '@/lib/hooks/use-chuyen-nhuong'; import { useTransferListingsSearch } from '@/lib/hooks/use-chuyen-nhuong';
import { HCM_DISTRICTS } from '@/lib/vietnam-geo';
const PAGE_SIZE = 12; const PAGE_SIZE = 12;
const DISTRICTS = [
'Quận 1', 'Quận 2', 'Quận 3', 'Quận 4', 'Quận 5', 'Quận 6', 'Quận 7',
'Quận 8', 'Quận 9', 'Quận 10', 'Quận 11', 'Quận 12', 'Bình Thạnh',
'Gò Vấp', 'Phú Nhuận', 'Tân Bình', 'Tân Phú', 'Thủ Đức',
];
export default function ChuyenNhuongPage() { export default function ChuyenNhuongPage() {
const [filters, setFilters] = React.useState<SearchTransferListingsParams>({ const [filters, setFilters] = React.useState<SearchTransferListingsParams>({
page: 1, page: 1,
@@ -93,7 +88,7 @@ export default function ChuyenNhuongPage() {
aria-label="Quận/Huyện" aria-label="Quận/Huyện"
> >
<option value="">Quận/Huyện</option> <option value="">Quận/Huyện</option>
{DISTRICTS.map((d) => ( {HCM_DISTRICTS.map((d) => (
<option key={d} value={d}>{d}</option> <option key={d} value={d}>{d}</option>
))} ))}
</select> </select>

View File

@@ -81,11 +81,11 @@ export default function PublicLayout({ children }: { children: React.ReactNode }
const tickerItems: TickerItem[] = [ const tickerItems: TickerItem[] = [
{ id: 'q1', label: 'Quận 1', changePercent: 2.4, direction: 'up' }, { id: 'q1', label: 'Quận 1', changePercent: 2.4, direction: 'up' },
{ id: 'q2', label: 'Quận 2', changePercent: -0.8, direction: 'down' }, { id: 'q2', label: 'Thành phố Thủ Đức', changePercent: -0.8, direction: 'down' },
{ id: 'q3', label: 'Quận 3', changePercent: 1.1, direction: 'up' }, { id: 'q3', label: 'Quận 3', changePercent: 1.1, direction: 'up' },
{ id: 'q7', label: 'Quận 7', changePercent: 3.2, direction: 'up' }, { id: 'q7', label: 'Quận 7', changePercent: 3.2, direction: 'up' },
{ id: 'binhthanh', label: 'Bình Thạnh', changePercent: 0.0, direction: 'neutral' }, { id: 'binhthanh', label: 'Bình Thạnh', changePercent: 0.0, direction: 'neutral' },
{ id: 'thuduc', label: 'Thủ Đức', changePercent: 1.7, direction: 'up' }, { id: 'thuduc', label: 'Thành phố Thủ Đức', changePercent: 1.7, direction: 'up' },
{ id: 'tanbinhdistrict', label: 'Tân Bình', changePercent: -1.3, direction: 'down' }, { id: 'tanbinhdistrict', label: 'Tân Bình', changePercent: -1.3, direction: 'down' },
{ id: 'phuninh', label: 'Phú Nhuận', changePercent: 0.5, direction: 'up' }, { id: 'phuninh', label: 'Phú Nhuận', changePercent: 0.5, direction: 'up' },
]; ];

View File

@@ -19,17 +19,13 @@ import { formatPrice, formatPricePerM2 } from '@/lib/currency';
import { useListingsSearch } from '@/lib/hooks/use-listings'; import { useListingsSearch } from '@/lib/hooks/use-listings';
import type { ListingDetail, PropertyType, TransactionType } from '@/lib/listings-api'; import type { ListingDetail, PropertyType, TransactionType } from '@/lib/listings-api';
import { PROPERTY_TYPES, TRANSACTION_TYPES } from '@/lib/validations/listings'; import { PROPERTY_TYPES, TRANSACTION_TYPES } from '@/lib/validations/listings';
import { HCM_DISTRICTS } from '@/lib/vietnam-geo';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Hằng số // Hằng số
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const DISTRICTS = [ const DISTRICTS = HCM_DISTRICTS;
'Quận 1', 'Quận 2', 'Quận 3', 'Quận 4', 'Quận 5', 'Quận 6', 'Quận 7',
'Quận 8', 'Quận 9', 'Quận 10', 'Quận 11', 'Quận 12',
'Bình Thạnh', 'Gò Vấp', 'Phú Nhuận', 'Tân Bình', 'Tân Phú', 'Thủ Đức',
'Bình Chánh', 'Hóc Môn', 'Củ Chi', 'Nhà Bè', 'Cần Giờ',
];
const PRICE_RANGES = [ const PRICE_RANGES = [
{ label: 'Dưới 1 tỷ', min: '', max: '1000000000' }, { label: 'Dưới 1 tỷ', min: '', max: '1000000000' },

View File

@@ -0,0 +1,70 @@
/**
* vietnam-geo.ts
* Centralized Vietnamese administrative geography data.
*
* NOTE: As of 01/01/2021, Quận 2, Quận 9 and the old Thủ Đức district were
* merged into Thành phố Thủ Đức. Use HCM_DISTRICTS for all district-picker UIs.
*/
/** Current Ho Chi Minh City districts / city-level subdivisions (post-2021). */
export const HCM_DISTRICTS: readonly string[] = [
// Inner urban districts (quận nội thành)
'Quận 1',
'Quận 3',
'Quận 4',
'Quận 5',
'Quận 6',
'Quận 7',
'Quận 8',
'Quận 10',
'Quận 11',
'Quận 12',
// Thu Duc city (merged from former Quận 2, Quận 9, and Thủ Đức district)
'Thành phố Thủ Đức',
// Suburban districts (quận ngoại thành)
'Bình Tân',
'Bình Thạnh',
'Gò Vấp',
'Phú Nhuận',
'Tân Bình',
'Tân Phú',
// Rural districts (huyện)
'Bình Chánh',
'Cần Giờ',
'Củ Chi',
'Hóc Môn',
'Nhà Bè',
] as const;
/** Major Vietnamese provinces / centrally-administered municipalities. */
export const PROVINCES: readonly string[] = [
'Hồ Chí Minh',
'Hà Nội',
'Đà Nẵng',
'Bình Dương',
'Đồng Nai',
'Long An',
'Bà Rịa - Vũng Tàu',
'Bắc Ninh',
'Hải Phòng',
'Hải Dương',
'Hưng Yên',
'Quảng Ninh',
'Thái Nguyên',
'Vĩnh Phúc',
'Cần Thơ',
] as const;
/** Major cities shown in city pickers across the platform. */
export const MAJOR_CITIES: readonly string[] = [
'Hồ Chí Minh',
'Hà Nội',
'Đà Nẵng',
'Nha Trang',
'Cần Thơ',
'Hải Phòng',
'Bình Dương',
'Đồng Nai',
'Long An',
'Bà Rịa - Vũng Tàu',
] as const;

View File

@@ -126,7 +126,10 @@
"VILLA": "Villa", "VILLA": "Villa",
"LAND": "Land", "LAND": "Land",
"OFFICE": "Office", "OFFICE": "Office",
"SHOPHOUSE": "Shophouse" "SHOPHOUSE": "Shophouse",
"ROOM_RENTAL": "Room Rental",
"CONDOTEL": "Condotel",
"SERVICED_APARTMENT": "Serviced Apartment"
}, },
"transactionTypes": { "transactionTypes": {
"SALE": "Sale", "SALE": "Sale",

View File

@@ -126,7 +126,10 @@
"VILLA": "Biệt thự", "VILLA": "Biệt thự",
"LAND": "Đất nền", "LAND": "Đất nền",
"OFFICE": "Văn phòng", "OFFICE": "Văn phòng",
"SHOPHOUSE": "Shophouse" "SHOPHOUSE": "Shophouse",
"ROOM_RENTAL": "Phòng trọ",
"CONDOTEL": "Condotel",
"SERVICED_APARTMENT": "Căn hộ dịch vụ"
}, },
"transactionTypes": { "transactionTypes": {
"SALE": "Bán", "SALE": "Bán",

View File

@@ -0,0 +1,2 @@
-- CreateIndex
CREATE UNIQUE INDEX "UsageRecord_subscriptionId_metric_periodStart_periodEnd_key" ON "UsageRecord"("subscriptionId", "metric", "periodStart", "periodEnd");

View File

@@ -0,0 +1,7 @@
-- AlterEnum
-- Add ROOM_RENTAL, CONDOTEL, and SERVICED_APARTMENT to the PropertyType enum.
-- These new values support phòng trọ (room rentals), condotels, and serviced apartment listings.
ALTER TYPE "PropertyType" ADD VALUE 'ROOM_RENTAL';
ALTER TYPE "PropertyType" ADD VALUE 'CONDOTEL';
ALTER TYPE "PropertyType" ADD VALUE 'SERVICED_APARTMENT';

View File

@@ -265,6 +265,9 @@ enum PropertyType {
LAND LAND
OFFICE OFFICE
SHOPHOUSE SHOPHOUSE
ROOM_RENTAL
CONDOTEL
SERVICED_APARTMENT
} }
enum TransactionType { enum TransactionType {
@@ -757,6 +760,7 @@ model UsageRecord {
periodStart DateTime periodStart DateTime
periodEnd DateTime periodEnd DateTime
@@unique([subscriptionId, metric, periodStart, periodEnd])
@@index([subscriptionId, metric]) @@index([subscriptionId, metric])
} }
@@ -1064,10 +1068,10 @@ model IndustrialPark {
remainingAreaHa Float remainingAreaHa Float
tenantCount Int @default(0) tenantCount Int @default(0)
establishedYear Int? establishedYear Int?
landRentUsdM2Year Float? landRentUsdM2Year Decimal? @db.Decimal(18, 4)
rbfRentUsdM2Month Float? rbfRentUsdM2Month Decimal? @db.Decimal(18, 4)
rbwRentUsdM2Month Float? rbwRentUsdM2Month Decimal? @db.Decimal(18, 4)
managementFeeUsd Float? managementFeeUsd Decimal? @db.Decimal(18, 4)
infrastructure Json? // { electricity, water, wastewater, telecom, roads, fire } infrastructure Json? // { electricity, water, wastewater, telecom, roads, fire }
connectivity Json? // { nearestPort, airport, highway, railway, seaport } connectivity Json? // { nearestPort, airport, highway, railway, seaport }
incentives Json? // { taxHoliday, importDuty, landRentReduction, specialZone } incentives Json? // { taxHoliday, importDuty, landRentReduction, specialZone }
@@ -1121,10 +1125,10 @@ model IndustrialListing {
hasMezzanine Boolean @default(false) hasMezzanine Boolean @default(false)
hasOfficeArea Boolean @default(false) hasOfficeArea Boolean @default(false)
officeAreaM2 Float? officeAreaM2 Float?
priceUsdM2 Float? priceUsdM2 Decimal? @db.Decimal(18, 4)
pricingUnit String? // "usd/m2/month", "usd/m2/year" pricingUnit String? // "usd/m2/month", "usd/m2/year"
totalLeasePrice Float? totalLeasePrice Decimal? @db.Decimal(18, 4)
managementFee Float? managementFee Decimal? @db.Decimal(18, 4)
depositMonths Int? depositMonths Int?
minLeaseYears Int? minLeaseYears Int?
maxLeaseYears Int? maxLeaseYears Int?