Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
86f863a
fix(api): eliminate SSRF in OCR image fetch by accepting base64 only
Shinitaii Jul 12, 2026
fccf06c
fix(ui): switch OCR photo uploads to base64, drop photo persistence
Shinitaii Jul 12, 2026
dc9b38f
fix(mobile): drop photo persistence for meter-reading OCR
Shinitaii Jul 12, 2026
d775059
fix(api): create users entirely server-side to stop admin session hijack
Shinitaii Jul 12, 2026
1274973
fix(ui): switch user creation to the server-side POST /users flow
Shinitaii Jul 12, 2026
c6a9f0a
docs: document the OCR SSRF fix and server-side user creation
Shinitaii Jul 12, 2026
02972b4
refactor(api): consolidate cascade-delete transaction logic into shar…
Shinitaii Jul 12, 2026
bbab993
refactor(api): extract CRUD helper factories to cut service boilerplate
Shinitaii Jul 12, 2026
0774624
refactor(api): remove per-handler auth guards already enforced by mid…
Shinitaii Jul 12, 2026
2b7ed3b
refactor(mobile): remove dead API/util exports with no call sites
Shinitaii Jul 12, 2026
1998407
refactor(mobile): add sessionCache.getOrFetch helpers to dedupe cache…
Shinitaii Jul 12, 2026
da693a1
refactor(mobile): extract readings-wizard meter-group lookup helper
Shinitaii Jul 12, 2026
09209f5
refactor(mobile): simplify auth error mapping to a lookup table
Shinitaii Jul 12, 2026
85bab61
refactor(ui): extract shared archive-page state, dedupe archive pages
Shinitaii Jul 12, 2026
2e4df36
refactor(ui): factor out createListStore for meter-group/property/ten…
Shinitaii Jul 12, 2026
f31d6a1
refactor(ui): dedupe true-reading calculation in billings page
Shinitaii Jul 12, 2026
04c0a0e
refactor(ui): add formatFirestoreDate helper and adopt across pages
Shinitaii Jul 12, 2026
9166edd
fix(mobile): enforce Android cleartext-traffic config automatically
Shinitaii Jul 12, 2026
26417e0
lint: fix lint errors
Shinitaii Jul 13, 2026
be8cca7
fix(ui): show raw meter readings instead of cumulative true total
Shinitaii Jul 18, 2026
c023c14
Merge branch 'chore/simplify'
Shinitaii Jul 18, 2026
4b8b4e2
feat(billing): denormalize meter group & billing period, add scoped f…
Shinitaii Jul 18, 2026
b626625
chore(billing): add one-time meter-group/period-date backfill script
Shinitaii Jul 18, 2026
4fab1da
docs: correct meter-version migration status (main meters stay on Met…
Shinitaii Jul 18, 2026
2c828d6
chore: gitignore generated backfill report artifact
Shinitaii Jul 18, 2026
3137780
fix(api): correct date-range filters on billing & billing-cycle list …
Shinitaii Jul 19, 2026
9daa572
feat(ui): scope billings page & dashboard fetches (Landing 2)
Shinitaii Jul 19, 2026
fe69a2f
feat(user): disable account creation while single-tenant
Shinitaii Jul 19, 2026
092b6f2
feat(reports): combined endpoint, role gate, shared join cache, full …
Shinitaii Jul 19, 2026
0bc98c5
fix(api): rate-limit vision OCR endpoints per user instead of per-IP
Shinitaii Jul 19, 2026
352bdee
fix(billing): keep list cache in sync on single billing create
Shinitaii Jul 19, 2026
3bbc1b1
fix(ui): sync mark-as-paid cache, fix date-range and money-math bugs
Shinitaii Jul 19, 2026
faf0b69
refactor(api,ui): batch-get lookups, repoFor()/date-range-filter help…
Shinitaii Jul 19, 2026
b870cf0
refactor(ui): extract shared toQueryString() helper for API modules
Shinitaii Jul 19, 2026
2a01b06
chore: add root .env
Shinitaii Jul 19, 2026
dbb490b
lint(ui): fix ui/ with prettier
Shinitaii Jul 19, 2026
a1c3610
chore(api): update api packages
Shinitaii Jul 19, 2026
bc6e9b9
chore(ui): update ui packages
Shinitaii Jul 19, 2026
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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,5 @@ decisions/

# Personal Claude Code hook config (machine-specific absolute paths)
.claude/settings.local.json

.env
9 changes: 4 additions & 5 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -229,7 +229,7 @@ Each page/component is organized by:

### API Features (Complete + Audited May 2026)

- ✅ Meter Groups (CRUD, batch; dynamic sorting; `POST /:id/reset` and its `current_version`/`versions` fields are **@deprecated** — version tracking now lives per-property on `Property.meter_groups[entry]`, see `decisions/`)
- ✅ Meter Groups (CRUD, batch; dynamic sorting; **submeter** version tracking lives per-property on `Property.meter_groups[entry]`, but `POST /:id/reset` and `MeterGroup.current_version`/`versions` are **still the live source of truth for main meters** — the "migrate main meters too" backfill was never done and is not pending, so do NOT treat those fields as fully deprecated or delete them, see `decisions/20260608_meter-group-version-tracking-moved-to-property.md` Amendment 2026-07-19)
- ✅ Properties (CRUD, batch; dynamic sorting; optimized duplicate detection)
- ✅ Tenants (CRUD, batch; dynamic sorting)
- ✅ Readings (CRUD, batch; auto-billing on single create; anomaly guard; meter rollback prevention; utility extraction)
Expand All @@ -239,10 +239,9 @@ Each page/component is organized by:
- ✅ Image Extraction (`POST /image-extraction/readings` + `POST /image-extraction/billings` — vision OCR via the user's configured `llm-config` vision provider, Groq or Ollama Cloud only; no Gemini)
- ✅ Reports (`GET /reports/summary`, `/consumption`, `/billing-trends`, `/collection-status`)
- ✅ Bills (`POST /bills/ocr` — functional 3-step UI wizard; overlaps with image-extraction)
- ⚠️ Users (`POST /users` — partial stub for user role management)
- Users (`POST /users` — creates both the Firebase Auth account and Firestore profile server-side via the Admin SDK in one call; the client never touches Firebase Auth for this flow, so the acting admin's own session is never hijacked by the newly created account)
- ✅ LLM Config (`GET`/`PATCH /llm-config` for the chatbot provider/model/API key + `PATCH /llm-config/vision` for an **independent** vision provider/model/API key used by OCR — separate because not every provider has a usable free vision model; reuses the chatbot's API key when both configs use the same provider; API keys AES-256-GCM encrypted at rest via `lib/crypto.lib.ts`)
- ✅ Chatbot (`POST /chatbot` — insight assistant scoped to the authenticated user's data, tool-calling via `lib/llm.lib.ts` against Groq/Ollama Cloud; regex jailbreak guard in `chatbot.guard.ts`)
- ✅ Photo Settings (`GET`/`PATCH /photo-settings` — per-user `savePhotos` preference, defaults to `false`; web and mobile check it before attaching a meter-reading `image_url` on create. OCR suggest always works regardless; billing-cycle/bill photos are never persisted either way)

**Audit Highlights (25 fixes)**:

Expand All @@ -265,7 +264,7 @@ Each page/component is organized by:
- ✅ Readings (filterable list; True Total column; batch form with decoupled OCR suggest; archive page)
- ✅ Billings (cycle-centric: expandable cycles with nested billings; bill photo OCR autofill; cycle edit modal for rate/consumption/date corrections; archive page)
- ✅ Bills / OCR upload (3-step wizard: upload → review/map → submit, creates a billing cycle)
- 🚧 Reports (stub — API module ready, UI not built)
- Reports (analytics dashboard: filters, summary cards, consumption/billing-trends charts, collection-status cards, per-property table)
- 🚧 Settings (partial — payment + user management tabs scaffolded; LLM Provider tab added)
- ✅ Insight Chatbot (`ChatWidget` floating widget, mounted globally on all protected routes)

Expand All @@ -276,7 +275,7 @@ Each page/component is organized by:
- ✅ CaptureReadings (3-step wizard: meter group select → per-property readings + camera with auto OCR suggest → review & batch submit)
- ✅ ReadingHistory (filterable by utility type + property)
- ✅ Billings (grouped by status: overdue/pending/paid; mark-as-paid action)
- ✅ Settings (account info + sign out; save-photos preference toggle)
- ✅ Settings (account info + sign out)

---

Expand Down
13 changes: 7 additions & 6 deletions PRIVACY.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,13 @@ consumption data is processed.

- **Property/tenant records**: room/unit names, tenant names, occupancy dates — entered by
staff, stored in Firestore.
- **Meter readings**: numeric consumption values, timestamps, and (optionally) a photo of
the meter face, if the per-user photo-settings preference (`GET/PATCH /photo-settings`,
default **off**) is enabled. See `api/CLAUDE.md` → "Photo Settings".
- **Meter readings**: numeric consumption values and timestamps. A photo of the meter face
may be captured to drive the OCR "suggest" feature, but it is never persisted — it exists
only in-memory for that one request and is discarded immediately after (readings have no
`image_url` field).
- **Billing records and billing cycles**: consumption, rates, and computed charges derived
from readings — no bill/cycle photos are ever persisted (no `image_url` field exists on
the billing-cycle model).
the billing-cycle model either).
- **Account data**: email and role, via Firebase Authentication.

## Third-Party AI Processors (OCR / Vision)
Expand Down Expand Up @@ -127,8 +128,8 @@ data exposure beyond what the requesting admin's role already permits.
purges cascade to their already-archived readings/billings, mirroring the existing
archive cascade, so a purge cannot leave orphaned records behind. See `api/CLAUDE.md` →
"Archive-Then-Purge Lifecycle".
- Meter-reading photos are only stored if the photo-settings preference is explicitly
enabled per user; bill/billing-cycle photos are never stored.
- Meter-reading, bill, and billing-cycle photos are never stored — every photo is used
transiently, in-memory, for the OCR "suggest" call only, then discarded.
- This document does not set a fixed data-retention period — retention is governed by
operational need and, if the organization is subject to a specific data protection
regime (e.g. the Philippine Data Privacy Act), by that regime's requirements. The
Expand Down
1 change: 1 addition & 0 deletions api/functions/.eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
"jest.config.ts",
"jest.setup.ts",
"seed.js",
"scripts/**/*", // One-off admin scripts, outside the main tsconfig "src" scope.
],
plugins: [
"@typescript-eslint",
Expand Down
5 changes: 4 additions & 1 deletion api/functions/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,7 @@ secrets/
.env
logs/

src/migrations/
src/migrations/

# Generated report from the one-off backfill script (runtime artifact)
scripts/*.report.json
36 changes: 4 additions & 32 deletions api/functions/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -258,7 +258,7 @@ Represents snapshots of meter consumption. **Single create has a critical side e
- reading_amount must be non-negative
- reading_date cannot be in the future
- No duplicate readings per meter group per month (enforced at write time)
- `image_url` is optional
- Readings have no `image_url` field — meter photos are only ever used transiently for OCR suggest (`POST /readings/ocr`), never persisted
- `meter_version` is server-set from the meter group's `current_version` at creation time (not provided by client)
- Anomaly guard: if the reading delta exceeds 5× the rolling average for that meter group, returns 422 with a descriptive message — record a meter group reset first if the meter was physically replaced
- **Cascade delete**: `DELETE /:id` soft-deletes the reading + all billings that reference it (as previous or current reading) (atomic transaction)
Expand Down Expand Up @@ -343,7 +343,7 @@ Represents billing periods with validation and rate calculation.
5. Reject if cycle-level total is outside ±3% of `billing_consumption`

**OCR endpoint** (`POST /billing-cycles/ocr`):
- Accepts `{ image_url: string }` (data URL or HTTPS URL of a utility bill photo)
- Accepts `{ image_url: string }` — a base64 `data:image/*;base64,...` URL of a utility bill photo (no fetchable URLs — see SSRF note below)
- Returns `{ billing_start_date, billing_end_date, billing_consumption, billing_rate, raw_amount }`
- Returns 404 if the user has no `vision_model` configured in `llm-config` (no fallback), 422 if the vision model cannot extract the data or any numeric field is invalid
- Requires `admin` or `landlord` role
Expand All @@ -364,7 +364,7 @@ no Gemini, no fallback.
| POST | `/image-extraction/billings` | OCR utility bill photo → `{ billing_start_date, billing_end_date, billing_consumption, billing_rate, raw_amount }` |

**Business rules**:
- Accepts `{ image_url: string }` — data URL or HTTPS URL
- Accepts `{ image_url: string }` — base64 `data:image/*;base64,...` only (no fetchable URLs — see SSRF note below)
- Returns 404 if the user has no `vision_model` configured in `llm-config`, 422 if extraction fails, 400 if the image URL is invalid
- Backed by `src/lib/vision-ocr.lib.ts` (via `src/lib/llm.lib.ts`'s `LlmClient`, same Groq/Ollama Cloud providers as the chatbot)
- Requires authentication (BearerAuth)
Expand Down Expand Up @@ -415,22 +415,6 @@ Restricted to the `admin` role (`requireRole("admin")` in `chatbot.route.ts`)
|--------|------|---------|
| POST | `/chatbot` | Send a message (+ optional up-to-20-message history); returns `{ reply }`. Admin-only. |

### Photo Settings (`/photo-settings` — protected)

Located: `src/features/photo-settings/`

Per-user preference for whether meter-reading photos get persisted (`image_url`) when a reading is created — separate collection, same one-doc-per-user pattern as `llm-config`. Unlike `llm-config`, `GET` never 404s: no stored doc just means the default applies.

| Method | Path | Purpose |
|--------|------|---------|
| GET | `/photo-settings` | Fetch `{savePhotos: boolean}` — defaults to `false` if never configured |
| PATCH | `/photo-settings` | Set `{savePhotos: boolean}` |

**Business rules**:
- Default is `false` (disabled) — a captured/uploaded photo is still used transiently for OCR suggest (`POST /readings/ocr`, `POST /image-extraction/readings`), but web and mobile clients strip `image_url` from the create payload unless this is `true`.
- Utility-bill / billing-cycle photos are **never** persisted regardless of this setting — there's no `image_url` field on the billing-cycle model to begin with, so this setting only governs meter-reading photos.
- Enforcement is client-side (web `readings` page, mobile `CaptureReadings`) — the backend doesn't inspect this setting itself; `POST /readings`/`POST /readings/batch` still accept an optional `image_url` as before.

### Stub & Incomplete Features

The following feature folders exist but are **not fully implemented**:
Expand Down Expand Up @@ -628,18 +612,6 @@ api/functions/src/features/chatbot/
└── chatbot.swagger.ts
```

### Photo Settings
```
api/functions/src/features/photo-settings/
├── photo-settings.model.ts → PhotoSettings (save_photos: boolean)
├── photo-settings.dto.ts
├── photo-settings.repository.ts → One doc per user, same pattern as llm-config.repository.ts
├── photo-settings.service.ts → get() defaults to {savePhotos: false} when no doc exists (never 404s)
├── photo-settings.controller.ts
├── photo-settings.route.ts
└── photo-settings.swagger.ts
```

### Authentication (special case — public routes)
```
api/functions/src/features/auth/
Expand Down Expand Up @@ -669,7 +641,7 @@ api/functions/src/
│ ├── auth.lib.ts → JWT generation (jsonwebtoken)
│ ├── llm.lib.ts → LlmClient — OpenAI-compatible chat-completions client (Groq, Ollama Cloud); serializes text+image content per-provider
│ ├── vision-ocr.lib.ts → Vision OCR (readings, bills) via LlmClient — no Gemini, requires `vision_model`
│ ├── image-fetch.util.ts → SSRF-guarded image fetch/decode shared by vision-ocr.lib.ts
│ ├── image-fetch.util.ts → Decodes base64 `data:image/*;base64,...` payloads shared by vision-ocr.lib.ts — no server-side URL fetch exists, eliminating SSRF entirely (all OCR endpoints reject non-`data:` image_url via the shared `ImageUrlSchema`)
│ ├── ocr-parsing.util.ts → OCR prompts + response parsing shared by vision-ocr.lib.ts
│ ├── crypto.lib.ts → AES-256-GCM encrypt/decryptSecret() for LLM API keys
│ └── ... (Realtime DB, storage stubs)
Expand Down
14 changes: 7 additions & 7 deletions api/functions/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion api/functions/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@
"deploy": "firebase deploy --only functions",
"deploy:staging": "cross-env APP_ENV=staging firebase deploy --project staging --only functions",
"deploy:prod": "cross-env APP_ENV=production firebase deploy --project production --only functions",
"logs": "firebase functions:log"
"logs": "firebase functions:log",
"backfill:billing-meter-group": "ts-node scripts/backfill-billing-meter-group.ts"
},
"engines": {
"node": "24"
Expand Down
129 changes: 129 additions & 0 deletions api/functions/scripts/backfill-billing-meter-group.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
/**
* One-time backfill: denormalizes meter_group_id + billing_period_date onto existing
* Billing documents, resolved from each billing's current_reading_id. Idempotent — already
* backfilled billings are skipped, so it's safe to re-run.
*
* Usage (from api/functions/):
* APP_ENV=staging GOOGLE_APPLICATION_CREDENTIALS=... npx ts-node scripts/backfill-billing-meter-group.ts --dry-run
* APP_ENV=staging GOOGLE_APPLICATION_CREDENTIALS=... npx ts-node scripts/backfill-billing-meter-group.ts --apply
*
* --dry-run (default if neither flag is passed) writes a report to
* scripts/backfill-billing-meter-group.report.json and performs no writes.
* --apply performs the writes, in batches of 500 (Firestore batch limit).
*/
import * as fs from "node:fs";
import * as path from "node:path";
import {firestore} from "../src/config/firebase.config";
import {COLLECTIONS} from "../src/constants/collection.constants";

const BATCH_LIMIT = 500;
const REPORT_PATH = path.join(__dirname, "backfill-billing-meter-group.report.json");

interface ReportEntry {
billingId: string;
currentReadingId: string;
status: "resolved" | "already-backfilled" | "reading-not-found";
meterGroupId?: string;
billingPeriodDate?: string;
}

async function main() {
const apply = process.argv.includes("--apply");
const mode = apply ? "apply" : "dry-run";
console.log(`Running backfill in ${mode} mode...`);

const billingsSnap = await firestore.collection(COLLECTIONS.BILLINGS).get();
console.log(`Found ${billingsSnap.size} billing documents.`);

const report: ReportEntry[] = [];
const toWrite: { ref: FirebaseFirestore.DocumentReference; meterGroupId: string; billingPeriodDate: FirebaseFirestore.Timestamp }[] = [];

// Firestore getAll() accepts many refs in one round trip; chunk to stay well under
// request-size limits.
const readingIds = Array.from(
new Set(billingsSnap.docs.map((d) => d.data().current_reading_id as string).filter(Boolean))
);
const readingById = new Map<string, FirebaseFirestore.DocumentData>();
const CHUNK = 300;
for (let i = 0; i < readingIds.length; i += CHUNK) {
const chunk = readingIds.slice(i, i + CHUNK);
const refs = chunk.map((id) => firestore.collection(COLLECTIONS.READINGS).doc(id));
const snaps = await firestore.getAll(...refs);
snaps.forEach((snap, idx) => {
if (snap.exists) readingById.set(chunk[idx], snap.data()!);
});
}

for (const doc of billingsSnap.docs) {
const data = doc.data();
const currentReadingId = data.current_reading_id as string;

if (data.meter_group_id && data.billing_period_date) {
report.push({
billingId: doc.id,
currentReadingId,
status: "already-backfilled",
meterGroupId: data.meter_group_id,
});
continue;
}

const reading = readingById.get(currentReadingId);
if (!reading) {
report.push({billingId: doc.id, currentReadingId, status: "reading-not-found"});
continue;
}

report.push({
billingId: doc.id,
currentReadingId,
status: "resolved",
meterGroupId: reading.meter_group_id,
billingPeriodDate: reading.reading_date?.toDate?.().toISOString(),
});
toWrite.push({
ref: doc.ref,
meterGroupId: reading.meter_group_id,
billingPeriodDate: reading.reading_date,
});
}

const resolved = report.filter((r) => r.status === "resolved").length;
const alreadyDone = report.filter((r) => r.status === "already-backfilled").length;
const failed = report.filter((r) => r.status === "reading-not-found").length;
console.log(`Resolved: ${resolved}, already backfilled: ${alreadyDone}, failed: ${failed}`);

fs.writeFileSync(REPORT_PATH, JSON.stringify({mode, resolved, alreadyDone, failed, entries: report}, null, 2));
console.log(`Report written to ${REPORT_PATH}`);

if (!apply) {
console.log("Dry-run complete. No writes performed. Re-run with --apply to write changes.");
return;
}

if (failed > 0) {
console.warn(`WARNING: ${failed} billing(s) could not be resolved (see report) — these will remain un-backfilled.`);
}

for (let i = 0; i < toWrite.length; i += BATCH_LIMIT) {
const chunk = toWrite.slice(i, i + BATCH_LIMIT);
const batch = firestore.batch();
for (const item of chunk) {
batch.update(item.ref, {
meter_group_id: item.meterGroupId,
billing_period_date: item.billingPeriodDate,
});
}
await batch.commit();
console.log(`Wrote batch ${i / BATCH_LIMIT + 1} (${chunk.length} documents).`);
}

console.log(`Apply complete. ${toWrite.length} billing document(s) updated.`);
}

main()
.then(() => process.exit(0))
.catch((err) => {
console.error(err);
process.exit(1);
});
Loading
Loading