From 18b439202461548a985598e4ab957f40cb6ee982 Mon Sep 17 00:00:00 2001 From: shadrach68 Date: Mon, 1 Jun 2026 11:56:55 +0100 Subject: [PATCH 1/2] feat(stellar): add transaction history for wallet activities --- app/api/snippets/[id]/route.ts | 49 ++++++++++ app/api/snippets/route.ts | 15 +++ app/api/transactions/route.ts | 63 +++++++++++++ app/transactions/page.tsx | 154 ++++++++++++++++++++++++++++++ components/WalletConnect.tsx | 75 ++++++++++++--- components/navbar.tsx | 167 +++++++++------------------------ lib/db.ts | 59 ++++++++++++ scripts/add-transactions.sql | 10 ++ 8 files changed, 456 insertions(+), 136 deletions(-) create mode 100644 app/api/transactions/route.ts create mode 100644 app/transactions/page.tsx create mode 100644 scripts/add-transactions.sql diff --git a/app/api/snippets/[id]/route.ts b/app/api/snippets/[id]/route.ts index 2eaf630..d36f589 100644 --- a/app/api/snippets/[id]/route.ts +++ b/app/api/snippets/[id]/route.ts @@ -1,3 +1,10 @@ +import { + createSnippetVersion, + getVersionHistory, + getVersionById, + restoreVersion, + createTransaction, +} from "@/lib/db"; import { NextRequest, NextResponse } from "next/server"; import { SnippetService } from "../snippet.service"; import { SnippetRepository } from "../snippet.repository"; @@ -89,6 +96,20 @@ export async function PUT( } const restored = await restoreVersion(versionId, editorId || null); + + // Log restore action if wallet provided + if (req.headers.get("x-wallet-address")) { + try { + await createTransaction( + req.headers.get("x-wallet-address")!, + "version_restore", + `Restored version ${versionId} for snippet ${restored.snippet_id}`, + { versionId, snippetId: restored.snippet_id }, + ); + } catch (err) { + console.error("[transactions] Failed to log version_restore:", err); + } + } return NextResponse.json(restored); } @@ -116,6 +137,20 @@ export async function PUT( const body = await req.json(); const snippet = await service.updateSnippet(id, body); + // Log update + if (walletAddress) { + try { + await createTransaction( + walletAddress, + "snippet_update", + `Updated snippet ${id}`, + { snippetId: id }, + ); + } catch (err) { + console.error("[transactions] Failed to log snippet_update:", err); + } + } + return NextResponse.json(snippet); } catch (error) { if (error instanceof ZodError) { @@ -164,6 +199,20 @@ export async function DELETE( await service.deleteSnippet(id); + // Log delete + if (walletAddress) { + try { + await createTransaction( + walletAddress, + "snippet_delete", + `Deleted snippet ${id}`, + { snippetId: id }, + ); + } catch (err) { + console.error("[transactions] Failed to log snippet_delete:", err); + } + } + return NextResponse.json({ message: "Snippet deleted successfully" }); } catch (error) { if (error instanceof Error && error.message === "Snippet not found") { diff --git a/app/api/snippets/route.ts b/app/api/snippets/route.ts index 5332715..f2226c0 100644 --- a/app/api/snippets/route.ts +++ b/app/api/snippets/route.ts @@ -3,6 +3,7 @@ import { SnippetService } from "./snippet.service"; import { SnippetRepository } from "./snippet.repository"; import { OwnershipMiddleware } from "./ownership.middleware"; import { ZodError } from "zod"; +import { createTransaction } from "@/lib/db"; // Dependency Injection instantiation const repository = new SnippetRepository(); @@ -34,6 +35,20 @@ export async function POST(req: NextRequest) { const snippet = await service.createSnippet(body); + // Log transaction if wallet address provided + if (walletAddress) { + try { + await createTransaction( + walletAddress, + "snippet_create", + `Created snippet ${snippet.id}`, + { snippetId: snippet.id }, + ); + } catch (err) { + console.error("[transactions] Failed to log snippet_create:", err); + } + } + return NextResponse.json(snippet, { status: 201 }); } catch (error) { if (error instanceof ZodError) { diff --git a/app/api/transactions/route.ts b/app/api/transactions/route.ts new file mode 100644 index 0000000..5455fdf --- /dev/null +++ b/app/api/transactions/route.ts @@ -0,0 +1,63 @@ +import { NextRequest, NextResponse } from "next/server"; +import { createTransaction, getTransactionsByWallet } from "@/lib/db"; + +export async function GET(req: NextRequest) { + try { + const walletAddress = req.headers.get("x-wallet-address"); + if (!walletAddress) { + return NextResponse.json( + { error: "Wallet address required" }, + { status: 401 }, + ); + } + + const url = new URL(req.url); + const page = parseInt(url.searchParams.get("page") || "1"); + const pageSize = parseInt(url.searchParams.get("pageSize") || "20"); + + const data = await getTransactionsByWallet(walletAddress, page, pageSize); + return NextResponse.json(data); + } catch (error) { + console.error("[transactions] GET error:", error); + return NextResponse.json( + { error: "Failed to fetch transactions" }, + { status: 500 }, + ); + } +} + +export async function POST(req: NextRequest) { + try { + const walletAddress = req.headers.get("x-wallet-address"); + if (!walletAddress) { + return NextResponse.json( + { error: "Wallet address required" }, + { status: 401 }, + ); + } + + const body = await req.json(); + const { type, description, metadata } = body || {}; + + if (!type) { + return NextResponse.json( + { error: "Transaction type is required" }, + { status: 400 }, + ); + } + + const tx = await createTransaction( + walletAddress, + type, + description || null, + metadata || null, + ); + return NextResponse.json(tx, { status: 201 }); + } catch (error) { + console.error("[transactions] POST error:", error); + return NextResponse.json( + { error: "Failed to create transaction" }, + { status: 500 }, + ); + } +} diff --git a/app/transactions/page.tsx b/app/transactions/page.tsx new file mode 100644 index 0000000..19f3e86 --- /dev/null +++ b/app/transactions/page.tsx @@ -0,0 +1,154 @@ +"use client"; + +import React, { useEffect, useState } from "react"; +import { useWallet } from "@/components/WalletConnect"; +import { formatDistanceToNow } from "date-fns"; + +interface Transaction { + id: string; + wallet_address: string; + type: string; + description: string | null; + metadata: any; + created_at: string; +} + +export default function TransactionHistoryPage() { + const { connected, publicKey } = useWallet(); + const [transactions, setTransactions] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(""); + const [page, setPage] = useState(1); + const [totalPages, setTotalPages] = useState(1); + const pageSize = 10; + + useEffect(() => { + if (!connected || !publicKey) { + setLoading(false); + return; + } + + const fetchTransactions = async () => { + try { + setLoading(true); + setError(""); + const res = await fetch( + `/api/transactions?page=${page}&pageSize=${pageSize}`, + { + headers: { + "x-wallet-address": publicKey, + }, + }, + ); + + if (!res.ok) { + throw new Error("Failed to fetch transactions"); + } + + const data = await res.json(); + setTransactions(data.transactions || []); + setTotalPages(Math.ceil((data.total || 0) / pageSize) || 1); + } catch (err) { + setError(err instanceof Error ? err.message : "An error occurred"); + } finally { + setLoading(false); + } + }; + + fetchTransactions(); + }, [connected, publicKey, page]); + + if (!connected) { + return ( +
+

+ Transaction History +

+

+ Please connect your Stellar wallet to view your activity history. +

+
+ ); + } + + return ( +
+

+ Wallet Activity +

+

+ A secure, queryable log of all actions associated with your connected + wallet. +

+ + {error && ( +
+ {error} +
+ )} + + {loading ? ( +
+
+
+ ) : transactions.length === 0 ? ( +
+ No transactions found for this wallet. +
+ ) : ( +
+ {transactions.map((tx) => ( +
+
+
+
+ + {tx.type.replace("_", " ")} + + + {formatDistanceToNow(new Date(tx.created_at), { + addSuffix: true, + })} + +
+

{tx.description}

+
+ {tx.metadata?.snippetId && ( +
+ ID: {tx.metadata.snippetId.split("-")[0]}... +
+ )} +
+
+ ))} + + {/* Pagination */} + {totalPages > 1 && ( +
+ + + Page {page} of {totalPages} + + +
+ )} +
+ )} +
+ ); +} diff --git a/components/WalletConnect.tsx b/components/WalletConnect.tsx index 4e4cf9c..58acd75 100644 --- a/components/WalletConnect.tsx +++ b/components/WalletConnect.tsx @@ -41,24 +41,45 @@ export function WalletProvider({ children }: { children: React.ReactNode }) { for (let i = 0; i < 50; i++) { if (window.freighter || window.freighterApi) { freighter = window.freighter || window.freighterApi; - console.log('Freighter detected'); + console.log("Freighter detected"); break; } - await new Promise(resolve => setTimeout(resolve, 100)); + await new Promise((resolve) => setTimeout(resolve, 100)); } if (!freighter) { throw new Error( - "Freighter wallet not detected. Please install from https://www.freighter.app/ and refresh the page" + "Freighter wallet not detected. Please install from https://www.freighter.app/ and refresh the page", ); } const pubKey = await freighter.getPublicKey(); - if (!pubKey) throw new Error("Failed to retrieve public key from Freighter."); + if (!pubKey) + throw new Error("Failed to retrieve public key from Freighter."); setPublicKey(pubKey); setWalletName("Freighter"); setConnected(true); + + // Log connection to server (best-effort) + (async () => { + try { + await fetch("/api/transactions", { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-wallet-address": pubKey, + }, + body: JSON.stringify({ + type: "wallet_connect", + description: `Connected via freighter`, + metadata: { walletType: "freighter" }, + }), + }); + } catch (e) { + console.error("[transactions] failed to log wallet_connect", e); + } + })(); } // ========================== @@ -71,6 +92,26 @@ export function WalletProvider({ children }: { children: React.ReactNode }) { setPublicKey(result.pubkey); setWalletName("Albedo"); setConnected(true); + + // Log connection to server (best-effort) + (async () => { + try { + await fetch("/api/transactions", { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-wallet-address": result.pubkey, + }, + body: JSON.stringify({ + type: "wallet_connect", + description: `Connected via albedo`, + metadata: { walletType: "albedo" }, + }), + }); + } catch (e) { + console.error("[transactions] failed to log wallet_connect", e); + } + })(); } // ========================== @@ -78,7 +119,7 @@ export function WalletProvider({ children }: { children: React.ReactNode }) { // ========================== else if (walletType === "lobstr") { throw new Error( - "Lobstr wallet integration coming soon (requires WalletConnect)." + "Lobstr wallet integration coming soon (requires WalletConnect).", ); } } catch (err: any) { @@ -112,10 +153,12 @@ export function WalletProvider({ children }: { children: React.ReactNode }) { disconnect, clearError, }), - [connected, publicKey, walletName, connecting, error] + [connected, publicKey, walletName, connecting, error], ); - return {children}; + return ( + {children} + ); } export const useWallet = () => useContext(WalletContext); @@ -130,10 +173,18 @@ export function WalletButton() { if (!wallet) return null; - const { connected, publicKey, connecting, connect, disconnect, error, clearError } = wallet; + const { + connected, + publicKey, + connecting, + connect, + disconnect, + error, + clearError, + } = wallet; const handleWalletSelect = async ( - walletType: "freighter" | "albedo" | "lobstr" + walletType: "freighter" | "albedo" | "lobstr", ) => { setShowModal(false); await connect(walletType); @@ -220,11 +271,9 @@ export function WalletButton() { - {error && ( -
{error}
- )} + {error &&
{error}
} ); -} \ No newline at end of file +} diff --git a/components/navbar.tsx b/components/navbar.tsx index f32c6f5..ff85a1a 100644 --- a/components/navbar.tsx +++ b/components/navbar.tsx @@ -2,135 +2,56 @@ import Link from "next/link"; import { usePathname } from "next/navigation"; -import { Code2, Menu, X } from "lucide-react"; +import { Code2 } from "lucide-react"; import { Button } from "@/components/ui/button"; import { WalletButton } from "@/components/WalletConnect"; import { cn } from "@/lib/utils"; -import { useState } from "react"; const NAV_LINKS = [ - { label: "Home", href: "/" }, - { label: "Snippets", href: "/snippets" }, + { label: "Home", href: "/" }, + { label: "Snippets", href: "/snippets" }, + { label: "Activity", href: "/transactions" }, ]; export function Navbar() { - const pathname = usePathname(); - const [mobileOpen, setMobileOpen] = useState(false); - - return -
-
- {/* Logo */} - - - - Codely - - - - {/* Nav links & Wallet */} -
- - -
-
-
- ); -
-
- {/* Logo */} - - - - Codely - - - - {/* Desktop nav */} - - - {/* Desktop CTA */} -
- - - -
- - {/* Mobile menu button */} - -
- - {/* Mobile menu */} - {mobileOpen && ( -
- {NAV_LINKS.map(({ label, href }) => ( - setMobileOpen(false)}> - - - ))} - setMobileOpen(false)}> - - -
- )} -
- ) + const pathname = usePathname(); + + return ( +
+
+ {/* Logo */} + + + + Codely + + + + {/* Nav links & Wallet */} +
+ + +
+
+
+ ); } diff --git a/lib/db.ts b/lib/db.ts index 030df5b..ae6bb7b 100644 --- a/lib/db.ts +++ b/lib/db.ts @@ -187,3 +187,62 @@ export async function restoreVersion( throw error; } } + +// ============ Transaction History ============ + +export async function createTransaction( + walletAddress: string, + type: string, + description: string | null = null, + metadata: any = null, +) { + try { + const id = crypto.randomUUID(); + const createdAt = new Date(); + + const result = await sql` + INSERT INTO transactions (id, wallet_address, type, description, metadata, created_at) + VALUES (${id}, ${walletAddress}, ${type}, ${description}, ${metadata ? JSON.stringify(metadata) : null}, ${createdAt}) + RETURNING * + `; + + return result[0] as any; + } catch (error) { + console.error("[db] Error creating transaction:", error); + throw error; + } +} + +export async function getTransactionsByWallet( + walletAddress: string, + page: number = 1, + pageSize: number = 20, +) { + try { + const offset = (page - 1) * pageSize; + + const countResult = await sql` + SELECT COUNT(*) as total + FROM transactions + WHERE wallet_address = ${walletAddress} + `; + const total = parseInt(countResult[0]?.total || "0"); + + const result = await sql` + SELECT * FROM transactions + WHERE wallet_address = ${walletAddress} + ORDER BY created_at DESC + LIMIT ${pageSize} OFFSET ${offset} + `; + + return { + transactions: result as any[], + total, + page, + pageSize, + }; + } catch (error) { + console.error("[db] Error fetching transactions:", error); + throw error; + } +} diff --git a/scripts/add-transactions.sql b/scripts/add-transactions.sql new file mode 100644 index 0000000..1cf9383 --- /dev/null +++ b/scripts/add-transactions.sql @@ -0,0 +1,10 @@ +CREATE TABLE IF NOT EXISTS transactions ( + id UUID PRIMARY KEY, + wallet_address VARCHAR(255) NOT NULL, + type VARCHAR(100) NOT NULL, + description TEXT, + metadata JSONB, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_transactions_wallet_address ON transactions(wallet_address, created_at DESC); \ No newline at end of file From 4268d488848276a4825b5edd9ba1c70ddfca667c Mon Sep 17 00:00:00 2001 From: shadrach68 Date: Mon, 1 Jun 2026 12:31:21 +0100 Subject: [PATCH 2/2] resolve merge conflict --- app/api/snippets/[id]/route.ts | 21 +++--- components/WalletConnect.tsx | 5 +- components/navbar.tsx | 115 ++++++++++++--------------------- 3 files changed, 53 insertions(+), 88 deletions(-) diff --git a/app/api/snippets/[id]/route.ts b/app/api/snippets/[id]/route.ts index 206b30b..ca0d7b8 100644 --- a/app/api/snippets/[id]/route.ts +++ b/app/api/snippets/[id]/route.ts @@ -9,12 +9,6 @@ import { NextRequest, NextResponse } from "next/server"; import { SnippetService } from "../snippet.service"; import { SnippetRepository } from "../snippet.repository"; import { OwnershipMiddleware } from "../ownership.middleware"; -import { - createSnippetVersion, - getVersionHistory, - getVersionById, - restoreVersion, -} from "@/lib/db"; import { canView, canEdit } from "@/lib/permissions.service"; import { ZodError } from "zod"; @@ -73,7 +67,10 @@ export async function GET( const allowed = await canView(id, walletAddress); if (!allowed) { return NextResponse.json( - { error: "Forbidden", message: "You do not have view access to this snippet." }, + { + error: "Forbidden", + message: "You do not have view access to this snippet.", + }, { status: 403 }, ); } @@ -146,7 +143,10 @@ export async function PUT( const editAllowed = await canEdit(id, walletAddress); if (!editAllowed) { return NextResponse.json( - { error: "Forbidden", message: "You do not have edit access to this snippet." }, + { + error: "Forbidden", + message: "You do not have edit access to this snippet.", + }, { status: 403 }, ); } @@ -231,10 +231,9 @@ export async function DELETE( } } - return NextResponse.json({ message: "Snippet deleted successfully" }); - return NextResponse.json({ + return NextResponse.json({ message: "Snippet deleted successfully", - note: "Snippet moved to trash. You can restore it from the trash section." + note: "Snippet moved to trash. You can restore it from the trash section.", }); } catch (error) { if (error instanceof Error && error.message === "Snippet not found") { diff --git a/components/WalletConnect.tsx b/components/WalletConnect.tsx index 5be0642..6f05fda 100644 --- a/components/WalletConnect.tsx +++ b/components/WalletConnect.tsx @@ -70,7 +70,7 @@ export function WalletProvider({ children }: { children: React.ReactNode }) { ); } - const pubKey = await freighter.getPublicKey(); + pubKey = await freighter.getPublicKey(); if (!pubKey) throw new Error("Failed to retrieve public key from Freighter."); @@ -97,9 +97,6 @@ export function WalletProvider({ children }: { children: React.ReactNode }) { console.error("[transactions] failed to log wallet_connect", e); } })(); - pubKey = await freighter.getPublicKey(); - if (!pubKey) - throw new Error("Failed to retrieve public key from Freighter."); } // ========================== diff --git a/components/navbar.tsx b/components/navbar.tsx index e2588c8..71c8a6a 100644 --- a/components/navbar.tsx +++ b/components/navbar.tsx @@ -1,8 +1,9 @@ "use client"; +import { useState } from "react"; import Link from "next/link"; import { usePathname } from "next/navigation"; -import { Code2 } from "lucide-react"; +import { Code2, Menu, X } from "lucide-react"; import { Button } from "@/components/ui/button"; import { WalletButton } from "@/components/WalletConnect"; import { cn } from "@/lib/utils"; @@ -15,6 +16,7 @@ const NAV_LINKS = [ export function Navbar() { const pathname = usePathname(); + const [mobileOpen, setMobileOpen] = useState(false); return (
@@ -30,8 +32,8 @@ export function Navbar() { - {/* Nav links & Wallet */} -
+ {/* Desktop nav links & Wallet */} +
+ + {/* Mobile menu button */} +
+ + {/* Mobile menu */} + {mobileOpen && ( +
+ {NAV_LINKS.map(({ label, href }) => ( + setMobileOpen(false)}> + + + ))} +
+ +
+
+ )}
); - const pathname = usePathname(); - const [mobileOpen, setMobileOpen] = useState(false); - - return ( -
-
- {/* Logo */} - - - - Codely - - - - {/* Desktop nav links & Wallet */} -
- - -
- - {/* Mobile menu button */} - -
- - {/* Mobile menu */} - {mobileOpen && ( -
- {NAV_LINKS.map(({ label, href }) => ( - setMobileOpen(false)}> - - - ))} -
- -
-
- )} -
- ); }