Skip to content

Commit b114a72

Browse files
authored
Merge pull request #18 from LEO0331/lc/dev/newfeatures
Lc/dev/newfeatures
2 parents ec6ffea + 2058a8e commit b114a72

File tree

7 files changed

+200
-7
lines changed

7 files changed

+200
-7
lines changed
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
[
2+
{
3+
"aliasNormalized": "acme booth a",
4+
"venueId": "taipei-nangang-exhibition-center-hall-1",
5+
"active": true,
6+
"reviewedBy": "ops-leo",
7+
"reviewedAt": "2026-04-02T00:00:00.000Z",
8+
"notes": "manual verification passed"
9+
},
10+
{
11+
"aliasNormalized": "demo drinks co",
12+
"venueId": "taipei-nangang-exhibition-center-hall-2",
13+
"active": true,
14+
"reviewedBy": "ops-leo",
15+
"reviewedAt": "2026-04-02T00:00:00.000Z",
16+
"notes": "event organizer confirmation"
17+
}
18+
]

lib/features/surplus/presentation/browse/my_reservations_page.dart

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,11 @@ class _MyReservationsPageState extends State<MyReservationsPage> {
9595
final s = AppStrings.of(context);
9696
return Scaffold(
9797
appBar: AppBar(
98+
leading: IconButton(
99+
onPressed: () => context.go('/'),
100+
tooltip: s.navListings,
101+
icon: const Icon(Icons.arrow_back),
102+
),
98103
title: Text(s.myReservationsTitle),
99104
actions: [
100105
IconButton(

render.yaml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,3 +15,7 @@ services:
1515
sync: false
1616
- key: FIREBASE_PRIVATE_KEY
1717
sync: false
18+
- key: UNVERIFIED_DAILY_LIMIT
19+
value: "10"
20+
- key: RECIPIENT_DAILY_RESERVATION_LIMIT
21+
value: "5"
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
#!/usr/bin/env node
2+
3+
const fs = require('fs');
4+
const path = require('path');
5+
const admin = require('firebase-admin');
6+
7+
function fail(message) {
8+
console.error(message);
9+
process.exit(1);
10+
}
11+
12+
function normalizeAlias(value) {
13+
return String(value || '').trim().toLowerCase();
14+
}
15+
16+
function initAdminFromEnv() {
17+
const projectId = process.env.FIREBASE_PROJECT_ID;
18+
const clientEmail = process.env.FIREBASE_CLIENT_EMAIL;
19+
const privateKey = process.env.FIREBASE_PRIVATE_KEY;
20+
21+
if (!projectId || !clientEmail || !privateKey) {
22+
fail(
23+
'Missing FIREBASE_PROJECT_ID / FIREBASE_CLIENT_EMAIL / FIREBASE_PRIVATE_KEY in environment.'
24+
);
25+
}
26+
27+
if (!admin.apps.length) {
28+
admin.initializeApp({
29+
credential: admin.credential.cert({
30+
projectId,
31+
clientEmail,
32+
privateKey: privateKey.replace(/\\n/g, '\n')
33+
})
34+
});
35+
}
36+
}
37+
38+
async function main() {
39+
const inputPath =
40+
process.argv[2] ||
41+
path.resolve(process.cwd(), 'docs/ops/verified_enterprises.seed.json');
42+
43+
if (!fs.existsSync(inputPath)) {
44+
fail(`Seed file not found: ${inputPath}`);
45+
}
46+
47+
const raw = fs.readFileSync(inputPath, 'utf8');
48+
let records;
49+
try {
50+
records = JSON.parse(raw);
51+
} catch (error) {
52+
fail(`Invalid JSON in ${inputPath}: ${error.message}`);
53+
}
54+
55+
if (!Array.isArray(records) || records.length === 0) {
56+
fail('Seed data must be a non-empty JSON array.');
57+
}
58+
59+
initAdminFromEnv();
60+
const db = admin.firestore();
61+
const collection = db.collection('verified_enterprises');
62+
const now = new Date();
63+
64+
let upserted = 0;
65+
for (const entry of records) {
66+
const aliasNormalized = normalizeAlias(entry.aliasNormalized);
67+
const venueId = String(entry.venueId || '').trim();
68+
const active = entry.active !== false;
69+
const reviewedBy = String(entry.reviewedBy || 'ops').trim();
70+
const notes = String(entry.notes || '').trim();
71+
const reviewedAtInput = entry.reviewedAt;
72+
const reviewedAt = reviewedAtInput ? new Date(reviewedAtInput) : now;
73+
74+
if (!aliasNormalized) {
75+
fail('Every record must include non-empty aliasNormalized.');
76+
}
77+
if (!venueId) {
78+
fail('Every record must include non-empty venueId.');
79+
}
80+
if (Number.isNaN(reviewedAt.getTime())) {
81+
fail(`Invalid reviewedAt datetime for alias: ${aliasNormalized}`);
82+
}
83+
84+
const docId = `${aliasNormalized}__${venueId}`.replace(/[^a-z0-9_\-:.]/g, '_');
85+
await collection.doc(docId).set(
86+
{
87+
aliasNormalized,
88+
venueId,
89+
active,
90+
reviewedBy,
91+
reviewedAt,
92+
notes,
93+
updatedAt: now,
94+
createdAt: now
95+
},
96+
{ merge: true }
97+
);
98+
upserted += 1;
99+
}
100+
101+
console.log(
102+
JSON.stringify({
103+
ok: true,
104+
collection: 'verified_enterprises',
105+
upserted,
106+
source: inputPath
107+
})
108+
);
109+
}
110+
111+
main().catch((error) => {
112+
console.error(error);
113+
process.exit(1);
114+
});

server/README.md

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,11 @@ Base URL (after deploy on Render):
4848
}
4949
```
5050

51+
Business rule:
52+
53+
- recipient daily reservation cap is enforced by `claimerUid` + UTC day.
54+
- when cap is hit, API returns `429` with code `RECIPIENT_DAILY_LIMIT_REACHED`.
55+
5156
### Create listing
5257

5358
```json
@@ -93,8 +98,6 @@ Base URL (after deploy on Render):
9398

9499
- Responses include `requestId` and `code` for tracing/debugging.
95100
- Server emits structured JSON logs (`http.request.completed` + endpoint failure events).
96-
- Reason taxonomy and log schema:
97-
- [docs/ops/logging-taxonomy.md](/Users/Leo/Documents/boxmatch/docs/ops/logging-taxonomy.md)
98101

99102
## KPI Tracking
100103

@@ -117,7 +120,7 @@ npm run export:kpi:7d
117120
npm run export:kpi:30d
118121
```
119122

120-
Exports are written to `/Users/Leo/Documents/boxmatch/reports`.
123+
Exports are written to `../Documents/boxmatch/reports`.
121124

122125
## Render environment variables needed
123126

@@ -126,6 +129,7 @@ Exports are written to `/Users/Leo/Documents/boxmatch/reports`.
126129
- `FIREBASE_PRIVATE_KEY` (use raw key with `\n` escaped)
127130
- `ENABLE_KPI_EVENT_LOGS` (optional; set `true` only when raw event audit is needed)
128131
- `UNVERIFIED_DAILY_LIMIT` (optional; default `5`)
132+
- `RECIPIENT_DAILY_RESERVATION_LIMIT` (optional; default `5`)
129133

130134
## GitHub Actions secret needed
131135

@@ -165,6 +169,29 @@ Rules:
165169
- `venueId` must match posting venue.
166170
- `active=true` means verified badge is granted.
167171

172+
### Seed template + one-click import
173+
174+
You do **not** need to manually create the `verified_enterprises` collection first.
175+
Running the script will auto-create collection/documents.
176+
177+
From repo root:
178+
179+
```bash
180+
export FIREBASE_PROJECT_ID="boxmatch-e2224"
181+
export FIREBASE_CLIENT_EMAIL="..."
182+
export FIREBASE_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----\n"
183+
184+
cd ../boxmatch/server
185+
npm run seed:verified
186+
```
187+
188+
Optional custom file path:
189+
190+
```bash
191+
cd ../boxmatch/server
192+
node ../scripts/seed_verified_enterprises.js /absolute/path/to/your.seed.json
193+
```
194+
168195
### 3) Runtime behavior
169196

170197
- Verified match:

server/index.js

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,10 @@ const UNVERIFIED_DAILY_LIMIT = Math.max(
4040
1,
4141
Number(process.env.UNVERIFIED_DAILY_LIMIT || 5)
4242
);
43+
const RECIPIENT_DAILY_RESERVATION_LIMIT = Math.max(
44+
1,
45+
Number(process.env.RECIPIENT_DAILY_RESERVATION_LIMIT || 5)
46+
);
4347

4448
function sha256(value) {
4549
return crypto.createHash('sha256').update(value, 'utf8').digest('hex');
@@ -552,6 +556,22 @@ app.post('/recipient/listings/:listingId/reserve', async (req, res) => {
552556
throw new Error('Idempotency key conflict.');
553557
}
554558

559+
const now = nowDate();
560+
const from = startOfDay(now);
561+
const to = endOfDay(now);
562+
const recipientDailySnap = await tx.get(
563+
db
564+
.collection(RESERVATIONS)
565+
.where('claimerUid', '==', claimerUid)
566+
.where('createdAt', '>=', from)
567+
.where('createdAt', '<', to)
568+
);
569+
if (recipientDailySnap.size >= RECIPIENT_DAILY_RESERVATION_LIMIT) {
570+
throw new Error(
571+
`Recipient daily reservation limit reached (${RECIPIENT_DAILY_RESERVATION_LIMIT}).`
572+
);
573+
}
574+
555575
const listingRef = db.collection(LISTINGS).doc(listingId);
556576
const listingSnap = await tx.get(listingRef);
557577
if (!listingSnap.exists) {
@@ -562,7 +582,6 @@ app.post('/recipient/listings/:listingId/reserve', async (req, res) => {
562582
const expiresAt = listing.expiresAt?.toDate
563583
? listing.expiresAt.toDate()
564584
: new Date(listing.expiresAt);
565-
const now = nowDate();
566585
const quantityRemaining = Number(listing.quantityRemaining || 0);
567586
const status = String(listing.status || 'active');
568587

@@ -664,12 +683,17 @@ app.post('/recipient/listings/:listingId/reserve', async (req, res) => {
664683
const status = [
665684
'Listing not found.',
666685
'This listing is no longer available.',
667-
'Idempotency key conflict.'
686+
'Idempotency key conflict.',
687+
`Recipient daily reservation limit reached (${RECIPIENT_DAILY_RESERVATION_LIMIT}).`
668688
].includes(message)
669-
? 400
689+
? message.startsWith('Recipient daily reservation limit reached')
690+
? 429
691+
: 400
670692
: 500;
671693
const reasonCode = message === 'Idempotency key conflict.'
672694
? 'IDEMPOTENCY_KEY_CONFLICT'
695+
: message.startsWith('Recipient daily reservation limit reached')
696+
? 'RECIPIENT_DAILY_LIMIT_REACHED'
673697
: status == 400
674698
? 'RESERVE_FAILED_BUSINESS_RULE'
675699
: 'RESERVE_FAILED_INTERNAL';

server/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@
1010
"start": "node index.js",
1111
"dev": "node index.js",
1212
"export:kpi:7d": "node ../scripts/export_kpi_csv.js 7",
13-
"export:kpi:30d": "node ../scripts/export_kpi_csv.js 30"
13+
"export:kpi:30d": "node ../scripts/export_kpi_csv.js 30",
14+
"seed:verified": "node ../scripts/seed_verified_enterprises.js"
1415
},
1516
"dependencies": {
1617
"cors": "^2.8.5",

0 commit comments

Comments
 (0)