Skip to content
Open
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
165 changes: 165 additions & 0 deletions src/__tests__/nlQueryParser.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
import { parseNaturalLanguageQuery, applyParsedFilters } from '@/lib/nlQueryParser';
import { describe, it, expect } from 'vitest';

describe('nlQueryParser', () => {
describe('parseNaturalLanguageQuery', () => {
it('should parse "unpaid invoices over 200 XLM from last month"', () => {
const result = parseNaturalLanguageQuery('unpaid invoices over 200 XLM from last month');
expect(result.filters.status).toBe('pending');
expect(result.filters.amountGte).toBe(200);
expect(result.filters.dueDateFrom).toBeDefined();
expect(result.filters.dueDateTo).toBeDefined();
expect(result.tokens.length).toBeGreaterThan(0);
});

it('should parse amount ranges', () => {
const result = parseNaturalLanguageQuery('invoices between 100 and 500 XLM');
expect(result.filters.amountGte).toBe(100);
expect(result.filters.amountLte).toBe(500);
expect(result.tokens.some((t) => t.type === 'amount_range')).toBe(true);
});

it('should parse minimum amount', () => {
const result = parseNaturalLanguageQuery('over 500 XLM');
expect(result.filters.amountGte).toBe(500);
});

it('should parse maximum amount', () => {
const result = parseNaturalLanguageQuery('under 1000 XLM');
expect(result.filters.amountLte).toBe(1000);
});

it('should parse status keywords', () => {
expect(parseNaturalLanguageQuery('unpaid').filters.status).toBe('pending');
expect(parseNaturalLanguageQuery('paid').filters.status).toBe('paid');
expect(parseNaturalLanguageQuery('overdue').filters.status).toBe('overdue');
expect(parseNaturalLanguageQuery('draft').filters.status).toBe('draft');
});

it('should parse relative dates', () => {
const result = parseNaturalLanguageQuery('this week');
expect(result.filters.dueDateFrom).toBeDefined();
expect(result.filters.dueDateTo).toBeDefined();
});

it('should parse "past N days"', () => {
const result = parseNaturalLanguageQuery('past 7 days');
expect(result.filters.dueDateFrom).toBeDefined();
expect(result.filters.dueDateTo).toBeDefined();
});

it('should parse asset names', () => {
expect(parseNaturalLanguageQuery('XLM').filters.asset).toBe('XLM');
expect(parseNaturalLanguageQuery('USDC').filters.asset).toBe('USDC');
});

it('should handle mixed case', () => {
const result = parseNaturalLanguageQuery('UNPAID Invoices OVER 200 xlm');
expect(result.filters.status).toBe('pending');
expect(result.filters.amountGte).toBe(200);
});

it('should handle typos gracefully (with keyword fallback)', () => {
const result = parseNaturalLanguageQuery('invoces for customer A');
expect(result.filters.titleKeyword).toBeDefined();
});

it('should handle complex query with multiple constraints', () => {
const result = parseNaturalLanguageQuery(
'unpaid invoices between 100 and 500 XLM in USDC status from last week'
);
expect(result.filters.status).toBe('pending');
expect(result.filters.amountGte).toBe(100);
expect(result.filters.amountLte).toBe(500);
expect(result.filters.dueDateFrom).toBeDefined();
});

it('should generate filter tokens', () => {
const result = parseNaturalLanguageQuery('paid invoices over 300 XLM this month');
expect(result.tokens.length).toBeGreaterThan(0);
expect(result.tokens.some((t) => t.type === 'status')).toBe(true);
expect(result.tokens.some((t) => t.type === 'amount_min')).toBe(true);
});

it('should handle empty query', () => {
const result = parseNaturalLanguageQuery('');
expect(result.filters).toEqual({});
expect(result.tokens.length).toBe(0);
});

it('should handle query with only whitespace', () => {
const result = parseNaturalLanguageQuery(' ');
expect(result.tokens.length).toBe(0);
});
});

describe('applyParsedFilters', () => {
const mockInvoices = [
{
id: '1',
title: 'Invoice 1',
amount: 150,
status: 'pending',
recipients: [{ address: 'customer1@example.com' }],
},
{
id: '2',
title: 'Invoice 2',
amount: 300,
status: 'paid',
recipients: [{ address: 'customer2@example.com' }],
},
{
id: '3',
title: 'Invoice 3',
amount: 600,
status: 'overdue',
recipients: [{ address: 'customer3@example.com' }],
},
];

it('should filter by amount minimum', () => {
const result = applyParsedFilters(mockInvoices, { amountGte: 300 });
expect(result.length).toBe(2);
expect(result.map((i) => i.id)).toEqual(['2', '3']);
});

it('should filter by amount maximum', () => {
const result = applyParsedFilters(mockInvoices, { amountLte: 300 });
expect(result.length).toBe(2);
expect(result.map((i) => i.id)).toEqual(['1', '2']);
});

it('should filter by amount range', () => {
const result = applyParsedFilters(mockInvoices, {
amountGte: 200,
amountLte: 500,
});
expect(result.length).toBe(1);
expect(result[0].id).toBe('2');
});

it('should filter by status', () => {
const result = applyParsedFilters(mockInvoices, { status: 'paid' });
expect(result.length).toBe(1);
expect(result[0].id).toBe('2');
});

it('should filter by keyword', () => {
const result = applyParsedFilters(mockInvoices, {
titleKeyword: 'customer1',
});
expect(result.length).toBe(1);
expect(result[0].id).toBe('1');
});

it('should combine multiple filters', () => {
const result = applyParsedFilters(mockInvoices, {
status: 'paid',
amountGte: 250,
});
expect(result.length).toBe(1);
expect(result[0].id).toBe('2');
});
});
});
124 changes: 124 additions & 0 deletions src/app/api/invoices/share-links/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import { NextRequest, NextResponse } from 'next/server';
import {
createShareLink,
getShareLinksForInvoice,
revokeShareLink,
type ShareLinkPermission,
} from '@/lib/shareLink';

interface CreateShareLinkRequest {
invoiceId: string;
permissions?: ShareLinkPermission;
durationMs?: number;
maxUses?: boolean;
}

interface RevokeShareLinkRequest {
tokenHash: string;
}

/**
* POST - Generate a new share link
* Body: { invoiceId, permissions, durationMs, maxUses }
*/
export async function POST(request: NextRequest) {
try {
const body = (await request.json().catch(() => null)) as CreateShareLinkRequest | null;

if (!body?.invoiceId) {
return NextResponse.json({ error: 'invoiceId is required' }, { status: 400 });
}

const permissions: ShareLinkPermission = body.permissions || 'read';
const durationMs = body.durationMs || 3600000; // 1 hour default
const maxUses = body.maxUses ? 1 : undefined; // Single-use if specified

// Validate duration
if (durationMs < 300000 || durationMs > 2592000000) {
// 5 minutes to 30 days
return NextResponse.json(
{ error: 'durationMs must be between 5 minutes and 30 days' },
{ status: 400 }
);
}

const shareLink = createShareLink(body.invoiceId, permissions, durationMs, maxUses);

return NextResponse.json({
tokenHash: shareLink.tokenHash,
token: shareLink.token, // Return this only on creation
invoiceId: shareLink.invoiceId,
permissions: shareLink.permissions,
expiresAt: shareLink.expiresAt,
maxUses: shareLink.maxUses,
createdAt: shareLink.createdAt,
});
} catch (error) {
console.error('Error creating share link:', error);
return NextResponse.json(
{ error: 'Failed to create share link' },
{ status: 500 }
);
}
}

/**
* GET - List share links for an invoice
* Query: invoiceId
*/
export async function GET(request: NextRequest) {
try {
const invoiceId = request.nextUrl.searchParams.get('invoiceId');

if (!invoiceId) {
return NextResponse.json({ error: 'invoiceId is required' }, { status: 400 });
}

const links = getShareLinksForInvoice(invoiceId);

return NextResponse.json({
links: links.map((link) => ({
tokenHash: link.tokenHash,
permissions: link.permissions,
expiresAt: link.expiresAt,
maxUses: link.maxUses,
usesConsumed: link.usesConsumed,
createdAt: link.createdAt,
})),
});
} catch (error) {
console.error('Error fetching share links:', error);
return NextResponse.json(
{ error: 'Failed to fetch share links' },
{ status: 500 }
);
}
}

/**
* DELETE - Revoke a share link
* Body: { tokenHash }
*/
export async function DELETE(request: NextRequest) {
try {
const body = (await request.json().catch(() => null)) as RevokeShareLinkRequest | null;

if (!body?.tokenHash) {
return NextResponse.json({ error: 'tokenHash is required' }, { status: 400 });
}

const revoked = revokeShareLink(body.tokenHash);

if (!revoked) {
return NextResponse.json({ error: 'Share link not found' }, { status: 404 });
}

return NextResponse.json({ success: true });
} catch (error) {
console.error('Error revoking share link:', error);
return NextResponse.json(
{ error: 'Failed to revoke share link' },
{ status: 500 }
);
}
}
Loading