Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 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
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
5 changes: 2 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 Down Expand Up @@ -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
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
40 changes: 2 additions & 38 deletions api/functions/src/config/swagger.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ import {billsPaths} from "../features/bills/bills.swagger";
import {paths as imageExtractionPaths} from "../features/image-extraction/image-extraction.swagger";
import {reportsPaths} from "../features/reports/reports.swagger";
import {llmConfigPaths} from "../features/llm-config/llm-config.swagger";
import {photoSettingsPaths} from "../features/photo-settings/photo-settings.swagger";
import {chatbotPaths} from "../features/chatbot/chatbot.swagger";

const swaggerSpec = {
Expand Down Expand Up @@ -471,11 +470,6 @@ const swaggerSpec = {
reading_date: {
$ref: "#/components/schemas/Timestamp",
},
image_url: {
type: "string",
format: "uri",
description: "Optional photo of the meter (requires Firebase Storage)",
},
meter_version: {
type: "integer",
minimum: 1,
Expand Down Expand Up @@ -504,11 +498,6 @@ const swaggerSpec = {
reading_date: {
$ref: "#/components/schemas/Timestamp",
},
image_url: {
type: "string",
format: "uri",
description: "Optional photo URL (requires Firebase Storage to be configured)",
},
},
required: ["meter_group_id", "property_id", "reading_amount", "reading_date"],
},
Expand All @@ -530,10 +519,6 @@ const swaggerSpec = {
reading_date: {
$ref: "#/components/schemas/Timestamp",
},
image_url: {
type: "string",
format: "uri",
},
},
},
PaginatedReadings: {
Expand Down Expand Up @@ -935,9 +920,8 @@ const swaggerSpec = {
properties: {
image_url: {
type: "string",
format: "uri",
description: "URL of the bill image to process",
example: "https://storage.googleapis.com/bucket/bills/bill.jpg",
description: "Base64 data URL of the bill image to process (data:image/*;base64,...)",
example: "data:image/jpeg;base64,/9j/4AAQSkZJRg...",
},
},
},
Expand Down Expand Up @@ -1013,25 +997,6 @@ const swaggerSpec = {
},
required: ["provider", "model"],
},
PhotoSettingsResponse: {
type: "object",
properties: {
savePhotos: {
type: "boolean",
description: "Whether meter-reading photos are persisted (image_url) on create. Defaults to false. Bill/billing-cycle photos are never persisted regardless.",
},
},
required: ["savePhotos"],
},
UpsertPhotoSettingsRequest: {
type: "object",
properties: {
savePhotos: {
type: "boolean",
},
},
required: ["savePhotos"],
},
ChatHistoryMessage: {
type: "object",
properties: {
Expand Down Expand Up @@ -1128,7 +1093,6 @@ const swaggerSpec = {
...reportsPaths,
...llmConfigPaths,
...chatbotPaths,
...photoSettingsPaths,
},
};

Expand Down
1 change: 0 additions & 1 deletion api/functions/src/constants/collection.constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,4 @@
USERS: "users",
READING_LOCKS: "reading_locks",
LLM_CONFIG: "llm_config",
PHOTO_SETTINGS: "photo_settings",
};
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,7 @@ export const createBillingCycle = async (
res: Response
): Promise<void> => {
const data = req.body as CreateBillingCycleDTO;
const userId = req.user?.userId;
if (!userId) throw new AppError(401, "User not authenticated");
const userId = req.user!.userId;
const result = await billingCycleService.create(userId, data);
res.status(201).json(result);
};
Expand All @@ -29,8 +28,7 @@ export const createBatchBillingCycles = async (
res: Response
): Promise<void> => {
const data = req.body as CreateBillingCycleDTO[];
const userId = req.user?.userId;
if (!userId) throw new AppError(401, "User not authenticated");
const userId = req.user!.userId;
const result = await billingCycleService.createBatch(userId, data);
res.status(201).json(result);
};
Expand All @@ -40,8 +38,7 @@ export const getBillingCycleById = async (
res: Response
): Promise<void> => {
const {id} = req.params as unknown as BillingCycleByIdParamsDTO;
const userId = req.user?.userId;
if (!userId) throw new AppError(401, "User not authenticated");
const userId = req.user!.userId;
const billingCycle = await billingCycleService.getById(userId, id);

if (!billingCycle) {
Expand All @@ -56,8 +53,7 @@ export const getBillingCycles = async (
res: Response
): Promise<void> => {
const query = req.query as unknown as GetBillingCyclesQueryDTO;
const userId = req.user?.userId;
if (!userId) throw new AppError(401, "User not authenticated");
const userId = req.user!.userId;

const result = await billingCycleService.search(userId, {
billingStartDate: query.billingStartDate,
Expand All @@ -77,8 +73,7 @@ export const updateBillingCycle = async (
): Promise<void> => {
const {id} = req.params as unknown as BillingCycleByIdParamsDTO;
const data = req.body as Partial<UpdateBillingCycleDTO>;
const userId = req.user?.userId;
if (!userId) throw new AppError(401, "User not authenticated");
const userId = req.user!.userId;
const result = await billingCycleService.update(userId, id, data);
res.status(200).json(result);
};
Expand All @@ -88,8 +83,7 @@ export const updateBatchBillingCycles = async (
res: Response
): Promise<void> => {
const updates = req.body as { id: string; data: Partial<UpdateBillingCycleDTO> }[];
const userId = req.user?.userId;
if (!userId) throw new AppError(401, "User not authenticated");
const userId = req.user!.userId;
const result = await billingCycleService.updateBatch(userId, updates);
res.status(200).json(result);
};
Expand All @@ -99,8 +93,7 @@ export const deleteBillingCycle = async (
res: Response
): Promise<void> => {
const {id} = req.params as unknown as BillingCycleByIdParamsDTO;
const userId = req.user?.userId;
if (!userId) throw new AppError(401, "User not authenticated");
const userId = req.user!.userId;
await billingCycleService.delete(userId, id);
res.status(204).send();
};
Expand All @@ -110,8 +103,7 @@ export const softDeleteBillingCycle = async (
res: Response
): Promise<void> => {
const {id} = req.params as unknown as BillingCycleByIdParamsDTO;
const userId = req.user?.userId;
if (!userId) throw new AppError(401, "User not authenticated");
const userId = req.user!.userId;
const result = await billingCycleService.softDelete(userId, id);
res.status(200).json(result);
};
Expand All @@ -121,8 +113,7 @@ export const restoreBillingCycle = async (
res: Response
): Promise<void> => {
const {id} = req.params as unknown as BillingCycleByIdParamsDTO;
const userId = req.user?.userId;
if (!userId) throw new AppError(401, "User not authenticated");
const userId = req.user!.userId;
const result = await billingCycleService.restore(userId, id);
res.status(200).json(result);
};
Expand All @@ -132,8 +123,7 @@ export const purgeBillingCycle = async (
res: Response
): Promise<void> => {
const {id} = req.params as unknown as BillingCycleByIdParamsDTO;
const userId = req.user?.userId;
if (!userId) throw new AppError(401, "User not authenticated");
const userId = req.user!.userId;
await billingCycleService.purge(userId, id);
res.status(204).send();
};
Expand All @@ -143,8 +133,7 @@ export const ocrBillingCycle = async (
res: Response
): Promise<void> => {
const {image_url} = req.body as OcrBillingCycleDTO;
const userId = req.user?.userId;
if (!userId) throw new AppError(401, "User not authenticated");
const userId = req.user!.userId;
const extracted = await ImageExtractionService.extractBillingFromImage(image_url, userId);
const validated = OcrBillingCycleResponseSchema.parse({
billing_start_date: extracted.billing_start_date,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -202,7 +202,7 @@ export const billingCyclePaths = {
properties: {
image_url: {
type: "string",
description: "Data URL (data:image/...) or public HTTPS URL of the utility bill photo",
description: "Base64 data URL of the utility bill photo (data:image/*;base64,...)",
},
},
},
Expand Down
Loading
Loading