438 lines
14 KiB
TypeScript
438 lines
14 KiB
TypeScript
import { App, Context, staticFiles } from "fresh";
|
|
import { type State } from "./utils.ts";
|
|
import { loadSync } from "$std/dotenv/mod.ts";
|
|
import { sql } from "drizzle-orm";
|
|
|
|
// Allow empty values for optional Stripe variables
|
|
loadSync({ allowEmptyValues: true, export: true });
|
|
import { checkDatabaseConnection } from "./db/db.ts";
|
|
|
|
// Development mode can be checked via: Deno.env.get("DEVELOPMENT") === "true"
|
|
|
|
/** Error page HTML for service unavailability */
|
|
const SERVICE_UNAVAILABLE_HTML = `<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Service Unavailable</title>
|
|
<link rel="stylesheet" href="/assets/styles.css" />
|
|
</head>
|
|
<body class="service-unavailable">
|
|
<div class="service-unavailable__container">
|
|
<div class="service-unavailable__icon">🔧</div>
|
|
<h1>The problem is on our side!</h1>
|
|
<p>We will fix this as soon as we can.</p>
|
|
</div>
|
|
</body>
|
|
</html>`;
|
|
|
|
/** Check if required KV cache files exist */
|
|
function checkKvCacheFiles(): boolean {
|
|
// const requiredFiles = ["./data/kv-cache-shm", "./data/kv-cache-wal"];
|
|
// for (const file of requiredFiles) {
|
|
// try {
|
|
// Deno.statSync(file);
|
|
// } catch {
|
|
// console.error(`⚠️ Required file missing: ${file}`);
|
|
// return false;
|
|
// }
|
|
// }
|
|
return true;
|
|
}
|
|
|
|
/** Perform all startup checks */
|
|
async function _performStartupChecks(): Promise<boolean> {
|
|
//console.log("Checking startup requirements...");
|
|
|
|
// Check KV cache files
|
|
const kvFilesExist = checkKvCacheFiles();
|
|
if (!kvFilesExist) {
|
|
console.error(
|
|
"⚠️ KV cache files missing - app will display maintenance page",
|
|
);
|
|
return false;
|
|
}
|
|
//console.log("✅ KV cache files found");
|
|
|
|
// Check database connection
|
|
//console.log("Checking database connection...");
|
|
const dbAvailable = await checkDatabaseConnection();
|
|
if (!dbAvailable) {
|
|
console.error(
|
|
"⚠️ Database connection failed - app will display maintenance page",
|
|
);
|
|
return false;
|
|
}
|
|
//console.log("✅ Database connection successful");
|
|
|
|
return true;
|
|
}
|
|
|
|
// Run startup checks first
|
|
const isAppReady = true; // await _performStartupChecks();
|
|
|
|
// Create app based on startup check result
|
|
export const app = new App<State>();
|
|
|
|
if (isAppReady) {
|
|
// Full app with all routes and middleware
|
|
const { InitiPGSessionMiddleware } = await import(
|
|
"./services/stateMiddleware.ts"
|
|
);
|
|
const { createAuthMiddleware } = await import(
|
|
"./services/authMiddleware.ts"
|
|
);
|
|
|
|
app.use(staticFiles());
|
|
|
|
app.get("/health", async (_ctx: Context<State>) => {
|
|
console.log("Health check - verifying database connection...");
|
|
const dbConnected = await checkDatabaseConnection();
|
|
|
|
if (dbConnected) {
|
|
console.log("✅ Health check: Database connected");
|
|
return new Response(
|
|
JSON.stringify({
|
|
status: "healthy",
|
|
database: "connected",
|
|
timestamp: new Date().toISOString(),
|
|
}),
|
|
{
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" },
|
|
},
|
|
);
|
|
} else {
|
|
console.log("❌ Health check: Database connection failed");
|
|
return new Response(
|
|
JSON.stringify({
|
|
status: "unhealthy",
|
|
database: "disconnected",
|
|
timestamp: new Date().toISOString(),
|
|
}),
|
|
{
|
|
status: 503,
|
|
headers: { "Content-Type": "application/json" },
|
|
},
|
|
);
|
|
}
|
|
});
|
|
|
|
// Debug endpoint to verify database schema (no auth required)
|
|
app.get("/api/debug/schema", async (_ctx: Context<State>) => {
|
|
try {
|
|
const { db } = await import("./db/db.ts");
|
|
|
|
// Query PostgreSQL information_schema to get all tables
|
|
const query = `
|
|
SELECT table_name, table_schema
|
|
FROM information_schema.tables
|
|
WHERE table_schema = 'memory'
|
|
OR table_schema = 'checkpoints'
|
|
OR table_schema = 'public'
|
|
ORDER BY table_schema, table_name
|
|
`;
|
|
|
|
const result = await db.execute(query);
|
|
|
|
// Expected tables based on schema.ts
|
|
const expectedTables = [
|
|
"users",
|
|
"roles",
|
|
"user_roles",
|
|
"state_keys",
|
|
"user_tokens",
|
|
"web_tokens",
|
|
"obligations",
|
|
"incomes",
|
|
"financial_balances",
|
|
"properties",
|
|
"rent_schedules",
|
|
"leases",
|
|
"payment_methods",
|
|
"property_media",
|
|
"lease_documents",
|
|
"tenant_applications",
|
|
"tenant_invites",
|
|
"payment_transactions",
|
|
"reminders",
|
|
"messages",
|
|
"maintenance_requests",
|
|
"user_pins",
|
|
"session_configs",
|
|
];
|
|
|
|
// Extract table names from query result
|
|
const existingTables = result.rows.map((row: Record<string, unknown>) =>
|
|
String(row["table_name"])
|
|
);
|
|
|
|
// Find missing tables
|
|
const missingTables = expectedTables.filter(
|
|
(table) => !existingTables.includes(table),
|
|
);
|
|
|
|
return new Response(
|
|
JSON.stringify({
|
|
status: "success",
|
|
existing_tables: existingTables,
|
|
expected_tables: expectedTables,
|
|
missing_tables: missingTables,
|
|
all_tables_present: missingTables.length === 0,
|
|
total_existing: existingTables.length,
|
|
total_expected: expectedTables.length,
|
|
}),
|
|
{
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" },
|
|
},
|
|
);
|
|
} catch (error) {
|
|
console.error("Error checking database schema:", error);
|
|
return new Response(
|
|
JSON.stringify({
|
|
status: "error",
|
|
message: error instanceof Error ? error.message : "Unknown error",
|
|
}),
|
|
{
|
|
status: 500,
|
|
headers: { "Content-Type": "application/json" },
|
|
},
|
|
);
|
|
}
|
|
});
|
|
|
|
// Debug endpoint to test session persistence across server restarts
|
|
app.get("/api/debug/persistence-test", async (_ctx: Context<State>) => {
|
|
const url = new URL(_ctx.req.url);
|
|
const action = url.searchParams.get("action") || "check";
|
|
|
|
try {
|
|
const { db } = await import("./db/db.ts");
|
|
const TEST_USER_ID = "PERSIST_TEST_12345";
|
|
const TEST_STATE_ID = "test-persist-state-" + Date.now();
|
|
|
|
if (action === "create") {
|
|
// Use Drizzle ORM functions for proper database operations
|
|
const { sqlSetStateKey } = await import("./db/sqlUserRepository.ts");
|
|
|
|
const createdOn = Date.now();
|
|
const sealKey = "test-seal-key-" + createdOn;
|
|
|
|
// Insert test state key using Drizzle ORM
|
|
await sqlSetStateKey(
|
|
sealKey,
|
|
TEST_STATE_ID,
|
|
"test-cookie-hash-" + createdOn,
|
|
);
|
|
|
|
// For user_tokens, use raw SQL with schema qualification to avoid drizzle issues
|
|
try {
|
|
await db.execute(sql`
|
|
INSERT INTO memory.user_tokens (user_id, state_id, access_token, refresh_token, created_on, updated_on)
|
|
VALUES (${TEST_USER_ID}, ${TEST_STATE_ID}, ${"test-access-token"}, ${"test-refresh-token"}, ${createdOn}, ${createdOn})
|
|
ON CONFLICT (user_id, state_id)
|
|
DO UPDATE SET
|
|
access_token = EXCLUDED.access_token,
|
|
refresh_token = EXCLUDED.refresh_token,
|
|
updated_on = EXCLUDED.updated_on
|
|
`);
|
|
} catch (tokenError) {
|
|
console.error("Failed to insert user_token:", tokenError);
|
|
// Continue anyway - state_key is the critical test
|
|
}
|
|
|
|
return new Response(
|
|
JSON.stringify({
|
|
status: "created",
|
|
message: "Test data created in PostgreSQL",
|
|
test_user_id: TEST_USER_ID,
|
|
test_state_id: TEST_STATE_ID,
|
|
seal_key: sealKey,
|
|
next_step:
|
|
"Restart the server and call with action=verify&state_id=" +
|
|
TEST_STATE_ID,
|
|
}),
|
|
{ status: 200, headers: { "Content-Type": "application/json" } },
|
|
);
|
|
}
|
|
|
|
if (action === "verify") {
|
|
const stateIdParam = url.searchParams.get("state_id");
|
|
const stateIdToCheck = stateIdParam || TEST_STATE_ID;
|
|
|
|
// Use existing SQL repository functions
|
|
const { sqlGetStateKeys, sqlGetUserTokens } = await import(
|
|
"./db/sqlUserRepository.ts"
|
|
);
|
|
|
|
const stateKeys = await sqlGetStateKeys(stateIdToCheck);
|
|
const userTokens = await sqlGetUserTokens(TEST_USER_ID);
|
|
|
|
const stateKeyExists = stateKeys && stateKeys.length > 0;
|
|
const userTokenExists = userTokens && userTokens.length > 0;
|
|
|
|
// State key persistence is the critical test - it's the session data
|
|
// User tokens are secondary
|
|
const persistenceVerified = stateKeyExists;
|
|
|
|
return new Response(
|
|
JSON.stringify({
|
|
status: "verified",
|
|
test_user_id: TEST_USER_ID,
|
|
test_state_id: stateIdToCheck,
|
|
state_key_exists: stateKeyExists,
|
|
state_key_data: stateKeyExists ? stateKeys[0] : null,
|
|
user_token_exists: userTokenExists,
|
|
user_token_data: userTokenExists ? userTokens[0] : null,
|
|
persistence_verified: persistenceVerified,
|
|
message: persistenceVerified
|
|
? "✅ Session state_key PERSISTS across server restart - PostgreSQL is working!"
|
|
: "❌ Session data LOST - using in-memory storage (CRITICAL FAILURE)",
|
|
details: stateKeyExists && !userTokenExists
|
|
? "State key persisted (primary session data), user token may have had insertion error"
|
|
: null,
|
|
next_step: "Call with action=cleanup&state_id=" + stateIdToCheck +
|
|
" to remove test data",
|
|
}),
|
|
{ status: 200, headers: { "Content-Type": "application/json" } },
|
|
);
|
|
}
|
|
|
|
if (action === "cleanup") {
|
|
// Remove test data using SQL repository functions
|
|
const { sqlDeleteStateKey, sqlDeleteUserTokens } = await import(
|
|
"./db/sqlUserRepository.ts"
|
|
);
|
|
|
|
const stateIdParam = url.searchParams.get("state_id");
|
|
const stateIdToClean = stateIdParam || TEST_STATE_ID;
|
|
|
|
// Get the seal key first
|
|
const { sqlGetStateKeys } = await import("./db/sqlUserRepository.ts");
|
|
const stateKeys = await sqlGetStateKeys(stateIdToClean);
|
|
if (stateKeys && stateKeys.length > 0) {
|
|
await sqlDeleteStateKey(stateKeys[0].seal_key);
|
|
}
|
|
await sqlDeleteUserTokens(TEST_USER_ID);
|
|
|
|
return new Response(
|
|
JSON.stringify({
|
|
status: "cleaned",
|
|
message: "Test data removed from database",
|
|
}),
|
|
{ status: 200, headers: { "Content-Type": "application/json" } },
|
|
);
|
|
}
|
|
|
|
// Default: show instructions
|
|
return new Response(
|
|
JSON.stringify({
|
|
status: "ready",
|
|
message: "Persistence test endpoint",
|
|
instructions: [
|
|
"1. Call with action=create to insert test data",
|
|
"2. Restart the Deno server completely",
|
|
"3. Call with action=verify to check if data persisted",
|
|
"4. Call with action=cleanup to remove test data",
|
|
],
|
|
}),
|
|
{ status: 200, headers: { "Content-Type": "application/json" } },
|
|
);
|
|
} catch (error) {
|
|
console.error("Persistence test error:", error);
|
|
return new Response(
|
|
JSON.stringify({
|
|
status: "error",
|
|
message: error instanceof Error ? error.message : "Unknown error",
|
|
}),
|
|
{ status: 500, headers: { "Content-Type": "application/json" } },
|
|
);
|
|
}
|
|
});
|
|
|
|
// app.post("/graph-update", async (ctx: Context<State>) => {
|
|
// console.log("Graph update");
|
|
// const body = await ctx.req.json();
|
|
// //console.log(body);
|
|
// //const { userId, roles, organization } = body;
|
|
|
|
// // await inMemoryCacheKv.set(["ciam", userId], {
|
|
// // userId,
|
|
// // roles,
|
|
// // organization,
|
|
// // lastSynced: Date.now(),
|
|
// // });
|
|
|
|
// //console.log(`Graph update applied for user ${userId}`);
|
|
// return new Response("Graph update stored in cache", { status: 202 });
|
|
// });
|
|
|
|
// 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.use(InitiPGSessionMiddleware());
|
|
app.use(createAuthMiddleware());
|
|
app.fsRoutes();
|
|
|
|
//console.log("✅ Full application loaded");
|
|
} else {
|
|
// Maintenance mode - serve error page for all routes
|
|
app.use(staticFiles());
|
|
|
|
app.get("/health", () => {
|
|
return new Response("Service Unavailable", { status: 503 });
|
|
});
|
|
|
|
app.use(() => {
|
|
return new Response(SERVICE_UNAVAILABLE_HTML, {
|
|
status: 503,
|
|
headers: { "Content-Type": "text/html; charset=utf-8" },
|
|
});
|
|
});
|
|
|
|
console.log("⚠️ Maintenance mode - serving error page only");
|
|
|
|
// Start periodic database connection check (every 60 seconds)
|
|
// setInterval(async () => {
|
|
// console.log("🔄 Checking if database connection is restored...");
|
|
// const dbAvailable = await checkDatabaseConnection();
|
|
// if (dbAvailable) {
|
|
// console.log("✅ Database connection restored! Restarting app...");
|
|
|
|
// // Check if running in development mode
|
|
|
|
// // Use exec to replace shell with new process, keeping terminal connection
|
|
// const command = new Deno.Command("sh", {
|
|
// args: [
|
|
// "-c",
|
|
// `lsof -ti:8080 | xargs kill -9 2>/dev/null || true; ${restartCmd}`,
|
|
// ],
|
|
// cwd: Deno.cwd(),
|
|
// stdin: "inherit",
|
|
// stdout: "inherit",
|
|
// stderr: "inherit",
|
|
// });
|
|
// command.spawn();
|
|
// Deno.exit(0);
|
|
// } else {
|
|
// console.log("❌ Database still unavailable, will retry in 60 seconds");
|
|
// }
|
|
// }, 60_000);
|
|
}
|
|
|
|
// if (import.meta.main) {
|
|
// await app.listen();
|
|
// }
|