integrated hub
This commit is contained in:
parent
bfa90b7377
commit
f5fa820530
@ -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
|
||||
|
||||
@ -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<number, number>;
|
||||
incomesByDay: Record<number, number[]>;
|
||||
obligationsByDay: Record<number, number[]>;
|
||||
} {
|
||||
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<number, number> = {};
|
||||
const incomesByDay: Record<number, number[]> = {};
|
||||
const obligationsByDay: Record<number, number[]> = {};
|
||||
// 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++) {
|
||||
|
||||
22
main.ts
22
main.ts
@ -370,17 +370,17 @@ if (isAppReady) {
|
||||
// });
|
||||
|
||||
// TODO: add a well-known endpoint
|
||||
// app.get("/.well-known/acme-challenge/:token", (ctx: Context<State>) => {
|
||||
// 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<State>) => {
|
||||
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());
|
||||
|
||||
@ -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" } },
|
||||
|
||||
@ -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 });
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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 }), {
|
||||
|
||||
@ -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 }), {
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -399,7 +399,8 @@ export default define.page(function TenantDashboard({ data }) {
|
||||
<dt class="dashboard-stat-label">Address</dt>
|
||||
<dd class="dashboard-stat-value">
|
||||
{currentProperty
|
||||
? buildPropertyAddress(currentProperty) || "Property Address"
|
||||
? buildPropertyAddress(currentProperty) ||
|
||||
"Property Address"
|
||||
: currentLease.property_address || "Property Address"}
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
423
services/dailyBalanceCache.ts
Normal file
423
services/dailyBalanceCache.ts
Normal file
@ -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<MonthlyBalanceCache> {
|
||||
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<number, number[]> = {};
|
||||
const obligationsByDay: Record<number, number[]> = {};
|
||||
|
||||
// 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<void> {
|
||||
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<MonthlyBalanceCache | null> {
|
||||
const key = getCacheKey(userId, year, month);
|
||||
return await kvStore.get<MonthlyBalanceCache>(key);
|
||||
}
|
||||
|
||||
// Invalidate cache for a user (call when incomes/obligations change)
|
||||
export async function invalidateBalanceCache(userId: string): Promise<void> {
|
||||
// List all cache entries for this user
|
||||
const entries: Array<{ key: Deno.KvKey; value: unknown }> = [];
|
||||
for await (
|
||||
const entry of kvStore.list<MonthlyBalanceCache>({
|
||||
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<void> {
|
||||
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<number> {
|
||||
const now = Date.now();
|
||||
let deletedCount = 0;
|
||||
|
||||
for await (
|
||||
const entry of kvStore.list<MonthlyBalanceCache>({
|
||||
prefix: CACHE_PREFIX,
|
||||
})
|
||||
) {
|
||||
const cache = entry.value;
|
||||
if (now - cache.computedAt > maxAge) {
|
||||
await kvStore.delete(entry.key);
|
||||
deletedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
return deletedCount;
|
||||
}
|
||||
@ -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<PropertyExpense | null> {
|
||||
export async function getExpenseById(
|
||||
expenseId: string,
|
||||
): Promise<PropertyExpense | null> {
|
||||
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<PropertyExpense
|
||||
/**
|
||||
* Get all expenses for a property
|
||||
*/
|
||||
export async function getExpensesByProperty(property_id: string): Promise<PropertyExpense[]> {
|
||||
export async function getExpensesByProperty(
|
||||
property_id: string,
|
||||
): Promise<PropertyExpense[]> {
|
||||
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<PropertyExpense[]> {
|
||||
export async function getRecurringExpenses(
|
||||
property_id: string,
|
||||
): Promise<PropertyExpense[]> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<RentChange[]> {
|
||||
export async function getRentChangesByProperty(
|
||||
property_id: string,
|
||||
): Promise<RentChange[]> {
|
||||
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<Ren
|
||||
/**
|
||||
* Get the current rent for a property (most recent effective date that has passed)
|
||||
*/
|
||||
export async function getCurrentRentForProperty(property_id: string): Promise<number | null> {
|
||||
export async function getCurrentRentForProperty(
|
||||
property_id: string,
|
||||
): Promise<number | null> {
|
||||
const now = Date.now();
|
||||
const result = await db.select()
|
||||
.from(rent_changes)
|
||||
@ -95,7 +99,9 @@ export async function getCurrentRentForProperty(property_id: string): Promise<nu
|
||||
/**
|
||||
* Get rent change by ID
|
||||
*/
|
||||
export async function getRentChangeById(changeId: string): Promise<RentChange | null> {
|
||||
export async function getRentChangeById(
|
||||
changeId: string,
|
||||
): Promise<RentChange | null> {
|
||||
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<RentChange |
|
||||
/**
|
||||
* Get upcoming rent changes for a property (effective_date is in the future)
|
||||
*/
|
||||
export async function getUpcomingRentChanges(property_id: string): Promise<RentChange[]> {
|
||||
export async function getUpcomingRentChanges(
|
||||
property_id: string,
|
||||
): Promise<RentChange[]> {
|
||||
const now = Date.now();
|
||||
return await db.select()
|
||||
.from(rent_changes)
|
||||
@ -116,13 +124,15 @@ export async function getUpcomingRentChanges(property_id: string): Promise<RentC
|
||||
/**
|
||||
* Get rent history for a property including current rent from the property record
|
||||
*/
|
||||
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;
|
||||
}>> {
|
||||
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,
|
||||
|
||||
@ -64,7 +64,7 @@ export function InitiPGSessionMiddleware() {
|
||||
return define.middleware(async (ctx: Context<State>) => {
|
||||
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/") ||
|
||||
|
||||
@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user