Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 56 additions & 6 deletions app/api/snippets/[id]/route.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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 },
);
}
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -136,14 +153,30 @@ 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 },
);
}

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,
Expand Down Expand Up @@ -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,
Expand Down
14 changes: 14 additions & 0 deletions app/api/snippets/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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,
Expand Down
63 changes: 63 additions & 0 deletions app/api/transactions/route.ts
Original file line number Diff line number Diff line change
@@ -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 },
);
}
}
154 changes: 154 additions & 0 deletions app/transactions/page.tsx
Original file line number Diff line number Diff line change
@@ -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<Transaction[]>([]);
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 (
<div className="flex flex-col items-center justify-center min-h-[60vh] text-center px-4">
<h1 className="text-3xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-purple-400 to-blue-500 mb-4">
Transaction History
</h1>
<p className="text-gray-400 mb-6">
Please connect your Stellar wallet to view your activity history.
</p>
</div>
);
}

return (
<div className="container mx-auto px-4 py-12 max-w-4xl min-h-screen">
<h1 className="text-3xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-purple-400 to-blue-500 mb-2">
Wallet Activity
</h1>
<p className="text-gray-400 mb-8">
A secure, queryable log of all actions associated with your connected
wallet.
</p>

{error && (
<div className="bg-red-500/10 border border-red-500/50 text-red-500 px-4 py-3 rounded-md mb-6">
{error}
</div>
)}

{loading ? (
<div className="flex justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-500"></div>
</div>
) : transactions.length === 0 ? (
<div className="bg-gray-800/50 border border-gray-700 rounded-lg p-12 text-center text-gray-400">
No transactions found for this wallet.
</div>
) : (
<div className="space-y-4">
{transactions.map((tx) => (
<div
key={tx.id}
className="bg-gray-900 border border-gray-800 rounded-lg p-5 hover:border-gray-700 transition-colors"
>
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div>
<div className="flex items-center gap-2 mb-1">
<span className="px-2.5 py-1 text-xs font-medium rounded-full bg-purple-500/10 text-purple-400 border border-purple-500/20 uppercase tracking-wider">
{tx.type.replace("_", " ")}
</span>
<span className="text-gray-500 text-sm">
{formatDistanceToNow(new Date(tx.created_at), {
addSuffix: true,
})}
</span>
</div>
<p className="text-gray-200">{tx.description}</p>
</div>
{tx.metadata?.snippetId && (
<div className="text-sm text-gray-500 font-mono bg-gray-950 px-3 py-1.5 rounded-md">
ID: {tx.metadata.snippetId.split("-")[0]}...
</div>
)}
</div>
</div>
))}

{/* Pagination */}
{totalPages > 1 && (
<div className="flex justify-center items-center gap-4 mt-8 pt-4">
<button
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page === 1}
className="px-4 py-2 rounded-md bg-gray-800 text-gray-300 disabled:opacity-50 hover:bg-gray-700 transition-colors"
>
Previous
</button>
<span className="text-gray-400 text-sm">
Page {page} of {totalPages}
</span>
<button
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={page === totalPages}
className="px-4 py-2 rounded-md bg-gray-800 text-gray-300 disabled:opacity-50 hover:bg-gray-700 transition-colors"
>
Next
</button>
</div>
)}
</div>
)}
</div>
);
}
Loading