From f5fa820530ed11b68efb6cc566f798b655facc3b Mon Sep 17 00:00:00 2001 From: nathanAndrinoid Date: Thu, 4 Jun 2026 13:05:50 +0000 Subject: [PATCH] integrated hub --- build-publish.sh | 85 +- drizzle/meta/0002_snapshot.json | 2 +- drizzle/meta/0003_snapshot.json | 2 +- drizzle/meta/_journal.json | 2 +- islands/FinancialCalendar.tsx | 214 ++-- main.ts | 22 +- routes/api/create/income.ts | 9 + routes/api/create/obligation.ts | 11 + routes/api/delete/income.ts | 15 + routes/api/delete/obligation.ts | 17 +- routes/api/update/income.ts | 17 +- routes/api/update/obligation.ts | 15 + .../financial-calendar/[year]/[month].tsx | 303 ++--- routes/landlord/properties/[id]/index.tsx | 1060 +++++++++-------- routes/tenant/dashboard/index.tsx | 3 +- services/dailyBalanceCache.ts | 423 +++++++ services/expenseService.ts | 18 +- services/rentChangeService.ts | 36 +- services/stateMiddleware.ts | 2 +- services/tenantInviteService.ts | 5 +- 20 files changed, 1344 insertions(+), 917 deletions(-) create mode 100644 services/dailyBalanceCache.ts diff --git a/build-publish.sh b/build-publish.sh index fe02a61..14bc6e9 100755 --- a/build-publish.sh +++ b/build-publish.sh @@ -1,30 +1,49 @@ #!/usr/bin/env bash -# Build a publish directory: Linux binary (server) + _fresh for deployment. .env is not copied (publish dir is not recreated). -set -e +# Build and deploy CyberHybridHub to /home/chh/publish: +# - compiles bootstrap-serve.ts to publish/server +# - syncs _fresh/ into publish +# - preserves existing publish/.env (never overwritten from the repo) +set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -PUBLISH_DIR="$(cd "$SCRIPT_DIR/.." && pwd)/publish" +PUBLISH_DIR="/home/chh/publish" +SERVICE_NAME="cyberhybridhub" +COMPILE_TARGET="${COMPILE_TARGET:-x86_64-unknown-linux-gnu}" + cd "$SCRIPT_DIR" echo "Building Fresh app..." deno task build -# So the compiled binary finds _fresh at runtime (not compile-time path) -echo "Patching server-entry to use FRESH_ROOT at runtime..." -sed -i 's|const root = join(import.meta.dirname, "..");|const root = Deno.env.get("FRESH_ROOT") ?? join(import.meta.dirname, "..");|' _fresh/server/server-entry.mjs - -echo "Ensuring publish directory exists (parent of project)..." +echo "Ensuring publish directory exists..." mkdir -p "$PUBLISH_DIR" -echo "Compiling Linux binary (server)..." -deno compile -A --no-check --include ./_fresh/client --target x86_64-unknown-linux-gnu -o "$PUBLISH_DIR/server" bootstrap-serve.ts +if [[ -f "$PUBLISH_DIR/.env" ]]; then + echo "Keeping existing $PUBLISH_DIR/.env" +else + echo "Warning: $PUBLISH_DIR/.env not found." + echo " Create it before starting the service (copy from .env.example and set production values)." +fi -echo "Copying _fresh into publish/..." -# Full _fresh (server + client assets) so the binary finds favicon, static files, etc. -cp -r _fresh "$PUBLISH_DIR/" +echo "Compiling Linux binary to $PUBLISH_DIR/server.new ..." +deno compile -A --no-check \ + --include ./_fresh/client \ + --target "$COMPILE_TARGET" \ + -o "$PUBLISH_DIR/server.new" \ + bootstrap-serve.ts + +echo "Syncing _fresh into $PUBLISH_DIR ..." +rm -rf "$PUBLISH_DIR/_fresh" +cp -a _fresh "$PUBLISH_DIR/_fresh" + +echo "Patching publish _fresh for runtime FRESH_ROOT ..." +sed -i 's|const root = join(import.meta.dirname, "..");|const root = Deno.env.get("FRESH_ROOT") ?? join(import.meta.dirname, "..");|' \ + "$PUBLISH_DIR/_fresh/server/server-entry.mjs" + +echo "Installing new server binary..." +chmod +x "$PUBLISH_DIR/server.new" +mv -f "$PUBLISH_DIR/server.new" "$PUBLISH_DIR/server" -# Create systemd service file (install with: sudo cp ... /etc/systemd/system/ && sudo systemctl daemon-reload && sudo systemctl enable --now cyberhybridhub) -SERVICE_NAME="cyberhybridhub" SERVICE_FILE="$PUBLISH_DIR/${SERVICE_NAME}.service" cat > "$SERVICE_FILE" << EOF [Unit] @@ -34,24 +53,40 @@ After=network.target [Service] Type=simple WorkingDirectory=$PUBLISH_DIR -Environment=PORT=80 +EnvironmentFile=-$PUBLISH_DIR/.env ExecStart=$PUBLISH_DIR/server Restart=on-failure -# Binding to port 80 typically requires root; run 'sudo systemctl start $SERVICE_NAME' when installing +RestartSec=5 [Install] WantedBy=multi-user.target EOF chmod 644 "$SERVICE_FILE" -echo "Created $SERVICE_FILE" +echo "Wrote $SERVICE_FILE" -echo "Installing and reinitializing systemd service..." -sudo cp "$SERVICE_FILE" "/etc/systemd/system/" -sudo systemctl daemon-reload -sudo systemctl stop "$SERVICE_NAME" 2>/dev/null || true -sudo systemctl enable --now "$SERVICE_NAME" -echo "Service $SERVICE_NAME is enabled and running (port 80)." +if [[ "${SKIP_SYSTEMD:-0}" == "1" ]]; then + echo "SKIP_SYSTEMD=1 — skipped systemd install/restart." + echo " sudo cp $SERVICE_FILE /etc/systemd/system/" + echo " sudo systemctl daemon-reload && sudo systemctl enable --now $SERVICE_NAME" +elif sudo -n true 2>/dev/null; then + echo "Installing and restarting systemd service..." + sudo cp "$SERVICE_FILE" "/etc/systemd/system/${SERVICE_NAME}.service" + sudo systemctl daemon-reload + sudo systemctl enable "$SERVICE_NAME" + sudo systemctl restart "$SERVICE_NAME" -echo "Done. Contents of $PUBLISH_DIR:" + if sudo systemctl is-active --quiet "$SERVICE_NAME"; then + echo "Service $SERVICE_NAME is active." + else + echo "Service $SERVICE_NAME failed to start. Check: sudo journalctl -u $SERVICE_NAME -n 50 --no-pager" + exit 1 + fi +else + echo "No passwordless sudo — binary deployed; restart the service manually:" + echo " sudo cp $SERVICE_FILE /etc/systemd/system/" + echo " sudo systemctl daemon-reload && sudo systemctl restart $SERVICE_NAME" +fi + +echo "Done. Deployed to $PUBLISH_DIR:" ls -la "$PUBLISH_DIR/" ls -la "$PUBLISH_DIR/_fresh" 2>/dev/null || true diff --git a/drizzle/meta/0002_snapshot.json b/drizzle/meta/0002_snapshot.json index fa16f41..fc52b6e 100644 --- a/drizzle/meta/0002_snapshot.json +++ b/drizzle/meta/0002_snapshot.json @@ -2269,4 +2269,4 @@ "schemas": {}, "tables": {} } -} \ No newline at end of file +} diff --git a/drizzle/meta/0003_snapshot.json b/drizzle/meta/0003_snapshot.json index 30d1aaf..403aac1 100644 --- a/drizzle/meta/0003_snapshot.json +++ b/drizzle/meta/0003_snapshot.json @@ -2357,4 +2357,4 @@ "schemas": {}, "tables": {} } -} \ No newline at end of file +} diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index ba509b0..4ab755a 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -31,4 +31,4 @@ "breakpoints": true } ] -} \ No newline at end of file +} diff --git a/islands/FinancialCalendar.tsx b/islands/FinancialCalendar.tsx index 76a5fcc..813f4e7 100644 --- a/islands/FinancialCalendar.tsx +++ b/islands/FinancialCalendar.tsx @@ -3,7 +3,6 @@ import { decryptDataWithStoredPassword, getDate, - getDay, getEncryptionPassword, getMonth, getYear, @@ -11,6 +10,7 @@ import { setEncryptionPassword, } from "../helpers.ts"; import { Income } from "../services/financhalServices.ts"; +import type { MonthlyBalanceCache } from "../services/dailyBalanceCache.ts"; interface Obligation { id: string; @@ -32,6 +32,7 @@ interface FinancialCalendarProps { oldBalance?: string; oldDate?: string; userId?: string; + monthlyCache?: MonthlyBalanceCache | null; } export function getDaysInBaseMonth(year: number, month: number) { @@ -177,8 +178,16 @@ function financialCalendarReducer( } export default function FinancialCalendar( - { obligations, incomes, yearNum, monthNum, oldBalance, oldDate, userId }: - FinancialCalendarProps, + { + obligations, + incomes, + yearNum, + monthNum, + oldBalance, + oldDate, + userId, + monthlyCache, + }: FinancialCalendarProps, ) { // Theme classes are defined in styles.css and respond to theme mode + media. @@ -392,121 +401,109 @@ export default function FinancialCalendar( return { incomes: dayIncomes, obligations: dayObligations }; } - // Helper: Compute running balance for each day after the set balance date + // Helper: Get daily balances - use cache if available, otherwise compute as fallback function getDailyBalances(): { dailyBalances: Record; incomesByDay: Record; obligationsByDay: Record; } { - const start = MakeNewDate(state.formDate); - const startYear = getYear(start); - const startMonth = getMonth(start); - const startDay = getDate(start); - const startMonthYear = startYear * 12 + startMonth; - const endMonthYear = yearNum * 12 + baseMonth; - let runningBalance = 0; const dailyBalances: Record = {}; const incomesByDay: Record = {}; const obligationsByDay: Record = {}; - // console.log("startMonthYear", startMonthYear); - // console.log("endMonthYear", endMonthYear); - let numberNegative = 0; - let numberPositive = 0; - let maxYear = 0; - let minYear = 9999; - if (startMonthYear < endMonthYear) { - state.processedIncomes.forEach((inc) => { - if (!inc.received_date) return; - const d = MakeNewDate(inc.received_date); - const thisYear = getYear(d); - const thisMonth = getMonth(d); - const thisDay = getDate(d); - const thisMonthYear = thisYear * 12 + thisMonth; - if (thisMonthYear < endMonthYear) { - if ( - thisYear > startYear || - (thisYear === startYear && thisMonth > startMonth) || - (thisYear === startYear && thisMonth === startMonth && - thisDay >= startDay) - ) { - runningBalance += inc.amount || 0; - numberPositive++; - if (thisYear > maxYear) maxYear = thisYear; - if (thisYear < minYear) minYear = thisYear; - } - } - }); - state.processedObligations.forEach((ob) => { - if (!ob.due_date) return; - const d = MakeNewDate(ob.due_date); - const thisYear = getYear(d); - const thisMonth = getMonth(d); - const thisDay = getDate(d); - const thisMonthYear = thisYear * 12 + thisMonth; - if (thisMonthYear < endMonthYear) { - if ( - thisYear > startYear || - (thisYear === startYear && thisMonth > startMonth) || - (thisYear === startYear && thisMonth === startMonth && - thisDay >= startDay) - ) { - runningBalance -= ob.amount || 0; - numberNegative++; - if (thisYear > maxYear) maxYear = thisYear; - if (thisYear < minYear) minYear = thisYear; - } - } - }); - } - // console.log("runningBalance", runningBalance); - // console.log("numberPositive", numberPositive); - // console.log("numberNegative", numberNegative); - // console.log("maxYear", maxYear); - // console.log("minYear", minYear); - if (startMonthYear <= endMonthYear) { - runningBalance += state.formBalance || 0; - const sDay = (startMonthYear < endMonthYear) ? 1 : startDay; - for (let d = 1; d <= daysInThisMonth; d++) { - incomesByDay[d] = []; - obligationsByDay[d] = []; + // Always calculate daily changes from client-side decrypted data + for (let d = 1; d <= daysInThisMonth; d++) { + incomesByDay[d] = []; + obligationsByDay[d] = []; + } + + state.processedIncomes.forEach((inc) => { + if (!inc.received_date) return; + const d = MakeNewDate(inc.received_date); + if (getYear(d) === yearNum && getMonth(d) === baseMonth) { + const day = getDate(d); + incomesByDay[day].push(inc.amount || 0); + } + }); + + state.processedObligations.forEach((ob) => { + if (!ob.due_date) return; + const d = MakeNewDate(ob.due_date); + if (getYear(d) === yearNum && getMonth(d) === baseMonth) { + const day = getDate(d); + obligationsByDay[day].push(ob.amount || 0); + } + }); + + // If we have cached data, use it for running balances + if (monthlyCache && monthlyCache.entries) { + for (const entry of monthlyCache.entries) { + const date = new Date(entry.date); + const day = date.getDate(); + dailyBalances[day] = entry.balance; + } + } else { + // Fallback: Compute running balances from scratch + const start = MakeNewDate(state.formDate); + const startYear = getYear(start); + const startMonth = getMonth(start); + const startDay = getDate(start); + const startMonthYear = startYear * 12 + startMonth; + const endMonthYear = yearNum * 12 + baseMonth; + let runningBalance = 0; + + if (startMonthYear < endMonthYear) { + state.processedIncomes.forEach((inc) => { + if (!inc.received_date) return; + const d = MakeNewDate(inc.received_date); + const thisYear = getYear(d); + const thisMonth = getMonth(d); + const thisDay = getDate(d); + const thisMonthYear = thisYear * 12 + thisMonth; + if (thisMonthYear < endMonthYear) { + if ( + thisYear > startYear || + (thisYear === startYear && thisMonth > startMonth) || + (thisYear === startYear && thisMonth === startMonth && + thisDay >= startDay) + ) { + runningBalance += inc.amount || 0; + } + } + }); + state.processedObligations.forEach((ob) => { + if (!ob.due_date) return; + const d = MakeNewDate(ob.due_date); + const thisYear = getYear(d); + const thisMonth = getMonth(d); + const thisDay = getDate(d); + const thisMonthYear = thisYear * 12 + thisMonth; + if (thisMonthYear < endMonthYear) { + if ( + thisYear > startYear || + (thisYear === startYear && thisMonth > startMonth) || + (thisYear === startYear && thisMonth === startMonth && + thisDay >= startDay) + ) { + runningBalance -= ob.amount || 0; + } + } + }); } - state.processedIncomes.forEach((inc) => { - if (!inc.received_date) return; - const d = MakeNewDate(inc.received_date); - const thisYear = getYear(d); - const thisMonth = getMonth(d); - const thisDay = getDate(d); - const thisMonthYear = thisYear * 12 + thisMonth; - if (thisMonthYear === endMonthYear) { - incomesByDay[thisDay].push(inc.amount || 0); - } - }); + if (startMonthYear <= endMonthYear) { + runningBalance += state.formBalance || 0; + const sDay = (startMonthYear < endMonthYear) ? 1 : startDay; - state.processedObligations.forEach((ob) => { - if (!ob.due_date) return; - const d = MakeNewDate(ob.due_date); - const thisYear = getYear(d); - const thisMonth = getMonth(d); - const thisDay = getDate(d); - const thisMonthYear = thisYear * 12 + thisMonth; - if (thisMonthYear === endMonthYear) { - obligationsByDay[thisDay].push(ob.amount || 0); + for (let d = sDay; d <= daysInThisMonth; d++) { + if (incomesByDay[d]) { + for (const amt of incomesByDay[d]) runningBalance += amt; + } + if (obligationsByDay[d]) { + for (const amt of obligationsByDay[d]) runningBalance -= amt; + } + dailyBalances[d] = runningBalance; } - }); - - // Calculate running balance for current month - for (let d = sDay; d <= daysInThisMonth; d++) { - // Add incomes - if (incomesByDay[d]) { - for (const amt of incomesByDay[d]) runningBalance += amt; - } - // Subtract obligations - if (obligationsByDay[d]) { - for (const amt of obligationsByDay[d]) runningBalance -= amt; - } - dailyBalances[d] = runningBalance; } } @@ -514,10 +511,9 @@ export default function FinancialCalendar( } // Render calendar grid - const first = MakeNewDate( - yearNum + "-" + (monthNum < 10 ? "0" + monthNum : monthNum) + "-" + "01", - ); // 0=Sun - const firstDay = getDay(first); + // Explicitly create date in local time to avoid timezone issues + const first = new Date(yearNum, monthNum - 1, 1); + const firstDay = first.getDay(); // 0=Sun, 1=Mon, ..., 6=Sat let weeks: (string | number)[][] = []; let week: (string | number)[] = Array(firstDay).fill(""); for (let day = 1; day <= daysInThisMonth; day++) { diff --git a/main.ts b/main.ts index 9c1c3aa..6ef8a48 100644 --- a/main.ts +++ b/main.ts @@ -370,17 +370,17 @@ if (isAppReady) { // }); // TODO: add a well-known endpoint - // app.get("/.well-known/acme-challenge/:token", (ctx: Context) => { - // const token = ctx.params.token; - // const content = `${token}.EXAMPLE_ACCOUNT_THUMBPRINT`; - // return new Response(content, { - // status: 200, - // headers: { - // "content-type": "text/plain; charset=utf-8", - // "cache-control": "public, max-age=0, must-revalidate", - // }, - // }); - // }); + app.get("/.well-known/acme-challenge/:token", (ctx: Context) => { + const token = ctx.params.token; + const content = `${token}.EXAMPLE_ACCOUNT_THUMBPRINT`; + return new Response(content, { + status: 200, + headers: { + "content-type": "text/plain; charset=utf-8", + "cache-control": "public, max-age=0, must-revalidate", + }, + }); + }); app.use(InitiPGSessionMiddleware()); app.use(createAuthMiddleware()); diff --git a/routes/api/create/income.ts b/routes/api/create/income.ts index 5b4aca8..ddfa20c 100644 --- a/routes/api/create/income.ts +++ b/routes/api/create/income.ts @@ -8,6 +8,7 @@ import { validateRequiredString, withTimeout, } from "../../../utils/errorHandler.ts"; +import { invalidateBalanceCache } from "../../../services/dailyBalanceCache.ts"; const REQUEST_TIMEOUT = 30000; // 30 seconds @@ -63,6 +64,14 @@ export const handler = define.handlers({ "Request timed out while creating income record", ); + // Invalidate balance cache for this user + try { + await invalidateBalanceCache(stateData.user_id); + } catch (cacheError) { + console.error("Failed to invalidate cache:", cacheError); + // Non-fatal error - continue with success response + } + return new Response( JSON.stringify({ success: true }), { status: 200, headers: { "Content-Type": "application/json" } }, diff --git a/routes/api/create/obligation.ts b/routes/api/create/obligation.ts index 64b841c..f21852e 100644 --- a/routes/api/create/obligation.ts +++ b/routes/api/create/obligation.ts @@ -2,6 +2,7 @@ import { define } from "../../../utils.ts"; import { createObligation } from "../../../services/financhalServices.ts"; import { SessionCookie } from "../../auth/callback.ts"; +import { invalidateBalanceCache } from "../../../services/dailyBalanceCache.ts"; export const handler = define.handlers({ async POST(ctx) { @@ -23,6 +24,16 @@ export const handler = define.handlers({ encrypted_data ? encrypted_data.toString() : "", ); + // Invalidate balance cache for this user + if (stateData.user_id) { + try { + await invalidateBalanceCache(stateData.user_id); + } catch (cacheError) { + console.error("Failed to invalidate cache:", cacheError); + // Non-fatal error - continue with success response + } + } + return new Response("", { status: 200 }); } catch (e) { return new Response(JSON.stringify({ error: e }), { status: 500 }); diff --git a/routes/api/delete/income.ts b/routes/api/delete/income.ts index ea7223e..e484c4c 100644 --- a/routes/api/delete/income.ts +++ b/routes/api/delete/income.ts @@ -1,6 +1,8 @@ // routes/api/delete/income.ts import { define } from "../../../utils.ts"; import { deleteIncome } from "../../../services/financhalServices.ts"; +import { SessionCookie } from "../../auth/callback.ts"; +import { invalidateBalanceCache } from "../../../services/dailyBalanceCache.ts"; export const handler = define.handlers({ async POST(ctx) { @@ -12,8 +14,21 @@ export const handler = define.handlers({ return new Response("Missing income_id or sender", { status: 400 }); } + // Get user_id from session for cache invalidation + const stateData: SessionCookie = ctx.state.data; + await deleteIncome(income_id.toString()); + // Invalidate balance cache for this user + if (stateData.user_id) { + try { + await invalidateBalanceCache(stateData.user_id); + } catch (cacheError) { + console.error("Failed to invalidate cache:", cacheError); + // Non-fatal error - continue with redirect + } + } + // Redirect back to the sender URL (must be absolute) const senderPath = sender.toString(); let redirectUrl = senderPath; diff --git a/routes/api/delete/obligation.ts b/routes/api/delete/obligation.ts index a3b151c..4970d50 100644 --- a/routes/api/delete/obligation.ts +++ b/routes/api/delete/obligation.ts @@ -1,6 +1,8 @@ -// routes/api/delete/lease.ts +// routes/api/delete/obligation.ts import { deleteObligation } from "../../../services/financhalServices.ts"; import { define } from "../../../utils.ts"; +import { SessionCookie } from "../../auth/callback.ts"; +import { invalidateBalanceCache } from "../../../services/dailyBalanceCache.ts"; export const handler = define.handlers({ async POST(ctx) { @@ -12,9 +14,22 @@ export const handler = define.handlers({ return new Response("Missing obligation_id or sender", { status: 400 }); } + // Get user_id from session for cache invalidation + const stateData: SessionCookie = ctx.state.data; + // Delete the obligation using your service function await deleteObligation(obligation_id.toString()); + // Invalidate balance cache for this user + if (stateData.user_id) { + try { + await invalidateBalanceCache(stateData.user_id); + } catch (cacheError) { + console.error("Failed to invalidate cache:", cacheError); + // Non-fatal error - continue with redirect + } + } + // Redirect back to the sender URL (must be absolute) const senderPath = sender.toString(); let redirectUrl = senderPath; diff --git a/routes/api/update/income.ts b/routes/api/update/income.ts index 5b9f11b..3a1abc1 100644 --- a/routes/api/update/income.ts +++ b/routes/api/update/income.ts @@ -1,12 +1,17 @@ -// routes/api/update/obligation.ts +// routes/api/update/income.ts import { updateIncome } from "../../../services/financhalServices.ts"; import { define } from "../../../utils.ts"; +import { SessionCookie } from "../../auth/callback.ts"; +import { invalidateBalanceCache } from "../../../services/dailyBalanceCache.ts"; export const handler = define.handlers({ async POST(ctx) { const contentType = ctx.req.headers.get("content-type") || ""; const isJsonRequest = contentType.includes("application/json"); + // Get user_id from session for cache invalidation + const stateData: SessionCookie = ctx.state.data; + let incomeId: string; const updates: Partial<{ encrypted_data: string; @@ -75,6 +80,16 @@ export const handler = define.handlers({ try { await updateIncome(incomeId, updates); + // Invalidate balance cache for this user + if (stateData.user_id) { + try { + await invalidateBalanceCache(stateData.user_id); + } catch (cacheError) { + console.error("Failed to invalidate cache:", cacheError); + // Non-fatal error - continue with success response + } + } + if (isJsonRequest) { // Return JSON response for JSON requests return new Response(JSON.stringify({ success: true }), { diff --git a/routes/api/update/obligation.ts b/routes/api/update/obligation.ts index dead5a0..58e0226 100644 --- a/routes/api/update/obligation.ts +++ b/routes/api/update/obligation.ts @@ -1,12 +1,17 @@ // routes/api/update/obligation.ts import { updateObligation } from "../../../services/financhalServices.ts"; import { define } from "../../../utils.ts"; +import { SessionCookie } from "../../auth/callback.ts"; +import { invalidateBalanceCache } from "../../../services/dailyBalanceCache.ts"; export const handler = define.handlers({ async POST(ctx) { const contentType = ctx.req.headers.get("content-type") || ""; const isJsonRequest = contentType.includes("application/json"); + // Get user_id from session for cache invalidation + const stateData: SessionCookie = ctx.state.data; + let obligationId: string; const updates: Partial<{ encrypted_data: string; @@ -75,6 +80,16 @@ export const handler = define.handlers({ try { await updateObligation(obligationId, updates); + // Invalidate balance cache for this user + if (stateData.user_id) { + try { + await invalidateBalanceCache(stateData.user_id); + } catch (cacheError) { + console.error("Failed to invalidate cache:", cacheError); + // Non-fatal error - continue with success response + } + } + if (isJsonRequest) { // Return JSON response for JSON requests return new Response(JSON.stringify({ success: true }), { diff --git a/routes/calendar/financial-calendar/[year]/[month].tsx b/routes/calendar/financial-calendar/[year]/[month].tsx index 0505a0e..322e42a 100644 --- a/routes/calendar/financial-calendar/[year]/[month].tsx +++ b/routes/calendar/financial-calendar/[year]/[month].tsx @@ -1,6 +1,4 @@ -import FinancialCalendar, { - getDaysInBaseMonth, -} from "../../../../islands/FinancialCalendar.tsx"; +import FinancialCalendar from "../../../../islands/FinancialCalendar.tsx"; import { define } from "../../../../utils.ts"; import { getIncomesByUser, @@ -12,206 +10,18 @@ import { getFinancialBalancesByUser, upsertFinancialBalance, } from "../../../../services/userService.ts"; -import { page } from "fresh"; import { - getDate, - getMonth, - getYear, - MakeNewDate, -} from "../../../../helpers.ts"; + computeMonthlyBalances, + generateRecurringIncomesForMonth, + generateRecurringObligationsForMonth, + invalidateMonthCache, + type MonthlyBalanceCache, +} from "../../../../services/dailyBalanceCache.ts"; +import { page } from "fresh"; +import { getMonth, getYear } from "../../../../helpers.ts"; import { createPublicId } from "../../../../helpers.ts"; import { SessionCookie } from "../../../auth/callback.ts"; -// Helper function to generate recurring income entries -function generateRecurringIncomes( - incomes: Income[], - targetYear: number, - targetMonth: number, -): Income[] { - const result: Income[] = []; - const baseMonth = targetMonth - 1; - - for (const income of incomes) { - if (!income.frequency || !income.received_date) continue; - - let currentDate = MakeNewDate(income.received_date); - let daysInMonth = getDaysInBaseMonth( - getYear(currentDate), - getMonth(currentDate), - ); - - while ( - (getYear(currentDate) === targetYear && - getMonth(currentDate) <= baseMonth) || - getYear(currentDate) < targetYear - ) { - if (getDate(currentDate) <= daysInMonth) { - result.push({ - ...income, - received_date: currentDate.getTime(), - }); - } - - switch (income.frequency.toLowerCase()) { - case "daily": - currentDate = MakeNewDate( - currentDate.getTime() + 24 * 60 * 60 * 1000, - ); - break; - case "biweekly": - currentDate = MakeNewDate( - currentDate.getTime() + 14 * 24 * 60 * 60 * 1000, - ); - break; - case "monthly": - currentDate = MakeNewDate( - currentDate.getTime() + - getDaysInBaseMonth( - getYear(currentDate), - getMonth(currentDate), - ) * 24 * 60 * 60 * 1000, - ); - break; - case "one-time": - currentDate = MakeNewDate((targetYear + 2) + "-01-01"); - break; - case "weekly": - currentDate = MakeNewDate( - currentDate.getTime() + 7 * 24 * 60 * 60 * 1000, - ); - break; - case "quarterly": - currentDate = MakeNewDate( - currentDate.getTime() + - Number( - getDaysInBaseMonth( - getYear(currentDate), - getMonth(currentDate), - ), - ) * 3 * 24 * 60 * 60 * 1000, - ); - break; - case "yearly": - case "annually": { - const monthNumber = getMonth(currentDate) + 1; - const monthStr = monthNumber > 9 ? monthNumber : "0" + monthNumber; - currentDate = MakeNewDate( - (getYear(currentDate) + 1) + "-" + monthStr + "-" + - getDate(currentDate), - ); - break; - } - default: - // Unknown frequency, skip to prevent infinite loop - currentDate = MakeNewDate((targetYear + 2) + "-01-01"); - break; - } - - daysInMonth = getDaysInBaseMonth( - getYear(currentDate), - getMonth(currentDate), - ); - } - } - - return result; -} - -// Helper function to generate recurring obligation entries -function generateRecurringObligations( - obligations: Obligation[], - targetYear: number, - targetMonth: number, -): Obligation[] { - const result: Obligation[] = []; - const baseMonth = targetMonth - 1; - - for (const obligation of obligations) { - if (!obligation.frequency || !obligation.due_date) continue; - - let currentDate = MakeNewDate(obligation.due_date); - let daysInMonth = getDaysInBaseMonth( - getYear(currentDate), - getMonth(currentDate), - ); - - while ( - (getYear(currentDate) === targetYear && - getMonth(currentDate) <= baseMonth) || - getYear(currentDate) < targetYear - ) { - if (getDate(currentDate) <= daysInMonth) { - result.push({ - ...obligation, - due_date: currentDate.getTime(), - }); - } - - switch (obligation.frequency.toLowerCase()) { - case "daily": - currentDate = MakeNewDate( - currentDate.getTime() + 24 * 60 * 60 * 1000, - ); - break; - case "biweekly": - currentDate = MakeNewDate( - currentDate.getTime() + 14 * 24 * 60 * 60 * 1000, - ); - break; - case "monthly": - currentDate = MakeNewDate( - currentDate.getTime() + - getDaysInBaseMonth( - getYear(currentDate), - getMonth(currentDate), - ) * 24 * 60 * 60 * 1000, - ); - break; - case "one-time": - currentDate = MakeNewDate((targetYear + 2) + "-01-01"); - break; - case "weekly": - currentDate = MakeNewDate( - currentDate.getTime() + 7 * 24 * 60 * 60 * 1000, - ); - break; - case "quarterly": - currentDate = MakeNewDate( - currentDate.getTime() + - Number( - getDaysInBaseMonth( - getYear(currentDate), - getMonth(currentDate), - ), - ) * 3 * 24 * 60 * 60 * 1000, - ); - break; - case "yearly": - case "annually": { - const monthNumber = getMonth(currentDate) + 1; - const monthStr = monthNumber > 9 ? monthNumber : "0" + monthNumber; - currentDate = MakeNewDate( - (getYear(currentDate) + 1) + "-" + monthStr + "-" + - getDate(currentDate), - ); - break; - } - default: - // Unknown frequency, skip to prevent infinite loop - currentDate = MakeNewDate((targetYear + 2) + "-01-01"); - break; - } - - daysInMonth = getDaysInBaseMonth( - getYear(currentDate), - getMonth(currentDate), - ); - } - } - - return result; -} - export const handler = define.handlers({ GET: async (ctx) => { const params = ctx.params; @@ -237,10 +47,12 @@ export const handler = define.handlers({ ? await createPublicId(stateData.user_id) : ""; const isEncrypted = stateData?.user_encrypted; + let obligations: Obligation[] = []; let incomes: Income[] = []; - let balance: number | null; + let balance: number | null = null; let date: number | null = null; + let monthlyCache: MonthlyBalanceCache | null = null; if (stateData?.user_id) { const rawObligations = await getObligationsByUser(stateData.user_id) ?? @@ -257,20 +69,44 @@ export const handler = define.handlers({ inc.updated_on ); - obligations = generateRecurringObligations( + // Get base balance + const financialBalances = await getFinancialBalancesByUser( + stateData.user_id, + ); + let baseBalance: { date: string; balance: number } | null = null; + if (financialBalances) { + balance = financialBalances.balance ?? null; + date = financialBalances.balance_date ?? null; + if (balance !== null && date !== null) { + baseBalance = { + date: new Date(date).toISOString().slice(0, 10), + balance: balance, + }; + } + } + + // Compute or retrieve cached monthly balances + monthlyCache = await computeMonthlyBalances( + stateData.user_id, + yearNum, + monthNum, + filteredIncomes, + filteredObligations, + baseBalance, + ); + + // Generate recurring entries for the current month for display + // (used for day markers and list views - retains encrypted_data) + obligations = generateRecurringObligationsForMonth( filteredObligations, yearNum, monthNum, ); - incomes = generateRecurringIncomes(filteredIncomes, yearNum, monthNum); - const financialBalances = await getFinancialBalancesByUser( - stateData.user_id, + incomes = generateRecurringIncomesForMonth( + filteredIncomes, + yearNum, + monthNum, ); - if (financialBalances) { - const latestBalance = financialBalances; - balance = latestBalance.balance; - date = latestBalance.balance_date; - } } return page({ stateData, @@ -285,6 +121,7 @@ export const handler = define.handlers({ isEncrypted, urlRoute: `/calendar/financial-calendar/${yearNum}/${monthNum}`, stateId: state.stateId, + monthlyCache, }); }, POST: async (ctx) => { @@ -321,6 +158,7 @@ export const handler = define.handlers({ const isEncrypted = stateData?.user_encrypted; let obligations: Obligation[] = []; let incomes: Income[] = []; + let monthlyCache: MonthlyBalanceCache | null = null; if (stateData?.user_id) { const rawObligations = await getObligationsByUser(stateData.user_id) ?? @@ -337,14 +175,6 @@ export const handler = define.handlers({ inc.updated_on ); - obligations = generateRecurringObligations( - filteredObligations, - yearNum, - monthNum, - ); - incomes = generateRecurringIncomes(filteredIncomes, yearNum, monthNum); - console.log("obligations", obligations.length); - console.log("incomes", incomes.length); if (!balance || !date) { const financialBalances = await getFinancialBalancesByUser( stateData.user_id, @@ -361,12 +191,46 @@ export const handler = define.handlers({ } } } else { - const _updatedBalance = await upsertFinancialBalance( + // Update balance and invalidate cache for this month + await upsertFinancialBalance( stateData.user_id, balance, date, ); + // Invalidate cache so next request recomputes with new base balance + await invalidateMonthCache(stateData.user_id, yearNum, monthNum); } + + // Compute or retrieve cached monthly balances + let baseBalance: { date: string; balance: number } | null = null; + if (balance !== null && date !== null) { + baseBalance = { + date: new Date(date).toISOString().slice(0, 10), + balance: balance, + }; + } + + monthlyCache = await computeMonthlyBalances( + stateData.user_id, + yearNum, + monthNum, + filteredIncomes, + filteredObligations, + baseBalance, + ); + + // Generate recurring entries for the current month for display + // (used for day markers and list views - retains encrypted_data) + obligations = generateRecurringObligationsForMonth( + filteredObligations, + yearNum, + monthNum, + ); + incomes = generateRecurringIncomesForMonth( + filteredIncomes, + yearNum, + monthNum, + ); } return page({ stateData, @@ -381,6 +245,7 @@ export const handler = define.handlers({ isEncrypted, urlRoute: `/calendar/financial-calendar/${yearNum}/${monthNum}`, stateId: state.stateId, + monthlyCache, }); }, }); @@ -400,6 +265,7 @@ export default define.page( isEncrypted: boolean; urlRoute: string; stateId: string; + monthlyCache?: MonthlyBalanceCache | null; }; }, ) => { @@ -414,6 +280,7 @@ export default define.page( oldBalance={data.balance} oldDate={data.date} userId={data.publicId} + monthlyCache={data.monthlyCache} /> diff --git a/routes/landlord/properties/[id]/index.tsx b/routes/landlord/properties/[id]/index.tsx index 601be1e..b813b0b 100644 --- a/routes/landlord/properties/[id]/index.tsx +++ b/routes/landlord/properties/[id]/index.tsx @@ -17,7 +17,7 @@ import { import { db } from "../../../../db/db.ts"; import { leases, payment_transactions } from "../../../../db/schema.ts"; import { eq } from "drizzle-orm"; -import type { Property, Lease, User } from "../../../../db/interfaces.ts"; +import type { Lease, Property, User } from "../../../../db/interfaces.ts"; interface PropertyDetailsData { property: Property | null; @@ -106,9 +106,7 @@ export const handler = define.handlers({ } // Sort payments by date (newest first) - payments.sort((a, b) => - (b.payment_date || 0) - (a.payment_date || 0) - ); + payments.sort((a, b) => (b.payment_date || 0) - (a.payment_date || 0)); // Fetch all expenses for this property const expenses = await getExpensesByProperty(id); @@ -137,563 +135,575 @@ export const handler = define.handlers({ }, }); -export default define.page(function PropertyDetailsPage({ data }: { data: PropertyDetailsData }) { - const { property, leases, payments, expenses, rentHistory, error } = data; +export default define.page( + function PropertyDetailsPage({ data }: { data: PropertyDetailsData }) { + const { property, leases, payments, expenses, rentHistory, error } = data; - if (error) { - return ( -
-
-
-

Error

-

{error}

- - Back to Properties - -
-
-
- ); - } - - if (!property) { - return ( -
-
-
-

Property Not Found

-

The property you're looking for doesn't exist.

- - Back to Properties - -
-
-
- ); - } - - const propertyAddress = buildPropertyAddress(property); - const totalPayments = payments.reduce((sum, p) => sum + (p.amount || 0), 0); - const totalExpenses = expenses.reduce((sum, e) => sum + (e.amount || 0), 0); - const netIncome = totalPayments - totalExpenses; - - // Calculate expenses by category - const expensesByCategory: Record = {}; - for (const expense of expenses) { - const category = expense.category || "uncategorized"; - expensesByCategory[category] = (expensesByCategory[category] || 0) + (expense.amount || 0); - } - - return ( -
- {/* Header */} -
-
-
-
-

Property Details

-

- {property.title || propertyAddress} -

-
- + ); + } -
- {/* Property Info Card */} -
-
-

- Property Information -

-
+ const propertyAddress = buildPropertyAddress(property); + const totalPayments = payments.reduce((sum, p) => sum + (p.amount || 0), 0); + const totalExpenses = expenses.reduce((sum, e) => sum + (e.amount || 0), 0); + const netIncome = totalPayments - totalExpenses; + + // Calculate expenses by category + const expensesByCategory: Record = {}; + for (const expense of expenses) { + const category = expense.category || "uncategorized"; + expensesByCategory[category] = (expensesByCategory[category] || 0) + + (expense.amount || 0); + } + + return ( +
+ {/* Header */} +
+
+
-
Address
-
- {propertyAddress || "N/A"} -
+

Property Details

+

+ {property.title || propertyAddress} +

-
-
Monthly Rent
-
- ${property.monthly_rent?.toLocaleString() || "N/A"} -
-
-
-
Status
-
- - {property.rental_status || "unknown"} - -
-
-
-
Bedrooms / Bathrooms
-
- {property.bedrooms || 0}bd / {property.bathrooms || 0}ba -
-
-
-
Square Footage
-
- {property.square_footage?.toLocaleString() || "N/A"} sq ft -
-
-
-
Property Type
-
- {property.property_type || "N/A"} -
-
-
-
-
- - {/* Financial Summary */} -
-
-
-
-
- - - -
-
-
-
Total Payments Received
-
- ${totalPayments.toLocaleString()} -
-
-
-
-
-
- -
-
-
-
- - - -
-
-
-
Total Expenses
-
- ${totalExpenses.toLocaleString()} -
-
-
-
-
-
- -
-
-
-
- - - -
-
-
-
Net Income
-
= 0 ? "text-success" : "text-danger" - }`}> - ${netIncome.toLocaleString()} -
-
-
-
-
-
- -
-
-
-
- - - -
-
-
-
Active Leases
-
- {leases.filter((l) => l.lease_status === "active").length} -
-
-
-
-
-
-
- - {/* Two column layout */} -
- {/* Payment History */} -
-
-
-

Payment History

- - {payments.length} payments received - -
-
- - - - - - - - - - - - {payments && payments.length > 0 - ? ( - payments.map((payment) => ( - - - - - - - - )) - ) - : ( - - - - )} - -
DateTenantMethodAmountStatus
- {payment.payment_date - ? new Date(payment.payment_date) - .toLocaleDateString() - : "N/A"} - - {payment.tenant_name || "Unknown"} - - {payment.payment_method || "N/A"} - - ${payment.amount?.toLocaleString() || "0"} - - - {payment.transaction_status || "unknown"} - -
- No payment history yet -
-
-
-
- - {/* Expenses by Category */} - {Object.keys(expensesByCategory).length > 0 && ( -
-
-

- Expenses by Category -

-
- {Object.entries(expensesByCategory) - .sort(([, a], [, b]) => b - a) - .map(([category, amount]) => ( -
-
-

- {category} -

-
- - ${amount.toLocaleString()} - -
- ))} -
-
-
- )} - - {/* Expense History */} -
-
-
-

Expense History

+ -
- - - - - - - - - - - - {expenses && expenses.length > 0 - ? ( - expenses.map((expense) => ( - - - - - - - - )) - ) - : ( - - - - )} - -
DateCategoryDescriptionVendorAmount
- {expense.expense_date - ? new Date(expense.expense_date) - .toLocaleDateString() - : "N/A"} - - {expense.category || "N/A"} - - {expense.description || "-"} - - {expense.vendor || "-"} - - ${expense.amount?.toLocaleString() || "0"} -
- No expenses recorded yet -
+
+
+
+ +
+ {/* Property Info Card */} +
+
+

+ Property Information +

+
+
+
Address
+
+ {propertyAddress || "N/A"} +
+
+
+
Monthly Rent
+
+ ${property.monthly_rent?.toLocaleString() || "N/A"} +
+
+
+
Status
+
+ + {property.rental_status || "unknown"} + +
+
+
+
Bedrooms / Bathrooms
+
+ {property.bedrooms || 0}bd / {property.bathrooms || 0}ba +
+
+
+
Square Footage
+
+ {property.square_footage?.toLocaleString() || "N/A"} sq ft +
+
+
+
Property Type
+
+ {property.property_type || "N/A"} +
+
- {/* Active Leases */} -
-
-
-

Active Leases

+ {/* Financial Summary */} +
+
+
+
+
+ + + +
+
+
+
+ Total Payments Received +
+
+ ${totalPayments.toLocaleString()} +
+
+
+
-
- {leases && leases.length > 0 - ? ( - leases - .filter((l) => l.lease_status === "active") - .map((lease) => ( -
+
+ +
+
+
+
+ + + +
+
+
+
Total Expenses
+
+ ${totalExpenses.toLocaleString()} +
+
+
+
+
+
+ +
+
+
+
+ + + +
+
+
+
Net Income
+
= 0 ? "text-success" : "text-danger" + }`} + > + ${netIncome.toLocaleString()} +
+
+
+
+
+
+ +
+
+
+
+ + + +
+
+
+
Active Leases
+
+ {leases.filter((l) => l.lease_status === "active") + .length} +
+
+
+
+
+
+
+ + {/* Two column layout */} +
+ {/* Payment History */} +
+
+
+

Payment History

+ + {payments.length} payments received + +
+
+ + + + + + + + + + + + {payments && payments.length > 0 + ? ( + payments.map((payment) => ( + + + + + + + + )) + ) + : ( + + + + )} + +
DateTenantMethodAmountStatus
+ {payment.payment_date + ? new Date(payment.payment_date) + .toLocaleDateString() + : "N/A"} + + {payment.tenant_name || "Unknown"} + + {payment.payment_method || "N/A"} + + ${payment.amount?.toLocaleString() || "0"} + + + {payment.transaction_status || "unknown"} + +
+ No payment history yet +
+
+
+
+ + {/* Expenses by Category */} + {Object.keys(expensesByCategory).length > 0 && ( +
+
+

+ Expenses by Category +

+
+ {Object.entries(expensesByCategory) + .sort(([, a], [, b]) => b - a) + .map(([category, amount]) => ( +
-

- {lease.title || `Lease #${lease.id.slice(0, 8)}`} -

-

- {lease.start_date - ? new Date(lease.start_date) - .toLocaleDateString() - : ""}{" "} - -{" "} - {lease.end_date - ? new Date(lease.end_date) - .toLocaleDateString() - : ""} +

+ {category}

- ${lease.rent?.toLocaleString() || "0"}/mo + ${amount.toLocaleString()}
- )) - ) - : ( -
-

No active leases

-
- )} + ))} +
+
+
+ )} + + {/* Expense History */} +
+
+
+

Expense History

+ + Add Expense + +
+
+ + + + + + + + + + + + {expenses && expenses.length > 0 + ? ( + expenses.map((expense) => ( + + + + + + + + )) + ) + : ( + + + + )} + +
DateCategoryDescriptionVendorAmount
+ {expense.expense_date + ? new Date(expense.expense_date) + .toLocaleDateString() + : "N/A"} + + {expense.category || "N/A"} + + {expense.description || "-"} + + {expense.vendor || "-"} + + ${expense.amount?.toLocaleString() || "0"} +
+ No expenses recorded yet +
+
-
- {/* Rent History */} -
-
-
-

Rent History

- - Change Rent - + {/* Active Leases */} +
+
+
+

Active Leases

+
+
+ {leases && leases.length > 0 + ? ( + leases + .filter((l) => l.lease_status === "active") + .map((lease) => ( +
+
+

+ {lease.title || + `Lease #${lease.id.slice(0, 8)}`} +

+

+ {lease.start_date + ? new Date(lease.start_date) + .toLocaleDateString() + : ""} - {lease.end_date + ? new Date(lease.end_date) + .toLocaleDateString() + : ""} +

+
+ + ${lease.rent?.toLocaleString() || "0"}/mo + +
+ )) + ) + : ( +
+

No active leases

+
+ )} +
-
- - - - - - - - - - - - {rentHistory && rentHistory.length > 0 - ? ( - rentHistory.map((entry, index) => { - const previousRent = entry.previous_rent || 0; - const newRent = entry.monthly_rent || 0; - const changeAmount = newRent - previousRent; - const isIncrease = changeAmount > 0; - const isDecrease = changeAmount < 0; + - return ( - - + + + ); + }) + ) + : ( + + + + )} + +
Effective DatePrevious RentNew RentChangeReason
- {entry.effective_date - ? new Date(entry.effective_date) + {/* Rent History */} +
+
+
+

Rent History

+ + Change Rent + +
+
+ + + + + + + + + + + + {rentHistory && rentHistory.length > 0 + ? ( + rentHistory.map((entry, index) => { + const previousRent = entry.previous_rent || 0; + const newRent = entry.monthly_rent || 0; + const changeAmount = newRent - previousRent; + const isIncrease = changeAmount > 0; + const isDecrease = changeAmount < 0; + + return ( + + - - - + + + - - - ); - }) - ) - : ( - - - - )} - -
Effective DatePrevious RentNew RentChangeReason
+ {entry.effective_date + ? new Date(entry.effective_date) .toLocaleDateString() - : "N/A"} - {entry.is_current && ( - - Current - - )} - - ${previousRent.toLocaleString()} - - ${newRent.toLocaleString()} - - {previousRent > 0 && ( - + Current + + )} + + ${previousRent.toLocaleString()} + + ${newRent.toLocaleString()} + + {previousRent > 0 && ( + - {isIncrease && "+"} - ${changeAmount.toLocaleString()} - {changeAmount !== 0 && ` (${ - Math.round( - (changeAmount / previousRent) * 100 - ) - }%)`} - - )} - {previousRent === 0 && -} - - {entry.reason || "-"} -
- No rent history available -
+ : ""} + > + {isIncrease && "+"} + ${changeAmount.toLocaleString()} + {changeAmount !== 0 && ` (${ + Math.round( + (changeAmount / previousRent) * 100, + ) + }%)`} + + )} + {previousRent === 0 && -} +
+ {entry.reason || "-"} +
+ No rent history available +
+
-
- ); -}); + ); + }, +); diff --git a/routes/tenant/dashboard/index.tsx b/routes/tenant/dashboard/index.tsx index 755229d..4d2bd04 100644 --- a/routes/tenant/dashboard/index.tsx +++ b/routes/tenant/dashboard/index.tsx @@ -399,7 +399,8 @@ export default define.page(function TenantDashboard({ data }) {
Address
{currentProperty - ? buildPropertyAddress(currentProperty) || "Property Address" + ? buildPropertyAddress(currentProperty) || + "Property Address" : currentLease.property_address || "Property Address"}
diff --git a/services/dailyBalanceCache.ts b/services/dailyBalanceCache.ts new file mode 100644 index 0000000..3a3be19 --- /dev/null +++ b/services/dailyBalanceCache.ts @@ -0,0 +1,423 @@ +// services/dailyBalanceCache.ts +// Cached daily balance computation with incremental updates and time-based expiration + +import { kvStore } from "../modules/KvCacheStore.ts"; +import { Income, Obligation } from "./financhalServices.ts"; + +export interface DailyBalanceEntry { + date: string; // YYYY-MM-DD + balance: number; + dayIncome: number; + dayObligation: number; +} + +export interface MonthlyBalanceCache { + userId: string; + year: number; + month: number; // 1-12 + startDate: string; // YYYY-MM-DD - first day with balance data + startBalance: number; // Balance at startDate (before day's transactions) + entries: DailyBalanceEntry[]; + computedAt: number; // Unix timestamp +} + +export interface ComputeBalanceOptions { + ttl?: number; // Cache TTL in milliseconds (default: 1 hour) + forceRecompute?: boolean; +} + +const DEFAULT_CACHE_TTL = 60 * 60 * 1000; // 1 hour +const CACHE_PREFIX = ["daily_balances"]; + +// Get cache key for a user's month +function getCacheKey(userId: string, year: number, month: number): Deno.KvKey { + return [...CACHE_PREFIX, userId, String(year), String(month)]; +} + +// Get days in month +function getDaysInMonth(year: number, month: number): number { + return new Date(year, month + 1, 0).getDate(); +} + +// Format date to YYYY-MM-DD +function formatDateStr(year: number, month: number, day: number): string { + const monthStr = month < 9 ? `0${month + 1}` : `${month + 1}`; + const dayStr = day < 10 ? `0${day}` : `${day}`; + return `${year}-${monthStr}-${dayStr}`; +} + +// Advance a date by frequency +function advanceDate( + date: Date, + frequency: string, +): Date { + switch (frequency.toLowerCase()) { + case "daily": + return new Date(date.getFullYear(), date.getMonth(), date.getDate() + 1); + case "weekly": + return new Date(date.getFullYear(), date.getMonth(), date.getDate() + 7); + case "biweekly": + return new Date(date.getFullYear(), date.getMonth(), date.getDate() + 14); + case "monthly": { + // Advance by one month, preserving the day number + return new Date(date.getFullYear(), date.getMonth() + 1, date.getDate()); + } + case "quarterly": + return new Date(date.getFullYear(), date.getMonth() + 3, date.getDate()); + case "yearly": + case "annually": { + // Advance by year, preserving month/day + const newYear = date.getFullYear() + 1; + const newMonth = date.getMonth(); + const newDay = date.getDate(); + return new Date(newYear, newMonth, newDay); + } + default: + return date; // Unknown frequency - don't advance + } +} + +// Generate recurring income occurrences for a specific month (for display) +export function generateRecurringIncomesForMonth( + incomes: Income[], + targetYear: number, + targetMonth: number, +): Income[] { + const result: Income[] = []; + + for (const income of incomes) { + if (!income.frequency || !income.received_date) continue; + + let currentDate = new Date(income.received_date); + const targetMonthIdx = targetMonth - 1; + + // Advance until we reach or pass the target month/year + while ( + currentDate.getFullYear() < targetYear || + currentDate.getMonth() < targetMonthIdx + ) { + currentDate = advanceDate(currentDate, income.frequency); + + // Safety check for one-time or unknown frequencies + if ( + income.frequency.toLowerCase() === "one-time" || + currentDate.getFullYear() > targetYear + 2 + ) { + break; + } + } + + // Add all occurrences in target month + while ( + currentDate.getFullYear() === targetYear && + currentDate.getMonth() === targetMonthIdx + ) { + // Create timestamp using local date components to avoid timezone issues + const localTimestamp = new Date( + currentDate.getFullYear(), + currentDate.getMonth(), + currentDate.getDate(), + ).getTime(); + result.push({ + ...income, + received_date: localTimestamp, + }); + currentDate = advanceDate(currentDate, income.frequency); + } + } + + return result; +} + +// Generate recurring obligation occurrences for a specific month (for display) +export function generateRecurringObligationsForMonth( + obligations: Obligation[], + targetYear: number, + targetMonth: number, +): Obligation[] { + const result: Obligation[] = []; + + for (const obligation of obligations) { + if (!obligation.frequency || !obligation.due_date) continue; + + let currentDate = new Date(obligation.due_date); + const targetMonthIdx = targetMonth - 1; + + // Advance until we reach or pass the target month/year + while ( + currentDate.getFullYear() < targetYear || + currentDate.getMonth() < targetMonthIdx + ) { + currentDate = advanceDate( + currentDate, + obligation.frequency, + ); + + // Safety check for one-time or unknown frequencies + if ( + obligation.frequency.toLowerCase() === "one-time" || + currentDate.getFullYear() > targetYear + 2 + ) { + break; + } + } + + // Add all occurrences in target month + while ( + currentDate.getFullYear() === targetYear && + currentDate.getMonth() === targetMonthIdx + ) { + // Create timestamp using local date components to avoid timezone issues + const localTimestamp = new Date( + currentDate.getFullYear(), + currentDate.getMonth(), + currentDate.getDate(), + ).getTime(); + result.push({ + ...obligation, + due_date: localTimestamp, + }); + currentDate = advanceDate( + currentDate, + obligation.frequency, + ); + } + } + + return result; +} + +// Parse YYYY-MM-DD string to date components +function parseDateStr( + dateStr: string, +): { year: number; month: number; day: number } { + const [year, month, day] = dateStr.split("-").map(Number); + return { year, month: month - 1, day }; // month is 0-indexed in Date +} + +// Compute daily balances for a specific month +export async function computeMonthlyBalances( + userId: string, + year: number, + month: number, + incomes: Income[], + obligations: Obligation[], + baseBalance: { date: string; balance: number } | null, + options: ComputeBalanceOptions = {}, +): Promise { + const { ttl = DEFAULT_CACHE_TTL, forceRecompute = false } = options; + + // Check cache first (unless force recompute) + if (!forceRecompute) { + const cached = await getCachedMonthlyBalances(userId, year, month); + if (cached && (Date.now() - cached.computedAt) < ttl) { + return cached; + } + } + + // Compute from scratch or incremental + const result: MonthlyBalanceCache = { + userId, + year, + month, + startDate: baseBalance?.date || formatDateStr(year, month - 1, 1), + startBalance: baseBalance?.balance || 0, + entries: [], + computedAt: Date.now(), + }; + + const daysInMonth = getDaysInMonth(year, month - 1); + let runningBalance = baseBalance?.balance || 0; + + // If we have a base balance, start from that date + const startDate = baseBalance ? parseDateStr(baseBalance.date) : null; + + // Compute incomes by day for this month + const incomesByDay: Record = {}; + const obligationsByDay: Record = {}; + + // Initialize day arrays + for (let d = 1; d <= daysInMonth; d++) { + incomesByDay[d] = []; + obligationsByDay[d] = []; + } + + // Generate recurring incomes for this month + for (const income of incomes) { + if (!income.frequency || !income.received_date) continue; + + let currentDate = new Date(income.received_date); + const targetMonth = month - 1; // 0-indexed + + // Advance until we reach or pass the target month/year + while ( + currentDate.getFullYear() < year || currentDate.getMonth() < targetMonth + ) { + currentDate = advanceDate( + currentDate, + income.frequency, + ); + + // Safety check for one-time or unknown frequencies + if ( + income.frequency.toLowerCase() === "one-time" || + currentDate.getFullYear() > year + 2 + ) { + break; + } + } + + // Add all occurrences in target month + while ( + currentDate.getFullYear() === year && + currentDate.getMonth() === targetMonth + ) { + const day = currentDate.getDate(); + if (day <= daysInMonth) { + incomesByDay[day].push(income.amount || 0); + } + currentDate = advanceDate( + currentDate, + income.frequency, + ); + } + } + + // Generate recurring obligations for this month + for (const obligation of obligations) { + if (!obligation.frequency || !obligation.due_date) continue; + + let currentDate = new Date(obligation.due_date); + const targetMonth = month - 1; // 0-indexed + + // Advance until we reach or pass the target month/year + while ( + currentDate.getFullYear() < year || currentDate.getMonth() < targetMonth + ) { + currentDate = advanceDate( + currentDate, + obligation.frequency, + ); + + // Safety check for one-time or unknown frequencies + if ( + obligation.frequency.toLowerCase() === "one-time" || + currentDate.getFullYear() > year + 2 + ) { + break; + } + } + + // Add all occurrences in target month + while ( + currentDate.getFullYear() === year && + currentDate.getMonth() === targetMonth + ) { + const day = currentDate.getDate(); + if (day <= daysInMonth) { + obligationsByDay[day].push(obligation.amount || 0); + } + currentDate = advanceDate( + currentDate, + obligation.frequency, + ); + } + } + + // Calculate running balance for each day + const startDay = startDate && + startDate.year === year && + startDate.month === month - 1 + ? startDate.day + : 1; + + for (let day = startDay; day <= daysInMonth; day++) { + const dayIncome = incomesByDay[day].reduce((sum, amt) => sum + amt, 0); + const dayObligation = obligationsByDay[day].reduce( + (sum, amt) => sum + amt, + 0, + ); + + runningBalance += dayIncome - dayObligation; + + result.entries.push({ + date: formatDateStr(year, month - 1, day), + balance: runningBalance, + dayIncome, + dayObligation, + }); + } + + // Cache the result + await cacheMonthlyBalances(result); + + return result; +} + +// Cache monthly balances +export async function cacheMonthlyBalances( + cache: MonthlyBalanceCache, +): Promise { + const key = getCacheKey(cache.userId, cache.year, cache.month); + await kvStore.set(key, cache); +} + +// Retrieve cached monthly balances +export async function getCachedMonthlyBalances( + userId: string, + year: number, + month: number, +): Promise { + const key = getCacheKey(userId, year, month); + return await kvStore.get(key); +} + +// Invalidate cache for a user (call when incomes/obligations change) +export async function invalidateBalanceCache(userId: string): Promise { + // List all cache entries for this user + const entries: Array<{ key: Deno.KvKey; value: unknown }> = []; + for await ( + const entry of kvStore.list({ + prefix: [...CACHE_PREFIX, userId], + }) + ) { + entries.push(entry); + } + + // Delete all entries + for (const entry of entries) { + await kvStore.delete(entry.key); + } +} + +// Invalidate cache for a specific month (more targeted) +export async function invalidateMonthCache( + userId: string, + year: number, + month: number, +): Promise { + const key = getCacheKey(userId, year, month); + await kvStore.delete(key); +} + +// Clear all expired cache entries (cleanup utility) +export async function clearExpiredCache( + maxAge: number = DEFAULT_CACHE_TTL, +): Promise { + const now = Date.now(); + let deletedCount = 0; + + for await ( + const entry of kvStore.list({ + prefix: CACHE_PREFIX, + }) + ) { + const cache = entry.value; + if (now - cache.computedAt > maxAge) { + await kvStore.delete(entry.key); + deletedCount++; + } + } + + return deletedCount; +} diff --git a/services/expenseService.ts b/services/expenseService.ts index 06fa9fb..76b7989 100644 --- a/services/expenseService.ts +++ b/services/expenseService.ts @@ -1,7 +1,7 @@ // services/expenseService.ts // Handle property expense management and reporting -import { and, eq, between, desc } from "drizzle-orm"; +import { and, between, desc, eq } from "drizzle-orm"; import { db } from "../db/db.ts"; import { property_expenses } from "../db/schema.ts"; @@ -54,7 +54,9 @@ export async function createExpense( /** * Get expense by ID */ -export async function getExpenseById(expenseId: string): Promise { +export async function getExpenseById( + expenseId: string, +): Promise { const result = await db.select() .from(property_expenses) .where(eq(property_expenses.id, expenseId)); @@ -64,7 +66,9 @@ export async function getExpenseById(expenseId: string): Promise { +export async function getExpensesByProperty( + property_id: string, +): Promise { return await db.select() .from(property_expenses) .where(eq(property_expenses.property_id, property_id)) @@ -111,7 +115,9 @@ export async function getExpensesByPropertyAndCategory( /** * Get recurring expenses for a property */ -export async function getRecurringExpenses(property_id: string): Promise { +export async function getRecurringExpenses( + property_id: string, +): Promise { return await db.select() .from(property_expenses) .where( @@ -169,7 +175,9 @@ export async function getExpenseSummary( if (exp.expense_date) { const date = new Date(exp.expense_date); - const monthKey = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}`; + const monthKey = `${date.getFullYear()}-${ + String(date.getMonth() + 1).padStart(2, "0") + }`; byMonth[monthKey] = (byMonth[monthKey] || 0) + (exp.amount || 0); } } diff --git a/services/rentChangeService.ts b/services/rentChangeService.ts index 1adfa68..b7b48bd 100644 --- a/services/rentChangeService.ts +++ b/services/rentChangeService.ts @@ -1,9 +1,9 @@ // services/rentChangeService.ts // Handle rent change history tracking for properties -import { eq, desc } from "drizzle-orm"; +import { desc, eq } from "drizzle-orm"; import { db } from "../db/db.ts"; -import { rent_changes, properties } from "../db/schema.ts"; +import { properties, rent_changes } from "../db/schema.ts"; // Use the type from the schema export type RentChange = typeof rent_changes.$inferSelect; @@ -58,7 +58,9 @@ export async function createRentChange( /** * Get all rent changes for a property, ordered by effective date (newest first) */ -export async function getRentChangesByProperty(property_id: string): Promise { +export async function getRentChangesByProperty( + property_id: string, +): Promise { return await db.select() .from(rent_changes) .where(eq(rent_changes.property_id, property_id)) @@ -68,7 +70,9 @@ export async function getRentChangesByProperty(property_id: string): Promise { +export async function getCurrentRentForProperty( + property_id: string, +): Promise { const now = Date.now(); const result = await db.select() .from(rent_changes) @@ -95,7 +99,9 @@ export async function getCurrentRentForProperty(property_id: string): Promise { +export async function getRentChangeById( + changeId: string, +): Promise { const result = await db.select() .from(rent_changes) .where(eq(rent_changes.id, changeId)); @@ -105,7 +111,9 @@ export async function getRentChangeById(changeId: string): Promise { +export async function getUpcomingRentChanges( + property_id: string, +): Promise { const now = Date.now(); return await db.select() .from(rent_changes) @@ -116,13 +124,15 @@ export async function getUpcomingRentChanges(property_id: string): Promise> { +export async function getRentHistory(property_id: string): Promise< + Array<{ + effective_date: number; + monthly_rent: number; + previous_rent: number | null; + reason: string | null; + is_current: boolean; + }> +> { // Get the property's current rent const propertyResult = await db.select({ monthly_rent: properties.monthly_rent, diff --git a/services/stateMiddleware.ts b/services/stateMiddleware.ts index 0b27df7..0428979 100755 --- a/services/stateMiddleware.ts +++ b/services/stateMiddleware.ts @@ -64,7 +64,7 @@ export function InitiPGSessionMiddleware() { return define.middleware(async (ctx: Context) => { const url = new URL(ctx.req.url); - console.log("Pathname:", ctx.req.method, url.pathname); + //console.log("Pathname:", ctx.req.method, url.pathname); if ( url.pathname.startsWith("/api/ai-images/file/") || diff --git a/services/tenantInviteService.ts b/services/tenantInviteService.ts index 1fd6b34..ad86e6d 100644 --- a/services/tenantInviteService.ts +++ b/services/tenantInviteService.ts @@ -104,11 +104,9 @@ function toPlainEmail(email: string): string { } export async function getTenantInvitesByEmail(email: string) { - const normalizedEmail = toPlainEmail(email); - + if (!normalizedEmail) { - return []; } // ILIKE substring match then exact filter (handles JSON array and comma-separated stored format). @@ -117,7 +115,6 @@ export async function getTenantInvitesByEmail(email: string) { ilike(tenant_invites.invited_emails, pattern), ); return result.filter((invite) => { - return hasMatchingInviteEmail(invite, normalizedEmail); }); }