Refactor to generic ERP base
Remove influencer-specific features (campaigns, TikTok, gifts, shadow ledger, contracts, briefings) and consolidate into a clean ERP foundation with core bookkeeping, invoicing, receipts, tax reporting, and calendar functionality. Reorganize database migrations into a clean numbered sequence. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
a25d10a528
commit
0aecf5b41c
@@ -1,65 +0,0 @@
|
||||
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 })
|
||||
}
|
||||
@@ -1,159 +0,0 @@
|
||||
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 })
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -61,9 +61,9 @@ export async function GET(
|
||||
const endStr = endDate.toISOString().split('T')[0]
|
||||
|
||||
// Fetch relevant data based on feed options
|
||||
const [deadlinesResult, invoicesResult, campaignsResult, exclusivitiesResult] = await Promise.all([
|
||||
const [deadlinesResult, invoicesResult] = await Promise.all([
|
||||
// Deadlines
|
||||
(feed.include_tax_deadlines || feed.include_campaigns)
|
||||
feed.include_tax_deadlines
|
||||
? supabase
|
||||
.from('deadlines')
|
||||
.select('*')
|
||||
@@ -83,25 +83,6 @@ export async function GET(
|
||||
.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 {
|
||||
@@ -109,21 +90,17 @@ export async function GET(
|
||||
{
|
||||
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"',
|
||||
'Content-Disposition': 'attachment; filename="erp-base.ics"',
|
||||
'Cache-Control': 'no-cache, no-store, must-revalidate',
|
||||
'Pragma': 'no-cache',
|
||||
'Expires': '0',
|
||||
|
||||
@@ -27,7 +27,7 @@ export async function GET() {
|
||||
}
|
||||
|
||||
// Generate the feed URL
|
||||
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://app.influencer-biz.se'
|
||||
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://app.erp-base.se'
|
||||
|
||||
if (feed) {
|
||||
return NextResponse.json({
|
||||
@@ -79,8 +79,6 @@ export async function POST() {
|
||||
is_active: true,
|
||||
include_tax_deadlines: true,
|
||||
include_invoices: true,
|
||||
include_campaigns: true,
|
||||
include_exclusivity: false, // Avstängt som standard
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
@@ -89,7 +87,7 @@ export async function POST() {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://app.influencer-biz.se'
|
||||
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://app.erp-base.se'
|
||||
|
||||
return NextResponse.json({
|
||||
data: {
|
||||
@@ -126,7 +124,7 @@ export async function PUT(request: Request) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://app.influencer-biz.se'
|
||||
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://app.erp-base.se'
|
||||
|
||||
return NextResponse.json({
|
||||
data: {
|
||||
@@ -166,7 +164,7 @@ export async function DELETE() {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://app.influencer-biz.se'
|
||||
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://app.erp-base.se'
|
||||
|
||||
return NextResponse.json({
|
||||
data: {
|
||||
|
||||
@@ -1,146 +0,0 @@
|
||||
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 })
|
||||
}
|
||||
@@ -1,117 +0,0 @@
|
||||
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 })
|
||||
}
|
||||
}
|
||||
@@ -1,185 +0,0 @@
|
||||
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 })
|
||||
}
|
||||
}
|
||||
@@ -1,126 +0,0 @@
|
||||
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 })
|
||||
}
|
||||
@@ -1,159 +0,0 @@
|
||||
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
|
||||
})
|
||||
}
|
||||
@@ -1,204 +0,0 @@
|
||||
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 })
|
||||
}
|
||||
@@ -1,321 +0,0 @@
|
||||
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 })
|
||||
}
|
||||
}
|
||||
@@ -1,173 +0,0 @@
|
||||
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 })
|
||||
}
|
||||
@@ -1,144 +0,0 @@
|
||||
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
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
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
|
||||
})
|
||||
}
|
||||
@@ -1,244 +0,0 @@
|
||||
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 || []
|
||||
}
|
||||
@@ -1,163 +0,0 @@
|
||||
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 })
|
||||
}
|
||||
@@ -31,18 +31,10 @@ export async function GET(
|
||||
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')
|
||||
.select('id, invoice_number, invoice_date, due_date, status, total, currency')
|
||||
.eq('customer_id', id)
|
||||
.eq('user_id', user.id)
|
||||
.order('invoice_date', { ascending: false })
|
||||
@@ -50,7 +42,6 @@ export async function GET(
|
||||
return NextResponse.json({
|
||||
data: {
|
||||
...data,
|
||||
campaigns: campaigns || [],
|
||||
invoices: invoices || [],
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,153 +0,0 @@
|
||||
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 })
|
||||
}
|
||||
@@ -1,126 +0,0 @@
|
||||
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 })
|
||||
}
|
||||
@@ -1,145 +0,0 @@
|
||||
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 })
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
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
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,168 +0,0 @@
|
||||
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 })
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,170 +0,0 @@
|
||||
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 })
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
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 })
|
||||
}
|
||||
@@ -3,7 +3,6 @@ import { NextResponse } from 'next/server'
|
||||
import {
|
||||
sendTaxDeadlineNotifications,
|
||||
sendInvoiceNotifications,
|
||||
sendCampaignNotifications,
|
||||
} from '@/lib/push/notification-scheduler'
|
||||
|
||||
/**
|
||||
@@ -37,16 +36,13 @@ export async function GET(request: Request) {
|
||||
|
||||
try {
|
||||
// Send all notification types in parallel
|
||||
const [taxResult, invoiceResult, campaignResult] = await Promise.all([
|
||||
const [taxResult, invoiceResult] = 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
|
||||
const totalSent = taxResult.sent + invoiceResult.sent
|
||||
const totalSkipped = taxResult.skipped + invoiceResult.skipped
|
||||
|
||||
console.log(
|
||||
`Push notification cron completed: ${totalSent} sent, ${totalSkipped} skipped`
|
||||
@@ -57,9 +53,6 @@ export async function GET(request: Request) {
|
||||
console.log(
|
||||
` Invoice: ${invoiceResult.sent} sent, ${invoiceResult.skipped} skipped`
|
||||
)
|
||||
console.log(
|
||||
` Campaign: ${campaignResult.sent} sent, ${campaignResult.skipped} skipped`
|
||||
)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
@@ -68,7 +61,6 @@ export async function GET(request: Request) {
|
||||
details: {
|
||||
taxDeadlines: taxResult,
|
||||
invoices: invoiceResult,
|
||||
campaigns: campaignResult,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
|
||||
@@ -81,7 +81,6 @@ export async function POST(request: Request) {
|
||||
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',
|
||||
|
||||
@@ -1,156 +0,0 @@
|
||||
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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -33,7 +33,7 @@ export async function GET(request: Request) {
|
||||
fiscal_period_id: periodId,
|
||||
company_name: company.company_name || 'Unknown',
|
||||
org_number: company.org_number,
|
||||
program_name: 'InfluencerBiz',
|
||||
program_name: 'ERPBase',
|
||||
})
|
||||
|
||||
// Return as downloadable file
|
||||
|
||||
@@ -53,8 +53,8 @@ export async function PUT(request: Request) {
|
||||
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)) {
|
||||
// Check if tax-relevant fields changed and regenerate deadlines
|
||||
if (oldSettings && didTaxFieldsChange(oldSettings, data)) {
|
||||
try {
|
||||
await regenerateTaxDeadlinesForUser(supabase, user.id, {
|
||||
entity_type: data.entity_type,
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
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 })
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
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 })
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
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 })
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,117 +0,0 @@
|
||||
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'
|
||||
)}`
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
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)
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,153 +0,0 @@
|
||||
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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -132,33 +132,6 @@ export async function POST(
|
||||
? (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,
|
||||
|
||||
Reference in New Issue
Block a user