diff --git a/app/api/snippets/[id]/route.ts b/app/api/snippets/[id]/route.ts index 5a935c7..11ca30c 100644 --- a/app/api/snippets/[id]/route.ts +++ b/app/api/snippets/[id]/route.ts @@ -1,13 +1,14 @@ -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, + createTransaction, } from "@/lib/db"; +import { NextRequest, NextResponse } from "next/server"; +import { SnippetService } from "../snippet.service"; +import { SnippetRepository } from "../snippet.repository"; +import { OwnershipMiddleware } from "../ownership.middleware"; import { canView, canEdit } from "@/lib/permissions.service"; import { ZodError } from "zod"; import { appendActivityLog, extractIp, extractUserAgent } from "@/lib/activity-logger"; @@ -67,7 +68,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 }, ); } @@ -109,6 +113,19 @@ 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); + } + } // Log the restore action await appendActivityLog("snippet.restored", "snippet", { actorWallet: await OwnershipMiddleware.extractWalletAddress(req), @@ -136,7 +153,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 }, ); } @@ -144,6 +164,19 @@ 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); + } + } // Log the update await appendActivityLog("snippet.updated", "snippet", { actorWallet: walletAddress, @@ -202,6 +235,23 @@ export async function DELETE( // Use soft delete instead of hard delete await service.deleteSnippet(id, walletAddress); + // 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", + note: "Snippet moved to trash. You can restore it from the trash section.", // Log the deletion await appendActivityLog("snippet.deleted", "snippet", { actorWallet: walletAddress, diff --git a/app/api/snippets/route.ts b/app/api/snippets/route.ts index 0360557..3415303 100644 --- a/app/api/snippets/route.ts +++ b/app/api/snippets/route.ts @@ -2,6 +2,7 @@ import { appendActivityLog, extractIp, extractUserAgent } from "@/lib/activity-l import { rateLimit } from "@/lib/rateLimiter"; import { NextRequest, NextResponse } from "next/server"; import { ZodError } from "zod"; +import { createTransaction } from "@/lib/db"; import { OwnershipMiddleware } from "./ownership.middleware"; import { SnippetRepository } from "./snippet.repository"; import { SnippetService } from "./snippet.service"; @@ -121,6 +122,19 @@ 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); + } + } // Log snippet creation (fire-and-forget — never throws) await appendActivityLog("snippet.created", "snippet", { actorWallet: walletAddress, 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 79e804f..b27e592 100644 --- a/components/WalletConnect.tsx +++ b/components/WalletConnect.tsx @@ -74,6 +74,30 @@ export function WalletProvider({ children }: { children: React.ReactNode }) { pubKey = await freighter.getPublicKey(); 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); + } + })(); } // ========================== @@ -82,6 +106,30 @@ export function WalletProvider({ children }: { children: React.ReactNode }) { else if (walletType === "albedo") { const albedo = await import("@albedo-link/intent"); const result = await albedo.default.publicKey({}); + + 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); + } + })(); pubKey = result.pubkey; } @@ -204,6 +252,7 @@ export function WalletProvider({ children }: { children: React.ReactNode }) { disconnect, clearError, }), + [connected, publicKey, walletName, connecting, error], [connected, publicKey, walletName, connecting, error, token], ); diff --git a/components/navbar.tsx b/components/navbar.tsx index 2c17f7c..0ee75b2 100644 --- a/components/navbar.tsx +++ b/components/navbar.tsx @@ -1,22 +1,97 @@ "use client"; +import { useState } from "react"; import Link from "next/link"; import { usePathname } from "next/navigation"; import { Code2, Menu, X } 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); + 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)}> + + + ))} +
+ +
+
+ )} +
return (
diff --git a/lib/db.ts b/lib/db.ts index 0d8103f..a774caf 100644 --- a/lib/db.ts +++ b/lib/db.ts @@ -188,6 +188,27 @@ export async function restoreVersion( } } +// ============ 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); // ============ NFT Functions ============ export async function updateSnippetNft( @@ -211,6 +232,36 @@ export async function updateSnippetNft( } } +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); // ============ On-Chain Timestamp Verification Functions ============ /** 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