Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions docs/ops/verified_enterprises.seed.json
Original file line number Diff line number Diff line change
@@ -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"
}
]
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,11 @@ class _MyReservationsPageState extends State<MyReservationsPage> {
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(
Expand Down
4 changes: 4 additions & 0 deletions render.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
114 changes: 114 additions & 0 deletions scripts/seed_verified_enterprises.js
Original file line number Diff line number Diff line change
@@ -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);
});
33 changes: 30 additions & 3 deletions server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand All @@ -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

Expand All @@ -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

Expand Down Expand Up @@ -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:
Expand Down
30 changes: 27 additions & 3 deletions server/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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) {
Expand All @@ -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');

Expand Down Expand Up @@ -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';
Expand Down
3 changes: 2 additions & 1 deletion server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading