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
18 changes: 18 additions & 0 deletions next.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,24 @@ const nextConfig = {
},
async headers() {
return [
{
// Applied to every route; the more specific /embed/:id rule below
// overrides X-Frame-Options for that one path.
source: "/:path*",
headers: [
{ key: "X-Frame-Options", value: "DENY" },
{ key: "X-Content-Type-Options", value: "nosniff" },
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
{
key: "Strict-Transport-Security",
value: "max-age=63072000; includeSubDomains; preload",
},
{
key: "Permissions-Policy",
value: "camera=(), microphone=(), geolocation=(), payment=(self)",
},
],
},
{
source: "/sw.js",
headers: [
Expand Down
62 changes: 62 additions & 0 deletions src/app/api/folders/[id]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { NextRequest, NextResponse } from "next/server";
import { FolderNameSchema, deleteFolder, getFolder, renameFolder } from "@/lib/folders";

/** PATCH /api/folders/:id — rename a folder. */
export async function PATCH(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
if (!getFolder(params.id)) {
return NextResponse.json({ error: "Folder not found" }, { status: 404 });
}

const rawBody = await request.json();
const parsed = FolderNameSchema.safeParse(rawBody);

if (!parsed.success) {
return NextResponse.json(
{
error: "Invalid folder payload — expected { name: string }",
details: parsed.error.issues,
},
{ status: 422 }
);
}

const folder = renameFolder(params.id, parsed.data.name);
return NextResponse.json({ folder }, { status: 200 });
} catch (error) {
console.error("Folder rename error:", error);
return NextResponse.json(
{
error: "Failed to rename folder",
details: error instanceof Error ? error.message : String(error),
},
{ status: 500 }
);
}
}

/** DELETE /api/folders/:id — delete a folder and clear its membership. */
export async function DELETE(
_request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const removed = deleteFolder(params.id);
if (!removed) {
return NextResponse.json({ error: "Folder not found" }, { status: 404 });
}
return NextResponse.json({ success: true }, { status: 200 });
} catch (error) {
console.error("Folder delete error:", error);
return NextResponse.json(
{
error: "Failed to delete folder",
details: error instanceof Error ? error.message : String(error),
},
{ status: 500 }
);
}
}
66 changes: 66 additions & 0 deletions src/app/api/folders/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { NextRequest, NextResponse } from "next/server";
import {
FolderNameSchema,
MAX_FOLDERS,
createFolder,
getMembershipMap,
listFolders,
} from "@/lib/folders";

/**
* GET /api/folders — every folder, plus the invoiceId → folderIds membership map.
*
* One request serves both the sidebar (the folder list) and list filtering
* (the membership map), so the folders page doesn't fan out a request per invoice.
*/
export async function GET(_request: NextRequest) {
try {
return NextResponse.json(
{ folders: listFolders(), byInvoice: getMembershipMap() },
{ status: 200 }
);
} catch (error) {
console.error("Folder list error:", error);
return NextResponse.json(
{
error: "Failed to list folders",
details: error instanceof Error ? error.message : String(error),
},
{ status: 500 }
);
}
}

/** POST /api/folders — create a new named folder. */
export async function POST(request: NextRequest) {
try {
if (listFolders().length >= MAX_FOLDERS) {
return NextResponse.json({ error: `Cannot exceed ${MAX_FOLDERS} folders` }, { status: 422 });
}

const rawBody = await request.json();
const parsed = FolderNameSchema.safeParse(rawBody);

if (!parsed.success) {
return NextResponse.json(
{
error: "Invalid folder payload — expected { name: string }",
details: parsed.error.issues,
},
{ status: 422 }
);
}

const folder = createFolder(parsed.data.name);
return NextResponse.json({ folder }, { status: 201 });
} catch (error) {
console.error("Folder create error:", error);
return NextResponse.json(
{
error: "Failed to create folder",
details: error instanceof Error ? error.message : String(error),
},
{ status: 500 }
);
}
}
66 changes: 66 additions & 0 deletions src/app/api/invoices/[id]/folders/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { NextRequest, NextResponse } from "next/server";
import { FolderMembershipSchema, getFoldersForInvoice, setFoldersForInvoice } from "@/lib/folders";

/** GET /api/invoices/:id/folders — folder ids this invoice currently belongs to. */
export async function GET(
_request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
return NextResponse.json(
{ invoiceId: params.id, folderIds: getFoldersForInvoice(params.id) },
{ status: 200 }
);
} catch (error) {
console.error("Invoice folder fetch error:", error);
return NextResponse.json(
{
error: "Failed to fetch invoice folders",
details: error instanceof Error ? error.message : String(error),
},
{ status: 500 }
);
}
}

/**
* PATCH /api/invoices/:id/folders — replace the invoice's folder membership.
*
* The client sends the full desired list (both adds and removes go through
* here), matching `PATCH /api/invoices/:id/tags`.
*/
export async function PATCH(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const rawBody = await request.json();
const parsed = FolderMembershipSchema.safeParse(rawBody);

if (!parsed.success) {
return NextResponse.json(
{
error: "Invalid payload — expected { folderIds: string[] }",
details: parsed.error.issues,
},
{ status: 422 }
);
}

const folderIds = setFoldersForInvoice(params.id, parsed.data.folderIds);

return NextResponse.json(
{ success: true, invoiceId: params.id, folderIds },
{ status: 200 }
);
} catch (error) {
console.error("Invoice folder save error:", error);
return NextResponse.json(
{
error: "Failed to save invoice folders",
details: error instanceof Error ? error.message : String(error),
},
{ status: 500 }
);
}
}
74 changes: 9 additions & 65 deletions src/app/api/invoices/route.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
import { NextRequest, NextResponse } from "next/server";
import { splitClient } from "@/lib/stellar";

const PAGE_SIZE = 20;
import { fetchInvoicesForAddress } from "@/lib/stellar/horizonServer";

/**
* GET /api/invoices?cursor=<id>&limit=20&publicKey=<address>&q=<query>
Expand All @@ -19,9 +17,9 @@ const PAGE_SIZE = 20;
export async function GET(request: NextRequest) {
const { searchParams } = request.nextUrl;
const publicKey = searchParams.get("publicKey");
const cursorParam = searchParams.get("cursor");
const cursor = searchParams.get("cursor");
const limitParam = searchParams.get("limit");
const q = searchParams.get("q")?.trim().toLowerCase() || "";
const q = searchParams.get("q") ?? undefined;

if (!publicKey) {
return NextResponse.json(
Expand All @@ -30,66 +28,12 @@ export async function GET(request: NextRequest) {
);
}

const limit = Math.min(
Math.max(1, parseInt(limitParam ?? String(PAGE_SIZE), 10) || PAGE_SIZE),
50,
);

// Determine starting invoice id (cursor is the last id we already returned)
const startId = cursorParam ? parseInt(cursorParam, 10) + 1 : 1;

const results = [];
let lastCheckedId = startId - 1;

for (let id = startId; results.length < limit; id++) {
// Safety cap — don't scan more than limit*10 ids in a single request
if (id > startId + limit * 10) break;

lastCheckedId = id;

try {
const inv = await splitClient.getInvoice(String(id));
const mine =
inv.creator === publicKey ||
inv.recipients.some((r) => r.address === publicKey);

if (mine) {
if (q) {
const memo = (inv as any).memo as string | undefined;
const matchesQuery =
(inv.title || "").toLowerCase().startsWith(q) ||
(memo || "").toLowerCase().startsWith(q);
if (matchesQuery) {
results.push(inv);
}
} else {
results.push(inv);
}
}
} catch {
// splitClient throws when invoice id does not exist — treat as end of list
return NextResponse.json(
{ invoices: results, nextCursor: null },
{
headers: {
"Cache-Control": "private, no-store",
},
},
);
}
}

// If we filled the page we don't know yet whether there are more — return the
// id of the last invoice we fetched so the client can continue from there.
const nextCursor =
results.length === limit ? String(results[results.length - 1].id) : null;
const limit = limitParam ? parseInt(limitParam, 10) : undefined;
const result = await fetchInvoicesForAddress(publicKey, { cursor, limit, q });

return NextResponse.json(
{ invoices: results, nextCursor },
{
headers: {
"Cache-Control": "private, no-store",
},
return NextResponse.json(result, {
headers: {
"Cache-Control": "private, no-store",
},
);
});
}
Loading
Loading