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:
@@ -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 (1M–10M 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 */
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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' },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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' },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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' },
|
||||||
|
|||||||
70
apps/web/lib/vietnam-geo.ts
Normal file
70
apps/web/lib/vietnam-geo.ts
Normal 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;
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "UsageRecord_subscriptionId_metric_periodStart_periodEnd_key" ON "UsageRecord"("subscriptionId", "metric", "periodStart", "periodEnd");
|
||||||
@@ -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';
|
||||||
@@ -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?
|
||||||
|
|||||||
Reference in New Issue
Block a user