New base
This commit is contained in:
@@ -0,0 +1,169 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { generateChatResponse } from '@/extensions/ai-chat/chatbot/chain'
|
||||
import { CHATBOT_CONFIG } from '@/extensions/ai-chat/chatbot/config'
|
||||
import type { ChatMessage, ChatRequest } from '@/types/chat'
|
||||
|
||||
// Simple in-memory rate limiting (per user)
|
||||
const rateLimitMap = new Map<string, { count: number; resetTime: number }>()
|
||||
|
||||
function checkRateLimit(userId: string): boolean {
|
||||
const now = Date.now()
|
||||
const limit = rateLimitMap.get(userId)
|
||||
|
||||
if (!limit || now > limit.resetTime) {
|
||||
rateLimitMap.set(userId, {
|
||||
count: 1,
|
||||
resetTime: now + 60000, // 1 minute window
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
if (limit.count >= CHATBOT_CONFIG.rateLimitPerMinute) {
|
||||
return false
|
||||
}
|
||||
|
||||
limit.count++
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/chat
|
||||
* Send a message and get a response
|
||||
*/
|
||||
export async function POST(request: Request) {
|
||||
const supabase = await createClient()
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Rate limiting
|
||||
if (!checkRateLimit(user.id)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Rate limit exceeded. Please wait a moment.' },
|
||||
{ status: 429 }
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
const body: ChatRequest = await request.json()
|
||||
const { message, session_id } = body
|
||||
|
||||
if (!message || typeof message !== 'string' || message.trim().length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Message is required' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
let sessionId = session_id
|
||||
|
||||
// Create new session if not provided
|
||||
if (!sessionId) {
|
||||
const { data: newSession, error: sessionError } = await supabase
|
||||
.from('chat_sessions')
|
||||
.insert({
|
||||
user_id: user.id,
|
||||
title: message.slice(0, 100), // Use first 100 chars of message as title
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (sessionError) {
|
||||
console.error('Error creating session:', sessionError)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to create chat session' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
sessionId = newSession.id
|
||||
} else {
|
||||
// Verify session belongs to user
|
||||
const { data: existingSession } = await supabase
|
||||
.from('chat_sessions')
|
||||
.select('id')
|
||||
.eq('id', sessionId)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (!existingSession) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Session not found' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Save user message
|
||||
const { data: userMessage, error: userMsgError } = await supabase
|
||||
.from('chat_messages')
|
||||
.insert({
|
||||
session_id: sessionId,
|
||||
user_id: user.id,
|
||||
role: 'user',
|
||||
content: message.trim(),
|
||||
sources: [],
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (userMsgError) {
|
||||
console.error('Error saving user message:', userMsgError)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to save message' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
// Get conversation history
|
||||
const { data: history } = await supabase
|
||||
.from('chat_messages')
|
||||
.select('role, content')
|
||||
.eq('session_id', sessionId)
|
||||
.order('created_at', { ascending: true })
|
||||
.limit(CHATBOT_CONFIG.maxHistoryMessages)
|
||||
|
||||
const conversationHistory = (history || []) as ChatMessage[]
|
||||
|
||||
// Generate AI response
|
||||
const result = await generateChatResponse(message.trim(), conversationHistory)
|
||||
|
||||
// Save assistant message
|
||||
const { data: assistantMessage, error: assistantMsgError } = await supabase
|
||||
.from('chat_messages')
|
||||
.insert({
|
||||
session_id: sessionId,
|
||||
user_id: user.id,
|
||||
role: 'assistant',
|
||||
content: result.content,
|
||||
sources: result.sources,
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (assistantMsgError) {
|
||||
console.error('Error saving assistant message:', assistantMsgError)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to save response' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
message: assistantMessage,
|
||||
session_id: sessionId,
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('Chat error:', err)
|
||||
return NextResponse.json(
|
||||
{ error: err instanceof Error ? err.message : 'Failed to process chat' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
|
||||
/**
|
||||
* GET /api/chat/sessions/[id]
|
||||
* Get a single chat session with its messages
|
||||
*/
|
||||
export async function GET(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const supabase = await createClient()
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { id } = await params
|
||||
|
||||
// Get session with messages
|
||||
const { data: session, error: sessionError } = await supabase
|
||||
.from('chat_sessions')
|
||||
.select('*')
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (sessionError || !session) {
|
||||
return NextResponse.json({ error: 'Session not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Get messages
|
||||
const { data: messages, error: messagesError } = await supabase
|
||||
.from('chat_messages')
|
||||
.select('*')
|
||||
.eq('session_id', id)
|
||||
.order('created_at', { ascending: true })
|
||||
|
||||
if (messagesError) {
|
||||
console.error('Error fetching messages:', messagesError)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch messages' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
session,
|
||||
messages: messages || [],
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH /api/chat/sessions/[id]
|
||||
* Update a chat session (e.g., rename)
|
||||
*/
|
||||
export async function PATCH(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const supabase = await createClient()
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { id } = await params
|
||||
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { title } = body
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('chat_sessions')
|
||||
.update({ title })
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
console.error('Error updating session:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to update session' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return NextResponse.json({ error: 'Session not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ data })
|
||||
} catch (err) {
|
||||
return NextResponse.json(
|
||||
{ error: err instanceof Error ? err.message : 'Failed to update session' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/chat/sessions/[id]
|
||||
* Delete a chat session and its messages
|
||||
*/
|
||||
export async function DELETE(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const supabase = await createClient()
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { id } = await params
|
||||
|
||||
// Delete session (messages will cascade delete due to FK)
|
||||
const { error } = await supabase
|
||||
.from('chat_sessions')
|
||||
.delete()
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
|
||||
if (error) {
|
||||
console.error('Error deleting session:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to delete session' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
|
||||
/**
|
||||
* GET /api/chat/sessions
|
||||
* List all chat sessions for the authenticated user
|
||||
*/
|
||||
export async function GET(request: Request) {
|
||||
const supabase = await createClient()
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url)
|
||||
const limit = parseInt(searchParams.get('limit') || '20')
|
||||
const offset = parseInt(searchParams.get('offset') || '0')
|
||||
|
||||
const { data, error, count } = await supabase
|
||||
.from('chat_sessions')
|
||||
.select('*', { count: 'exact' })
|
||||
.eq('user_id', user.id)
|
||||
.order('created_at', { ascending: false })
|
||||
.range(offset, offset + limit - 1)
|
||||
|
||||
if (error) {
|
||||
console.error('Error fetching sessions:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch sessions' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json({ data, count })
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/chat/sessions
|
||||
* Create a new chat session
|
||||
*/
|
||||
export async function POST(request: Request) {
|
||||
const supabase = await createClient()
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { title } = body
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('chat_sessions')
|
||||
.insert({
|
||||
user_id: user.id,
|
||||
title: title || null,
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
console.error('Error creating session:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to create session' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json({ data })
|
||||
} catch (err) {
|
||||
return NextResponse.json(
|
||||
{ error: err instanceof Error ? err.message : 'Failed to create session' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { streamChatResponse } from '@/extensions/ai-chat/chatbot/chain'
|
||||
import { CHATBOT_CONFIG } from '@/extensions/ai-chat/chatbot/config'
|
||||
import type { ChatMessage, ChatRequest, SourceReference } from '@/types/chat'
|
||||
|
||||
// Simple in-memory rate limiting (per user)
|
||||
const rateLimitMap = new Map<string, { count: number; resetTime: number }>()
|
||||
|
||||
function checkRateLimit(userId: string): boolean {
|
||||
const now = Date.now()
|
||||
const limit = rateLimitMap.get(userId)
|
||||
|
||||
if (!limit || now > limit.resetTime) {
|
||||
rateLimitMap.set(userId, {
|
||||
count: 1,
|
||||
resetTime: now + 60000,
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
if (limit.count >= CHATBOT_CONFIG.rateLimitPerMinute) {
|
||||
return false
|
||||
}
|
||||
|
||||
limit.count++
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/chat/stream
|
||||
* Streaming chat response via Server-Sent Events
|
||||
*/
|
||||
export async function POST(request: Request) {
|
||||
const supabase = await createClient()
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'Unauthorized' }),
|
||||
{ status: 401, headers: { 'Content-Type': 'application/json' } }
|
||||
)
|
||||
}
|
||||
|
||||
if (!checkRateLimit(user.id)) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'Rate limit exceeded' }),
|
||||
{ status: 429, headers: { 'Content-Type': 'application/json' } }
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
const body: ChatRequest = await request.json()
|
||||
const { message, session_id } = body
|
||||
|
||||
if (!message || typeof message !== 'string' || message.trim().length === 0) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'Message is required' }),
|
||||
{ status: 400, headers: { 'Content-Type': 'application/json' } }
|
||||
)
|
||||
}
|
||||
|
||||
let sessionId = session_id
|
||||
|
||||
// Create new session if not provided
|
||||
if (!sessionId) {
|
||||
const { data: newSession, error: sessionError } = await supabase
|
||||
.from('chat_sessions')
|
||||
.insert({
|
||||
user_id: user.id,
|
||||
title: message.slice(0, 100),
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (sessionError) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'Failed to create session' }),
|
||||
{ status: 500, headers: { 'Content-Type': 'application/json' } }
|
||||
)
|
||||
}
|
||||
|
||||
sessionId = newSession.id
|
||||
} else {
|
||||
// Verify session belongs to user
|
||||
const { data: existingSession } = await supabase
|
||||
.from('chat_sessions')
|
||||
.select('id')
|
||||
.eq('id', sessionId)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (!existingSession) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'Session not found' }),
|
||||
{ status: 404, headers: { 'Content-Type': 'application/json' } }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Save user message
|
||||
await supabase
|
||||
.from('chat_messages')
|
||||
.insert({
|
||||
session_id: sessionId,
|
||||
user_id: user.id,
|
||||
role: 'user',
|
||||
content: message.trim(),
|
||||
sources: [],
|
||||
})
|
||||
|
||||
// Get conversation history
|
||||
const { data: history } = await supabase
|
||||
.from('chat_messages')
|
||||
.select('role, content')
|
||||
.eq('session_id', sessionId)
|
||||
.order('created_at', { ascending: true })
|
||||
.limit(CHATBOT_CONFIG.maxHistoryMessages)
|
||||
|
||||
const conversationHistory = (history || []) as ChatMessage[]
|
||||
|
||||
// Create streaming response
|
||||
const encoder = new TextEncoder()
|
||||
let fullContent = ''
|
||||
let sources: SourceReference[] = []
|
||||
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
try {
|
||||
// Send session ID first
|
||||
controller.enqueue(
|
||||
encoder.encode(`data: ${JSON.stringify({ type: 'session', session_id: sessionId })}\n\n`)
|
||||
)
|
||||
|
||||
// Stream the response
|
||||
for await (const chunk of streamChatResponse(message.trim(), conversationHistory)) {
|
||||
if (chunk.type === 'content') {
|
||||
fullContent += chunk.data as string
|
||||
controller.enqueue(
|
||||
encoder.encode(`data: ${JSON.stringify({ type: 'content', content: chunk.data })}\n\n`)
|
||||
)
|
||||
} else if (chunk.type === 'sources') {
|
||||
sources = chunk.data as SourceReference[]
|
||||
controller.enqueue(
|
||||
encoder.encode(`data: ${JSON.stringify({ type: 'sources', sources: chunk.data })}\n\n`)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Save the complete assistant message
|
||||
const { data: savedMessage } = await supabase
|
||||
.from('chat_messages')
|
||||
.insert({
|
||||
session_id: sessionId,
|
||||
user_id: user.id,
|
||||
role: 'assistant',
|
||||
content: fullContent,
|
||||
sources,
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
// Send done signal with message ID
|
||||
controller.enqueue(
|
||||
encoder.encode(`data: ${JSON.stringify({ type: 'done', message_id: savedMessage?.id })}\n\n`)
|
||||
)
|
||||
|
||||
controller.close()
|
||||
} catch (error) {
|
||||
console.error('Streaming error:', error)
|
||||
controller.enqueue(
|
||||
encoder.encode(`data: ${JSON.stringify({ type: 'error', error: 'Streaming failed' })}\n\n`)
|
||||
)
|
||||
controller.close()
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Connection': 'keep-alive',
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('Stream setup error:', err)
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'Failed to setup stream' }),
|
||||
{ status: 500, headers: { 'Content-Type': 'application/json' } }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { generateNEDeclaration } from '@/extensions/ne-bilaga/ne-engine'
|
||||
import {
|
||||
generateSRUFile,
|
||||
sruFileToString,
|
||||
getSRUFilename,
|
||||
} from '@/extensions/sru-export/lib/sru-generator'
|
||||
|
||||
/**
|
||||
* GET /api/extensions/ne-bilaga
|
||||
*
|
||||
* Generate NE declaration (NE-bilaga) for enskild firma.
|
||||
*
|
||||
* Query parameters:
|
||||
* - period_id: Fiscal period ID (required)
|
||||
* - format: 'json' (default) or 'sru' for SRU file download
|
||||
*
|
||||
* Returns:
|
||||
* - JSON: NE declaration with rutor R1-R11 and breakdown
|
||||
* - SRU: Downloadable SRU file for Skatteverket submission
|
||||
*/
|
||||
export async function GET(request: Request) {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url)
|
||||
const periodId = searchParams.get('period_id')
|
||||
const format = searchParams.get('format') || 'json'
|
||||
|
||||
if (!periodId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'period_id is required' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
const declaration = await generateNEDeclaration(user.id, periodId)
|
||||
|
||||
if (format === 'sru') {
|
||||
// Generate and return SRU file
|
||||
const sruFile = generateSRUFile(declaration)
|
||||
const sruContent = sruFileToString(sruFile)
|
||||
const filename = getSRUFilename(declaration)
|
||||
|
||||
return new NextResponse(sruContent, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'text/plain; charset=utf-8',
|
||||
'Content-Disposition': `attachment; filename="${filename}"`,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Default: return JSON
|
||||
return NextResponse.json({ data: declaration })
|
||||
} catch (err) {
|
||||
console.error('Error generating NE declaration:', err)
|
||||
return NextResponse.json(
|
||||
{ error: err instanceof Error ? err.message : 'Failed to generate NE declaration' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { createClient } from '@supabase/supabase-js'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { loadExtensions } from '@/lib/extensions/loader'
|
||||
import {
|
||||
sendTaxDeadlineNotifications,
|
||||
sendInvoiceNotifications,
|
||||
} from '@/extensions/push-notifications/notification-scheduler'
|
||||
|
||||
/**
|
||||
* GET /api/extensions/push-notifications/cron
|
||||
* Daily cron job to send push notifications
|
||||
* Runs at 09:00 every day
|
||||
*
|
||||
* Vercel Cron: "0 9 * * *"
|
||||
*/
|
||||
export async function GET(request: Request) {
|
||||
// Ensure extensions are loaded so event handlers are registered
|
||||
loadExtensions()
|
||||
|
||||
// Verify cron secret for security
|
||||
const authHeader = request.headers.get('authorization')
|
||||
const cronSecret = process.env.CRON_SECRET
|
||||
|
||||
if (cronSecret && authHeader !== `Bearer ${cronSecret}`) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Create a service role client for accessing all user data
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL
|
||||
const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY
|
||||
|
||||
if (!supabaseUrl || !supabaseServiceKey) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Missing Supabase configuration' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
const supabase = createClient(supabaseUrl, supabaseServiceKey)
|
||||
|
||||
try {
|
||||
// Send all notification types in parallel
|
||||
const [taxResult, invoiceResult] = await Promise.all([
|
||||
sendTaxDeadlineNotifications(supabase),
|
||||
sendInvoiceNotifications(supabase),
|
||||
])
|
||||
|
||||
const totalSent = taxResult.sent + invoiceResult.sent
|
||||
const totalSkipped = taxResult.skipped + invoiceResult.skipped
|
||||
|
||||
console.log(
|
||||
`Push notification cron completed: ${totalSent} sent, ${totalSkipped} skipped`
|
||||
)
|
||||
console.log(
|
||||
` Tax: ${taxResult.sent} sent, ${taxResult.skipped} skipped`
|
||||
)
|
||||
console.log(
|
||||
` Invoice: ${invoiceResult.sent} sent, ${invoiceResult.skipped} skipped`
|
||||
)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
totalSent,
|
||||
totalSkipped,
|
||||
details: {
|
||||
taxDeadlines: taxResult,
|
||||
invoices: invoiceResult,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error in push notification cron:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to send push notifications' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getSettings, saveSettings } from '@/extensions/push-notifications'
|
||||
|
||||
/**
|
||||
* GET /api/extensions/push-notifications/settings
|
||||
* Get the current user's push-notification extension settings
|
||||
*/
|
||||
export async function GET() {
|
||||
const supabase = await createClient()
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const settings = await getSettings(user.id)
|
||||
return NextResponse.json({ data: settings })
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH /api/extensions/push-notifications/settings
|
||||
* Update the current user's push-notification extension settings
|
||||
*/
|
||||
export async function PATCH(request: Request) {
|
||||
const supabase = await createClient()
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
|
||||
const allowedKeys = [
|
||||
'periodLockedEnabled',
|
||||
'periodYearClosedEnabled',
|
||||
'invoiceSentEnabled',
|
||||
'receiptExtractedEnabled',
|
||||
'receiptMatchedEnabled',
|
||||
]
|
||||
const filtered: Record<string, unknown> = {}
|
||||
for (const key of allowedKeys) {
|
||||
if (key in body) {
|
||||
filtered[key] = body[key]
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(filtered).length === 0) {
|
||||
return NextResponse.json({ error: 'No valid settings provided' }, { status: 400 })
|
||||
}
|
||||
|
||||
const settings = await saveSettings(user.id, filtered)
|
||||
return NextResponse.json({ data: settings })
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getVapidPublicKey } from '@/extensions/push-notifications/notification-sender'
|
||||
|
||||
/**
|
||||
* GET /api/extensions/push-notifications/subscribe
|
||||
* Get the VAPID public key for client-side subscription
|
||||
*/
|
||||
export async function GET() {
|
||||
const vapidKey = getVapidPublicKey()
|
||||
|
||||
if (!vapidKey) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Push notifications not configured' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json({ vapidPublicKey: vapidKey })
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/extensions/push-notifications/subscribe
|
||||
* Save a new push subscription
|
||||
*/
|
||||
export async function POST(request: Request) {
|
||||
const supabase = await createClient()
|
||||
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const { endpoint, keys } = body
|
||||
|
||||
if (!endpoint || !keys?.p256dh || !keys?.auth) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid subscription data' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Get user agent for debugging
|
||||
const userAgent = request.headers.get('user-agent') || null
|
||||
|
||||
// Upsert subscription (update if endpoint exists)
|
||||
const { data, error } = await supabase
|
||||
.from('push_subscriptions')
|
||||
.upsert(
|
||||
{
|
||||
user_id: user.id,
|
||||
endpoint,
|
||||
p256dh: keys.p256dh,
|
||||
auth: keys.auth,
|
||||
user_agent: userAgent,
|
||||
is_active: true,
|
||||
last_used_at: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
onConflict: 'user_id,endpoint',
|
||||
}
|
||||
)
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
console.error('Error saving subscription:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to save subscription' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
// Also ensure notification settings exist with defaults
|
||||
await supabase
|
||||
.from('notification_settings')
|
||||
.upsert(
|
||||
{
|
||||
user_id: user.id,
|
||||
tax_deadlines_enabled: true,
|
||||
invoice_reminders_enabled: true,
|
||||
push_enabled: true,
|
||||
email_enabled: true,
|
||||
quiet_start: '21:00',
|
||||
quiet_end: '08:00',
|
||||
},
|
||||
{
|
||||
onConflict: 'user_id',
|
||||
ignoreDuplicates: true,
|
||||
}
|
||||
)
|
||||
|
||||
return NextResponse.json({ success: true, id: data.id })
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/extensions/push-notifications/subscribe
|
||||
* Remove a push subscription
|
||||
*/
|
||||
export async function DELETE(request: Request) {
|
||||
const supabase = await createClient()
|
||||
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const { endpoint } = body
|
||||
|
||||
if (!endpoint) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Endpoint is required' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const { error } = await supabase
|
||||
.from('push_subscriptions')
|
||||
.delete()
|
||||
.eq('user_id', user.id)
|
||||
.eq('endpoint', endpoint)
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to remove subscription' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { eventBus } from '@/lib/events/bus'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import type { ConfirmReceiptInput, Receipt, ReceiptLineItem } from '@/types'
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
/**
|
||||
* POST /api/receipts/[id]/confirm
|
||||
* Confirm line item classifications and optionally link to a transaction
|
||||
*/
|
||||
export async function POST(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const supabase = await createClient()
|
||||
const { id } = await params
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Verify receipt ownership
|
||||
const { data: receipt, error: fetchError } = await supabase
|
||||
.from('receipts')
|
||||
.select('*')
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (fetchError || !receipt) {
|
||||
return NextResponse.json({ error: 'Receipt not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const body: ConfirmReceiptInput = await request.json()
|
||||
|
||||
// Update line items with classifications
|
||||
if (body.line_items && body.line_items.length > 0) {
|
||||
for (const item of body.line_items) {
|
||||
const { error: updateError } = await supabase
|
||||
.from('receipt_line_items')
|
||||
.update({
|
||||
is_business: item.is_business,
|
||||
category: item.category || null,
|
||||
bas_account: item.bas_account || null,
|
||||
})
|
||||
.eq('id', item.id)
|
||||
.eq('receipt_id', id)
|
||||
|
||||
if (updateError) {
|
||||
console.error('Line item update error:', updateError)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build receipt update
|
||||
const receiptUpdate: Record<string, unknown> = {
|
||||
status: 'confirmed',
|
||||
}
|
||||
|
||||
// Add restaurant representation data if provided
|
||||
if (body.representation_persons !== undefined) {
|
||||
receiptUpdate.representation_persons = body.representation_persons
|
||||
}
|
||||
if (body.representation_purpose !== undefined) {
|
||||
receiptUpdate.representation_purpose = body.representation_purpose
|
||||
}
|
||||
|
||||
// Link to transaction if provided
|
||||
if (body.matched_transaction_id) {
|
||||
// Verify transaction ownership
|
||||
const { data: transaction, error: txError } = await supabase
|
||||
.from('transactions')
|
||||
.select('id')
|
||||
.eq('id', body.matched_transaction_id)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (!txError && transaction) {
|
||||
receiptUpdate.matched_transaction_id = body.matched_transaction_id
|
||||
|
||||
// Also update the transaction with the receipt link
|
||||
await supabase
|
||||
.from('transactions')
|
||||
.update({ receipt_id: id })
|
||||
.eq('id', body.matched_transaction_id)
|
||||
}
|
||||
}
|
||||
|
||||
// Update the receipt
|
||||
const { data: updatedReceipt, error: updateError } = await supabase
|
||||
.from('receipts')
|
||||
.update(receiptUpdate)
|
||||
.eq('id', id)
|
||||
.select(`
|
||||
*,
|
||||
line_items:receipt_line_items(*)
|
||||
`)
|
||||
.single()
|
||||
|
||||
if (updateError) {
|
||||
console.error('Receipt update error:', updateError)
|
||||
return NextResponse.json({ error: 'Failed to update receipt' }, { status: 500 })
|
||||
}
|
||||
|
||||
// Calculate business/private totals from line items
|
||||
const lineItems = ((updatedReceipt as unknown as Receipt).line_items || []) as ReceiptLineItem[]
|
||||
let businessTotal = 0
|
||||
let privateTotal = 0
|
||||
for (const item of lineItems) {
|
||||
if (item.is_business === true) {
|
||||
businessTotal += item.line_total
|
||||
} else if (item.is_business === false) {
|
||||
privateTotal += item.line_total
|
||||
}
|
||||
}
|
||||
|
||||
// Emit receipt.confirmed event
|
||||
await eventBus.emit({
|
||||
type: 'receipt.confirmed',
|
||||
payload: {
|
||||
receipt: updatedReceipt as unknown as Receipt,
|
||||
businessTotal: Math.round(businessTotal * 100) / 100,
|
||||
privateTotal: Math.round(privateTotal * 100) / 100,
|
||||
userId: user.id,
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json({ data: updatedReceipt })
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { findTransactionMatches } from '@/extensions/receipt-ocr/lib/receipt-matcher'
|
||||
import { eventBus } from '@/lib/events/bus'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import type { Receipt, Transaction } from '@/types'
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
/**
|
||||
* POST /api/receipts/[id]/match
|
||||
* Find potential transaction matches for a receipt
|
||||
*/
|
||||
export async function POST(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const supabase = await createClient()
|
||||
const { id } = await params
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Fetch receipt
|
||||
const { data: receipt, error: receiptError } = await supabase
|
||||
.from('receipts')
|
||||
.select('*')
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (receiptError || !receipt) {
|
||||
return NextResponse.json({ error: 'Receipt not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Get date range for transaction search (±7 days from receipt date)
|
||||
const receiptDate = receipt.receipt_date ? new Date(receipt.receipt_date) : new Date()
|
||||
const startDate = new Date(receiptDate)
|
||||
startDate.setDate(startDate.getDate() - 7)
|
||||
const endDate = new Date(receiptDate)
|
||||
endDate.setDate(endDate.getDate() + 7)
|
||||
|
||||
// Fetch unmatched transactions in date range
|
||||
const { data: transactions, error: txError } = await supabase
|
||||
.from('transactions')
|
||||
.select('*')
|
||||
.eq('user_id', user.id)
|
||||
.is('receipt_id', null)
|
||||
.lt('amount', 0) // Only expenses
|
||||
.gte('date', startDate.toISOString().split('T')[0])
|
||||
.lte('date', endDate.toISOString().split('T')[0])
|
||||
.order('date', { ascending: false })
|
||||
|
||||
if (txError) {
|
||||
console.error('Transaction fetch error:', txError)
|
||||
return NextResponse.json({ error: 'Failed to fetch transactions' }, { status: 500 })
|
||||
}
|
||||
|
||||
// Find matches
|
||||
const matches = findTransactionMatches(
|
||||
receipt as unknown as Receipt,
|
||||
transactions as Transaction[]
|
||||
)
|
||||
|
||||
return NextResponse.json({
|
||||
data: {
|
||||
receipt_id: id,
|
||||
matches: matches.slice(0, 5), // Return top 5 matches
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH /api/receipts/[id]/match
|
||||
* Link a receipt to a specific transaction
|
||||
*/
|
||||
export async function PATCH(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const supabase = await createClient()
|
||||
const { id } = await params
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const { transaction_id, match_confidence } = body
|
||||
|
||||
if (!transaction_id) {
|
||||
return NextResponse.json({ error: 'transaction_id is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Verify receipt ownership
|
||||
const { data: receipt, error: receiptError } = await supabase
|
||||
.from('receipts')
|
||||
.select('*, line_items:receipt_line_items(*)')
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (receiptError || !receipt) {
|
||||
return NextResponse.json({ error: 'Receipt not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Verify transaction ownership
|
||||
const { data: transaction, error: txError } = await supabase
|
||||
.from('transactions')
|
||||
.select('*')
|
||||
.eq('id', transaction_id)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (txError || !transaction) {
|
||||
return NextResponse.json({ error: 'Transaction not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Update receipt with match
|
||||
const { error: updateReceiptError } = await supabase
|
||||
.from('receipts')
|
||||
.update({
|
||||
matched_transaction_id: transaction_id,
|
||||
match_confidence: match_confidence || null,
|
||||
})
|
||||
.eq('id', id)
|
||||
|
||||
if (updateReceiptError) {
|
||||
console.error('Receipt update error:', updateReceiptError)
|
||||
return NextResponse.json({ error: 'Failed to update receipt' }, { status: 500 })
|
||||
}
|
||||
|
||||
// Update transaction with receipt link
|
||||
const { error: updateTxError } = await supabase
|
||||
.from('transactions')
|
||||
.update({ receipt_id: id })
|
||||
.eq('id', transaction_id)
|
||||
|
||||
if (updateTxError) {
|
||||
console.error('Transaction update error:', updateTxError)
|
||||
}
|
||||
|
||||
// Emit receipt.matched event
|
||||
await eventBus.emit({
|
||||
type: 'receipt.matched',
|
||||
payload: {
|
||||
receipt: receipt as unknown as Receipt,
|
||||
transaction: transaction as Transaction,
|
||||
confidence: match_confidence || 0,
|
||||
autoMatched: false,
|
||||
userId: user.id,
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
data: {
|
||||
receipt_id: id,
|
||||
transaction_id,
|
||||
matched: true,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/receipts/[id]/match
|
||||
* Unlink a receipt from its matched transaction
|
||||
*/
|
||||
export async function DELETE(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const supabase = await createClient()
|
||||
const { id } = await params
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Verify receipt ownership and get current transaction link
|
||||
const { data: receipt, error: receiptError } = await supabase
|
||||
.from('receipts')
|
||||
.select('id, matched_transaction_id')
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (receiptError || !receipt) {
|
||||
return NextResponse.json({ error: 'Receipt not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const transactionId = receipt.matched_transaction_id
|
||||
|
||||
// Remove match from receipt
|
||||
const { error: updateReceiptError } = await supabase
|
||||
.from('receipts')
|
||||
.update({
|
||||
matched_transaction_id: null,
|
||||
match_confidence: null,
|
||||
})
|
||||
.eq('id', id)
|
||||
|
||||
if (updateReceiptError) {
|
||||
console.error('Receipt update error:', updateReceiptError)
|
||||
return NextResponse.json({ error: 'Failed to update receipt' }, { status: 500 })
|
||||
}
|
||||
|
||||
// Remove receipt link from transaction
|
||||
if (transactionId) {
|
||||
await supabase
|
||||
.from('transactions')
|
||||
.update({ receipt_id: null })
|
||||
.eq('id', transactionId)
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
data: {
|
||||
receipt_id: id,
|
||||
unmatched: true,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
|
||||
/**
|
||||
* GET /api/receipts/[id]
|
||||
* Get a single receipt with line items
|
||||
*/
|
||||
export async function GET(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const supabase = await createClient()
|
||||
const { id } = await params
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('receipts')
|
||||
.select(`
|
||||
*,
|
||||
line_items:receipt_line_items(*),
|
||||
matched_transaction:transactions(*)
|
||||
`)
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
if (error.code === 'PGRST116') {
|
||||
return NextResponse.json({ error: 'Receipt not found' }, { status: 404 })
|
||||
}
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ data })
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH /api/receipts/[id]
|
||||
* Update a receipt
|
||||
*/
|
||||
export async function PATCH(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const supabase = await createClient()
|
||||
const { id } = await params
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
|
||||
// Allowed update fields
|
||||
const allowedFields = [
|
||||
'merchant_name',
|
||||
'receipt_date',
|
||||
'receipt_time',
|
||||
'total_amount',
|
||||
'currency',
|
||||
'vat_amount',
|
||||
'is_restaurant',
|
||||
'is_systembolaget',
|
||||
'is_foreign_merchant',
|
||||
'representation_persons',
|
||||
'representation_purpose',
|
||||
'status',
|
||||
]
|
||||
|
||||
const updates: Record<string, unknown> = {}
|
||||
for (const field of allowedFields) {
|
||||
if (body[field] !== undefined) {
|
||||
updates[field] = body[field]
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(updates).length === 0) {
|
||||
return NextResponse.json({ error: 'No valid fields to update' }, { status: 400 })
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('receipts')
|
||||
.update(updates)
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
.select(`
|
||||
*,
|
||||
line_items:receipt_line_items(*)
|
||||
`)
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
if (error.code === 'PGRST116') {
|
||||
return NextResponse.json({ error: 'Receipt not found' }, { status: 404 })
|
||||
}
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ data })
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/receipts/[id]
|
||||
* Delete a receipt and its line items
|
||||
*/
|
||||
export async function DELETE(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const supabase = await createClient()
|
||||
const { id } = await params
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Get receipt to find image URL for cleanup
|
||||
const { data: receipt } = await supabase
|
||||
.from('receipts')
|
||||
.select('image_url, matched_transaction_id')
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (!receipt) {
|
||||
return NextResponse.json({ error: 'Receipt not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Unlink from transaction if matched
|
||||
if (receipt.matched_transaction_id) {
|
||||
await supabase
|
||||
.from('transactions')
|
||||
.update({ receipt_id: null })
|
||||
.eq('id', receipt.matched_transaction_id)
|
||||
}
|
||||
|
||||
// Delete receipt (line items are cascade deleted)
|
||||
const { error } = await supabase
|
||||
.from('receipts')
|
||||
.delete()
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
// Optionally delete image from storage
|
||||
if (receipt.image_url) {
|
||||
try {
|
||||
const urlParts = receipt.image_url.split('/receipts/')
|
||||
if (urlParts[1]) {
|
||||
await supabase.storage.from('receipts').remove([urlParts[1]])
|
||||
}
|
||||
} catch {
|
||||
// Ignore storage cleanup errors
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
|
||||
/**
|
||||
* GET /api/receipts/queue
|
||||
* Get unmatched receipts and queue statistics
|
||||
*/
|
||||
export async function GET(request: Request) {
|
||||
const supabase = await createClient()
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Parse query params
|
||||
const { searchParams } = new URL(request.url)
|
||||
const status = searchParams.get('status') // 'pending', 'extracted', 'confirmed', or null for all
|
||||
const unmatched = searchParams.get('unmatched') === 'true'
|
||||
|
||||
// Build query
|
||||
let query = supabase
|
||||
.from('receipts')
|
||||
.select(`
|
||||
*,
|
||||
line_items:receipt_line_items(*)
|
||||
`)
|
||||
.eq('user_id', user.id)
|
||||
.order('created_at', { ascending: false })
|
||||
|
||||
if (status) {
|
||||
query = query.eq('status', status)
|
||||
}
|
||||
|
||||
if (unmatched) {
|
||||
query = query.is('matched_transaction_id', null)
|
||||
}
|
||||
|
||||
const { data: receipts, error: receiptsError } = await query
|
||||
|
||||
if (receiptsError) {
|
||||
console.error('Receipts fetch error:', receiptsError)
|
||||
return NextResponse.json({ error: 'Failed to fetch receipts' }, { status: 500 })
|
||||
}
|
||||
|
||||
// Get counts for queue summary
|
||||
const { count: unmatchedReceiptsCount } = await supabase
|
||||
.from('receipts')
|
||||
.select('*', { count: 'exact', head: true })
|
||||
.eq('user_id', user.id)
|
||||
.eq('status', 'confirmed')
|
||||
.is('matched_transaction_id', null)
|
||||
|
||||
const { count: pendingReviewCount } = await supabase
|
||||
.from('receipts')
|
||||
.select('*', { count: 'exact', head: true })
|
||||
.eq('user_id', user.id)
|
||||
.eq('status', 'extracted')
|
||||
|
||||
const { count: unmatchedTransactionsCount } = await supabase
|
||||
.from('transactions')
|
||||
.select('*', { count: 'exact', head: true })
|
||||
.eq('user_id', user.id)
|
||||
.lt('amount', 0)
|
||||
.is('receipt_id', null)
|
||||
|
||||
// Calculate streak (days with at least one categorized transaction)
|
||||
const { data: recentActivity } = await supabase
|
||||
.from('receipts')
|
||||
.select('created_at')
|
||||
.eq('user_id', user.id)
|
||||
.eq('status', 'confirmed')
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(30)
|
||||
|
||||
let streakCount = 0
|
||||
if (recentActivity && recentActivity.length > 0) {
|
||||
const today = new Date()
|
||||
today.setHours(0, 0, 0, 0)
|
||||
|
||||
const activityDates = new Set(
|
||||
recentActivity.map((r) => new Date(r.created_at).toISOString().split('T')[0])
|
||||
)
|
||||
|
||||
let checkDate = new Date(today)
|
||||
while (activityDates.has(checkDate.toISOString().split('T')[0])) {
|
||||
streakCount++
|
||||
checkDate.setDate(checkDate.getDate() - 1)
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
data: {
|
||||
receipts,
|
||||
summary: {
|
||||
unmatched_receipts_count: unmatchedReceiptsCount || 0,
|
||||
unmatched_transactions_count: unmatchedTransactionsCount || 0,
|
||||
pending_review_count: pendingReviewCount || 0,
|
||||
streak_count: streakCount,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
|
||||
/**
|
||||
* GET /api/receipts
|
||||
* List receipts for the authenticated user
|
||||
* Query params:
|
||||
* - status: filter by status
|
||||
* - limit: max results (default 50)
|
||||
* - offset: pagination offset
|
||||
*/
|
||||
export async function GET(request: Request) {
|
||||
const supabase = await createClient()
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url)
|
||||
const status = searchParams.get('status')
|
||||
const limit = parseInt(searchParams.get('limit') || '50', 10)
|
||||
const offset = parseInt(searchParams.get('offset') || '0', 10)
|
||||
|
||||
let query = supabase
|
||||
.from('receipts')
|
||||
.select(`
|
||||
*,
|
||||
line_items:receipt_line_items(*)
|
||||
`, { count: 'exact' })
|
||||
.eq('user_id', user.id)
|
||||
.order('created_at', { ascending: false })
|
||||
.range(offset, offset + limit - 1)
|
||||
|
||||
if (status) {
|
||||
query = query.eq('status', status)
|
||||
}
|
||||
|
||||
const { data, error, count } = await query
|
||||
|
||||
if (error) {
|
||||
console.error('Receipts fetch error:', error)
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
data,
|
||||
count,
|
||||
limit,
|
||||
offset,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { analyzeReceipt } from '@/extensions/receipt-ocr/lib/receipt-analyzer'
|
||||
import { processLineItems } from '@/extensions/receipt-ocr/lib/receipt-categorizer'
|
||||
import { eventBus } from '@/lib/events/bus'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
/**
|
||||
* POST /api/receipts/upload
|
||||
* Upload a receipt image and extract data using Claude Vision
|
||||
*
|
||||
* Accepts multipart/form-data with:
|
||||
* - image: The receipt image file
|
||||
*/
|
||||
export async function POST(request: Request) {
|
||||
const supabase = await createClient()
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
try {
|
||||
const formData = await request.formData()
|
||||
const imageFile = formData.get('image') as File | null
|
||||
|
||||
if (!imageFile) {
|
||||
return NextResponse.json({ error: 'No image file provided' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Validate file type
|
||||
const validTypes = ['image/jpeg', 'image/png', 'image/webp', 'image/gif']
|
||||
if (!validTypes.includes(imageFile.type)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid file type. Supported: JPEG, PNG, WebP, GIF' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Convert file to base64
|
||||
const arrayBuffer = await imageFile.arrayBuffer()
|
||||
const base64 = Buffer.from(arrayBuffer).toString('base64')
|
||||
|
||||
// Generate unique filename
|
||||
const ext = imageFile.type.split('/')[1]
|
||||
const filename = `${user.id}/${Date.now()}-${Math.random().toString(36).slice(2)}.${ext}`
|
||||
|
||||
// Upload to Supabase Storage
|
||||
const { data: uploadData, error: uploadError } = await supabase.storage
|
||||
.from('receipts')
|
||||
.upload(filename, arrayBuffer, {
|
||||
contentType: imageFile.type,
|
||||
cacheControl: '3600',
|
||||
})
|
||||
|
||||
if (uploadError) {
|
||||
console.error('Storage upload error:', uploadError)
|
||||
return NextResponse.json({ error: 'Failed to upload image' }, { status: 500 })
|
||||
}
|
||||
|
||||
// Get public URL
|
||||
const { data: urlData } = supabase.storage.from('receipts').getPublicUrl(filename)
|
||||
const imageUrl = urlData.publicUrl
|
||||
|
||||
// Create receipt record with pending status
|
||||
const { data: receipt, error: insertError } = await supabase
|
||||
.from('receipts')
|
||||
.insert({
|
||||
user_id: user.id,
|
||||
image_url: imageUrl,
|
||||
status: 'processing',
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (insertError) {
|
||||
console.error('Receipt insert error:', insertError)
|
||||
return NextResponse.json({ error: 'Failed to create receipt record' }, { status: 500 })
|
||||
}
|
||||
|
||||
// Analyze receipt with Claude Vision
|
||||
try {
|
||||
const mimeType = imageFile.type as 'image/jpeg' | 'image/png' | 'image/webp' | 'image/gif'
|
||||
const extraction = await analyzeReceipt(base64, mimeType)
|
||||
|
||||
// Process and categorize line items
|
||||
const processedLineItems = processLineItems(extraction.lineItems)
|
||||
|
||||
// Update receipt with extracted data
|
||||
const { error: updateError } = await supabase
|
||||
.from('receipts')
|
||||
.update({
|
||||
status: 'extracted',
|
||||
extraction_confidence: extraction.confidence,
|
||||
merchant_name: extraction.merchant.name,
|
||||
merchant_org_number: extraction.merchant.orgNumber,
|
||||
merchant_vat_number: extraction.merchant.vatNumber,
|
||||
receipt_date: extraction.receipt.date,
|
||||
receipt_time: extraction.receipt.time,
|
||||
total_amount: extraction.totals.total,
|
||||
currency: extraction.receipt.currency,
|
||||
vat_amount: extraction.totals.vatAmount,
|
||||
is_restaurant: extraction.flags.isRestaurant,
|
||||
is_systembolaget: extraction.flags.isSystembolaget,
|
||||
is_foreign_merchant: extraction.flags.isForeignMerchant,
|
||||
raw_extraction: extraction,
|
||||
})
|
||||
.eq('id', receipt.id)
|
||||
|
||||
if (updateError) {
|
||||
console.error('Receipt update error:', updateError)
|
||||
}
|
||||
|
||||
// Insert line items
|
||||
if (processedLineItems.length > 0) {
|
||||
const lineItemsToInsert = processedLineItems.map((item, index) => ({
|
||||
receipt_id: receipt.id,
|
||||
description: item.description,
|
||||
quantity: item.quantity,
|
||||
unit_price: item.unitPrice,
|
||||
line_total: item.lineTotal,
|
||||
vat_rate: item.vatRate,
|
||||
vat_amount: item.vatRate && item.lineTotal ? (item.lineTotal * item.vatRate) / (100 + item.vatRate) : null,
|
||||
extraction_confidence: item.confidence,
|
||||
suggested_category: item.suggestedCategory,
|
||||
sort_order: index,
|
||||
}))
|
||||
|
||||
const { error: lineItemsError } = await supabase
|
||||
.from('receipt_line_items')
|
||||
.insert(lineItemsToInsert)
|
||||
|
||||
if (lineItemsError) {
|
||||
console.error('Line items insert error:', lineItemsError)
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch the complete receipt with line items
|
||||
const { data: completeReceipt, error: fetchError } = await supabase
|
||||
.from('receipts')
|
||||
.select(`
|
||||
*,
|
||||
line_items:receipt_line_items(*)
|
||||
`)
|
||||
.eq('id', receipt.id)
|
||||
.single()
|
||||
|
||||
if (fetchError) {
|
||||
console.error('Fetch error:', fetchError)
|
||||
return NextResponse.json({
|
||||
data: {
|
||||
id: receipt.id,
|
||||
status: 'extracted',
|
||||
extraction,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Emit receipt.extracted event
|
||||
await eventBus.emit({
|
||||
type: 'receipt.extracted',
|
||||
payload: {
|
||||
receipt: completeReceipt,
|
||||
documentId: null,
|
||||
confidence: extraction.confidence,
|
||||
userId: user.id,
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json({ data: completeReceipt })
|
||||
} catch (analysisError) {
|
||||
console.error('Receipt analysis error:', analysisError)
|
||||
|
||||
// Update receipt with error status
|
||||
await supabase
|
||||
.from('receipts')
|
||||
.update({
|
||||
status: 'error',
|
||||
raw_extraction: {
|
||||
error: analysisError instanceof Error ? analysisError.message : 'Unknown error',
|
||||
},
|
||||
})
|
||||
.eq('id', receipt.id)
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Failed to analyze receipt',
|
||||
receiptId: receipt.id,
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Upload error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : 'Upload failed' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getSRUCoverage } from '@/extensions/sru-export/sru-engine'
|
||||
|
||||
/**
|
||||
* GET /api/extensions/sru-export/coverage
|
||||
*
|
||||
* Returns SRU code coverage stats: how many accounts have SRU codes vs total,
|
||||
* and a list of accounts missing codes.
|
||||
*/
|
||||
export async function GET() {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
try {
|
||||
const coverage = await getSRUCoverage(user.id)
|
||||
return NextResponse.json({ data: coverage })
|
||||
} catch (err) {
|
||||
console.error('Error fetching SRU coverage:', err)
|
||||
return NextResponse.json(
|
||||
{ error: err instanceof Error ? err.message : 'Failed to fetch SRU coverage' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { aggregateBalancesBySRU } from '@/extensions/sru-export/sru-engine'
|
||||
import {
|
||||
generateGenericSRU,
|
||||
getGenericSRUFilename,
|
||||
sruFileToString,
|
||||
validateSRUFile,
|
||||
SRU_CODE_DESCRIPTIONS,
|
||||
} from '@/extensions/sru-export/sru-generator'
|
||||
import type { SRUFormType } from '@/extensions/sru-export/sru-generator'
|
||||
import type { EntityType } from '@/types'
|
||||
|
||||
/**
|
||||
* GET /api/extensions/sru-export
|
||||
*
|
||||
* Generate SRU export from chart_of_accounts sru_code mappings.
|
||||
*
|
||||
* Query parameters:
|
||||
* - period_id: Fiscal period ID (required)
|
||||
* - format: 'json' (default) or 'sru' for downloadable file
|
||||
*/
|
||||
export async function GET(request: Request) {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url)
|
||||
const periodId = searchParams.get('period_id')
|
||||
const format = searchParams.get('format') || 'json'
|
||||
|
||||
if (!periodId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'period_id is required' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
// Fetch company settings to determine entity type
|
||||
const { data: settings } = await supabase
|
||||
.from('company_settings')
|
||||
.select('entity_type, company_name, org_number')
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (!settings) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Company settings not found. Complete onboarding first.' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Determine form type from entity type
|
||||
const formType: SRUFormType = settings.entity_type === 'aktiebolag' ? 'INK2' : 'NE'
|
||||
|
||||
// Fetch fiscal period for date range
|
||||
const { data: period } = await supabase
|
||||
.from('fiscal_periods')
|
||||
.select('*')
|
||||
.eq('id', periodId)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (!period) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Fiscal period not found' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
// Aggregate balances by SRU code
|
||||
const sruBalances = await aggregateBalancesBySRU(user.id, periodId)
|
||||
|
||||
if (format === 'sru') {
|
||||
// Generate and return SRU file
|
||||
const sruFile = generateGenericSRU({
|
||||
formType,
|
||||
orgNumber: settings.org_number,
|
||||
companyName: settings.company_name || 'Okänt företag',
|
||||
fiscalYearStart: period.period_start,
|
||||
fiscalYearEnd: period.period_end,
|
||||
sruBalances,
|
||||
})
|
||||
|
||||
const validation = validateSRUFile(sruFile)
|
||||
if (!validation.isValid) {
|
||||
return NextResponse.json(
|
||||
{ error: 'SRU validation failed', details: validation.errors },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
const sruContent = sruFileToString(sruFile)
|
||||
const filename = getGenericSRUFilename(formType, settings.org_number, period.period_start)
|
||||
|
||||
return new NextResponse(sruContent, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'text/plain; charset=utf-8',
|
||||
'Content-Disposition': `attachment; filename="${filename}"`,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Default: return JSON preview
|
||||
const balancesArray = Array.from(sruBalances.values())
|
||||
.sort((a, b) => a.sruCode.localeCompare(b.sruCode))
|
||||
.map((b) => ({
|
||||
sruCode: b.sruCode,
|
||||
description: SRU_CODE_DESCRIPTIONS[b.sruCode] || `SRU ${b.sruCode}`,
|
||||
amount: b.amount,
|
||||
accounts: b.accounts,
|
||||
}))
|
||||
|
||||
const warnings: string[] = []
|
||||
if (!period.is_closed) {
|
||||
warnings.push('Räkenskapsåret är inte stängt. Siffrorna kan ändras.')
|
||||
}
|
||||
if (balancesArray.length === 0) {
|
||||
warnings.push('Inga konton med SRU-koder har bokförda poster i denna period.')
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
data: {
|
||||
formType,
|
||||
entityType: settings.entity_type as EntityType,
|
||||
companyName: settings.company_name,
|
||||
orgNumber: settings.org_number,
|
||||
fiscalYear: {
|
||||
id: period.id,
|
||||
name: period.name,
|
||||
start: period.period_start,
|
||||
end: period.period_end,
|
||||
},
|
||||
balances: balancesArray,
|
||||
warnings,
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('Error generating SRU export:', err)
|
||||
return NextResponse.json(
|
||||
{ error: err instanceof Error ? err.message : 'Failed to generate SRU export' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user