diff --git a/docs/ops/verified_enterprises.seed.json b/docs/ops/verified_enterprises.seed.json new file mode 100644 index 0000000..a7803de --- /dev/null +++ b/docs/ops/verified_enterprises.seed.json @@ -0,0 +1,18 @@ +[ + { + "aliasNormalized": "acme booth a", + "venueId": "taipei-nangang-exhibition-center-hall-1", + "active": true, + "reviewedBy": "ops-leo", + "reviewedAt": "2026-04-02T00:00:00.000Z", + "notes": "manual verification passed" + }, + { + "aliasNormalized": "demo drinks co", + "venueId": "taipei-nangang-exhibition-center-hall-2", + "active": true, + "reviewedBy": "ops-leo", + "reviewedAt": "2026-04-02T00:00:00.000Z", + "notes": "event organizer confirmation" + } +] diff --git a/lib/features/surplus/presentation/browse/my_reservations_page.dart b/lib/features/surplus/presentation/browse/my_reservations_page.dart index 7a13f50..28d0f75 100644 --- a/lib/features/surplus/presentation/browse/my_reservations_page.dart +++ b/lib/features/surplus/presentation/browse/my_reservations_page.dart @@ -95,6 +95,11 @@ class _MyReservationsPageState extends State { final s = AppStrings.of(context); return Scaffold( appBar: AppBar( + leading: IconButton( + onPressed: () => context.go('/'), + tooltip: s.navListings, + icon: const Icon(Icons.arrow_back), + ), title: Text(s.myReservationsTitle), actions: [ IconButton( diff --git a/render.yaml b/render.yaml index 5373b57..73eec51 100644 --- a/render.yaml +++ b/render.yaml @@ -15,3 +15,7 @@ services: sync: false - key: FIREBASE_PRIVATE_KEY sync: false + - key: UNVERIFIED_DAILY_LIMIT + value: "10" + - key: RECIPIENT_DAILY_RESERVATION_LIMIT + value: "5" diff --git a/scripts/seed_verified_enterprises.js b/scripts/seed_verified_enterprises.js new file mode 100644 index 0000000..2500b0b --- /dev/null +++ b/scripts/seed_verified_enterprises.js @@ -0,0 +1,114 @@ +#!/usr/bin/env node + +const fs = require('fs'); +const path = require('path'); +const admin = require('firebase-admin'); + +function fail(message) { + console.error(message); + process.exit(1); +} + +function normalizeAlias(value) { + return String(value || '').trim().toLowerCase(); +} + +function initAdminFromEnv() { + const projectId = process.env.FIREBASE_PROJECT_ID; + const clientEmail = process.env.FIREBASE_CLIENT_EMAIL; + const privateKey = process.env.FIREBASE_PRIVATE_KEY; + + if (!projectId || !clientEmail || !privateKey) { + fail( + 'Missing FIREBASE_PROJECT_ID / FIREBASE_CLIENT_EMAIL / FIREBASE_PRIVATE_KEY in environment.' + ); + } + + if (!admin.apps.length) { + admin.initializeApp({ + credential: admin.credential.cert({ + projectId, + clientEmail, + privateKey: privateKey.replace(/\\n/g, '\n') + }) + }); + } +} + +async function main() { + const inputPath = + process.argv[2] || + path.resolve(process.cwd(), 'docs/ops/verified_enterprises.seed.json'); + + if (!fs.existsSync(inputPath)) { + fail(`Seed file not found: ${inputPath}`); + } + + const raw = fs.readFileSync(inputPath, 'utf8'); + let records; + try { + records = JSON.parse(raw); + } catch (error) { + fail(`Invalid JSON in ${inputPath}: ${error.message}`); + } + + if (!Array.isArray(records) || records.length === 0) { + fail('Seed data must be a non-empty JSON array.'); + } + + initAdminFromEnv(); + const db = admin.firestore(); + const collection = db.collection('verified_enterprises'); + const now = new Date(); + + let upserted = 0; + for (const entry of records) { + const aliasNormalized = normalizeAlias(entry.aliasNormalized); + const venueId = String(entry.venueId || '').trim(); + const active = entry.active !== false; + const reviewedBy = String(entry.reviewedBy || 'ops').trim(); + const notes = String(entry.notes || '').trim(); + const reviewedAtInput = entry.reviewedAt; + const reviewedAt = reviewedAtInput ? new Date(reviewedAtInput) : now; + + if (!aliasNormalized) { + fail('Every record must include non-empty aliasNormalized.'); + } + if (!venueId) { + fail('Every record must include non-empty venueId.'); + } + if (Number.isNaN(reviewedAt.getTime())) { + fail(`Invalid reviewedAt datetime for alias: ${aliasNormalized}`); + } + + const docId = `${aliasNormalized}__${venueId}`.replace(/[^a-z0-9_\-:.]/g, '_'); + await collection.doc(docId).set( + { + aliasNormalized, + venueId, + active, + reviewedBy, + reviewedAt, + notes, + updatedAt: now, + createdAt: now + }, + { merge: true } + ); + upserted += 1; + } + + console.log( + JSON.stringify({ + ok: true, + collection: 'verified_enterprises', + upserted, + source: inputPath + }) + ); +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/server/README.md b/server/README.md index 1f9d3c3..bee3274 100644 --- a/server/README.md +++ b/server/README.md @@ -48,6 +48,11 @@ Base URL (after deploy on Render): } ``` +Business rule: + +- recipient daily reservation cap is enforced by `claimerUid` + UTC day. +- when cap is hit, API returns `429` with code `RECIPIENT_DAILY_LIMIT_REACHED`. + ### Create listing ```json @@ -93,8 +98,6 @@ Base URL (after deploy on Render): - Responses include `requestId` and `code` for tracing/debugging. - Server emits structured JSON logs (`http.request.completed` + endpoint failure events). -- Reason taxonomy and log schema: - - [docs/ops/logging-taxonomy.md](/Users/Leo/Documents/boxmatch/docs/ops/logging-taxonomy.md) ## KPI Tracking @@ -117,7 +120,7 @@ npm run export:kpi:7d npm run export:kpi:30d ``` -Exports are written to `/Users/Leo/Documents/boxmatch/reports`. +Exports are written to `../Documents/boxmatch/reports`. ## Render environment variables needed @@ -126,6 +129,7 @@ Exports are written to `/Users/Leo/Documents/boxmatch/reports`. - `FIREBASE_PRIVATE_KEY` (use raw key with `\n` escaped) - `ENABLE_KPI_EVENT_LOGS` (optional; set `true` only when raw event audit is needed) - `UNVERIFIED_DAILY_LIMIT` (optional; default `5`) +- `RECIPIENT_DAILY_RESERVATION_LIMIT` (optional; default `5`) ## GitHub Actions secret needed @@ -165,6 +169,29 @@ Rules: - `venueId` must match posting venue. - `active=true` means verified badge is granted. +### Seed template + one-click import + +You do **not** need to manually create the `verified_enterprises` collection first. +Running the script will auto-create collection/documents. + +From repo root: + +```bash +export FIREBASE_PROJECT_ID="boxmatch-e2224" +export FIREBASE_CLIENT_EMAIL="..." +export FIREBASE_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----\n" + +cd ../boxmatch/server +npm run seed:verified +``` + +Optional custom file path: + +```bash +cd ../boxmatch/server +node ../scripts/seed_verified_enterprises.js /absolute/path/to/your.seed.json +``` + ### 3) Runtime behavior - Verified match: diff --git a/server/index.js b/server/index.js index e4ef71f..1d14486 100644 --- a/server/index.js +++ b/server/index.js @@ -40,6 +40,10 @@ const UNVERIFIED_DAILY_LIMIT = Math.max( 1, Number(process.env.UNVERIFIED_DAILY_LIMIT || 5) ); +const RECIPIENT_DAILY_RESERVATION_LIMIT = Math.max( + 1, + Number(process.env.RECIPIENT_DAILY_RESERVATION_LIMIT || 5) +); function sha256(value) { return crypto.createHash('sha256').update(value, 'utf8').digest('hex'); @@ -552,6 +556,22 @@ app.post('/recipient/listings/:listingId/reserve', async (req, res) => { throw new Error('Idempotency key conflict.'); } + const now = nowDate(); + const from = startOfDay(now); + const to = endOfDay(now); + const recipientDailySnap = await tx.get( + db + .collection(RESERVATIONS) + .where('claimerUid', '==', claimerUid) + .where('createdAt', '>=', from) + .where('createdAt', '<', to) + ); + if (recipientDailySnap.size >= RECIPIENT_DAILY_RESERVATION_LIMIT) { + throw new Error( + `Recipient daily reservation limit reached (${RECIPIENT_DAILY_RESERVATION_LIMIT}).` + ); + } + const listingRef = db.collection(LISTINGS).doc(listingId); const listingSnap = await tx.get(listingRef); if (!listingSnap.exists) { @@ -562,7 +582,6 @@ app.post('/recipient/listings/:listingId/reserve', async (req, res) => { const expiresAt = listing.expiresAt?.toDate ? listing.expiresAt.toDate() : new Date(listing.expiresAt); - const now = nowDate(); const quantityRemaining = Number(listing.quantityRemaining || 0); const status = String(listing.status || 'active'); @@ -664,12 +683,17 @@ app.post('/recipient/listings/:listingId/reserve', async (req, res) => { const status = [ 'Listing not found.', 'This listing is no longer available.', - 'Idempotency key conflict.' + 'Idempotency key conflict.', + `Recipient daily reservation limit reached (${RECIPIENT_DAILY_RESERVATION_LIMIT}).` ].includes(message) - ? 400 + ? message.startsWith('Recipient daily reservation limit reached') + ? 429 + : 400 : 500; const reasonCode = message === 'Idempotency key conflict.' ? 'IDEMPOTENCY_KEY_CONFLICT' + : message.startsWith('Recipient daily reservation limit reached') + ? 'RECIPIENT_DAILY_LIMIT_REACHED' : status == 400 ? 'RESERVE_FAILED_BUSINESS_RULE' : 'RESERVE_FAILED_INTERNAL'; diff --git a/server/package.json b/server/package.json index c159348..5628144 100644 --- a/server/package.json +++ b/server/package.json @@ -10,7 +10,8 @@ "start": "node index.js", "dev": "node index.js", "export:kpi:7d": "node ../scripts/export_kpi_csv.js 7", - "export:kpi:30d": "node ../scripts/export_kpi_csv.js 30" + "export:kpi:30d": "node ../scripts/export_kpi_csv.js 30", + "seed:verified": "node ../scripts/seed_verified_enterprises.js" }, "dependencies": { "cors": "^2.8.5",