Initial copy from influencer-biz
This commit is contained in:
@@ -0,0 +1,151 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { createSession, getAccountBalance, type AccountInfo } from '@/lib/banking/enable-banking'
|
||||
|
||||
interface StoredAccount {
|
||||
uid: string
|
||||
iban?: string
|
||||
name?: string
|
||||
currency: string
|
||||
balance?: number
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const { searchParams } = new URL(request.url)
|
||||
|
||||
// Enable Banking returns: ?code=XXX&state=user_id or ?error=XXX&error_description=YYY
|
||||
const code = searchParams.get('code')
|
||||
const state = searchParams.get('state') // This is the user_id we passed during authorization
|
||||
const error = searchParams.get('error')
|
||||
const errorDescription = searchParams.get('error_description')
|
||||
|
||||
// Redirect URL for success/error
|
||||
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'
|
||||
|
||||
// Handle errors from bank authorization
|
||||
if (error) {
|
||||
const errorMessage = errorDescription || error
|
||||
console.error('Bank authorization error:', errorMessage)
|
||||
return NextResponse.redirect(
|
||||
`${baseUrl}/settings?bank_error=${encodeURIComponent(errorMessage)}`
|
||||
)
|
||||
}
|
||||
|
||||
if (!code || !state) {
|
||||
return NextResponse.redirect(`${baseUrl}/settings?bank_error=missing_parameters`)
|
||||
}
|
||||
|
||||
const supabase = await createClient()
|
||||
|
||||
try {
|
||||
// Create session from authorization code
|
||||
const sessionData = await createSession(code)
|
||||
|
||||
// Extract data from session response
|
||||
const { session_id, accounts, access, aspsp } = sessionData
|
||||
const consentExpiresAt = access.valid_until
|
||||
|
||||
// Get balances for each account
|
||||
const accountsWithBalances: StoredAccount[] = await Promise.all(
|
||||
accounts.map(async (account: AccountInfo) => {
|
||||
try {
|
||||
const balance = await getAccountBalance(account.uid)
|
||||
return {
|
||||
uid: account.uid,
|
||||
iban: account.account_id?.iban,
|
||||
name: account.name || account.product,
|
||||
currency: account.currency,
|
||||
balance: balance.amount,
|
||||
}
|
||||
} catch (balanceError) {
|
||||
console.error(`Failed to get balance for account ${account.uid}:`, balanceError)
|
||||
return {
|
||||
uid: account.uid,
|
||||
iban: account.account_id?.iban,
|
||||
name: account.name || account.product,
|
||||
currency: account.currency,
|
||||
balance: undefined,
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
// Find the pending connection for this user
|
||||
// We match by user_id (state) and status='pending'
|
||||
const { data: pendingConnection, error: findError } = await supabase
|
||||
.from('bank_connections')
|
||||
.select('id')
|
||||
.eq('user_id', state)
|
||||
.eq('status', 'pending')
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(1)
|
||||
.single()
|
||||
|
||||
if (findError || !pendingConnection) {
|
||||
console.error('Could not find pending connection:', findError)
|
||||
// Create a new connection if no pending one exists
|
||||
const { error: insertError } = await supabase
|
||||
.from('bank_connections')
|
||||
.insert({
|
||||
user_id: state,
|
||||
bank_id: `${aspsp.name.toLowerCase().replace(/\s+/g, '-')}-${aspsp.country.toLowerCase()}`,
|
||||
bank_name: aspsp.name,
|
||||
session_id,
|
||||
status: 'active',
|
||||
accounts: accountsWithBalances,
|
||||
consent_expires_at: consentExpiresAt,
|
||||
last_synced_at: new Date().toISOString(),
|
||||
})
|
||||
|
||||
if (insertError) {
|
||||
throw new Error('Failed to create connection')
|
||||
}
|
||||
} else {
|
||||
// Update the pending connection with session data
|
||||
const { error: updateError } = await supabase
|
||||
.from('bank_connections')
|
||||
.update({
|
||||
session_id,
|
||||
status: 'active',
|
||||
accounts: accountsWithBalances,
|
||||
consent_expires_at: consentExpiresAt,
|
||||
last_synced_at: new Date().toISOString(),
|
||||
})
|
||||
.eq('id', pendingConnection.id)
|
||||
|
||||
if (updateError) {
|
||||
throw new Error('Failed to update connection')
|
||||
}
|
||||
}
|
||||
|
||||
// Check if the user has completed onboarding to decide redirect target
|
||||
const { data: userSettings } = await supabase
|
||||
.from('company_settings')
|
||||
.select('onboarding_complete')
|
||||
.eq('user_id', state)
|
||||
.single()
|
||||
|
||||
const redirectTarget = userSettings?.onboarding_complete
|
||||
? '/settings?bank_connected=true'
|
||||
: '/onboarding?bank_connected=true'
|
||||
|
||||
return NextResponse.redirect(`${baseUrl}${redirectTarget}`)
|
||||
} catch (error) {
|
||||
console.error('Bank callback error:', error)
|
||||
|
||||
// Try to update connection status to error
|
||||
try {
|
||||
await supabase
|
||||
.from('bank_connections')
|
||||
.update({ status: 'error' })
|
||||
.eq('user_id', state)
|
||||
.eq('status', 'pending')
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
|
||||
return NextResponse.redirect(
|
||||
`${baseUrl}/settings?bank_error=${encodeURIComponent('Connection failed')}`
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { startAuthorization, getASPSPs, type ASPSP } from '@/lib/banking/enable-banking'
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const aspsps = await getASPSPs('SE')
|
||||
|
||||
// Transform to frontend-friendly format
|
||||
const banks = aspsps.map((aspsp: ASPSP) => ({
|
||||
name: aspsp.name,
|
||||
country: aspsp.country,
|
||||
logo: aspsp.logo,
|
||||
bic: aspsp.bic,
|
||||
}))
|
||||
|
||||
return NextResponse.json({ banks })
|
||||
} catch (error) {
|
||||
console.error('Error fetching banks:', error)
|
||||
// Return fallback list
|
||||
return NextResponse.json({
|
||||
banks: [
|
||||
{ name: 'Nordea', country: 'SE', bic: 'NDEASESS' },
|
||||
{ name: 'SEB', country: 'SE', bic: 'ESSESESS' },
|
||||
{ name: 'Swedbank', country: 'SE', bic: 'SWEDSESS' },
|
||||
{ name: 'Handelsbanken', country: 'SE', bic: 'HANDSESS' },
|
||||
]
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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 { aspsp_name, aspsp_country } = await request.json()
|
||||
|
||||
if (!aspsp_name || !aspsp_country) {
|
||||
return NextResponse.json(
|
||||
{ error: 'aspsp_name and aspsp_country are required' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
const redirectUrl = `${process.env.NEXT_PUBLIC_APP_URL}/api/banking/callback`
|
||||
|
||||
// Start the authorization flow with Enable Banking
|
||||
const { url, authorization_id } = await startAuthorization(
|
||||
aspsp_name,
|
||||
aspsp_country,
|
||||
redirectUrl,
|
||||
user.id, // state parameter - returned in callback
|
||||
'personal'
|
||||
)
|
||||
|
||||
// Store pending connection in database with authorization_id
|
||||
// Note: session_id will be set after callback receives the code
|
||||
const { data: connection, error } = await supabase
|
||||
.from('bank_connections')
|
||||
.insert({
|
||||
user_id: user.id,
|
||||
bank_id: `${aspsp_name.toLowerCase().replace(/\s+/g, '-')}-${aspsp_country.toLowerCase()}`,
|
||||
bank_name: aspsp_name,
|
||||
authorization_id,
|
||||
status: 'pending',
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
console.error('Database error:', error)
|
||||
throw new Error('Failed to store connection')
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
connection_id: connection.id,
|
||||
authorization_url: url,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Bank connection error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : 'Connection failed' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import { createClient } from '@supabase/supabase-js'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { syncAccountTransactions } from '@/lib/banking/sync-transactions'
|
||||
import { isConsentExpiringSoon, getDaysUntilExpiry } from '@/lib/banking/enable-banking'
|
||||
|
||||
interface StoredAccount {
|
||||
uid: string
|
||||
iban?: string
|
||||
name?: string
|
||||
currency: string
|
||||
balance?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/banking/sync/cron
|
||||
* Automatic daily bank transaction sync
|
||||
* Runs at 05:00 UTC (07:00 Swedish time)
|
||||
*
|
||||
* Processes up to 10 connections per run (Vercel Hobby 60s timeout).
|
||||
* Prioritizes connections not synced for the longest time.
|
||||
* Deduplication via external_id makes repeated runs safe.
|
||||
*/
|
||||
export async function GET(request: Request) {
|
||||
// Verify cron secret
|
||||
const authHeader = request.headers.get('authorization')
|
||||
const cronSecret = process.env.CRON_SECRET
|
||||
|
||||
if (cronSecret && authHeader !== `Bearer ${cronSecret}`) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
// Fetch active bank connections, prioritize least recently synced
|
||||
const { data: connections, error: connError } = await supabase
|
||||
.from('bank_connections')
|
||||
.select('*')
|
||||
.eq('status', 'active')
|
||||
.order('last_synced_at', { ascending: true, nullsFirst: true })
|
||||
.limit(10)
|
||||
|
||||
if (connError) {
|
||||
console.error('Failed to fetch bank connections:', connError)
|
||||
return NextResponse.json({ error: 'Failed to fetch connections' }, { status: 500 })
|
||||
}
|
||||
|
||||
if (!connections || connections.length === 0) {
|
||||
return NextResponse.json({ message: 'No active connections to sync', processed: 0 })
|
||||
}
|
||||
|
||||
const results: {
|
||||
connectionId: string
|
||||
userId: string
|
||||
bankName: string
|
||||
imported: number
|
||||
duplicates: number
|
||||
errors: number
|
||||
status: 'synced' | 'expired' | 'expiring_soon' | 'error'
|
||||
daysUntilExpiry?: number | null
|
||||
}[] = []
|
||||
|
||||
for (const connection of connections) {
|
||||
try {
|
||||
// Check consent expiry
|
||||
const daysLeft = getDaysUntilExpiry(connection.consent_expires_at)
|
||||
const isExpired = daysLeft !== null && daysLeft <= 0
|
||||
|
||||
if (isExpired) {
|
||||
// Mark as expired, skip sync
|
||||
await supabase
|
||||
.from('bank_connections')
|
||||
.update({ status: 'expired' })
|
||||
.eq('id', connection.id)
|
||||
|
||||
results.push({
|
||||
connectionId: connection.id,
|
||||
userId: connection.user_id,
|
||||
bankName: connection.bank_name,
|
||||
imported: 0,
|
||||
duplicates: 0,
|
||||
errors: 0,
|
||||
status: 'expired',
|
||||
daysUntilExpiry: 0,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
const expiringSoon = isConsentExpiringSoon(connection.consent_expires_at)
|
||||
|
||||
// Sync last 7 days (daily cron, with overlap for safety)
|
||||
const toDate = new Date().toISOString().split('T')[0]
|
||||
const fromDate = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000)
|
||||
.toISOString()
|
||||
.split('T')[0]
|
||||
|
||||
const accounts = (connection.accounts as StoredAccount[] || []).map(a => ({ ...a }))
|
||||
|
||||
let totalImported = 0
|
||||
let totalDuplicates = 0
|
||||
let totalErrors = 0
|
||||
|
||||
for (const account of accounts) {
|
||||
const result = await syncAccountTransactions(
|
||||
supabase,
|
||||
connection.user_id,
|
||||
connection.id,
|
||||
account,
|
||||
fromDate,
|
||||
toDate
|
||||
)
|
||||
|
||||
totalImported += result.imported
|
||||
totalDuplicates += result.duplicates
|
||||
totalErrors += result.errors
|
||||
}
|
||||
|
||||
// Update connection with new account balances and sync timestamp
|
||||
await supabase
|
||||
.from('bank_connections')
|
||||
.update({
|
||||
accounts,
|
||||
last_synced_at: new Date().toISOString(),
|
||||
})
|
||||
.eq('id', connection.id)
|
||||
|
||||
results.push({
|
||||
connectionId: connection.id,
|
||||
userId: connection.user_id,
|
||||
bankName: connection.bank_name,
|
||||
imported: totalImported,
|
||||
duplicates: totalDuplicates,
|
||||
errors: totalErrors,
|
||||
status: expiringSoon ? 'expiring_soon' : 'synced',
|
||||
daysUntilExpiry: daysLeft,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error(`Sync failed for connection ${connection.id}:`, error)
|
||||
results.push({
|
||||
connectionId: connection.id,
|
||||
userId: connection.user_id,
|
||||
bankName: connection.bank_name,
|
||||
imported: 0,
|
||||
duplicates: 0,
|
||||
errors: 1,
|
||||
status: 'error',
|
||||
})
|
||||
// Continue with other connections
|
||||
}
|
||||
}
|
||||
|
||||
const totalImported = results.reduce((sum, r) => sum + r.imported, 0)
|
||||
const totalExpired = results.filter(r => r.status === 'expired').length
|
||||
const totalExpiringSoon = results.filter(r => r.status === 'expiring_soon').length
|
||||
const totalFailed = results.filter(r => r.status === 'error').length
|
||||
|
||||
console.log(`[bank-sync-cron] Processed ${results.length} connections: ${totalImported} imported, ${totalExpired} expired, ${totalExpiringSoon} expiring soon, ${totalFailed} failed`)
|
||||
|
||||
return NextResponse.json({
|
||||
processed: results.length,
|
||||
totalImported,
|
||||
totalExpired,
|
||||
totalExpiringSoon,
|
||||
totalFailed,
|
||||
results,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { syncAccountTransactions } from '@/lib/banking/sync-transactions'
|
||||
|
||||
interface StoredAccount {
|
||||
uid: string
|
||||
iban?: string
|
||||
name?: string
|
||||
currency: string
|
||||
balance?: number
|
||||
}
|
||||
|
||||
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 { connection_id, days_back = 30 } = await request.json()
|
||||
|
||||
// Get the bank connection
|
||||
const { data: connection, error: connectionError } = await supabase
|
||||
.from('bank_connections')
|
||||
.select('*')
|
||||
.eq('id', connection_id)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (connectionError || !connection) {
|
||||
return NextResponse.json({ error: 'Connection not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
if (connection.status !== 'active') {
|
||||
return NextResponse.json({ error: 'Connection is not active' }, { status: 400 })
|
||||
}
|
||||
|
||||
try {
|
||||
const accounts = (connection.accounts as StoredAccount[] || []).map(a => ({ ...a }))
|
||||
|
||||
const toDate = new Date().toISOString().split('T')[0]
|
||||
const fromDate = new Date(Date.now() - days_back * 24 * 60 * 60 * 1000)
|
||||
.toISOString()
|
||||
.split('T')[0]
|
||||
|
||||
let totalImported = 0
|
||||
let totalDuplicates = 0
|
||||
|
||||
for (const account of accounts) {
|
||||
const result = await syncAccountTransactions(
|
||||
supabase,
|
||||
user.id,
|
||||
connection.id,
|
||||
account,
|
||||
fromDate,
|
||||
toDate
|
||||
)
|
||||
|
||||
totalImported += result.imported
|
||||
totalDuplicates += result.duplicates
|
||||
}
|
||||
|
||||
// Update connection with new account balances and sync timestamp
|
||||
await supabase
|
||||
.from('bank_connections')
|
||||
.update({
|
||||
accounts,
|
||||
last_synced_at: new Date().toISOString(),
|
||||
})
|
||||
.eq('id', connection.id)
|
||||
|
||||
return NextResponse.json({
|
||||
imported: totalImported,
|
||||
duplicates: totalDuplicates,
|
||||
last_synced_at: new Date().toISOString(),
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Sync error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : 'Sync failed' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
|
||||
export async function PUT(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ number: string }> }
|
||||
) {
|
||||
const { number } = await params
|
||||
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 { data, error } = await supabase
|
||||
.from('chart_of_accounts')
|
||||
.update({
|
||||
account_name: body.account_name,
|
||||
is_active: body.is_active,
|
||||
description: body.description,
|
||||
default_vat_code: body.default_vat_code,
|
||||
})
|
||||
.eq('user_id', user.id)
|
||||
.eq('account_number', number)
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ data })
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
|
||||
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 accountClass = searchParams.get('class')
|
||||
const activeOnly = searchParams.get('active') !== 'false'
|
||||
|
||||
let query = supabase
|
||||
.from('chart_of_accounts')
|
||||
.select('*')
|
||||
.eq('user_id', user.id)
|
||||
.order('sort_order')
|
||||
|
||||
if (activeOnly) {
|
||||
query = query.eq('is_active', true)
|
||||
}
|
||||
|
||||
if (accountClass) {
|
||||
query = query.eq('account_class', parseInt(accountClass))
|
||||
}
|
||||
|
||||
const { data, error } = await query
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ data })
|
||||
}
|
||||
|
||||
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 { data, error } = await supabase
|
||||
.from('chart_of_accounts')
|
||||
.insert({
|
||||
user_id: user.id,
|
||||
account_number: body.account_number,
|
||||
account_name: body.account_name,
|
||||
account_class: parseInt(body.account_number[0]),
|
||||
account_group: body.account_number.substring(0, 2),
|
||||
account_type: body.account_type,
|
||||
normal_balance: body.normal_balance,
|
||||
plan_type: body.plan_type || 'k1',
|
||||
is_system_account: false,
|
||||
description: body.description || null,
|
||||
sort_order: parseInt(body.account_number),
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ data })
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import type { CreateFiscalPeriodInput } from '@/types'
|
||||
|
||||
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 { data, error } = await supabase
|
||||
.from('fiscal_periods')
|
||||
.select('*')
|
||||
.eq('user_id', user.id)
|
||||
.order('period_start', { ascending: false })
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ data })
|
||||
}
|
||||
|
||||
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() as CreateFiscalPeriodInput
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('fiscal_periods')
|
||||
.insert({
|
||||
user_id: user.id,
|
||||
name: body.name,
|
||||
period_start: body.period_start,
|
||||
period_end: body.period_end,
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ data })
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { reverseEntry } from '@/lib/bookkeeping/engine'
|
||||
|
||||
export async function POST(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
try {
|
||||
const reversalEntry = await reverseEntry(user.id, id)
|
||||
return NextResponse.json({ data: reversalEntry })
|
||||
} catch (err) {
|
||||
return NextResponse.json(
|
||||
{ error: err instanceof Error ? err.message : 'Failed to reverse entry' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
|
||||
export async function GET(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('journal_entries')
|
||||
.select('*, lines:journal_entry_lines(*)')
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 404 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ data })
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { createJournalEntry } from '@/lib/bookkeeping/engine'
|
||||
import type { CreateJournalEntryInput } from '@/types'
|
||||
|
||||
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 status = searchParams.get('status')
|
||||
const limit = parseInt(searchParams.get('limit') || '50')
|
||||
const offset = parseInt(searchParams.get('offset') || '0')
|
||||
const dateFrom = searchParams.get('date_from')
|
||||
const dateTo = searchParams.get('date_to')
|
||||
|
||||
let query = supabase
|
||||
.from('journal_entries')
|
||||
.select('*, lines:journal_entry_lines(*)', { count: 'exact' })
|
||||
.eq('user_id', user.id)
|
||||
.order('entry_date', { ascending: false })
|
||||
.order('voucher_number', { ascending: false })
|
||||
.range(offset, offset + limit - 1)
|
||||
|
||||
if (periodId) {
|
||||
query = query.eq('fiscal_period_id', periodId)
|
||||
}
|
||||
|
||||
if (status) {
|
||||
query = query.eq('status', status)
|
||||
}
|
||||
|
||||
if (dateFrom) {
|
||||
query = query.gte('entry_date', dateFrom)
|
||||
}
|
||||
|
||||
if (dateTo) {
|
||||
query = query.lte('entry_date', dateTo)
|
||||
}
|
||||
|
||||
const { data, error, count } = await query
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ data, count })
|
||||
}
|
||||
|
||||
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() as CreateJournalEntryInput
|
||||
|
||||
try {
|
||||
const entry = await createJournalEntry(user.id, body)
|
||||
return NextResponse.json({ data: entry })
|
||||
} catch (err) {
|
||||
return NextResponse.json(
|
||||
{ error: err instanceof Error ? err.message : 'Failed to create journal entry' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { evaluateMappingRules } from '@/lib/bookkeeping/mapping-engine'
|
||||
import type { Transaction } from '@/types'
|
||||
|
||||
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()
|
||||
|
||||
// Accept either a transaction ID or raw transaction data
|
||||
let transaction: Transaction
|
||||
|
||||
if (body.transaction_id) {
|
||||
const { data, error } = await supabase
|
||||
.from('transactions')
|
||||
.select('*')
|
||||
.eq('id', body.transaction_id)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (error || !data) {
|
||||
return NextResponse.json({ error: 'Transaction not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
transaction = data as Transaction
|
||||
} else {
|
||||
transaction = body as Transaction
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await evaluateMappingRules(user.id, transaction)
|
||||
return NextResponse.json({ data: result })
|
||||
} catch (err) {
|
||||
return NextResponse.json(
|
||||
{ error: err instanceof Error ? err.message : 'Evaluation failed' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
|
||||
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 { data, error } = await supabase
|
||||
.from('mapping_rules')
|
||||
.select('*')
|
||||
.or(`user_id.eq.${user.id},user_id.is.null`)
|
||||
.eq('is_active', true)
|
||||
.order('priority')
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ data })
|
||||
}
|
||||
|
||||
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 { data, error } = await supabase
|
||||
.from('mapping_rules')
|
||||
.insert({
|
||||
user_id: user.id,
|
||||
rule_name: body.rule_name,
|
||||
rule_type: body.rule_type,
|
||||
priority: body.priority || 10,
|
||||
mcc_codes: body.mcc_codes || null,
|
||||
merchant_pattern: body.merchant_pattern || null,
|
||||
description_pattern: body.description_pattern || null,
|
||||
amount_min: body.amount_min || null,
|
||||
amount_max: body.amount_max || null,
|
||||
debit_account: body.debit_account,
|
||||
credit_account: body.credit_account,
|
||||
vat_treatment: body.vat_treatment || null,
|
||||
risk_level: body.risk_level || 'NONE',
|
||||
default_private: body.default_private || false,
|
||||
requires_review: body.requires_review || false,
|
||||
confidence_score: body.confidence_score || 0.9,
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ data })
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
|
||||
/**
|
||||
* GET /api/briefings/[id]/download
|
||||
* Get a signed URL to download a PDF briefing
|
||||
*/
|
||||
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 })
|
||||
}
|
||||
|
||||
// Get the briefing
|
||||
const { data: briefing, error: fetchError } = await supabase
|
||||
.from('briefings')
|
||||
.select('briefing_type, content, filename')
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (fetchError) {
|
||||
if (fetchError.code === 'PGRST116') {
|
||||
return NextResponse.json({ error: 'Briefing not found' }, { status: 404 })
|
||||
}
|
||||
return NextResponse.json({ error: fetchError.message }, { status: 500 })
|
||||
}
|
||||
|
||||
// Verify it's a PDF type
|
||||
if (briefing.briefing_type !== 'pdf') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Download is only available for PDF briefings' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
if (!briefing.content) {
|
||||
return NextResponse.json(
|
||||
{ error: 'No file path found for this briefing' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
// Create signed URL (valid for 1 hour)
|
||||
const { data: signedUrl, error: signError } = await supabase.storage
|
||||
.from('contracts')
|
||||
.createSignedUrl(briefing.content, 3600, {
|
||||
download: briefing.filename || 'briefing.pdf',
|
||||
})
|
||||
|
||||
if (signError) {
|
||||
return NextResponse.json({ error: signError.message }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ url: signedUrl.signedUrl })
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import type { CreateBriefingInput } from '@/types'
|
||||
|
||||
/**
|
||||
* GET /api/briefings/[id]
|
||||
* Get a single briefing
|
||||
*/
|
||||
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('briefings')
|
||||
.select('*')
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
if (error.code === 'PGRST116') {
|
||||
return NextResponse.json({ error: 'Briefing not found' }, { status: 404 })
|
||||
}
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ data })
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH /api/briefings/[id]
|
||||
* Update a briefing
|
||||
*/
|
||||
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 })
|
||||
}
|
||||
|
||||
// Verify briefing exists and belongs to user
|
||||
const { data: existing, error: fetchError } = await supabase
|
||||
.from('briefings')
|
||||
.select('*')
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (fetchError) {
|
||||
if (fetchError.code === 'PGRST116') {
|
||||
return NextResponse.json({ error: 'Briefing not found' }, { status: 404 })
|
||||
}
|
||||
return NextResponse.json({ error: fetchError.message }, { status: 500 })
|
||||
}
|
||||
|
||||
const body: Partial<CreateBriefingInput> = await request.json()
|
||||
|
||||
// Build update object
|
||||
const updateData: Record<string, unknown> = {}
|
||||
|
||||
if (body.title !== undefined) updateData.title = body.title
|
||||
if (body.content !== undefined) updateData.content = body.content
|
||||
if (body.text_content !== undefined) updateData.text_content = body.text_content
|
||||
if (body.notes !== undefined) updateData.notes = body.notes
|
||||
|
||||
// Only allow updating certain fields for PDF type
|
||||
if (existing.briefing_type === 'pdf') {
|
||||
if (body.filename !== undefined) updateData.filename = body.filename
|
||||
if (body.file_size !== undefined) updateData.file_size = body.file_size
|
||||
if (body.mime_type !== undefined) updateData.mime_type = body.mime_type
|
||||
}
|
||||
|
||||
// Update the briefing
|
||||
const { data, error } = await supabase
|
||||
.from('briefings')
|
||||
.update(updateData)
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ data })
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/briefings/[id]
|
||||
* Delete a briefing
|
||||
*/
|
||||
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 the briefing to check if we need to delete a file
|
||||
const { data: briefing, error: fetchError } = await supabase
|
||||
.from('briefings')
|
||||
.select('briefing_type, content')
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (fetchError) {
|
||||
if (fetchError.code === 'PGRST116') {
|
||||
return NextResponse.json({ error: 'Briefing not found' }, { status: 404 })
|
||||
}
|
||||
return NextResponse.json({ error: fetchError.message }, { status: 500 })
|
||||
}
|
||||
|
||||
// If it's a PDF, delete the file from storage
|
||||
if (briefing.briefing_type === 'pdf' && briefing.content) {
|
||||
await supabase.storage.from('contracts').remove([briefing.content])
|
||||
}
|
||||
|
||||
// Delete the briefing
|
||||
const { error } = await supabase
|
||||
.from('briefings')
|
||||
.delete()
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import Anthropic from '@anthropic-ai/sdk'
|
||||
|
||||
const anthropic = new Anthropic()
|
||||
|
||||
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 { text } = await request.json()
|
||||
|
||||
if (!text || typeof text !== 'string') {
|
||||
return NextResponse.json({ error: 'Text is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
try {
|
||||
const message = await anthropic.messages.create({
|
||||
model: 'claude-sonnet-4-5-20250929',
|
||||
max_tokens: 1024,
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: `Du är en assistent som hjälper influencers. Sammanfatta följande mailkonversation/text till en strukturerad briefing.
|
||||
|
||||
Formatera sammanfattningen med dessa rubriker (hoppa över de som inte nämns):
|
||||
- **Varumärke/Kund**: Vilken kund eller varumärke det gäller
|
||||
- **Vad ska göras**: Innehåll/publiceringar som förväntas
|
||||
- **Deadlines**: Datum och tidsramar
|
||||
- **Belopp**: Ersättning om nämnt
|
||||
- **Övriga detaljer**: Annat viktigt
|
||||
|
||||
Texten att sammanfatta:
|
||||
|
||||
${text}`,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const summary = message.content[0].type === 'text' ? message.content[0].text : ''
|
||||
|
||||
return NextResponse.json({ data: { summary } })
|
||||
} catch (error) {
|
||||
console.error('Briefing summarization error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to summarize briefing' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import { createClient } from '@supabase/supabase-js'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { generateCalendarFeed } from '@/lib/calendar/ics-generator'
|
||||
|
||||
/**
|
||||
* GET /api/calendar/feed/[token]
|
||||
* Returns an ICS calendar feed for the given token
|
||||
* No authentication required - the token IS the authentication
|
||||
*/
|
||||
export async function GET(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ token: string }> }
|
||||
) {
|
||||
const { token } = await params
|
||||
|
||||
// Validate token format (UUID)
|
||||
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
||||
if (!uuidRegex.test(token)) {
|
||||
return new NextResponse('Invalid token', { status: 400 })
|
||||
}
|
||||
|
||||
// Create service client (no user auth required)
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL
|
||||
const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY
|
||||
|
||||
if (!supabaseUrl || !supabaseServiceKey) {
|
||||
return new NextResponse('Server configuration error', { status: 500 })
|
||||
}
|
||||
|
||||
const supabase = createClient(supabaseUrl, supabaseServiceKey)
|
||||
|
||||
// Fetch feed settings by token
|
||||
const { data: feed, error: feedError } = await supabase
|
||||
.from('calendar_feeds')
|
||||
.select('*')
|
||||
.eq('feed_token', token)
|
||||
.eq('is_active', true)
|
||||
.single()
|
||||
|
||||
if (feedError || !feed) {
|
||||
return new NextResponse('Feed not found or inactive', { status: 404 })
|
||||
}
|
||||
|
||||
// Update access tracking
|
||||
await supabase
|
||||
.from('calendar_feeds')
|
||||
.update({
|
||||
last_accessed_at: new Date().toISOString(),
|
||||
access_count: feed.access_count + 1,
|
||||
})
|
||||
.eq('id', feed.id)
|
||||
|
||||
// Calculate date range: 3 months back, 12 months forward
|
||||
const now = new Date()
|
||||
const startDate = new Date(now)
|
||||
startDate.setMonth(startDate.getMonth() - 3)
|
||||
const endDate = new Date(now)
|
||||
endDate.setMonth(endDate.getMonth() + 12)
|
||||
|
||||
const startStr = startDate.toISOString().split('T')[0]
|
||||
const endStr = endDate.toISOString().split('T')[0]
|
||||
|
||||
// Fetch relevant data based on feed options
|
||||
const [deadlinesResult, invoicesResult, campaignsResult, exclusivitiesResult] = await Promise.all([
|
||||
// Deadlines
|
||||
(feed.include_tax_deadlines || feed.include_campaigns)
|
||||
? supabase
|
||||
.from('deadlines')
|
||||
.select('*')
|
||||
.eq('user_id', feed.user_id)
|
||||
.gte('due_date', startStr)
|
||||
.lte('due_date', endStr)
|
||||
.order('due_date')
|
||||
: { data: [] },
|
||||
|
||||
// Invoices
|
||||
feed.include_invoices
|
||||
? supabase
|
||||
.from('invoices')
|
||||
.select('*, customer:customers(*)')
|
||||
.eq('user_id', feed.user_id)
|
||||
.gte('due_date', startStr)
|
||||
.lte('due_date', endStr)
|
||||
.order('due_date')
|
||||
: { data: [] },
|
||||
|
||||
// Campaigns with deliverables
|
||||
feed.include_campaigns
|
||||
? supabase
|
||||
.from('campaigns')
|
||||
.select('*, deliverables(*)')
|
||||
.eq('user_id', feed.user_id)
|
||||
.in('status', ['active', 'contracted', 'delivered'])
|
||||
: { data: [] },
|
||||
|
||||
// Exclusivities
|
||||
feed.include_exclusivity
|
||||
? supabase
|
||||
.from('exclusivities')
|
||||
.select('*, campaign:campaigns(name)')
|
||||
.eq('user_id', feed.user_id)
|
||||
.gte('end_date', startStr)
|
||||
.lte('start_date', endStr)
|
||||
: { data: [] },
|
||||
])
|
||||
|
||||
try {
|
||||
const icsContent = await generateCalendarFeed(
|
||||
{
|
||||
deadlines: deadlinesResult.data || [],
|
||||
invoices: invoicesResult.data || [],
|
||||
campaigns: campaignsResult.data || [],
|
||||
exclusivities: exclusivitiesResult.data || [],
|
||||
},
|
||||
{
|
||||
includeTaxDeadlines: feed.include_tax_deadlines,
|
||||
includeInvoices: feed.include_invoices,
|
||||
includeCampaigns: feed.include_campaigns,
|
||||
includeExclusivity: feed.include_exclusivity,
|
||||
}
|
||||
)
|
||||
|
||||
return new NextResponse(icsContent, {
|
||||
headers: {
|
||||
'Content-Type': 'text/calendar; charset=utf-8',
|
||||
'Content-Disposition': 'attachment; filename="influencer-biz.ics"',
|
||||
'Cache-Control': 'no-cache, no-store, must-revalidate',
|
||||
'Pragma': 'no-cache',
|
||||
'Expires': '0',
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error generating ICS feed:', error)
|
||||
return new NextResponse('Failed to generate calendar feed', { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import type { UpdateCalendarFeedInput } from '@/types'
|
||||
|
||||
/**
|
||||
* GET /api/calendar/feed
|
||||
* Get current user's calendar feed 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 { data: feed, error } = await supabase
|
||||
.from('calendar_feeds')
|
||||
.select('*')
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (error && error.code !== 'PGRST116') {
|
||||
// PGRST116 = no rows returned, which is fine
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
// Generate the feed URL
|
||||
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://app.influencer-biz.se'
|
||||
|
||||
if (feed) {
|
||||
return NextResponse.json({
|
||||
data: {
|
||||
...feed,
|
||||
// Generate webcal:// URL for Apple Calendar
|
||||
webcalUrl: `webcal://${baseUrl.replace(/^https?:\/\//, '')}/api/calendar/feed/${feed.feed_token}`,
|
||||
// Generate https:// URL for other calendars
|
||||
httpsUrl: `${baseUrl}/api/calendar/feed/${feed.feed_token}`,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return NextResponse.json({ data: null })
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/calendar/feed
|
||||
* Create a new calendar feed for the current user
|
||||
*/
|
||||
export async function POST() {
|
||||
const supabase = await createClient()
|
||||
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Check if feed already exists
|
||||
const { data: existingFeed } = await supabase
|
||||
.from('calendar_feeds')
|
||||
.select('id')
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (existingFeed) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Calendar feed already exists' },
|
||||
{ status: 409 }
|
||||
)
|
||||
}
|
||||
|
||||
// Create new feed
|
||||
const { data: feed, error } = await supabase
|
||||
.from('calendar_feeds')
|
||||
.insert({
|
||||
user_id: user.id,
|
||||
is_active: true,
|
||||
include_tax_deadlines: true,
|
||||
include_invoices: true,
|
||||
include_campaigns: true,
|
||||
include_exclusivity: false, // Avstängt som standard
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://app.influencer-biz.se'
|
||||
|
||||
return NextResponse.json({
|
||||
data: {
|
||||
...feed,
|
||||
webcalUrl: `webcal://${baseUrl.replace(/^https?:\/\//, '')}/api/calendar/feed/${feed.feed_token}`,
|
||||
httpsUrl: `${baseUrl}/api/calendar/feed/${feed.feed_token}`,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT /api/calendar/feed
|
||||
* Update calendar feed settings
|
||||
*/
|
||||
export async function PUT(request: Request) {
|
||||
const supabase = await createClient()
|
||||
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body: UpdateCalendarFeedInput = await request.json()
|
||||
|
||||
const { data: feed, error } = await supabase
|
||||
.from('calendar_feeds')
|
||||
.update(body)
|
||||
.eq('user_id', user.id)
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://app.influencer-biz.se'
|
||||
|
||||
return NextResponse.json({
|
||||
data: {
|
||||
...feed,
|
||||
webcalUrl: `webcal://${baseUrl.replace(/^https?:\/\//, '')}/api/calendar/feed/${feed.feed_token}`,
|
||||
httpsUrl: `${baseUrl}/api/calendar/feed/${feed.feed_token}`,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/calendar/feed
|
||||
* Regenerate calendar feed token (invalidates old URL)
|
||||
*/
|
||||
export async function DELETE() {
|
||||
const supabase = await createClient()
|
||||
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Generate a new token by updating with a new UUID
|
||||
const { data: feed, error } = await supabase
|
||||
.from('calendar_feeds')
|
||||
.update({
|
||||
feed_token: crypto.randomUUID(),
|
||||
access_count: 0,
|
||||
last_accessed_at: null,
|
||||
})
|
||||
.eq('user_id', user.id)
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://app.influencer-biz.se'
|
||||
|
||||
return NextResponse.json({
|
||||
data: {
|
||||
...feed,
|
||||
webcalUrl: `webcal://${baseUrl.replace(/^https?:\/\//, '')}/api/calendar/feed/${feed.feed_token}`,
|
||||
httpsUrl: `${baseUrl}/api/calendar/feed/${feed.feed_token}`,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import type { CreateBriefingInput, BriefingType } from '@/types'
|
||||
|
||||
/**
|
||||
* GET /api/campaigns/[id]/briefings
|
||||
* List all briefings for a campaign
|
||||
*/
|
||||
export async function GET(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const supabase = await createClient()
|
||||
const { id: campaignId } = await params
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Verify campaign belongs to user
|
||||
const { data: campaign, error: campaignError } = await supabase
|
||||
.from('campaigns')
|
||||
.select('id')
|
||||
.eq('id', campaignId)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (campaignError || !campaign) {
|
||||
return NextResponse.json({ error: 'Campaign not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Fetch briefings
|
||||
const { data, error } = await supabase
|
||||
.from('briefings')
|
||||
.select('*')
|
||||
.eq('campaign_id', campaignId)
|
||||
.eq('user_id', user.id)
|
||||
.order('created_at', { ascending: false })
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ data })
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/campaigns/[id]/briefings
|
||||
* Create a new briefing for a campaign
|
||||
*/
|
||||
export async function POST(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const supabase = await createClient()
|
||||
const { id: campaignId } = await params
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Verify campaign belongs to user
|
||||
const { data: campaign, error: campaignError } = await supabase
|
||||
.from('campaigns')
|
||||
.select('id')
|
||||
.eq('id', campaignId)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (campaignError || !campaign) {
|
||||
return NextResponse.json({ error: 'Campaign not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const body: Omit<CreateBriefingInput, 'campaign_id'> = await request.json()
|
||||
|
||||
// Validate required fields
|
||||
if (!body.title || !body.briefing_type) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Title and briefing_type are required' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Validate briefing type
|
||||
const validTypes: BriefingType[] = ['pdf', 'link', 'text']
|
||||
if (!validTypes.includes(body.briefing_type)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid briefing_type. Must be pdf, link, or text' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Validate type-specific content
|
||||
if (body.briefing_type === 'text' && !body.text_content) {
|
||||
return NextResponse.json(
|
||||
{ error: 'text_content is required for text type briefings' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
if (body.briefing_type === 'link' && !body.content) {
|
||||
return NextResponse.json(
|
||||
{ error: 'content (URL) is required for link type briefings' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
if (body.briefing_type === 'pdf' && !body.content) {
|
||||
return NextResponse.json(
|
||||
{ error: 'content (file path) is required for pdf type briefings' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Create the briefing
|
||||
const { data, error } = await supabase
|
||||
.from('briefings')
|
||||
.insert({
|
||||
campaign_id: campaignId,
|
||||
user_id: user.id,
|
||||
briefing_type: body.briefing_type,
|
||||
title: body.title,
|
||||
content: body.content || null,
|
||||
text_content: body.text_content || null,
|
||||
filename: body.filename || null,
|
||||
file_size: body.file_size || null,
|
||||
mime_type: body.mime_type || null,
|
||||
notes: body.notes || null,
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ data }, { status: 201 })
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
|
||||
/**
|
||||
* POST /api/campaigns/[id]/briefings/upload
|
||||
* Upload a PDF briefing file
|
||||
* Expects multipart/form-data with:
|
||||
* - file: the PDF file
|
||||
* - title: briefing title
|
||||
* - notes: optional notes
|
||||
*/
|
||||
export async function POST(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const supabase = await createClient()
|
||||
const { id: campaignId } = await params
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Verify campaign exists and belongs to user
|
||||
const { data: campaign, error: campaignError } = await supabase
|
||||
.from('campaigns')
|
||||
.select('id')
|
||||
.eq('id', campaignId)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (campaignError || !campaign) {
|
||||
return NextResponse.json({ error: 'Campaign not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
try {
|
||||
const formData = await request.formData()
|
||||
const file = formData.get('file') as File | null
|
||||
const title = formData.get('title') as string | null
|
||||
const notes = formData.get('notes') as string | null
|
||||
|
||||
if (!file) {
|
||||
return NextResponse.json({ error: 'No file provided' }, { status: 400 })
|
||||
}
|
||||
|
||||
if (!title) {
|
||||
return NextResponse.json({ error: 'Title is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Validate file type (only PDF)
|
||||
if (file.type !== 'application/pdf') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid file type. Only PDF files are allowed.' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Max file size: 10MB
|
||||
const maxSize = 10 * 1024 * 1024
|
||||
if (file.size > maxSize) {
|
||||
return NextResponse.json(
|
||||
{ error: 'File too large. Max size: 10MB' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Generate unique file path in contracts bucket (reusing existing bucket)
|
||||
// Path: {user_id}/{campaign_id}/briefings/{timestamp}_{filename}
|
||||
const timestamp = Date.now()
|
||||
const safeFilename = file.name.replace(/[^a-zA-Z0-9.-]/g, '_')
|
||||
const filePath = `${user.id}/${campaignId}/briefings/${timestamp}_${safeFilename}`
|
||||
|
||||
// Upload to Supabase Storage (using contracts bucket)
|
||||
const { error: uploadError } = await supabase.storage
|
||||
.from('contracts')
|
||||
.upload(filePath, file, {
|
||||
cacheControl: '3600',
|
||||
upsert: false,
|
||||
})
|
||||
|
||||
if (uploadError) {
|
||||
console.error('Upload error:', uploadError)
|
||||
return NextResponse.json({ error: 'Failed to upload file' }, { status: 500 })
|
||||
}
|
||||
|
||||
// Create briefing record
|
||||
const { data, error } = await supabase
|
||||
.from('briefings')
|
||||
.insert({
|
||||
user_id: user.id,
|
||||
campaign_id: campaignId,
|
||||
briefing_type: 'pdf',
|
||||
title: title.trim(),
|
||||
content: filePath,
|
||||
filename: file.name,
|
||||
file_size: file.size,
|
||||
mime_type: file.type,
|
||||
notes: notes?.trim() || null,
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
// Try to clean up uploaded file
|
||||
await supabase.storage.from('contracts').remove([filePath])
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ data }, { status: 201 })
|
||||
} catch (err) {
|
||||
console.error('Briefing upload error:', err)
|
||||
return NextResponse.json({ error: 'Failed to process upload' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
|
||||
/**
|
||||
* GET /api/campaigns/[id]/contracts
|
||||
* List contracts for a campaign
|
||||
*/
|
||||
export async function GET(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const supabase = await createClient()
|
||||
const { id: campaignId } = await params
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Verify campaign exists and belongs to user
|
||||
const { data: campaign, error: campaignError } = await supabase
|
||||
.from('campaigns')
|
||||
.select('id')
|
||||
.eq('id', campaignId)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (campaignError || !campaign) {
|
||||
return NextResponse.json({ error: 'Campaign not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('contracts')
|
||||
.select('*')
|
||||
.eq('campaign_id', campaignId)
|
||||
.order('uploaded_at', { ascending: false })
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ data })
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/campaigns/[id]/contracts
|
||||
* Upload a contract to a campaign
|
||||
* Expects multipart/form-data with:
|
||||
* - file: the contract file
|
||||
* - signing_date: optional ISO date string
|
||||
* - is_primary: optional boolean
|
||||
* - notes: optional string
|
||||
*/
|
||||
export async function POST(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const supabase = await createClient()
|
||||
const { id: campaignId } = await params
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Verify campaign exists and belongs to user
|
||||
const { data: campaign, error: campaignError } = await supabase
|
||||
.from('campaigns')
|
||||
.select('id, name')
|
||||
.eq('id', campaignId)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (campaignError || !campaign) {
|
||||
return NextResponse.json({ error: 'Campaign not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
try {
|
||||
const formData = await request.formData()
|
||||
const file = formData.get('file') as File | null
|
||||
const signingDate = formData.get('signing_date') as string | null
|
||||
const isPrimary = formData.get('is_primary') === 'true'
|
||||
const notes = formData.get('notes') as string | null
|
||||
|
||||
if (!file) {
|
||||
return NextResponse.json({ error: 'No file provided' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Validate file type (PDF, DOC, DOCX, images)
|
||||
const allowedTypes = [
|
||||
'application/pdf',
|
||||
'application/msword',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'image/jpeg',
|
||||
'image/png',
|
||||
'image/webp'
|
||||
]
|
||||
|
||||
if (!allowedTypes.includes(file.type)) {
|
||||
return NextResponse.json({
|
||||
error: 'Invalid file type. Allowed: PDF, DOC, DOCX, JPG, PNG, WEBP'
|
||||
}, { status: 400 })
|
||||
}
|
||||
|
||||
// Max file size: 10MB
|
||||
const maxSize = 10 * 1024 * 1024
|
||||
if (file.size > maxSize) {
|
||||
return NextResponse.json({ error: 'File too large. Max size: 10MB' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Generate unique file path
|
||||
const timestamp = Date.now()
|
||||
const safeFilename = file.name.replace(/[^a-zA-Z0-9.-]/g, '_')
|
||||
const filePath = `${user.id}/${campaignId}/${timestamp}_${safeFilename}`
|
||||
|
||||
// Upload to Supabase Storage
|
||||
const { error: uploadError } = await supabase.storage
|
||||
.from('contracts')
|
||||
.upload(filePath, file, {
|
||||
cacheControl: '3600',
|
||||
upsert: false
|
||||
})
|
||||
|
||||
if (uploadError) {
|
||||
console.error('Upload error:', uploadError)
|
||||
return NextResponse.json({ error: 'Failed to upload file' }, { status: 500 })
|
||||
}
|
||||
|
||||
// If this is set as primary, unset other primary contracts
|
||||
if (isPrimary) {
|
||||
await supabase
|
||||
.from('contracts')
|
||||
.update({ is_primary: false })
|
||||
.eq('campaign_id', campaignId)
|
||||
.eq('is_primary', true)
|
||||
}
|
||||
|
||||
// Create contract record
|
||||
const { data, error } = await supabase
|
||||
.from('contracts')
|
||||
.insert({
|
||||
user_id: user.id,
|
||||
campaign_id: campaignId,
|
||||
filename: file.name,
|
||||
file_path: filePath,
|
||||
file_size: file.size,
|
||||
mime_type: file.type,
|
||||
signing_date: signingDate || null,
|
||||
is_primary: isPrimary,
|
||||
notes: notes || null,
|
||||
extraction_status: 'pending'
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
// Try to clean up uploaded file
|
||||
await supabase.storage.from('contracts').remove([filePath])
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
// If contract is signed and this is primary, update campaign status
|
||||
if (isPrimary && signingDate) {
|
||||
await supabase
|
||||
.from('campaigns')
|
||||
.update({
|
||||
contract_signed_at: signingDate,
|
||||
status: 'contracted'
|
||||
})
|
||||
.eq('id', campaignId)
|
||||
.eq('status', 'negotiation')
|
||||
}
|
||||
|
||||
return NextResponse.json({ data })
|
||||
} catch (err) {
|
||||
console.error('Contract upload error:', err)
|
||||
return NextResponse.json({ error: 'Failed to process upload' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import type { CreateDeliverableInput } from '@/types'
|
||||
|
||||
/**
|
||||
* GET /api/campaigns/[id]/deliverables
|
||||
* List deliverables for a campaign
|
||||
*/
|
||||
export async function GET(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const supabase = await createClient()
|
||||
const { id: campaignId } = await params
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Verify campaign exists and belongs to user
|
||||
const { data: campaign, error: campaignError } = await supabase
|
||||
.from('campaigns')
|
||||
.select('id')
|
||||
.eq('id', campaignId)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (campaignError || !campaign) {
|
||||
return NextResponse.json({ error: 'Campaign not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('deliverables')
|
||||
.select('*')
|
||||
.eq('campaign_id', campaignId)
|
||||
.order('due_date', { ascending: true, nullsFirst: false })
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ data })
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/campaigns/[id]/deliverables
|
||||
* Add a deliverable to a campaign
|
||||
*/
|
||||
export async function POST(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const supabase = await createClient()
|
||||
const { id: campaignId } = await params
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Verify campaign exists and belongs to user
|
||||
const { data: campaign, error: campaignError } = await supabase
|
||||
.from('campaigns')
|
||||
.select('id')
|
||||
.eq('id', campaignId)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (campaignError || !campaign) {
|
||||
return NextResponse.json({ error: 'Campaign not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const body: Omit<CreateDeliverableInput, 'campaign_id'> = await request.json()
|
||||
|
||||
// Validate required fields
|
||||
if (!body.title || !body.deliverable_type || !body.platform) {
|
||||
return NextResponse.json({ error: 'Missing required fields (title, deliverable_type, platform)' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Insert the deliverable
|
||||
const { data, error } = await supabase
|
||||
.from('deliverables')
|
||||
.insert({
|
||||
user_id: user.id,
|
||||
campaign_id: campaignId,
|
||||
title: body.title,
|
||||
deliverable_type: body.deliverable_type,
|
||||
platform: body.platform,
|
||||
account_handle: body.account_handle || null,
|
||||
quantity: body.quantity || 1,
|
||||
description: body.description || null,
|
||||
specifications: body.specifications || {},
|
||||
due_date: body.due_date || null,
|
||||
status: 'pending',
|
||||
notes: body.notes || null,
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
// Optionally auto-generate a deadline for this deliverable
|
||||
if (body.due_date) {
|
||||
await supabase.from('deadlines').insert({
|
||||
user_id: user.id,
|
||||
campaign_id: campaignId,
|
||||
deliverable_id: data.id,
|
||||
title: `Leverans: ${body.title}`,
|
||||
due_date: body.due_date,
|
||||
deadline_type: 'delivery',
|
||||
priority: 'important',
|
||||
is_auto_generated: true,
|
||||
})
|
||||
}
|
||||
|
||||
return NextResponse.json({ data })
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import type { CreateExclusivityInput } from '@/types'
|
||||
|
||||
/**
|
||||
* GET /api/campaigns/[id]/exclusivities
|
||||
* List exclusivities for a campaign
|
||||
*/
|
||||
export async function GET(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const supabase = await createClient()
|
||||
const { id: campaignId } = await params
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Verify campaign exists and belongs to user
|
||||
const { data: campaign, error: campaignError } = await supabase
|
||||
.from('campaigns')
|
||||
.select('id')
|
||||
.eq('id', campaignId)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (campaignError || !campaign) {
|
||||
return NextResponse.json({ error: 'Campaign not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('exclusivities')
|
||||
.select('*')
|
||||
.eq('campaign_id', campaignId)
|
||||
.order('start_date', { ascending: true })
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ data })
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/campaigns/[id]/exclusivities
|
||||
* Add an exclusivity to a campaign
|
||||
*/
|
||||
export async function POST(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const supabase = await createClient()
|
||||
const { id: campaignId } = await params
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Verify campaign exists and belongs to user
|
||||
const { data: campaign, error: campaignError } = await supabase
|
||||
.from('campaigns')
|
||||
.select('id, name')
|
||||
.eq('id', campaignId)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (campaignError || !campaign) {
|
||||
return NextResponse.json({ error: 'Campaign not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const body: Omit<CreateExclusivityInput, 'campaign_id'> = await request.json()
|
||||
|
||||
// Validate required fields
|
||||
if (!body.categories || body.categories.length === 0 || !body.start_date || !body.end_date) {
|
||||
return NextResponse.json({ error: 'Missing required fields (categories, start_date, end_date)' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Validate date range
|
||||
if (new Date(body.end_date) < new Date(body.start_date)) {
|
||||
return NextResponse.json({ error: 'End date must be after start date' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Check for conflicts with existing exclusivities
|
||||
const { data: existingExclusivities } = await supabase
|
||||
.from('exclusivities')
|
||||
.select(`
|
||||
*,
|
||||
campaign:campaigns(id, name, customer_id)
|
||||
`)
|
||||
.eq('user_id', user.id)
|
||||
.neq('campaign_id', campaignId)
|
||||
.lte('start_date', body.end_date)
|
||||
.gte('end_date', body.start_date)
|
||||
|
||||
const conflicts = []
|
||||
if (existingExclusivities) {
|
||||
for (const existing of existingExclusivities) {
|
||||
const overlappingCategories = body.categories.filter(cat =>
|
||||
existing.categories.some((existingCat: string) =>
|
||||
existingCat.toLowerCase() === cat.toLowerCase()
|
||||
)
|
||||
)
|
||||
|
||||
if (overlappingCategories.length > 0) {
|
||||
conflicts.push({
|
||||
exclusivity_id: existing.id,
|
||||
campaign_id: existing.campaign?.id,
|
||||
campaign_name: existing.campaign?.name,
|
||||
overlapping_categories: overlappingCategories,
|
||||
overlap_start: body.start_date > existing.start_date ? body.start_date : existing.start_date,
|
||||
overlap_end: body.end_date < existing.end_date ? body.end_date : existing.end_date
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Insert the exclusivity
|
||||
const { data, error } = await supabase
|
||||
.from('exclusivities')
|
||||
.insert({
|
||||
user_id: user.id,
|
||||
campaign_id: campaignId,
|
||||
categories: body.categories,
|
||||
excluded_brands: body.excluded_brands || [],
|
||||
start_date: body.start_date,
|
||||
end_date: body.end_date,
|
||||
start_calculation_type: body.start_calculation_type || 'absolute',
|
||||
end_calculation_type: body.end_calculation_type || 'absolute',
|
||||
start_reference: body.start_reference || null,
|
||||
end_reference: body.end_reference || null,
|
||||
start_offset_days: body.start_offset_days || null,
|
||||
end_offset_days: body.end_offset_days || null,
|
||||
notes: body.notes || null,
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
// Return with conflict warnings if any
|
||||
return NextResponse.json({
|
||||
data,
|
||||
conflicts: conflicts.length > 0 ? conflicts : undefined,
|
||||
warning: conflicts.length > 0
|
||||
? `Varning: ${conflicts.length} överlappande exklusivitet(er) hittades`
|
||||
: undefined
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import type { CreateCampaignInput, CampaignStatus } from '@/types'
|
||||
|
||||
/**
|
||||
* GET /api/campaigns/[id]
|
||||
* Get a single campaign with all related data
|
||||
*/
|
||||
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('campaigns')
|
||||
.select(`
|
||||
*,
|
||||
customer:customers!campaigns_customer_id_fkey(id, name, email, customer_category, customer_type),
|
||||
end_customer:customers!campaigns_end_customer_id_fkey(id, name),
|
||||
deliverables(
|
||||
id, title, description, deliverable_type, platform, account_handle,
|
||||
quantity, specifications, due_date, status, submitted_at, approved_at,
|
||||
published_at, notes, created_at
|
||||
),
|
||||
exclusivities(
|
||||
id, categories, excluded_brands, start_date, end_date,
|
||||
start_calculation_type, end_calculation_type, notes, created_at
|
||||
),
|
||||
contracts(
|
||||
id, filename, file_path, file_size, mime_type, signing_date,
|
||||
is_primary, extraction_status, notes, uploaded_at
|
||||
),
|
||||
briefings(
|
||||
id, briefing_type, title, content, text_content, filename,
|
||||
file_size, mime_type, notes, created_at, updated_at
|
||||
)
|
||||
`)
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
if (error.code === 'PGRST116') {
|
||||
return NextResponse.json({ error: 'Campaign not found' }, { status: 404 })
|
||||
}
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
// Also fetch related invoices
|
||||
const { data: invoices } = await supabase
|
||||
.from('invoices')
|
||||
.select('id, invoice_number, invoice_date, due_date, status, total, currency, payment_status')
|
||||
.eq('campaign_id', id)
|
||||
.eq('user_id', user.id)
|
||||
.order('invoice_date', { ascending: false })
|
||||
|
||||
return NextResponse.json({ data: { ...data, invoices: invoices || [] } })
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH /api/campaigns/[id]
|
||||
* Update a campaign
|
||||
*/
|
||||
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: Partial<CreateCampaignInput> & { status?: CampaignStatus; contract_signed_at?: string } = await request.json()
|
||||
|
||||
// Verify campaign exists and belongs to user
|
||||
const { data: existing, error: fetchError } = await supabase
|
||||
.from('campaigns')
|
||||
.select('*')
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (fetchError) {
|
||||
if (fetchError.code === 'PGRST116') {
|
||||
return NextResponse.json({ error: 'Campaign not found' }, { status: 404 })
|
||||
}
|
||||
return NextResponse.json({ error: fetchError.message }, { status: 500 })
|
||||
}
|
||||
|
||||
// Validate customer if changed
|
||||
if (body.customer_id && body.customer_id !== existing.customer_id) {
|
||||
const { data: customer, error: customerError } = await supabase
|
||||
.from('customers')
|
||||
.select('id')
|
||||
.eq('id', body.customer_id)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (customerError || !customer) {
|
||||
return NextResponse.json({ error: 'Customer not found' }, { status: 404 })
|
||||
}
|
||||
}
|
||||
|
||||
// Build update object
|
||||
const updateData: Record<string, unknown> = {}
|
||||
|
||||
if (body.name !== undefined) updateData.name = body.name
|
||||
if (body.description !== undefined) updateData.description = body.description
|
||||
if (body.customer_id !== undefined) updateData.customer_id = body.customer_id || null
|
||||
if (body.end_customer_id !== undefined) updateData.end_customer_id = body.end_customer_id || null
|
||||
if (body.campaign_type !== undefined) updateData.campaign_type = body.campaign_type
|
||||
if (body.status !== undefined) updateData.status = body.status
|
||||
if (body.total_value !== undefined) updateData.total_value = body.total_value
|
||||
if (body.currency !== undefined) updateData.currency = body.currency
|
||||
if (body.vat_included !== undefined) updateData.vat_included = body.vat_included
|
||||
if (body.payment_terms !== undefined) updateData.payment_terms = body.payment_terms
|
||||
if (body.billing_frequency !== undefined) updateData.billing_frequency = body.billing_frequency
|
||||
if (body.brand_name !== undefined) updateData.brand_name = body.brand_name
|
||||
if (body.start_date !== undefined) updateData.start_date = body.start_date
|
||||
if (body.end_date !== undefined) updateData.end_date = body.end_date
|
||||
if (body.publication_date !== undefined) updateData.publication_date = body.publication_date
|
||||
if (body.draft_deadline !== undefined) updateData.draft_deadline = body.draft_deadline
|
||||
if (body.contract_signed_at !== undefined) updateData.contract_signed_at = body.contract_signed_at
|
||||
if (body.notes !== undefined) updateData.notes = body.notes
|
||||
|
||||
// Update the campaign
|
||||
const { data, error } = await supabase
|
||||
.from('campaigns')
|
||||
.update(updateData)
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
.select(`
|
||||
*,
|
||||
customer:customers!campaigns_customer_id_fkey(id, name),
|
||||
end_customer:customers!campaigns_end_customer_id_fkey(id, name)
|
||||
`)
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ data })
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/campaigns/[id]
|
||||
* Delete a campaign (cascades to deliverables, exclusivities, contracts)
|
||||
*/
|
||||
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 })
|
||||
}
|
||||
|
||||
// First, delete any contract files from storage
|
||||
const { data: contracts } = await supabase
|
||||
.from('contracts')
|
||||
.select('file_path')
|
||||
.eq('campaign_id', id)
|
||||
|
||||
if (contracts && contracts.length > 0) {
|
||||
const filePaths = contracts.map(c => c.file_path)
|
||||
await supabase.storage.from('contracts').remove(filePaths)
|
||||
}
|
||||
|
||||
// Delete the campaign (cascades to related tables)
|
||||
const { error } = await supabase
|
||||
.from('campaigns')
|
||||
.delete()
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { checkExclusivityConflicts } from '@/lib/campaigns/exclusivity-checker'
|
||||
import type {
|
||||
ContractExtractionResult,
|
||||
CreateCampaignInput,
|
||||
CreateDeliverableInput,
|
||||
CreateExclusivityInput,
|
||||
Exclusivity,
|
||||
} from '@/types'
|
||||
|
||||
interface CreateFromContractInput {
|
||||
contractId: string
|
||||
extraction: ContractExtractionResult
|
||||
customerId: string | null
|
||||
endCustomerId: string | null
|
||||
brandName?: string | null
|
||||
createNewCustomer?: {
|
||||
name: string
|
||||
org_number?: string
|
||||
email?: string
|
||||
customer_type: 'individual' | 'swedish_business' | 'eu_business' | 'non_eu_business'
|
||||
}
|
||||
createNewEndCustomer?: {
|
||||
name: string
|
||||
org_number?: string
|
||||
email?: string
|
||||
customer_type: 'individual' | 'swedish_business' | 'eu_business' | 'non_eu_business'
|
||||
}
|
||||
overrides?: Partial<CreateCampaignInput>
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/campaigns/from-contract
|
||||
* Create a campaign from extracted contract data
|
||||
*/
|
||||
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: CreateFromContractInput = await request.json()
|
||||
const { contractId, extraction, overrides } = body
|
||||
let { customerId, endCustomerId } = body
|
||||
|
||||
// Verify contract exists and belongs to user
|
||||
const { data: contract, error: contractError } = await supabase
|
||||
.from('contracts')
|
||||
.select('*')
|
||||
.eq('id', contractId)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (contractError) {
|
||||
return NextResponse.json({ error: 'Contract not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
try {
|
||||
// Create new customers if needed
|
||||
if (body.createNewCustomer && !customerId) {
|
||||
const { data: newCustomer, error: customerError } = await supabase
|
||||
.from('customers')
|
||||
.insert({
|
||||
user_id: user.id,
|
||||
name: body.createNewCustomer.name,
|
||||
org_number: body.createNewCustomer.org_number,
|
||||
email: body.createNewCustomer.email,
|
||||
customer_type: body.createNewCustomer.customer_type,
|
||||
country: 'Sweden',
|
||||
default_payment_terms: extraction.financials.paymentTerms || 30,
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (customerError) {
|
||||
throw new Error(`Failed to create customer: ${customerError.message}`)
|
||||
}
|
||||
customerId = newCustomer.id
|
||||
}
|
||||
|
||||
if (body.createNewEndCustomer && !endCustomerId) {
|
||||
const { data: newEndCustomer, error: endCustomerError } = await supabase
|
||||
.from('customers')
|
||||
.insert({
|
||||
user_id: user.id,
|
||||
name: body.createNewEndCustomer.name,
|
||||
org_number: body.createNewEndCustomer.org_number,
|
||||
email: body.createNewEndCustomer.email,
|
||||
customer_type: body.createNewEndCustomer.customer_type,
|
||||
country: 'Sweden',
|
||||
default_payment_terms: 30,
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (endCustomerError) {
|
||||
throw new Error(`Failed to create end customer: ${endCustomerError.message}`)
|
||||
}
|
||||
endCustomerId = newEndCustomer.id
|
||||
}
|
||||
|
||||
// Create campaign
|
||||
const campaignData: CreateCampaignInput = {
|
||||
customer_id: customerId || undefined,
|
||||
end_customer_id: endCustomerId || undefined,
|
||||
name: extraction.campaignName || `Samarbete ${new Date().toLocaleDateString('sv-SE')}`,
|
||||
brand_name: body.brandName || extraction.parties.brand?.name || undefined,
|
||||
campaign_type: 'influencer',
|
||||
total_value: extraction.financials.amount || undefined,
|
||||
currency: extraction.financials.currency || 'SEK',
|
||||
vat_included: extraction.financials.vatIncluded ?? false,
|
||||
payment_terms: extraction.financials.paymentTerms || undefined,
|
||||
billing_frequency: extraction.financials.billingFrequency || undefined,
|
||||
start_date: extraction.period.startDate || undefined,
|
||||
end_date: extraction.period.endDate || undefined,
|
||||
publication_date: extraction.period.publicationDate || undefined,
|
||||
draft_deadline: (extraction.period as Record<string, unknown>).draftDeadline as string || undefined,
|
||||
...overrides,
|
||||
}
|
||||
|
||||
const { data: campaign, error: campaignError } = await supabase
|
||||
.from('campaigns')
|
||||
.insert({
|
||||
user_id: user.id,
|
||||
...campaignData,
|
||||
status: 'contracted',
|
||||
contract_signed_at: extraction.signingDate || new Date().toISOString().split('T')[0],
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (campaignError) {
|
||||
throw new Error(`Failed to create campaign: ${campaignError.message}`)
|
||||
}
|
||||
|
||||
// Link contract to campaign
|
||||
await supabase
|
||||
.from('contracts')
|
||||
.update({
|
||||
campaign_id: campaign.id,
|
||||
is_primary: true,
|
||||
extraction_status: 'completed',
|
||||
})
|
||||
.eq('id', contractId)
|
||||
|
||||
// Create deliverables
|
||||
const deliverables: { id: string }[] = []
|
||||
for (const del of extraction.deliverables) {
|
||||
const deliverableData: CreateDeliverableInput = {
|
||||
campaign_id: campaign.id,
|
||||
title: del.description || `${del.type} - ${del.platform || 'Okänd plattform'}`,
|
||||
deliverable_type: del.type,
|
||||
platform: del.platform || 'instagram',
|
||||
account_handle: del.account || undefined,
|
||||
quantity: del.quantity,
|
||||
due_date: del.dueDate || undefined,
|
||||
}
|
||||
|
||||
const { data: deliverable, error: deliverableError } = await supabase
|
||||
.from('deliverables')
|
||||
.insert({
|
||||
user_id: user.id,
|
||||
...deliverableData,
|
||||
status: 'pending',
|
||||
})
|
||||
.select('id')
|
||||
.single()
|
||||
|
||||
if (deliverableError) {
|
||||
console.error('Failed to create deliverable:', deliverableError)
|
||||
continue
|
||||
}
|
||||
deliverables.push(deliverable)
|
||||
}
|
||||
|
||||
// Create exclusivity if present
|
||||
let exclusivityConflicts: unknown[] = []
|
||||
if (extraction.exclusivity.categories.length > 0) {
|
||||
// Calculate exclusivity dates
|
||||
const exclusivityStart = extraction.period.startDate ||
|
||||
new Date().toISOString().split('T')[0]
|
||||
|
||||
let exclusivityEnd = extraction.period.endDate ||
|
||||
new Date(Date.now() + 90 * 24 * 60 * 60 * 1000).toISOString().split('T')[0]
|
||||
|
||||
// Extend end date if post period specified
|
||||
if (extraction.exclusivity.postPeriodDays) {
|
||||
const endDate = new Date(exclusivityEnd)
|
||||
endDate.setDate(endDate.getDate() + extraction.exclusivity.postPeriodDays)
|
||||
exclusivityEnd = endDate.toISOString().split('T')[0]
|
||||
}
|
||||
|
||||
// Check for conflicts
|
||||
const { data: existingExclusivities } = await supabase
|
||||
.from('exclusivities')
|
||||
.select('*, campaign:campaigns(*)')
|
||||
.eq('user_id', user.id)
|
||||
.neq('campaign_id', campaign.id)
|
||||
|
||||
if (existingExclusivities) {
|
||||
exclusivityConflicts = checkExclusivityConflicts(
|
||||
{
|
||||
categories: extraction.exclusivity.categories,
|
||||
start_date: exclusivityStart,
|
||||
end_date: exclusivityEnd,
|
||||
} as Exclusivity,
|
||||
existingExclusivities as Exclusivity[]
|
||||
)
|
||||
}
|
||||
|
||||
const exclusivityData: CreateExclusivityInput = {
|
||||
campaign_id: campaign.id,
|
||||
categories: extraction.exclusivity.categories,
|
||||
excluded_brands: extraction.exclusivity.excludedBrands,
|
||||
start_date: exclusivityStart,
|
||||
end_date: exclusivityEnd,
|
||||
start_calculation_type: 'absolute',
|
||||
end_calculation_type: extraction.exclusivity.postReference ? 'relative' : 'absolute',
|
||||
end_reference: extraction.exclusivity.postReference || undefined,
|
||||
end_offset_days: extraction.exclusivity.postPeriodDays || undefined,
|
||||
}
|
||||
|
||||
await supabase
|
||||
.from('exclusivities')
|
||||
.insert({
|
||||
user_id: user.id,
|
||||
...exclusivityData,
|
||||
})
|
||||
}
|
||||
|
||||
// Create deadlines
|
||||
const createdDeadlines: unknown[] = []
|
||||
for (const deadline of extraction.deadlines) {
|
||||
let dueDate = deadline.absoluteDate
|
||||
|
||||
// Calculate relative dates if possible
|
||||
if (!dueDate && deadline.isRelative && deadline.referenceEvent && deadline.offsetDays) {
|
||||
let referenceDate: string | null = null
|
||||
|
||||
switch (deadline.referenceEvent) {
|
||||
case 'publication':
|
||||
referenceDate = extraction.period.publicationDate
|
||||
break
|
||||
case 'delivery':
|
||||
referenceDate = extraction.period.endDate
|
||||
break
|
||||
case 'contract':
|
||||
referenceDate = extraction.signingDate
|
||||
break
|
||||
}
|
||||
|
||||
if (referenceDate) {
|
||||
const refDate = new Date(referenceDate)
|
||||
refDate.setDate(refDate.getDate() + deadline.offsetDays)
|
||||
dueDate = refDate.toISOString().split('T')[0]
|
||||
}
|
||||
}
|
||||
|
||||
// Skip if we still don't have a date
|
||||
if (!dueDate) continue
|
||||
|
||||
const { data: createdDeadline, error: deadlineError } = await supabase
|
||||
.from('deadlines')
|
||||
.insert({
|
||||
user_id: user.id,
|
||||
title: deadline.description,
|
||||
due_date: dueDate,
|
||||
deadline_type: deadline.type,
|
||||
priority: 'normal',
|
||||
customer_id: customerId,
|
||||
campaign_id: campaign.id,
|
||||
is_auto_generated: true,
|
||||
date_calculation_type: deadline.isRelative ? 'relative' : 'absolute',
|
||||
reference_event: deadline.referenceEvent,
|
||||
offset_days: deadline.offsetDays,
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (!deadlineError && createdDeadline) {
|
||||
createdDeadlines.push(createdDeadline)
|
||||
}
|
||||
}
|
||||
|
||||
// Also create standard campaign deadlines
|
||||
if (campaign.end_date) {
|
||||
// Invoicing deadline (5 days after end)
|
||||
const invoiceDate = new Date(campaign.end_date)
|
||||
invoiceDate.setDate(invoiceDate.getDate() + 5)
|
||||
|
||||
await supabase.from('deadlines').insert({
|
||||
user_id: user.id,
|
||||
title: `Fakturera: ${campaign.name}`,
|
||||
due_date: invoiceDate.toISOString().split('T')[0],
|
||||
deadline_type: 'invoicing',
|
||||
priority: 'important',
|
||||
customer_id: customerId,
|
||||
campaign_id: campaign.id,
|
||||
is_auto_generated: true,
|
||||
})
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
data: {
|
||||
campaign,
|
||||
deliverablesCreated: deliverables.length,
|
||||
deadlinesCreated: createdDeadlines.length,
|
||||
exclusivityConflicts,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to create campaign'
|
||||
return NextResponse.json({ error: message }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import type { CreateCampaignInput } from '@/types'
|
||||
|
||||
/**
|
||||
* GET /api/campaigns
|
||||
* List campaigns for the authenticated user
|
||||
* Query params:
|
||||
* - status: CampaignStatus (optional, comma-separated for multiple)
|
||||
* - type: CampaignType (optional)
|
||||
* - customer_id: string (optional)
|
||||
* - from: ISO date string (optional, start_date >=)
|
||||
* - to: ISO date string (optional, end_date <=)
|
||||
* - limit: number (default: 50)
|
||||
* - offset: number (default: 0)
|
||||
*/
|
||||
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')
|
||||
const type = searchParams.get('type')
|
||||
const customerId = searchParams.get('customer_id')
|
||||
const from = searchParams.get('from')
|
||||
const to = searchParams.get('to')
|
||||
const limit = parseInt(searchParams.get('limit') || '50')
|
||||
const offset = parseInt(searchParams.get('offset') || '0')
|
||||
|
||||
// Build query with relations
|
||||
let query = supabase
|
||||
.from('campaigns')
|
||||
.select(`
|
||||
*,
|
||||
customer:customers!campaigns_customer_id_fkey(id, name, customer_category),
|
||||
end_customer:customers!campaigns_end_customer_id_fkey(id, name),
|
||||
deliverables(id, title, status, due_date, platform, deliverable_type),
|
||||
exclusivities(id, categories, start_date, end_date),
|
||||
contracts(id, filename, is_primary)
|
||||
`, { count: 'exact' })
|
||||
.eq('user_id', user.id)
|
||||
|
||||
// Apply filters
|
||||
if (status) {
|
||||
const statuses = status.split(',')
|
||||
if (statuses.length === 1) {
|
||||
query = query.eq('status', status)
|
||||
} else {
|
||||
query = query.in('status', statuses)
|
||||
}
|
||||
}
|
||||
|
||||
if (type) {
|
||||
query = query.eq('campaign_type', type)
|
||||
}
|
||||
|
||||
if (customerId) {
|
||||
query = query.eq('customer_id', customerId)
|
||||
}
|
||||
|
||||
if (from) {
|
||||
query = query.gte('start_date', from)
|
||||
}
|
||||
|
||||
if (to) {
|
||||
query = query.lte('end_date', to)
|
||||
}
|
||||
|
||||
const { data, error, count } = await query
|
||||
.order('created_at', { ascending: false })
|
||||
.range(offset, offset + limit - 1)
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ data, count })
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/campaigns
|
||||
* Create a new campaign
|
||||
*/
|
||||
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: CreateCampaignInput = await request.json()
|
||||
|
||||
// Validate required fields
|
||||
if (!body.name) {
|
||||
return NextResponse.json({ error: 'Campaign name is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Validate customer exists if provided
|
||||
if (body.customer_id) {
|
||||
const { data: customer, error: customerError } = await supabase
|
||||
.from('customers')
|
||||
.select('id')
|
||||
.eq('id', body.customer_id)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (customerError || !customer) {
|
||||
return NextResponse.json({ error: 'Customer not found' }, { status: 404 })
|
||||
}
|
||||
}
|
||||
|
||||
// Validate end_customer exists if provided
|
||||
if (body.end_customer_id) {
|
||||
const { data: endCustomer, error: endCustomerError } = await supabase
|
||||
.from('customers')
|
||||
.select('id')
|
||||
.eq('id', body.end_customer_id)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (endCustomerError || !endCustomer) {
|
||||
return NextResponse.json({ error: 'End customer not found' }, { status: 404 })
|
||||
}
|
||||
}
|
||||
|
||||
// Insert the campaign
|
||||
const { data, error } = await supabase
|
||||
.from('campaigns')
|
||||
.insert({
|
||||
user_id: user.id,
|
||||
customer_id: body.customer_id || null,
|
||||
end_customer_id: body.end_customer_id || null,
|
||||
name: body.name,
|
||||
description: body.description || null,
|
||||
brand_name: body.brand_name || null,
|
||||
campaign_type: body.campaign_type || 'influencer',
|
||||
status: 'negotiation',
|
||||
total_value: body.total_value || null,
|
||||
currency: body.currency || 'SEK',
|
||||
vat_included: body.vat_included || false,
|
||||
payment_terms: body.payment_terms || null,
|
||||
billing_frequency: body.billing_frequency || null,
|
||||
start_date: body.start_date || null,
|
||||
end_date: body.end_date || null,
|
||||
publication_date: body.publication_date || null,
|
||||
draft_deadline: body.draft_deadline || null,
|
||||
notes: body.notes || null,
|
||||
})
|
||||
.select(`
|
||||
*,
|
||||
customer:customers!campaigns_customer_id_fkey(id, name),
|
||||
end_customer:customers!campaigns_end_customer_id_fkey(id, name)
|
||||
`)
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ data })
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import type { PlatformType, DeliverableType, Deliverable } from '@/types'
|
||||
|
||||
interface WorkloadDay {
|
||||
date: string
|
||||
deliverables: Deliverable[]
|
||||
totalDeliverables: number
|
||||
byPlatform: Partial<Record<PlatformType, number>>
|
||||
byType: Partial<Record<DeliverableType, number>>
|
||||
workloadLevel: 'light' | 'normal' | 'heavy' | 'overloaded'
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/campaigns/workload
|
||||
* Get workload analysis for deliverables
|
||||
* Query params:
|
||||
* - from: ISO date string (default: today)
|
||||
* - to: ISO date string (default: 30 days from now)
|
||||
* - group_by: 'day' | 'week' (default: 'day')
|
||||
*/
|
||||
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 from = searchParams.get('from') || new Date().toISOString().split('T')[0]
|
||||
const defaultTo = new Date()
|
||||
defaultTo.setDate(defaultTo.getDate() + 30)
|
||||
const to = searchParams.get('to') || defaultTo.toISOString().split('T')[0]
|
||||
const groupBy = searchParams.get('group_by') || 'day'
|
||||
|
||||
// Fetch deliverables with due dates in range
|
||||
const { data: deliverables, error } = await supabase
|
||||
.from('deliverables')
|
||||
.select(`
|
||||
*,
|
||||
campaign:campaigns(id, name, customer_id, customer:customers(id, name))
|
||||
`)
|
||||
.eq('user_id', user.id)
|
||||
.gte('due_date', from)
|
||||
.lte('due_date', to)
|
||||
.not('status', 'in', '("approved","published")')
|
||||
.order('due_date', { ascending: true })
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
// Group deliverables by date
|
||||
const workloadByDate: Record<string, WorkloadDay> = {}
|
||||
|
||||
for (const deliverable of deliverables || []) {
|
||||
if (!deliverable.due_date) continue
|
||||
|
||||
let dateKey = deliverable.due_date
|
||||
|
||||
// If grouping by week, use the Monday of that week
|
||||
if (groupBy === 'week') {
|
||||
const date = new Date(deliverable.due_date)
|
||||
const day = date.getDay()
|
||||
const diff = date.getDate() - day + (day === 0 ? -6 : 1)
|
||||
date.setDate(diff)
|
||||
dateKey = date.toISOString().split('T')[0]
|
||||
}
|
||||
|
||||
if (!workloadByDate[dateKey]) {
|
||||
workloadByDate[dateKey] = {
|
||||
date: dateKey,
|
||||
deliverables: [],
|
||||
totalDeliverables: 0,
|
||||
byPlatform: {},
|
||||
byType: {},
|
||||
workloadLevel: 'light'
|
||||
}
|
||||
}
|
||||
|
||||
const day = workloadByDate[dateKey]
|
||||
day.deliverables.push(deliverable)
|
||||
day.totalDeliverables += deliverable.quantity || 1
|
||||
|
||||
// Count by platform
|
||||
const platform = deliverable.platform as PlatformType
|
||||
day.byPlatform[platform] = (day.byPlatform[platform] || 0) + (deliverable.quantity || 1)
|
||||
|
||||
// Count by type
|
||||
const type = deliverable.deliverable_type as DeliverableType
|
||||
day.byType[type] = (day.byType[type] || 0) + (deliverable.quantity || 1)
|
||||
}
|
||||
|
||||
// Calculate workload levels
|
||||
for (const day of Object.values(workloadByDate)) {
|
||||
if (groupBy === 'week') {
|
||||
// Weekly thresholds
|
||||
if (day.totalDeliverables <= 3) day.workloadLevel = 'light'
|
||||
else if (day.totalDeliverables <= 7) day.workloadLevel = 'normal'
|
||||
else if (day.totalDeliverables <= 12) day.workloadLevel = 'heavy'
|
||||
else day.workloadLevel = 'overloaded'
|
||||
} else {
|
||||
// Daily thresholds
|
||||
if (day.totalDeliverables <= 1) day.workloadLevel = 'light'
|
||||
else if (day.totalDeliverables <= 2) day.workloadLevel = 'normal'
|
||||
else if (day.totalDeliverables <= 4) day.workloadLevel = 'heavy'
|
||||
else day.workloadLevel = 'overloaded'
|
||||
}
|
||||
}
|
||||
|
||||
// Convert to sorted array
|
||||
const workload = Object.values(workloadByDate).sort((a, b) =>
|
||||
a.date.localeCompare(b.date)
|
||||
)
|
||||
|
||||
// Calculate summary stats
|
||||
const totalDeliverables = deliverables?.reduce((sum, d) => sum + (d.quantity || 1), 0) || 0
|
||||
const heavyDays = workload.filter(d => d.workloadLevel === 'heavy').length
|
||||
const overloadedDays = workload.filter(d => d.workloadLevel === 'overloaded').length
|
||||
|
||||
// Find busiest day/week
|
||||
const busiestPeriod = workload.length > 0
|
||||
? workload.reduce((max, day) => day.totalDeliverables > max.totalDeliverables ? day : max)
|
||||
: null
|
||||
|
||||
return NextResponse.json({
|
||||
workload,
|
||||
summary: {
|
||||
period: { from, to, group_by: groupBy },
|
||||
total_deliverables: totalDeliverables,
|
||||
total_periods: workload.length,
|
||||
heavy_periods: heavyDays,
|
||||
overloaded_periods: overloadedDays,
|
||||
busiest_period: busiestPeriod ? {
|
||||
date: busiestPeriod.date,
|
||||
count: busiestPeriod.totalDeliverables
|
||||
} : null
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { generateChatResponse } from '@/lib/ai/chatbot/chain'
|
||||
import { CHATBOT_CONFIG } from '@/lib/ai/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 '@/lib/ai/chatbot/chain'
|
||||
import { CHATBOT_CONFIG } from '@/lib/ai/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,55 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
|
||||
/**
|
||||
* GET /api/contracts/[id]/download
|
||||
* Download a contract file
|
||||
* Returns a signed URL for direct download
|
||||
*/
|
||||
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 })
|
||||
}
|
||||
|
||||
// Get contract to verify ownership and get file path
|
||||
const { data: contract, error: fetchError } = await supabase
|
||||
.from('contracts')
|
||||
.select('file_path, filename, mime_type')
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (fetchError) {
|
||||
if (fetchError.code === 'PGRST116') {
|
||||
return NextResponse.json({ error: 'Contract not found' }, { status: 404 })
|
||||
}
|
||||
return NextResponse.json({ error: fetchError.message }, { status: 500 })
|
||||
}
|
||||
|
||||
// Create signed URL (valid for 1 hour)
|
||||
const { data: signedUrl, error: signError } = await supabase.storage
|
||||
.from('contracts')
|
||||
.createSignedUrl(contract.file_path, 3600, {
|
||||
download: contract.filename
|
||||
})
|
||||
|
||||
if (signError || !signedUrl) {
|
||||
return NextResponse.json({ error: 'Failed to generate download URL' }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
url: signedUrl.signedUrl,
|
||||
filename: contract.filename,
|
||||
mime_type: contract.mime_type
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { pdfToBase64, getPDFInfo } from '@/lib/contracts/pdf-extractor'
|
||||
import { analyzeContract } from '@/lib/contracts/contract-analyzer'
|
||||
import { matchParties } from '@/lib/customers/customer-matcher'
|
||||
import type { ContractExtractionResult, Customer } from '@/types'
|
||||
|
||||
// Rate limiting map (in production, use Redis or similar)
|
||||
const extractionTimestamps = new Map<string, number[]>()
|
||||
const RATE_LIMIT = 10 // Max extractions per minute
|
||||
const RATE_WINDOW_MS = 60 * 1000 // 1 minute
|
||||
|
||||
/**
|
||||
* POST /api/contracts/[id]/extract
|
||||
* Extract and analyze contract content using AI
|
||||
*/
|
||||
export async function POST(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const supabase = await createClient()
|
||||
const { id } = await params
|
||||
|
||||
// Authenticate user
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Rate limiting
|
||||
const now = Date.now()
|
||||
const userTimestamps = extractionTimestamps.get(user.id) || []
|
||||
const recentTimestamps = userTimestamps.filter((t) => now - t < RATE_WINDOW_MS)
|
||||
|
||||
if (recentTimestamps.length >= RATE_LIMIT) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Rate limit exceeded. Please try again later.' },
|
||||
{ status: 429 }
|
||||
)
|
||||
}
|
||||
|
||||
// Record this extraction attempt
|
||||
recentTimestamps.push(now)
|
||||
extractionTimestamps.set(user.id, recentTimestamps)
|
||||
|
||||
// Get contract
|
||||
const { data: contract, error: fetchError } = await supabase
|
||||
.from('contracts')
|
||||
.select('*')
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (fetchError) {
|
||||
if (fetchError.code === 'PGRST116') {
|
||||
return NextResponse.json({ error: 'Contract not found' }, { status: 404 })
|
||||
}
|
||||
return NextResponse.json({ error: fetchError.message }, { status: 500 })
|
||||
}
|
||||
|
||||
// Check if already extracted
|
||||
if (contract.extraction_status === 'completed' && contract.extracted_data) {
|
||||
// Return cached result
|
||||
const customers = await fetchUserCustomers(supabase, user.id)
|
||||
const extraction = contract.extracted_data as ContractExtractionResult
|
||||
const matches = matchParties(
|
||||
extraction.parties.brand,
|
||||
extraction.parties.agency,
|
||||
customers
|
||||
)
|
||||
|
||||
return NextResponse.json({
|
||||
data: {
|
||||
extraction,
|
||||
customerMatches: matches,
|
||||
cached: true,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Update status to processing
|
||||
await supabase
|
||||
.from('contracts')
|
||||
.update({ extraction_status: 'processing' })
|
||||
.eq('id', id)
|
||||
|
||||
try {
|
||||
console.log('[Extract] Starting extraction for contract:', id)
|
||||
console.log('[Extract] File path:', contract.file_path)
|
||||
|
||||
// Download file from storage
|
||||
const { data: fileData, error: downloadError } = await supabase.storage
|
||||
.from('contracts')
|
||||
.download(contract.file_path)
|
||||
|
||||
if (downloadError) {
|
||||
console.error('[Extract] Download error:', downloadError)
|
||||
throw new Error(`Failed to download contract: ${downloadError.message}`)
|
||||
}
|
||||
|
||||
console.log('[Extract] File downloaded, size:', fileData.size)
|
||||
|
||||
// Check file type
|
||||
if (contract.mime_type !== 'application/pdf') {
|
||||
throw new Error('Only PDF files are supported for extraction')
|
||||
}
|
||||
|
||||
// Convert PDF to base64 for Claude
|
||||
console.log('[Extract] Converting PDF to base64...')
|
||||
const buffer = Buffer.from(await fileData.arrayBuffer())
|
||||
const pdfInfo = getPDFInfo(buffer)
|
||||
|
||||
if (!pdfInfo.isValidSize) {
|
||||
throw new Error(`PDF is too large (${pdfInfo.sizeMB.toFixed(1)}MB). Maximum size is 32MB.`)
|
||||
}
|
||||
|
||||
const pdfBase64 = pdfToBase64(buffer)
|
||||
console.log('[Extract] PDF converted, size:', pdfInfo.sizeMB.toFixed(2), 'MB')
|
||||
|
||||
// Analyze with Claude AI (sending PDF directly)
|
||||
console.log('[Extract] Analyzing with Claude AI...')
|
||||
const extraction = await analyzeContract(pdfBase64)
|
||||
console.log('[Extract] Analysis complete')
|
||||
|
||||
// Get customers for matching
|
||||
const customers = await fetchUserCustomers(supabase, user.id)
|
||||
const matches = matchParties(
|
||||
extraction.parties.brand,
|
||||
extraction.parties.agency,
|
||||
customers
|
||||
)
|
||||
|
||||
// Save extraction result
|
||||
await supabase
|
||||
.from('contracts')
|
||||
.update({
|
||||
extracted_data: extraction as unknown as Record<string, unknown>,
|
||||
extraction_status: 'completed',
|
||||
})
|
||||
.eq('id', id)
|
||||
|
||||
return NextResponse.json({
|
||||
data: {
|
||||
extraction,
|
||||
customerMatches: matches,
|
||||
cached: false,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('[Extract] Error:', error)
|
||||
|
||||
// Update status to failed
|
||||
await supabase
|
||||
.from('contracts')
|
||||
.update({
|
||||
extraction_status: 'failed',
|
||||
extracted_data: {
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
},
|
||||
})
|
||||
.eq('id', id)
|
||||
|
||||
const message = error instanceof Error ? error.message : 'Extraction failed'
|
||||
console.error('[Extract] Returning error:', message)
|
||||
return NextResponse.json({ error: message }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/contracts/[id]/extract
|
||||
* Get extraction status and result
|
||||
*/
|
||||
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: contract, error } = await supabase
|
||||
.from('contracts')
|
||||
.select('extraction_status, extracted_data')
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
if (error.code === 'PGRST116') {
|
||||
return NextResponse.json({ error: 'Contract not found' }, { status: 404 })
|
||||
}
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
// If completed, also return customer matches
|
||||
if (contract.extraction_status === 'completed' && contract.extracted_data) {
|
||||
const customers = await fetchUserCustomers(supabase, user.id)
|
||||
const extraction = contract.extracted_data as ContractExtractionResult
|
||||
const matches = matchParties(
|
||||
extraction.parties.brand,
|
||||
extraction.parties.agency,
|
||||
customers
|
||||
)
|
||||
|
||||
return NextResponse.json({
|
||||
data: {
|
||||
status: contract.extraction_status,
|
||||
extraction: contract.extracted_data,
|
||||
customerMatches: matches,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
data: {
|
||||
status: contract.extraction_status,
|
||||
extraction: contract.extracted_data,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Helper to fetch user's customers
|
||||
async function fetchUserCustomers(
|
||||
supabase: Awaited<ReturnType<typeof createClient>>,
|
||||
userId: string
|
||||
): Promise<Customer[]> {
|
||||
const { data } = await supabase
|
||||
.from('customers')
|
||||
.select('*')
|
||||
.eq('user_id', userId)
|
||||
.order('name')
|
||||
|
||||
return data || []
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
|
||||
/**
|
||||
* GET /api/contracts/[id]
|
||||
* Get a single contract
|
||||
*/
|
||||
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('contracts')
|
||||
.select('*, campaign:campaigns(id, name)')
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
if (error.code === 'PGRST116') {
|
||||
return NextResponse.json({ error: 'Contract not found' }, { status: 404 })
|
||||
}
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ data })
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH /api/contracts/[id]
|
||||
* Update contract metadata
|
||||
*/
|
||||
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: {
|
||||
signing_date?: string
|
||||
is_primary?: boolean
|
||||
notes?: string
|
||||
} = await request.json()
|
||||
|
||||
// Verify contract exists and belongs to user
|
||||
const { data: existing, error: fetchError } = await supabase
|
||||
.from('contracts')
|
||||
.select('*, campaign_id')
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (fetchError) {
|
||||
if (fetchError.code === 'PGRST116') {
|
||||
return NextResponse.json({ error: 'Contract not found' }, { status: 404 })
|
||||
}
|
||||
return NextResponse.json({ error: fetchError.message }, { status: 500 })
|
||||
}
|
||||
|
||||
// If setting as primary, unset other primary contracts
|
||||
if (body.is_primary === true) {
|
||||
await supabase
|
||||
.from('contracts')
|
||||
.update({ is_primary: false })
|
||||
.eq('campaign_id', existing.campaign_id)
|
||||
.eq('is_primary', true)
|
||||
.neq('id', id)
|
||||
}
|
||||
|
||||
// Build update object
|
||||
const updateData: Record<string, unknown> = {}
|
||||
if (body.signing_date !== undefined) updateData.signing_date = body.signing_date
|
||||
if (body.is_primary !== undefined) updateData.is_primary = body.is_primary
|
||||
if (body.notes !== undefined) updateData.notes = body.notes
|
||||
|
||||
// Update the contract
|
||||
const { data, error } = await supabase
|
||||
.from('contracts')
|
||||
.update(updateData)
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ data })
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/contracts/[id]
|
||||
* Delete a contract and its file
|
||||
*/
|
||||
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 contract to find file path
|
||||
const { data: contract, error: fetchError } = await supabase
|
||||
.from('contracts')
|
||||
.select('file_path')
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (fetchError) {
|
||||
if (fetchError.code === 'PGRST116') {
|
||||
return NextResponse.json({ error: 'Contract not found' }, { status: 404 })
|
||||
}
|
||||
return NextResponse.json({ error: fetchError.message }, { status: 500 })
|
||||
}
|
||||
|
||||
// Delete file from storage
|
||||
if (contract.file_path) {
|
||||
await supabase.storage.from('contracts').remove([contract.file_path])
|
||||
}
|
||||
|
||||
// Delete contract record
|
||||
const { error } = await supabase
|
||||
.from('contracts')
|
||||
.delete()
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import type { CreateCustomerInput } from '@/types'
|
||||
|
||||
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('customers')
|
||||
.select('*')
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
if (error.code === 'PGRST116') {
|
||||
return NextResponse.json({ error: 'Customer not found' }, { status: 404 })
|
||||
}
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
// Fetch related campaigns
|
||||
const { data: campaigns } = await supabase
|
||||
.from('campaigns')
|
||||
.select('id, name, status, total_value, currency, publication_date, brand_name')
|
||||
.eq('customer_id', id)
|
||||
.eq('user_id', user.id)
|
||||
.order('created_at', { ascending: false })
|
||||
|
||||
// Fetch related invoices
|
||||
const { data: invoices } = await supabase
|
||||
.from('invoices')
|
||||
.select('id, invoice_number, invoice_date, due_date, status, total, currency, payment_status')
|
||||
.eq('customer_id', id)
|
||||
.eq('user_id', user.id)
|
||||
.order('invoice_date', { ascending: false })
|
||||
|
||||
return NextResponse.json({
|
||||
data: {
|
||||
...data,
|
||||
campaigns: campaigns || [],
|
||||
invoices: invoices || [],
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
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: Partial<CreateCustomerInput> = await request.json()
|
||||
|
||||
const updateData: Record<string, unknown> = {}
|
||||
|
||||
if (body.name !== undefined) updateData.name = body.name
|
||||
if (body.customer_type !== undefined) updateData.customer_type = body.customer_type
|
||||
if (body.email !== undefined) updateData.email = body.email
|
||||
if (body.phone !== undefined) updateData.phone = body.phone
|
||||
if (body.address_line1 !== undefined) updateData.address_line1 = body.address_line1
|
||||
if (body.address_line2 !== undefined) updateData.address_line2 = body.address_line2
|
||||
if (body.postal_code !== undefined) updateData.postal_code = body.postal_code
|
||||
if (body.city !== undefined) updateData.city = body.city
|
||||
if (body.country !== undefined) updateData.country = body.country
|
||||
if (body.org_number !== undefined) updateData.org_number = body.org_number
|
||||
if (body.vat_number !== undefined) updateData.vat_number = body.vat_number
|
||||
if (body.default_payment_terms !== undefined) updateData.default_payment_terms = body.default_payment_terms
|
||||
if (body.notes !== undefined) updateData.notes = body.notes
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('customers')
|
||||
.update(updateData)
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ data })
|
||||
}
|
||||
|
||||
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 })
|
||||
}
|
||||
|
||||
const { error } = await supabase
|
||||
.from('customers')
|
||||
.delete()
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import type { CreateCustomerInput } from '@/types'
|
||||
|
||||
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 { data, error } = await supabase
|
||||
.from('customers')
|
||||
.select('*')
|
||||
.eq('user_id', user.id)
|
||||
.order('name', { ascending: true })
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ data })
|
||||
}
|
||||
|
||||
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: CreateCustomerInput = await request.json()
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('customers')
|
||||
.insert({
|
||||
user_id: user.id,
|
||||
name: body.name,
|
||||
customer_type: body.customer_type,
|
||||
email: body.email,
|
||||
phone: body.phone,
|
||||
address_line1: body.address_line1,
|
||||
address_line2: body.address_line2,
|
||||
postal_code: body.postal_code,
|
||||
city: body.city,
|
||||
country: body.country || 'Sweden',
|
||||
org_number: body.org_number,
|
||||
vat_number: body.vat_number,
|
||||
default_payment_terms: body.default_payment_terms || 30,
|
||||
notes: body.notes,
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ data })
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
|
||||
/**
|
||||
* POST /api/deadlines/[id]/complete
|
||||
* Toggle completion status of a deadline
|
||||
*/
|
||||
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 })
|
||||
}
|
||||
|
||||
// First, get current deadline state
|
||||
const { data: existing, error: fetchError } = await supabase
|
||||
.from('deadlines')
|
||||
.select('is_completed')
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (fetchError) {
|
||||
if (fetchError.code === 'PGRST116') {
|
||||
return NextResponse.json({ error: 'Deadline not found' }, { status: 404 })
|
||||
}
|
||||
return NextResponse.json({ error: fetchError.message }, { status: 500 })
|
||||
}
|
||||
|
||||
// Toggle completion
|
||||
const newCompletedState = !existing.is_completed
|
||||
const { data, error } = await supabase
|
||||
.from('deadlines')
|
||||
.update({
|
||||
is_completed: newCompletedState,
|
||||
completed_at: newCompletedState ? new Date().toISOString() : null,
|
||||
})
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
.select('*, customer:customers(id, name)')
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ data })
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import type { CreateDeadlineInput } from '@/types'
|
||||
|
||||
/**
|
||||
* GET /api/deadlines/[id]
|
||||
* Get a single deadline by ID
|
||||
*/
|
||||
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('deadlines')
|
||||
.select('*, customer:customers(id, name)')
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
if (error.code === 'PGRST116') {
|
||||
return NextResponse.json({ error: 'Deadline not found' }, { status: 404 })
|
||||
}
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ data })
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT /api/deadlines/[id]
|
||||
* Update a deadline
|
||||
*/
|
||||
export async function PUT(
|
||||
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: Partial<CreateDeadlineInput> = await request.json()
|
||||
|
||||
// First, get existing deadline to verify ownership
|
||||
const { data: existing, error: fetchError } = await supabase
|
||||
.from('deadlines')
|
||||
.select('*')
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (fetchError) {
|
||||
if (fetchError.code === 'PGRST116') {
|
||||
return NextResponse.json({ error: 'Deadline not found' }, { status: 404 })
|
||||
}
|
||||
return NextResponse.json({ error: fetchError.message }, { status: 500 })
|
||||
}
|
||||
|
||||
// Build update object
|
||||
const updateData: Record<string, unknown> = {}
|
||||
if (body.title !== undefined) updateData.title = body.title
|
||||
if (body.due_date !== undefined) updateData.due_date = body.due_date
|
||||
if (body.due_time !== undefined) updateData.due_time = body.due_time
|
||||
if (body.deadline_type !== undefined) updateData.deadline_type = body.deadline_type
|
||||
if (body.priority !== undefined) updateData.priority = body.priority
|
||||
if (body.customer_id !== undefined) updateData.customer_id = body.customer_id || null
|
||||
if (body.notes !== undefined) updateData.notes = body.notes
|
||||
|
||||
// Update the deadline
|
||||
const { data, error } = await supabase
|
||||
.from('deadlines')
|
||||
.update(updateData)
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
.select('*, customer:customers(id, name)')
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ data })
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/deadlines/[id]
|
||||
* Delete a deadline
|
||||
*/
|
||||
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 })
|
||||
}
|
||||
|
||||
const { error } = await supabase
|
||||
.from('deadlines')
|
||||
.delete()
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { updateDeadlineStatus, isValidTransition } from '@/lib/deadlines/status-engine'
|
||||
import type { DeadlineStatus } from '@/types'
|
||||
|
||||
/**
|
||||
* PATCH /api/deadlines/[id]/status
|
||||
* Manually update a deadline's status
|
||||
*/
|
||||
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
|
||||
|
||||
const body = await request.json()
|
||||
const newStatus = body.status as DeadlineStatus
|
||||
|
||||
if (!newStatus) {
|
||||
return NextResponse.json({ error: 'Status is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const validStatuses: DeadlineStatus[] = [
|
||||
'upcoming',
|
||||
'action_needed',
|
||||
'in_progress',
|
||||
'submitted',
|
||||
'confirmed',
|
||||
'overdue',
|
||||
]
|
||||
|
||||
if (!validStatuses.includes(newStatus)) {
|
||||
return NextResponse.json({ error: 'Invalid status' }, { status: 400 })
|
||||
}
|
||||
|
||||
const result = await updateDeadlineStatus(supabase, id, user.id, newStatus)
|
||||
|
||||
if (!result.success) {
|
||||
return NextResponse.json({ error: result.error }, { status: 400 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/deadlines/[id]/status
|
||||
* Get current status and valid transitions
|
||||
*/
|
||||
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
|
||||
|
||||
const { data: deadline, error } = await supabase
|
||||
.from('deadlines')
|
||||
.select('status, is_completed, due_date')
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (error || !deadline) {
|
||||
return NextResponse.json({ error: 'Deadline not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Calculate valid transitions from current status
|
||||
const validTransitions: DeadlineStatus[] = []
|
||||
const allStatuses: DeadlineStatus[] = [
|
||||
'upcoming',
|
||||
'action_needed',
|
||||
'in_progress',
|
||||
'submitted',
|
||||
'confirmed',
|
||||
'overdue',
|
||||
]
|
||||
|
||||
for (const status of allStatuses) {
|
||||
if (isValidTransition(deadline.status, status)) {
|
||||
validTransitions.push(status)
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
currentStatus: deadline.status,
|
||||
isCompleted: deadline.is_completed,
|
||||
dueDate: deadline.due_date,
|
||||
validTransitions,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import type { CreateDeadlineInput } from '@/types'
|
||||
|
||||
/**
|
||||
* GET /api/deadlines
|
||||
* List deadlines for the authenticated user
|
||||
* Query params:
|
||||
* - status: 'all' | 'pending' | 'completed' (default: 'all')
|
||||
* - type: DeadlineType (optional)
|
||||
* - from: ISO date string (optional)
|
||||
* - to: ISO date string (optional)
|
||||
*/
|
||||
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') || 'all'
|
||||
const type = searchParams.get('type')
|
||||
const from = searchParams.get('from')
|
||||
const to = searchParams.get('to')
|
||||
|
||||
// Build query
|
||||
let query = supabase
|
||||
.from('deadlines')
|
||||
.select('*, customer:customers(id, name)')
|
||||
.eq('user_id', user.id)
|
||||
|
||||
// Apply filters
|
||||
if (status === 'pending') {
|
||||
query = query.eq('is_completed', false)
|
||||
} else if (status === 'completed') {
|
||||
query = query.eq('is_completed', true)
|
||||
}
|
||||
|
||||
if (type) {
|
||||
query = query.eq('deadline_type', type)
|
||||
}
|
||||
|
||||
if (from) {
|
||||
query = query.gte('due_date', from)
|
||||
}
|
||||
|
||||
if (to) {
|
||||
query = query.lte('due_date', to)
|
||||
}
|
||||
|
||||
const { data, error } = await query.order('due_date', { ascending: true })
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ data })
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/deadlines
|
||||
* Create a new deadline
|
||||
*/
|
||||
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: CreateDeadlineInput = await request.json()
|
||||
|
||||
// Validate required fields
|
||||
if (!body.title || !body.due_date || !body.deadline_type) {
|
||||
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Insert the deadline
|
||||
const { data, error } = await supabase
|
||||
.from('deadlines')
|
||||
.insert({
|
||||
user_id: user.id,
|
||||
title: body.title,
|
||||
due_date: body.due_date,
|
||||
due_time: body.due_time || null,
|
||||
deadline_type: body.deadline_type,
|
||||
priority: body.priority || 'normal',
|
||||
customer_id: body.customer_id || null,
|
||||
notes: body.notes || null,
|
||||
})
|
||||
.select('*, customer:customers(id, name)')
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ data })
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { createClient } from '@supabase/supabase-js'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { updateDeadlineStatuses } from '@/lib/deadlines/status-engine'
|
||||
|
||||
/**
|
||||
* GET /api/deadlines/status/cron
|
||||
* Daily cron job to update deadline statuses
|
||||
* Runs at 06:00 every day
|
||||
*
|
||||
* Vercel Cron: "0 6 * * *"
|
||||
*/
|
||||
export async function GET(request: Request) {
|
||||
// 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 {
|
||||
const result = await updateDeadlineStatuses(supabase)
|
||||
|
||||
console.log(
|
||||
`Deadline status cron completed: ${result.updated} updated, ` +
|
||||
`${result.newlyOverdue} newly overdue, ${result.newlyActionNeeded} newly action_needed`
|
||||
)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
updated: result.updated,
|
||||
newlyOverdue: result.newlyOverdue,
|
||||
newlyActionNeeded: result.newlyActionNeeded,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error in deadline status cron:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to update deadline statuses' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import type { CreateDeliverableInput } from '@/types'
|
||||
|
||||
/**
|
||||
* GET /api/deliverables/[id]
|
||||
* Get a single deliverable
|
||||
*/
|
||||
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('deliverables')
|
||||
.select('*, campaign:campaigns(id, name, customer_id)')
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
if (error.code === 'PGRST116') {
|
||||
return NextResponse.json({ error: 'Deliverable not found' }, { status: 404 })
|
||||
}
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ data })
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH /api/deliverables/[id]
|
||||
* Update a deliverable
|
||||
*/
|
||||
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: Partial<Omit<CreateDeliverableInput, 'campaign_id'>> = await request.json()
|
||||
|
||||
// Verify deliverable exists and belongs to user
|
||||
const { data: existing, error: fetchError } = await supabase
|
||||
.from('deliverables')
|
||||
.select('*')
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (fetchError) {
|
||||
if (fetchError.code === 'PGRST116') {
|
||||
return NextResponse.json({ error: 'Deliverable not found' }, { status: 404 })
|
||||
}
|
||||
return NextResponse.json({ error: fetchError.message }, { status: 500 })
|
||||
}
|
||||
|
||||
// Build update object
|
||||
const updateData: Record<string, unknown> = {}
|
||||
|
||||
if (body.title !== undefined) updateData.title = body.title
|
||||
if (body.deliverable_type !== undefined) updateData.deliverable_type = body.deliverable_type
|
||||
if (body.platform !== undefined) updateData.platform = body.platform
|
||||
if (body.account_handle !== undefined) updateData.account_handle = body.account_handle
|
||||
if (body.quantity !== undefined) updateData.quantity = body.quantity
|
||||
if (body.description !== undefined) updateData.description = body.description
|
||||
if (body.specifications !== undefined) updateData.specifications = body.specifications
|
||||
if (body.due_date !== undefined) updateData.due_date = body.due_date
|
||||
if (body.notes !== undefined) updateData.notes = body.notes
|
||||
|
||||
// Update the deliverable
|
||||
const { data, error } = await supabase
|
||||
.from('deliverables')
|
||||
.update(updateData)
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
// Update auto-generated deadline if due_date changed
|
||||
if (body.due_date !== undefined) {
|
||||
await supabase
|
||||
.from('deadlines')
|
||||
.update({ due_date: body.due_date })
|
||||
.eq('deliverable_id', id)
|
||||
.eq('is_auto_generated', true)
|
||||
}
|
||||
|
||||
return NextResponse.json({ data })
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/deliverables/[id]
|
||||
* Delete a deliverable
|
||||
*/
|
||||
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 })
|
||||
}
|
||||
|
||||
// Delete auto-generated deadlines first
|
||||
await supabase
|
||||
.from('deadlines')
|
||||
.delete()
|
||||
.eq('deliverable_id', id)
|
||||
.eq('is_auto_generated', true)
|
||||
|
||||
// Delete the deliverable
|
||||
const { error } = await supabase
|
||||
.from('deliverables')
|
||||
.delete()
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import type { DeliverableStatus } from '@/types'
|
||||
|
||||
const VALID_STATUSES: DeliverableStatus[] = [
|
||||
'pending',
|
||||
'in_progress',
|
||||
'submitted',
|
||||
'revision',
|
||||
'approved',
|
||||
'published'
|
||||
]
|
||||
|
||||
/**
|
||||
* PATCH /api/deliverables/[id]/status
|
||||
* Update deliverable status with automatic timestamp tracking
|
||||
*/
|
||||
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: { status: DeliverableStatus } = await request.json()
|
||||
|
||||
// Validate status
|
||||
if (!body.status || !VALID_STATUSES.includes(body.status)) {
|
||||
return NextResponse.json({
|
||||
error: `Invalid status. Must be one of: ${VALID_STATUSES.join(', ')}`
|
||||
}, { status: 400 })
|
||||
}
|
||||
|
||||
// Verify deliverable exists and belongs to user
|
||||
const { data: existing, error: fetchError } = await supabase
|
||||
.from('deliverables')
|
||||
.select('*')
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (fetchError) {
|
||||
if (fetchError.code === 'PGRST116') {
|
||||
return NextResponse.json({ error: 'Deliverable not found' }, { status: 404 })
|
||||
}
|
||||
return NextResponse.json({ error: fetchError.message }, { status: 500 })
|
||||
}
|
||||
|
||||
// Build update object with automatic timestamps
|
||||
const now = new Date().toISOString()
|
||||
const updateData: Record<string, unknown> = {
|
||||
status: body.status
|
||||
}
|
||||
|
||||
// Set appropriate timestamp based on status
|
||||
if (body.status === 'submitted' && !existing.submitted_at) {
|
||||
updateData.submitted_at = now
|
||||
} else if (body.status === 'approved' && !existing.approved_at) {
|
||||
updateData.approved_at = now
|
||||
} else if (body.status === 'published' && !existing.published_at) {
|
||||
updateData.published_at = now
|
||||
}
|
||||
|
||||
// Update the deliverable
|
||||
const { data, error } = await supabase
|
||||
.from('deliverables')
|
||||
.update(updateData)
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
// If status is now 'approved' or 'published', mark related deadline as completed
|
||||
if (body.status === 'approved' || body.status === 'published') {
|
||||
await supabase
|
||||
.from('deadlines')
|
||||
.update({
|
||||
is_completed: true,
|
||||
completed_at: now
|
||||
})
|
||||
.eq('deliverable_id', id)
|
||||
.eq('is_completed', false)
|
||||
}
|
||||
|
||||
// Check if all deliverables are completed - if so, maybe update campaign status
|
||||
if (body.status === 'published' || body.status === 'approved') {
|
||||
const { data: campaign } = await supabase
|
||||
.from('campaigns')
|
||||
.select('id, status')
|
||||
.eq('id', existing.campaign_id)
|
||||
.single()
|
||||
|
||||
if (campaign && campaign.status === 'active') {
|
||||
// Check if all deliverables are done
|
||||
const { data: allDeliverables } = await supabase
|
||||
.from('deliverables')
|
||||
.select('status')
|
||||
.eq('campaign_id', existing.campaign_id)
|
||||
|
||||
const allDone = allDeliverables?.every(d =>
|
||||
d.status === 'approved' || d.status === 'published'
|
||||
)
|
||||
|
||||
if (allDone) {
|
||||
await supabase
|
||||
.from('campaigns')
|
||||
.update({ status: 'delivered' })
|
||||
.eq('id', existing.campaign_id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ data })
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import type { CreateExclusivityInput } from '@/types'
|
||||
|
||||
/**
|
||||
* GET /api/exclusivities/[id]
|
||||
* Get a single exclusivity
|
||||
*/
|
||||
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('exclusivities')
|
||||
.select('*, campaign:campaigns(id, name, customer_id)')
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
if (error.code === 'PGRST116') {
|
||||
return NextResponse.json({ error: 'Exclusivity not found' }, { status: 404 })
|
||||
}
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ data })
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH /api/exclusivities/[id]
|
||||
* Update an exclusivity
|
||||
*/
|
||||
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: Partial<Omit<CreateExclusivityInput, 'campaign_id'>> = await request.json()
|
||||
|
||||
// Verify exclusivity exists and belongs to user
|
||||
const { data: existing, error: fetchError } = await supabase
|
||||
.from('exclusivities')
|
||||
.select('*')
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (fetchError) {
|
||||
if (fetchError.code === 'PGRST116') {
|
||||
return NextResponse.json({ error: 'Exclusivity not found' }, { status: 404 })
|
||||
}
|
||||
return NextResponse.json({ error: fetchError.message }, { status: 500 })
|
||||
}
|
||||
|
||||
// Build update object
|
||||
const updateData: Record<string, unknown> = {}
|
||||
|
||||
if (body.categories !== undefined) updateData.categories = body.categories
|
||||
if (body.excluded_brands !== undefined) updateData.excluded_brands = body.excluded_brands
|
||||
if (body.start_date !== undefined) updateData.start_date = body.start_date
|
||||
if (body.end_date !== undefined) updateData.end_date = body.end_date
|
||||
if (body.start_calculation_type !== undefined) updateData.start_calculation_type = body.start_calculation_type
|
||||
if (body.end_calculation_type !== undefined) updateData.end_calculation_type = body.end_calculation_type
|
||||
if (body.start_reference !== undefined) updateData.start_reference = body.start_reference
|
||||
if (body.end_reference !== undefined) updateData.end_reference = body.end_reference
|
||||
if (body.start_offset_days !== undefined) updateData.start_offset_days = body.start_offset_days
|
||||
if (body.end_offset_days !== undefined) updateData.end_offset_days = body.end_offset_days
|
||||
if (body.notes !== undefined) updateData.notes = body.notes
|
||||
|
||||
// Validate date range if dates are being updated
|
||||
const startDate = body.start_date ?? existing.start_date
|
||||
const endDate = body.end_date ?? existing.end_date
|
||||
if (new Date(endDate) < new Date(startDate)) {
|
||||
return NextResponse.json({ error: 'End date must be after start date' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Update the exclusivity
|
||||
const { data, error } = await supabase
|
||||
.from('exclusivities')
|
||||
.update(updateData)
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ data })
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/exclusivities/[id]
|
||||
* Delete an exclusivity
|
||||
*/
|
||||
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 })
|
||||
}
|
||||
|
||||
const { error } = await supabase
|
||||
.from('exclusivities')
|
||||
.delete()
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
|
||||
/**
|
||||
* GET /api/exclusivities/conflicts
|
||||
* Check for exclusivity conflicts
|
||||
* Query params:
|
||||
* - categories: comma-separated list of categories to check
|
||||
* - start_date: ISO date string
|
||||
* - end_date: ISO date string
|
||||
* - exclude_campaign_id: campaign to exclude from check (optional)
|
||||
*/
|
||||
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 categoriesParam = searchParams.get('categories')
|
||||
const startDate = searchParams.get('start_date')
|
||||
const endDate = searchParams.get('end_date')
|
||||
const excludeCampaignId = searchParams.get('exclude_campaign_id')
|
||||
|
||||
if (!categoriesParam || !startDate || !endDate) {
|
||||
return NextResponse.json({
|
||||
error: 'Missing required parameters: categories, start_date, end_date'
|
||||
}, { status: 400 })
|
||||
}
|
||||
|
||||
const categories = categoriesParam.split(',').map(c => c.trim().toLowerCase())
|
||||
|
||||
// Find overlapping exclusivities
|
||||
let query = supabase
|
||||
.from('exclusivities')
|
||||
.select(`
|
||||
*,
|
||||
campaign:campaigns(id, name, customer_id, customer:customers(id, name))
|
||||
`)
|
||||
.eq('user_id', user.id)
|
||||
.lte('start_date', endDate)
|
||||
.gte('end_date', startDate)
|
||||
|
||||
if (excludeCampaignId) {
|
||||
query = query.neq('campaign_id', excludeCampaignId)
|
||||
}
|
||||
|
||||
const { data: existingExclusivities, error } = await query
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
// Find conflicts
|
||||
const conflicts = []
|
||||
if (existingExclusivities) {
|
||||
for (const existing of existingExclusivities) {
|
||||
const overlappingCategories = categories.filter(cat =>
|
||||
existing.categories.some((existingCat: string) =>
|
||||
existingCat.toLowerCase() === cat
|
||||
)
|
||||
)
|
||||
|
||||
if (overlappingCategories.length > 0) {
|
||||
// Calculate exact overlap period
|
||||
const overlapStart = startDate > existing.start_date ? startDate : existing.start_date
|
||||
const overlapEnd = endDate < existing.end_date ? endDate : existing.end_date
|
||||
|
||||
conflicts.push({
|
||||
exclusivity_id: existing.id,
|
||||
campaign_id: existing.campaign?.id,
|
||||
campaign_name: existing.campaign?.name,
|
||||
customer_name: existing.campaign?.customer?.name,
|
||||
categories: existing.categories,
|
||||
overlapping_categories: overlappingCategories,
|
||||
exclusivity_start: existing.start_date,
|
||||
exclusivity_end: existing.end_date,
|
||||
overlap_start: overlapStart,
|
||||
overlap_end: overlapEnd,
|
||||
overlap_days: Math.ceil(
|
||||
(new Date(overlapEnd).getTime() - new Date(overlapStart).getTime()) / (1000 * 60 * 60 * 24)
|
||||
) + 1
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
has_conflicts: conflicts.length > 0,
|
||||
conflicts,
|
||||
checked: {
|
||||
categories,
|
||||
start_date: startDate,
|
||||
end_date: endDate
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { classifyGift } from '@/lib/benefits/gift-classifier'
|
||||
import { createGiftJournalEntry } from '@/lib/benefits/gift-booking'
|
||||
import type { CreateGiftInput, GiftInput, Gift } from '@/types'
|
||||
|
||||
/**
|
||||
* GET /api/gifts/[id]
|
||||
* Get a single gift by ID
|
||||
*/
|
||||
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('gifts')
|
||||
.select('*')
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
if (error.code === 'PGRST116') {
|
||||
return NextResponse.json({ error: 'Gift not found' }, { status: 404 })
|
||||
}
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ data })
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT /api/gifts/[id]
|
||||
* Update a gift with re-classification
|
||||
*/
|
||||
export async function PUT(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: Partial<CreateGiftInput> = await request.json()
|
||||
|
||||
// First, get existing gift to merge with updates
|
||||
const { data: existing, error: fetchError } = await supabase
|
||||
.from('gifts')
|
||||
.select('*')
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (fetchError) {
|
||||
if (fetchError.code === 'PGRST116') {
|
||||
return NextResponse.json({ error: 'Gift not found' }, { status: 404 })
|
||||
}
|
||||
return NextResponse.json({ error: fetchError.message }, { status: 500 })
|
||||
}
|
||||
|
||||
// Merge existing with updates
|
||||
const merged = {
|
||||
date: body.date ?? existing.date,
|
||||
brand_name: body.brand_name ?? existing.brand_name,
|
||||
description: body.description ?? existing.description,
|
||||
estimated_value: body.estimated_value ?? existing.estimated_value,
|
||||
has_motprestation: body.has_motprestation ?? existing.has_motprestation,
|
||||
used_in_business: body.used_in_business ?? existing.used_in_business,
|
||||
used_privately: body.used_privately ?? existing.used_privately,
|
||||
is_simple_promo: body.is_simple_promo ?? existing.is_simple_promo,
|
||||
}
|
||||
|
||||
// Re-classify with updated values
|
||||
const classificationInput: GiftInput = {
|
||||
estimatedValue: merged.estimated_value,
|
||||
hasMotprestation: merged.has_motprestation,
|
||||
usedInBusiness: merged.used_in_business,
|
||||
usedPrivately: merged.used_privately,
|
||||
isSimplePromoItem: merged.is_simple_promo,
|
||||
}
|
||||
const classification = classifyGift(classificationInput)
|
||||
|
||||
// Update the gift
|
||||
const { data, error } = await supabase
|
||||
.from('gifts')
|
||||
.update({
|
||||
...merged,
|
||||
classification,
|
||||
})
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
// Handle journal entry update
|
||||
// If classification changed to/from taxable, we may need to create/update entry
|
||||
const wasBookable = existing.classification?.taxable && existing.journal_entry_id
|
||||
const isBookable = classification.taxable
|
||||
|
||||
if (isBookable && !existing.journal_entry_id) {
|
||||
// Need to create a new journal entry
|
||||
try {
|
||||
const journalEntry = await createGiftJournalEntry(user.id, data as Gift)
|
||||
if (journalEntry) {
|
||||
await supabase
|
||||
.from('gifts')
|
||||
.update({ journal_entry_id: journalEntry.id })
|
||||
.eq('id', id)
|
||||
data.journal_entry_id = journalEntry.id
|
||||
}
|
||||
} catch (bookingError) {
|
||||
console.error('Failed to create gift journal entry:', bookingError)
|
||||
return NextResponse.json({
|
||||
data,
|
||||
warning: 'Gåvan uppdaterades men bokföring kunde inte skapas.',
|
||||
})
|
||||
}
|
||||
} else if (isBookable && existing.journal_entry_id) {
|
||||
// Classification changed but still taxable - add warning that old entry may need reversal
|
||||
return NextResponse.json({
|
||||
data,
|
||||
warning: 'Klassificeringen ändrades. Den befintliga verifikationen kan behöva makuleras manuellt.',
|
||||
})
|
||||
}
|
||||
|
||||
return NextResponse.json({ data })
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/gifts/[id]
|
||||
* Delete a gift
|
||||
*/
|
||||
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 })
|
||||
}
|
||||
|
||||
const { error } = await supabase.from('gifts').delete().eq('id', id).eq('user_id', user.id)
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { estimateProductValue } from '@/lib/receipts/receipt-analyzer'
|
||||
|
||||
/**
|
||||
* POST /api/gifts/estimate
|
||||
* Lightweight endpoint: accepts an image, returns AI price estimate.
|
||||
* Does NOT upload to storage, classify, or write to DB.
|
||||
*/
|
||||
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: 'Image is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
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 }
|
||||
)
|
||||
}
|
||||
|
||||
const arrayBuffer = await imageFile.arrayBuffer()
|
||||
const base64 = Buffer.from(arrayBuffer).toString('base64')
|
||||
const mimeType = imageFile.type as 'image/jpeg' | 'image/png' | 'image/webp' | 'image/gif'
|
||||
|
||||
const estimation = await estimateProductValue(base64, mimeType)
|
||||
|
||||
return NextResponse.json({ data: estimation })
|
||||
} catch (error) {
|
||||
console.error('Gift estimation error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : 'Estimation failed' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { classifyGift, classifyGiftForEntity } from '@/lib/benefits/gift-classifier'
|
||||
import { createGiftJournalEntry } from '@/lib/benefits/gift-booking'
|
||||
import { calculateGiftVirtualTaxDebt } from '@/lib/tax/light-calculator'
|
||||
import type { CreateGiftInput, GiftInput, Gift, EntityType } from '@/types'
|
||||
|
||||
/**
|
||||
* GET /api/gifts
|
||||
* List gifts for the authenticated user
|
||||
* Query params: year (optional, defaults to current year)
|
||||
*/
|
||||
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 year = searchParams.get('year') || new Date().getFullYear().toString()
|
||||
|
||||
// Build date range for the year
|
||||
const startDate = `${year}-01-01`
|
||||
const endDate = `${year}-12-31`
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('gifts')
|
||||
.select('*')
|
||||
.eq('user_id', user.id)
|
||||
.gte('date', startDate)
|
||||
.lte('date', endDate)
|
||||
.order('date', { ascending: false })
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ data })
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/gifts
|
||||
* Create a new gift with auto-classification
|
||||
*/
|
||||
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: CreateGiftInput = await request.json()
|
||||
|
||||
// Validate required fields
|
||||
if (!body.date || !body.brand_name || !body.description || body.estimated_value === undefined) {
|
||||
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Fetch entity type and tax settings
|
||||
const { data: settings } = await supabase
|
||||
.from('company_settings')
|
||||
.select('entity_type, municipal_tax_rate, church_tax, church_tax_rate')
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
const entityType: EntityType = (settings?.entity_type as EntityType) || 'enskild_firma'
|
||||
|
||||
// Build classification input
|
||||
const classificationInput: GiftInput = {
|
||||
estimatedValue: body.estimated_value,
|
||||
hasMotprestation: body.has_motprestation,
|
||||
usedInBusiness: body.used_in_business,
|
||||
usedPrivately: body.used_privately,
|
||||
isSimplePromoItem: body.is_simple_promo || false,
|
||||
}
|
||||
|
||||
// Classify the gift using entity-type-aware classifier
|
||||
const classification = classifyGiftForEntity(classificationInput, entityType)
|
||||
|
||||
// Insert the gift with classification
|
||||
const { data, error } = await supabase
|
||||
.from('gifts')
|
||||
.insert({
|
||||
user_id: user.id,
|
||||
date: body.date,
|
||||
brand_name: body.brand_name,
|
||||
description: body.description,
|
||||
estimated_value: body.estimated_value,
|
||||
has_motprestation: body.has_motprestation,
|
||||
used_in_business: body.used_in_business,
|
||||
used_privately: body.used_privately,
|
||||
is_simple_promo: body.is_simple_promo || false,
|
||||
classification,
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
if (entityType === 'light') {
|
||||
// Light mode: create shadow_ledger_entry instead of journal entry
|
||||
if (classification.taxable && data) {
|
||||
try {
|
||||
const municipalRate = Number(settings?.municipal_tax_rate) || 0.3238
|
||||
const churchRate = settings?.church_tax ? (Number(settings?.church_tax_rate) || 0.01) : 0
|
||||
const virtualTaxDebt = calculateGiftVirtualTaxDebt(
|
||||
body.estimated_value,
|
||||
municipalRate,
|
||||
churchRate
|
||||
)
|
||||
|
||||
await supabase
|
||||
.from('shadow_ledger_entries')
|
||||
.insert({
|
||||
user_id: user.id,
|
||||
date: body.date,
|
||||
type: 'gift',
|
||||
source: 'manual',
|
||||
gross_amount: body.estimated_value,
|
||||
net_amount: body.estimated_value,
|
||||
description: `Gåva: ${body.description} (${body.brand_name})`,
|
||||
gift_id: data.id,
|
||||
virtual_tax_debt: virtualTaxDebt,
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('Failed to create shadow ledger entry for gift:', err)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// EF/AB mode: create journal entry for taxable gifts
|
||||
let journalEntryId: string | null = null
|
||||
if (classification.taxable && data) {
|
||||
try {
|
||||
const journalEntry = await createGiftJournalEntry(user.id, data as Gift)
|
||||
if (journalEntry) {
|
||||
journalEntryId = journalEntry.id
|
||||
|
||||
// Update gift with journal entry reference
|
||||
await supabase
|
||||
.from('gifts')
|
||||
.update({ journal_entry_id: journalEntryId })
|
||||
.eq('id', data.id)
|
||||
|
||||
// Update the returned data
|
||||
data.journal_entry_id = journalEntryId
|
||||
}
|
||||
} catch (bookingError) {
|
||||
console.error('Failed to create gift journal entry:', bookingError)
|
||||
return NextResponse.json({
|
||||
data,
|
||||
warning: 'Gåvan sparades men bokföring kunde inte skapas. Kontrollera att räkenskapsår finns.',
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ data })
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import type { Gift, GiftSummary } from '@/types'
|
||||
|
||||
/**
|
||||
* GET /api/gifts/summary
|
||||
* Get gift summary for a year (used in dashboard and reports)
|
||||
* Query params: year (optional, defaults to current year)
|
||||
*/
|
||||
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 yearParam = searchParams.get('year')
|
||||
const year = yearParam ? parseInt(yearParam) : new Date().getFullYear()
|
||||
|
||||
// Build date range for the year
|
||||
const startDate = `${year}-01-01`
|
||||
const endDate = `${year}-12-31`
|
||||
|
||||
const { data: gifts, error } = await supabase
|
||||
.from('gifts')
|
||||
.select('estimated_value, classification')
|
||||
.eq('user_id', user.id)
|
||||
.gte('date', startDate)
|
||||
.lte('date', endDate)
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
// Calculate summary
|
||||
const summary: GiftSummary = {
|
||||
year,
|
||||
total_count: gifts.length,
|
||||
total_value: 0,
|
||||
taxable_count: 0,
|
||||
taxable_value: 0,
|
||||
tax_free_count: 0,
|
||||
tax_free_value: 0,
|
||||
deductible_count: 0,
|
||||
deductible_value: 0,
|
||||
}
|
||||
|
||||
for (const gift of gifts as Pick<Gift, 'estimated_value' | 'classification'>[]) {
|
||||
const value = Number(gift.estimated_value)
|
||||
const classification = gift.classification
|
||||
|
||||
summary.total_value += value
|
||||
|
||||
if (classification?.taxable) {
|
||||
summary.taxable_count++
|
||||
summary.taxable_value += value
|
||||
} else {
|
||||
summary.tax_free_count++
|
||||
summary.tax_free_value += value
|
||||
}
|
||||
|
||||
if (classification?.deductibleAsExpense) {
|
||||
summary.deductible_count++
|
||||
summary.deductible_value += value
|
||||
}
|
||||
}
|
||||
|
||||
// Round values to 2 decimal places
|
||||
summary.total_value = Math.round(summary.total_value * 100) / 100
|
||||
summary.taxable_value = Math.round(summary.taxable_value * 100) / 100
|
||||
summary.tax_free_value = Math.round(summary.tax_free_value * 100) / 100
|
||||
summary.deductible_value = Math.round(summary.deductible_value * 100) / 100
|
||||
|
||||
return NextResponse.json({ data: summary })
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
|
||||
/**
|
||||
* GET /api/import/sie/[id]
|
||||
* Get details of a specific SIE import
|
||||
*/
|
||||
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('sie_imports')
|
||||
.select('*')
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return NextResponse.json({ error: 'Import not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ data })
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/import/sie/[id]
|
||||
* Delete an import record (does not delete created journal entries)
|
||||
*/
|
||||
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 })
|
||||
}
|
||||
|
||||
const { error } = await supabase
|
||||
.from('sie_imports')
|
||||
.delete()
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import type { SIEAccount } from '@/lib/import/types'
|
||||
|
||||
/**
|
||||
* Determine account type based on account class (first digit)
|
||||
*/
|
||||
function getAccountType(accountNumber: string): 'asset' | 'equity' | 'liability' | 'revenue' | 'expense' {
|
||||
const firstDigit = parseInt(accountNumber.charAt(0), 10)
|
||||
|
||||
switch (firstDigit) {
|
||||
case 1:
|
||||
return 'asset'
|
||||
case 2:
|
||||
// 20xx-20xx is equity, 21xx-29xx is liability
|
||||
const group = parseInt(accountNumber.substring(0, 2), 10)
|
||||
return group <= 20 ? 'equity' : 'liability'
|
||||
case 3:
|
||||
return 'revenue'
|
||||
case 4:
|
||||
case 5:
|
||||
case 6:
|
||||
case 7:
|
||||
return 'expense'
|
||||
case 8:
|
||||
// 8xxx can be either revenue (83xx interest income) or expense
|
||||
const subGroup = parseInt(accountNumber.substring(0, 2), 10)
|
||||
return subGroup >= 83 && subGroup <= 84 ? 'revenue' : 'expense'
|
||||
default:
|
||||
return 'expense'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine normal balance based on account type
|
||||
*/
|
||||
function getNormalBalance(accountType: string): 'debit' | 'credit' {
|
||||
switch (accountType) {
|
||||
case 'asset':
|
||||
case 'expense':
|
||||
return 'debit'
|
||||
case 'equity':
|
||||
case 'liability':
|
||||
case 'revenue':
|
||||
return 'credit'
|
||||
default:
|
||||
return 'debit'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/import/sie/create-accounts
|
||||
* Create missing accounts from SIE file definitions
|
||||
*/
|
||||
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 accounts: SIEAccount[] = body.accounts
|
||||
|
||||
if (!accounts || !Array.isArray(accounts) || accounts.length === 0) {
|
||||
return NextResponse.json({ error: 'No accounts provided' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Fetch existing accounts to avoid duplicates
|
||||
const { data: existingAccounts } = await supabase
|
||||
.from('chart_of_accounts')
|
||||
.select('account_number')
|
||||
.eq('user_id', user.id)
|
||||
|
||||
const existingNumbers = new Set(existingAccounts?.map(a => a.account_number) || [])
|
||||
|
||||
// Filter to only accounts that don't exist
|
||||
const newAccounts = accounts.filter(a => !existingNumbers.has(a.number))
|
||||
|
||||
if (newAccounts.length === 0) {
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
created: 0,
|
||||
message: 'All accounts already exist'
|
||||
})
|
||||
}
|
||||
|
||||
// Prepare accounts for insertion
|
||||
const accountsToInsert = newAccounts.map(account => {
|
||||
const accountClass = parseInt(account.number.charAt(0), 10) || 1
|
||||
const accountGroup = account.number.substring(0, 2)
|
||||
const accountType = getAccountType(account.number)
|
||||
const normalBalance = getNormalBalance(accountType)
|
||||
|
||||
return {
|
||||
user_id: user.id,
|
||||
account_number: account.number,
|
||||
account_name: account.name,
|
||||
account_class: accountClass,
|
||||
account_group: accountGroup,
|
||||
account_type: accountType,
|
||||
normal_balance: normalBalance,
|
||||
plan_type: 'full_bas',
|
||||
is_active: true,
|
||||
is_system_account: false, // User-created via import
|
||||
sort_order: parseInt(account.number, 10) || 0,
|
||||
}
|
||||
})
|
||||
|
||||
// Insert in batches of 100 to avoid timeout
|
||||
const batchSize = 100
|
||||
let totalCreated = 0
|
||||
|
||||
for (let i = 0; i < accountsToInsert.length; i += batchSize) {
|
||||
const batch = accountsToInsert.slice(i, i + batchSize)
|
||||
|
||||
const { error } = await supabase
|
||||
.from('chart_of_accounts')
|
||||
.insert(batch)
|
||||
|
||||
if (error) {
|
||||
console.error('Error inserting accounts batch:', error)
|
||||
return NextResponse.json({
|
||||
error: `Failed to create accounts: ${error.message}`,
|
||||
created: totalCreated,
|
||||
}, { status: 500 })
|
||||
}
|
||||
|
||||
totalCreated += batch.length
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
created: totalCreated,
|
||||
message: `Created ${totalCreated} new accounts`,
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
console.error('Create accounts error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : 'Failed to create accounts' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { parseSIEFile, detectEncoding, decodeBuffer } from '@/lib/import/sie-parser'
|
||||
import { suggestMappings } from '@/lib/import/account-mapper'
|
||||
import { executeSIEImport } from '@/lib/import/sie-import'
|
||||
import type { AccountMapping, SIEAccountMappingRecord } from '@/lib/import/types'
|
||||
|
||||
/**
|
||||
* POST /api/import/sie/execute
|
||||
* Execute the SIE import
|
||||
*/
|
||||
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 {
|
||||
// Get form data with file and options
|
||||
const formData = await request.formData()
|
||||
const file = formData.get('file') as File | null
|
||||
const mappingsJson = formData.get('mappings') as string | null
|
||||
const optionsJson = formData.get('options') as string | null
|
||||
|
||||
if (!file) {
|
||||
return NextResponse.json({ error: 'No file provided' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Parse options
|
||||
const options = optionsJson ? JSON.parse(optionsJson) : {
|
||||
createFiscalPeriod: true,
|
||||
importOpeningBalances: true,
|
||||
importTransactions: true,
|
||||
voucherSeries: 'B',
|
||||
}
|
||||
|
||||
// Read and decode file
|
||||
const arrayBuffer = await file.arrayBuffer()
|
||||
const encoding = detectEncoding(arrayBuffer)
|
||||
const content = decodeBuffer(arrayBuffer, encoding)
|
||||
|
||||
// Parse the SIE file
|
||||
const parsed = parseSIEFile(content)
|
||||
|
||||
// Get mappings - either from request or generate new ones
|
||||
let mappings: AccountMapping[]
|
||||
|
||||
if (mappingsJson) {
|
||||
mappings = JSON.parse(mappingsJson)
|
||||
} else {
|
||||
// Fetch user's chart of accounts and generate mappings
|
||||
const { data: basAccounts } = await supabase
|
||||
.from('chart_of_accounts')
|
||||
.select('*')
|
||||
.eq('user_id', user.id)
|
||||
.eq('is_active', true)
|
||||
.order('account_number')
|
||||
|
||||
if (!basAccounts || basAccounts.length === 0) {
|
||||
return NextResponse.json({
|
||||
error: 'No chart of accounts found. Please complete onboarding first.',
|
||||
}, { status: 400 })
|
||||
}
|
||||
|
||||
// Load stored mappings
|
||||
const { data: storedMappings } = await supabase
|
||||
.from('sie_account_mappings')
|
||||
.select('*')
|
||||
.eq('user_id', user.id)
|
||||
|
||||
mappings = suggestMappings(
|
||||
parsed.accounts,
|
||||
basAccounts,
|
||||
(storedMappings as SIEAccountMappingRecord[]) || undefined
|
||||
)
|
||||
}
|
||||
|
||||
// Validate all accounts are mapped
|
||||
const unmapped = mappings.filter((m) => !m.targetAccount)
|
||||
if (unmapped.length > 0) {
|
||||
return NextResponse.json({
|
||||
error: 'validation',
|
||||
message: `${unmapped.length} account(s) are not mapped`,
|
||||
unmappedAccounts: unmapped.map((m) => ({
|
||||
account: m.sourceAccount,
|
||||
name: m.sourceName,
|
||||
})),
|
||||
}, { status: 400 })
|
||||
}
|
||||
|
||||
// Execute the import
|
||||
const result = await executeSIEImport(
|
||||
user.id,
|
||||
parsed,
|
||||
mappings,
|
||||
{
|
||||
filename: file.name,
|
||||
fileContent: content,
|
||||
createFiscalPeriod: options.createFiscalPeriod,
|
||||
importOpeningBalances: options.importOpeningBalances,
|
||||
importTransactions: options.importTransactions,
|
||||
voucherSeries: options.voucherSeries || 'B',
|
||||
}
|
||||
)
|
||||
|
||||
if (!result.success) {
|
||||
return NextResponse.json({
|
||||
error: 'import',
|
||||
message: 'Import completed with errors',
|
||||
result,
|
||||
}, { status: 400 })
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
result,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('SIE import error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : 'Failed to import SIE file' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { saveMappings } from '@/lib/import/sie-import'
|
||||
import type { AccountMapping } from '@/lib/import/types'
|
||||
|
||||
/**
|
||||
* GET /api/import/sie/mappings
|
||||
* Get all saved account mappings for the user
|
||||
*/
|
||||
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 { data, error } = await supabase
|
||||
.from('sie_account_mappings')
|
||||
.select('*')
|
||||
.eq('user_id', user.id)
|
||||
.order('source_account')
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ data })
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/import/sie/mappings
|
||||
* Save account mappings (bulk upsert)
|
||||
*/
|
||||
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 mappings: AccountMapping[] = body.mappings
|
||||
|
||||
if (!mappings || !Array.isArray(mappings)) {
|
||||
return NextResponse.json({ error: 'Invalid mappings data' }, { status: 400 })
|
||||
}
|
||||
|
||||
try {
|
||||
await saveMappings(user.id, mappings)
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : 'Failed to save mappings' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT /api/import/sie/mappings
|
||||
* Update a single mapping
|
||||
*/
|
||||
export async function PUT(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 { sourceAccount, targetAccount } = body
|
||||
|
||||
if (!sourceAccount || !targetAccount) {
|
||||
return NextResponse.json(
|
||||
{ error: 'sourceAccount and targetAccount are required' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('sie_account_mappings')
|
||||
.upsert({
|
||||
user_id: user.id,
|
||||
source_account: sourceAccount,
|
||||
target_account: targetAccount,
|
||||
confidence: 1.0,
|
||||
match_type: 'manual',
|
||||
}, {
|
||||
onConflict: 'user_id,source_account',
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ data })
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/import/sie/mappings
|
||||
* Delete a specific mapping or all mappings
|
||||
*/
|
||||
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 { searchParams } = new URL(request.url)
|
||||
const sourceAccount = searchParams.get('sourceAccount')
|
||||
|
||||
if (sourceAccount) {
|
||||
// Delete specific mapping
|
||||
const { error } = await supabase
|
||||
.from('sie_account_mappings')
|
||||
.delete()
|
||||
.eq('user_id', user.id)
|
||||
.eq('source_account', sourceAccount)
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
} else {
|
||||
// Delete all mappings
|
||||
const { error } = await supabase
|
||||
.from('sie_account_mappings')
|
||||
.delete()
|
||||
.eq('user_id', user.id)
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import {
|
||||
parseSIEFile,
|
||||
validateSIEFile,
|
||||
detectEncoding,
|
||||
decodeBuffer,
|
||||
calculateFileHash,
|
||||
} from '@/lib/import/sie-parser'
|
||||
import { suggestMappings, getMappingStats } from '@/lib/import/account-mapper'
|
||||
import { generateImportPreview, checkDuplicateImport } from '@/lib/import/sie-import'
|
||||
import type { SIEAccountMappingRecord, SIEAccount } from '@/lib/import/types'
|
||||
|
||||
/**
|
||||
* POST /api/import/sie/parse
|
||||
* Parse an uploaded SIE file and return preview data
|
||||
*/
|
||||
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 {
|
||||
// Get form data with file
|
||||
const formData = await request.formData()
|
||||
const file = formData.get('file') as File | null
|
||||
|
||||
if (!file) {
|
||||
return NextResponse.json({ error: 'No file provided' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Validate file type
|
||||
const filename = file.name.toLowerCase()
|
||||
if (!filename.endsWith('.sie') && !filename.endsWith('.se')) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid file type. Please upload a .sie file' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Read file as ArrayBuffer for encoding detection
|
||||
const arrayBuffer = await file.arrayBuffer()
|
||||
const encoding = detectEncoding(arrayBuffer)
|
||||
|
||||
// Decode to string
|
||||
const content = decodeBuffer(arrayBuffer, encoding)
|
||||
|
||||
// Check for duplicate import
|
||||
const duplicate = await checkDuplicateImport(user.id, content)
|
||||
if (duplicate) {
|
||||
return NextResponse.json({
|
||||
error: 'duplicate',
|
||||
message: `This file has already been imported on ${new Date(duplicate.imported_at!).toLocaleDateString('sv-SE')}`,
|
||||
importId: duplicate.id,
|
||||
}, { status: 409 })
|
||||
}
|
||||
|
||||
// Parse the SIE file
|
||||
const parsed = parseSIEFile(content)
|
||||
|
||||
// Validate the parsed data
|
||||
const validation = validateSIEFile(parsed)
|
||||
|
||||
// If there are critical errors, return them
|
||||
if (!validation.valid) {
|
||||
return NextResponse.json({
|
||||
error: 'validation',
|
||||
message: 'SIE file has validation errors',
|
||||
errors: validation.errors,
|
||||
warnings: validation.warnings,
|
||||
}, { status: 400 })
|
||||
}
|
||||
|
||||
// Fetch user's chart of accounts
|
||||
const { data: basAccounts } = await supabase
|
||||
.from('chart_of_accounts')
|
||||
.select('*')
|
||||
.eq('user_id', user.id)
|
||||
.eq('is_active', true)
|
||||
.order('account_number')
|
||||
|
||||
if (!basAccounts || basAccounts.length === 0) {
|
||||
return NextResponse.json({
|
||||
error: 'No chart of accounts found. Please complete onboarding first.',
|
||||
}, { status: 400 })
|
||||
}
|
||||
|
||||
// Fetch stored mappings from database
|
||||
const { data: storedMappings } = await supabase
|
||||
.from('sie_account_mappings')
|
||||
.select('*')
|
||||
.eq('user_id', user.id)
|
||||
|
||||
// Suggest account mappings
|
||||
const mappings = suggestMappings(
|
||||
parsed.accounts,
|
||||
basAccounts,
|
||||
(storedMappings as SIEAccountMappingRecord[]) || undefined
|
||||
)
|
||||
|
||||
// Generate preview
|
||||
const preview = generateImportPreview(parsed, mappings)
|
||||
|
||||
// Calculate file hash for storage
|
||||
const fileHash = await calculateFileHash(content)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
encoding,
|
||||
fileHash,
|
||||
parsed: {
|
||||
header: parsed.header,
|
||||
accounts: parsed.accounts,
|
||||
stats: parsed.stats,
|
||||
issues: parsed.issues,
|
||||
},
|
||||
mappings,
|
||||
mappingStats: getMappingStats(mappings),
|
||||
preview,
|
||||
validation: {
|
||||
valid: validation.valid,
|
||||
errors: validation.errors,
|
||||
warnings: validation.warnings,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('SIE parse error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : 'Failed to parse SIE file' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
|
||||
/**
|
||||
* GET /api/import/sie
|
||||
* List all SIE imports for the 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 })
|
||||
}
|
||||
|
||||
// Parse query params
|
||||
const { searchParams } = new URL(request.url)
|
||||
const limit = parseInt(searchParams.get('limit') || '20', 10)
|
||||
const offset = parseInt(searchParams.get('offset') || '0', 10)
|
||||
const status = searchParams.get('status')
|
||||
|
||||
let query = supabase
|
||||
.from('sie_imports')
|
||||
.select('*', { 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) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
data,
|
||||
count,
|
||||
limit,
|
||||
offset,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { renderToBuffer } from '@react-pdf/renderer'
|
||||
import { InvoicePDF } from '@/lib/invoice/pdf-template'
|
||||
import type { Invoice, InvoiceItem, Customer, CompanySettings } from '@/types'
|
||||
|
||||
export async function GET(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params
|
||||
const supabase = await createClient()
|
||||
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Fetch invoice with customer and items
|
||||
const { data: invoice, error: invoiceError } = await supabase
|
||||
.from('invoices')
|
||||
.select(`
|
||||
*,
|
||||
customer:customers(*),
|
||||
items:invoice_items(*)
|
||||
`)
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (invoiceError || !invoice) {
|
||||
return NextResponse.json({ error: 'Invoice not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Fetch company settings
|
||||
const { data: company, error: companyError } = await supabase
|
||||
.from('company_settings')
|
||||
.select('*')
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (companyError || !company) {
|
||||
return NextResponse.json({ error: 'Company settings not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Sort items by sort_order
|
||||
const items = (invoice.items as InvoiceItem[]).sort((a, b) => a.sort_order - b.sort_order)
|
||||
|
||||
// If this is a credit note, fetch the original invoice number
|
||||
let originalInvoiceNumber: string | undefined
|
||||
if (invoice.credited_invoice_id) {
|
||||
const { data: originalInvoice } = await supabase
|
||||
.from('invoices')
|
||||
.select('invoice_number')
|
||||
.eq('id', invoice.credited_invoice_id)
|
||||
.single()
|
||||
|
||||
if (originalInvoice) {
|
||||
originalInvoiceNumber = originalInvoice.invoice_number
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// Generate PDF
|
||||
const pdfBuffer = await renderToBuffer(
|
||||
InvoicePDF({
|
||||
invoice: invoice as Invoice,
|
||||
customer: invoice.customer as Customer,
|
||||
items,
|
||||
company: company as CompanySettings,
|
||||
originalInvoiceNumber,
|
||||
})
|
||||
)
|
||||
|
||||
// Convert Node.js Buffer to Uint8Array for Response
|
||||
const uint8Array = new Uint8Array(pdfBuffer)
|
||||
|
||||
// Return PDF as response
|
||||
const isCreditNote = !!invoice.credited_invoice_id
|
||||
const filename = isCreditNote
|
||||
? `kreditfaktura-${invoice.invoice_number}.pdf`
|
||||
: `faktura-${invoice.invoice_number}.pdf`
|
||||
|
||||
return new NextResponse(uint8Array, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/pdf',
|
||||
'Content-Disposition': `attachment; filename="${filename}"`,
|
||||
'Content-Length': pdfBuffer.length.toString(),
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('PDF generation error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : 'PDF generation failed' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { renderToBuffer } from '@react-pdf/renderer'
|
||||
import { InvoicePDF } from '@/lib/invoice/pdf-template'
|
||||
import { sendEmail, isResendConfigured } from '@/lib/email/resend'
|
||||
import {
|
||||
generateInvoiceEmailHtml,
|
||||
generateInvoiceEmailText,
|
||||
generateInvoiceEmailSubject
|
||||
} from '@/lib/email/invoice-templates'
|
||||
import type { Invoice, InvoiceItem, Customer, CompanySettings } from '@/types'
|
||||
|
||||
export async function POST(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params
|
||||
const supabase = await createClient()
|
||||
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Check if Resend is configured
|
||||
if (!isResendConfigured()) {
|
||||
return NextResponse.json(
|
||||
{ error: 'E-posttjänsten är inte konfigurerad. Kontakta support.' },
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
|
||||
// Fetch invoice with customer and items
|
||||
const { data: invoice, error: invoiceError } = await supabase
|
||||
.from('invoices')
|
||||
.select(`
|
||||
*,
|
||||
customer:customers(*),
|
||||
items:invoice_items(*)
|
||||
`)
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (invoiceError || !invoice) {
|
||||
return NextResponse.json({ error: 'Fakturan hittades inte' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Verify customer has email
|
||||
const customer = invoice.customer as Customer
|
||||
if (!customer.email) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Kunden saknar e-postadress. Uppdatera kunduppgifterna först.' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Fetch company settings
|
||||
const { data: company, error: companyError } = await supabase
|
||||
.from('company_settings')
|
||||
.select('*')
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (companyError || !company) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Företagsinställningar saknas' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
// Sort items by sort_order
|
||||
const items = (invoice.items as InvoiceItem[]).sort(
|
||||
(a, b) => a.sort_order - b.sort_order
|
||||
)
|
||||
|
||||
// If this is a credit note, fetch the original invoice number
|
||||
let originalInvoiceNumber: string | undefined
|
||||
if (invoice.credited_invoice_id) {
|
||||
const { data: originalInvoice } = await supabase
|
||||
.from('invoices')
|
||||
.select('invoice_number')
|
||||
.eq('id', invoice.credited_invoice_id)
|
||||
.single()
|
||||
|
||||
if (originalInvoice) {
|
||||
originalInvoiceNumber = originalInvoice.invoice_number
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// Generate PDF
|
||||
const pdfBuffer = await renderToBuffer(
|
||||
InvoicePDF({
|
||||
invoice: invoice as Invoice,
|
||||
customer,
|
||||
items,
|
||||
company: company as CompanySettings,
|
||||
originalInvoiceNumber,
|
||||
})
|
||||
)
|
||||
|
||||
// Prepare email data
|
||||
const emailData = {
|
||||
invoice: invoice as Invoice,
|
||||
customer,
|
||||
company: company as CompanySettings
|
||||
}
|
||||
|
||||
// Determine filename
|
||||
const isCreditNote = !!invoice.credited_invoice_id
|
||||
const filename = isCreditNote
|
||||
? `kreditfaktura-${invoice.invoice_number}.pdf`
|
||||
: `faktura-${invoice.invoice_number}.pdf`
|
||||
|
||||
// Send email
|
||||
const result = await sendEmail({
|
||||
to: customer.email,
|
||||
subject: generateInvoiceEmailSubject(emailData),
|
||||
html: generateInvoiceEmailHtml(emailData),
|
||||
text: generateInvoiceEmailText(emailData),
|
||||
replyTo: (company as CompanySettings & { email?: string }).email || undefined,
|
||||
fromName: company.company_name,
|
||||
attachments: [
|
||||
{
|
||||
filename,
|
||||
content: pdfBuffer,
|
||||
contentType: 'application/pdf'
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
if (!result.success) {
|
||||
console.error('Failed to send invoice email:', result.error)
|
||||
return NextResponse.json(
|
||||
{ error: `Kunde inte skicka e-post: ${result.error}` },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
// Update invoice status to "sent" and set sent_at timestamp
|
||||
const { error: updateError } = await supabase
|
||||
.from('invoices')
|
||||
.update({
|
||||
status: 'sent',
|
||||
sent_at: new Date().toISOString()
|
||||
})
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
|
||||
if (updateError) {
|
||||
console.error('Failed to update invoice status:', updateError)
|
||||
// Don't fail the request - the email was sent successfully
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: `Fakturan har skickats till ${customer.email}`,
|
||||
messageId: result.messageId
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Send invoice error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : 'Kunde inte skicka fakturan' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
import { createServerClient } from '@supabase/ssr'
|
||||
import { NextResponse } from 'next/server'
|
||||
|
||||
// Create a service client (no auth needed - public endpoint with token validation)
|
||||
function createServiceClient() {
|
||||
return createServerClient(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||
process.env.SUPABASE_SERVICE_ROLE_KEY!,
|
||||
{
|
||||
cookies: {
|
||||
getAll() { return [] },
|
||||
setAll() { }
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { token, action } = body
|
||||
|
||||
if (!token) {
|
||||
return NextResponse.json({ error: 'Token saknas' }, { status: 400 })
|
||||
}
|
||||
|
||||
if (!action || !['marked_paid', 'disputed'].includes(action)) {
|
||||
return NextResponse.json({ error: 'Ogiltig åtgärd' }, { status: 400 })
|
||||
}
|
||||
|
||||
const supabase = createServiceClient()
|
||||
|
||||
// Find the reminder by action token
|
||||
const { data: reminder, error: findError } = await supabase
|
||||
.from('invoice_reminders')
|
||||
.select(`
|
||||
*,
|
||||
invoice:invoices(
|
||||
id,
|
||||
invoice_number,
|
||||
status,
|
||||
user_id
|
||||
)
|
||||
`)
|
||||
.eq('action_token', token)
|
||||
.single()
|
||||
|
||||
if (findError || !reminder) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Ogiltig eller utgången länk' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
// Check if token was already used
|
||||
if (reminder.action_token_used) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Denna länk har redan använts' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Update the reminder with the response
|
||||
const { error: updateError } = await supabase
|
||||
.from('invoice_reminders')
|
||||
.update({
|
||||
response_type: action,
|
||||
response_at: new Date().toISOString(),
|
||||
action_token_used: true
|
||||
})
|
||||
.eq('id', reminder.id)
|
||||
|
||||
if (updateError) {
|
||||
console.error('Failed to update reminder:', updateError)
|
||||
return NextResponse.json(
|
||||
{ error: 'Kunde inte spara ditt svar' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
// If customer marked as paid, we could optionally notify the business owner
|
||||
// For now, we just log it - the business owner will see it in the UI
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const invoiceData = reminder.invoice as any
|
||||
const invoice = Array.isArray(invoiceData) ? invoiceData[0] : invoiceData
|
||||
console.log(`Customer responded to invoice ${invoice?.invoice_number}: ${action}`)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: action === 'marked_paid'
|
||||
? 'Tack! Vi har noterat att du har betalat fakturan.'
|
||||
: 'Tack! Vi har noterat din invändning och kommer att kontakta dig.'
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Action handler error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Ett fel uppstod' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// GET endpoint to fetch reminder/invoice info by token (for the public page)
|
||||
export async function GET(request: Request) {
|
||||
const url = new URL(request.url)
|
||||
const token = url.searchParams.get('token')
|
||||
|
||||
if (!token) {
|
||||
return NextResponse.json({ error: 'Token saknas' }, { status: 400 })
|
||||
}
|
||||
|
||||
const supabase = createServiceClient()
|
||||
|
||||
// Find the reminder by action token
|
||||
const { data: reminder, error: findError } = await supabase
|
||||
.from('invoice_reminders')
|
||||
.select(`
|
||||
id,
|
||||
reminder_level,
|
||||
sent_at,
|
||||
response_type,
|
||||
action_token_used,
|
||||
invoice:invoices(
|
||||
id,
|
||||
invoice_number,
|
||||
invoice_date,
|
||||
due_date,
|
||||
total,
|
||||
currency,
|
||||
status,
|
||||
customer:customers(
|
||||
name
|
||||
)
|
||||
)
|
||||
`)
|
||||
.eq('action_token', token)
|
||||
.single()
|
||||
|
||||
if (findError || !reminder) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Ogiltig eller utgången länk' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
// Don't expose sensitive data, just what's needed for the public page
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const invoiceData = reminder.invoice as any
|
||||
const invoice = Array.isArray(invoiceData) ? invoiceData[0] : invoiceData
|
||||
|
||||
if (!invoice) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Faktura hittades inte' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
// Handle nested customer which may also be an array
|
||||
const customerData = invoice.customer
|
||||
const customer = Array.isArray(customerData) ? customerData[0] : customerData
|
||||
|
||||
return NextResponse.json({
|
||||
invoiceNumber: invoice.invoice_number,
|
||||
invoiceDate: invoice.invoice_date,
|
||||
dueDate: invoice.due_date,
|
||||
total: invoice.total,
|
||||
currency: invoice.currency,
|
||||
customerName: customer?.name,
|
||||
reminderLevel: reminder.reminder_level,
|
||||
alreadyResponded: reminder.action_token_used,
|
||||
previousResponse: reminder.response_type
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { processOverdueReminders } from '@/lib/invoices/reminder-processor'
|
||||
import { isResendConfigured } from '@/lib/email/resend'
|
||||
|
||||
// Verify cron secret for security
|
||||
function verifyCronSecret(request: Request): boolean {
|
||||
const authHeader = request.headers.get('authorization')
|
||||
const cronSecret = process.env.CRON_SECRET
|
||||
|
||||
if (!cronSecret) {
|
||||
console.error('CRON_SECRET not configured')
|
||||
return false
|
||||
}
|
||||
|
||||
if (!authHeader) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Support both "Bearer <token>" and just "<token>" formats
|
||||
const token = authHeader.startsWith('Bearer ')
|
||||
? authHeader.substring(7)
|
||||
: authHeader
|
||||
|
||||
return token === cronSecret
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
// Verify cron authentication
|
||||
if (!verifyCronSecret(request)) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Check if email service is configured
|
||||
if (!isResendConfigured()) {
|
||||
console.error('Resend not configured, skipping reminder cron')
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: 'Email service not configured'
|
||||
}, { status: 503 })
|
||||
}
|
||||
|
||||
try {
|
||||
console.log('Starting invoice reminder cron job...')
|
||||
|
||||
const result = await processOverdueReminders()
|
||||
|
||||
console.log(`Reminder cron completed: ${result.sent} sent, ${result.failed} failed out of ${result.processed} processed`)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
processed: result.processed,
|
||||
sent: result.sent,
|
||||
failed: result.failed,
|
||||
results: result.results.map(r => ({
|
||||
invoiceNumber: r.invoiceNumber,
|
||||
reminderLevel: r.reminderLevel,
|
||||
success: r.success,
|
||||
error: r.error
|
||||
}))
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Invoice reminder cron job error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : 'Cron job failed' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Also support POST for manual triggering via dashboard
|
||||
export async function POST(request: Request) {
|
||||
return GET(request)
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import type { CreateInvoiceInput, Invoice } from '@/types'
|
||||
import { getVatRules, calculateVat, calculateTotal } from '@/lib/invoice/vat-rules'
|
||||
import { fetchExchangeRate, convertToSEK } from '@/lib/currency/riksbanken'
|
||||
import {
|
||||
createInvoiceJournalEntry,
|
||||
createCreditNoteJournalEntry,
|
||||
} from '@/lib/bookkeeping/invoice-entries'
|
||||
|
||||
interface CreateCreditNoteInput {
|
||||
credited_invoice_id: string
|
||||
reason?: string
|
||||
}
|
||||
|
||||
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')
|
||||
const offset = parseInt(searchParams.get('offset') || '0')
|
||||
|
||||
let query = supabase
|
||||
.from('invoices')
|
||||
.select('*, customer:customers(*)', { count: 'exact' })
|
||||
.eq('user_id', user.id)
|
||||
.order('invoice_date', { ascending: false })
|
||||
.range(offset, offset + limit - 1)
|
||||
|
||||
if (status) {
|
||||
query = query.eq('status', status)
|
||||
}
|
||||
|
||||
const { data, error, count } = await query
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ data, count })
|
||||
}
|
||||
|
||||
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()
|
||||
|
||||
// Check if this is a credit note creation request
|
||||
if (body.credited_invoice_id) {
|
||||
return createCreditNote(supabase, user.id, body as CreateCreditNoteInput)
|
||||
}
|
||||
|
||||
const invoiceInput = body as CreateInvoiceInput
|
||||
|
||||
// Get customer for VAT calculation
|
||||
const { data: customer, error: customerError } = await supabase
|
||||
.from('customers')
|
||||
.select('*')
|
||||
.eq('id', invoiceInput.customer_id)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (customerError || !customer) {
|
||||
return NextResponse.json({ error: 'Customer not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Calculate VAT rules
|
||||
const vatRules = getVatRules(customer.customer_type, customer.vat_number_validated)
|
||||
|
||||
// Calculate subtotal from items
|
||||
const subtotal = invoiceInput.items.reduce((sum, item) => {
|
||||
return sum + item.quantity * item.unit_price
|
||||
}, 0)
|
||||
|
||||
const vatAmount = calculateVat(subtotal, vatRules.rate)
|
||||
const total = subtotal + vatAmount
|
||||
|
||||
// Handle currency conversion
|
||||
let exchangeRate: number | null = null
|
||||
let exchangeRateDate: string | null = null
|
||||
let subtotalSek: number | null = null
|
||||
let vatAmountSek: number | null = null
|
||||
let totalSek: number | null = null
|
||||
|
||||
if (invoiceInput.currency !== 'SEK') {
|
||||
const rateData = await fetchExchangeRate(invoiceInput.currency)
|
||||
if (rateData) {
|
||||
exchangeRate = rateData.rate
|
||||
exchangeRateDate = rateData.date
|
||||
subtotalSek = convertToSEK(subtotal, exchangeRate)
|
||||
vatAmountSek = convertToSEK(vatAmount, exchangeRate)
|
||||
totalSek = convertToSEK(total, exchangeRate)
|
||||
}
|
||||
}
|
||||
|
||||
// Generate invoice number
|
||||
const { data: invoiceNumber } = await supabase.rpc('generate_invoice_number', {
|
||||
p_user_id: user.id,
|
||||
})
|
||||
|
||||
// Create invoice
|
||||
const { data: invoice, error: invoiceError } = await supabase
|
||||
.from('invoices')
|
||||
.insert({
|
||||
user_id: user.id,
|
||||
customer_id: invoiceInput.customer_id,
|
||||
invoice_number: invoiceNumber,
|
||||
invoice_date: invoiceInput.invoice_date,
|
||||
due_date: invoiceInput.due_date,
|
||||
currency: invoiceInput.currency,
|
||||
exchange_rate: exchangeRate,
|
||||
exchange_rate_date: exchangeRateDate,
|
||||
subtotal,
|
||||
subtotal_sek: subtotalSek,
|
||||
vat_amount: vatAmount,
|
||||
vat_amount_sek: vatAmountSek,
|
||||
total,
|
||||
total_sek: totalSek,
|
||||
vat_treatment: vatRules.treatment,
|
||||
vat_rate: vatRules.rate,
|
||||
moms_ruta: vatRules.momsRuta,
|
||||
reverse_charge_text: vatRules.reverseChargeText || null,
|
||||
your_reference: invoiceInput.your_reference,
|
||||
our_reference: invoiceInput.our_reference,
|
||||
notes: invoiceInput.notes,
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (invoiceError) {
|
||||
return NextResponse.json({ error: invoiceError.message }, { status: 500 })
|
||||
}
|
||||
|
||||
// Create invoice items
|
||||
const items = invoiceInput.items.map((item, index) => ({
|
||||
invoice_id: invoice.id,
|
||||
sort_order: index,
|
||||
description: item.description,
|
||||
quantity: item.quantity,
|
||||
unit: item.unit,
|
||||
unit_price: item.unit_price,
|
||||
line_total: item.quantity * item.unit_price,
|
||||
}))
|
||||
|
||||
const { error: itemsError } = await supabase
|
||||
.from('invoice_items')
|
||||
.insert(items)
|
||||
|
||||
if (itemsError) {
|
||||
// Rollback invoice creation
|
||||
await supabase.from('invoices').delete().eq('id', invoice.id)
|
||||
return NextResponse.json({ error: itemsError.message }, { status: 500 })
|
||||
}
|
||||
|
||||
// Fetch complete invoice with items
|
||||
const { data: completeInvoice } = await supabase
|
||||
.from('invoices')
|
||||
.select('*, customer:customers(*), items:invoice_items(*)')
|
||||
.eq('id', invoice.id)
|
||||
.single()
|
||||
|
||||
// Create journal entry for the invoice (non-blocking)
|
||||
if (completeInvoice) {
|
||||
try {
|
||||
const journalEntry = await createInvoiceJournalEntry(
|
||||
user.id,
|
||||
completeInvoice as Invoice
|
||||
)
|
||||
if (journalEntry) {
|
||||
await supabase
|
||||
.from('invoices')
|
||||
.update({ journal_entry_id: journalEntry.id })
|
||||
.eq('id', invoice.id)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to create invoice journal entry:', err)
|
||||
// Don't fail the invoice creation
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ data: completeInvoice })
|
||||
}
|
||||
|
||||
// Create a credit note for an existing invoice
|
||||
async function createCreditNote(
|
||||
supabase: Awaited<ReturnType<typeof createClient>>,
|
||||
userId: string,
|
||||
input: CreateCreditNoteInput
|
||||
) {
|
||||
// Fetch the original invoice with items
|
||||
const { data: originalInvoice, error: originalError } = await supabase
|
||||
.from('invoices')
|
||||
.select('*, items:invoice_items(*)')
|
||||
.eq('id', input.credited_invoice_id)
|
||||
.eq('user_id', userId)
|
||||
.single()
|
||||
|
||||
if (originalError || !originalInvoice) {
|
||||
return NextResponse.json({ error: 'Original invoice not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Check if invoice is already credited
|
||||
if (originalInvoice.status === 'credited') {
|
||||
return NextResponse.json({ error: 'Invoice has already been credited' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Check if invoice can be credited (only sent, paid, or overdue invoices can be credited)
|
||||
if (!['sent', 'paid', 'overdue'].includes(originalInvoice.status)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Only sent, paid, or overdue invoices can be credited' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Generate credit note number
|
||||
const creditNoteNumber = `KR-${originalInvoice.invoice_number}`
|
||||
|
||||
// Create the credit note with negated amounts
|
||||
const { data: creditNote, error: creditNoteError } = await supabase
|
||||
.from('invoices')
|
||||
.insert({
|
||||
user_id: userId,
|
||||
customer_id: originalInvoice.customer_id,
|
||||
invoice_number: creditNoteNumber,
|
||||
invoice_date: new Date().toISOString().split('T')[0],
|
||||
due_date: new Date().toISOString().split('T')[0],
|
||||
currency: originalInvoice.currency,
|
||||
exchange_rate: originalInvoice.exchange_rate,
|
||||
exchange_rate_date: originalInvoice.exchange_rate_date,
|
||||
// Negate all amounts
|
||||
subtotal: -Math.abs(originalInvoice.subtotal),
|
||||
subtotal_sek: originalInvoice.subtotal_sek ? -Math.abs(originalInvoice.subtotal_sek) : null,
|
||||
vat_amount: -Math.abs(originalInvoice.vat_amount),
|
||||
vat_amount_sek: originalInvoice.vat_amount_sek ? -Math.abs(originalInvoice.vat_amount_sek) : null,
|
||||
total: -Math.abs(originalInvoice.total),
|
||||
total_sek: originalInvoice.total_sek ? -Math.abs(originalInvoice.total_sek) : null,
|
||||
// Same VAT treatment as original
|
||||
vat_treatment: originalInvoice.vat_treatment,
|
||||
vat_rate: originalInvoice.vat_rate,
|
||||
moms_ruta: originalInvoice.moms_ruta,
|
||||
reverse_charge_text: originalInvoice.reverse_charge_text,
|
||||
// References
|
||||
your_reference: originalInvoice.your_reference,
|
||||
our_reference: originalInvoice.our_reference,
|
||||
notes: input.reason || `Krediterar faktura ${originalInvoice.invoice_number}`,
|
||||
credited_invoice_id: input.credited_invoice_id,
|
||||
status: 'sent', // Credit notes are immediately "sent"
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (creditNoteError) {
|
||||
return NextResponse.json({ error: creditNoteError.message }, { status: 500 })
|
||||
}
|
||||
|
||||
// Create credit note items (negated from original)
|
||||
const creditNoteItems = (originalInvoice.items || []).map((item: { sort_order: number; description: string; quantity: number; unit: string; unit_price: number; line_total: number }, index: number) => ({
|
||||
invoice_id: creditNote.id,
|
||||
sort_order: item.sort_order,
|
||||
description: item.description,
|
||||
quantity: -Math.abs(item.quantity),
|
||||
unit: item.unit,
|
||||
unit_price: item.unit_price,
|
||||
line_total: -Math.abs(item.line_total),
|
||||
}))
|
||||
|
||||
const { error: itemsError } = await supabase
|
||||
.from('invoice_items')
|
||||
.insert(creditNoteItems)
|
||||
|
||||
if (itemsError) {
|
||||
// Rollback credit note creation
|
||||
await supabase.from('invoices').delete().eq('id', creditNote.id)
|
||||
return NextResponse.json({ error: itemsError.message }, { status: 500 })
|
||||
}
|
||||
|
||||
// Update original invoice status to 'credited'
|
||||
await supabase
|
||||
.from('invoices')
|
||||
.update({ status: 'credited' })
|
||||
.eq('id', input.credited_invoice_id)
|
||||
|
||||
// Fetch complete credit note with items
|
||||
const { data: completeCreditNote } = await supabase
|
||||
.from('invoices')
|
||||
.select('*, customer:customers(*), items:invoice_items(*)')
|
||||
.eq('id', creditNote.id)
|
||||
.single()
|
||||
|
||||
// Create journal entry for the credit note (non-blocking)
|
||||
if (completeCreditNote) {
|
||||
try {
|
||||
const journalEntry = await createCreditNoteJournalEntry(
|
||||
userId,
|
||||
completeCreditNote as Invoice
|
||||
)
|
||||
if (journalEntry) {
|
||||
await supabase
|
||||
.from('invoices')
|
||||
.update({ journal_entry_id: journalEntry.id })
|
||||
.eq('id', creditNote.id)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to create credit note journal entry:', err)
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ data: completeCreditNote })
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { createClient } from '@supabase/supabase-js'
|
||||
import { NextResponse } from 'next/server'
|
||||
import {
|
||||
sendTaxDeadlineNotifications,
|
||||
sendInvoiceNotifications,
|
||||
sendCampaignNotifications,
|
||||
} from '@/lib/push/notification-scheduler'
|
||||
|
||||
/**
|
||||
* GET /api/push/cron
|
||||
* Daily cron job to send push notifications
|
||||
* Runs at 09:00 every day
|
||||
*
|
||||
* Vercel Cron: "0 9 * * *"
|
||||
*/
|
||||
export async function GET(request: Request) {
|
||||
// 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, campaignResult] = await Promise.all([
|
||||
sendTaxDeadlineNotifications(supabase),
|
||||
sendInvoiceNotifications(supabase),
|
||||
sendCampaignNotifications(supabase),
|
||||
])
|
||||
|
||||
const totalSent =
|
||||
taxResult.sent + invoiceResult.sent + campaignResult.sent
|
||||
const totalSkipped =
|
||||
taxResult.skipped + invoiceResult.skipped + campaignResult.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`
|
||||
)
|
||||
console.log(
|
||||
` Campaign: ${campaignResult.sent} sent, ${campaignResult.skipped} skipped`
|
||||
)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
totalSent,
|
||||
totalSkipped,
|
||||
details: {
|
||||
taxDeadlines: taxResult,
|
||||
invoices: invoiceResult,
|
||||
campaigns: campaignResult,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error in push notification cron:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to send push notifications' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getVapidPublicKey } from '@/lib/push/web-push'
|
||||
|
||||
/**
|
||||
* GET /api/push/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/push/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,
|
||||
campaign_deadlines_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/push/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,108 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import type { ConfirmReceiptInput } from '@/types'
|
||||
|
||||
/**
|
||||
* 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 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ data: updatedReceipt })
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { findTransactionMatches } from '@/lib/receipts/receipt-matcher'
|
||||
import type { Receipt, Transaction } from '@/types'
|
||||
|
||||
/**
|
||||
* 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('id')
|
||||
.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('id')
|
||||
.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)
|
||||
}
|
||||
|
||||
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,156 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { estimateProductValue } from '@/lib/receipts/receipt-analyzer'
|
||||
import { classifyGift } from '@/lib/benefits/gift-classifier'
|
||||
import type { GiftInput } from '@/types'
|
||||
|
||||
/**
|
||||
* POST /api/receipts/product
|
||||
* Register a product/gift without a receipt
|
||||
* Uses AI to estimate value, then routes to gift classification
|
||||
*/
|
||||
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
|
||||
const manualValue = formData.get('estimated_value') as string | null
|
||||
const brandName = formData.get('brand_name') as string | null
|
||||
const description = formData.get('description') as string | null
|
||||
|
||||
// Gift classification inputs
|
||||
const hasMotprestation = formData.get('has_motprestation') === 'true'
|
||||
const usedInBusiness = formData.get('used_in_business') === 'true'
|
||||
const usedPrivately = formData.get('used_privately') === 'true'
|
||||
const isSimplePromo = formData.get('is_simple_promo') === 'true'
|
||||
|
||||
let estimatedValue = manualValue ? parseFloat(manualValue) : null
|
||||
let productDescription = description || ''
|
||||
let productBrand = brandName || null
|
||||
let imageUrl: string | null = null
|
||||
|
||||
// If image provided, analyze and estimate value
|
||||
if (imageFile) {
|
||||
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 to base64
|
||||
const arrayBuffer = await imageFile.arrayBuffer()
|
||||
const base64 = Buffer.from(arrayBuffer).toString('base64')
|
||||
|
||||
// Upload image to storage
|
||||
const ext = imageFile.type.split('/')[1]
|
||||
const filename = `${user.id}/products/${Date.now()}-${Math.random().toString(36).slice(2)}.${ext}`
|
||||
|
||||
const { error: uploadError } = await supabase.storage
|
||||
.from('receipts')
|
||||
.upload(filename, arrayBuffer, {
|
||||
contentType: imageFile.type,
|
||||
cacheControl: '3600',
|
||||
})
|
||||
|
||||
if (uploadError) {
|
||||
console.error('Storage upload error:', uploadError)
|
||||
} else {
|
||||
const { data: urlData } = supabase.storage.from('receipts').getPublicUrl(filename)
|
||||
imageUrl = urlData.publicUrl
|
||||
}
|
||||
|
||||
// Estimate value if not manually provided
|
||||
if (!estimatedValue) {
|
||||
try {
|
||||
const mimeType = imageFile.type as 'image/jpeg' | 'image/png' | 'image/webp' | 'image/gif'
|
||||
const estimation = await estimateProductValue(base64, mimeType)
|
||||
|
||||
estimatedValue = estimation.estimatedValue
|
||||
if (!productDescription) {
|
||||
productDescription = estimation.description
|
||||
}
|
||||
if (!productBrand && estimation.brand) {
|
||||
productBrand = estimation.brand
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Value estimation error:', error)
|
||||
// Continue without AI estimation
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate required data
|
||||
if (!estimatedValue || estimatedValue <= 0) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Estimated value is required' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
if (!productDescription) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Product description is required' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Classify the gift
|
||||
const giftInput: GiftInput = {
|
||||
estimatedValue,
|
||||
hasMotprestation,
|
||||
usedInBusiness,
|
||||
usedPrivately,
|
||||
isSimplePromoItem: isSimplePromo,
|
||||
}
|
||||
|
||||
const classification = classifyGift(giftInput)
|
||||
|
||||
// Create gift record
|
||||
const { data: gift, error: giftError } = await supabase
|
||||
.from('gifts')
|
||||
.insert({
|
||||
user_id: user.id,
|
||||
date: new Date().toISOString().split('T')[0],
|
||||
brand_name: productBrand || 'Okänt varumärke',
|
||||
description: productDescription,
|
||||
estimated_value: estimatedValue,
|
||||
has_motprestation: hasMotprestation,
|
||||
used_in_business: usedInBusiness,
|
||||
used_privately: usedPrivately,
|
||||
is_simple_promo: isSimplePromo,
|
||||
classification,
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (giftError) {
|
||||
console.error('Gift insert error:', giftError)
|
||||
return NextResponse.json({ error: 'Failed to create gift record' }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
data: {
|
||||
gift,
|
||||
classification,
|
||||
imageUrl,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Product registration error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : 'Registration failed' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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,190 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { analyzeReceipt } from '@/lib/receipts/receipt-analyzer'
|
||||
import { processLineItems } from '@/lib/receipts/receipt-categorizer'
|
||||
|
||||
/**
|
||||
* 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,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
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,44 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { generateBalanceSheet } from '@/lib/reports/balance-sheet'
|
||||
|
||||
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')
|
||||
|
||||
if (!periodId) {
|
||||
return NextResponse.json({ error: 'period_id is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const { data: period } = await supabase
|
||||
.from('fiscal_periods')
|
||||
.select('period_start, period_end')
|
||||
.eq('id', periodId)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
try {
|
||||
const result = await generateBalanceSheet(user.id, periodId)
|
||||
|
||||
if (period) {
|
||||
result.period = {
|
||||
start: period.period_start,
|
||||
end: period.period_end,
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ data: result })
|
||||
} catch (err) {
|
||||
return NextResponse.json(
|
||||
{ error: err instanceof Error ? err.message : 'Failed to generate balance sheet' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { generateIncomeStatement } from '@/lib/reports/income-statement'
|
||||
|
||||
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')
|
||||
|
||||
if (!periodId) {
|
||||
return NextResponse.json({ error: 'period_id is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Get period dates
|
||||
const { data: period } = await supabase
|
||||
.from('fiscal_periods')
|
||||
.select('period_start, period_end')
|
||||
.eq('id', periodId)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
try {
|
||||
const result = await generateIncomeStatement(user.id, periodId)
|
||||
|
||||
if (period) {
|
||||
result.period = {
|
||||
start: period.period_start,
|
||||
end: period.period_end,
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ data: result })
|
||||
} catch (err) {
|
||||
return NextResponse.json(
|
||||
{ error: err instanceof Error ? err.message : 'Failed to generate income statement' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { generateNEDeclaration } from '@/lib/reports/ne-declaration'
|
||||
import {
|
||||
generateSRUFile,
|
||||
sruFileToString,
|
||||
getSRUFilename,
|
||||
} from '@/lib/reports/sru-generator'
|
||||
|
||||
/**
|
||||
* GET /api/reports/ne-declaration
|
||||
*
|
||||
* 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,53 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { generateSIEExport } from '@/lib/reports/sie-export'
|
||||
|
||||
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')
|
||||
|
||||
if (!periodId) {
|
||||
return NextResponse.json({ error: 'period_id is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Get company settings for SIE metadata
|
||||
const { data: company } = await supabase
|
||||
.from('company_settings')
|
||||
.select('company_name, org_number')
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (!company) {
|
||||
return NextResponse.json({ error: 'Company settings not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
try {
|
||||
const sieContent = await generateSIEExport(user.id, {
|
||||
fiscal_period_id: periodId,
|
||||
company_name: company.company_name || 'Unknown',
|
||||
org_number: company.org_number,
|
||||
program_name: 'InfluencerBiz',
|
||||
})
|
||||
|
||||
// Return as downloadable file
|
||||
return new NextResponse(sieContent, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'text/plain; charset=utf-8',
|
||||
'Content-Disposition': `attachment; filename="export_${periodId}.se"`,
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
return NextResponse.json(
|
||||
{ error: err instanceof Error ? err.message : 'Failed to generate SIE export' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { generateTrialBalance } from '@/lib/reports/trial-balance'
|
||||
|
||||
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')
|
||||
|
||||
if (!periodId) {
|
||||
return NextResponse.json({ error: 'period_id is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await generateTrialBalance(user.id, periodId)
|
||||
return NextResponse.json({ data: result })
|
||||
} catch (err) {
|
||||
return NextResponse.json(
|
||||
{ error: err instanceof Error ? err.message : 'Failed to generate trial balance' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import {
|
||||
calculateVatDeclaration,
|
||||
formatPeriodLabel,
|
||||
} from '@/lib/reports/vat-declaration'
|
||||
import type { VatPeriodType } from '@/types'
|
||||
|
||||
/**
|
||||
* GET /api/reports/vat-declaration
|
||||
*
|
||||
* Calculate VAT declaration (momsdeklaration) for a given period.
|
||||
*
|
||||
* Query parameters:
|
||||
* - periodType: 'monthly' | 'quarterly' | 'yearly'
|
||||
* - year: number (e.g., 2025)
|
||||
* - period: number (1-12 for monthly, 1-4 for quarterly, 1 for yearly)
|
||||
*
|
||||
* Returns:
|
||||
* - VAT rutor (boxes) according to Swedish tax authority format
|
||||
* - Period information
|
||||
* - Breakdown by source (invoices, transactions, receipts)
|
||||
*/
|
||||
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 periodType = searchParams.get('periodType') as VatPeriodType | null
|
||||
const yearStr = searchParams.get('year')
|
||||
const periodStr = searchParams.get('period')
|
||||
|
||||
// Validate required parameters
|
||||
if (!periodType || !yearStr || !periodStr) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Missing required parameters: periodType, year, period' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Validate periodType
|
||||
if (!['monthly', 'quarterly', 'yearly'].includes(periodType)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid periodType. Must be: monthly, quarterly, or yearly' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const year = parseInt(yearStr, 10)
|
||||
const period = parseInt(periodStr, 10)
|
||||
|
||||
// Validate year
|
||||
if (isNaN(year) || year < 2000 || year > 2100) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid year. Must be between 2000 and 2100' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Validate period based on type
|
||||
if (isNaN(period)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid period' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
if (periodType === 'monthly' && (period < 1 || period > 12)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid period for monthly. Must be 1-12' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
if (periodType === 'quarterly' && (period < 1 || period > 4)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid period for quarterly. Must be 1-4' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
if (periodType === 'yearly' && period !== 1) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid period for yearly. Must be 1' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
const declaration = await calculateVatDeclaration(
|
||||
user.id,
|
||||
periodType,
|
||||
year,
|
||||
period
|
||||
)
|
||||
|
||||
return NextResponse.json({
|
||||
data: {
|
||||
...declaration,
|
||||
periodLabel: formatPeriodLabel(periodType, year, period),
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('Error calculating VAT declaration:', err)
|
||||
return NextResponse.json(
|
||||
{ error: err instanceof Error ? err.message : 'Failed to calculate VAT declaration' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { didTaxFieldsChange, regenerateTaxDeadlinesForUser } from '@/lib/tax/deadline-generator'
|
||||
|
||||
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 { data, error } = await supabase
|
||||
.from('company_settings')
|
||||
.select('*')
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ data })
|
||||
}
|
||||
|
||||
export async function PUT(request: Request) {
|
||||
const supabase = await createClient()
|
||||
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Fetch current settings to check for tax-relevant changes
|
||||
const { data: oldSettings } = await supabase
|
||||
.from('company_settings')
|
||||
.select('entity_type, moms_period, f_skatt, vat_registered, pays_salaries, fiscal_year_start_month')
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
const body = await request.json()
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('company_settings')
|
||||
.update(body)
|
||||
.eq('user_id', user.id)
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
// Check if tax-relevant fields changed and regenerate deadlines (skip for light mode)
|
||||
if (data.entity_type !== 'light' && oldSettings && didTaxFieldsChange(oldSettings, data)) {
|
||||
try {
|
||||
await regenerateTaxDeadlinesForUser(supabase, user.id, {
|
||||
entity_type: data.entity_type,
|
||||
moms_period: data.moms_period,
|
||||
f_skatt: data.f_skatt,
|
||||
vat_registered: data.vat_registered,
|
||||
pays_salaries: data.pays_salaries ?? false,
|
||||
fiscal_year_start_month: data.fiscal_year_start_month,
|
||||
})
|
||||
console.log('Tax deadlines regenerated after settings change')
|
||||
} catch (err) {
|
||||
console.error('Failed to regenerate tax deadlines:', err)
|
||||
// Don't fail the settings update if deadline generation fails
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ data })
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
|
||||
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('shadow_ledger_entries')
|
||||
.select('*')
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 404 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ data })
|
||||
}
|
||||
|
||||
export async function PUT(
|
||||
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()
|
||||
|
||||
// Remove fields that shouldn't be updated directly
|
||||
const { id: _id, user_id: _uid, created_at: _ca, updated_at: _ua, ...updates } = body
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('shadow_ledger_entries')
|
||||
.update(updates)
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ data })
|
||||
}
|
||||
|
||||
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 })
|
||||
}
|
||||
|
||||
const { error } = await supabase
|
||||
.from('shadow_ledger_entries')
|
||||
.delete()
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { calculateGiftVirtualTaxDebt } from '@/lib/tax/light-calculator'
|
||||
import type { CreateShadowLedgerEntryInput } from '@/types'
|
||||
|
||||
/**
|
||||
* GET /api/shadow-ledger
|
||||
* List shadow ledger entries for the authenticated user
|
||||
* Query params: year (optional, defaults to current year)
|
||||
*/
|
||||
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 year = searchParams.get('year') || new Date().getFullYear().toString()
|
||||
|
||||
const startDate = `${year}-01-01`
|
||||
const endDate = `${year}-12-31`
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('shadow_ledger_entries')
|
||||
.select('*')
|
||||
.eq('user_id', user.id)
|
||||
.gte('date', startDate)
|
||||
.lte('date', endDate)
|
||||
.order('date', { ascending: false })
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ data })
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/shadow-ledger
|
||||
* Create a new shadow ledger entry
|
||||
*/
|
||||
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: CreateShadowLedgerEntryInput = await request.json()
|
||||
|
||||
if (!body.date || !body.type || body.gross_amount === undefined || body.net_amount === undefined) {
|
||||
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Fetch settings for umbrella config and tax rates
|
||||
const { data: settings } = await supabase
|
||||
.from('company_settings')
|
||||
.select('umbrella_provider, umbrella_fee_percent, umbrella_pension_percent, municipal_tax_rate, church_tax, church_tax_rate')
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
// For payout type: auto-populate service_fee and pension_deduction from settings if not provided
|
||||
let serviceFee = body.service_fee ?? 0
|
||||
let pensionDeduction = body.pension_deduction ?? 0
|
||||
let provider = body.provider ?? null
|
||||
|
||||
if (body.type === 'payout' && settings) {
|
||||
if (!body.provider && settings.umbrella_provider) {
|
||||
provider = settings.umbrella_provider
|
||||
}
|
||||
if (body.service_fee === undefined && settings.umbrella_fee_percent) {
|
||||
serviceFee = Math.round(body.gross_amount * (Number(settings.umbrella_fee_percent) / 100) * 100) / 100
|
||||
}
|
||||
if (body.pension_deduction === undefined && settings.umbrella_pension_percent) {
|
||||
pensionDeduction = Math.round(body.gross_amount * (Number(settings.umbrella_pension_percent) / 100) * 100) / 100
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate virtual tax debt for gift entries
|
||||
let virtualTaxDebt = 0
|
||||
if (body.type === 'gift' && settings) {
|
||||
const municipalRate = Number(settings.municipal_tax_rate) || 0.3238
|
||||
const churchRate = settings.church_tax ? (Number(settings.church_tax_rate) || 0.01) : 0
|
||||
virtualTaxDebt = calculateGiftVirtualTaxDebt(body.gross_amount, municipalRate, churchRate)
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('shadow_ledger_entries')
|
||||
.insert({
|
||||
user_id: user.id,
|
||||
date: body.date,
|
||||
type: body.type,
|
||||
source: body.source || 'manual',
|
||||
provider,
|
||||
gross_amount: body.gross_amount,
|
||||
platform_fee: body.platform_fee ?? 0,
|
||||
service_fee: serviceFee,
|
||||
pension_deduction: pensionDeduction,
|
||||
social_fees: body.social_fees ?? 0,
|
||||
income_tax_withheld: body.income_tax_withheld ?? 0,
|
||||
net_amount: body.net_amount,
|
||||
currency: body.currency || 'SEK',
|
||||
description: body.description || null,
|
||||
bank_transaction_id: body.bank_transaction_id || null,
|
||||
gift_id: body.gift_id || null,
|
||||
campaign_id: body.campaign_id || null,
|
||||
metadata: body.metadata || {},
|
||||
virtual_tax_debt: virtualTaxDebt,
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ data })
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import type { ShadowLedgerSummary, ShadowLedgerEntryType } from '@/types'
|
||||
|
||||
/**
|
||||
* GET /api/shadow-ledger/summary
|
||||
* Aggregated summary of shadow ledger entries for a year
|
||||
*/
|
||||
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 year = searchParams.get('year') || new Date().getFullYear().toString()
|
||||
|
||||
const startDate = `${year}-01-01`
|
||||
const endDate = `${year}-12-31`
|
||||
|
||||
const { data: entries, error } = await supabase
|
||||
.from('shadow_ledger_entries')
|
||||
.select('type, gross_amount, net_amount, service_fee, income_tax_withheld, pension_deduction, social_fees, platform_fee, virtual_tax_debt')
|
||||
.eq('user_id', user.id)
|
||||
.gte('date', startDate)
|
||||
.lte('date', endDate)
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
const types: ShadowLedgerEntryType[] = ['payout', 'gift', 'expense', 'hobby_income', 'hobby_expense']
|
||||
const byType = {} as Record<ShadowLedgerEntryType, { count: number; gross: number; net: number }>
|
||||
for (const t of types) {
|
||||
byType[t] = { count: 0, gross: 0, net: 0 }
|
||||
}
|
||||
|
||||
let totalGross = 0
|
||||
let totalNet = 0
|
||||
let totalFees = 0
|
||||
let totalTaxWithheld = 0
|
||||
let totalPension = 0
|
||||
let totalSocialFees = 0
|
||||
let totalPlatformFees = 0
|
||||
let virtualTaxDebt = 0
|
||||
|
||||
for (const entry of entries || []) {
|
||||
const type = entry.type as ShadowLedgerEntryType
|
||||
const gross = Number(entry.gross_amount)
|
||||
const net = Number(entry.net_amount)
|
||||
|
||||
totalGross += gross
|
||||
totalNet += net
|
||||
totalFees += Number(entry.service_fee) || 0
|
||||
totalTaxWithheld += Number(entry.income_tax_withheld) || 0
|
||||
totalPension += Number(entry.pension_deduction) || 0
|
||||
totalSocialFees += Number(entry.social_fees) || 0
|
||||
totalPlatformFees += Number(entry.platform_fee) || 0
|
||||
virtualTaxDebt += Number(entry.virtual_tax_debt) || 0
|
||||
|
||||
if (byType[type]) {
|
||||
byType[type].count++
|
||||
byType[type].gross += gross
|
||||
byType[type].net += net
|
||||
}
|
||||
}
|
||||
|
||||
const summary: ShadowLedgerSummary = {
|
||||
year: parseInt(year),
|
||||
total_gross: Math.round(totalGross * 100) / 100,
|
||||
total_net: Math.round(totalNet * 100) / 100,
|
||||
total_fees: Math.round(totalFees * 100) / 100,
|
||||
total_tax_withheld: Math.round(totalTaxWithheld * 100) / 100,
|
||||
total_pension: Math.round(totalPension * 100) / 100,
|
||||
total_social_fees: Math.round(totalSocialFees * 100) / 100,
|
||||
total_platform_fees: Math.round(totalPlatformFees * 100) / 100,
|
||||
virtual_tax_debt: Math.round(virtualTaxDebt * 100) / 100,
|
||||
entry_count: (entries || []).length,
|
||||
by_type: byType,
|
||||
}
|
||||
|
||||
return NextResponse.json({ data: summary })
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { createClient } from '@supabase/supabase-js'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { generateNewYearDeadlines } from '@/lib/tax/deadline-generator'
|
||||
|
||||
/**
|
||||
* GET /api/tax-deadlines/cron
|
||||
* Annual cron job to generate tax deadlines for the new year
|
||||
* Runs on January 2nd
|
||||
*
|
||||
* Vercel Cron: "0 0 2 1 *" (midnight on January 2nd)
|
||||
*/
|
||||
export async function GET(request: Request) {
|
||||
// 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 {
|
||||
const result = await generateNewYearDeadlines(supabase)
|
||||
|
||||
console.log(`Tax deadlines cron completed: ${result.usersProcessed} users, ${result.totalCreated} deadlines created`)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
usersProcessed: result.usersProcessed,
|
||||
totalCreated: result.totalCreated,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error in tax deadlines cron:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to generate tax deadlines' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { regenerateTaxDeadlinesForUser } from '@/lib/tax/deadline-generator'
|
||||
|
||||
/**
|
||||
* POST /api/tax-deadlines/generate
|
||||
* Manually trigger tax deadline generation for the current user
|
||||
*/
|
||||
export async function POST() {
|
||||
const supabase = await createClient()
|
||||
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Fetch company settings
|
||||
const { data: settings, error: settingsError } = await supabase
|
||||
.from('company_settings')
|
||||
.select('entity_type, moms_period, f_skatt, vat_registered, pays_salaries, fiscal_year_start_month')
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (settingsError || !settings) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Company settings not found' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await regenerateTaxDeadlinesForUser(supabase, user.id, {
|
||||
entity_type: settings.entity_type,
|
||||
moms_period: settings.moms_period,
|
||||
f_skatt: settings.f_skatt,
|
||||
vat_registered: settings.vat_registered,
|
||||
pays_salaries: settings.pays_salaries ?? false,
|
||||
fiscal_year_start_month: settings.fiscal_year_start_month,
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
created: result.created,
|
||||
deleted: result.deleted,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error generating tax deadlines:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to generate tax deadlines' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
|
||||
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 {
|
||||
// Get user's TikTok accounts (exclude encrypted tokens)
|
||||
const { data: accounts, error } = await supabase
|
||||
.from('tiktok_accounts')
|
||||
.select(`
|
||||
id,
|
||||
tiktok_user_id,
|
||||
username,
|
||||
display_name,
|
||||
avatar_url,
|
||||
token_expires_at,
|
||||
refresh_token_expires_at,
|
||||
status,
|
||||
last_synced_at,
|
||||
last_stats_sync_at,
|
||||
last_video_sync_at,
|
||||
last_error,
|
||||
error_count,
|
||||
created_at,
|
||||
updated_at
|
||||
`)
|
||||
.eq('user_id', user.id)
|
||||
.order('created_at', { ascending: false })
|
||||
|
||||
if (error) {
|
||||
throw error
|
||||
}
|
||||
|
||||
return NextResponse.json({ accounts: accounts || [] })
|
||||
} catch (error) {
|
||||
console.error('Error fetching TikTok accounts:', error)
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : 'Failed to fetch accounts' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { validateOAuthState, exchangeCodeForTokens, calculateTokenExpiration } from '@/lib/tiktok/oauth'
|
||||
import { encryptTokens } from '@/lib/tiktok/encryption'
|
||||
import { getUserInfo } from '@/lib/tiktok/api'
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const code = searchParams.get('code')
|
||||
const state = searchParams.get('state')
|
||||
const error = searchParams.get('error')
|
||||
const errorDescription = searchParams.get('error_description')
|
||||
|
||||
const redirectUrl = `${process.env.NEXT_PUBLIC_APP_URL}/settings`
|
||||
|
||||
// Handle OAuth errors
|
||||
if (error) {
|
||||
console.error('TikTok OAuth error:', error, errorDescription)
|
||||
return NextResponse.redirect(
|
||||
`${redirectUrl}?tiktok_error=${encodeURIComponent(errorDescription || error)}`
|
||||
)
|
||||
}
|
||||
|
||||
// Validate required parameters
|
||||
if (!code || !state) {
|
||||
return NextResponse.redirect(
|
||||
`${redirectUrl}?tiktok_error=${encodeURIComponent('Missing authorization code or state')}`
|
||||
)
|
||||
}
|
||||
|
||||
// Validate state (CSRF protection) and get PKCE code verifier
|
||||
const stateData = await validateOAuthState(state)
|
||||
if (!stateData) {
|
||||
return NextResponse.redirect(
|
||||
`${redirectUrl}?tiktok_error=${encodeURIComponent('Invalid or expired state parameter')}`
|
||||
)
|
||||
}
|
||||
|
||||
const { userId, codeVerifier } = stateData
|
||||
const supabase = await createClient()
|
||||
|
||||
try {
|
||||
// Exchange code for tokens (with PKCE verifier)
|
||||
const redirectUri = `${process.env.NEXT_PUBLIC_APP_URL}/api/tiktok/callback`
|
||||
const tokens = await exchangeCodeForTokens(code, redirectUri, codeVerifier)
|
||||
|
||||
// Encrypt tokens
|
||||
const encryptedTokens = encryptTokens(tokens.access_token, tokens.refresh_token)
|
||||
|
||||
// Calculate expiration dates
|
||||
const { tokenExpiresAt, refreshTokenExpiresAt } = calculateTokenExpiration(tokens)
|
||||
|
||||
// Get user info from TikTok
|
||||
const userInfo = await getUserInfo({
|
||||
accessToken: tokens.access_token,
|
||||
userId,
|
||||
})
|
||||
|
||||
// Check if account already exists (maybe in revoked/expired state)
|
||||
const { data: existingAccount } = await supabase
|
||||
.from('tiktok_accounts')
|
||||
.select('id')
|
||||
.eq('user_id', userId)
|
||||
.eq('tiktok_user_id', tokens.open_id)
|
||||
.single()
|
||||
|
||||
if (existingAccount) {
|
||||
// Update existing account
|
||||
const { error: updateError } = await supabase
|
||||
.from('tiktok_accounts')
|
||||
.update({
|
||||
username: userInfo.username,
|
||||
display_name: userInfo.display_name,
|
||||
avatar_url: userInfo.avatar_url,
|
||||
...encryptedTokens,
|
||||
token_expires_at: tokenExpiresAt.toISOString(),
|
||||
refresh_token_expires_at: refreshTokenExpiresAt.toISOString(),
|
||||
status: 'active',
|
||||
error_count: 0,
|
||||
last_error: null,
|
||||
})
|
||||
.eq('id', existingAccount.id)
|
||||
|
||||
if (updateError) {
|
||||
throw updateError
|
||||
}
|
||||
} else {
|
||||
// Create new account
|
||||
const { error: insertError } = await supabase
|
||||
.from('tiktok_accounts')
|
||||
.insert({
|
||||
user_id: userId,
|
||||
tiktok_user_id: tokens.open_id,
|
||||
username: userInfo.username,
|
||||
display_name: userInfo.display_name,
|
||||
avatar_url: userInfo.avatar_url,
|
||||
...encryptedTokens,
|
||||
token_expires_at: tokenExpiresAt.toISOString(),
|
||||
refresh_token_expires_at: refreshTokenExpiresAt.toISOString(),
|
||||
status: 'active',
|
||||
})
|
||||
|
||||
if (insertError) {
|
||||
throw insertError
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.redirect(`${redirectUrl}?tiktok_connected=true`)
|
||||
} catch (error) {
|
||||
console.error('TikTok callback error:', error)
|
||||
return NextResponse.redirect(
|
||||
`${redirectUrl}?tiktok_error=${encodeURIComponent(
|
||||
error instanceof Error ? error.message : 'Failed to connect TikTok account'
|
||||
)}`
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { generateOAuthState, getAuthorizationUrl } from '@/lib/tiktok/oauth'
|
||||
|
||||
export async function POST() {
|
||||
const supabase = await createClient()
|
||||
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
try {
|
||||
// Check if user already has an active TikTok account
|
||||
const { data: existingAccount } = await supabase
|
||||
.from('tiktok_accounts')
|
||||
.select('id, status')
|
||||
.eq('user_id', user.id)
|
||||
.eq('status', 'active')
|
||||
.single()
|
||||
|
||||
if (existingAccount) {
|
||||
return NextResponse.json(
|
||||
{ error: 'TikTok account already connected' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Generate OAuth state for CSRF protection and PKCE
|
||||
const { state, codeChallenge } = await generateOAuthState(user.id)
|
||||
|
||||
// Get redirect URL
|
||||
const redirectUri = `${process.env.NEXT_PUBLIC_APP_URL}/api/tiktok/callback`
|
||||
|
||||
// Generate authorization URL with PKCE
|
||||
const authorizationUrl = getAuthorizationUrl(redirectUri, state, codeChallenge)
|
||||
|
||||
return NextResponse.json({ authorization_url: authorizationUrl })
|
||||
} catch (error) {
|
||||
console.error('TikTok connect error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : 'Connection failed' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { syncAllAccounts, refreshExpiringTokens } from '@/lib/tiktok/sync'
|
||||
|
||||
// Verify cron secret for security
|
||||
function verifyCronSecret(request: Request): boolean {
|
||||
const authHeader = request.headers.get('authorization')
|
||||
const cronSecret = process.env.CRON_SECRET
|
||||
|
||||
if (!cronSecret) {
|
||||
console.error('CRON_SECRET not configured')
|
||||
return false
|
||||
}
|
||||
|
||||
if (!authHeader) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Support both "Bearer <token>" and just "<token>" formats
|
||||
const token = authHeader.startsWith('Bearer ')
|
||||
? authHeader.substring(7)
|
||||
: authHeader
|
||||
|
||||
return token === cronSecret
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
// Verify cron authentication
|
||||
if (!verifyCronSecret(request)) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
try {
|
||||
console.log('Starting TikTok cron job...')
|
||||
|
||||
// First, refresh tokens that are expiring soon
|
||||
const tokensRefreshed = await refreshExpiringTokens()
|
||||
console.log(`Refreshed ${tokensRefreshed} expiring tokens`)
|
||||
|
||||
// Then sync all active accounts
|
||||
const syncResults = await syncAllAccounts()
|
||||
console.log(`Sync completed: ${syncResults.successful}/${syncResults.total} successful, ${syncResults.failed} failed`)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
tokens_refreshed: tokensRefreshed,
|
||||
accounts_total: syncResults.total,
|
||||
accounts_successful: syncResults.successful,
|
||||
accounts_failed: syncResults.failed,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('TikTok cron job error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : 'Cron job failed' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Also support POST for manual triggering via dashboard
|
||||
export async function POST(request: Request) {
|
||||
return GET(request)
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { revokeToken } from '@/lib/tiktok/oauth'
|
||||
import { decryptToken } from '@/lib/tiktok/encryption'
|
||||
|
||||
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 { account_id } = await request.json()
|
||||
|
||||
if (!account_id) {
|
||||
return NextResponse.json({ error: 'account_id is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
try {
|
||||
// Get account with encrypted token
|
||||
const { data: account, error: fetchError } = await supabase
|
||||
.from('tiktok_accounts')
|
||||
.select('id, user_id, access_token_encrypted')
|
||||
.eq('id', account_id)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (fetchError || !account) {
|
||||
return NextResponse.json({ error: 'Account not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Try to revoke token with TikTok (best effort)
|
||||
try {
|
||||
const accessToken = decryptToken(account.access_token_encrypted)
|
||||
await revokeToken(accessToken)
|
||||
} catch (revokeError) {
|
||||
// Log but don't fail - we'll still mark as revoked locally
|
||||
console.error('Failed to revoke TikTok token:', revokeError)
|
||||
}
|
||||
|
||||
// Update account status to revoked
|
||||
const { error: updateError } = await supabase
|
||||
.from('tiktok_accounts')
|
||||
.update({ status: 'revoked' })
|
||||
.eq('id', account_id)
|
||||
|
||||
if (updateError) {
|
||||
throw updateError
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
console.error('TikTok disconnect error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : 'Disconnect failed' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getStatsSummary, calculateFollowerGrowth, getFollowerHistory } from '@/lib/tiktok/metrics'
|
||||
|
||||
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 accountId = searchParams.get('account_id')
|
||||
const period = searchParams.get('period') as '7d' | '30d' | '90d' | '1y' | null
|
||||
|
||||
try {
|
||||
// If no account_id, get summary for user's active account
|
||||
if (!accountId) {
|
||||
const summary = await getStatsSummary(user.id)
|
||||
|
||||
if (!summary) {
|
||||
return NextResponse.json({ error: 'No connected TikTok account found' }, { status: 404 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ summary })
|
||||
}
|
||||
|
||||
// Verify account belongs to user
|
||||
const { data: account, error: fetchError } = await supabase
|
||||
.from('tiktok_accounts')
|
||||
.select('id')
|
||||
.eq('id', accountId)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (fetchError || !account) {
|
||||
return NextResponse.json({ error: 'Account not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Get growth data for specified period
|
||||
if (period) {
|
||||
const growth = await calculateFollowerGrowth(accountId, period)
|
||||
return NextResponse.json({ growth })
|
||||
}
|
||||
|
||||
// Get follower history (default 30 days)
|
||||
const days = parseInt(searchParams.get('days') || '30', 10)
|
||||
const history = await getFollowerHistory(accountId, days)
|
||||
|
||||
return NextResponse.json({ history })
|
||||
} catch (error) {
|
||||
console.error('Error fetching TikTok stats:', error)
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : 'Failed to fetch stats' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { syncAccount } from '@/lib/tiktok/sync'
|
||||
import type { TikTokAccount, TikTokSyncType } from '@/types'
|
||||
|
||||
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 { account_id, sync_type = 'full' } = await request.json()
|
||||
|
||||
if (!account_id) {
|
||||
return NextResponse.json({ error: 'account_id is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Validate sync_type
|
||||
const validSyncTypes: TikTokSyncType[] = ['stats', 'videos', 'full']
|
||||
if (!validSyncTypes.includes(sync_type)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid sync_type. Must be one of: stats, videos, full' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
// Get account
|
||||
const { data: account, error: fetchError } = await supabase
|
||||
.from('tiktok_accounts')
|
||||
.select('*')
|
||||
.eq('id', account_id)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (fetchError || !account) {
|
||||
return NextResponse.json({ error: 'Account not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Check if account is in a valid state for syncing
|
||||
if (account.status === 'revoked') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Account has been disconnected' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Perform sync
|
||||
const result = await syncAccount(account as TikTokAccount, sync_type)
|
||||
|
||||
if (!result.success) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: result.error || 'Sync failed',
|
||||
errorCode: result.errorCode,
|
||||
partial: result.statsSynced || result.videosSynced > 0,
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
stats_synced: result.statsSynced,
|
||||
videos_synced: result.videosSynced,
|
||||
new_videos: result.newVideos,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('TikTok sync error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : 'Sync failed' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
|
||||
interface RouteContext {
|
||||
params: Promise<{ id: string }>
|
||||
}
|
||||
|
||||
export async function POST(request: Request, context: RouteContext) {
|
||||
const supabase = await createClient()
|
||||
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { id: videoId } = await context.params
|
||||
const { campaign_id, deliverable_id } = await request.json()
|
||||
|
||||
if (!campaign_id && !deliverable_id) {
|
||||
return NextResponse.json(
|
||||
{ error: 'campaign_id or deliverable_id is required' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
// Verify video belongs to user
|
||||
const { data: video, error: fetchError } = await supabase
|
||||
.from('tiktok_videos')
|
||||
.select('id, user_id')
|
||||
.eq('id', videoId)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (fetchError || !video) {
|
||||
return NextResponse.json({ error: 'Video not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Verify campaign belongs to user (if provided)
|
||||
if (campaign_id) {
|
||||
const { data: campaign, error: campaignError } = await supabase
|
||||
.from('campaigns')
|
||||
.select('id')
|
||||
.eq('id', campaign_id)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (campaignError || !campaign) {
|
||||
return NextResponse.json({ error: 'Campaign not found' }, { status: 404 })
|
||||
}
|
||||
}
|
||||
|
||||
// Verify deliverable belongs to user (if provided)
|
||||
if (deliverable_id) {
|
||||
const { data: deliverable, error: deliverableError } = await supabase
|
||||
.from('deliverables')
|
||||
.select('id, campaign_id')
|
||||
.eq('id', deliverable_id)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (deliverableError || !deliverable) {
|
||||
return NextResponse.json({ error: 'Deliverable not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// If deliverable is provided but campaign_id is not, use deliverable's campaign
|
||||
if (!campaign_id && deliverable.campaign_id) {
|
||||
const updateData = {
|
||||
campaign_id: deliverable.campaign_id,
|
||||
deliverable_id,
|
||||
}
|
||||
|
||||
const { error: updateError } = await supabase
|
||||
.from('tiktok_videos')
|
||||
.update(updateData)
|
||||
.eq('id', videoId)
|
||||
|
||||
if (updateError) {
|
||||
throw updateError
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, ...updateData })
|
||||
}
|
||||
}
|
||||
|
||||
// Update video with campaign and/or deliverable link
|
||||
const updateData: { campaign_id?: string; deliverable_id?: string } = {}
|
||||
if (campaign_id) updateData.campaign_id = campaign_id
|
||||
if (deliverable_id) updateData.deliverable_id = deliverable_id
|
||||
|
||||
const { error: updateError } = await supabase
|
||||
.from('tiktok_videos')
|
||||
.update(updateData)
|
||||
.eq('id', videoId)
|
||||
|
||||
if (updateError) {
|
||||
throw updateError
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, ...updateData })
|
||||
} catch (error) {
|
||||
console.error('Error linking video:', error)
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : 'Failed to link video' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: Request, context: RouteContext) {
|
||||
const supabase = await createClient()
|
||||
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { id: videoId } = await context.params
|
||||
|
||||
try {
|
||||
// Verify video belongs to user
|
||||
const { data: video, error: fetchError } = await supabase
|
||||
.from('tiktok_videos')
|
||||
.select('id, user_id')
|
||||
.eq('id', videoId)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (fetchError || !video) {
|
||||
return NextResponse.json({ error: 'Video not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Remove campaign and deliverable links
|
||||
const { error: updateError } = await supabase
|
||||
.from('tiktok_videos')
|
||||
.update({ campaign_id: null, deliverable_id: null })
|
||||
.eq('id', videoId)
|
||||
|
||||
if (updateError) {
|
||||
throw updateError
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
console.error('Error unlinking video:', error)
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : 'Failed to unlink video' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
|
||||
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 accountId = searchParams.get('account_id')
|
||||
const campaignId = searchParams.get('campaign_id')
|
||||
const limit = parseInt(searchParams.get('limit') || '20', 10)
|
||||
const offset = parseInt(searchParams.get('offset') || '0', 10)
|
||||
const unlinkedOnly = searchParams.get('unlinked_only') === 'true'
|
||||
|
||||
try {
|
||||
let query = supabase
|
||||
.from('tiktok_videos')
|
||||
.select(`
|
||||
*,
|
||||
campaign:campaigns(id, name),
|
||||
deliverable:deliverables(id, title)
|
||||
`, { count: 'exact' })
|
||||
.eq('user_id', user.id)
|
||||
.order('published_at', { ascending: false })
|
||||
.range(offset, offset + limit - 1)
|
||||
|
||||
if (accountId) {
|
||||
query = query.eq('tiktok_account_id', accountId)
|
||||
}
|
||||
|
||||
if (campaignId) {
|
||||
query = query.eq('campaign_id', campaignId)
|
||||
}
|
||||
|
||||
if (unlinkedOnly) {
|
||||
query = query.is('campaign_id', null)
|
||||
}
|
||||
|
||||
const { data: videos, error, count } = await query
|
||||
|
||||
if (error) {
|
||||
throw error
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
videos: videos || [],
|
||||
total: count || 0,
|
||||
limit,
|
||||
offset,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error fetching TikTok videos:', error)
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : 'Failed to fetch videos' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { buildMappingResultFromCategory } from '@/lib/bookkeeping/category-mapping'
|
||||
import { createTransactionJournalEntry } from '@/lib/bookkeeping/transaction-entries'
|
||||
import { saveUserMappingRule } from '@/lib/bookkeeping/mapping-engine'
|
||||
import type { Transaction, TransactionCategory, EntityType } from '@/types'
|
||||
|
||||
interface CategorizeRequest {
|
||||
is_business: boolean
|
||||
category?: TransactionCategory
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure a fiscal period exists for the given date, create one if needed
|
||||
*/
|
||||
async function ensureFiscalPeriod(
|
||||
supabase: Awaited<ReturnType<typeof createClient>>,
|
||||
userId: string,
|
||||
date: string
|
||||
): Promise<boolean> {
|
||||
// Check if a fiscal period already covers this date
|
||||
const { data: existing } = await supabase
|
||||
.from('fiscal_periods')
|
||||
.select('id')
|
||||
.eq('user_id', userId)
|
||||
.lte('period_start', date)
|
||||
.gte('period_end', date)
|
||||
.eq('is_closed', false)
|
||||
.single()
|
||||
|
||||
if (existing) {
|
||||
return true
|
||||
}
|
||||
|
||||
// No fiscal period exists - create one for the year of the transaction
|
||||
const transactionDate = new Date(date)
|
||||
const year = transactionDate.getFullYear()
|
||||
|
||||
const periodStart = `${year}-01-01`
|
||||
const periodEnd = `${year}-12-31`
|
||||
|
||||
const { error } = await supabase
|
||||
.from('fiscal_periods')
|
||||
.upsert({
|
||||
user_id: userId,
|
||||
name: `Räkenskapsår ${year}`,
|
||||
period_start: periodStart,
|
||||
period_end: periodEnd,
|
||||
}, {
|
||||
onConflict: 'user_id,period_start,period_end',
|
||||
})
|
||||
|
||||
if (error) {
|
||||
console.error('Failed to create fiscal period:', error)
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
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 })
|
||||
}
|
||||
|
||||
// Parse request body
|
||||
const body: CategorizeRequest = await request.json()
|
||||
const { is_business, category } = body
|
||||
|
||||
// Fetch the transaction (validates ownership)
|
||||
const { data: transaction, error: fetchError } = await supabase
|
||||
.from('transactions')
|
||||
.select('*')
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (fetchError || !transaction) {
|
||||
return NextResponse.json({ error: 'Transaction not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// If already has a journal entry, just update category and is_business (skip journal entry creation)
|
||||
if (transaction.journal_entry_id) {
|
||||
const finalCat: TransactionCategory = is_business
|
||||
? (category || 'uncategorized')
|
||||
: 'private'
|
||||
|
||||
const { error: updateErr } = await supabase
|
||||
.from('transactions')
|
||||
.update({
|
||||
is_business,
|
||||
category: finalCat,
|
||||
})
|
||||
.eq('id', id)
|
||||
|
||||
if (updateErr) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to update transaction' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
journal_entry_created: false,
|
||||
journal_entry_id: transaction.journal_entry_id,
|
||||
journal_entry_error: null,
|
||||
category: finalCat,
|
||||
already_had_journal_entry: true,
|
||||
})
|
||||
}
|
||||
|
||||
// Fetch company settings to get entity type
|
||||
const { data: settings } = await supabase
|
||||
.from('company_settings')
|
||||
.select('entity_type')
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
const entityType: EntityType = (settings?.entity_type as EntityType) || 'enskild_firma'
|
||||
|
||||
// Determine the category to use
|
||||
const finalCategory: TransactionCategory = is_business
|
||||
? (category || 'uncategorized')
|
||||
: 'private'
|
||||
|
||||
// Light mode: skip journal entry creation, just update category
|
||||
if (entityType === 'light') {
|
||||
const { error: updateError } = await supabase
|
||||
.from('transactions')
|
||||
.update({
|
||||
is_business,
|
||||
category: finalCategory,
|
||||
})
|
||||
.eq('id', id)
|
||||
|
||||
if (updateError) {
|
||||
console.error('Failed to update transaction:', updateError)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to update transaction' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
journal_entry_created: false,
|
||||
journal_entry_id: null,
|
||||
journal_entry_error: null,
|
||||
category: finalCategory,
|
||||
})
|
||||
}
|
||||
|
||||
// Build mapping result from category
|
||||
const mappingResult = buildMappingResultFromCategory(
|
||||
finalCategory,
|
||||
transaction as Transaction,
|
||||
is_business,
|
||||
entityType
|
||||
)
|
||||
|
||||
// Ensure fiscal period exists for the transaction date
|
||||
await ensureFiscalPeriod(supabase, user.id, transaction.date)
|
||||
|
||||
// Try to create journal entry
|
||||
let journalEntryCreated = false
|
||||
let journalEntryId: string | null = null
|
||||
let journalEntryError: string | null = null
|
||||
|
||||
try {
|
||||
const journalEntry = await createTransactionJournalEntry(
|
||||
user.id,
|
||||
transaction as Transaction,
|
||||
mappingResult
|
||||
)
|
||||
|
||||
if (journalEntry) {
|
||||
journalEntryCreated = true
|
||||
journalEntryId = journalEntry.id
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to create journal entry:', err)
|
||||
journalEntryError = err instanceof Error ? err.message : 'Unknown error'
|
||||
// Continue - we still want to save the categorization
|
||||
}
|
||||
|
||||
// Save mapping rule for future auto-categorization (only for business expenses with merchant)
|
||||
if (is_business && transaction.merchant_name) {
|
||||
try {
|
||||
await saveUserMappingRule(
|
||||
user.id,
|
||||
transaction.merchant_name,
|
||||
mappingResult.debit_account,
|
||||
mappingResult.credit_account,
|
||||
!is_business
|
||||
)
|
||||
} catch (err) {
|
||||
console.error('Failed to save mapping rule:', err)
|
||||
// Non-critical, continue
|
||||
}
|
||||
}
|
||||
|
||||
// Update the transaction
|
||||
const { error: updateError } = await supabase
|
||||
.from('transactions')
|
||||
.update({
|
||||
is_business,
|
||||
category: finalCategory,
|
||||
journal_entry_id: journalEntryId,
|
||||
})
|
||||
.eq('id', id)
|
||||
|
||||
if (updateError) {
|
||||
console.error('Failed to update transaction:', updateError)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to update transaction' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
journal_entry_created: journalEntryCreated,
|
||||
journal_entry_id: journalEntryId,
|
||||
journal_entry_error: journalEntryError,
|
||||
category: finalCategory,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { createJournalEntry, findFiscalPeriod } from '@/lib/bookkeeping/engine'
|
||||
import type { Transaction, Invoice, CreateJournalEntryInput } from '@/types'
|
||||
|
||||
interface MatchInvoiceRequest {
|
||||
invoice_id: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure a fiscal period exists for the given date, create one if needed
|
||||
*/
|
||||
async function ensureFiscalPeriod(
|
||||
supabase: Awaited<ReturnType<typeof createClient>>,
|
||||
userId: string,
|
||||
date: string
|
||||
): Promise<string | null> {
|
||||
// Check if a fiscal period already covers this date
|
||||
const existingPeriodId = await findFiscalPeriod(userId, date)
|
||||
if (existingPeriodId) {
|
||||
return existingPeriodId
|
||||
}
|
||||
|
||||
// No fiscal period exists - create one for the year of the transaction
|
||||
const transactionDate = new Date(date)
|
||||
const year = transactionDate.getFullYear()
|
||||
|
||||
const periodStart = `${year}-01-01`
|
||||
const periodEnd = `${year}-12-31`
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('fiscal_periods')
|
||||
.upsert({
|
||||
user_id: userId,
|
||||
name: `Räkenskapsår ${year}`,
|
||||
period_start: periodStart,
|
||||
period_end: periodEnd,
|
||||
}, {
|
||||
onConflict: 'user_id,period_start,period_end',
|
||||
})
|
||||
.select('id')
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
console.error('Failed to create fiscal period:', error)
|
||||
return null
|
||||
}
|
||||
|
||||
return data?.id || null
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/transactions/[id]/match-invoice
|
||||
*
|
||||
* Confirms an invoice match for a transaction:
|
||||
* 1. Links transaction to invoice (sets invoice_id)
|
||||
* 2. Updates invoice status to 'paid'
|
||||
* 3. Sets paid_at and paid_amount on invoice
|
||||
* 4. Creates journal entry for payment receipt
|
||||
* - Debit 1930 Företagskonto (Bank)
|
||||
* - Credit 1510 Kundfordringar (Accounts Receivable)
|
||||
*/
|
||||
export async function POST(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const supabase = await createClient()
|
||||
const { id: transactionId } = await params
|
||||
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Parse request body
|
||||
const body: MatchInvoiceRequest = await request.json()
|
||||
const { invoice_id } = body
|
||||
|
||||
if (!invoice_id) {
|
||||
return NextResponse.json({ error: 'invoice_id is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Fetch the transaction (validates ownership)
|
||||
const { data: transaction, error: fetchTxError } = await supabase
|
||||
.from('transactions')
|
||||
.select('*')
|
||||
.eq('id', transactionId)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (fetchTxError || !transaction) {
|
||||
return NextResponse.json({ error: 'Transaction not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Verify transaction is income (amount > 0)
|
||||
if (transaction.amount <= 0) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Only income transactions can be matched to invoices' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Check if transaction is already linked to an invoice
|
||||
if (transaction.invoice_id) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Transaction is already linked to an invoice' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Fetch the invoice (validates ownership)
|
||||
const { data: invoice, error: fetchInvError } = await supabase
|
||||
.from('invoices')
|
||||
.select('*, customer:customers(*)')
|
||||
.eq('id', invoice_id)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (fetchInvError || !invoice) {
|
||||
return NextResponse.json({ error: 'Invoice not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Verify invoice is unpaid
|
||||
if (invoice.status !== 'sent' && invoice.status !== 'overdue') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invoice is not in an unpaid state' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const now = new Date().toISOString()
|
||||
const paidAmount = transaction.amount
|
||||
|
||||
// Create journal entry for payment receipt
|
||||
let journalEntryId: string | null = null
|
||||
let journalEntryError: string | null = null
|
||||
|
||||
try {
|
||||
const fiscalPeriodId = await ensureFiscalPeriod(supabase, user.id, transaction.date)
|
||||
|
||||
if (fiscalPeriodId) {
|
||||
const journalInput: CreateJournalEntryInput = {
|
||||
fiscal_period_id: fiscalPeriodId,
|
||||
entry_date: transaction.date,
|
||||
description: `Betalning mottagen: Faktura ${invoice.invoice_number}`,
|
||||
source_type: 'invoice_paid',
|
||||
source_id: invoice.id,
|
||||
lines: [
|
||||
{
|
||||
account_number: '1930', // Företagskonto (Bank)
|
||||
debit_amount: paidAmount,
|
||||
credit_amount: 0,
|
||||
line_description: `Inbetalning faktura ${invoice.invoice_number}`,
|
||||
},
|
||||
{
|
||||
account_number: '1510', // Kundfordringar (Accounts Receivable)
|
||||
debit_amount: 0,
|
||||
credit_amount: paidAmount,
|
||||
line_description: `Faktura ${invoice.invoice_number} betald`,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const journalEntry = await createJournalEntry(user.id, journalInput)
|
||||
journalEntryId = journalEntry.id
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to create payment journal entry:', err)
|
||||
journalEntryError = err instanceof Error ? err.message : 'Unknown error'
|
||||
// Continue - we still want to update the invoice and transaction
|
||||
}
|
||||
|
||||
// Update invoice to paid status
|
||||
const { error: updateInvError } = await supabase
|
||||
.from('invoices')
|
||||
.update({
|
||||
status: 'paid',
|
||||
paid_at: now,
|
||||
paid_amount: paidAmount,
|
||||
})
|
||||
.eq('id', invoice_id)
|
||||
|
||||
if (updateInvError) {
|
||||
console.error('Failed to update invoice:', updateInvError)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to update invoice status' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
// Update transaction to link to invoice and clear potential match
|
||||
const { error: updateTxError } = await supabase
|
||||
.from('transactions')
|
||||
.update({
|
||||
invoice_id: invoice_id,
|
||||
potential_invoice_id: null,
|
||||
journal_entry_id: journalEntryId,
|
||||
is_business: true,
|
||||
category: 'income_services', // Default to services income
|
||||
})
|
||||
.eq('id', transactionId)
|
||||
|
||||
if (updateTxError) {
|
||||
console.error('Failed to update transaction:', updateTxError)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to link transaction to invoice' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
invoice_status: 'paid',
|
||||
paid_at: now,
|
||||
paid_amount: paidAmount,
|
||||
journal_entry_id: journalEntryId,
|
||||
journal_entry_error: journalEntryError,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getBestInvoiceMatch } from '@/lib/invoice/invoice-matching'
|
||||
import type { Transaction } from '@/types'
|
||||
|
||||
/**
|
||||
* POST /api/transactions/batch-match-invoices
|
||||
* Run invoice matching for all uncategorized income transactions without potential_invoice_id
|
||||
*/
|
||||
export async function POST() {
|
||||
const supabase = await createClient()
|
||||
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Fetch uncategorized income transactions without a potential match
|
||||
const { data: transactions, error: txError } = await supabase
|
||||
.from('transactions')
|
||||
.select('*')
|
||||
.eq('user_id', user.id)
|
||||
.is('is_business', null)
|
||||
.gt('amount', 0)
|
||||
.is('potential_invoice_id', null)
|
||||
.is('invoice_id', null)
|
||||
.order('date', { ascending: false })
|
||||
.limit(50)
|
||||
|
||||
if (txError || !transactions) {
|
||||
return NextResponse.json({ error: 'Failed to fetch transactions' }, { status: 500 })
|
||||
}
|
||||
|
||||
let matched = 0
|
||||
|
||||
for (const tx of transactions) {
|
||||
try {
|
||||
const bestMatch = await getBestInvoiceMatch(
|
||||
user.id,
|
||||
tx as Transaction,
|
||||
0.50
|
||||
)
|
||||
|
||||
if (bestMatch) {
|
||||
await supabase
|
||||
.from('transactions')
|
||||
.update({ potential_invoice_id: bestMatch.invoice.id })
|
||||
.eq('id', tx.id)
|
||||
|
||||
matched++
|
||||
}
|
||||
} catch {
|
||||
// Continue with other transactions
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
processed: transactions.length,
|
||||
matched,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getSuggestedCategories, type SuggestedCategory } from '@/lib/transactions/category-suggestions'
|
||||
import type { Transaction, TransactionCategory } from '@/types'
|
||||
|
||||
/**
|
||||
* POST /api/transactions/suggest-categories
|
||||
* Batch endpoint for getting category suggestions for multiple transactions
|
||||
*/
|
||||
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 { transaction_ids } = await request.json()
|
||||
|
||||
if (!Array.isArray(transaction_ids) || transaction_ids.length === 0) {
|
||||
return NextResponse.json({ error: 'transaction_ids is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Limit batch size
|
||||
const ids = transaction_ids.slice(0, 50)
|
||||
|
||||
// Fetch transactions
|
||||
const { data: transactions, error: txError } = await supabase
|
||||
.from('transactions')
|
||||
.select('*')
|
||||
.eq('user_id', user.id)
|
||||
.in('id', ids)
|
||||
|
||||
if (txError || !transactions) {
|
||||
return NextResponse.json({ error: 'Failed to fetch transactions' }, { status: 500 })
|
||||
}
|
||||
|
||||
// Fetch user's mapping rules (once, for all transactions)
|
||||
const { data: mappingRules } = await supabase
|
||||
.from('mapping_rules')
|
||||
.select('*')
|
||||
.or(`user_id.eq.${user.id},user_id.is.null`)
|
||||
.eq('is_active', true)
|
||||
.order('priority', { ascending: false })
|
||||
|
||||
// Build category history from user's past categorized transactions
|
||||
const { data: historicalTxns } = await supabase
|
||||
.from('transactions')
|
||||
.select('category')
|
||||
.eq('user_id', user.id)
|
||||
.not('is_business', 'is', null)
|
||||
.neq('category', 'uncategorized')
|
||||
.neq('category', 'private')
|
||||
.limit(200)
|
||||
|
||||
const categoryHistory: Record<string, number> = {}
|
||||
if (historicalTxns) {
|
||||
for (const tx of historicalTxns) {
|
||||
categoryHistory[tx.category] = (categoryHistory[tx.category] || 0) + 1
|
||||
}
|
||||
}
|
||||
|
||||
// Generate suggestions for each transaction
|
||||
const suggestions: Record<string, SuggestedCategory[]> = {}
|
||||
|
||||
for (const tx of transactions) {
|
||||
suggestions[tx.id] = getSuggestedCategories(
|
||||
tx as Transaction,
|
||||
mappingRules || [],
|
||||
categoryHistory
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json({ suggestions })
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
|
||||
/**
|
||||
* Validate EU VAT number using VIES (VAT Information Exchange System)
|
||||
*
|
||||
* The EU provides a SOAP-based API, but we'll use a REST wrapper
|
||||
* In production, you might want to use the official SOAP API or a dedicated service
|
||||
*/
|
||||
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 { vat_number, customer_id } = await request.json()
|
||||
|
||||
if (!vat_number) {
|
||||
return NextResponse.json({ error: 'VAT number is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Extract country code and number
|
||||
const countryCode = vat_number.substring(0, 2).toUpperCase()
|
||||
const vatNumber = vat_number.substring(2).replace(/\s/g, '')
|
||||
|
||||
try {
|
||||
// Use the EU VIES validation API
|
||||
// Note: In production, you should use the official SOAP API or a reliable service
|
||||
const response = await fetch(
|
||||
`https://ec.europa.eu/taxation_customs/vies/rest-api/ms/${countryCode}/vat/${vatNumber}`,
|
||||
{
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
// If VIES is unavailable, return a soft error
|
||||
return NextResponse.json({
|
||||
valid: false,
|
||||
error: 'VAT validation service unavailable. Please try again later.',
|
||||
})
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
const isValid = data.isValid === true
|
||||
|
||||
// Update customer if customer_id provided
|
||||
if (customer_id && isValid) {
|
||||
await supabase
|
||||
.from('customers')
|
||||
.update({
|
||||
vat_number: vat_number.toUpperCase(),
|
||||
vat_number_validated: true,
|
||||
vat_number_validated_at: new Date().toISOString(),
|
||||
})
|
||||
.eq('id', customer_id)
|
||||
.eq('user_id', user.id)
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
valid: isValid,
|
||||
name: data.name || null,
|
||||
address: data.address || null,
|
||||
country_code: countryCode,
|
||||
vat_number: vat_number.toUpperCase(),
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('VAT validation error:', error)
|
||||
|
||||
// Fallback: basic format validation
|
||||
const isValidFormat = validateVatNumberFormat(countryCode, vatNumber)
|
||||
|
||||
return NextResponse.json({
|
||||
valid: false,
|
||||
error: 'Could not verify VAT number. Service temporarily unavailable.',
|
||||
format_valid: isValidFormat,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Basic VAT number format validation by country
|
||||
*/
|
||||
function validateVatNumberFormat(countryCode: string, vatNumber: string): boolean {
|
||||
const patterns: Record<string, RegExp> = {
|
||||
AT: /^U\d{8}$/,
|
||||
BE: /^0\d{9}$/,
|
||||
BG: /^\d{9,10}$/,
|
||||
CY: /^\d{8}[A-Z]$/,
|
||||
CZ: /^\d{8,10}$/,
|
||||
DE: /^\d{9}$/,
|
||||
DK: /^\d{8}$/,
|
||||
EE: /^\d{9}$/,
|
||||
EL: /^\d{9}$/, // Greece
|
||||
ES: /^[A-Z0-9]\d{7}[A-Z0-9]$/,
|
||||
FI: /^\d{8}$/,
|
||||
FR: /^[A-Z0-9]{2}\d{9}$/,
|
||||
HR: /^\d{11}$/,
|
||||
HU: /^\d{8}$/,
|
||||
IE: /^[0-9A-Z]{8,9}$/,
|
||||
IT: /^\d{11}$/,
|
||||
LT: /^\d{9,12}$/,
|
||||
LU: /^\d{8}$/,
|
||||
LV: /^\d{11}$/,
|
||||
MT: /^\d{8}$/,
|
||||
NL: /^\d{9}B\d{2}$/,
|
||||
PL: /^\d{10}$/,
|
||||
PT: /^\d{9}$/,
|
||||
RO: /^\d{2,10}$/,
|
||||
SE: /^\d{12}$/,
|
||||
SI: /^\d{8}$/,
|
||||
SK: /^\d{10}$/,
|
||||
}
|
||||
|
||||
const pattern = patterns[countryCode]
|
||||
if (!pattern) {
|
||||
return false
|
||||
}
|
||||
|
||||
return pattern.test(vatNumber)
|
||||
}
|
||||
Reference in New Issue
Block a user