From 86f863a84527634c934e484a0093144920daeae3 Mon Sep 17 00:00:00 2001 From: Richmond Glenn Viloria Date: Sun, 12 Jul 2026 23:04:31 +0800 Subject: [PATCH 01/19] fix(api): eliminate SSRF in OCR image fetch by accepting base64 only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit image-fetch.util.ts and the shared ImageUrlSchema previously allowed server-side fetches of client-supplied http/https image URLs, guarded only by a hostname-string blocklist with no DNS resolution — any attacker-registered domain resolving to an internal/metadata IP (or a handful of unblocked IPv6 ranges/0.0.0.0) reached internal infrastructure from the Cloud Functions runtime. Remove the vulnerable branch entirely instead of patching the blocklist: every OCR endpoint (readings, billing-cycles, bills, image-extraction) now only accepts inline data:image/*;base64,... payloads, so the server never issues an outbound request to a client-supplied host. Readings no longer have an image_url field at all — meter photos are used transiently for OCR suggest only, never persisted, which also removes the now-pointless photo-settings feature (per-user savePhotos toggle) and its Firestore collection entirely. Co-Authored-By: Claude Sonnet 5 --- api/functions/src/config/swagger.config.ts | 40 +----------- .../src/constants/collection.constants.ts | 1 - .../billing-cycle/billing-cycle.swagger.ts | 2 +- .../src/features/bills/bills.swagger.ts | 5 +- .../image-extraction.swagger.ts | 6 +- .../photo-settings.controller.ts | 20 ------ .../photo-settings/photo-settings.dto.ts | 14 ----- .../photo-settings/photo-settings.model.ts | 5 -- .../photo-settings.repository.ts | 19 ------ .../photo-settings/photo-settings.route.ts | 16 ----- .../photo-settings/photo-settings.service.ts | 29 --------- .../photo-settings/photo-settings.swagger.ts | 54 ---------------- .../src/features/reading/reading.dto.ts | 4 +- .../src/features/reading/reading.model.ts | 1 - api/functions/src/index.ts | 2 - api/functions/src/lib/image-fetch.util.ts | 63 +++---------------- .../src/utils/image-url.util.test.ts | 40 +++--------- api/functions/src/utils/image-url.util.ts | 58 +++-------------- 18 files changed, 37 insertions(+), 342 deletions(-) delete mode 100644 api/functions/src/features/photo-settings/photo-settings.controller.ts delete mode 100644 api/functions/src/features/photo-settings/photo-settings.dto.ts delete mode 100644 api/functions/src/features/photo-settings/photo-settings.model.ts delete mode 100644 api/functions/src/features/photo-settings/photo-settings.repository.ts delete mode 100644 api/functions/src/features/photo-settings/photo-settings.route.ts delete mode 100644 api/functions/src/features/photo-settings/photo-settings.service.ts delete mode 100644 api/functions/src/features/photo-settings/photo-settings.swagger.ts 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.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/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/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/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/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/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/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"); From fccf06c52bcdfad966250fe41512dc4294daf08f Mon Sep 17 00:00:00 2001 From: Richmond Glenn Viloria Date: Sun, 12 Jul 2026 23:04:47 +0800 Subject: [PATCH 02/19] fix(ui): switch OCR photo uploads to base64, drop photo persistence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Matches the API's SSRF fix: the backend now only accepts inline data:image/*;base64,... payloads for OCR, so image_url is no longer a fetchable URL anywhere. - readings page: drop the Firebase Storage upload path and the savePhotos-gated persistence — the compressed data URL is now the only value ever held in image_url, used purely to drive OCR suggest. - bills page: was uploading the bill photo to Storage first and then sending that HTTPS URL to POST /bills/ocr — the same SSRF-relevant upload-then-fetch pattern, and a pointless one since the resulting URL was never attached to the created billing cycle anyway. Switched to sending a compressed base64 data URL directly, matching the billings page's existing OCR flow. - Delete the photo-settings API module/types/settings page — nothing is persisted anymore, so there's nothing left to toggle. Co-Authored-By: Claude Sonnet 5 --- ui/src/lib/api/photo-settings.ts | 15 --- ui/src/lib/types/photo-settings.types.ts | 7 -- ui/src/lib/types/reading.types.ts | 4 - ui/src/lib/utils/true-reading.test.ts | 1 - ui/src/routes/(app)/bills/+page.svelte | 6 +- ui/src/routes/(app)/readings/+page.svelte | 76 +++---------- ui/src/routes/(app)/settings/+page.svelte | 14 --- .../routes/(app)/settings/photos/+page.svelte | 100 ------------------ 8 files changed, 14 insertions(+), 209 deletions(-) delete mode 100644 ui/src/lib/api/photo-settings.ts delete mode 100644 ui/src/lib/types/photo-settings.types.ts delete mode 100644 ui/src/routes/(app)/settings/photos/+page.svelte diff --git a/ui/src/lib/api/photo-settings.ts b/ui/src/lib/api/photo-settings.ts deleted file mode 100644 index a47d4aa..0000000 --- a/ui/src/lib/api/photo-settings.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { apiGet, apiPatch } from './client'; -import type { - PhotoSettingsResponse, - UpsertPhotoSettingsRequest -} from '$lib/types/photo-settings.types'; - -export async function getPhotoSettings(): Promise { - return apiGet('/photo-settings'); -} - -export async function upsertPhotoSettings( - data: UpsertPhotoSettingsRequest -): Promise { - return apiPatch('/photo-settings', data); -} 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/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)/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)/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)/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} -
-
From dc9b38f1214364e392acda044351170c29de6cba Mon Sep 17 00:00:00 2001 From: Richmond Glenn Viloria Date: Sun, 12 Jul 2026 23:04:59 +0800 Subject: [PATCH 03/19] fix(mobile): drop photo persistence for meter-reading OCR Matches the API/UI SSRF fix: readings no longer have an image_url field, so there's nothing to persist. Remove the savePhotos toggle from Settings, the photo-settings API module, the gated image_url attachment in CaptureReadings' submit payload, and the photo thumbnail in ReadingHistory. A captured photo is still used in-memory to drive the OCR suggest call, sent as a base64 data: URI, then discarded. Co-Authored-By: Claude Sonnet 5 --- mobile/src/lib/api/photo-settings.ts | 13 ----- mobile/src/lib/api/readings.ts | 6 --- mobile/src/screens/CaptureReadings.svelte | 63 ++++++----------------- mobile/src/screens/ReadingHistory.svelte | 28 ++-------- mobile/src/screens/Settings.svelte | 61 ---------------------- 5 files changed, 19 insertions(+), 152 deletions(-) delete mode 100644 mobile/src/lib/api/photo-settings.ts 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/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/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/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)/meter-groups/archive/+page.svelte b/ui/src/routes/(app)/meter-groups/archive/+page.svelte index 1ca69b6..95ff014 100644 --- a/ui/src/routes/(app)/meter-groups/archive/+page.svelte +++ b/ui/src/routes/(app)/meter-groups/archive/+page.svelte @@ -1,22 +1,17 @@
-
- -
+
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/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)/tenants/archive/+page.svelte b/ui/src/routes/(app)/tenants/archive/+page.svelte index be342ff..54d95a7 100644 --- a/ui/src/routes/(app)/tenants/archive/+page.svelte +++ b/ui/src/routes/(app)/tenants/archive/+page.svelte @@ -2,24 +2,24 @@ import { onMount } from 'svelte'; import { getTenants, restoreTenant, clearCache } from '$lib/api/tenants'; import { getProperties } from '$lib/api/properties'; - import type { Tenant } from '$lib/types/tenant.types'; import type { Property } from '$lib/types/property.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 properties = $state([]); - let isLoading = $state(false); - let error = $state(''); - let restoringId = $state(null); - let isClearing = $state(false); + + const page = createArchivePageState({ + listFn: getTenants, + restoreFn: restoreTenant, + clearCacheFn: clearCache, + entityLabel: 'tenant', + loadExtra: async () => { + const result = await getProperties({ limit: 100 }); + properties = result.data; + } + }); const columns = [ { key: 'tenant_name', label: 'Tenant Name' }, @@ -31,76 +31,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 [tenantsResult, propertiesResult] = await Promise.all([ - getTenants({ limit: 100, archived: true }), - getProperties({ limit: 100 }) - ]); - data = tenantsResult; - properties = propertiesResult.data; - } catch (err) { - error = err instanceof Error ? err.message : 'Failed to load archived tenants'; - } finally { - isLoading = false; - } - } - - async function handleRestore(id: string) { - restoringId = id; - try { - await restoreTenant(id); - await loadData(); - } catch (err) { - error = err instanceof Error ? err.message : 'Failed to restore tenant'; - } 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; - } - }
-
- -
+
From 2e4df364b239203d0d044ff046ab6d9d04a7c531 Mon Sep 17 00:00:00 2001 From: Richmond Glenn Viloria Date: Sun, 12 Jul 2026 23:14:55 +0800 Subject: [PATCH 15/19] refactor(ui): factor out createListStore for meter-group/property/tenant stores meter-groups.svelte.ts, properties.svelte.ts, and tenants.svelte.ts were byte-for-byte identical (fetch-with-staleness caching) aside from the imported fetch function and type. Extracted createListStore() so each file is now a 3-line instantiation. Co-Authored-By: Claude Sonnet 5 --- ui/src/lib/stores/list-store.svelte.ts | 54 ++++++++++++++++++++++++ ui/src/lib/stores/meter-groups.svelte.ts | 47 +-------------------- ui/src/lib/stores/properties.svelte.ts | 47 +-------------------- ui/src/lib/stores/tenants.svelte.ts | 47 +-------------------- 4 files changed, 60 insertions(+), 135 deletions(-) create mode 100644 ui/src/lib/stores/list-store.svelte.ts 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); From f31d6a140674e781c2c405f123d7a49b25430b71 Mon Sep 17 00:00:00 2001 From: Richmond Glenn Viloria Date: Sun, 12 Jul 2026 23:15:02 +0800 Subject: [PATCH 16/19] refactor(ui): dedupe true-reading calculation in billings page billings/+page.svelte reimplemented getCumulativeOffset/getVersionsSource/ trueReading verbatim (including comments) from lib/utils/true-reading.ts instead of importing them. Now imports the shared versions and keeps only the page-specific readingConsumption() on top. Co-Authored-By: Claude Sonnet 5 --- ui/src/routes/(app)/billings/+page.svelte | 46 ++--------------------- 1 file changed, 4 insertions(+), 42 deletions(-) diff --git a/ui/src/routes/(app)/billings/+page.svelte b/ui/src/routes/(app)/billings/+page.svelte index cb0a653..ad8ae57 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, From 04c0a0edfc9cc9f21f5b3a21913bb81c3892a314 Mon Sep 17 00:00:00 2001 From: Richmond Glenn Viloria Date: Sun, 12 Jul 2026 23:15:09 +0800 Subject: [PATCH 17/19] refactor(ui): add formatFirestoreDate helper and adopt across pages formatDate(toDate(x)) was composed ad hoc at call sites across dashboard, meter-groups, properties, and tenants. Added formatFirestoreDate() to format.ts and swapped in the affected call sites. Co-Authored-By: Claude Sonnet 5 --- ui/src/lib/utils/format.ts | 6 ++++++ ui/src/routes/(app)/dashboard/+page.svelte | 6 +++--- ui/src/routes/(app)/meter-groups/+page.svelte | 5 ++--- ui/src/routes/(app)/properties/+page.svelte | 12 ++++++------ ui/src/routes/(app)/tenants/+page.svelte | 4 ++-- 5 files changed, 19 insertions(+), 14 deletions(-) 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/routes/(app)/dashboard/+page.svelte b/ui/src/routes/(app)/dashboard/+page.svelte index 19a101b..6f6fea4 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'; @@ -186,8 +186,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)}
- {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)/tenants/+page.svelte b/ui/src/routes/(app)/tenants/+page.svelte index 50a341b..91d3328 100644 --- a/ui/src/routes/(app)/tenants/+page.svelte +++ b/ui/src/routes/(app)/tenants/+page.svelte @@ -6,7 +6,7 @@ import type { Tenant, CreateTenantRequest, UpdateTenantRequest } from '$lib/types/tenant.types'; import type { Property } from '$lib/types/property.types'; import type { PaginatedResult } from '$lib/types/api.types'; - import { formatDate } from '$lib/utils/format'; + import { formatFirestoreDate } from '$lib/utils/format'; import { toDate } from '$lib/utils/timestamp'; import EmptyState from '$lib/components/shared/EmptyState.svelte'; import TableSkeleton from '$lib/components/shared/TableSkeleton.svelte'; @@ -270,7 +270,7 @@ {properties.find((p) => p.id === item.property_id)?.room_name || 'Unknown'} - {formatDate(toDate(item.tenant_start_date))} + {formatFirestoreDate(item.tenant_start_date)} {#if item.tenant_end_date} From 9166edda08453e284baff1002071e6db03d84cf5 Mon Sep 17 00:00:00 2001 From: Richmond Glenn Viloria Date: Sun, 12 Jul 2026 23:16:37 +0800 Subject: [PATCH 18/19] fix(mobile): enforce Android cleartext-traffic config automatically MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit android/ is git-ignored and regenerated locally via `npx cap add android`, so the network_security_config.xml + AndroidManifest.xml wiring documented in BUILD.md was a manual, unenforced step — nothing checked it existed before packaging, so it was easy to lose silently after a fresh checkout, a new machine, or deleting android/ to fix a sync issue. Add scripts/apply-network-security-config.mjs, wired into both `npm run cap:add:android` and `npm run cap:sync`, so the config is created and wired into the manifest automatically every time those commands run instead of relying on a human to remember BUILD.md. Idempotent — verified against both a fresh (config/attribute missing) and already-configured android/ tree. Co-Authored-By: Claude Sonnet 5 --- mobile/BUILD.md | 41 +++++---- mobile/package.json | 5 +- .../scripts/apply-network-security-config.mjs | 87 +++++++++++++++++++ 3 files changed, 113 insertions(+), 20 deletions(-) create mode 100644 mobile/scripts/apply-network-security-config.mjs 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/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(); From 26417e0e5977152794f10d150fe080129651ecbf Mon Sep 17 00:00:00 2001 From: Richmond Glenn Viloria Date: Mon, 13 Jul 2026 08:50:51 +0800 Subject: [PATCH 19/19] lint: fix lint errors --- .../src/features/chatbot/chatbot.tools.ts | 38 +- ui/src/routes/(app)/billings/+page.svelte | 509 +++++++++--------- ui/src/routes/(app)/dashboard/+page.svelte | 12 +- 3 files changed, 278 insertions(+), 281 deletions(-) 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/ui/src/routes/(app)/billings/+page.svelte b/ui/src/routes/(app)/billings/+page.svelte index ad8ae57..78f77f5 100644 --- a/ui/src/routes/(app)/billings/+page.svelte +++ b/ui/src/routes/(app)/billings/+page.svelte @@ -790,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); @@ -1356,9 +1357,7 @@
- + - {getCycleMeterGroupName(cycle)} + {getCycleMeterGroupName(cycle)} {cycleUtilityType}{cycleUtilityType}
@@ -1581,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)/dashboard/+page.svelte b/ui/src/routes/(app)/dashboard/+page.svelte index 6f6fea4..3ba63cd 100644 --- a/ui/src/routes/(app)/dashboard/+page.svelte +++ b/ui/src/routes/(app)/dashboard/+page.svelte @@ -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;