Initial commit: Lageplan v1.0 - Next.js 15.5, React 19
This commit is contained in:
86
src/app/api/rapports/[token]/pdf/route.ts
Normal file
86
src/app/api/rapports/[token]/pdf/route.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { prisma } from '@/lib/db'
|
||||
import React from 'react'
|
||||
|
||||
// Convert a MinIO/internal logo URL to a base64 data URI server-side
|
||||
async function resolveLogoDataUri(rapport: any): Promise<string> {
|
||||
try {
|
||||
const logoUrl = rapport.data?.logoUrl
|
||||
// Already a data URI — use as-is
|
||||
if (logoUrl && logoUrl.startsWith('data:')) return logoUrl
|
||||
|
||||
// Try to load logo from MinIO via tenant's logoFileKey / logoUrl
|
||||
const tenantId = rapport.tenantId
|
||||
if (!tenantId) return ''
|
||||
|
||||
const tenant = await (prisma as any).tenant.findUnique({
|
||||
where: { id: tenantId },
|
||||
select: { logoFileKey: true, logoUrl: true },
|
||||
})
|
||||
|
||||
let fileKey = tenant?.logoFileKey
|
||||
if (!fileKey && tenant?.logoUrl) {
|
||||
const match = tenant.logoUrl.match(/logos\/[^?]+/)
|
||||
if (match) fileKey = match[0]
|
||||
}
|
||||
if (!fileKey) return ''
|
||||
|
||||
const { getFileStream } = await import('@/lib/minio')
|
||||
const { stream, contentType } = await getFileStream(fileKey)
|
||||
const chunks: Buffer[] = []
|
||||
for await (const chunk of stream as AsyncIterable<Buffer>) {
|
||||
chunks.push(chunk)
|
||||
}
|
||||
const buffer = Buffer.concat(chunks)
|
||||
return `data:${contentType};base64,${buffer.toString('base64')}`
|
||||
} catch (e) {
|
||||
console.warn('[Rapport PDF] Could not resolve logo:', e)
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
// GET: Generate and serve PDF for a rapport (public, token-based)
|
||||
export async function GET(req: NextRequest, { params }: { params: { token: string } }) {
|
||||
try {
|
||||
const rapport = await (prisma as any).rapport.findUnique({
|
||||
where: { token: params.token },
|
||||
include: {
|
||||
tenant: { select: { name: true } },
|
||||
},
|
||||
})
|
||||
|
||||
if (!rapport) {
|
||||
return NextResponse.json({ error: 'Rapport nicht gefunden' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Ensure logo is a valid data URI for PDF rendering
|
||||
const pdfData = { ...rapport.data }
|
||||
const resolvedLogo = await resolveLogoDataUri(rapport)
|
||||
if (resolvedLogo) {
|
||||
pdfData.logoUrl = resolvedLogo
|
||||
} else if (pdfData.logoUrl && !pdfData.logoUrl.startsWith('data:')) {
|
||||
// Remove non-data-URI logo URLs — @react-pdf can't fetch them
|
||||
pdfData.logoUrl = ''
|
||||
}
|
||||
|
||||
// Dynamic import to avoid issues during build when @react-pdf/renderer isn't installed
|
||||
const { renderToBuffer } = await import('@react-pdf/renderer')
|
||||
const { RapportDocument } = await import('@/lib/rapport-pdf')
|
||||
|
||||
const buffer = await renderToBuffer(
|
||||
React.createElement(RapportDocument, { data: pdfData })
|
||||
)
|
||||
|
||||
return new NextResponse(buffer, {
|
||||
headers: {
|
||||
'Content-Type': 'application/pdf',
|
||||
'Content-Disposition': `inline; filename="Rapport-${rapport.reportNumber}.pdf"`,
|
||||
'Cache-Control': 'public, max-age=3600',
|
||||
},
|
||||
})
|
||||
} catch (error: any) {
|
||||
console.error('Error generating rapport PDF:', error)
|
||||
const msg = error?.message || String(error)
|
||||
return NextResponse.json({ error: 'PDF-Generierung fehlgeschlagen', detail: msg }, { status: 500 })
|
||||
}
|
||||
}
|
||||
77
src/app/api/rapports/[token]/route.ts
Normal file
77
src/app/api/rapports/[token]/route.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { prisma } from '@/lib/db'
|
||||
|
||||
// Resolve tenant logo to a base64 data URI (avoids exposing internal MinIO URLs)
|
||||
async function resolveLogoForClient(rapport: any): Promise<string> {
|
||||
try {
|
||||
const logoUrl = rapport.data?.logoUrl
|
||||
if (logoUrl && logoUrl.startsWith('data:')) return logoUrl
|
||||
|
||||
const tenantId = rapport.tenantId
|
||||
if (!tenantId) return ''
|
||||
|
||||
const tenant = await (prisma as any).tenant.findUnique({
|
||||
where: { id: tenantId },
|
||||
select: { logoFileKey: true, logoUrl: true },
|
||||
})
|
||||
|
||||
let fileKey = tenant?.logoFileKey
|
||||
if (!fileKey && tenant?.logoUrl) {
|
||||
const match = tenant.logoUrl.match(/logos\/[^?]+/)
|
||||
if (match) fileKey = match[0]
|
||||
}
|
||||
if (!fileKey) return ''
|
||||
|
||||
const { getFileStream } = await import('@/lib/minio')
|
||||
const { stream, contentType } = await getFileStream(fileKey)
|
||||
const chunks: Buffer[] = []
|
||||
for await (const chunk of stream as AsyncIterable<Buffer>) {
|
||||
chunks.push(chunk)
|
||||
}
|
||||
const buffer = Buffer.concat(chunks)
|
||||
return `data:${contentType};base64,${buffer.toString('base64')}`
|
||||
} catch (e) {
|
||||
console.warn('[Rapport] Could not resolve logo:', e)
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
// GET: Public access to rapport by token (no auth required)
|
||||
export async function GET(req: NextRequest, { params }: { params: { token: string } }) {
|
||||
try {
|
||||
const rapport = await (prisma as any).rapport.findUnique({
|
||||
where: { token: params.token },
|
||||
include: {
|
||||
project: { select: { title: true, location: true } },
|
||||
tenant: { select: { name: true } },
|
||||
createdBy: { select: { name: true } },
|
||||
},
|
||||
})
|
||||
|
||||
if (!rapport) {
|
||||
return NextResponse.json({ error: 'Rapport nicht gefunden' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Resolve logo to data URI so the client never sees internal MinIO URLs
|
||||
const rapportData = { ...rapport.data }
|
||||
const resolvedLogo = await resolveLogoForClient(rapport)
|
||||
if (resolvedLogo) {
|
||||
rapportData.logoUrl = resolvedLogo
|
||||
} else if (rapportData.logoUrl && !rapportData.logoUrl.startsWith('data:')) {
|
||||
rapportData.logoUrl = ''
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
id: rapport.id,
|
||||
reportNumber: rapport.reportNumber,
|
||||
data: rapportData,
|
||||
generatedAt: rapport.generatedAt,
|
||||
project: rapport.project,
|
||||
tenant: rapport.tenant,
|
||||
createdBy: rapport.createdBy,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error fetching rapport by token:', error)
|
||||
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
71
src/app/api/rapports/[token]/send/route.ts
Normal file
71
src/app/api/rapports/[token]/send/route.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { prisma } from '@/lib/db'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { sendEmail } from '@/lib/email'
|
||||
|
||||
// POST: Send rapport link via email
|
||||
export async function POST(req: NextRequest, { params }: { params: { token: string } }) {
|
||||
try {
|
||||
const user = await getSession()
|
||||
if (!user) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
|
||||
|
||||
const { email } = await req.json()
|
||||
if (!email) return NextResponse.json({ error: 'E-Mail-Adresse erforderlich' }, { status: 400 })
|
||||
|
||||
const rapport = await (prisma as any).rapport.findUnique({
|
||||
where: { token: params.token },
|
||||
include: {
|
||||
tenant: { select: { name: true } },
|
||||
project: { select: { title: true, location: true } },
|
||||
},
|
||||
})
|
||||
|
||||
if (!rapport) {
|
||||
return NextResponse.json({ error: 'Rapport nicht gefunden' }, { status: 404 })
|
||||
}
|
||||
|
||||
const baseUrl = process.env.NEXTAUTH_URL || req.headers.get('origin') || `${req.headers.get('x-forwarded-proto') || 'https'}://${req.headers.get('host')}` || 'http://localhost:3000'
|
||||
const rapportUrl = `${baseUrl}/rapport/${rapport.token}`
|
||||
const pdfUrl = `${baseUrl}/api/rapports/${rapport.token}/pdf`
|
||||
|
||||
const html = `
|
||||
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 600px; margin: 0 auto;">
|
||||
<div style="background: #1a1a1a; color: white; padding: 20px 24px; border-radius: 8px 8px 0 0;">
|
||||
<h2 style="margin: 0; font-size: 18px;">Einsatzrapport</h2>
|
||||
<p style="margin: 4px 0 0; font-size: 13px; opacity: 0.8;">${rapport.tenant?.name || ''}</p>
|
||||
</div>
|
||||
<div style="border: 1px solid #e5e7eb; border-top: none; padding: 24px; border-radius: 0 0 8px 8px;">
|
||||
<table style="width: 100%; font-size: 14px; margin-bottom: 16px;">
|
||||
<tr><td style="color: #6b7280; padding: 4px 0;">Rapport-Nr.</td><td style="font-weight: 600;">${rapport.reportNumber}</td></tr>
|
||||
<tr><td style="color: #6b7280; padding: 4px 0;">Einsatz</td><td style="font-weight: 600;">${rapport.project?.title || '—'}</td></tr>
|
||||
<tr><td style="color: #6b7280; padding: 4px 0;">Standort</td><td>${rapport.project?.location || '—'}</td></tr>
|
||||
<tr><td style="color: #6b7280; padding: 4px 0;">Erstellt</td><td>${new Date(rapport.createdAt).toLocaleString('de-CH')}</td></tr>
|
||||
</table>
|
||||
<div style="margin: 20px 0;">
|
||||
<a href="${rapportUrl}" style="display: inline-block; background: #1a1a1a; color: white; padding: 10px 24px; border-radius: 6px; text-decoration: none; font-weight: 600; font-size: 14px; margin-right: 8px;">
|
||||
Rapport ansehen
|
||||
</a>
|
||||
<a href="${pdfUrl}" style="display: inline-block; background: white; color: #1a1a1a; padding: 10px 24px; border-radius: 6px; text-decoration: none; font-weight: 600; font-size: 14px; border: 1px solid #d1d5db;">
|
||||
PDF herunterladen
|
||||
</a>
|
||||
</div>
|
||||
<p style="font-size: 12px; color: #9ca3af; margin-top: 16px;">
|
||||
Dieser Link ist öffentlich zugänglich — keine Anmeldung nötig.<br/>
|
||||
Gesendet von ${user.name || user.email} via app.lageplan.ch
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
|
||||
await sendEmail(
|
||||
email,
|
||||
`Einsatzrapport ${rapport.reportNumber} — ${rapport.project?.title || 'Lageplan'}`,
|
||||
html
|
||||
)
|
||||
|
||||
return NextResponse.json({ success: true, message: `Rapport an ${email} gesendet` })
|
||||
} catch (error) {
|
||||
console.error('Error sending rapport email:', error)
|
||||
return NextResponse.json({ error: 'E-Mail konnte nicht gesendet werden' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
140
src/app/api/rapports/route.ts
Normal file
140
src/app/api/rapports/route.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { prisma } from '@/lib/db'
|
||||
import { getSession, isAdmin } from '@/lib/auth'
|
||||
import QRCode from 'qrcode'
|
||||
|
||||
// Helper: create a rapport record and return JSON response
|
||||
async function createRapport(projectId: string, data: any, tenantId: string, userId: string, req: NextRequest) {
|
||||
// Einsatz-Nummer: one unique number per project (not per rapport)
|
||||
const existingRapport = await (prisma as any).rapport.findFirst({
|
||||
where: { projectId },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
select: { reportNumber: true },
|
||||
})
|
||||
|
||||
let reportNumber: string
|
||||
if (existingRapport?.reportNumber) {
|
||||
reportNumber = existingRapport.reportNumber
|
||||
} else {
|
||||
const year = new Date().getFullYear()
|
||||
const prefix = `${year}-`
|
||||
const lastRapport = await (prisma as any).rapport.findFirst({
|
||||
where: { tenantId, reportNumber: { startsWith: prefix } },
|
||||
orderBy: { reportNumber: 'desc' },
|
||||
select: { reportNumber: true },
|
||||
})
|
||||
let nextNum = 1
|
||||
if (lastRapport?.reportNumber) {
|
||||
const lastNum = parseInt(lastRapport.reportNumber.split('-')[1])
|
||||
if (!isNaN(lastNum)) nextNum = lastNum + 1
|
||||
}
|
||||
reportNumber = `${prefix}${String(nextNum).padStart(4, '0')}`
|
||||
}
|
||||
|
||||
data.reportNumber = reportNumber
|
||||
|
||||
const rapport = await (prisma as any).rapport.create({
|
||||
data: { reportNumber, data, projectId, tenantId, createdById: userId },
|
||||
})
|
||||
|
||||
// Generate QR code
|
||||
const baseUrl = process.env.NEXTAUTH_URL || req.headers.get('origin') || `${req.headers.get('x-forwarded-proto') || 'https'}://${req.headers.get('host')}` || 'http://localhost:3000'
|
||||
const rapportUrl = `${baseUrl}/rapport/${rapport.token}`
|
||||
let qrCodeDataUri = ''
|
||||
try {
|
||||
qrCodeDataUri = await QRCode.toDataURL(rapportUrl, { width: 200, margin: 1, color: { dark: '#1a1a1a', light: '#ffffff' } })
|
||||
} catch (e) {
|
||||
console.warn('QR code generation failed:', e)
|
||||
}
|
||||
|
||||
if (qrCodeDataUri) {
|
||||
data.qrCodeUrl = qrCodeDataUri
|
||||
await (prisma as any).rapport.update({ where: { id: rapport.id }, data: { data } })
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
id: rapport.id,
|
||||
reportNumber: rapport.reportNumber,
|
||||
token: rapport.token,
|
||||
qrCodeUrl: qrCodeDataUri,
|
||||
})
|
||||
}
|
||||
|
||||
// GET: List rapports for a project
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const user = await getSession()
|
||||
if (!user) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
|
||||
|
||||
const { searchParams } = new URL(req.url)
|
||||
const projectId = searchParams.get('projectId')
|
||||
|
||||
if (!projectId) {
|
||||
return NextResponse.json({ error: 'projectId erforderlich' }, { status: 400 })
|
||||
}
|
||||
|
||||
const rapports = await (prisma as any).rapport.findMany({
|
||||
where: { projectId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
select: {
|
||||
id: true,
|
||||
reportNumber: true,
|
||||
token: true,
|
||||
generatedAt: true,
|
||||
createdAt: true,
|
||||
createdBy: { select: { name: true } },
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json({ rapports })
|
||||
} catch (error) {
|
||||
console.error('Error fetching rapports:', error)
|
||||
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
// POST: Create a new rapport
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const user = await getSession()
|
||||
if (!user) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
|
||||
|
||||
let body: any
|
||||
try {
|
||||
body = await req.json()
|
||||
} catch (parseErr: any) {
|
||||
console.error('[Rapport] Body parse error:', parseErr?.message)
|
||||
return NextResponse.json({ error: 'Request zu gross oder ungültig: ' + (parseErr?.message || 'Body konnte nicht gelesen werden') }, { status: 400 })
|
||||
}
|
||||
const { projectId, data } = body || {}
|
||||
|
||||
if (!projectId || !data) {
|
||||
console.error('[Rapport] Missing fields — projectId:', !!projectId, 'data:', !!data, 'body keys:', Object.keys(body || {}))
|
||||
return NextResponse.json({ error: `Felder fehlen (projectId=${!!projectId}, data=${!!data})` }, { status: 400 })
|
||||
}
|
||||
|
||||
// Resolve tenantId: project → user session → membership lookup
|
||||
const project = await (prisma as any).project.findUnique({
|
||||
where: { id: projectId },
|
||||
select: { tenantId: true },
|
||||
})
|
||||
|
||||
let tenantId = project?.tenantId || user.tenantId || null
|
||||
if (!tenantId) {
|
||||
const membership = await (prisma as any).tenantMembership.findFirst({
|
||||
where: { userId: user.id },
|
||||
select: { tenantId: true },
|
||||
})
|
||||
tenantId = membership?.tenantId || null
|
||||
}
|
||||
|
||||
console.log('[Rapport] Creating rapport — project:', projectId, 'tenant:', tenantId || '(none)')
|
||||
return await createRapport(projectId, data, tenantId, user.id, req)
|
||||
} catch (error: any) {
|
||||
console.error('[Rapport] Error:', error?.message || error)
|
||||
if (error?.code === 'P2002') {
|
||||
return NextResponse.json({ error: 'Rapportnummer existiert bereits' }, { status: 409 })
|
||||
}
|
||||
return NextResponse.json({ error: error?.message || 'Serverfehler' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user