diff --git a/CLAUDE.md b/CLAUDE.md index 0b6b59a..6e4d5a2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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)**: @@ -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) --- diff --git a/PRIVACY.md b/PRIVACY.md index 891fb77..050f17e 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -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) @@ -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 diff --git a/api/functions/CLAUDE.md b/api/functions/CLAUDE.md index e3be546..f992276 100644 --- a/api/functions/CLAUDE.md +++ b/api/functions/CLAUDE.md @@ -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) @@ -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 @@ -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) @@ -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**: @@ -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/ @@ -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) diff --git a/api/functions/src/config/swagger.config.ts b/api/functions/src/config/swagger.config.ts index 5ac5e20..c7b74c0 100644 --- a/api/functions/src/config/swagger.config.ts +++ b/api/functions/src/config/swagger.config.ts @@ -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 = { @@ -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, @@ -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"], }, @@ -530,10 +519,6 @@ const swaggerSpec = { reading_date: { $ref: "#/components/schemas/Timestamp", }, - image_url: { - type: "string", - format: "uri", - }, }, }, PaginatedReadings: { @@ -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...", }, }, }, @@ -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: { @@ -1128,7 +1093,6 @@ const swaggerSpec = { ...reportsPaths, ...llmConfigPaths, ...chatbotPaths, - ...photoSettingsPaths, }, }; diff --git a/api/functions/src/constants/collection.constants.ts b/api/functions/src/constants/collection.constants.ts index 854fb22..08fc7c8 100644 --- a/api/functions/src/constants/collection.constants.ts +++ b/api/functions/src/constants/collection.constants.ts @@ -9,5 +9,4 @@ USERS: "users", READING_LOCKS: "reading_locks", LLM_CONFIG: "llm_config", - PHOTO_SETTINGS: "photo_settings", }; diff --git a/api/functions/src/features/billing-cycle/billing-cycle.controller.ts b/api/functions/src/features/billing-cycle/billing-cycle.controller.ts index b851bf8..b5fad5e 100644 --- a/api/functions/src/features/billing-cycle/billing-cycle.controller.ts +++ b/api/functions/src/features/billing-cycle/billing-cycle.controller.ts @@ -18,8 +18,7 @@ export const createBillingCycle = async ( res: Response ): Promise => { 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); }; @@ -29,8 +28,7 @@ export const createBatchBillingCycles = async ( res: Response ): Promise => { 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); }; @@ -40,8 +38,7 @@ export const getBillingCycleById = async ( res: Response ): Promise => { 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) { @@ -56,8 +53,7 @@ export const getBillingCycles = async ( res: Response ): Promise => { 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, @@ -77,8 +73,7 @@ export const updateBillingCycle = async ( ): Promise => { const {id} = req.params as unknown as BillingCycleByIdParamsDTO; const data = req.body as Partial; - 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); }; @@ -88,8 +83,7 @@ export const updateBatchBillingCycles = async ( res: Response ): Promise => { const updates = req.body as { id: string; data: Partial }[]; - 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); }; @@ -99,8 +93,7 @@ export const deleteBillingCycle = async ( res: Response ): Promise => { 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(); }; @@ -110,8 +103,7 @@ export const softDeleteBillingCycle = async ( res: Response ): Promise => { 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); }; @@ -121,8 +113,7 @@ export const restoreBillingCycle = async ( res: Response ): Promise => { 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); }; @@ -132,8 +123,7 @@ export const purgeBillingCycle = async ( res: Response ): Promise => { 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(); }; @@ -143,8 +133,7 @@ export const ocrBillingCycle = async ( res: Response ): Promise => { 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, diff --git a/api/functions/src/features/billing-cycle/billing-cycle.swagger.ts b/api/functions/src/features/billing-cycle/billing-cycle.swagger.ts index c2e50da..7feac14 100644 --- a/api/functions/src/features/billing-cycle/billing-cycle.swagger.ts +++ b/api/functions/src/features/billing-cycle/billing-cycle.swagger.ts @@ -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,...)", }, }, }, diff --git a/api/functions/src/features/billing/billing.controller.ts b/api/functions/src/features/billing/billing.controller.ts index 39b0821..d4de2eb 100644 --- a/api/functions/src/features/billing/billing.controller.ts +++ b/api/functions/src/features/billing/billing.controller.ts @@ -15,8 +15,7 @@ export const createBilling = async ( res: Response ): Promise => { const data = req.body as CreateBillingDTO; - const userId = req.user?.userId; - if (!userId) throw new AppError(401, "User not authenticated"); + const userId = req.user!.userId; const result = await billingService.create(userId, data); res.status(201).json(result); }; @@ -26,8 +25,7 @@ export const createBatchBillings = async ( res: Response ): Promise => { const data = req.body as CreateBillingDTO[]; - const userId = req.user?.userId; - if (!userId) throw new AppError(401, "User not authenticated"); + const userId = req.user!.userId; const result = await billingService.createBatch(userId, data); res.status(201).json(result); }; @@ -37,8 +35,7 @@ export const getBillingById = async ( res: Response ): Promise => { const {id} = req.params as unknown as BillingByIdParamsDTO; - const userId = req.user?.userId; - if (!userId) throw new AppError(401, "User not authenticated"); + const userId = req.user!.userId; const billing = await billingService.getById(userId, id); if (!billing) { @@ -53,8 +50,7 @@ export const getBillings = async ( res: Response ): Promise => { const query = req.query as unknown as GetBillingsQueryDTO; - const userId = req.user?.userId; - if (!userId) throw new AppError(401, "User not authenticated"); + const userId = req.user!.userId; const result = await billingService.search(userId, { propertyId: query.propertyId, @@ -73,8 +69,7 @@ export const updateBilling = async ( ): Promise => { const {id} = req.params as unknown as BillingByIdParamsDTO; const data = req.body as Partial; - const userId = req.user?.userId; - if (!userId) throw new AppError(401, "User not authenticated"); + const userId = req.user!.userId; const result = await billingService.update(userId, id, data); res.status(200).json(result); }; @@ -84,8 +79,7 @@ export const updateBatchBillings = async ( res: Response ): Promise => { const updates = req.body as { id: string; data: Partial }[]; - const userId = req.user?.userId; - if (!userId) throw new AppError(401, "User not authenticated"); + const userId = req.user!.userId; const result = await billingService.updateBatch(userId, updates); res.status(200).json(result); }; @@ -95,8 +89,7 @@ export const deleteBilling = async ( res: Response ): Promise => { const {id} = req.params as unknown as BillingByIdParamsDTO; - const userId = req.user?.userId; - if (!userId) throw new AppError(401, "User not authenticated"); + const userId = req.user!.userId; await billingService.delete(userId, id); res.status(204).send(); }; @@ -106,8 +99,7 @@ export const softDeleteBilling = async ( res: Response ): Promise => { const {id} = req.params as unknown as BillingByIdParamsDTO; - const userId = req.user?.userId; - if (!userId) throw new AppError(401, "User not authenticated"); + const userId = req.user!.userId; const result = await billingService.softDelete(userId, id); res.status(200).json(result); }; @@ -117,8 +109,7 @@ export const restoreBilling = async ( res: Response ): Promise => { const {id} = req.params as unknown as BillingByIdParamsDTO; - const userId = req.user?.userId; - if (!userId) throw new AppError(401, "User not authenticated"); + const userId = req.user!.userId; const result = await billingService.restore(userId, id); res.status(200).json(result); }; @@ -128,8 +119,7 @@ export const purgeBilling = async ( res: Response ): Promise => { const {id} = req.params as unknown as BillingByIdParamsDTO; - const userId = req.user?.userId; - if (!userId) throw new AppError(401, "User not authenticated"); + const userId = req.user!.userId; await billingService.purge(userId, id); res.status(204).send(); }; diff --git a/api/functions/src/features/bills/bills.controller.ts b/api/functions/src/features/bills/bills.controller.ts index e15ddfb..9a7d0c9 100644 --- a/api/functions/src/features/bills/bills.controller.ts +++ b/api/functions/src/features/bills/bills.controller.ts @@ -2,7 +2,6 @@ import {Response} from "express"; import type {AuthenticatedRequest} from "../../utils/auth.util"; import {ImageExtractionService} from "../image-extraction/image-extraction.service"; import {OcrBillDTO, OcrBillResponse} from "./bills.dto"; -import {AppError} from "../../utils/error.util"; /** * Thin wrapper around the shared image-extraction service — this endpoint and @@ -13,8 +12,7 @@ import {AppError} from "../../utils/error.util"; */ export const ocrBill = async (req: AuthenticatedRequest, res: Response): Promise => { const data = req.body as OcrBillDTO; - const userId = req.user?.userId; - if (!userId) throw new AppError(401, "User not authenticated"); + const userId = req.user!.userId; const extracted = await ImageExtractionService.extractBillingFromImage(data.image_url, userId); diff --git a/api/functions/src/features/bills/bills.swagger.ts b/api/functions/src/features/bills/bills.swagger.ts index 288e408..31ee306 100644 --- a/api/functions/src/features/bills/bills.swagger.ts +++ b/api/functions/src/features/bills/bills.swagger.ts @@ -44,9 +44,8 @@ export const schemas = { 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...", }, }, }, diff --git a/api/functions/src/features/chatbot/chatbot.controller.ts b/api/functions/src/features/chatbot/chatbot.controller.ts index d6021ea..fc85961 100644 --- a/api/functions/src/features/chatbot/chatbot.controller.ts +++ b/api/functions/src/features/chatbot/chatbot.controller.ts @@ -2,11 +2,9 @@ import type {AuthenticatedRequest} from "../../utils/auth.util"; import {Response} from "express"; import {chatbotService} from "./chatbot.service"; import {ChatRequestDTO} from "./chatbot.dto"; -import {AppError} from "../../utils/error.util"; export const postChatMessage = async (req: AuthenticatedRequest, res: Response): Promise => { - const userId = req.user?.userId; - if (!userId) throw new AppError(401, "User not authenticated"); + const userId = req.user!.userId; const {message, history} = req.body as ChatRequestDTO; const reply = await chatbotService.chat(userId, message, history); res.status(200).json({reply}); diff --git a/api/functions/src/features/chatbot/chatbot.tools.ts b/api/functions/src/features/chatbot/chatbot.tools.ts index 64d2efa..140a7fd 100644 --- a/api/functions/src/features/chatbot/chatbot.tools.ts +++ b/api/functions/src/features/chatbot/chatbot.tools.ts @@ -439,25 +439,25 @@ export async function getBillingReports(args: GetBillingReportsArgs) { const userId = "chatbot"; // unused by reportsService — every report is scoped by query filters, not caller switch (args.reportType) { - case "summary": - return reportsService.getSummary(userId, query); - case "consumption": { - const report = await reportsService.getConsumption(userId, query); - return { - by_month: report.by_month, - by_property: report.by_property.map((p) => ({ - propertyName: p.room_name, - electricity: p.electricity, - water: p.water, - })), - }; - } - case "billing_trends": - return reportsService.getBillingTrends(userId, query); - case "collection_status": - return reportsService.getCollectionStatus(userId, query); - default: - throw new AppError(400, `Unknown reportType: ${args.reportType}`); + case "summary": + return reportsService.getSummary(userId, query); + case "consumption": { + const report = await reportsService.getConsumption(userId, query); + return { + by_month: report.by_month, + by_property: report.by_property.map((p) => ({ + propertyName: p.room_name, + electricity: p.electricity, + water: p.water, + })), + }; + } + case "billing_trends": + return reportsService.getBillingTrends(userId, query); + case "collection_status": + return reportsService.getCollectionStatus(userId, query); + default: + throw new AppError(400, `Unknown reportType: ${args.reportType}`); } } diff --git a/api/functions/src/features/image-extraction/image-extraction.controller.ts b/api/functions/src/features/image-extraction/image-extraction.controller.ts index a9e6f5d..7731157 100644 --- a/api/functions/src/features/image-extraction/image-extraction.controller.ts +++ b/api/functions/src/features/image-extraction/image-extraction.controller.ts @@ -3,7 +3,6 @@ import type {AuthenticatedRequest} from "../../utils/auth.util"; import {ImageExtractionService} from "./image-extraction.service"; import {ImageExtractionValidator} from "./image-extraction.validator"; import type {ExtractReadingRequest, ExtractBillingRequest} from "./image-extraction.dto"; -import {AppError} from "../../utils/error.util"; const validator = new ImageExtractionValidator(); @@ -11,8 +10,7 @@ export async function extractReadingFromImage( req: AuthenticatedRequest, res: Response ) { - const userId = req.user?.userId; - if (!userId) throw new AppError(401, "User not authenticated"); + const userId = req.user!.userId; const body = req.body as ExtractReadingRequest; validator.validateExtractReading(body); const result = await ImageExtractionService.extractReadingFromImage(body.image_url, userId); @@ -23,8 +21,7 @@ export async function extractBillingFromImage( req: AuthenticatedRequest, res: Response ) { - const userId = req.user?.userId; - if (!userId) throw new AppError(401, "User not authenticated"); + const userId = req.user!.userId; const body = req.body as ExtractBillingRequest; validator.validateExtractBilling(body); const result = await ImageExtractionService.extractBillingFromImage(body.image_url, userId); diff --git a/api/functions/src/features/image-extraction/image-extraction.swagger.ts b/api/functions/src/features/image-extraction/image-extraction.swagger.ts index 3bf41dc..89f5f2d 100644 --- a/api/functions/src/features/image-extraction/image-extraction.swagger.ts +++ b/api/functions/src/features/image-extraction/image-extraction.swagger.ts @@ -13,8 +13,7 @@ export const paths = { properties: { image_url: { type: "string", - format: "uri", - description: "URL of the meter photo (data URL or HTTPS)", + description: "Base64 data URL of the meter photo (data:image/*;base64,...)", }, }, required: ["image_url"], @@ -61,8 +60,7 @@ export const paths = { properties: { image_url: { type: "string", - format: "uri", - description: "URL of the utility bill photo (data URL or HTTPS)", + description: "Base64 data URL of the utility bill photo (data:image/*;base64,...)", }, }, required: ["image_url"], diff --git a/api/functions/src/features/llm-config/llm-config.controller.ts b/api/functions/src/features/llm-config/llm-config.controller.ts index bdf1b87..b2d338e 100644 --- a/api/functions/src/features/llm-config/llm-config.controller.ts +++ b/api/functions/src/features/llm-config/llm-config.controller.ts @@ -2,11 +2,9 @@ import type {AuthenticatedRequest} from "../../utils/auth.util"; import {Response} from "express"; import {llmConfigService} from "./llm-config.service"; import {UpsertLlmConfigDTO, UpsertVisionLlmConfigDTO} from "./llm-config.dto"; -import {AppError} from "../../utils/error.util"; export const getLlmConfig = async (req: AuthenticatedRequest, res: Response): Promise => { - const userId = req.user?.userId; - if (!userId) throw new AppError(401, "User not authenticated"); + const userId = req.user!.userId; const result = await llmConfigService.get(userId); if (!result) { res.status(200).json({ @@ -23,16 +21,14 @@ export const getLlmConfig = async (req: AuthenticatedRequest, res: Response): Pr }; export const upsertLlmConfig = async (req: AuthenticatedRequest, res: Response): Promise => { - const userId = req.user?.userId; - if (!userId) throw new AppError(401, "User not authenticated"); + const userId = req.user!.userId; const data = req.body as UpsertLlmConfigDTO; const result = await llmConfigService.upsert(userId, data); res.status(200).json(result); }; export const upsertVisionLlmConfig = async (req: AuthenticatedRequest, res: Response): Promise => { - const userId = req.user?.userId; - if (!userId) throw new AppError(401, "User not authenticated"); + const userId = req.user!.userId; const data = req.body as UpsertVisionLlmConfigDTO; const result = await llmConfigService.upsertVision(userId, data); res.status(200).json(result); diff --git a/api/functions/src/features/meter-group/meter-group.controller.ts b/api/functions/src/features/meter-group/meter-group.controller.ts index 13b9083..11b34fa 100644 --- a/api/functions/src/features/meter-group/meter-group.controller.ts +++ b/api/functions/src/features/meter-group/meter-group.controller.ts @@ -15,8 +15,7 @@ export const createMeterGroup = async ( res: Response ): Promise => { const data = req.body as CreateMeterGroupDTO; - const userId = req.user?.userId; - if (!userId) throw new AppError(401, "User not authenticated"); + const userId = req.user!.userId; const result = await meterGroupService.create(userId, data); res.status(201).json(result); }; @@ -26,8 +25,7 @@ export const createBatchMeterGroups = async ( res: Response ): Promise => { const data = req.body as CreateMeterGroupDTO[]; - const userId = req.user?.userId; - if (!userId) throw new AppError(401, "User not authenticated"); + const userId = req.user!.userId; const result = await meterGroupService.createBatch(userId, data); res.status(201).json(result); }; @@ -37,8 +35,7 @@ export const getMeterGroupById = async ( res: Response ): Promise => { const {id} = req.params as unknown as MeterGroupByIdParamsDTO; - const userId = req.user?.userId; - if (!userId) throw new AppError(401, "User not authenticated"); + const userId = req.user!.userId; const meterGroup = await meterGroupService.getById(userId, id); if (!meterGroup) { @@ -53,8 +50,7 @@ export const getMeterGroups = async ( res: Response ): Promise => { const query = req.query as unknown as GetMeterGroupsQueryDTO; - const userId = req.user?.userId; - if (!userId) throw new AppError(401, "User not authenticated"); + const userId = req.user!.userId; const result = await meterGroupService.search(userId, { meterName: query.meterName, @@ -75,8 +71,7 @@ export const updateMeterGroup = async ( ): Promise => { const {id} = req.params as unknown as MeterGroupByIdParamsDTO; const data = req.body as Partial; - const userId = req.user?.userId; - if (!userId) throw new AppError(401, "User not authenticated"); + const userId = req.user!.userId; const result = await meterGroupService.update(userId, id, data); res.status(200).json(result); }; @@ -86,8 +81,7 @@ export const updateBatchMeterGroups = async ( res: Response ): Promise => { const updates = req.body as { id: string; data: Partial }[]; - const userId = req.user?.userId; - if (!userId) throw new AppError(401, "User not authenticated"); + const userId = req.user!.userId; const result = await meterGroupService.updateBatch(userId, updates); res.status(200).json(result); }; @@ -97,8 +91,7 @@ export const deleteMeterGroup = async ( res: Response ): Promise => { const {id} = req.params as unknown as MeterGroupByIdParamsDTO; - const userId = req.user?.userId; - if (!userId) throw new AppError(401, "User not authenticated"); + const userId = req.user!.userId; await meterGroupService.delete(userId, id); res.status(204).send(); }; @@ -108,8 +101,7 @@ export const softDeleteMeterGroup = async ( res: Response ): Promise => { const {id} = req.params as unknown as MeterGroupByIdParamsDTO; - const userId = req.user?.userId; - if (!userId) throw new AppError(401, "User not authenticated"); + const userId = req.user!.userId; const result = await meterGroupService.softDelete(userId, id); res.status(200).json(result); }; @@ -119,8 +111,7 @@ export const restoreMeterGroup = async ( res: Response ): Promise => { const {id} = req.params as unknown as MeterGroupByIdParamsDTO; - const userId = req.user?.userId; - if (!userId) throw new AppError(401, "User not authenticated"); + const userId = req.user!.userId; const result = await meterGroupService.restore(userId, id); res.status(200).json(result); }; @@ -130,8 +121,6 @@ export const purgeMeterGroup = async ( res: Response ): Promise => { const {id} = req.params as unknown as MeterGroupByIdParamsDTO; - const userId = req.user?.userId; - if (!userId) throw new AppError(401, "User not authenticated"); await meterGroupService.purge(id); res.status(204).send(); }; @@ -141,8 +130,7 @@ export const recordMeterGroupReset = async ( res: Response ): Promise => { const {id} = req.params as unknown as MeterGroupByIdParamsDTO; - const userId = req.user?.userId; - if (!userId) throw new AppError(401, "User not authenticated"); + const userId = req.user!.userId; const result = await meterGroupService.recordReset(userId, id); res.status(200).json(result); }; diff --git a/api/functions/src/features/meter-group/meter-group.service.ts b/api/functions/src/features/meter-group/meter-group.service.ts index 2694d74..28005ec 100644 --- a/api/functions/src/features/meter-group/meter-group.service.ts +++ b/api/functions/src/features/meter-group/meter-group.service.ts @@ -5,18 +5,21 @@ import {CreateMeterGroupDTO} from "./meter-group.dto"; import {readingRepository} from "../reading/reading.repository"; import {PaginatedResult} from "../../utils/pagination.util"; import {MeterGroupValidator} from "./meter-group.validator"; -import {AppError} from "../../utils/error.util"; +import {AppError, getOrThrow} from "../../utils/error.util"; import {collectionRef} from "../../lib/firestore.lib"; import {COLLECTIONS} from "../../constants/collection.constants"; import {listRemove} from "../../utils/list-cache.util"; import {cacheDel, cacheSet} from "../../utils/cache.util"; import {cascadeDeleteMeterGroup, cascadeRestoreMeterGroup, cascadePurgeMeterGroup} from "../../utils/cascade-delete.util"; import {CachedRepository} from "../../lib/cached-repository.lib"; -import {logger} from "../../utils/logger.util"; const validator = new MeterGroupValidator(); const CACHE_TTL = 30 * 60; // 30 minutes +function repoFor(userId: string): CachedRepository { + return new CachedRepository(meterGroupRepository, userId, "meter-groups", CACHE_TTL); +} + type MeterGroupSearchOptions = { meterName?: string; utilityType?: MeterGroup["utility_type"]; @@ -66,8 +69,7 @@ function applyProjections( export const meterGroupService = { async create(userId: string, data: CreateMeterGroupDTO): Promise { await validator.validateCreate(data); - const cachedRepo = new CachedRepository(meterGroupRepository, userId, "meter-groups", CACHE_TTL); - return cachedRepo.create({ + return repoFor(userId).create({ ...data, current_version: 1, versions: {}, @@ -76,8 +78,7 @@ export const meterGroupService = { async createBatch(userId: string, data: CreateMeterGroupDTO[]): Promise { await validator.validateBatch(data); - const cachedRepo = new CachedRepository(meterGroupRepository, userId, "meter-groups", CACHE_TTL); - return cachedRepo.createBatch( + return repoFor(userId).createBatch( data.map((item) => ({ ...item, current_version: 1, @@ -90,8 +91,7 @@ export const meterGroupService = { userId: string, options: MeterGroupSearchOptions ): Promise> { - const cachedRepo = new CachedRepository(meterGroupRepository, userId, "meter-groups", CACHE_TTL); - const result = await cachedRepo.search({ + const result = await repoFor(userId).search({ limit: options.limit, orderBy: (options.sortBy ?? "created_at") as any, orderDirection: options.sortOrder ?? "desc", @@ -106,22 +106,16 @@ export const meterGroupService = { }, async getById(userId: string, id: string): Promise { - const cachedRepo = new CachedRepository(meterGroupRepository, userId, "meter-groups", CACHE_TTL); - return cachedRepo.getById(id); + return repoFor(userId).getById(id); }, async update(userId: string, id: string, data: Partial): Promise { - const meterGroup = await meterGroupRepository.getById(id); - if (!meterGroup) { - throw new AppError(404, "Meter group not found"); - } - const cachedRepo = new CachedRepository(meterGroupRepository, userId, "meter-groups", CACHE_TTL); - return cachedRepo.update(id, data); + await getOrThrow(meterGroupRepository.getById.bind(meterGroupRepository), id, "Meter group"); + return repoFor(userId).update(id, data); }, async updateBatch(userId: string, updates: {id: string, data: Partial}[]): Promise { - const cachedRepo = new CachedRepository(meterGroupRepository, userId, "meter-groups", CACHE_TTL); - return cachedRepo.updateBatch(updates); + return repoFor(userId).updateBatch(updates); }, async delete(userId: string, id: string): Promise { @@ -136,15 +130,11 @@ export const meterGroupService = { throw new AppError(409, "Cannot delete meter group: it has active readings. Archive readings first."); } - const cachedRepo = new CachedRepository(meterGroupRepository, userId, "meter-groups", CACHE_TTL); - await cachedRepo.delete(id); + await repoFor(userId).delete(id); }, async softDelete(userId: string, id: string): Promise { - const meterGroup = await meterGroupRepository.getById(id); - if (!meterGroup) { - throw new AppError(404, "Meter group not found"); - } + await getOrThrow(meterGroupRepository.getById.bind(meterGroupRepository), id, "Meter group"); await cascadeDeleteMeterGroup(id); const deleted = await meterGroupRepository.getById(id); @@ -154,10 +144,7 @@ export const meterGroupService = { }, async restore(userId: string, id: string): Promise { - const meterGroup = await meterGroupRepository.getById(id); - if (!meterGroup) { - throw new AppError(404, "Meter group not found"); - } + await getOrThrow(meterGroupRepository.getById.bind(meterGroupRepository), id, "Meter group"); // cascadeRestoreMeterGroup already refreshes id caches and invalidates list // caches (wholesale, for all users) for meter-groups/readings/billings — // appending here would race with that invalidation and risk duplicates. @@ -168,21 +155,14 @@ export const meterGroupService = { }, /** - * @deprecated Meter-group-level version tracking is being replaced by per-property version - * tracking on `Property.meter_groups[entry]` (submeters already migrated; main meters pending - * backfill). This endpoint remains functional for main meters in the interim, but new - * integrations should not depend on `MeterGroup.current_version`/`versions`. + * Submeter version tracking has moved to `Property.meter_groups[entry]` + * (see `propertyService.recordMeterGroupReset`) — main-meter resets still + * route through this endpoint and remain the active source of truth for + * `Reading.meter_version` (see `reading.service.ts`), so it is not + * deprecated despite the field-level `@deprecated` notes on the model. */ async recordReset(userId: string, id: string): Promise { - logger.warn( - {userId, meterGroupId: id}, - "[Deprecated] meterGroupService.recordReset: meter-group-level version tracking is being replaced by per-property tracking on Property.meter_groups[entry]" - ); - - const meterGroup = await meterGroupRepository.getById(id); - if (!meterGroup) { - throw new AppError(404, "Meter group not found"); - } + const meterGroup = await getOrThrow(meterGroupRepository.getById.bind(meterGroupRepository), id, "Meter group"); const latestReadingsResult = await readingRepository.search({ limit: 1, @@ -211,8 +191,7 @@ export const meterGroupService = { current_version: currentVersion + 1, versions: updatedVersions, }; - const cachedRepo = new CachedRepository(meterGroupRepository, userId, "meter-groups", CACHE_TTL); - return cachedRepo.update(id, updatePayload); + return repoFor(userId).update(id, updatePayload); }, /** diff --git a/api/functions/src/features/photo-settings/photo-settings.controller.ts b/api/functions/src/features/photo-settings/photo-settings.controller.ts deleted file mode 100644 index 0f744e6..0000000 --- a/api/functions/src/features/photo-settings/photo-settings.controller.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type {AuthenticatedRequest} from "../../utils/auth.util"; -import {Response} from "express"; -import {photoSettingsService} from "./photo-settings.service"; -import {UpsertPhotoSettingsDTO} from "./photo-settings.dto"; -import {AppError} from "../../utils/error.util"; - -export const getPhotoSettings = async (req: AuthenticatedRequest, res: Response): Promise => { - const userId = req.user?.userId; - if (!userId) throw new AppError(401, "User not authenticated"); - const result = await photoSettingsService.get(userId); - res.status(200).json(result); -}; - -export const upsertPhotoSettings = async (req: AuthenticatedRequest, res: Response): Promise => { - const userId = req.user?.userId; - if (!userId) throw new AppError(401, "User not authenticated"); - const data = req.body as UpsertPhotoSettingsDTO; - const result = await photoSettingsService.upsert(userId, data); - res.status(200).json(result); -}; diff --git a/api/functions/src/features/photo-settings/photo-settings.dto.ts b/api/functions/src/features/photo-settings/photo-settings.dto.ts deleted file mode 100644 index e2957a8..0000000 --- a/api/functions/src/features/photo-settings/photo-settings.dto.ts +++ /dev/null @@ -1,14 +0,0 @@ -import {z} from "zod"; - -export const UpsertPhotoSettingsDTOSchema = z.object({ - savePhotos: z.boolean(), -}); -export type UpsertPhotoSettingsDTO = z.infer; - -export interface PhotoSettingsResponseDTO { - // Whether to persist meter-reading photos (image_url) when creating readings. - // Defaults to false — OCR suggest still works either way, but the photo - // itself is discarded before submission unless explicitly enabled. Bill / - // billing-cycle photos are never persisted regardless of this setting. - savePhotos: boolean; -} diff --git a/api/functions/src/features/photo-settings/photo-settings.model.ts b/api/functions/src/features/photo-settings/photo-settings.model.ts deleted file mode 100644 index 3baa4d7..0000000 --- a/api/functions/src/features/photo-settings/photo-settings.model.ts +++ /dev/null @@ -1,5 +0,0 @@ -import {BaseModel} from "../../utils/model.util"; - -export interface PhotoSettings extends BaseModel { - save_photos: boolean; -} diff --git a/api/functions/src/features/photo-settings/photo-settings.repository.ts b/api/functions/src/features/photo-settings/photo-settings.repository.ts deleted file mode 100644 index 2eeb269..0000000 --- a/api/functions/src/features/photo-settings/photo-settings.repository.ts +++ /dev/null @@ -1,19 +0,0 @@ -import {PhotoSettings} from "./photo-settings.model"; -import {COLLECTIONS} from "../../constants/collection.constants"; -import {getDocument, setDocument, updateDocument} from "../../lib/firestore.lib"; -import {WithoutBaseModel} from "../../utils/model.util"; - -// One settings document per user, keyed by userId (same pattern as llm-config). -export const photoSettingsRepository = { - async getByUserId(userId: string): Promise { - return getDocument(COLLECTIONS.PHOTO_SETTINGS, userId); - }, - - async create(userId: string, data: WithoutBaseModel): Promise { - return setDocument(COLLECTIONS.PHOTO_SETTINGS, userId, data); - }, - - async update(userId: string, data: Partial>): Promise { - return updateDocument(COLLECTIONS.PHOTO_SETTINGS, userId, data); - }, -}; diff --git a/api/functions/src/features/photo-settings/photo-settings.route.ts b/api/functions/src/features/photo-settings/photo-settings.route.ts deleted file mode 100644 index 0fdc9ad..0000000 --- a/api/functions/src/features/photo-settings/photo-settings.route.ts +++ /dev/null @@ -1,16 +0,0 @@ -import {Router} from "express"; -import {getPhotoSettings, upsertPhotoSettings} from "./photo-settings.controller"; -import {UpsertPhotoSettingsDTOSchema} from "./photo-settings.dto"; -import {validateRequest} from "../../middlewares/validate-request.middleware"; - -const router = Router(); - -router.get("/", getPhotoSettings); - -router.patch( - "/", - validateRequest({body: UpsertPhotoSettingsDTOSchema}), - upsertPhotoSettings -); - -export const photoSettingsRouter = router; diff --git a/api/functions/src/features/photo-settings/photo-settings.service.ts b/api/functions/src/features/photo-settings/photo-settings.service.ts deleted file mode 100644 index 102bbe3..0000000 --- a/api/functions/src/features/photo-settings/photo-settings.service.ts +++ /dev/null @@ -1,29 +0,0 @@ -import {photoSettingsRepository} from "./photo-settings.repository"; -import {UpsertPhotoSettingsDTO, PhotoSettingsResponseDTO} from "./photo-settings.dto"; -import {PhotoSettings} from "./photo-settings.model"; - -const DEFAULT_SAVE_PHOTOS = false; - -function toResponseDTO(config: PhotoSettings): PhotoSettingsResponseDTO { - return {savePhotos: config.save_photos}; -} - -export const photoSettingsService = { - // Unlike llm-config, this always resolves — no config yet just means the - // (disabled-by-default) default applies, not a 404. - async get(userId: string): Promise { - const config = await photoSettingsRepository.getByUserId(userId); - return config ? toResponseDTO(config) : {savePhotos: DEFAULT_SAVE_PHOTOS}; - }, - - async upsert(userId: string, data: UpsertPhotoSettingsDTO): Promise { - const existing = await photoSettingsRepository.getByUserId(userId); - const payload = {save_photos: data.savePhotos}; - - const result = existing ? - await photoSettingsRepository.update(userId, payload) : - await photoSettingsRepository.create(userId, payload as Omit); - - return toResponseDTO(result); - }, -}; diff --git a/api/functions/src/features/photo-settings/photo-settings.swagger.ts b/api/functions/src/features/photo-settings/photo-settings.swagger.ts deleted file mode 100644 index cb23874..0000000 --- a/api/functions/src/features/photo-settings/photo-settings.swagger.ts +++ /dev/null @@ -1,54 +0,0 @@ -export const photoSettingsPaths = { - "/photo-settings": { - get: { - tags: ["Photo Settings"], - summary: "Get the current user's photo-saving preference", - description: "Whether meter-reading photos should be persisted (image_url) when creating readings. Defaults to false (disabled) if never configured — OCR suggest still works, the photo is just discarded before submission. Bill / billing-cycle photos are never persisted regardless of this setting.", - security: [{BearerAuth: []}], - responses: { - "200": { - description: "Photo-saving preference", - content: { - "application/json": { - schema: {$ref: "#/components/schemas/PhotoSettingsResponse"}, - }, - }, - }, - "401": { - description: "Unauthorized", - content: {"application/json": {schema: {$ref: "#/components/schemas/ErrorResponse"}}}, - }, - }, - }, - patch: { - tags: ["Photo Settings"], - summary: "Set the current user's photo-saving preference", - security: [{BearerAuth: []}], - requestBody: { - content: { - "application/json": { - schema: {$ref: "#/components/schemas/UpsertPhotoSettingsRequest"}, - }, - }, - }, - responses: { - "200": { - description: "Updated photo-saving preference", - content: { - "application/json": { - schema: {$ref: "#/components/schemas/PhotoSettingsResponse"}, - }, - }, - }, - "400": { - description: "Validation error", - content: {"application/json": {schema: {$ref: "#/components/schemas/ValidationErrorResponse"}}}, - }, - "401": { - description: "Unauthorized", - content: {"application/json": {schema: {$ref: "#/components/schemas/ErrorResponse"}}}, - }, - }, - }, - }, -}; diff --git a/api/functions/src/features/property/property.controller.ts b/api/functions/src/features/property/property.controller.ts index a6486a1..f5fc85f 100644 --- a/api/functions/src/features/property/property.controller.ts +++ b/api/functions/src/features/property/property.controller.ts @@ -18,8 +18,7 @@ export const createProperty = async ( res: Response ): Promise => { const data = req.body as CreatePropertyDTO; - const userId = req.user?.userId; - if (!userId) throw new AppError(401, "User not authenticated"); + const userId = req.user!.userId; const result = await propertyService.create(userId, data); res.status(201).json(result); }; @@ -29,8 +28,7 @@ export const createBatchProperties = async ( res: Response ): Promise => { const data = req.body as CreatePropertyBatchDTO; - const userId = req.user?.userId; - if (!userId) throw new AppError(401, "User not authenticated"); + const userId = req.user!.userId; const result = await propertyService.createBatch(userId, data); res.status(201).json(result); }; @@ -40,8 +38,7 @@ export const getPropertyById = async ( res: Response ): Promise => { const {id} = req.params as unknown as PropertyByIdParamsDTO; - const userId = req.user?.userId; - if (!userId) throw new AppError(401, "User not authenticated"); + const userId = req.user!.userId; const property = await propertyService.getById(userId, id); if (!property) { @@ -56,8 +53,7 @@ export const getProperties = async ( res: Response ): Promise => { const query = req.query as unknown as GetPropertiesQueryDTO; - const userId = req.user?.userId; - if (!userId) throw new AppError(401, "User not authenticated"); + const userId = req.user!.userId; const result = await propertyService.search(userId, { roomName: query.roomName, @@ -78,8 +74,7 @@ export const updateProperty = async ( ): Promise => { const {id} = req.params as unknown as PropertyByIdParamsDTO; const data = req.body as UpdatePropertyDTO; - const userId = req.user?.userId; - if (!userId) throw new AppError(401, "User not authenticated"); + const userId = req.user!.userId; const result = await propertyService.update(userId, id, data); res.status(200).json(result); }; @@ -89,8 +84,7 @@ export const updateBatchProperties = async ( res: Response ): Promise => { const updates = req.body as UpdatePropertyBatchDTO; - const userId = req.user?.userId; - if (!userId) throw new AppError(401, "User not authenticated"); + const userId = req.user!.userId; const result = await propertyService.updateBatch(userId, updates); res.status(200).json(result); }; @@ -100,8 +94,7 @@ export const deleteProperty = async ( res: Response ): Promise => { const {id} = req.params as unknown as PropertyByIdParamsDTO; - const userId = req.user?.userId; - if (!userId) throw new AppError(401, "User not authenticated"); + const userId = req.user!.userId; await propertyService.delete(userId, id); res.status(204).send(); }; @@ -111,8 +104,7 @@ export const softDeleteProperty = async ( res: Response ): Promise => { const {id} = req.params as unknown as PropertyByIdParamsDTO; - const userId = req.user?.userId; - if (!userId) throw new AppError(401, "User not authenticated"); + const userId = req.user!.userId; const result = await propertyService.softDelete(userId, id); res.status(200).json(result); }; @@ -122,8 +114,7 @@ export const restoreProperty = async ( res: Response ): Promise => { const {id} = req.params as unknown as PropertyByIdParamsDTO; - const userId = req.user?.userId; - if (!userId) throw new AppError(401, "User not authenticated"); + const userId = req.user!.userId; const result = await propertyService.restore(userId, id); res.status(200).json(result); }; @@ -133,8 +124,6 @@ export const purgeProperty = async ( res: Response ): Promise => { const {id} = req.params as unknown as PropertyByIdParamsDTO; - const userId = req.user?.userId; - if (!userId) throw new AppError(401, "User not authenticated"); await propertyService.purge(id); res.status(204).send(); }; @@ -144,8 +133,7 @@ export const recordPropertyMeterGroupReset = async ( res: Response ): Promise => { const {id, meterGroupId} = req.params as unknown as PropertyMeterGroupResetParamsDTO; - const userId = req.user?.userId; - if (!userId) throw new AppError(401, "User not authenticated"); + const userId = req.user!.userId; const result = await propertyService.recordMeterGroupReset(userId, id, meterGroupId); res.status(200).json(result); }; diff --git a/api/functions/src/features/property/property.service.ts b/api/functions/src/features/property/property.service.ts index ff3438c..56c1660 100644 --- a/api/functions/src/features/property/property.service.ts +++ b/api/functions/src/features/property/property.service.ts @@ -1,4 +1,4 @@ -import {AppError} from "../../utils/error.util"; +import {AppError, getOrThrow} from "../../utils/error.util"; import {PaginatedResult} from "../../utils/pagination.util"; import {propertyRepository} from "./property.repository"; import {CreatePropertyDTO, UpdatePropertyDTO} from "./property.dto"; @@ -15,6 +15,10 @@ import {Timestamp} from "firebase-admin/firestore"; const validator = new PropertyValidator(); const CACHE_TTL = 20 * 60; // 20 minutes +function repoFor(userId: string): CachedRepository { + return new CachedRepository(propertyRepository, userId, "properties", CACHE_TTL); +} + type PropertySearchOptions = { roomName?: string; meterGroupId?: string; @@ -35,29 +39,24 @@ function cleanMeterGroups(meterGroups: Record { await validator.validateCreate(data); - const cachedRepo = new CachedRepository(propertyRepository, userId, "properties", CACHE_TTL); - return cachedRepo.create({...data, meter_groups: cleanMeterGroups(data.meter_groups)}); + return repoFor(userId).create({...data, meter_groups: cleanMeterGroups(data.meter_groups)}); }, async createBatch(userId: string, data: CreatePropertyDTO[]): Promise { await validator.validateBatchCreate(data); - const cachedRepo = new CachedRepository(propertyRepository, userId, "properties", CACHE_TTL); - return cachedRepo.createBatch(data.map((d) => ({...d, meter_groups: cleanMeterGroups(d.meter_groups)}))); + return repoFor(userId).createBatch(data.map((d) => ({...d, meter_groups: cleanMeterGroups(d.meter_groups)}))); }, async getById(userId: string, id: string): Promise { - const cachedRepo = new CachedRepository(propertyRepository, userId, "properties", CACHE_TTL); - return cachedRepo.getById(id); + return repoFor(userId).getById(id); }, async search( userId: string, options: PropertySearchOptions ): Promise> { - const cachedRepo = new CachedRepository(propertyRepository, userId, "properties", CACHE_TTL); - // Need custom filtering for meter_group_id since it's in a nested map - const result = await cachedRepo.search({ + const result = await repoFor(userId).search({ limit: options.limit, orderBy: (options.sortBy ?? "created_at") as any, orderDirection: options.sortOrder ?? "desc", @@ -84,43 +83,29 @@ export const propertyService = { }, async update(userId: string, id: string, data: UpdatePropertyDTO): Promise { - const property = await propertyRepository.getById(id); - - if (!property) { - throw new AppError(404, "Property not found"); - } + const property = await getOrThrow(propertyRepository.getById.bind(propertyRepository), id, "Property"); await validator.validateUpdate(property, data); - const cachedRepo = new CachedRepository(propertyRepository, userId, "properties", CACHE_TTL); const cleanData = data.meter_groups ? {...data, meter_groups: cleanMeterGroups(data.meter_groups)} : (data as any); - return cachedRepo.update(id, cleanData); + return repoFor(userId).update(id, cleanData); }, async updateBatch(userId: string, updates: { id: string; data: UpdatePropertyDTO }[]): Promise { await validator.validateBatchUpdate(updates); - const cachedRepo = new CachedRepository(propertyRepository, userId, "properties", CACHE_TTL); const cleanUpdates = updates.map((u) => ({ id: u.id, data: u.data.meter_groups ? {...u.data, meter_groups: cleanMeterGroups(u.data.meter_groups)} : (u.data as any), })); - return cachedRepo.updateBatch(cleanUpdates); + return repoFor(userId).updateBatch(cleanUpdates); }, async delete(userId: string, id: string): Promise { - const property = await propertyRepository.getById(id); - if (!property) { - throw new AppError(404, "Property not found"); - } - - const cachedRepo = new CachedRepository(propertyRepository, userId, "properties", CACHE_TTL); - await cachedRepo.delete(id); + await getOrThrow(propertyRepository.getById.bind(propertyRepository), id, "Property"); + await repoFor(userId).delete(id); }, async softDelete(userId: string, id: string): Promise { - const property = await propertyRepository.getById(id); - if (!property) { - throw new AppError(404, "Property not found"); - } + await getOrThrow(propertyRepository.getById.bind(propertyRepository), id, "Property"); await cascadeDeleteProperty(id); const deleted = await propertyRepository.getById(id); @@ -130,10 +115,7 @@ export const propertyService = { }, async restore(userId: string, id: string): Promise { - const property = await propertyRepository.getById(id); - if (!property) { - throw new AppError(404, "Property not found"); - } + await getOrThrow(propertyRepository.getById.bind(propertyRepository), id, "Property"); // cascadeRestoreProperty already refreshes id caches and invalidates list // caches (wholesale, for all users) for properties/readings/billings — @@ -159,10 +141,7 @@ export const propertyService = { * own MeterGroupEntry version tracking (main meters stay on the meter group). */ async recordMeterGroupReset(userId: string, propertyId: string, meterGroupId: string): Promise { - const property = await propertyRepository.getById(propertyId); - if (!property) { - throw new AppError(404, "Property not found"); - } + const property = await getOrThrow(propertyRepository.getById.bind(propertyRepository), propertyId, "Property"); const entryKey = Object.keys(property.meter_groups).find( (key) => property.meter_groups[key].meter_group_id === meterGroupId @@ -207,8 +186,7 @@ export const propertyService = { versions: updatedVersions, }; - const cachedRepo = new CachedRepository(propertyRepository, userId, "properties", CACHE_TTL); - return cachedRepo.update(propertyId, { + return repoFor(userId).update(propertyId, { meter_groups: { ...property.meter_groups, [entryKey]: updatedEntry, diff --git a/api/functions/src/features/reading/reading.controller.ts b/api/functions/src/features/reading/reading.controller.ts index 464d7db..31344a9 100644 --- a/api/functions/src/features/reading/reading.controller.ts +++ b/api/functions/src/features/reading/reading.controller.ts @@ -18,8 +18,7 @@ export const createReading = async ( res: Response ): Promise => { const data = req.body as CreateReadingDTO; - const userId = req.user?.userId; - if (!userId) throw new AppError(401, "User not authenticated"); + const userId = req.user!.userId; const result = await readingService.create(userId, data); res.status(201).json(result); }; @@ -29,8 +28,7 @@ export const createSeedReading = async ( res: Response ): Promise => { const data = req.body as CreateSeedReadingDTO; - const userId = req.user?.userId; - if (!userId) throw new AppError(401, "User not authenticated"); + const userId = req.user!.userId; const result = await readingService.createSeed(userId, data); res.status(201).json(result); }; @@ -40,8 +38,7 @@ export const createBatchReadings = async ( res: Response ): Promise => { const data = req.body as CreateReadingDTO[]; - const userId = req.user?.userId; - if (!userId) throw new AppError(401, "User not authenticated"); + const userId = req.user!.userId; const result = await readingService.createBatch(userId, data); res.status(201).json(result); }; @@ -51,8 +48,7 @@ export const getReadingById = async ( res: Response ): Promise => { const {id} = req.params as unknown as ReadingByIdParamsDTO; - const userId = req.user?.userId; - if (!userId) throw new AppError(401, "User not authenticated"); + const userId = req.user!.userId; const reading = await readingService.getById(userId, id); if (!reading) { @@ -67,8 +63,7 @@ export const getReadings = async ( res: Response ): Promise => { const query = req.query as unknown as GetReadingsQueryDTO; - const userId = req.user?.userId; - if (!userId) throw new AppError(401, "User not authenticated"); + const userId = req.user!.userId; const result = await readingService.search(userId, { meterGroupId: query.meterGroupId, @@ -90,8 +85,7 @@ export const updateReading = async ( ): Promise => { const {id} = req.params as unknown as ReadingByIdParamsDTO; const data = req.body as Partial; - const userId = req.user?.userId; - if (!userId) throw new AppError(401, "User not authenticated"); + const userId = req.user!.userId; const result = await readingService.update(userId, id, data); res.status(200).json(result); }; @@ -101,8 +95,7 @@ export const updateBatchReadings = async ( res: Response ): Promise => { const updates = req.body as { id: string; data: Partial }[]; - const userId = req.user?.userId; - if (!userId) throw new AppError(401, "User not authenticated"); + const userId = req.user!.userId; const result = await readingService.updateBatch(userId, updates); res.status(200).json(result); }; @@ -112,8 +105,7 @@ export const deleteReading = async ( res: Response ): Promise => { const {id} = req.params as unknown as ReadingByIdParamsDTO; - const userId = req.user?.userId; - if (!userId) throw new AppError(401, "User not authenticated"); + const userId = req.user!.userId; await readingService.delete(userId, id); res.status(204).send(); }; @@ -123,8 +115,7 @@ export const softDeleteReading = async ( res: Response ): Promise => { const {id} = req.params as unknown as ReadingByIdParamsDTO; - const userId = req.user?.userId; - if (!userId) throw new AppError(401, "User not authenticated"); + const userId = req.user!.userId; const result = await readingService.softDelete(userId, id); res.status(200).json(result); }; @@ -134,8 +125,7 @@ export const restoreReading = async ( res: Response ): Promise => { const {id} = req.params as unknown as ReadingByIdParamsDTO; - const userId = req.user?.userId; - if (!userId) throw new AppError(401, "User not authenticated"); + const userId = req.user!.userId; const result = await readingService.restore(userId, id); res.status(200).json(result); }; @@ -145,8 +135,6 @@ export const purgeReading = async ( res: Response ): Promise => { const {id} = req.params as unknown as ReadingByIdParamsDTO; - const userId = req.user?.userId; - if (!userId) throw new AppError(401, "User not authenticated"); await readingService.purge(id); res.status(204).send(); }; @@ -156,8 +144,7 @@ export const ocrReading = async ( res: Response ): Promise => { const data = req.body as OcrReadingDTO; - const userId = req.user?.userId; - if (!userId) throw new AppError(401, "User not authenticated"); + const userId = req.user!.userId; const extracted = await ImageExtractionService.extractReadingFromImage(data.image_url, userId); res.status(200).json({suggested_reading_amount: extracted.reading_amount}); }; diff --git a/api/functions/src/features/reading/reading.dto.ts b/api/functions/src/features/reading/reading.dto.ts index f58d595..d55c0d7 100644 --- a/api/functions/src/features/reading/reading.dto.ts +++ b/api/functions/src/features/reading/reading.dto.ts @@ -1,5 +1,6 @@ import {z} from "zod"; import {parseTimestamp} from "../../utils/firestore.util"; +import {ImageUrlSchema} from "../../utils/image-url.util"; // Create DTOS export const CreateReadingDTOSchema = z.object({ @@ -16,7 +17,6 @@ export const CreateReadingDTOSchema = z.object({ {message: "reading_date must be a valid date"} ) .transform((val) => parseTimestamp(val)), - image_url: z.url().optional(), }); export type CreateReadingDTO = z.infer; @@ -25,7 +25,7 @@ export type CreateSeedReadingDTO = z.infer; // OCR DTOS export const OcrReadingDTOSchema = z.object({ - image_url: z.url(), + image_url: ImageUrlSchema, }); export type OcrReadingDTO = z.infer; diff --git a/api/functions/src/features/reading/reading.model.ts b/api/functions/src/features/reading/reading.model.ts index b211574..d6e07c8 100644 --- a/api/functions/src/features/reading/reading.model.ts +++ b/api/functions/src/features/reading/reading.model.ts @@ -6,6 +6,5 @@ export interface Reading extends BaseModel { property_id: string; reading_amount: number; reading_date: Timestamp; - image_url?: string; meter_version: number; } diff --git a/api/functions/src/features/reports/reports.controller.ts b/api/functions/src/features/reports/reports.controller.ts index 395be5c..b5cd645 100644 --- a/api/functions/src/features/reports/reports.controller.ts +++ b/api/functions/src/features/reports/reports.controller.ts @@ -1,12 +1,10 @@ import {Response} from "express"; -import {AppError} from "../../utils/error.util"; import {getSummary, getConsumption, getBillingTrends, getCollectionStatus} from "./reports.service"; import type {ReportQueryDTO} from "./reports.dto"; import type {AuthenticatedRequest} from "../../utils/auth.util"; export async function getSummaryReport(req: AuthenticatedRequest, res: Response) { - const userId = req.user?.userId; - if (!userId) throw new AppError(401, "User not authenticated"); + const userId = req.user!.userId; const query = req.query as ReportQueryDTO; const summary = await getSummary(userId, query); res.set("Cache-Control", "no-store"); @@ -14,8 +12,7 @@ export async function getSummaryReport(req: AuthenticatedRequest, res: Response) } export async function getConsumptionReport(req: AuthenticatedRequest, res: Response) { - const userId = req.user?.userId; - if (!userId) throw new AppError(401, "User not authenticated"); + const userId = req.user!.userId; const query = req.query as ReportQueryDTO; const consumption = await getConsumption(userId, query); res.set("Cache-Control", "no-store"); @@ -23,8 +20,7 @@ export async function getConsumptionReport(req: AuthenticatedRequest, res: Respo } export async function getBillingTrendsReport(req: AuthenticatedRequest, res: Response) { - const userId = req.user?.userId; - if (!userId) throw new AppError(401, "User not authenticated"); + const userId = req.user!.userId; const query = req.query as ReportQueryDTO; const trends = await getBillingTrends(userId, query); res.set("Cache-Control", "no-store"); @@ -32,8 +28,7 @@ export async function getBillingTrendsReport(req: AuthenticatedRequest, res: Res } export async function getCollectionStatusReport(req: AuthenticatedRequest, res: Response) { - const userId = req.user?.userId; - if (!userId) throw new AppError(401, "User not authenticated"); + const userId = req.user!.userId; const query = req.query as ReportQueryDTO; const status = await getCollectionStatus(userId, query); res.set("Cache-Control", "no-store"); diff --git a/api/functions/src/features/tenant/tenant.controller.ts b/api/functions/src/features/tenant/tenant.controller.ts index a5593bc..63c7037 100644 --- a/api/functions/src/features/tenant/tenant.controller.ts +++ b/api/functions/src/features/tenant/tenant.controller.ts @@ -17,8 +17,7 @@ export const createTenant = async ( res: Response ): Promise => { const data = req.body as CreateTenantDTO; - const userId = req.user?.userId; - if (!userId) throw new AppError(401, "User not authenticated"); + const userId = req.user!.userId; const result = await tenantService.create(userId, data); res.status(201).json(result); }; @@ -28,8 +27,7 @@ export const createBatchTenants = async ( res: Response ): Promise => { const data = req.body as CreateTenantBatchDTO; - const userId = req.user?.userId; - if (!userId) throw new AppError(401, "User not authenticated"); + const userId = req.user!.userId; const result = await tenantService.createBatch(userId, data); res.status(201).json(result); }; @@ -39,8 +37,7 @@ export const getTenantById = async ( res: Response ): Promise => { const {id} = req.params as unknown as TenantByIdParamsDTO; - const userId = req.user?.userId; - if (!userId) throw new AppError(401, "User not authenticated"); + const userId = req.user!.userId; const tenant = await tenantService.getById(userId, id); if (!tenant) { @@ -55,8 +52,7 @@ export const getTenants = async ( res: Response ): Promise => { const query = req.query as unknown as GetTenantsQueryDTO; - const userId = req.user?.userId; - if (!userId) throw new AppError(401, "User not authenticated"); + const userId = req.user!.userId; const result = await tenantService.search(userId, { tenantName: query.tenantName, @@ -77,8 +73,7 @@ export const updateTenant = async ( ): Promise => { const {id} = req.params as unknown as TenantByIdParamsDTO; const data = req.body as UpdateTenantDTO; - const userId = req.user?.userId; - if (!userId) throw new AppError(401, "User not authenticated"); + const userId = req.user!.userId; const result = await tenantService.update(userId, id, data); res.status(200).json(result); }; @@ -88,8 +83,7 @@ export const updateBatchTenants = async ( res: Response ): Promise => { const updates = req.body as UpdateTenantBatchDTO; - const userId = req.user?.userId; - if (!userId) throw new AppError(401, "User not authenticated"); + const userId = req.user!.userId; const result = await tenantService.updateBatch(userId, updates); res.status(200).json(result); }; @@ -99,8 +93,7 @@ export const deleteTenant = async ( res: Response ): Promise => { const {id} = req.params as unknown as TenantByIdParamsDTO; - const userId = req.user?.userId; - if (!userId) throw new AppError(401, "User not authenticated"); + const userId = req.user!.userId; await tenantService.delete(userId, id); res.status(204).send(); }; @@ -110,8 +103,7 @@ export const softDeleteTenant = async ( res: Response ): Promise => { const {id} = req.params as unknown as TenantByIdParamsDTO; - const userId = req.user?.userId; - if (!userId) throw new AppError(401, "User not authenticated"); + const userId = req.user!.userId; const result = await tenantService.softDelete(userId, id); res.status(200).json(result); }; @@ -121,8 +113,7 @@ export const restoreTenant = async ( res: Response ): Promise => { const {id} = req.params as unknown as TenantByIdParamsDTO; - const userId = req.user?.userId; - if (!userId) throw new AppError(401, "User not authenticated"); + const userId = req.user!.userId; const result = await tenantService.restore(userId, id); res.status(200).json(result); }; @@ -132,8 +123,7 @@ export const purgeTenant = async ( res: Response ): Promise => { const {id} = req.params as unknown as TenantByIdParamsDTO; - const userId = req.user?.userId; - if (!userId) throw new AppError(401, "User not authenticated"); + const userId = req.user!.userId; await tenantService.purge(userId, id); res.status(204).send(); }; diff --git a/api/functions/src/features/tenant/tenant.service.ts b/api/functions/src/features/tenant/tenant.service.ts index bb50562..8b4dd9e 100644 --- a/api/functions/src/features/tenant/tenant.service.ts +++ b/api/functions/src/features/tenant/tenant.service.ts @@ -1,5 +1,5 @@ import {Timestamp} from "firebase-admin/firestore"; -import {AppError} from "../../utils/error.util"; +import {AppError, getOrThrow} from "../../utils/error.util"; import {PaginatedResult} from "../../utils/pagination.util"; import {tenantRepository} from "./tenant.repository"; import {CreateTenantDTO, UpdateTenantDTO} from "./tenant.dto"; @@ -14,6 +14,10 @@ import {Property} from "../property/property.model"; const validator = new TenantValidator(); const CACHE_TTL = 20 * 60; // 20 minutes +function repoFor(userId: string): CachedRepository { + return new CachedRepository(tenantRepository, userId, "tenants", CACHE_TTL); +} + /** * Atomically re-checks the tenant-count cap and creates the tenant doc inside * one Firestore transaction. This closes the TOCTOU race where two concurrent @@ -97,15 +101,14 @@ export const tenantService = { const property = await validator.validateCreate(data); const tenant = await createTenantWithCapacityCheck(data.property_id, property.tenant_amount, data); - const cachedRepo = new CachedRepository(tenantRepository, userId, "tenants", CACHE_TTL); - await cachedRepo.cacheCreatedItem(tenant); + await repoFor(userId).cacheCreatedItem(tenant); return tenant; }, async createBatch(userId: string, data: CreateTenantDTO[]): Promise { const propertyMap = await validator.validateBatchCreate(data); - const cachedRepo = new CachedRepository(tenantRepository, userId, "tenants", CACHE_TTL); + const cachedRepo = repoFor(userId); // Sequential, one transaction per item (not a single batch write): each item // re-checks the tenant-count cap for its property inside its own transaction, @@ -125,13 +128,11 @@ export const tenantService = { }, async getById(userId: string, id: string): Promise { - const cachedRepo = new CachedRepository(tenantRepository, userId, "tenants", CACHE_TTL); - return cachedRepo.getById(id); + return repoFor(userId).getById(id); }, async search(userId: string, options: TenantSearchOptions): Promise> { - const cachedRepo = new CachedRepository(tenantRepository, userId, "tenants", CACHE_TTL); - return cachedRepo.search({ + return repoFor(userId).search({ limit: options.limit, orderBy: (options.sortBy ?? "created_at") as any, orderDirection: options.sortOrder ?? "desc", @@ -145,13 +146,10 @@ export const tenantService = { }, async update(userId: string, id: string, data: UpdateTenantDTO): Promise { - const tenant = await tenantRepository.getById(id); - if (!tenant) { - throw new AppError(404, "Tenant not found"); - } + const tenant = await getOrThrow(tenantRepository.getById.bind(tenantRepository), id, "Tenant"); const newProperty = await validator.validateUpdate(tenant, data); - const cachedRepo = new CachedRepository(tenantRepository, userId, "tenants", CACHE_TTL); + const cachedRepo = repoFor(userId); if (newProperty) { const updated = await updateTenantWithCapacityCheck(tenant, newProperty, data); @@ -164,7 +162,7 @@ export const tenantService = { async updateBatch(userId: string, updates: { id: string; data: UpdateTenantDTO }[]): Promise { const transferMap = await validator.validateBatchUpdate(updates); - const cachedRepo = new CachedRepository(tenantRepository, userId, "tenants", CACHE_TTL); + const cachedRepo = repoFor(userId); const results: Tenant[] = new Array(updates.length); const nonTransferUpdates: { id: string; data: UpdateTenantDTO }[] = []; @@ -195,33 +193,18 @@ export const tenantService = { }, async delete(userId: string, id: string): Promise { - const tenant = await tenantRepository.getById(id); - if (!tenant) { - throw new AppError(404, "Tenant not found"); - } - - const cachedRepo = new CachedRepository(tenantRepository, userId, "tenants", CACHE_TTL); - await cachedRepo.delete(id); + await getOrThrow(tenantRepository.getById.bind(tenantRepository), id, "Tenant"); + await repoFor(userId).delete(id); }, async softDelete(userId: string, id: string): Promise { - const tenant = await tenantRepository.getById(id); - if (!tenant) { - throw new AppError(404, "Tenant not found"); - } - - const cachedRepo = new CachedRepository(tenantRepository, userId, "tenants", CACHE_TTL); - return cachedRepo.softDelete(id); + await getOrThrow(tenantRepository.getById.bind(tenantRepository), id, "Tenant"); + return repoFor(userId).softDelete(id); }, async restore(userId: string, id: string): Promise { - const tenant = await tenantRepository.getById(id); - if (!tenant) { - throw new AppError(404, "Tenant not found"); - } - - const cachedRepo = new CachedRepository(tenantRepository, userId, "tenants", CACHE_TTL); - return cachedRepo.restore(id); + await getOrThrow(tenantRepository.getById.bind(tenantRepository), id, "Tenant"); + return repoFor(userId).restore(id); }, /** @@ -229,7 +212,6 @@ export const tenantService = { * archive-then-purge lifecycle — throws 409 if the tenant is still active. */ async purge(userId: string, id: string): Promise { - const cachedRepo = new CachedRepository(tenantRepository, userId, "tenants", CACHE_TTL); - await cachedRepo.purge(id); + await repoFor(userId).purge(id); }, }; diff --git a/api/functions/src/features/user/user.controller.ts b/api/functions/src/features/user/user.controller.ts index 895e61b..8975d89 100644 --- a/api/functions/src/features/user/user.controller.ts +++ b/api/functions/src/features/user/user.controller.ts @@ -1,11 +1,42 @@ import {Request, Response} from "express"; +import * as admin from "firebase-admin"; import {CreateUserDTO} from "./user.dto"; import {userRepository} from "../auth/auth.repository"; +import {setDocument} from "../../lib/firestore.lib"; +import {COLLECTIONS} from "../../constants/collection.constants"; import {AppError} from "../../utils/error.util"; import type {User} from "../auth/auth.model"; +// Maps Firebase Admin SDK auth error codes to messages matching what the UI +// previously mapped from the client SDK, since account creation now happens +// entirely server-side (the client no longer calls createUserWithEmailAndPassword, +// which used to hijack the acting admin's own session by signing the client SDK +// into the newly created account). +const ADMIN_AUTH_ERROR_MESSAGES: Record = { + "auth/email-already-exists": {status: 409, message: "An account with this email already exists."}, + "auth/invalid-password": {status: 400, message: "Password is too weak. Use at least 8 characters."}, + "auth/invalid-email": {status: 400, message: "Invalid email address."}, +}; + export async function createUser(req: Request, unknown, CreateUserDTO>, res: Response) { - const {uid, role} = req.body; + const {email, password, displayName, role} = req.body; + + let uid: string; + try { + const userRecord = await admin.auth().createUser({ + email, + password, + ...(displayName ? {displayName} : {}), + }); + uid = userRecord.uid; + } catch (err: unknown) { + const code = (err as {code?: string}).code ?? ""; + const mapped = ADMIN_AUTH_ERROR_MESSAGES[code]; + if (mapped) { + throw new AppError(mapped.status, mapped.message); + } + throw new AppError(400, "Failed to create user account"); + } // Check if user profile already exists (non-deleted) const existing = await userRepository.getById(uid); @@ -23,16 +54,17 @@ export async function createUser(req: Request, unknown, Cr user = await userRepository.update(uid, {role}); } } else { - // Create new user + // Create new user, keyed by the Firebase Auth uid — required so that + // requireRole's getById(req.user.userId) (userId being this same uid, from + // the verified ID token) actually finds this profile and its chosen role + // on the new user's first request, instead of GET /auth/me's auto-create + // silently defaulting them to "landlord" because no doc existed at that ID. // Note: email and display_name are empty strings as per audit plan - user = await userRepository.create({ + user = await setDocument(COLLECTIONS.USERS, uid, { email: "", display_name: "", role, - is_deleted: false, - created_at: new Date(), - updated_at: new Date(), - } as unknown as User); + }); } res.status(201).json(user); diff --git a/api/functions/src/features/user/user.dto.ts b/api/functions/src/features/user/user.dto.ts index 73e2883..3f0a48f 100644 --- a/api/functions/src/features/user/user.dto.ts +++ b/api/functions/src/features/user/user.dto.ts @@ -1,7 +1,9 @@ import {z} from "zod"; export const CreateUserDTOSchema = z.object({ - uid: z.string().min(1, "uid is required"), + email: z.email("Invalid email address"), + password: z.string().min(8, "Password must be at least 8 characters"), + displayName: z.string().trim().optional(), role: z.enum(["admin", "landlord", "assistant"], { message: "role must be admin, landlord, or assistant", }), diff --git a/api/functions/src/features/user/user.test.ts b/api/functions/src/features/user/user.test.ts index 2db4623..6bc0473 100644 --- a/api/functions/src/features/user/user.test.ts +++ b/api/functions/src/features/user/user.test.ts @@ -1,6 +1,7 @@ jest.mock('firebase-admin', () => ({ auth: jest.fn().mockReturnValue({ verifyIdToken: jest.fn(), + createUser: jest.fn(), }), firestore: jest.fn().mockReturnValue({ settings: jest.fn(), @@ -18,6 +19,9 @@ jest.mock('../../config/firebase.config', () => ({ firebaseApp: {}, })); jest.mock('../auth/auth.repository'); +jest.mock('../../lib/firestore.lib', () => ({ + setDocument: jest.fn(), +})); import { describe, it, expect, jest, beforeEach } from '@jest/globals'; import request from 'supertest'; @@ -27,6 +31,7 @@ import 'express-async-errors'; import { userRouter } from './user.route'; import { authMiddleware } from '../../middlewares/auth.middleware'; import { userRepository } from '../auth/auth.repository'; +import { setDocument } from '../../lib/firestore.lib'; import { errorHandler } from '../../middlewares/error-handler.middleware'; import * as admin from 'firebase-admin'; @@ -52,17 +57,18 @@ describe('POST /users', () => { jest.clearAllMocks(); }); - it('should create a user with the specified role', async () => { + it('should create the Firebase Auth account server-side and the profile with the specified role', async () => { const newUid = 'new-meter-reader-1'; jest.mocked(admin.auth().verifyIdToken).mockResolvedValue({ uid: 'admin-uid' } as any); + jest.mocked(admin.auth().createUser).mockResolvedValue({ uid: newUid } as any); // Keyed on id (not call order) — the requireRole role-cache is a module-level // singleton that persists across tests, so it may skip calling getById for // 'admin-uid' on later tests, leaving mockResolvedValueOnce queues misaligned. jest.mocked(userRepository.getById).mockImplementation(async (id: string) => { if (id === 'admin-uid') return mockAdminUser as any; // requireRole: fetch caller's role - return null; // createUser: target does not exist + return null; // createUser: target does not exist yet }); - jest.mocked(userRepository.create).mockResolvedValue({ + jest.mocked(setDocument).mockResolvedValue({ id: newUid, email: '', display_name: '', @@ -75,38 +81,58 @@ describe('POST /users', () => { const response = await request(app) .post('/users') .set('Authorization', 'Bearer valid-token') - .send({ uid: newUid, role: 'assistant' }); + .send({ email: 'newuser@test.com', password: 'password123', role: 'assistant' }); expect(response.status).toBe(201); + expect(admin.auth().createUser).toHaveBeenCalledWith( + expect.objectContaining({ email: 'newuser@test.com', password: 'password123' }) + ); + expect(setDocument).toHaveBeenCalledWith( + expect.any(String), + newUid, + expect.objectContaining({ role: 'assistant' }) + ); expect(response.body.id).toBe(newUid); expect(response.body.role).toBe('assistant'); }); - it('should return 409 if user profile already exists', async () => { + it('should return 409 if the email already has a Firebase Auth account', async () => { jest.mocked(admin.auth().verifyIdToken).mockResolvedValue({ uid: 'admin-uid' } as any); - jest.mocked(userRepository.getById).mockImplementation(async (id: string) => { - if (id === 'admin-uid') return mockAdminUser as any; // requireRole - return { ...mockAdminUser, id: 'existing-user', is_deleted: false } as any; // createUser: target exists - }); + jest.mocked(userRepository.getById).mockResolvedValue(mockAdminUser as any); + jest.mocked(admin.auth().createUser).mockRejectedValue({ code: 'auth/email-already-exists' }); const response = await request(app) .post('/users') .set('Authorization', 'Bearer valid-token') - .send({ uid: 'existing-user', role: 'landlord' }); + .send({ email: 'existing@test.com', password: 'password123', role: 'landlord' }); expect(response.status).toBe(409); }); it('should return 400 if role is invalid', async () => { jest.mocked(admin.auth().verifyIdToken).mockResolvedValue({ uid: 'admin-uid' } as any); - jest.mocked(userRepository.getById).mockResolvedValue(mockAdminUser); + jest.mocked(userRepository.getById).mockResolvedValue(mockAdminUser as any); + + const response = await request(app) + .post('/users') + .set('Authorization', 'Bearer valid-token') + .send({ email: 'newuser@test.com', password: 'password123', role: 'invalid-role' }); + + expect(response.status).toBe(400); + expect(admin.auth().createUser).not.toHaveBeenCalled(); + }); + + it('should return 400 if password is too short', async () => { + jest.mocked(admin.auth().verifyIdToken).mockResolvedValue({ uid: 'admin-uid' } as any); + jest.mocked(userRepository.getById).mockResolvedValue(mockAdminUser as any); const response = await request(app) .post('/users') .set('Authorization', 'Bearer valid-token') - .send({ uid: 'some-uid', role: 'invalid-role' }); + .send({ email: 'newuser@test.com', password: 'short', role: 'assistant' }); expect(response.status).toBe(400); + expect(admin.auth().createUser).not.toHaveBeenCalled(); }); it('should return 403 if non-admin tries to create user', async () => { @@ -120,8 +146,9 @@ describe('POST /users', () => { const response = await request(app) .post('/users') .set('Authorization', 'Bearer valid-token') - .send({ uid: 'new-uid', role: 'assistant' }); + .send({ email: 'newuser@test.com', password: 'password123', role: 'assistant' }); expect(response.status).toBe(403); + expect(admin.auth().createUser).not.toHaveBeenCalled(); }); }); diff --git a/api/functions/src/index.ts b/api/functions/src/index.ts index ef14a91..031615c 100644 --- a/api/functions/src/index.ts +++ b/api/functions/src/index.ts @@ -28,7 +28,6 @@ import {imageExtractionRouter} from "./features/image-extraction/image-extractio import {reportsRouter} from "./features/reports/reports.route"; import {llmConfigRouter} from "./features/llm-config/llm-config.route"; import {chatbotRouter} from "./features/chatbot/chatbot.route"; -import {photoSettingsRouter} from "./features/photo-settings/photo-settings.route"; const app = express(); @@ -97,7 +96,6 @@ app.use("/image-extraction", imageExtractionRouter); app.use("/reports", reportsRouter); app.use("/llm-config", llmConfigRouter); app.use("/chatbot", chatbotRouter); -app.use("/photo-settings", photoSettingsRouter); // Error handling app.use(errorHandler); diff --git a/api/functions/src/lib/image-fetch.util.ts b/api/functions/src/lib/image-fetch.util.ts index 69de663..c7bbe46 100644 --- a/api/functions/src/lib/image-fetch.util.ts +++ b/api/functions/src/lib/image-fetch.util.ts @@ -10,68 +10,25 @@ const ALLOWED_IMAGE_MIME_TYPES = new Set([ "image/heif", ]); -function validateImageUrl(imageUrl: string): void { - let parsed: URL; - try { - parsed = new URL(imageUrl); - } catch { - throw new Error("Invalid image URL"); - } - - if (!["http:", "https:"].includes(parsed.protocol)) { - throw new Error("Invalid image URL: only http and https are allowed"); - } - - // Block RFC-1918, loopback, link-local, and GCP metadata service - const blockedPattern = - /^(10\.\d+\.\d+\.\d+|172\.(1[6-9]|2\d|3[01])\.\d+\.\d+|192\.168\.\d+\.\d+|127\.\d+\.\d+\.\d+|169\.254\.\d+\.\d+|::1|localhost|metadata\.google\.internal)/i; - - if (blockedPattern.test(parsed.hostname)) { - throw new Error("Invalid image URL: private or reserved addresses are not allowed"); - } -} - +// Images are only ever provided as inline `data:image/*;base64,...` payloads +// (enforced upstream by ImageUrlSchema) — the server never fetches a +// client-supplied URL, so there is no SSRF surface here to guard against. async function resolveImage(imageUrl: string): Promise<{ buffer: Buffer; mimeType: string }> { - // Handle data URLs (e.g., data:image/jpeg;base64,...) - if (imageUrl.startsWith("data:")) { - const [header, base64Data] = imageUrl.split(","); - if (!base64Data) throw new Error("Invalid data URL format"); - // Extract MIME type from "data:image/png;base64" prefix - const mimeType = header.split(":")[1]?.split(";")[0] ?? "image/jpeg"; - if (!ALLOWED_IMAGE_MIME_TYPES.has(mimeType)) { - throw new Error(`Unsupported image type: ${mimeType}`); - } - const buffer = Buffer.from(base64Data, "base64"); - if (buffer.byteLength > MAX_IMAGE_BYTES) { - throw new Error(`Image exceeds maximum allowed size of ${MAX_IMAGE_BYTES} bytes`); - } - return {buffer, mimeType}; + if (!imageUrl.startsWith("data:")) { + throw new Error("Invalid image URL: only data:image/*;base64,... URLs are allowed"); } - validateImageUrl(imageUrl); - - // Handle regular URLs - const response = await fetch(imageUrl); - if (!response.ok) { - throw new Error(`Failed to fetch image: ${response.statusText}`); - } - - const contentLength = response.headers.get("content-length"); - if (contentLength && Number(contentLength) > MAX_IMAGE_BYTES) { - throw new Error(`Image exceeds maximum allowed size of ${MAX_IMAGE_BYTES} bytes`); - } - - const contentType = response.headers.get("content-type") ?? "image/jpeg"; - const mimeType = contentType.split(";")[0].trim(); + const [header, base64Data] = imageUrl.split(","); + if (!base64Data) throw new Error("Invalid data URL format"); + // Extract MIME type from "data:image/png;base64" prefix + const mimeType = header.split(":")[1]?.split(";")[0] ?? "image/jpeg"; if (!ALLOWED_IMAGE_MIME_TYPES.has(mimeType)) { throw new Error(`Unsupported image type: ${mimeType}`); } - - const buffer = Buffer.from(await response.arrayBuffer()); + const buffer = Buffer.from(base64Data, "base64"); if (buffer.byteLength > MAX_IMAGE_BYTES) { throw new Error(`Image exceeds maximum allowed size of ${MAX_IMAGE_BYTES} bytes`); } - return {buffer, mimeType}; } diff --git a/api/functions/src/utils/cascade-delete.util.ts b/api/functions/src/utils/cascade-delete.util.ts index fda861d..d5d4f04 100644 --- a/api/functions/src/utils/cascade-delete.util.ts +++ b/api/functions/src/utils/cascade-delete.util.ts @@ -6,6 +6,8 @@ import {cacheDel, cacheDelPattern} from "./cache.util"; import {AppError} from "./error.util"; import {readingLockId, isAlreadyExistsError} from "../features/reading/reading.util"; +type CascadeMode = "soft-delete" | "restore" | "purge"; + /** * Release the "one reading per meter_group+property+month" lock doc (see * reading.util.ts) for a reading being soft-deleted, so a legitimate @@ -75,6 +77,103 @@ async function invalidateListCaches(featureNames: string[]): Promise { await Promise.all(featureNames.map((featureName) => cacheDelPattern(`utilitool:${featureName}:all:*`))); } +/** Soft-delete/restore/hard-delete a single doc ref per cascade mode. */ +function mutateDoc(txn: FirebaseFirestore.Transaction, ref: FirebaseFirestore.DocumentReference, mode: CascadeMode): void { + if (mode === "purge") txn.delete(ref); + else if (mode === "soft-delete") txn.update(ref, {is_deleted: true, deleted_at: new Date()}); + else txn.update(ref, {is_deleted: false, deleted_at: null}); +} + +function assertExists(snap: FirebaseFirestore.DocumentSnapshot, label: string): void { + if (!snap.exists) throw new AppError(404, `${label} not found`); +} + +function assertArchivedForPurge(snap: FirebaseFirestore.DocumentSnapshot, label: string): void { + assertExists(snap, label); + if (snap.data()?.is_deleted !== true) { + throw new AppError(409, `Cannot permanently delete an active ${label.toLowerCase()}. Archive it first.`); + } +} + +/** + * Query readings by `field == value` (scoped to the appropriate is_deleted + * state for the given mode) and mutate each one in the transaction, + * releasing/reclaiming its month lock along the way. Returns the matched IDs. + */ +async function cascadeReadingsBy( + txn: FirebaseFirestore.Transaction, + field: "property_id" | "meter_group_id", + value: string, + mode: CascadeMode +): Promise { + const snap = await collectionRef(COLLECTIONS.READINGS) + .where(field, "==", value) + .where("is_deleted", "==", mode !== "soft-delete") + .get(); + + for (const doc of snap.docs) { + mutateDoc(txn, doc.ref, mode); + if (mode === "purge") continue; + const data = doc.data(); + if (mode === "soft-delete") releaseReadingLock(txn, data.meter_group_id, data.property_id, data.reading_date); + else reclaimReadingLock(txn, data.meter_group_id, data.property_id, data.reading_date); + } + + return snap.docs.map((doc) => doc.id); +} + +/** Query billings directly by property_id and mutate each in the transaction. */ +async function cascadeBillingsByPropertyId( + txn: FirebaseFirestore.Transaction, + propertyId: string, + mode: CascadeMode +): Promise { + const snap = await collectionRef(COLLECTIONS.BILLINGS) + .where("property_id", "==", propertyId) + .where("is_deleted", "==", mode !== "soft-delete") + .get(); + + snap.docs.forEach((doc) => mutateDoc(txn, doc.ref, mode)); + return snap.docs.map((doc) => doc.id); +} + +/** + * Find billings referencing any of the given reading IDs (as previous or + * current reading) and mutate each in the transaction. Skips the query + * entirely when there are no reading IDs to match, avoiding a wasted scan. + */ +async function cascadeBillingsByReadingIds( + txn: FirebaseFirestore.Transaction, + readingIds: string[], + mode: CascadeMode +): Promise { + if (readingIds.length === 0) return []; + + const snap = await collectionRef(COLLECTIONS.BILLINGS).where("is_deleted", "==", mode !== "soft-delete").get(); + + const idSet = new Set(readingIds); + const matched = snap.docs.filter((doc) => { + const billing = doc.data(); + return idSet.has(billing.previous_reading_id) || idSet.has(billing.current_reading_id); + }); + + matched.forEach((doc) => mutateDoc(txn, doc.ref, mode)); + return matched.map((doc) => doc.id); +} + +async function refreshCaches( + entityCollectionCacheName: string, + entityId: string, + readingIds: string[], + billingIds: string[], + listCacheNames: string[] +): Promise { + await cacheDel(`utilitool:${entityCollectionCacheName}:id:${entityId}`); + await Promise.all(readingIds.map((id) => cacheDel(`utilitool:readings:id:${id}`))); + await Promise.all(billingIds.map((id) => cacheDel(`utilitool:billings:id:${id}`))); + await invalidateListCaches(listCacheNames); +} + export interface CascadeDeleteSummary { primary: number; // The entity being deleted (always 1) readings?: number; @@ -86,63 +185,21 @@ export interface CascadeDeleteSummary { * Uses a transaction for atomicity. */ export async function cascadeDeleteProperty(propertyId: string): Promise { - const summary: CascadeDeleteSummary = {primary: 1, readings: 0, billings: 0}; - const readingIds: string[] = []; - const billingIds: string[] = []; + let readingIds: string[] = []; + let billingIds: string[] = []; await firestore.runTransaction(async (txn) => { const propertyRef = firestore.collection(COLLECTIONS.PROPERTIES).doc(propertyId); - const propertySnap = await txn.get(propertyRef); - - if (!propertySnap.exists) { - throw new Error("Property not found"); - } - - // Soft-delete property + assertExists(await txn.get(propertyRef), "Property"); txn.update(propertyRef, {is_deleted: true, deleted_at: new Date()}); - // Find all readings for this property - const readingsSnap = await collectionRef(COLLECTIONS.READINGS) - .where("property_id", "==", propertyId) - .where("is_deleted", "==", false) - .get(); - - const foundReadingIds = readingsSnap.docs.map((doc) => doc.id); - readingIds.push(...foundReadingIds); - summary.readings = foundReadingIds.length; - - // Soft-delete all readings + release their month locks - for (const readingDoc of readingsSnap.docs) { - txn.update(readingDoc.ref, {is_deleted: true, deleted_at: new Date()}); - const readingData = readingDoc.data(); - releaseReadingLock(txn, readingData.meter_group_id, propertyId, readingData.reading_date); - } - - // Find all billings for this property - const billingsSnap = await collectionRef(COLLECTIONS.BILLINGS) - .where("property_id", "==", propertyId) - .where("is_deleted", "==", false) - .get(); - - const foundBillingIds = billingsSnap.docs.map((doc) => doc.id); - billingIds.push(...foundBillingIds); - summary.billings = foundBillingIds.length; - - // Soft-delete all billings - for (const billingDoc of billingsSnap.docs) { - txn.update(billingDoc.ref, {is_deleted: true, deleted_at: new Date()}); - } + readingIds = await cascadeReadingsBy(txn, "property_id", propertyId, "soft-delete"); + billingIds = await cascadeBillingsByPropertyId(txn, propertyId, "soft-delete"); }); - // Clear id caches after txn commits - await cacheDel(`utilitool:properties:id:${propertyId}`); - await Promise.all(readingIds.map((readingId) => cacheDel(`utilitool:readings:id:${readingId}`))); - await Promise.all(billingIds.map((billingId) => cacheDel(`utilitool:billings:id:${billingId}`))); - - // Clear list caches so cascaded readings/billings stop appearing in active lists - await invalidateListCaches(["properties", "readings", "billings"]); + await refreshCaches("properties", propertyId, readingIds, billingIds, ["properties", "readings", "billings"]); - return summary; + return {primary: 1, readings: readingIds.length, billings: billingIds.length}; } /** @@ -151,73 +208,21 @@ export async function cascadeDeleteProperty(propertyId: string): Promise { - const summary: CascadeDeleteSummary = {primary: 1, readings: 0, billings: 0}; - const readingIds: string[] = []; - const billingIds: string[] = []; + let readingIds: string[] = []; + let billingIds: string[] = []; await firestore.runTransaction(async (txn) => { const meterGroupRef = firestore.collection(COLLECTIONS.METER_GROUPS).doc(meterGroupId); - const meterGroupSnap = await txn.get(meterGroupRef); - - if (!meterGroupSnap.exists) { - throw new Error("Meter group not found"); - } - - // Soft-delete meter group + assertExists(await txn.get(meterGroupRef), "Meter group"); txn.update(meterGroupRef, {is_deleted: true, deleted_at: new Date()}); - // Find all readings for this meter group - const readingsSnap = await collectionRef(COLLECTIONS.READINGS) - .where("meter_group_id", "==", meterGroupId) - .where("is_deleted", "==", false) - .get(); - - const foundReadingIds = readingsSnap.docs.map((doc) => doc.id); - readingIds.push(...foundReadingIds); - summary.readings = foundReadingIds.length; - - // Soft-delete all readings + release their month locks - for (const readingDoc of readingsSnap.docs) { - txn.update(readingDoc.ref, {is_deleted: true, deleted_at: new Date()}); - const readingData = readingDoc.data(); - releaseReadingLock(txn, meterGroupId, readingData.property_id, readingData.reading_date); - } - - // Find all billings that reference these readings - if (foundReadingIds.length > 0) { - const billingsSnap = await collectionRef(COLLECTIONS.BILLINGS) - .where("is_deleted", "==", false) - .get(); - - const foundReadingIdSet = new Set(foundReadingIds); - const billingsToDelete = billingsSnap.docs.filter((doc) => { - const billing = doc.data(); - return ( - foundReadingIdSet.has(billing.previous_reading_id) || - foundReadingIdSet.has(billing.current_reading_id) - ); - }); - - const foundBillingIds = billingsToDelete.map((doc) => doc.id); - billingIds.push(...foundBillingIds); - summary.billings = billingsToDelete.length; - - // Soft-delete all related billings - for (const billingDoc of billingsToDelete) { - txn.update(billingDoc.ref, {is_deleted: true, deleted_at: new Date()}); - } - } + readingIds = await cascadeReadingsBy(txn, "meter_group_id", meterGroupId, "soft-delete"); + billingIds = await cascadeBillingsByReadingIds(txn, readingIds, "soft-delete"); }); - // Clear id caches after txn commits - await cacheDel(`utilitool:meter-groups:id:${meterGroupId}`); - await Promise.all(readingIds.map((readingId) => cacheDel(`utilitool:readings:id:${readingId}`))); - await Promise.all(billingIds.map((billingId) => cacheDel(`utilitool:billings:id:${billingId}`))); - - // Clear list caches so cascaded readings/billings stop appearing in active lists - await invalidateListCaches(["meter-groups", "readings", "billings"]); + await refreshCaches("meter-groups", meterGroupId, readingIds, billingIds, ["meter-groups", "readings", "billings"]); - return summary; + return {primary: 1, readings: readingIds.length, billings: billingIds.length}; } /** @@ -226,50 +231,23 @@ export async function cascadeDeleteMeterGroup(meterGroupId: string): Promise { - const summary: CascadeDeleteSummary = {primary: 1, billings: 0}; - const billingIds: string[] = []; + let billingIds: string[] = []; await firestore.runTransaction(async (txn) => { const readingRef = firestore.collection(COLLECTIONS.READINGS).doc(readingId); const readingSnap = await txn.get(readingRef); - - if (!readingSnap.exists) { - throw new Error("Reading not found"); - } - - // Soft-delete reading + assertExists(readingSnap, "Reading"); txn.update(readingRef, {is_deleted: true, deleted_at: new Date()}); const readingData = readingSnap.data()!; releaseReadingLock(txn, readingData.meter_group_id, readingData.property_id, readingData.reading_date); - // Find all billings that reference this reading - const billingsSnap = await collectionRef(COLLECTIONS.BILLINGS) - .where("is_deleted", "==", false) - .get(); - - const billingsToDelete = billingsSnap.docs.filter((doc) => { - const billing = doc.data(); - return billing.previous_reading_id === readingId || billing.current_reading_id === readingId; - }); - - billingIds.push(...billingsToDelete.map((doc) => doc.id)); - summary.billings = billingsToDelete.length; - - // Soft-delete all related billings - for (const billingDoc of billingsToDelete) { - txn.update(billingDoc.ref, {is_deleted: true, deleted_at: new Date()}); - } + billingIds = await cascadeBillingsByReadingIds(txn, [readingId], "soft-delete"); }); - // Clear id caches after txn commits - await cacheDel(`utilitool:readings:id:${readingId}`); - await Promise.all(billingIds.map((billingId) => cacheDel(`utilitool:billings:id:${billingId}`))); - - // Clear list caches so cascaded billings stop appearing in active lists - await invalidateListCaches(["readings", "billings"]); + await refreshCaches("readings", readingId, [], billingIds, ["readings", "billings"]); - return summary; + return {primary: 1, billings: billingIds.length}; } /** @@ -280,49 +258,23 @@ export async function cascadeDeleteReading(readingId: string): Promise { - const summary: CascadeDeleteSummary = {primary: 1, readings: 0, billings: 0}; - const readingIds: string[] = []; - const billingIds: string[] = []; + let readingIds: string[] = []; + let billingIds: string[] = []; await firestore.runTransaction(async (txn) => { const propertyRef = firestore.collection(COLLECTIONS.PROPERTIES).doc(propertyId); - const propertySnap = await txn.get(propertyRef); - - if (!propertySnap.exists) { - throw new AppError(404, "Property not found"); - } - if (propertySnap.data()?.is_deleted !== true) { - throw new AppError(409, "Cannot permanently delete an active property. Archive it first."); - } - + assertArchivedForPurge(await txn.get(propertyRef), "Property"); txn.delete(propertyRef); - const readingsSnap = await collectionRef(COLLECTIONS.READINGS) - .where("property_id", "==", propertyId) - .where("is_deleted", "==", true) - .get(); - - const foundReadingIds = readingsSnap.docs.map((doc) => doc.id); - readingIds.push(...foundReadingIds); - summary.readings = foundReadingIds.length; - readingsSnap.docs.forEach((doc) => txn.delete(doc.ref)); - - const billingsSnap = await collectionRef(COLLECTIONS.BILLINGS) - .where("property_id", "==", propertyId) - .where("is_deleted", "==", true) - .get(); - - const foundBillingIds = billingsSnap.docs.map((doc) => doc.id); - billingIds.push(...foundBillingIds); - summary.billings = foundBillingIds.length; - billingsSnap.docs.forEach((doc) => txn.delete(doc.ref)); + readingIds = await cascadeReadingsBy(txn, "property_id", propertyId, "purge"); + billingIds = await cascadeBillingsByPropertyId(txn, propertyId, "purge"); }); await cacheDel(`utilitool:properties:id:${propertyId}`); - await Promise.all(readingIds.map((readingId) => cacheDel(`utilitool:readings:id:${readingId}`))); - await Promise.all(billingIds.map((billingId) => cacheDel(`utilitool:billings:id:${billingId}`))); + await Promise.all(readingIds.map((id) => cacheDel(`utilitool:readings:id:${id}`))); + await Promise.all(billingIds.map((id) => cacheDel(`utilitool:billings:id:${id}`))); - return summary; + return {primary: 1, readings: readingIds.length, billings: billingIds.length}; } /** @@ -331,59 +283,23 @@ export async function cascadePurgeProperty(propertyId: string): Promise { - const summary: CascadeDeleteSummary = {primary: 1, readings: 0, billings: 0}; - const readingIds: string[] = []; - const billingIds: string[] = []; + let readingIds: string[] = []; + let billingIds: string[] = []; await firestore.runTransaction(async (txn) => { const meterGroupRef = firestore.collection(COLLECTIONS.METER_GROUPS).doc(meterGroupId); - const meterGroupSnap = await txn.get(meterGroupRef); - - if (!meterGroupSnap.exists) { - throw new AppError(404, "Meter group not found"); - } - if (meterGroupSnap.data()?.is_deleted !== true) { - throw new AppError(409, "Cannot permanently delete an active meter group. Archive it first."); - } - + assertArchivedForPurge(await txn.get(meterGroupRef), "Meter group"); txn.delete(meterGroupRef); - const readingsSnap = await collectionRef(COLLECTIONS.READINGS) - .where("meter_group_id", "==", meterGroupId) - .where("is_deleted", "==", true) - .get(); - - const foundReadingIds = readingsSnap.docs.map((doc) => doc.id); - readingIds.push(...foundReadingIds); - summary.readings = foundReadingIds.length; - readingsSnap.docs.forEach((doc) => txn.delete(doc.ref)); - - if (foundReadingIds.length > 0) { - const billingsSnap = await collectionRef(COLLECTIONS.BILLINGS) - .where("is_deleted", "==", true) - .get(); - - const foundReadingIdSet = new Set(foundReadingIds); - const billingsToPurge = billingsSnap.docs.filter((doc) => { - const billing = doc.data(); - return ( - foundReadingIdSet.has(billing.previous_reading_id) || - foundReadingIdSet.has(billing.current_reading_id) - ); - }); - - const foundBillingIds = billingsToPurge.map((doc) => doc.id); - billingIds.push(...foundBillingIds); - summary.billings = billingsToPurge.length; - billingsToPurge.forEach((doc) => txn.delete(doc.ref)); - } + readingIds = await cascadeReadingsBy(txn, "meter_group_id", meterGroupId, "purge"); + billingIds = await cascadeBillingsByReadingIds(txn, readingIds, "purge"); }); await cacheDel(`utilitool:meter-groups:id:${meterGroupId}`); - await Promise.all(readingIds.map((readingId) => cacheDel(`utilitool:readings:id:${readingId}`))); - await Promise.all(billingIds.map((billingId) => cacheDel(`utilitool:billings:id:${billingId}`))); + await Promise.all(readingIds.map((id) => cacheDel(`utilitool:readings:id:${id}`))); + await Promise.all(billingIds.map((id) => cacheDel(`utilitool:billings:id:${id}`))); - return summary; + return {primary: 1, readings: readingIds.length, billings: billingIds.length}; } /** @@ -391,40 +307,20 @@ export async function cascadePurgeMeterGroup(meterGroupId: string): Promise { - const summary: CascadeDeleteSummary = {primary: 1, billings: 0}; - const billingIds: string[] = []; + let billingIds: string[] = []; await firestore.runTransaction(async (txn) => { const readingRef = firestore.collection(COLLECTIONS.READINGS).doc(readingId); - const readingSnap = await txn.get(readingRef); - - if (!readingSnap.exists) { - throw new AppError(404, "Reading not found"); - } - if (readingSnap.data()?.is_deleted !== true) { - throw new AppError(409, "Cannot permanently delete an active reading. Archive it first."); - } - + assertArchivedForPurge(await txn.get(readingRef), "Reading"); txn.delete(readingRef); - const billingsSnap = await collectionRef(COLLECTIONS.BILLINGS) - .where("is_deleted", "==", true) - .get(); - - const billingsToPurge = billingsSnap.docs.filter((doc) => { - const billing = doc.data(); - return billing.previous_reading_id === readingId || billing.current_reading_id === readingId; - }); - - billingIds.push(...billingsToPurge.map((doc) => doc.id)); - summary.billings = billingsToPurge.length; - billingsToPurge.forEach((doc) => txn.delete(doc.ref)); + billingIds = await cascadeBillingsByReadingIds(txn, [readingId], "purge"); }); await cacheDel(`utilitool:readings:id:${readingId}`); - await Promise.all(billingIds.map((billingId) => cacheDel(`utilitool:billings:id:${billingId}`))); + await Promise.all(billingIds.map((id) => cacheDel(`utilitool:billings:id:${id}`))); - return summary; + return {primary: 1, billings: billingIds.length}; } /** @@ -432,62 +328,21 @@ export async function cascadePurgeReading(readingId: string): Promise { - const summary: CascadeDeleteSummary = {primary: 1, readings: 0, billings: 0}; - const readingIds: string[] = []; - const billingIds: string[] = []; + let readingIds: string[] = []; + let billingIds: string[] = []; await runCascadeTransaction(async (txn) => { const propertyRef = firestore.collection(COLLECTIONS.PROPERTIES).doc(propertyId); - const propertySnap = await txn.get(propertyRef); - - if (!propertySnap.exists) { - throw new AppError(404, "Property not found"); - } - - // Restore property + assertExists(await txn.get(propertyRef), "Property"); txn.update(propertyRef, {is_deleted: false, deleted_at: null}); - // Find all soft-deleted readings for this property - const readingsSnap = await collectionRef(COLLECTIONS.READINGS) - .where("property_id", "==", propertyId) - .where("is_deleted", "==", true) - .get(); - - const foundReadingIds = readingsSnap.docs.map((doc) => doc.id); - readingIds.push(...foundReadingIds); - summary.readings = foundReadingIds.length; - - // Restore all readings + reclaim their month locks - for (const readingDoc of readingsSnap.docs) { - txn.update(readingDoc.ref, {is_deleted: false, deleted_at: null}); - const readingData = readingDoc.data(); - reclaimReadingLock(txn, readingData.meter_group_id, propertyId, readingData.reading_date); - } - - // Find all soft-deleted billings for this property - const billingsSnap = await collectionRef(COLLECTIONS.BILLINGS) - .where("property_id", "==", propertyId) - .where("is_deleted", "==", true) - .get(); - - billingIds.push(...billingsSnap.docs.map((doc) => doc.id)); - summary.billings = billingsSnap.size; - - // Restore all billings - for (const billingDoc of billingsSnap.docs) { - txn.update(billingDoc.ref, {is_deleted: false, deleted_at: null}); - } + readingIds = await cascadeReadingsBy(txn, "property_id", propertyId, "restore"); + billingIds = await cascadeBillingsByPropertyId(txn, propertyId, "restore"); }); - // Refresh id caches so restored entities aren't served stale (archived) from cache - await cacheDel(`utilitool:properties:id:${propertyId}`); - await Promise.all(readingIds.map((readingId) => cacheDel(`utilitool:readings:id:${readingId}`))); - await Promise.all(billingIds.map((billingId) => cacheDel(`utilitool:billings:id:${billingId}`))); + await refreshCaches("properties", propertyId, readingIds, billingIds, ["properties", "readings", "billings"]); - // Clear list caches so cascaded readings/billings reappear in active lists - await invalidateListCaches(["properties", "readings", "billings"]); - - return summary; + return {primary: 1, readings: readingIds.length, billings: billingIds.length}; } /** @@ -495,72 +350,21 @@ export async function cascadeRestoreProperty(propertyId: string): Promise { - const summary: CascadeDeleteSummary = {primary: 1, readings: 0, billings: 0}; - const allReadingIds: string[] = []; - const allBillingIds: string[] = []; + let readingIds: string[] = []; + let billingIds: string[] = []; await runCascadeTransaction(async (txn) => { const meterGroupRef = firestore.collection(COLLECTIONS.METER_GROUPS).doc(meterGroupId); - const meterGroupSnap = await txn.get(meterGroupRef); - - if (!meterGroupSnap.exists) { - throw new AppError(404, "Meter group not found"); - } - - // Restore meter group + assertExists(await txn.get(meterGroupRef), "Meter group"); txn.update(meterGroupRef, {is_deleted: false, deleted_at: null}); - // Find all soft-deleted readings for this meter group - const readingsSnap = await collectionRef(COLLECTIONS.READINGS) - .where("meter_group_id", "==", meterGroupId) - .where("is_deleted", "==", true) - .get(); - - const readingIds = readingsSnap.docs.map((doc) => doc.id); - allReadingIds.push(...readingIds); - summary.readings = readingIds.length; - - // Restore all readings + reclaim their month locks - for (const readingDoc of readingsSnap.docs) { - txn.update(readingDoc.ref, {is_deleted: false, deleted_at: null}); - const readingData = readingDoc.data(); - reclaimReadingLock(txn, meterGroupId, readingData.property_id, readingData.reading_date); - } - - // Find all soft-deleted billings that reference these readings - if (readingIds.length > 0) { - const billingsSnap = await collectionRef(COLLECTIONS.BILLINGS) - .where("is_deleted", "==", true) - .get(); - - const readingIdSet = new Set(readingIds); - const billingsToRestore = billingsSnap.docs.filter((doc) => { - const billing = doc.data(); - return ( - readingIdSet.has(billing.previous_reading_id) || - readingIdSet.has(billing.current_reading_id) - ); - }); - - allBillingIds.push(...billingsToRestore.map((doc) => doc.id)); - summary.billings = billingsToRestore.length; - - // Restore all related billings - for (const billingDoc of billingsToRestore) { - txn.update(billingDoc.ref, {is_deleted: false, deleted_at: null}); - } - } + readingIds = await cascadeReadingsBy(txn, "meter_group_id", meterGroupId, "restore"); + billingIds = await cascadeBillingsByReadingIds(txn, readingIds, "restore"); }); - // Refresh id caches so restored entities aren't served stale (archived) from cache - await cacheDel(`utilitool:meter-groups:id:${meterGroupId}`); - await Promise.all(allReadingIds.map((readingId) => cacheDel(`utilitool:readings:id:${readingId}`))); - await Promise.all(allBillingIds.map((billingId) => cacheDel(`utilitool:billings:id:${billingId}`))); - - // Clear list caches so cascaded readings/billings reappear in active lists - await invalidateListCaches(["meter-groups", "readings", "billings"]); + await refreshCaches("meter-groups", meterGroupId, readingIds, billingIds, ["meter-groups", "readings", "billings"]); - return summary; + return {primary: 1, readings: readingIds.length, billings: billingIds.length}; } /** @@ -568,48 +372,21 @@ export async function cascadeRestoreMeterGroup(meterGroupId: string): Promise { - const summary: CascadeDeleteSummary = {primary: 1, billings: 0}; - const billingIds: string[] = []; + let billingIds: string[] = []; await runCascadeTransaction(async (txn) => { const readingRef = firestore.collection(COLLECTIONS.READINGS).doc(readingId); const readingSnap = await txn.get(readingRef); - - if (!readingSnap.exists) { - throw new AppError(404, "Reading not found"); - } - - // Restore reading + assertExists(readingSnap, "Reading"); txn.update(readingRef, {is_deleted: false, deleted_at: null}); const readingData = readingSnap.data()!; reclaimReadingLock(txn, readingData.meter_group_id, readingData.property_id, readingData.reading_date); - // Find all soft-deleted billings that reference this reading - const billingsSnap = await collectionRef(COLLECTIONS.BILLINGS) - .where("is_deleted", "==", true) - .get(); - - const billingsToRestore = billingsSnap.docs.filter((doc) => { - const billing = doc.data(); - return billing.previous_reading_id === readingId || billing.current_reading_id === readingId; - }); - - billingIds.push(...billingsToRestore.map((doc) => doc.id)); - summary.billings = billingsToRestore.length; - - // Restore all related billings - for (const billingDoc of billingsToRestore) { - txn.update(billingDoc.ref, {is_deleted: false, deleted_at: null}); - } + billingIds = await cascadeBillingsByReadingIds(txn, [readingId], "restore"); }); - // Refresh id caches so restored entities aren't served stale (archived) from cache - await cacheDel(`utilitool:readings:id:${readingId}`); - await Promise.all(billingIds.map((billingId) => cacheDel(`utilitool:billings:id:${billingId}`))); - - // Clear list caches so cascaded billings reappear in active lists - await invalidateListCaches(["readings", "billings"]); + await refreshCaches("readings", readingId, [], billingIds, ["readings", "billings"]); - return summary; + return {primary: 1, billings: billingIds.length}; } diff --git a/api/functions/src/utils/error.util.ts b/api/functions/src/utils/error.util.ts index 714ea3b..ec08d68 100644 --- a/api/functions/src/utils/error.util.ts +++ b/api/functions/src/utils/error.util.ts @@ -12,3 +12,16 @@ export class AppError extends Error { this.name = "AppError"; } } + +/** Fetch by id via the given lookup, or throw a 404 AppError with a consistent message. */ +export async function getOrThrow( + getById: (id: string) => Promise, + id: string, + label: string +): Promise { + const entity = await getById(id); + if (!entity) { + throw new AppError(404, `${label} not found`); + } + return entity; +} diff --git a/api/functions/src/utils/image-url.util.test.ts b/api/functions/src/utils/image-url.util.test.ts index 00c571e..2752043 100644 --- a/api/functions/src/utils/image-url.util.test.ts +++ b/api/functions/src/utils/image-url.util.test.ts @@ -2,39 +2,21 @@ import { describe, it, expect } from '@jest/globals'; import { isSafeImageUrl, ImageUrlSchema } from './image-url.util'; describe('isSafeImageUrl', () => { - it('allows https URLs to public hosts', () => { - expect(isSafeImageUrl('https://storage.googleapis.com/bucket/photo.jpg')).toBe(true); - }); - it('allows data:image/* URLs', () => { expect(isSafeImageUrl('data:image/png;base64,iVBORw0KGgo=')).toBe(true); + expect(isSafeImageUrl('data:image/jpeg;base64,/9j/4AAQ')).toBe(true); }); - it('rejects http (non-https) URLs', () => { - expect(isSafeImageUrl('http://example.com/photo.jpg')).toBe(false); - }); - - it('rejects the cloud metadata endpoint', () => { + it('rejects https URLs (no server-side fetch is ever performed)', () => { + expect(isSafeImageUrl('https://storage.googleapis.com/bucket/photo.jpg')).toBe(false); expect(isSafeImageUrl('https://169.254.169.254/latest/meta-data/')).toBe(false); }); - it('rejects loopback hosts', () => { - expect(isSafeImageUrl('https://127.0.0.1/photo.jpg')).toBe(false); - expect(isSafeImageUrl('https://localhost/photo.jpg')).toBe(false); - }); - - it('rejects private RFC1918 ranges', () => { - expect(isSafeImageUrl('https://10.0.0.5/photo.jpg')).toBe(false); - expect(isSafeImageUrl('https://172.16.0.1/photo.jpg')).toBe(false); - expect(isSafeImageUrl('https://172.31.255.255/photo.jpg')).toBe(false); - expect(isSafeImageUrl('https://192.168.1.1/photo.jpg')).toBe(false); - }); - - it('allows a public host that merely resembles a private one lexically', () => { - expect(isSafeImageUrl('https://172.32.0.1/photo.jpg')).toBe(true); + it('rejects http URLs', () => { + expect(isSafeImageUrl('http://example.com/photo.jpg')).toBe(false); }); - it('rejects malformed URLs', () => { + it('rejects malformed or non-data strings', () => { expect(isSafeImageUrl('not-a-url')).toBe(false); }); @@ -46,17 +28,15 @@ describe('isSafeImageUrl', () => { }); describe('ImageUrlSchema', () => { - it('parses a valid https URL', () => { - expect(ImageUrlSchema.parse('https://example.com/photo.jpg')).toBe( - 'https://example.com/photo.jpg' - ); - }); - it('parses a valid data URL', () => { const dataUrl = 'data:image/jpeg;base64,/9j/4AAQ'; expect(ImageUrlSchema.parse(dataUrl)).toBe(dataUrl); }); + it('throws on an https URL, even to a public host', () => { + expect(() => ImageUrlSchema.parse('https://example.com/photo.jpg')).toThrow(); + }); + it('throws on an SSRF-prone internal URL', () => { expect(() => ImageUrlSchema.parse('https://169.254.169.254/latest/meta-data/')).toThrow(); }); diff --git a/api/functions/src/utils/image-url.util.ts b/api/functions/src/utils/image-url.util.ts index bb55ceb..ead10c5 100644 --- a/api/functions/src/utils/image-url.util.ts +++ b/api/functions/src/utils/image-url.util.ts @@ -1,58 +1,16 @@ import {z} from "zod"; -// Blocks SSRF: only allow `data:image/*` URLs or `https://` URLs to a public host. -// Rejects internal/private/loopback/link-local hosts so a server-side fetch of a -// user-supplied image_url can't be used to probe internal services or the cloud -// metadata endpoint (169.254.169.254). -const PRIVATE_HOSTNAME_PATTERNS = [ - /^localhost$/i, - /^127\./, - /^0\.0\.0\.0$/, - /^10\./, - /^172\.(1[6-9]|2\d|3[0-1])\./, - /^192\.168\./, - /^169\.254\./, - /^::1$/, - /^\[?::1\]?$/, - /^fe80:/i, - /^fc00:/i, - /^fd[0-9a-f]{2}:/i, -]; - -function isPrivateHostname(hostname: string): boolean { - return PRIVATE_HOSTNAME_PATTERNS.some((pattern) => pattern.test(hostname)); -} +// Eliminates SSRF entirely: the server never fetches a client-supplied URL. +// Only inline `data:image/*;base64,...` payloads are accepted, so there is no +// code path where the server issues an outbound request to an attacker-chosen +// host (previously guarded by a hostname/IP blocklist, which is inherently +// incomplete against DNS-controlled hosts). +const DATA_IMAGE_URL_PATTERN = /^data:image\/[a-zA-Z0-9.+-]+;base64,/; export function isSafeImageUrl(value: string): boolean { - if (value.startsWith("data:image/")) { - return true; - } - - let url: URL; - try { - url = new URL(value); - } catch { - return false; - } - - if (url.protocol !== "https:") { - return false; - } - - return !isPrivateHostname(url.hostname); + return DATA_IMAGE_URL_PATTERN.test(value); } export const ImageUrlSchema = z .string() - .refine((value) => { - try { - // Reuse url() validation for non-data URLs; data URLs skip it. - if (!value.startsWith("data:")) { - z.string().url().parse(value); - } - return true; - } catch { - return false; - } - }, "Invalid image URL") - .refine(isSafeImageUrl, "image_url must be an https:// URL to a public host, or a data:image/* URL"); + .refine(isSafeImageUrl, "image_url must be a data:image/*;base64,... URL"); diff --git a/mobile/BUILD.md b/mobile/BUILD.md index c27e913..ad79be3 100644 --- a/mobile/BUILD.md +++ b/mobile/BUILD.md @@ -23,24 +23,29 @@ npx cap open android # Open Android Studio - Enable USB debugging on physical device - Connect via `adb devices` -## Security: Network Security Config (required after a fresh `npx cap add android`) - -The `android/` directory is git-ignored and regenerated locally, so this step must be repeated -whenever the Android project is recreated from scratch: - -1. Create `android/app/src/main/res/xml/network_security_config.xml`: - ```xml - - - - - - - - - ``` -2. Add `android:networkSecurityConfig="@xml/network_security_config"` to the `` tag - in `android/app/src/main/AndroidManifest.xml`. +## Security: Network Security Config (enforced automatically) + +The `android/` directory is git-ignored and regenerated locally via `npx cap add android`, so this +config previously had to be manually recreated every time — an easy step to forget on a fresh +checkout, a new machine, or after deleting `android/` to fix a sync issue. It's now applied +automatically by `scripts/apply-network-security-config.mjs`, wired into both +`npm run cap:add:android` and `npm run cap:sync` (see `package.json`). Run +`npm run verify:network-security-config` any time to check/re-apply it manually. + +The script creates `android/app/src/main/res/xml/network_security_config.xml`: +```xml + + + + + + + + +``` +and adds `android:networkSecurityConfig="@xml/network_security_config"` to the `` tag +in `android/app/src/main/AndroidManifest.xml` if it's missing. It's idempotent — safe to run +repeatedly, and a no-op once both are already in place. This explicitly denies cleartext (`http://`) traffic app-wide, rather than relying on the Android API-level default. Pairs with the build-time HTTPS guard in `src/lib/api/client.ts`. diff --git a/mobile/CLAUDE.md b/mobile/CLAUDE.md index 27091d8..91e44af 100644 --- a/mobile/CLAUDE.md +++ b/mobile/CLAUDE.md @@ -46,17 +46,16 @@ mobile/ │ └── lib/ │ ├── api/ │ │ ├── client.ts → Base fetch: Bearer token + 401 retry -│ │ ├── meter-groups.ts → listMeterGroups, getMeterGroup -│ │ ├── readings.ts → listReadings, getReading, createReading, createReadingsBatch, ocrReadingImage -│ │ ├── properties.ts → listProperties, getProperty -│ │ ├── billings.ts → listBillings, getBilling, updateBillingStatus -│ │ ├── billing-cycles.ts → listBillingCycles, getBillingCycle -│ │ └── photo-settings.ts → getPhotoSettings, upsertPhotoSettings (GET/PATCH /photo-settings) +│ │ ├── meter-groups.ts → listMeterGroups +│ │ ├── readings.ts → listReadings, getReading, createReadingsBatch, ocrReadingImage +│ │ ├── properties.ts → listProperties +│ │ ├── billings.ts → listBillings, updateBillingStatus +│ │ └── billing-cycles.ts → listBillingCycles │ ├── utils/ │ │ ├── auth-errors.ts → getReadableAuthError │ │ ├── billing-cycle.util.ts → getStatusSummary, getCyclePaidAmount, getCycleOutstandingAmount │ │ ├── format.ts → getReadingUnit, formatReading -│ │ ├── timestamp.ts → toDate, formatTimestampDate, formatTimestampDateTime (Firestore timestamp handling) +│ │ ├── timestamp.ts → toDate, formatTimestampDate (Firestore timestamp handling) │ │ └── utility-colors.ts → getUtilityTypeBadgeClasses │ └── stores/ │ └── session.ts → sessionCache: module-level cache for meterGroups + properties (survives screen nav, cleared on sign-out) @@ -97,12 +96,11 @@ All API calls go through `src/lib/api/client.ts`: | Module | Functions | API Endpoints | |--------|-----------|---------------| -| `meter-groups.ts` | `listMeterGroups`, `getMeterGroup` | `GET /meter-groups?summary=true`, `GET /meter-groups/:id` | -| `readings.ts` | `listReadings`, `getReading`, `createReading`, `createReadingsBatch` | `GET /readings`, `GET /readings/:id`, `POST /readings`, `POST /readings/batch` | -| `properties.ts` | `listProperties`, `getProperty` | `GET /properties`, `GET /properties/:id` | -| `billings.ts` | `listBillings`, `getBilling`, `updateBillingStatus` | `GET /billings`, `GET /billings/:id`, `PATCH /billings/:id` | -| `billing-cycles.ts` | `listBillingCycles`, `getBillingCycle` | `GET /billing-cycles`, `GET /billing-cycles/:id` | -| `photo-settings.ts` | `getPhotoSettings`, `upsertPhotoSettings` | `GET /photo-settings`, `PATCH /photo-settings` | +| `meter-groups.ts` | `listMeterGroups` | `GET /meter-groups?summary=true` | +| `readings.ts` | `listReadings`, `getReading`, `createReadingsBatch` | `GET /readings`, `GET /readings/:id`, `POST /readings/batch` | +| `properties.ts` | `listProperties` | `GET /properties` | +| `billings.ts` | `listBillings`, `updateBillingStatus` | `GET /billings`, `PATCH /billings/:id` | +| `billing-cycles.ts` | `listBillingCycles` | `GET /billing-cycles` | --- @@ -117,7 +115,7 @@ All API calls go through `src/lib/api/client.ts`: Properties are filtered client-side: only properties whose `meter_groups` values include the selected meter group ID. -**Photo persistence**: gated by the `savePhotos` preference from `GET /photo-settings` (defaults to `false`, configurable in Settings). The captured photo is always used in-memory for the OCR suggest call; `image_url` is only included in the create/batch payload when `savePhotos` is `true` — otherwise the photo never leaves the device beyond that one OCR request. +**Photo handling**: a captured photo is only ever used in-memory for the OCR suggest call (`POST /readings/ocr`) — it is never included in the create/batch payload and is never persisted anywhere. Photos are sent to the API as base64 `data:` URIs, not fetchable URLs. ### ReadingHistory — Filterable List `src/screens/ReadingHistory.svelte` diff --git a/mobile/package.json b/mobile/package.json index 780c479..6ac38b1 100644 --- a/mobile/package.json +++ b/mobile/package.json @@ -6,9 +6,10 @@ "dev": "vite", "build": "vite build", "preview": "vite preview", - "cap:add:android": "npx cap add android", + "cap:add:android": "npx cap add android && node scripts/apply-network-security-config.mjs", "cap:open:android": "npx cap open android", - "cap:sync": "npx cap sync" + "cap:sync": "npx cap sync && node scripts/apply-network-security-config.mjs", + "verify:network-security-config": "node scripts/apply-network-security-config.mjs" }, "devDependencies": { "@capacitor/cli": "^6.1.0", diff --git a/mobile/scripts/apply-network-security-config.mjs b/mobile/scripts/apply-network-security-config.mjs new file mode 100644 index 0000000..2bc0432 --- /dev/null +++ b/mobile/scripts/apply-network-security-config.mjs @@ -0,0 +1,87 @@ +#!/usr/bin/env node +// Enforces the Android cleartext-traffic guard documented in BUILD.md. `android/` +// is git-ignored and regenerated locally via `npx cap add android`, so this step +// was previously a manual, unenforced instruction — easy to forget on a fresh +// checkout, a new machine, or after deleting `android/` to fix a sync issue. Wired +// into `cap:add:android` and `cap:sync` (see package.json) so it runs every time +// those commands do, instead of relying on a human to remember BUILD.md. +// +// Safe to run repeatedly: no-ops if the config already exists and is wired up. +// No-ops (with a warning, not a failure) if `android/` hasn't been generated yet, +// since `cap:add:android` runs this only after `cap add android` succeeds, but a +// bare `npm install` or first clone shouldn't fail over a platform that hasn't +// been added. + +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const mobileRoot = join(__dirname, ".."); +const androidRoot = join(mobileRoot, "android"); +const xmlDir = join(androidRoot, "app", "src", "main", "res", "xml"); +const configPath = join(xmlDir, "network_security_config.xml"); +const manifestPath = join(androidRoot, "app", "src", "main", "AndroidManifest.xml"); + +const NETWORK_SECURITY_CONFIG = ` + + + + + + + +`; + +function main() { + if (!existsSync(androidRoot)) { + console.warn( + "[network-security-config] android/ not found yet — skipping " + + "(run `npm run cap:add:android` first)." + ); + return; + } + + let changed = false; + + if (!existsSync(configPath)) { + mkdirSync(xmlDir, { recursive: true }); + writeFileSync(configPath, NETWORK_SECURITY_CONFIG, "utf8"); + console.log(`[network-security-config] created ${configPath}`); + changed = true; + } + + if (!existsSync(manifestPath)) { + throw new Error( + `[network-security-config] android/ exists but AndroidManifest.xml is missing at ${manifestPath} — android/ may be partially generated; try removing it and re-running \`npm run cap:add:android\`.` + ); + } + + const manifest = readFileSync(manifestPath, "utf8"); + const applicationTagMatch = manifest.match(/]*>/s); + + if (!applicationTagMatch) { + throw new Error( + `[network-security-config] could not find an tag in ${manifestPath} — apply android:networkSecurityConfig="@xml/network_security_config" manually.` + ); + } + + const applicationTag = applicationTagMatch[0]; + if (!applicationTag.includes("android:networkSecurityConfig")) { + const patchedTag = applicationTag.replace( + />$/, + '\n android:networkSecurityConfig="@xml/network_security_config">' + ); + writeFileSync(manifestPath, manifest.replace(applicationTag, patchedTag), "utf8"); + console.log(`[network-security-config] wired networkSecurityConfig into ${manifestPath}`); + changed = true; + } + + console.log( + changed + ? "[network-security-config] cleartext traffic protection applied." + : "[network-security-config] already up to date." + ); +} + +main(); diff --git a/mobile/src/lib/api/billing-cycles.ts b/mobile/src/lib/api/billing-cycles.ts index 4fbdfb7..f5be6f6 100644 --- a/mobile/src/lib/api/billing-cycles.ts +++ b/mobile/src/lib/api/billing-cycles.ts @@ -31,7 +31,3 @@ export async function listBillingCycles(params?: { const path = query.toString() ? `/billing-cycles?${query}` : '/billing-cycles'; return apiGet(path); } - -export async function getBillingCycle(id: string): Promise { - return apiGet(`/billing-cycles/${id}`); -} diff --git a/mobile/src/lib/api/billings.ts b/mobile/src/lib/api/billings.ts index 61d4b54..5c981f2 100644 --- a/mobile/src/lib/api/billings.ts +++ b/mobile/src/lib/api/billings.ts @@ -18,10 +18,6 @@ export async function listBillings(propertyId?: string) { return apiGet(`/billings${params.toString() ? '?' + params.toString() : ''}`); } -export async function getBilling(id: string) { - return apiGet(`/billings/${id}`); -} - export async function updateBillingStatus(id: string, paymentStatus: string) { return apiPatch(`/billings/${id}`, { payment_status: paymentStatus }); } diff --git a/mobile/src/lib/api/meter-groups.ts b/mobile/src/lib/api/meter-groups.ts index 6f9c30b..6ccc394 100644 --- a/mobile/src/lib/api/meter-groups.ts +++ b/mobile/src/lib/api/meter-groups.ts @@ -14,7 +14,3 @@ export interface MeterGroup { export async function listMeterGroups() { return apiGet('/meter-groups?summary=true'); } - -export async function getMeterGroup(id: string) { - return apiGet(`/meter-groups/${id}`); -} diff --git a/mobile/src/lib/api/photo-settings.ts b/mobile/src/lib/api/photo-settings.ts deleted file mode 100644 index 28a0346..0000000 --- a/mobile/src/lib/api/photo-settings.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { apiGet, apiPatch } from './client'; - -export interface PhotoSettings { - savePhotos: boolean; -} - -export async function getPhotoSettings(): Promise { - return apiGet('/photo-settings'); -} - -export async function upsertPhotoSettings(data: PhotoSettings): Promise { - return apiPatch('/photo-settings', data); -} diff --git a/mobile/src/lib/api/properties.ts b/mobile/src/lib/api/properties.ts index 70516c6..7fabc38 100644 --- a/mobile/src/lib/api/properties.ts +++ b/mobile/src/lib/api/properties.ts @@ -18,7 +18,3 @@ export interface Property { export async function listProperties() { return apiGet('/properties'); } - -export async function getProperty(id: string) { - return apiGet(`/properties/${id}`); -} diff --git a/mobile/src/lib/api/readings.ts b/mobile/src/lib/api/readings.ts index 32d52f6..a4c9830 100644 --- a/mobile/src/lib/api/readings.ts +++ b/mobile/src/lib/api/readings.ts @@ -6,7 +6,6 @@ export interface Reading { property_id: string; reading_amount: number; reading_date: string; - image_url?: string; meter_version: number; created_at: string; updated_at: string; @@ -18,7 +17,6 @@ export interface CreateReadingRequest { property_id: string; reading_amount: number; reading_date: string; - image_url?: string; } export interface BatchReadingRequest { @@ -46,10 +44,6 @@ export async function getReading(id: string): Promise { return apiGet(`/readings/${id}`); } -export async function createReading(data: CreateReadingRequest) { - return apiPost('/readings', data); -} - export async function createReadingsBatch(data: BatchReadingRequest): Promise> { if (!data.readings || data.readings.length === 0) { throw new Error('Cannot submit an empty batch — add at least one reading.'); diff --git a/mobile/src/lib/stores/session.ts b/mobile/src/lib/stores/session.ts index c74564b..251e393 100644 --- a/mobile/src/lib/stores/session.ts +++ b/mobile/src/lib/stores/session.ts @@ -1,5 +1,5 @@ -import type { MeterGroup } from '../api/meter-groups'; -import type { Property } from '../api/properties'; +import { listMeterGroups, type MeterGroup } from '../api/meter-groups'; +import { listProperties, type Property } from '../api/properties'; // Module-level session cache — survives screen navigation, cleared on sign-out let _meterGroups: MeterGroup[] | null = null; @@ -16,6 +16,23 @@ export const sessionCache = { _properties = d; }, + // Returns the cached list, fetching and caching it on first call. + async getOrFetchMeterGroups(): Promise { + if (!_meterGroups) { + const res = await listMeterGroups(); + _meterGroups = res.data || []; + } + return _meterGroups!; + }, + + async getOrFetchProperties(): Promise { + if (!_properties) { + const res = await listProperties(); + _properties = res.data || []; + } + return _properties!; + }, + clear: () => { _meterGroups = null; _properties = null; diff --git a/mobile/src/lib/utils/auth-errors.ts b/mobile/src/lib/utils/auth-errors.ts index d80b820..b674684 100644 --- a/mobile/src/lib/utils/auth-errors.ts +++ b/mobile/src/lib/utils/auth-errors.ts @@ -1,30 +1,18 @@ +// Maps Firebase auth error codes to user-friendly messages. +const AUTH_ERROR_MESSAGES: Record = { + 'auth/invalid-email': 'Invalid email address', + 'auth/user-disabled': 'This account has been disabled', + 'auth/user-not-found': 'Invalid email or password', + 'auth/invalid-login-credentials': 'Invalid email or password', + 'auth/invalid-password': 'Invalid email or password', + 'auth/too-many-requests': 'Too many failed attempts. Please try again later', + 'auth/network-request-failed': 'Network error. Please check your connection', + 'auth/operation-not-allowed': 'This authentication method is not available' +}; + export function getReadableAuthError(error: any): string { if (typeof error === 'string') return error; if (!error) return 'Authentication failed'; - const code = error.code || ''; - const message = error.message || ''; - - // Map Firebase error codes to user-friendly messages - if (code === 'auth/invalid-email') { - return 'Invalid email address'; - } - if (code === 'auth/user-disabled') { - return 'This account has been disabled'; - } - if (code === 'auth/user-not-found' || code === 'auth/invalid-login-credentials' || code === 'auth/invalid-password') { - return 'Invalid email or password'; - } - if (code === 'auth/too-many-requests') { - return 'Too many failed attempts. Please try again later'; - } - if (code === 'auth/network-request-failed') { - return 'Network error. Please check your connection'; - } - if (code === 'auth/operation-not-allowed') { - return 'This authentication method is not available'; - } - - // Fallback to generic message for unknown errors - return 'Unable to sign in. Please try again'; + return AUTH_ERROR_MESSAGES[error.code] || 'Unable to sign in. Please try again'; } diff --git a/mobile/src/lib/utils/readings-wizard.util.ts b/mobile/src/lib/utils/readings-wizard.util.ts new file mode 100644 index 0000000..f62500a --- /dev/null +++ b/mobile/src/lib/utils/readings-wizard.util.ts @@ -0,0 +1,11 @@ +import type { Property, MeterGroupEntry } from '../api/properties'; + +export function findMeterGroupEntry(property: Property, meterGroupId: string): MeterGroupEntry | undefined { + return Object.values(property.meter_groups).find((entry) => entry.meter_group_id === meterGroupId); +} + +// Main-meter properties need a one-time baseline "seed" reading (POST /readings/seed) +// before they can take regular batch readings. +export function needsSeedReading(property: Property, meterGroupId: string): boolean { + return findMeterGroupEntry(property, meterGroupId)?.is_main_meter === true; +} diff --git a/mobile/src/lib/utils/timestamp.ts b/mobile/src/lib/utils/timestamp.ts index 2e2f9ef..67c624f 100644 --- a/mobile/src/lib/utils/timestamp.ts +++ b/mobile/src/lib/utils/timestamp.ts @@ -34,13 +34,3 @@ export function formatTimestampDate(timestamp: FirestoreTimestamp | string | any }); } -export function formatTimestampDateTime(timestamp: FirestoreTimestamp | string | any): string { - const date = toDate(timestamp); - return date.toLocaleDateString('en-US', { - month: 'short', - day: 'numeric', - year: 'numeric', - hour: '2-digit', - minute: '2-digit' - }); -} diff --git a/mobile/src/screens/Billings.svelte b/mobile/src/screens/Billings.svelte index ab631a4..d1bf5da 100644 --- a/mobile/src/screens/Billings.svelte +++ b/mobile/src/screens/Billings.svelte @@ -2,7 +2,6 @@ import { listBillingCycles, type BillingCycle } from '../lib/api/billing-cycles'; import { listBillings, updateBillingStatus, type Billing } from '../lib/api/billings'; import { listMeterGroups, type MeterGroup } from '../lib/api/meter-groups'; - import { listProperties } from '../lib/api/properties'; import { formatTimestampDate } from '../lib/utils/timestamp'; import { getReadingUnit } from '../lib/utils/format'; import { getStatusSummary } from '../lib/utils/billing-cycle.util'; @@ -35,13 +34,7 @@ meterGroups = meterGroupsRes.data; } - // Fetch property names from cache or API - let properties = sessionCache.getProperties(); - if (!properties) { - const propsRes = await listProperties(); - properties = propsRes.data || []; - sessionCache.setProperties(properties); - } + const properties = await sessionCache.getOrFetchProperties(); const names: Record = {}; const propertyMap = new Map(properties.map(p => [p.id, p.room_name])); diff --git a/mobile/src/screens/CaptureReadings.svelte b/mobile/src/screens/CaptureReadings.svelte index dab1adb..dcb1d39 100644 --- a/mobile/src/screens/CaptureReadings.svelte +++ b/mobile/src/screens/CaptureReadings.svelte @@ -1,11 +1,11 @@ + +
+ +
diff --git a/ui/src/lib/stores/archive-page.svelte.ts b/ui/src/lib/stores/archive-page.svelte.ts new file mode 100644 index 0000000..6c650ff --- /dev/null +++ b/ui/src/lib/stores/archive-page.svelte.ts @@ -0,0 +1,79 @@ +import type { PaginatedResult } from '$lib/types/api.types'; + +interface ArchivePageConfig { + listFn: (params: { limit: number; archived: true }) => Promise>; + restoreFn: (id: string) => Promise; + clearCacheFn: () => Promise<{ message: string }>; + entityLabel: string; + /** Optional side-fetch run alongside the main list (e.g. property/meter-group name lookups). */ + loadExtra?: () => Promise; +} + +export function createArchivePageState(config: ArchivePageConfig) { + let data = $state>({ data: [], nextCursor: null, hasMore: false }); + let isLoading = $state(false); + let error = $state(''); + let restoringId = $state(null); + let isClearing = $state(false); + + async function loadData() { + isLoading = true; + error = ''; + try { + const [result] = await Promise.all([ + config.listFn({ limit: 100, archived: true }), + config.loadExtra?.() ?? Promise.resolve() + ]); + data = result; + } catch (err) { + error = err instanceof Error ? err.message : `Failed to load archived ${config.entityLabel}s`; + } finally { + isLoading = false; + } + } + + async function handleRestore(id: string) { + restoringId = id; + try { + await config.restoreFn(id); + await loadData(); + } catch (err) { + error = err instanceof Error ? err.message : `Failed to restore ${config.entityLabel}`; + } finally { + restoringId = null; + } + } + + async function handleClearCache() { + isClearing = true; + try { + const result = await config.clearCacheFn(); + error = result.message; + } catch (err) { + error = err instanceof Error ? err.message : 'Failed to clear cache'; + } finally { + isClearing = false; + } + } + + return { + get data() { + return data; + }, + get isLoading() { + return isLoading; + }, + get error() { + return error; + }, + get restoringId() { + return restoringId; + }, + get isClearing() { + return isClearing; + }, + loadData, + handleRestore, + handleClearCache + }; +} diff --git a/ui/src/lib/stores/list-store.svelte.ts b/ui/src/lib/stores/list-store.svelte.ts new file mode 100644 index 0000000..bcc66c3 --- /dev/null +++ b/ui/src/lib/stores/list-store.svelte.ts @@ -0,0 +1,54 @@ +interface PaginatedResult { + data: T[]; +} + +const STALE_MS = 5 * 60 * 1000; // 5 minutes + +/** Shared cache-with-staleness pattern behind meterGroupsStore/propertiesStore/tenantsStore. */ +export function createListStore( + fetchFn: (params: { limit: number }) => Promise> +) { + let _data = $state([]); + let _lastFetched = $state(null); + let _isLoading = $state(false); + + return { + get data() { + return _data; + }, + get isLoading() { + return _isLoading; + }, + get isStale() { + return _lastFetched === null || Date.now() - _lastFetched > STALE_MS; + }, + + async fetch(force = false) { + if (!force && !this.isStale && _data.length > 0) { + return _data; + } + + if (_isLoading) { + return _data; + } + + _isLoading = true; + try { + const result = await fetchFn({ limit: 1000 }); + _data = result.data; + _lastFetched = Date.now(); + return _data; + } finally { + _isLoading = false; + } + }, + + invalidate() { + _lastFetched = null; + }, + + async refresh() { + return this.fetch(true); + } + }; +} diff --git a/ui/src/lib/stores/meter-groups.svelte.ts b/ui/src/lib/stores/meter-groups.svelte.ts index 1fddc81..3a93b22 100644 --- a/ui/src/lib/stores/meter-groups.svelte.ts +++ b/ui/src/lib/stores/meter-groups.svelte.ts @@ -1,47 +1,4 @@ import { getMeterGroups } from '$lib/api/meter-groups'; -import type { MeterGroup } from '$lib/types/meter-group.types'; +import { createListStore } from '$lib/stores/list-store.svelte'; -let _data = $state([]); -let _lastFetched = $state(null); -let _isLoading = $state(false); -const STALE_MS = 5 * 60 * 1000; // 5 minutes - -export const meterGroupsStore = { - get data() { - return _data; - }, - get isLoading() { - return _isLoading; - }, - get isStale() { - return _lastFetched === null || Date.now() - _lastFetched > STALE_MS; - }, - - async fetch(force = false) { - if (!force && !this.isStale && _data.length > 0) { - return _data; - } - - if (_isLoading) { - return _data; - } - - _isLoading = true; - try { - const result = await getMeterGroups({ limit: 1000 }); - _data = result.data; - _lastFetched = Date.now(); - return _data; - } finally { - _isLoading = false; - } - }, - - invalidate() { - _lastFetched = null; - }, - - async refresh() { - return this.fetch(true); - } -}; +export const meterGroupsStore = createListStore(getMeterGroups); diff --git a/ui/src/lib/stores/properties.svelte.ts b/ui/src/lib/stores/properties.svelte.ts index 49f6c2a..88a45f4 100644 --- a/ui/src/lib/stores/properties.svelte.ts +++ b/ui/src/lib/stores/properties.svelte.ts @@ -1,47 +1,4 @@ import { getProperties } from '$lib/api/properties'; -import type { Property } from '$lib/types/property.types'; +import { createListStore } from '$lib/stores/list-store.svelte'; -let _data = $state([]); -let _lastFetched = $state(null); -let _isLoading = $state(false); -const STALE_MS = 5 * 60 * 1000; // 5 minutes - -export const propertiesStore = { - get data() { - return _data; - }, - get isLoading() { - return _isLoading; - }, - get isStale() { - return _lastFetched === null || Date.now() - _lastFetched > STALE_MS; - }, - - async fetch(force = false) { - if (!force && !this.isStale && _data.length > 0) { - return _data; - } - - if (_isLoading) { - return _data; - } - - _isLoading = true; - try { - const result = await getProperties({ limit: 1000 }); - _data = result.data; - _lastFetched = Date.now(); - return _data; - } finally { - _isLoading = false; - } - }, - - invalidate() { - _lastFetched = null; - }, - - async refresh() { - return this.fetch(true); - } -}; +export const propertiesStore = createListStore(getProperties); diff --git a/ui/src/lib/stores/tenants.svelte.ts b/ui/src/lib/stores/tenants.svelte.ts index 50e4bc8..d584b0f 100644 --- a/ui/src/lib/stores/tenants.svelte.ts +++ b/ui/src/lib/stores/tenants.svelte.ts @@ -1,47 +1,4 @@ import { getTenants } from '$lib/api/tenants'; -import type { Tenant } from '$lib/types/tenant.types'; +import { createListStore } from '$lib/stores/list-store.svelte'; -let _data = $state([]); -let _lastFetched = $state(null); -let _isLoading = $state(false); -const STALE_MS = 5 * 60 * 1000; // 5 minutes - -export const tenantsStore = { - get data() { - return _data; - }, - get isLoading() { - return _isLoading; - }, - get isStale() { - return _lastFetched === null || Date.now() - _lastFetched > STALE_MS; - }, - - async fetch(force = false) { - if (!force && !this.isStale && _data.length > 0) { - return _data; - } - - if (_isLoading) { - return _data; - } - - _isLoading = true; - try { - const result = await getTenants({ limit: 1000 }); - _data = result.data; - _lastFetched = Date.now(); - return _data; - } finally { - _isLoading = false; - } - }, - - invalidate() { - _lastFetched = null; - }, - - async refresh() { - return this.fetch(true); - } -}; +export const tenantsStore = createListStore(getTenants); diff --git a/ui/src/lib/types/photo-settings.types.ts b/ui/src/lib/types/photo-settings.types.ts deleted file mode 100644 index 19a6a35..0000000 --- a/ui/src/lib/types/photo-settings.types.ts +++ /dev/null @@ -1,7 +0,0 @@ -export interface PhotoSettingsResponse { - savePhotos: boolean; -} - -export interface UpsertPhotoSettingsRequest { - savePhotos: boolean; -} diff --git a/ui/src/lib/types/reading.types.ts b/ui/src/lib/types/reading.types.ts index 7a18a03..9d2dd9e 100644 --- a/ui/src/lib/types/reading.types.ts +++ b/ui/src/lib/types/reading.types.ts @@ -5,7 +5,6 @@ export interface Reading extends BaseModel { property_id: string; reading_amount: number; reading_date: FirestoreTimestamp; - image_url?: string; meter_version: number; } @@ -14,7 +13,6 @@ export interface CreateReadingRequest { property_id: string; reading_amount: number; reading_date: FirestoreTimestamp | string; - image_url?: string; } export interface CreateSeedReadingRequest { @@ -22,12 +20,10 @@ export interface CreateSeedReadingRequest { property_id: string; reading_amount: number; reading_date: FirestoreTimestamp | string; - image_url?: string; } export interface UpdateReadingRequest { meter_group_id?: string; reading_amount?: number; reading_date?: FirestoreTimestamp | string; - image_url?: string; } diff --git a/ui/src/lib/utils/format.ts b/ui/src/lib/utils/format.ts index f483708..8c70493 100644 --- a/ui/src/lib/utils/format.ts +++ b/ui/src/lib/utils/format.ts @@ -1,4 +1,6 @@ import { format, parse } from 'date-fns'; +import type { FirestoreTimestamp } from '$lib/types/api.types'; +import { toDate } from '$lib/utils/timestamp'; export function formatCurrency(amount: number): string { return new Intl.NumberFormat('en-PH', { @@ -25,6 +27,10 @@ export function formatDate(date: Date): string { return format(date, 'MMM d, yyyy'); } +export function formatFirestoreDate(timestamp: FirestoreTimestamp | string): string { + return formatDate(toDate(timestamp)); +} + export function formatLongDate(date: Date): string { return format(date, 'MMMM d, yyyy'); } diff --git a/ui/src/lib/utils/true-reading.test.ts b/ui/src/lib/utils/true-reading.test.ts index 9ca0c1b..54e2dc4 100644 --- a/ui/src/lib/utils/true-reading.test.ts +++ b/ui/src/lib/utils/true-reading.test.ts @@ -56,7 +56,6 @@ describe('true-reading (submeter, post-reset cumulative offset)', () => { reading_amount: 5.5, reading_date: '' as any, meter_version: 2, - image_url: null as any, created_at: '' as any, updated_at: '' as any, is_deleted: false, diff --git a/ui/src/routes/(app)/billings/+page.svelte b/ui/src/routes/(app)/billings/+page.svelte index cb0a653..78f77f5 100644 --- a/ui/src/routes/(app)/billings/+page.svelte +++ b/ui/src/routes/(app)/billings/+page.svelte @@ -17,11 +17,12 @@ import type { Billing, UpdateBillingRequest } from '$lib/types/billing.types'; import type { Reading } from '$lib/types/reading.types'; import type { Property, MeterGroupEntry } from '$lib/types/property.types'; - import type { MeterGroup, MeterGroupVersionEntry } from '$lib/types/meter-group.types'; + import type { MeterGroup } from '$lib/types/meter-group.types'; import type { PaginatedResult } from '$lib/types/api.types'; import { formatDate, formatCurrency, formatReading, getReadingUnit } from '$lib/utils/format'; import { billAmount, sumMoney } from '$lib/utils/money'; import { toDate } from '$lib/utils/timestamp'; + import { trueReading } from '$lib/utils/true-reading'; import { getUtilityTypeBadgeClasses } from '$lib/utils/utility-colors'; import EmptyState from '$lib/components/shared/EmptyState.svelte'; import TableSkeleton from '$lib/components/shared/TableSkeleton.svelte'; @@ -112,47 +113,8 @@ const round = (value: number, decimals = 2) => Math.round(value * Math.pow(10, decimals)) / Math.pow(10, decimals); - // Version-aware "true reading" — mirrors the API's billing-cycle validator so consumption - // previews stay correct across meter resets (cumulative offset of prior versions + raw amount). - function getCumulativeOffset( - versions: Record | undefined, - version: number - ): number { - if (!versions) return 0; - let offset = 0; - for (let v = 1; v < version; v++) { - const versionData = versions[String(v)]; - if (versionData) offset += versionData.last_reading; - } - return offset; - } - - function getVersionsSource( - meterGroup: MeterGroup | undefined, - property: Property | undefined, - meterGroupId: string - ): Record | undefined { - let versionsSource = meterGroup?.versions; - if (property) { - const entry = Object.values(property.meter_groups).find( - (e): e is MeterGroupEntry => typeof e === 'object' && e?.meter_group_id === meterGroupId - ); - if (entry && !entry.is_main_meter) { - versionsSource = entry.versions; - } - } - return versionsSource; - } - - function trueReading( - reading: Reading, - meterGroup: MeterGroup | undefined, - property: Property | undefined - ): number { - const versionsSource = getVersionsSource(meterGroup, property, reading.meter_group_id); - return getCumulativeOffset(versionsSource, reading.meter_version ?? 1) + reading.reading_amount; - } - + // Version-aware "true reading" (imported from $lib/utils/true-reading) mirrors the + // API's billing-cycle validator so consumption previews stay correct across meter resets. function readingConsumption( currReading: Reading, prevReading: Reading | undefined, @@ -828,14 +790,15 @@ return true; }) .sort( - (a, b) => - toDate(b.billing_start_date).getTime() - toDate(a.billing_start_date).getTime() + (a, b) => toDate(b.billing_start_date).getTime() - toDate(a.billing_start_date).getTime() ); }); const cyclesPageSize = 20; let currentPage = $state(1); - const totalPages = $derived.by(() => Math.max(1, Math.ceil(filteredCycles.length / cyclesPageSize))); + const totalPages = $derived.by(() => + Math.max(1, Math.ceil(filteredCycles.length / cyclesPageSize)) + ); const pagedCycles = $derived.by(() => { const start = (currentPage - 1) * cyclesPageSize; return filteredCycles.slice(start, start + cyclesPageSize); @@ -1394,9 +1357,7 @@
- + - {getCycleMeterGroupName(cycle)} + {getCycleMeterGroupName(cycle)} {cycleUtilityType}{cycleUtilityType}
@@ -1619,266 +1581,257 @@
-
-
-
Consumption
-
- {cycle.billing_consumption.toLocaleString()} - {getReadingUnit(getCycleUtilityType(cycle))} -
+
+
+
Consumption
+
+ {cycle.billing_consumption.toLocaleString()} + {getReadingUnit(getCycleUtilityType(cycle))}
-
-
Rate
-
- ₱{cycle.billing_rate.toFixed(2)}/{getReadingUnit( - getCycleUtilityType(cycle) - )} -
+
+
+
Rate
+
+ ₱{cycle.billing_rate.toFixed(2)}/{getReadingUnit( + getCycleUtilityType(cycle) + )}
-
-
Total Amount
-
- {formatCurrency( - billAmount(cycle.billing_consumption, cycle.billing_rate) - )} -
+
+
+
Total Amount
+
+ {formatCurrency( + billAmount(cycle.billing_consumption, cycle.billing_rate) + )}
-
-
Currently Paid
-
- {formatCurrency(getCyclePaidAmount(cycle))} -
+
+
+
Currently Paid
+
+ {formatCurrency(getCyclePaidAmount(cycle))}
- -
-
- - - - -
+
+ +
+
+ + + +
+
- - {#if expandedCycleId === cycle.id} -
- {#if cycleBillings?.length === 0} -
- -
- {:else if cycleBillings} -
- - - - - - - - - - - - - - {#each cycleBillings || [] as billing (billing.id)} - {@const billingProperty = properties.find( - (p) => p.id === billing.property_id - )} - {@const cycleMeterGroup = cycle.meter_group_id - ? meterGroupMap.get(cycle.meter_group_id) - : undefined} - {@const meterEntry = cycle.meter_group_id - ? cycleMeterGroup?.utility_type === 'water' - ? billingProperty?.meter_groups.water - : billingProperty?.meter_groups.electricity - : null} - {@const isMainMeter = - typeof meterEntry === 'string' - ? false - : (meterEntry?.is_main_meter ?? false)} - {@const previousReading = readingMap.get(billing.previous_reading_id)} - {@const currentReading = readingMap.get(billing.current_reading_id)} - {@const previousMeterGroup = previousReading - ? meterGroupMap.get(previousReading.meter_group_id) - : undefined} - {@const currentMeterGroup = currentReading - ? meterGroupMap.get(currentReading.meter_group_id) - : undefined} - - - - - - + + {/each} + +
PropertyPrevious ReadingCurrent Reading - Consumption - AmountStatusActions
-
- {billingProperty?.room_name ?? 'Unknown Property'} -
-
- {#if previousReading} - {formatReading( - trueReading( - previousReading, - previousMeterGroup, - billingProperty - ), - previousMeterGroup?.utility_type || 'electricity' - )} - {:else} - N/A - {/if} - - {#if currentReading} - {formatReading( - trueReading(currentReading, currentMeterGroup, billingProperty), - currentMeterGroup?.utility_type || 'electricity' - )} - {:else} - N/A - {/if} - - {#if currentReading} - {(cycle.billing_ids[billing.id] ?? 0).toLocaleString()} - {getReadingUnit(currentMeterGroup?.utility_type || 'electricity')} - {:else} - N/A - {/if} - - {formatCurrency( - (cycle.billing_ids[billing.id] ?? 0) * cycle.billing_rate + + {#if expandedCycleId === cycle.id} +
+ {#if cycleBillings?.length === 0} +
+ +
+ {:else if cycleBillings} +
+ + + + + + + + + + + + + + {#each cycleBillings || [] as billing (billing.id)} + {@const billingProperty = properties.find( + (p) => p.id === billing.property_id + )} + {@const cycleMeterGroup = cycle.meter_group_id + ? meterGroupMap.get(cycle.meter_group_id) + : undefined} + {@const meterEntry = cycle.meter_group_id + ? cycleMeterGroup?.utility_type === 'water' + ? billingProperty?.meter_groups.water + : billingProperty?.meter_groups.electricity + : null} + {@const isMainMeter = + typeof meterEntry === 'string' + ? false + : (meterEntry?.is_main_meter ?? false)} + {@const previousReading = readingMap.get(billing.previous_reading_id)} + {@const currentReading = readingMap.get(billing.current_reading_id)} + {@const previousMeterGroup = previousReading + ? meterGroupMap.get(previousReading.meter_group_id) + : undefined} + {@const currentMeterGroup = currentReading + ? meterGroupMap.get(currentReading.meter_group_id) + : undefined} + + + - - + + + + + - - {/each} - -
PropertyPrevious ReadingCurrent Reading + Consumption + AmountStatusActions
+
+ {billingProperty?.room_name ?? 'Unknown Property'} +
+
+ {#if previousReading} + {formatReading( + trueReading(previousReading, previousMeterGroup, billingProperty), + previousMeterGroup?.utility_type || 'electricity' )} - - - -
- {#if billing.payment_status === 'pending'} - - {/if} - + {:else} + N/A + {/if} +
+ {#if currentReading} + {formatReading( + trueReading(currentReading, currentMeterGroup, billingProperty), + currentMeterGroup?.utility_type || 'electricity' + )} + {:else} + N/A + {/if} + + {#if currentReading} + {(cycle.billing_ids[billing.id] ?? 0).toLocaleString()} + {getReadingUnit(currentMeterGroup?.utility_type || 'electricity')} + {:else} + N/A + {/if} + + {formatCurrency( + (cycle.billing_ids[billing.id] ?? 0) * cycle.billing_rate + )} + + + +
+ {#if billing.payment_status === 'pending'} -
-
-
- {:else} - - {/if} -
- {/if} - - {/each} + {/if} + + + +
+
+ {:else} + + {/if} +
+ {/if} +
+ {/each} {#if totalPages > 1}
diff --git a/ui/src/routes/(app)/billings/archive/+page.svelte b/ui/src/routes/(app)/billings/archive/+page.svelte index c7900b0..ce7314a 100644 --- a/ui/src/routes/(app)/billings/archive/+page.svelte +++ b/ui/src/routes/(app)/billings/archive/+page.svelte @@ -1,22 +1,17 @@
-
- -
+
diff --git a/ui/src/routes/(app)/bills/+page.svelte b/ui/src/routes/(app)/bills/+page.svelte index 8fda8b8..35ad01d 100644 --- a/ui/src/routes/(app)/bills/+page.svelte +++ b/ui/src/routes/(app)/bills/+page.svelte @@ -6,7 +6,7 @@ import { getMeterGroups } from '$lib/api/meter-groups'; import { getBillings } from '$lib/api/billings'; import { createBillingCycle } from '$lib/api/billing-cycles'; - import { uploadToStorage } from '$lib/utils/firebase-storage'; + import { compressImage } from '$lib/utils/image-compression'; import { formatCurrency, formatDate } from '$lib/utils/format'; import { billAmount } from '$lib/utils/money'; import type { OcrBillResponse } from '$lib/types/bill.types'; @@ -62,9 +62,7 @@ isUploadingFile = true; error = ''; try { - const timestamp = Date.now(); - const path = `bills/${timestamp}_${file.name}`; - billImageUrl = await uploadToStorage(file, path); + billImageUrl = await compressImage(file, 800, 0.7); ocrResult = await ocrBill(billImageUrl); reviewForm.billing_start_date = ocrResult.billing_start_date; diff --git a/ui/src/routes/(app)/dashboard/+page.svelte b/ui/src/routes/(app)/dashboard/+page.svelte index 19a101b..3ba63cd 100644 --- a/ui/src/routes/(app)/dashboard/+page.svelte +++ b/ui/src/routes/(app)/dashboard/+page.svelte @@ -5,7 +5,7 @@ import { getBillings } from '$lib/api/billings'; import { getBillingCycles } from '$lib/api/billing-cycles'; import { getMeterGroups } from '$lib/api/meter-groups'; - import { formatCurrency, formatDate, getReadingUnit } from '$lib/utils/format'; + import { formatCurrency, formatFirestoreDate, getReadingUnit } from '$lib/utils/format'; import { toDate } from '$lib/utils/timestamp'; import { getCyclePaidAmount, getCycleOutstandingAmount } from '$lib/utils/billing-cycle.util'; import { getUtilityTypeBadgeClasses } from '$lib/utils/utility-colors'; @@ -25,8 +25,12 @@ let recentCyclesRangeMonths = $state<3 | 6 | 12>(3); const recentCycles = $derived.by(() => { - const cutoff = new Date(); - cutoff.setMonth(cutoff.getMonth() - recentCyclesRangeMonths); + const now = new Date(); + const cutoff = new Date( + now.getFullYear(), + now.getMonth() - recentCyclesRangeMonths, + now.getDate() + ); return allCycles .filter((cycle) => toDate(cycle.billing_start_date) >= cutoff) .sort( @@ -57,7 +61,9 @@ // page would silently under-count older cycles/billings, so page through with cursors // (mirrors the same fix applied to the Billings page). async function fetchAllPages( - fetchPage: (cursor?: string) => Promise<{ data: T[]; hasMore: boolean; nextCursor: string | null }> + fetchPage: ( + cursor?: string + ) => Promise<{ data: T[]; hasMore: boolean; nextCursor: string | null }> ): Promise { const all: T[] = []; let cursor: string | undefined; @@ -186,8 +192,8 @@ {@const meterGroup = meterGroups.find((m) => m.id === cycle.meter_group_id)} - {formatDate(toDate(cycle.billing_start_date))} – {formatDate( - toDate(cycle.billing_end_date) + {formatFirestoreDate(cycle.billing_start_date)} – {formatFirestoreDate( + cycle.billing_end_date )} diff --git a/ui/src/routes/(app)/meter-groups/+page.svelte b/ui/src/routes/(app)/meter-groups/+page.svelte index a13615e..f64fad8 100644 --- a/ui/src/routes/(app)/meter-groups/+page.svelte +++ b/ui/src/routes/(app)/meter-groups/+page.svelte @@ -13,8 +13,7 @@ UpdateMeterGroupRequest } from '$lib/types/meter-group.types'; import type { PaginatedResult } from '$lib/types/api.types'; - import { formatDate } from '$lib/utils/format'; - import { toDate } from '$lib/utils/timestamp'; + import { formatFirestoreDate } from '$lib/utils/format'; import { getUtilityTypeBadgeClasses } from '$lib/utils/utility-colors'; import EmptyState from '$lib/components/shared/EmptyState.svelte'; import EditModal from '$lib/components/shared/EditModal.svelte'; @@ -229,7 +228,7 @@ {item.utility_type} - {formatDate(toDate(item.created_at))} + {formatFirestoreDate(item.created_at)}
import { onMount } from 'svelte'; import { getMeterGroups, restoreMeterGroup, clearCache } from '$lib/api/meter-groups'; - import type { MeterGroup } from '$lib/types/meter-group.types'; - import type { PaginatedResult } from '$lib/types/api.types'; - import { formatDate } from '$lib/utils/format'; - import { toDate } from '$lib/utils/timestamp'; + import { formatFirestoreDate } from '$lib/utils/format'; import ArchivePageTemplate from '$lib/components/shared/ArchivePageTemplate.svelte'; - import { Trash2 } from 'lucide-svelte'; - - let data = $state>({ - data: [], - nextCursor: null, - hasMore: false + import ClearCacheButton from '$lib/components/shared/ClearCacheButton.svelte'; + import { createArchivePageState } from '$lib/stores/archive-page.svelte'; + + const page = createArchivePageState({ + listFn: getMeterGroups, + restoreFn: restoreMeterGroup, + clearCacheFn: clearCache, + entityLabel: 'meter group' }); - let isLoading = $state(false); - let error = $state(''); - let restoringId = $state(null); - let isClearing = $state(false); const columns = [ { key: 'meter_name', label: 'Meter Name' }, @@ -28,72 +23,26 @@ { key: 'created_at', label: 'Created', - format: (v: any) => formatDate(toDate(v)) + format: (v: any) => formatFirestoreDate(v) } ]; onMount(async () => { - await loadData(); + await page.loadData(); }); - - async function loadData() { - isLoading = true; - error = ''; - try { - const result = await getMeterGroups({ limit: 100, archived: true }); - data = result; - } catch (err) { - error = err instanceof Error ? err.message : 'Failed to load archived meter groups'; - } finally { - isLoading = false; - } - } - - async function handleRestore(id: string) { - restoringId = id; - try { - await restoreMeterGroup(id); - await loadData(); - } catch (err) { - error = err instanceof Error ? err.message : 'Failed to restore meter group'; - } finally { - restoringId = null; - } - } - - async function handleClearCache() { - isClearing = true; - try { - const result = await clearCache(); - error = result.message; - } catch (err) { - error = err instanceof Error ? err.message : 'Failed to clear cache'; - } finally { - isClearing = false; - } - }
-
- -
+
diff --git a/ui/src/routes/(app)/properties/+page.svelte b/ui/src/routes/(app)/properties/+page.svelte index 7ff3d0f..30b1f27 100644 --- a/ui/src/routes/(app)/properties/+page.svelte +++ b/ui/src/routes/(app)/properties/+page.svelte @@ -18,7 +18,7 @@ import type { Reading } from '$lib/types/reading.types'; import type { Billing } from '$lib/types/billing.types'; import type { PaginatedResult } from '$lib/types/api.types'; - import { formatDate, formatDateTime, formatReading } from '$lib/utils/format'; + import { formatFirestoreDate, formatDateTime, formatReading } from '$lib/utils/format'; import { toDate } from '$lib/utils/timestamp'; import { getUtilityTypeBadgeClasses } from '$lib/utils/utility-colors'; import EmptyState from '$lib/components/shared/EmptyState.svelte'; @@ -653,7 +653,7 @@ {/if}
- {formatDate(toDate(property.created_at))} + {formatFirestoreDate(property.created_at)}
@@ -784,7 +784,7 @@ > - {formatDate(toDate(tenant.tenant_start_date))} + {formatFirestoreDate(tenant.tenant_start_date)} {/each} @@ -851,7 +851,7 @@ )} - {formatDate(toDate(reading.reading_date))} + {formatFirestoreDate(reading.reading_date)} {/each} @@ -940,9 +940,9 @@ {prevReading && currReading - ? formatDate(toDate(prevReading.reading_date)) + + ? formatFirestoreDate(prevReading.reading_date) + ' - ' + - formatDate(toDate(currReading.reading_date)) + formatFirestoreDate(currReading.reading_date) : 'N/A'} diff --git a/ui/src/routes/(app)/properties/archive/+page.svelte b/ui/src/routes/(app)/properties/archive/+page.svelte index b010d12..38dbfe8 100644 --- a/ui/src/routes/(app)/properties/archive/+page.svelte +++ b/ui/src/routes/(app)/properties/archive/+page.svelte @@ -1,22 +1,17 @@
-
- -
+
diff --git a/ui/src/routes/(app)/readings/+page.svelte b/ui/src/routes/(app)/readings/+page.svelte index ca5318a..7eded63 100644 --- a/ui/src/routes/(app)/readings/+page.svelte +++ b/ui/src/routes/(app)/readings/+page.svelte @@ -16,11 +16,14 @@ import type { MeterGroup } from '$lib/types/meter-group.types'; import type { Property } from '$lib/types/property.types'; import type { PaginatedResult } from '$lib/types/api.types'; - import { formatDate, formatLongDate, formatReading, getReadingUnit } from '$lib/utils/format'; + import { + formatFirestoreDate, + formatLongDate, + formatReading, + getReadingUnit + } from '$lib/utils/format'; import { toDate } from '$lib/utils/timestamp'; - import { uploadToStorage } from '$lib/utils/firebase-storage'; import { compressImage } from '$lib/utils/image-compression'; - import { getPhotoSettings } from '$lib/api/photo-settings'; import { trueReading, resolveCurrentVersion, @@ -112,18 +115,8 @@ let isUpdating = $state(false); - // Defaults to false (don't persist photos to Storage) until loaded — matches the - // backend default so there's no window where an unloaded setting reads as "save". - let savePhotos = $state(false); - onMount(async () => { await loadData(); - try { - const settings = await getPhotoSettings(); - savePhotos = settings.savePhotos; - } catch { - // Keep the safe default (don't persist) if the setting fails to load. - } }); async function applyFilters() { @@ -304,9 +297,7 @@ reading_date: { _seconds: Math.floor(new Date(manualReadingForm.reading_date).getTime() / 1000), _nanoseconds: 0 - }, - image_url: - savePhotos && manualReadingForm.image_url ? manualReadingForm.image_url : undefined + } } as any; const isSeed = await shouldSeedReading( @@ -344,7 +335,7 @@ // Compress image to avoid "request entity too large" errors const compressedDataUrl = await compressImage(file, 800, 0.7); - // Show preview and run Suggest immediately — don't wait for Storage + // Photo is only ever used transiently for OCR suggest — never persisted. row.data_url = compressedDataUrl; row.image_url = compressedDataUrl; } catch (err) { @@ -356,19 +347,6 @@ // Auto-suggest a reading value from the photo — no separate Suggest button. await handleSuggestReading(rowIndex); - - // Silently upgrade to a persistent Storage URL in the background — only when the - // user has opted in via Photo Settings. Otherwise the data URL stays in-memory - // only, long enough to have been OCR'd above, and is never persisted. - if (row.data_url && savePhotos) { - uploadToStorage(file, `readings/${Date.now()}_${file.name}`) - .then((url) => { - row.image_url = url; - }) - .catch(() => { - /* keep data URL */ - }); - } } async function handleManualImageUpload(file: File | null) { @@ -395,18 +373,6 @@ } catch (err) { error = err instanceof Error ? err.message : 'Failed to suggest reading'; } - - // Silently upgrade to a persistent Storage URL in the background — only when the - // user has opted in via Photo Settings. - if (savePhotos) { - uploadToStorage(file, `readings/${Date.now()}_${file.name}`) - .then((url) => { - manualReadingForm.image_url = url; - }) - .catch(() => { - /* keep data URL */ - }); - } } async function handleCreateBatch() { @@ -435,10 +401,7 @@ reading_date: { _seconds: Math.floor(dateObj.getTime() / 1000), _nanoseconds: 0 - }, - // Only attach the photo when the user has opted into saving it — - // otherwise it was only ever used in-memory for the Suggest button. - image_url: savePhotos && row.image_url ? row.image_url : undefined + } })); const result = await createReadingsBatch(readingsData); @@ -884,7 +847,7 @@
{#if isLoading} - + {:else if readings.data.length === 0}
@@ -917,7 +880,6 @@ Meter Group Reading True Total - Photo Date Created Actions @@ -949,22 +911,8 @@ {trueReading(item, itemMeterGroup, itemProperty).toLocaleString()} {getReadingUnit(itemMeterGroup?.utility_type || 'electricity')} - - {#if item.image_url} - - - Meter reading - - {:else} - No image - {/if} - - {formatDate(toDate(item.reading_date))} - {formatDate(toDate(item.created_at))} + {formatFirestoreDate(item.reading_date)} + {formatFirestoreDate(item.created_at)} { diff --git a/ui/src/routes/(app)/readings/archive/+page.svelte b/ui/src/routes/(app)/readings/archive/+page.svelte index a836a74..472791d 100644 --- a/ui/src/routes/(app)/readings/archive/+page.svelte +++ b/ui/src/routes/(app)/readings/archive/+page.svelte @@ -2,24 +2,24 @@ import { onMount } from 'svelte'; import { getReadings, restoreReading, clearCache } from '$lib/api/readings'; import { getMeterGroups } from '$lib/api/meter-groups'; - import type { Reading } from '$lib/types/reading.types'; import type { MeterGroup } from '$lib/types/meter-group.types'; - import type { PaginatedResult } from '$lib/types/api.types'; - import { formatDate } from '$lib/utils/format'; - import { toDate } from '$lib/utils/timestamp'; + import { formatFirestoreDate } from '$lib/utils/format'; import ArchivePageTemplate from '$lib/components/shared/ArchivePageTemplate.svelte'; - import { Trash2 } from 'lucide-svelte'; + import ClearCacheButton from '$lib/components/shared/ClearCacheButton.svelte'; + import { createArchivePageState } from '$lib/stores/archive-page.svelte'; - let data = $state>({ - data: [], - nextCursor: null, - hasMore: false - }); let meterGroups = $state>(new Map()); - let isLoading = $state(false); - let error = $state(''); - let restoringId = $state(null); - let isClearing = $state(false); + + const page = createArchivePageState({ + listFn: getReadings, + restoreFn: restoreReading, + clearCacheFn: clearCache, + entityLabel: 'reading', + loadExtra: async () => { + const result = await getMeterGroups({ limit: 100 }); + meterGroups = new Map(result.data.map((mg: MeterGroup) => [mg.id, mg.meter_name])); + } + }); const columns = [ { @@ -35,79 +35,26 @@ { key: 'reading_date', label: 'Reading Date', - format: (v: any) => formatDate(toDate(v)) + format: (v: any) => formatFirestoreDate(v) } ]; onMount(async () => { - await loadData(); + await page.loadData(); }); - - async function loadData() { - isLoading = true; - error = ''; - try { - const [readingsResult, meterGroupsResult] = await Promise.all([ - getReadings({ limit: 100, archived: true }), - getMeterGroups({ limit: 100 }) - ]); - - // Build meter group name map - meterGroups = new Map(meterGroupsResult.data.map((mg: MeterGroup) => [mg.id, mg.meter_name])); - - data = readingsResult; - } catch (err) { - error = err instanceof Error ? err.message : 'Failed to load archived readings'; - } finally { - isLoading = false; - } - } - - async function handleRestore(id: string) { - restoringId = id; - try { - await restoreReading(id); - await loadData(); - } catch (err) { - error = err instanceof Error ? err.message : 'Failed to restore reading'; - } finally { - restoringId = null; - } - } - - async function handleClearCache() { - isClearing = true; - try { - const result = await clearCache(); - error = result.message; - } catch (err) { - error = err instanceof Error ? err.message : 'Failed to clear cache'; - } finally { - isClearing = false; - } - }
-
- -
+
diff --git a/ui/src/routes/(app)/settings/+page.svelte b/ui/src/routes/(app)/settings/+page.svelte index b0faad9..b7d9f35 100644 --- a/ui/src/routes/(app)/settings/+page.svelte +++ b/ui/src/routes/(app)/settings/+page.svelte @@ -130,20 +130,6 @@ Go to settings →
- - - -

Photo Settings

-

- Control whether meter-reading photos are saved after OCR, on web and mobile -

-
- Go to settings → -
-
diff --git a/ui/src/routes/(app)/settings/photos/+page.svelte b/ui/src/routes/(app)/settings/photos/+page.svelte deleted file mode 100644 index 9a03d84..0000000 --- a/ui/src/routes/(app)/settings/photos/+page.svelte +++ /dev/null @@ -1,100 +0,0 @@ - - -
-
-

Photo Settings

-

- Control whether meter-reading photos are kept after being used for OCR suggestions. -

-
- -
- {#if error} -
- {error} -
- {/if} - - {#if success} -
- {success} -
- {/if} - - {#if isLoading} -

Loading...

- {:else} -
-
-

Save meter-reading photos

-

- When off (default), a captured photo is only used in-memory to suggest a reading value — - it's discarded before the reading is submitted, never stored. When on, the photo is kept - and attached to the reading. -

-

- Applies to meter-reading photos on web and mobile. Utility-bill photos used for - billing-cycle OCR are never saved either way. -

-
- -
- {/if} -
-
diff --git a/ui/src/routes/(app)/settings/users/+page.svelte b/ui/src/routes/(app)/settings/users/+page.svelte index fcab5d6..0ab9255 100644 --- a/ui/src/routes/(app)/settings/users/+page.svelte +++ b/ui/src/routes/(app)/settings/users/+page.svelte @@ -1,7 +1,6 @@
-
- -
+