From c62825656a469ab63ddf90e68a647d3a6cd2432b Mon Sep 17 00:00:00 2001 From: andreschucks101 Date: Sat, 30 May 2026 00:27:08 +0100 Subject: [PATCH] add uptime docs, structured logger, request ID middleware, and escrow archive --- app/escrows/page.tsx | 103 ++++++++++++++++++++++++++++-------- docs/ops/uptime.md | 95 +++++++++++++++++++++++++++++++++ hooks/useArchivedEscrows.ts | 59 +++++++++++++++++++++ lib/logger.ts | 56 ++++++++++++++++++++ middleware.ts | 20 +++++++ 5 files changed, 311 insertions(+), 22 deletions(-) create mode 100644 docs/ops/uptime.md create mode 100644 hooks/useArchivedEscrows.ts create mode 100644 lib/logger.ts create mode 100644 middleware.ts diff --git a/app/escrows/page.tsx b/app/escrows/page.tsx index f38ebe1..5cbf17d 100644 --- a/app/escrows/page.tsx +++ b/app/escrows/page.tsx @@ -3,17 +3,23 @@ import { useEffect, useState } from "react"; import { useRouter } from "next/navigation"; import Link from "next/link"; -import { Wallet, Clock, ArrowRight, ShieldCheck, AlertCircle } from "lucide-react"; +import { Wallet, Clock, ArrowRight, ShieldCheck, AlertCircle, Archive, ArchiveRestore } from "lucide-react"; import { useStellarAuth } from "@/context/StellarContext"; import { getUserEscrows } from "@/lib/stellar/queries"; +import { useArchivedEscrows } from "@/hooks/useArchivedEscrows"; import type { ContractState } from "@/lib/stellar/types"; +const ARCHIVABLE_STATUSES: ContractState["status"][] = ["released", "expired"]; + export default function EscrowsPage() { const router = useRouter(); const { isConnected, publicKey } = useStellarAuth(); const [escrows, setEscrows] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const [showArchived, setShowArchived] = useState(false); + + const { archiveEscrow, unarchiveEscrow, isArchived } = useArchivedEscrows(); useEffect(() => { if (!isConnected) { @@ -38,6 +44,12 @@ export default function EscrowsPage() { if (!isConnected) return null; + const visibleEscrows = escrows.filter((e) => + showArchived ? isArchived(e.id) : !isArchived(e.id) + ); + + const archivedCount = escrows.filter((e) => isArchived(e.id)).length; + return (
@@ -49,12 +61,23 @@ export default function EscrowsPage() { Manage and view all rent agreements associated with your wallet.

- - Create Escrow - +
+ {archivedCount > 0 && ( + + )} + + Create Escrow + +
{error && ( @@ -70,25 +93,32 @@ export default function EscrowsPage() {
))}
- ) : escrows.length === 0 ? ( + ) : visibleEscrows.length === 0 ? (
- + {showArchived ? ( + + ) : ( + + )}
-

No escrows found

+

+ {showArchived ? "No archived escrows" : "No escrows found"} +

- You don't have any active or funded escrow agreements connected to this wallet yet. + {showArchived + ? "Archived escrows will appear here." + : "You don’t have any active or funded escrow agreements connected to this wallet yet."}

- - Create Your First Escrow - + {!showArchived && ( + + Create Your First Escrow + + )}
) : (
- {escrows.map((escrow) => ( + {visibleEscrows.map((escrow) => (
@@ -100,18 +130,47 @@ export default function EscrowsPage() { Deadline: {escrow.deadline}
-
+
{escrow.status === "funded" ? ( Funded + ) : escrow.status === "released" ? ( + + + Released + + ) : escrow.status === "expired" ? ( + + + Expired + ) : ( Active )} + {ARCHIVABLE_STATUSES.includes(escrow.status) && ( + isArchived(escrow.id) ? ( + + ) : ( + + ) + )}
@@ -124,13 +183,13 @@ export default function EscrowsPage() {
-
- +
Total Rent
diff --git a/docs/ops/uptime.md b/docs/ops/uptime.md new file mode 100644 index 0000000..24ff3dd --- /dev/null +++ b/docs/ops/uptime.md @@ -0,0 +1,95 @@ +# Uptime Monitoring with UptimeRobot + +## Overview + +PayEasy uses UptimeRobot to continuously monitor the `/api/health` endpoint and alert the team when the application goes down. + +## Setup + +### 1. Create a free UptimeRobot account + +Go to [https://uptimerobot.com](https://uptimerobot.com) and register. The free tier supports up to 50 monitors with 5-minute check intervals. + +### 2. Add a new HTTP(S) monitor + +1. Click **+ Add New Monitor** +2. Set the following fields: + +| Field | Value | +|---|---| +| Monitor Type | HTTP(S) | +| Friendly Name | PayEasy API Health | +| URL | `https:///api/health` | +| Monitoring Interval | 5 minutes | +| Monitor Timeout | 30 seconds | + +3. Under **Alert Contacts**, add at least one email address and optionally a Slack webhook (see below). +4. Click **Create Monitor**. + +### 3. Configure email alerts + +UptimeRobot sends email alerts by default to the account's registered email. To add additional recipients: + +1. Go to **My Settings → Alert Contacts** +2. Click **+ Add Alert Contact** +3. Choose **E-mail** and enter the address +4. Click **Save Changes** +5. Return to the monitor and attach the new contact under **Alert Contacts** + +### 4. Configure Slack alerts + +1. In Slack, create an incoming webhook for your alerts channel: + - Go to **Apps → Incoming WebHooks → Add to Slack** + - Choose or create a channel (e.g. `#ops-alerts`) + - Copy the **Webhook URL** +2. In UptimeRobot, go to **My Settings → Alert Contacts → Add Alert Contact** +3. Choose **Slack** and paste the webhook URL +4. Save and attach the contact to the PayEasy monitor + +### 5. Verify the health endpoint + +The `/api/health` endpoint must respond with HTTP `200` when the application is healthy. A non-`2xx` response or a connection timeout will trigger a downtime event. + +Expected response shape: + +```json +{ + "status": "ok", + "timestamp": "2024-01-01T00:00:00.000Z" +} +``` + +## Alert behavior + +| Event | Trigger | Channels | +|---|---|---| +| Downtime | First failed check | Email + Slack | +| Reminder | Every 30 minutes while down | Email + Slack | +| Recovery | First successful check after downtime | Email + Slack | + +UptimeRobot requires **two consecutive failures** before marking a monitor as down, so a single transient failure does not page the team. + +## Simulating a 6-minute downtime (acceptance test) + +To confirm that alerts fire correctly before going to production: + +1. Temporarily stop the application server (or return a non-`200` status from `/api/health`). +2. Wait **6 minutes** (longer than the 5-minute check interval and the two-failure confirmation window). +3. Verify that a **downtime** alert email and Slack message are received. +4. Restore the server. +5. Verify that a **recovery** alert is received. + +A successful test confirms that a real outage lasting 6 or more minutes will page the team through both channels. + +## Maintenance windows + +To suppress false alerts during planned maintenance: + +1. Open the monitor in UptimeRobot. +2. Click **Maintenance Windows** → **Add Maintenance Window**. +3. Set the start time, duration, and recurrence (one-time or weekly). +4. Save. No alerts will fire while the maintenance window is active. + +## Escalation + +If UptimeRobot alerts fire and the on-call engineer does not respond within 15 minutes, escalate using the team's standard incident response runbook. diff --git a/hooks/useArchivedEscrows.ts b/hooks/useArchivedEscrows.ts new file mode 100644 index 0000000..cfa2e53 --- /dev/null +++ b/hooks/useArchivedEscrows.ts @@ -0,0 +1,59 @@ +"use client"; + +import { useState, useEffect, useCallback } from "react"; + +const STORAGE_KEY = "payeasy:archived-escrows"; + +function readFromStorage(): string[] { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) return []; + const parsed: unknown = JSON.parse(raw); + if (Array.isArray(parsed) && parsed.every((x) => typeof x === "string")) { + return parsed as string[]; + } + return []; + } catch { + return []; + } +} + +function writeToStorage(ids: string[]): void { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(ids)); + } catch { + // quota exceeded or private browsing — fail silently + } +} + +export function useArchivedEscrows() { + const [archivedIds, setArchivedIds] = useState([]); + + useEffect(() => { + setArchivedIds(readFromStorage()); + }, []); + + const archiveEscrow = useCallback((id: string) => { + setArchivedIds((prev) => { + if (prev.includes(id)) return prev; + const next = [...prev, id]; + writeToStorage(next); + return next; + }); + }, []); + + const unarchiveEscrow = useCallback((id: string) => { + setArchivedIds((prev) => { + const next = prev.filter((x) => x !== id); + writeToStorage(next); + return next; + }); + }, []); + + const isArchived = useCallback( + (id: string) => archivedIds.includes(id), + [archivedIds] + ); + + return { archivedIds, archiveEscrow, unarchiveEscrow, isArchived }; +} diff --git a/lib/logger.ts b/lib/logger.ts new file mode 100644 index 0000000..182290e --- /dev/null +++ b/lib/logger.ts @@ -0,0 +1,56 @@ +type LogLevel = "debug" | "info" | "warn" | "error"; + +interface LogEntry { + level: LogLevel; + message: string; + timestamp: string; + context?: Record; +} + +const LEVEL_PRIORITY: Record = { + debug: 0, + info: 1, + warn: 2, + error: 3, +}; + +const isDev = process.env.NODE_ENV !== "production"; +const minLevel: LogLevel = isDev ? "debug" : "warn"; + +function shouldLog(level: LogLevel): boolean { + return LEVEL_PRIORITY[level] >= LEVEL_PRIORITY[minLevel]; +} + +function formatEntry(entry: LogEntry): string { + if (isDev) { + const ctx = entry.context ? ` ${JSON.stringify(entry.context)}` : ""; + return `[${entry.timestamp}] ${entry.level.toUpperCase()} ${entry.message}${ctx}`; + } + return JSON.stringify(entry); +} + +function log(level: LogLevel, message: string, context?: Record): void { + if (!shouldLog(level)) return; + + const entry: LogEntry = { + level, + message, + timestamp: new Date().toISOString(), + ...(context !== undefined ? { context } : {}), + }; + + const output = formatEntry(entry); + + if (level === "error" || level === "warn") { + console.error(output); + } else { + console.log(output); + } +} + +export const logger = { + debug: (message: string, context?: Record) => log("debug", message, context), + info: (message: string, context?: Record) => log("info", message, context), + warn: (message: string, context?: Record) => log("warn", message, context), + error: (message: string, context?: Record) => log("error", message, context), +}; diff --git a/middleware.ts b/middleware.ts new file mode 100644 index 0000000..7ade34a --- /dev/null +++ b/middleware.ts @@ -0,0 +1,20 @@ +import { NextResponse } from "next/server"; +import type { NextRequest } from "next/server"; + +export function middleware(request: NextRequest) { + const requestId = + request.headers.get("x-request-id") ?? + crypto.randomUUID(); + + const requestHeaders = new Headers(request.headers); + requestHeaders.set("x-request-id", requestId); + + const response = NextResponse.next({ request: { headers: requestHeaders } }); + response.headers.set("x-request-id", requestId); + + return response; +} + +export const config = { + matcher: "/api/:path*", +};