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

@@ -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)
const tickerItems: TickerItem[] = [
{ 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: 'q7', label: 'Quận 7', changePercent: 3.2, direction: 'up' },
{ 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: '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 { useGenerateReport } from '@/lib/hooks/use-reports';
import type { ReportType } from '@/lib/reports-api';
import { HCM_DISTRICTS } from '@/lib/vietnam-geo';
// ─── 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ơ',
];
const 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 HCM_DISTRICTS_LIST = HCM_DISTRICTS;
const PROPERTY_TYPES = [
{ 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"
>
<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>
))}
</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"
>
<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>
))}
</select>

View File

@@ -14,15 +14,10 @@ import {
STATUS_LABELS,
} from '@/lib/chuyen-nhuong-api';
import { useTransferListingsSearch } from '@/lib/hooks/use-chuyen-nhuong';
import { HCM_DISTRICTS } from '@/lib/vietnam-geo';
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() {
const [filters, setFilters] = React.useState<SearchTransferListingsParams>({
page: 1,
@@ -93,7 +88,7 @@ export default function ChuyenNhuongPage() {
aria-label="Quận/Huyện"
>
<option value="">Quận/Huyện</option>
{DISTRICTS.map((d) => (
{HCM_DISTRICTS.map((d) => (
<option key={d} value={d}>{d}</option>
))}
</select>

View File

@@ -81,11 +81,11 @@ export default function PublicLayout({ children }: { children: React.ReactNode }
const tickerItems: TickerItem[] = [
{ 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: 'q7', label: 'Quận 7', changePercent: 3.2, direction: 'up' },
{ 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: '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 type { ListingDetail, PropertyType, TransactionType } from '@/lib/listings-api';
import { PROPERTY_TYPES, TRANSACTION_TYPES } from '@/lib/validations/listings';
import { HCM_DISTRICTS } from '@/lib/vietnam-geo';
// ---------------------------------------------------------------------------
// Hằng số
// ---------------------------------------------------------------------------
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',
'Bình Chánh', 'Hóc Môn', 'Củ Chi', 'Nhà Bè', 'Cần Giờ',
];
const DISTRICTS = HCM_DISTRICTS;
const PRICE_RANGES = [
{ 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",
"LAND": "Land",
"OFFICE": "Office",
"SHOPHOUSE": "Shophouse"
"SHOPHOUSE": "Shophouse",
"ROOM_RENTAL": "Room Rental",
"CONDOTEL": "Condotel",
"SERVICED_APARTMENT": "Serviced Apartment"
},
"transactionTypes": {
"SALE": "Sale",

View File

@@ -126,7 +126,10 @@
"VILLA": "Biệt thự",
"LAND": "Đất nền",
"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": {
"SALE": "Bán",