diff --git a/Dockerfile b/Dockerfile index 1ae99c1..01c40ff 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,6 @@ # syntax=docker/dockerfile:1 -# ---------- Build the Rust backend (compiled here, in the build stage) ---------- +# ---------- Build the Rust backend ---------- FROM rust:1.94-bookworm AS backend-build WORKDIR /app @@ -17,10 +17,30 @@ COPY backend/migrations ./migrations RUN cargo build --release -# ---------- Runtime: prebuilt backend + frontend built at startup ---------- -# The frontend is built in this (run) stage, not at image-build time, so VITE_* -# values provided when the container starts are inlined into the bundle. +# ---------- Build the frontend with placeholder config ---------- +# The frontend is compiled once, here, with sentinel VITE_* values. The runtime +# entrypoint find-and-replaces those sentinels with the real configuration when +# the container starts, so one image can be deployed with any config — no rebuild +# and no build step at startup. +FROM node:20-slim AS frontend-build + +RUN npm install -g bun + +WORKDIR /app/frontend + +COPY frontend/package.json frontend/bun.lock ./ +RUN bun install + +COPY frontend ./ + +ENV VITE_API_URL=__VITE_API_URL__ \ + VITE_RELAY_DOMAIN=__VITE_RELAY_DOMAIN__ \ + VITE_PLATFORM_NAME=__VITE_PLATFORM_NAME__ +RUN bun run build + +# ---------- Runtime ---------- # node:20-slim is bookworm-based, so it is ABI-compatible with the backend binary. +# No bun / frontend sources here — just the prebuilt bundle and a static server. FROM node:20-slim RUN apt-get update \ @@ -28,33 +48,40 @@ RUN apt-get update \ ca-certificates \ libsqlite3-0 \ && rm -rf /var/lib/apt/lists/* \ - && npm install -g bun serve + && npm install -g serve WORKDIR /app -# Install frontend deps as a cached image layer. The actual `vite build` runs at -# startup (see the entrypoint below) so it can pick up runtime env vars. -COPY frontend/package.json frontend/bun.lock ./frontend/ -RUN cd frontend && bun install - -COPY frontend ./frontend - -# Prebuilt backend binary from the build stage. COPY --from=backend-build /app/target/release/backend /app/backend +COPY --from=frontend-build /app/frontend/dist /app/dist -# Single entrypoint: build the frontend with the runtime config, then run both -# processes and exit (so the orchestrator restarts us) if either one dies. +# Single entrypoint: substitute the real config into the prebuilt bundle, then +# run both processes and exit (so the orchestrator restarts us) if either dies. RUN cat > /app/entrypoint.sh <<'EOF' #!/usr/bin/env bash set -euo pipefail -echo "Building frontend with runtime configuration..." -(cd /app/frontend && bun run build) +# Map the provided runtime variables onto the frontend's VITE_* placeholders. +VITE_API_URL="${SERVER_URL:-}" +VITE_RELAY_DOMAIN="${RELAY_DOMAIN:-}" +VITE_PLATFORM_NAME="${PLATFORM_NAME:-}" + +# Escape characters that are special in a sed replacement. +esc() { printf '%s' "$1" | sed -e 's/[&|\\]/\\&/g'; } + +echo "Applying runtime configuration to the frontend bundle..." +while IFS= read -r -d '' f; do + sed -i \ + -e "s|__VITE_API_URL__|$(esc "$VITE_API_URL")|g" \ + -e "s|__VITE_RELAY_DOMAIN__|$(esc "$VITE_RELAY_DOMAIN")|g" \ + -e "s|__VITE_PLATFORM_NAME__|$(esc "$VITE_PLATFORM_NAME")|g" \ + "$f" +done < <(find /app/dist -type f \( -name '*.js' -o -name '*.html' \) -print0) echo "Starting backend (:2892) and frontend (:3000)..." /app/backend & backend_pid=$! -serve -s /app/frontend/dist -l 3000 & +serve -s /app/dist -l 3000 & serve_pid=$! trap 'kill -TERM "$backend_pid" "$serve_pid" 2>/dev/null || true' TERM INT diff --git a/README.md b/README.md index 818dc19..0f8dd86 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,64 @@ A multi-tenant platform for hosting Nostr community relays, built on top of [zooid](https://github.com/coracle-social/zooid). -## Quick Start (Local Development) +## Deployment + +Caravel ships as a single Docker image, built from the repository-root [`Dockerfile`](./Dockerfile) and published by [`.gitea/workflows/docker-publish.yml`](./.gitea/workflows/docker-publish.yml) as `gitea.coracle.social/coracle/caravel`. One container runs both services: the backend API on port `2892` and the frontend on port `3000`. + +Both the backend and the frontend are compiled into the image at build time. The frontend is built with placeholder config that the entrypoint replaces with the real values when the container starts, so one image can be deployed with any configuration — no rebuild required. + +Caravel needs a reachable [zooid](https://github.com/coracle-social/zooid) instance (the [Local Development](#local-development) section below shows how to run one). Substitute your own values for the placeholders below: + +```sh +docker run -d \ + --name caravel \ + -p 2892:2892 \ + -p 3000:3000 \ + -v my-caravel-data:/app/data \ + -e PLATFORM_NAME=Caravel \ + -e RELAY_DOMAIN=example.com \ + -e APP_URL=https://example.com \ + -e ZOOID_API_URL=http://zooid:3334 \ + -e DATABASE_URL=sqlite://data/caravel.db \ + -e SERVER_URL=https://api.example.com \ + -e SERVER_PORT=2892 \ + -e SERVER_ADMIN_PUBKEYS= \ + -e SERVER_ALLOW_ORIGINS=https://example.com \ + -e ROBOT_SECRET= \ + -e ROBOT_NAME=Caravel \ + -e ROBOT_DESCRIPTION="Relay manager bot" \ + -e ROBOT_PICTURE=https://example.com/robot.png \ + -e ROBOT_WALLET= \ + -e ROBOT_OUTBOX_RELAYS=wss://relay.damus.io,wss://relay.primal.net,wss://nos.lol \ + -e ROBOT_INDEXER_RELAYS=wss://purplepag.es,wss://relay.damus.io,wss://indexer.coracle.social \ + -e ROBOT_MESSAGING_RELAYS=wss://auth.nostr1.com,wss://relay.keychat.io,wss://relay.ditto.pub \ + -e BLOSSOM_S3_ENDPOINT=https://s3.example.com \ + -e BLOSSOM_S3_REGION=us-east-1 \ + -e BLOSSOM_S3_BUCKET=caravel-blossom \ + -e BLOSSOM_S3_ACCESS_KEY= \ + -e BLOSSOM_S3_SECRET_KEY= \ + -e LIVEKIT_URL=wss://livekit.example.com \ + -e LIVEKIT_API_KEY= \ + -e LIVEKIT_API_SECRET= \ + -e STRIPE_SECRET_KEY= \ + gitea.coracle.social/coracle/caravel +``` + +Notes: + +- Every backend variable above is **required** — the server exits on startup if any is missing or empty. See [`backend/.env.template`](./backend/.env.template) for what each one means. +- `ROBOT_SECRET` is the robot account's hex nostr secret key; its pubkey must be in zooid's `API_WHITELIST` (the backend signs zooid requests with it via NIP-98). +- `SERVER_ALLOW_ORIGINS` must include the frontend's public origin, or browsers will be blocked by CORS. +- The frontend's `VITE_` prefixed env variables are automatically populated from the provided env variables. +- `-v my-caravel-data:/app/data` persists the SQLite database. + +To build the image yourself instead of pulling it: + +```sh +docker build -t caravel . +``` + +## Local Development ### Prerequisites diff --git a/backend/README.md b/backend/README.md index 6791d53..743dc57 100644 --- a/backend/README.md +++ b/backend/README.md @@ -16,17 +16,16 @@ Rust backend for Caravel. It manages tenants, relays, invoices, and background w backend/ migrations/ 0001_init.sql - spec/ # Module-by-module design notes src/ main.rs # App bootstrap: load Env, build services, serve + spawn workers env.rs # Configuration from the environment (+ NIP-44 encryption, NIP-98 signing) api.rs # Shared Api state, router, NIP-98 auth + authorization helpers web.rs # HTTP response envelope + helpers - routes/ # HTTP route handlers (identity, plans, tenants, relays, invoices, stripe) + routes/ # HTTP route handlers (identity, plans, tenants, relays, invoices) models.rs # Domain models + sqlite rows query.rs # Database reads command.rs # Database writes + activity broadcast - pool.rs # SQLite pool + migrations + db.rs # SQLite pool, migrations, activity broadcast channel billing.rs # Stripe subscription reconciliation + Lightning collection worker stripe.rs # Thin Stripe REST client wallet.rs # NWC wallet handle (NIP-47) @@ -43,10 +42,11 @@ All configuration is read from the environment by `Env::load()` at startup. **Ev | Variable | Description | | ---------------------- | ------------------------------------------------------------------- | -| `SERVER_HOST` | API bind host; also the value the NIP-98 `u` tag must contain | -| `SERVER_PORT` | API bind port | +| `SERVER_URL` | Public API base URL; the value the NIP-98 `u` tag must equal exactly | +| `SERVER_PORT` | API bind port (the server always binds to `127.0.0.1`) | | `SERVER_ADMIN_PUBKEYS` | Comma-separated admin pubkeys (hex) | | `SERVER_ALLOW_ORIGINS` | Comma-separated allowed CORS origins | +| `APP_URL` | Frontend base URL; used to build links in DMs and invoices | | `DATABASE_URL` | SQLite URL; relative `sqlite://` paths are resolved under `backend/` | **Robot identity** @@ -84,18 +84,15 @@ All configuration is read from the environment by `Env::load()` at startup. **Ev **Billing (Stripe)** -| Variable | Description | -| ----------------------- | ----------------------------------------------------------------------- | -| `STRIPE_SECRET_KEY` | Stripe API secret key used for billing API operations | -| `STRIPE_WEBHOOK_SECRET` | Stripe webhook signing secret used to verify `Stripe-Signature` headers | -| `STRIPE_PRICE_BASIC` | Stripe price ID (`price_...`) backing the Basic plan | -| `STRIPE_PRICE_GROWTH` | Stripe price ID (`price_...`) backing the Growth plan | +| Variable | Description | +| ------------------- | ----------------------------------------------------- | +| `STRIPE_SECRET_KEY` | Stripe API secret key used for billing API operations | Comma-separated list variables are split on commas and trimmed; empty entries are dropped. ## Schema and Architecture -See [spec](spec) for more details +The database schema lives in [migrations](migrations); see the module-level doc comments in `src/` for architecture details. ## API Routes @@ -105,7 +102,6 @@ Public exceptions: - `GET /plans` - `GET /plans/:id` -- `POST /stripe/webhook` (validated with Stripe signatures) - `GET /identity` — get auth identity (`pubkey`, `is_admin`); side-effect-free - `GET /tenants` — list tenants (admin) @@ -122,16 +118,19 @@ Public exceptions: - `POST /relays/:id/deactivate` — deactivate relay (admin or relay tenant) - `POST /relays/:id/reactivate` — reactivate relay (admin or relay tenant) - `GET /tenants/:pubkey/invoices` — list tenant invoices (admin or same tenant) +- `GET /tenants/:pubkey/invoices/draft` — get the tenant's synthetic draft invoice for the current period, or `null` if nothing is due (admin or same tenant) +- `GET /tenants/:pubkey/invoices/draft/items` — list the draft invoice's line items (admin or same tenant) - `GET /invoices/:id` — get invoice (admin or same tenant) - `GET /invoices/:id/bolt11` — get invoice bolt11 (admin or same tenant) -- `GET /tenants/:pubkey/stripe/session` — create Stripe customer portal session (admin or same tenant) +- `GET /invoices/:id/items` — list invoice line items (admin or same tenant) +- `GET /tenants/:pubkey/stripe/session` — create Stripe customer portal session (same tenant only) ## API Auth Model Caravel intentionally uses a session-style variant of NIP-98 for client-to-backend API auth. - Frontend signs one kind `27235` event with `u = VITE_API_URL` and caches that header for about 10 minutes. -- Backend verifies event kind, signature, and that `u` contains configured `SERVER_HOST`. +- Backend verifies event kind, signature, and that `u` equals configured `SERVER_URL`. - Backend intentionally does not bind auth to exact request URL/method/query, and does not enforce payload hash, timestamp freshness window, or replay cache. - Goal: reduce repeated wallet signing prompts and avoid cookie-based sessions. - Tradeoff: this is weaker request-intent binding than strict NIP-98 semantics. diff --git a/backend/src/api.rs b/backend/src/api.rs index c9e21aa..0fa916e 100644 --- a/backend/src/api.rs +++ b/backend/src/api.rs @@ -32,7 +32,7 @@ use crate::models::{Relay, Tenant}; use crate::query; use crate::robot::Robot; use crate::routes::identity::get_identity; -use crate::routes::invoices::{get_invoice, get_invoice_bolt11, list_invoice_items}; +use crate::routes::invoices::{get_invoice, get_invoice_bolt11, list_invoice_items, list_invoices}; use crate::routes::plans::{get_plan, list_plans}; use crate::routes::relays::{ create_relay, deactivate_relay, get_relay, list_relay_activity, list_relay_members, @@ -87,6 +87,7 @@ impl Api { .route("/relays/:id/activity", get(list_relay_activity)) .route("/relays/:id/deactivate", post(deactivate_relay)) .route("/relays/:id/reactivate", post(reactivate_relay)) + .route("/invoices", get(list_invoices)) .route("/invoices/:id", get(get_invoice)) .route("/invoices/:id/bolt11", get(get_invoice_bolt11)) .route("/invoices/:id/items", get(list_invoice_items)) diff --git a/backend/src/query.rs b/backend/src/query.rs index 84e6fa0..ec0841e 100644 --- a/backend/src/query.rs +++ b/backend/src/query.rs @@ -131,7 +131,15 @@ pub async fn get_invoice(invoice_id: &str) -> Result> { ) } -pub async fn list_invoices(tenant_pubkey: &str) -> Result> { +pub async fn list_invoices() -> Result> { + Ok( + sqlx::query_as::<_, Invoice>("SELECT * FROM invoice ORDER BY created_at DESC") + .fetch_all(pool()) + .await?, + ) +} + +pub async fn list_invoices_for_tenant(tenant_pubkey: &str) -> Result> { Ok(sqlx::query_as::<_, Invoice>( "SELECT * FROM invoice WHERE tenant_pubkey = ? ORDER BY created_at DESC", ) diff --git a/backend/src/routes/invoices.rs b/backend/src/routes/invoices.rs index 5990225..8290517 100644 --- a/backend/src/routes/invoices.rs +++ b/backend/src/routes/invoices.rs @@ -6,6 +6,15 @@ use crate::api::{Api, AuthedPubkey}; use crate::query; use crate::web::{ApiResult, internal, not_found, ok}; +pub async fn list_invoices( + State(api): State>, + AuthedPubkey(auth): AuthedPubkey, +) -> ApiResult { + api.require_admin(&auth)?; + + ok(query::list_invoices().await.map_err(internal)?) +} + pub async fn get_invoice( State(api): State>, AuthedPubkey(auth): AuthedPubkey, diff --git a/backend/src/routes/tenants.rs b/backend/src/routes/tenants.rs index c37bb54..e00da8f 100644 --- a/backend/src/routes/tenants.rs +++ b/backend/src/routes/tenants.rs @@ -174,7 +174,9 @@ pub async fn list_tenant_invoices( .await .map_err(internal)?; - let invoices = query::list_invoices(&pubkey).await.map_err(internal)?; + let invoices = query::list_invoices_for_tenant(&pubkey) + .await + .map_err(internal)?; ok(invoices) } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 6f61441..5a77e8c 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -14,6 +14,8 @@ import AdminTenantDetail from "@/pages/admin/AdminTenantDetail" import AdminRelayList from "@/pages/admin/AdminRelayList" import AdminRelayDetail from "@/pages/admin/AdminRelayDetail" import AdminRelayEdit from "@/pages/admin/AdminRelayEdit" +import AdminInvoiceList from "@/pages/admin/AdminInvoiceList" +import AdminInvoiceDetail from "@/pages/admin/AdminInvoiceDetail" import { account, eventStore, identity, pool } from "@/lib/state" import { NostrProvider } from "@/lib/nostr" @@ -72,6 +74,8 @@ export default function App() { + + ) diff --git a/frontend/src/components/AdminInvoiceListItem.tsx b/frontend/src/components/AdminInvoiceListItem.tsx new file mode 100644 index 0000000..d0a1497 --- /dev/null +++ b/frontend/src/components/AdminInvoiceListItem.tsx @@ -0,0 +1,55 @@ +import { A } from "@solidjs/router" +import { Show } from "solid-js" +import { getProfilePicture } from "applesauce-core/helpers/profile" +import { invoiceStatus, type Invoice } from "@/lib/api" +import { useProfileMetadata } from "@/lib/hooks" +import { formatPeriod, formatUsd } from "@/lib/format" +import { shortenPubkey } from "@/lib/pubkey" + +const invoiceStatusStyles: Record = { + open: "bg-yellow-50 text-yellow-700 border-yellow-200", + paid: "bg-green-50 text-green-700 border-green-200", + void: "bg-gray-100 text-gray-500 border-gray-200", +} + +type AdminInvoiceListItemProps = { + invoice: Invoice + href: string +} + +export default function AdminInvoiceListItem(props: AdminInvoiceListItemProps) { + // Resolve the owning tenant's profile from the event store. AdminInvoiceList + // primes these profiles in one batch, so this subscription does not prime. + const metadata = useProfileMetadata(() => props.invoice.tenant_pubkey, { prime: false }) + + const status = () => invoiceStatus(props.invoice) + + return ( +
  • + +
    +
    +

    {formatUsd(props.invoice.amount)}

    +

    {formatPeriod(props.invoice.period_start, props.invoice.period_end)}

    +
    + + {((metadata()?.name || metadata()?.display_name) || props.invoice.tenant_pubkey).slice(0, 1).toUpperCase()} +
    + } + > + + + {(metadata()?.name || metadata()?.display_name) || shortenPubkey(props.invoice.tenant_pubkey)} +
    +
    + + {status()} + + +
    +
  • + ) +} diff --git a/frontend/src/components/AppShell.tsx b/frontend/src/components/AppShell.tsx index d522ade..5ed3d0c 100644 --- a/frontend/src/components/AppShell.tsx +++ b/frontend/src/components/AppShell.tsx @@ -47,6 +47,7 @@ export default function AppShell(props: { children?: any }) { const adminResources = [ { href: "/admin/tenants", label: "Tenants" }, { href: "/admin/relays", label: "Relays" }, + { href: "/admin/invoices", label: "Invoices" }, ] const navItemClass = (href: string) => { diff --git a/frontend/src/components/InvoiceDetailCard.tsx b/frontend/src/components/InvoiceDetailCard.tsx new file mode 100644 index 0000000..dbbfb16 --- /dev/null +++ b/frontend/src/components/InvoiceDetailCard.tsx @@ -0,0 +1,68 @@ +import { A } from "@solidjs/router" +import { Show } from "solid-js" +import { getProfilePicture } from "applesauce-core/helpers/profile" +import { invoiceStatus, type Invoice } from "@/lib/api" +import Field from "@/components/Field" +import { useProfileMetadata } from "@/lib/hooks" +import { formatPeriod, formatUsd } from "@/lib/format" +import { shortenPubkey } from "@/lib/pubkey" + +const invoiceStatusStyles: Record = { + open: "bg-yellow-50 text-yellow-700 border-yellow-200", + paid: "bg-green-50 text-green-700 border-green-200", + void: "bg-gray-100 text-gray-500 border-gray-200", +} + +type InvoiceDetailCardProps = { + invoice: Invoice +} + +export default function InvoiceDetailCard(props: InvoiceDetailCardProps) { + // Resolve the owning tenant's profile so the Tenant field can show a name and + // avatar instead of a raw pubkey, like the admin list item. + const metadata = useProfileMetadata(() => props.invoice.tenant_pubkey, { prime: false }) + + const status = () => invoiceStatus(props.invoice) + + return ( +
    +
    +

    {formatUsd(props.invoice.amount)}

    + + {status()} + +
    + +
    + +
    + + {formatPeriod(props.invoice.period_start, props.invoice.period_end)} + + + {new Date(props.invoice.created_at * 1000).toLocaleDateString()} + + + + {props.invoice.method} + + + + + + {((metadata()?.name || metadata()?.display_name) || props.invoice.tenant_pubkey).slice(0, 1).toUpperCase()} +
    + } + > + + + {(metadata()?.name || metadata()?.display_name) || shortenPubkey(props.invoice.tenant_pubkey)} + + + + + ) +} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 61bf76b..7a69c85 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -285,6 +285,10 @@ export function listRelays() { return callApi("GET", "/relays") } +export function listInvoices() { + return callApi("GET", "/invoices") +} + export function getRelay(id: string) { return callApi("GET", `/relays/${id}`) } diff --git a/frontend/src/lib/hooks.ts b/frontend/src/lib/hooks.ts index 39946a0..56fae76 100644 --- a/frontend/src/lib/hooks.ts +++ b/frontend/src/lib/hooks.ts @@ -10,9 +10,11 @@ import { createRelay, deactivateRelay, reactivateRelay, + getInvoice, getRelay, getTenant, invoiceStatus, + listInvoices, listRelayActivity, listRelays, listTenantInvoices, @@ -145,6 +147,10 @@ export const useAdminTenants = () => createResource(listTenants) export const useAdminRelays = () => createResource(listRelays) +export const useAdminInvoices = () => createResource(listInvoices) + +export const useInvoice = (id: () => string) => createResource(id, getInvoice) + export const useAdminTenant = (pubkey: () => string) => createResource(pubkey, getTenant) export const useAdminTenantRelays = (pubkey: () => string) => createResource(pubkey, listTenantRelays) diff --git a/frontend/src/lib/state.ts b/frontend/src/lib/state.ts index 4fe7cf3..018d58f 100644 --- a/frontend/src/lib/state.ts +++ b/frontend/src/lib/state.ts @@ -25,7 +25,7 @@ export type EventSigner = { signEvent(event: UnsignedEvent): Promise } -export const PLATFORM_NAME = import.meta.env.VITE_PLATFORM_NAME || "Caravel" +export const PLATFORM_NAME = import.meta.env.VITE_PLATFORM_NAME export const eventStore = new EventStore() export const pool = new RelayPool() diff --git a/frontend/src/pages/admin/AdminInvoiceDetail.tsx b/frontend/src/pages/admin/AdminInvoiceDetail.tsx new file mode 100644 index 0000000..40c886b --- /dev/null +++ b/frontend/src/pages/admin/AdminInvoiceDetail.tsx @@ -0,0 +1,43 @@ +import { useParams } from "@solidjs/router" +import { createResource, Show } from "solid-js" +import BackLink from "@/components/BackLink" +import InvoiceDetailCard from "@/components/InvoiceDetailCard" +import InvoiceItemsList from "@/components/payment/InvoiceItemsList" +import PageContainer from "@/components/PageContainer" +import ResourceState from "@/components/ResourceState" +import useMinLoading from "@/lib/useMinLoading" +import { listInvoiceItems } from "@/lib/api" +import { useInvoice } from "@/lib/hooks" + +export default function AdminInvoiceDetail() { + const params = useParams() + const invoiceId = () => params.id ?? "" + const [invoice] = useInvoice(invoiceId) + const [items] = createResource(invoiceId, async (id) => { + if (!id) return [] + + try { + return await listInvoiceItems(id) + } catch { + return [] + } + }) + const loading = useMinLoading(() => invoice.loading && !invoice()) + + return ( + + + + + {(i) => ( +
    + + 0}> + + +
    + )} +
    +
    + ) +} diff --git a/frontend/src/pages/admin/AdminInvoiceList.tsx b/frontend/src/pages/admin/AdminInvoiceList.tsx new file mode 100644 index 0000000..f0a2af7 --- /dev/null +++ b/frontend/src/pages/admin/AdminInvoiceList.tsx @@ -0,0 +1,49 @@ +import { createEffect, createMemo, createSignal, For, onCleanup, Show } from "solid-js" +import { fuzzySearch } from "@/lib/search" +import AdminInvoiceListItem from "@/components/AdminInvoiceListItem" +import PageContainer from "@/components/PageContainer" +import ResourceState from "@/components/ResourceState" +import SearchInput from "@/components/SearchInput" +import useMinLoading from "@/lib/useMinLoading" +import { primeProfiles, useAdminInvoices } from "@/lib/hooks" + +export default function AdminInvoiceList() { + const [query, setQuery] = createSignal("") + const [invoices] = useAdminInvoices() + const loading = useMinLoading(() => invoices.loading) + + // Each list item shows its tenant's profile; prime them all in one batch so + // we don't open a separate outbox subscription per invoice. + createEffect(() => { + const list = invoices() ?? [] + if (!list.length) return + const sub = primeProfiles(list.map(i => i.tenant_pubkey)) + onCleanup(() => sub.unsubscribe()) + }) + + const filtered = createMemo(() => { + const list = invoices() ?? [] + const q = query().trim() + if (!q) return list + return fuzzySearch(list, ["tenant_pubkey", "id"], q) + }) + + return ( + +

    Invoices

    +
    + +
    + + + 0} fallback={

    No invoices found.

    }> +
      + + {(invoice) => } + +
    +
    +
    +
    + ) +}