Initial commit: Lageplan v1.0 - Next.js 15.5, React 19

This commit is contained in:
Pepe Ziberi
2026-02-21 11:57:44 +01:00
commit adf3dc8c1d
167 changed files with 34265 additions and 0 deletions

View File

@@ -0,0 +1,86 @@
import { NextRequest, NextResponse } from 'next/server'
import db from '@/lib/db'
import { getSession } from '@/lib/auth'
import { z } from 'zod'
const updateSchema = z.object({
name: z.string().min(1).max(100).optional(),
description: z.string().max(500).nullable().optional(),
sortOrder: z.number().int().optional(),
})
export async function PATCH(
req: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const user = await getSession()
if (!user) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
// Check permission: TENANT_ADMIN can only edit their own tenant categories
const category = await db.iconCategory.findUnique({ where: { id: params.id } }) as any
if (!category) return NextResponse.json({ error: 'Kategorie nicht gefunden' }, { status: 404 })
if (user.role !== 'SERVER_ADMIN') {
if (!category.tenantId || category.tenantId !== user.tenantId) {
return NextResponse.json({ error: 'Keine Berechtigung für diese Kategorie' }, { status: 403 })
}
}
const body = await req.json()
const data = updateSchema.parse(body)
const updated = await db.iconCategory.update({
where: { id: params.id },
data,
})
return NextResponse.json({ category: updated })
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json({ error: 'Ungültige Daten' }, { status: 400 })
}
console.error('Error updating category:', error)
return NextResponse.json({ error: 'Interner Fehler' }, { status: 500 })
}
}
export async function DELETE(
req: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const user = await getSession()
if (!user) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
// Check permission: TENANT_ADMIN can only delete their own tenant categories
const category = await db.iconCategory.findUnique({ where: { id: params.id } }) as any
if (!category) return NextResponse.json({ error: 'Kategorie nicht gefunden' }, { status: 404 })
// Global categories (isGlobal=true, tenantId=null) cannot be deleted
if (category.isGlobal || (!category.tenantId && category.isSystem !== false)) {
return NextResponse.json({ error: 'Globale Kategorien können nicht gelöscht werden. Blenden Sie sie stattdessen aus.' }, { status: 403 })
}
if (user.role !== 'SERVER_ADMIN') {
if (!category.tenantId || category.tenantId !== user.tenantId) {
return NextResponse.json({ error: 'Keine Berechtigung für diese Kategorie' }, { status: 403 })
}
}
// Disconnect all icons from this category (set categoryId to null) so they are NOT deleted
await db.$executeRawUnsafe(
`UPDATE icon_assets SET "categoryId" = NULL WHERE "categoryId" = $1`,
params.id
)
await db.iconCategory.delete({
where: { id: params.id },
})
return NextResponse.json({ success: true })
} catch (error) {
console.error('Error deleting category:', error)
return NextResponse.json({ error: 'Interner Fehler' }, { status: 500 })
}
}

View File

@@ -0,0 +1,77 @@
import { NextRequest, NextResponse } from 'next/server'
import db from '@/lib/db'
import { getSession } from '@/lib/auth'
import { z } from 'zod'
const categorySchema = z.object({
name: z.string().min(1).max(100),
description: z.string().max(500).nullable().optional(),
})
export async function GET() {
try {
const user = await getSession()
if (!user) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
let where: any = {}
if (user.role !== 'SERVER_ADMIN') {
// TENANT_ADMIN: show global categories (tenantId=null) + their own tenant categories
where = {
OR: [
{ tenantId: null },
...(user.tenantId ? [{ tenantId: user.tenantId }] : []),
],
}
}
// SERVER_ADMIN: show all categories (no filter)
const categories = await db.iconCategory.findMany({
where,
orderBy: { sortOrder: 'asc' },
include: {
_count: { select: { icons: true } },
},
})
return NextResponse.json({ categories })
} catch (error) {
console.error('Error fetching categories:', error)
return NextResponse.json({ error: 'Interner Fehler' }, { status: 500 })
}
}
export async function POST(req: NextRequest) {
try {
const user = await getSession()
if (!user) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
const body = await req.json()
const data = categorySchema.parse(body)
const maxOrder = await db.iconCategory.aggregate({
_max: { sortOrder: true },
})
// SERVER_ADMIN creates global categories; TENANT_ADMIN creates tenant-specific ones
const isGlobal = user.role === 'SERVER_ADMIN'
const tenantId = user.role === 'SERVER_ADMIN' ? null : (user.tenantId || null)
const category = await db.iconCategory.create({
data: {
name: data.name,
description: data.description,
sortOrder: (maxOrder._max.sortOrder || 0) + 1,
isGlobal,
tenantId,
} as any,
})
return NextResponse.json({ category }, { status: 201 })
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json({ error: 'Ungültige Daten' }, { status: 400 })
}
console.error('Error creating category:', error)
return NextResponse.json({ error: 'Interner Fehler' }, { status: 500 })
}
}

View File

@@ -0,0 +1,103 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/db'
import { deleteFile } from '@/lib/minio'
import { getSession, isAdmin, isServerAdmin } from '@/lib/auth'
import { z } from 'zod'
const updateSchema = z.object({
name: z.string().min(1).max(100).optional(),
categoryId: z.string().uuid().optional(),
iconType: z.enum(['STANDARD', 'RETTUNG', 'GEFAHRSTOFF', 'FEUER', 'WASSER', 'FAHRZEUG']).optional(),
tags: z.array(z.string()).optional(),
isActive: z.boolean().optional(),
})
export async function PATCH(
req: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const user = await getSession()
if (!user || !isAdmin(user.role)) {
return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 403 })
}
const body = await req.json()
const data = updateSchema.parse(body)
// TENANT_ADMIN can only edit their own icons
if (!isServerAdmin(user.role)) {
const existing = await (prisma as any).iconAsset.findUnique({ where: { id: params.id } })
if (existing && existing.tenantId !== user.tenantId) {
return NextResponse.json({ error: 'Keine Berechtigung für dieses Symbol' }, { status: 403 })
}
}
const icon = await (prisma as any).iconAsset.update({
where: { id: params.id },
data: {
...(data.name && { name: data.name }),
...(data.categoryId && { categoryId: data.categoryId }),
...(data.iconType && { iconType: data.iconType }),
...(data.tags && { tags: data.tags }),
...(data.isActive !== undefined && { isActive: data.isActive }),
},
include: {
category: true,
},
})
return NextResponse.json({ icon })
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json({ error: 'Ungültige Daten' }, { status: 400 })
}
console.error('Error updating icon:', error)
return NextResponse.json({ error: 'Interner Fehler' }, { status: 500 })
}
}
export async function DELETE(
req: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const user = await getSession()
if (!user || !isAdmin(user.role)) {
return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 403 })
}
const icon = await (prisma as any).iconAsset.findUnique({
where: { id: params.id },
})
if (!icon) {
return NextResponse.json({ error: 'Icon nicht gefunden' }, { status: 404 })
}
// TENANT_ADMIN can only delete their own tenant icons, not global/system ones
if (!isServerAdmin(user.role)) {
if (!icon.tenantId || icon.tenantId !== user.tenantId) {
return NextResponse.json({ error: 'Globale Symbole können nicht gelöscht werden. Sie können nur Ihre eigenen Symbole löschen.' }, { status: 403 })
}
}
// Delete from MinIO if not a system icon
if (icon.fileKey && !icon.fileKey.startsWith('system:') && !icon.isSystem) {
try {
await deleteFile(icon.fileKey)
} catch (e) {
console.error('Error deleting file from MinIO:', e)
}
}
await (prisma as any).iconAsset.delete({
where: { id: params.id },
})
return NextResponse.json({ success: true })
} catch (error) {
console.error('Error deleting icon:', error)
return NextResponse.json({ error: 'Interner Fehler' }, { status: 500 })
}
}

View File

@@ -0,0 +1,18 @@
import { NextResponse } from 'next/server'
import db from '@/lib/db'
export async function GET() {
try {
const icons = await db.iconAsset.findMany({
orderBy: [{ category: { sortOrder: 'asc' } }, { name: 'asc' }],
include: {
category: true,
},
})
return NextResponse.json({ icons })
} catch (error) {
console.error('Error fetching icons:', error)
return NextResponse.json({ error: 'Interner Fehler' }, { status: 500 })
}
}

View File

@@ -0,0 +1,91 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/db'
import { uploadFile } from '@/lib/minio'
import { getSession, isAdmin } from '@/lib/auth'
import { v4 as uuidv4 } from 'uuid'
const ALLOWED_TYPES = ['image/png', 'image/svg+xml', 'image/jpeg', 'image/webp']
const MAX_SIZE = 5 * 1024 * 1024 // 5MB
export async function POST(req: NextRequest) {
try {
const user = await getSession()
if (!user || !isAdmin(user.role)) {
return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 403 })
}
const formData = await req.formData()
const file = formData.get('file') as File | null
const categoryId = formData.get('categoryId') as string
const iconType = (formData.get('iconType') as string) || 'STANDARD'
const name = formData.get('name') as string
if (!file || !categoryId || !name) {
return NextResponse.json(
{ error: 'Datei, Kategorie und Name sind erforderlich' },
{ status: 400 }
)
}
if (!ALLOWED_TYPES.includes(file.type)) {
return NextResponse.json(
{ error: 'Nur PNG, SVG, JPEG und WebP Dateien erlaubt' },
{ status: 400 }
)
}
if (file.size > MAX_SIZE) {
return NextResponse.json(
{ error: 'Datei zu groß (max. 5MB)' },
{ status: 400 }
)
}
// Check category exists
const category = await (prisma as any).iconCategory.findUnique({
where: { id: categoryId },
})
if (!category) {
return NextResponse.json(
{ error: 'Kategorie nicht gefunden' },
{ status: 404 }
)
}
// Generate safe filename
const ext = file.name.split('.').pop()?.toLowerCase() || 'png'
const safeFileName = `${uuidv4()}.${ext}`
const fileKey = `icons/${safeFileName}`
// Upload to MinIO
const buffer = Buffer.from(await file.arrayBuffer())
await uploadFile(fileKey, buffer, file.type)
// TENANT_ADMIN: icons get tenantId. SERVER_ADMIN: global icons (tenantId=null)
const tenantId = user.role === 'SERVER_ADMIN' ? null : user.tenantId || null
// Create database entry
const icon = await (prisma as any).iconAsset.create({
data: {
name: name.trim(),
fileKey,
mimeType: file.type,
categoryId,
iconType: iconType as any,
isSystem: false,
isActive: true,
tenantId,
ownerId: user.id,
},
include: {
category: true,
},
})
return NextResponse.json({ icon }, { status: 201 })
} catch (error) {
console.error('Error uploading icon:', error)
return NextResponse.json({ error: 'Upload fehlgeschlagen' }, { status: 500 })
}
}

View File

@@ -0,0 +1,156 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/db'
import { getSession, isServerAdmin } from '@/lib/auth'
import { getSmtpConfig, saveSmtpConfig, testSmtpConnection, sendEmail } from '@/lib/email'
import { getStripeConfig, saveStripeConfig } from '@/lib/stripe'
// GET SMTP settings (mask password)
export async function GET() {
try {
const user = await getSession()
if (!user || !isServerAdmin(user.role)) {
return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 403 })
}
const smtp = await getSmtpConfig()
// Load contact email
let contactEmail = 'app@lageplan.ch'
try {
const setting = await (prisma as any).systemSetting.findUnique({ where: { key: 'contact_email' } })
if (setting) contactEmail = setting.value
} catch {}
// Load Stripe settings
const stripeConfig = await getStripeConfig()
// Load demo project ID
let demoProjectId = ''
try {
const demoSetting = await (prisma as any).systemSetting.findUnique({ where: { key: 'demo_project_id' } })
if (demoSetting) demoProjectId = demoSetting.value
} catch {}
// Load registration notification email
let notifyRegistrationEmail = ''
try {
const nrSetting = await (prisma as any).systemSetting.findUnique({ where: { key: 'notify_registration_email' } })
if (nrSetting) notifyRegistrationEmail = nrSetting.value
} catch {}
// Load default symbol scale
let defaultSymbolScale = '1.5'
try {
const scaleSetting = await (prisma as any).systemSetting.findUnique({ where: { key: 'default_symbol_scale' } })
if (scaleSetting) defaultSymbolScale = scaleSetting.value
} catch {}
return NextResponse.json({
smtp: smtp ? { ...smtp, pass: smtp.pass ? '••••••••' : '' } : null,
contactEmail,
notifyRegistrationEmail,
demoProjectId,
defaultSymbolScale,
stripe: stripeConfig ? {
publicKey: stripeConfig.publicKey || '',
secretKey: stripeConfig.secretKey ? '••••••••' : '',
webhookSecret: stripeConfig.webhookSecret ? '••••••••' : '',
} : null,
})
} catch (error) {
console.error('Error fetching settings:', error)
return NextResponse.json({ error: 'Interner Fehler' }, { status: 500 })
}
}
// Save SMTP settings
export async function PUT(req: NextRequest) {
try {
const user = await getSession()
if (!user || !isServerAdmin(user.role)) {
return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 403 })
}
const body = await req.json()
const { action, smtp, testEmail } = body
if (action === 'save_smtp') {
// Don't overwrite password if masked
const config: any = { ...smtp }
if (config.pass === '••••••••') {
delete config.pass
}
await saveSmtpConfig(config)
return NextResponse.json({ success: true, message: 'SMTP-Einstellungen gespeichert' })
}
if (action === 'test_smtp') {
const result = await testSmtpConnection()
return NextResponse.json(result)
}
if (action === 'send_test_email') {
if (!testEmail) {
return NextResponse.json({ error: 'E-Mail-Adresse erforderlich' }, { status: 400 })
}
try {
await sendEmail(
testEmail,
'Lageplan - Test E-Mail',
`<h2>Test E-Mail</h2><p>Diese E-Mail wurde von der Lageplan-Applikation gesendet.</p><p>SMTP-Konfiguration funktioniert korrekt.</p><p><small>Gesendet am ${new Date().toLocaleString('de-CH')}</small></p>`
)
return NextResponse.json({ success: true, message: `Test-E-Mail an ${testEmail} gesendet` })
} catch (error) {
return NextResponse.json({ success: false, error: error instanceof Error ? error.message : 'Senden fehlgeschlagen' })
}
}
if (action === 'save_stripe') {
const { stripe: stripeData } = body
if (!stripeData) return NextResponse.json({ error: 'Stripe-Daten fehlen' }, { status: 400 })
await saveStripeConfig({
secretKey: stripeData.secretKey,
publicKey: stripeData.publicKey,
webhookSecret: stripeData.webhookSecret,
})
return NextResponse.json({ success: true, message: 'Stripe-Einstellungen gespeichert' })
}
if (action === 'save_demo_project') {
const { demoProjectId } = body
await (prisma as any).systemSetting.upsert({
where: { key: 'demo_project_id' },
update: { value: demoProjectId || '' },
create: { key: 'demo_project_id', value: demoProjectId || '', isSecret: false, category: 'general' },
})
return NextResponse.json({ success: true, message: 'Demo-Projekt gespeichert' })
}
if (action === 'save_contact_email') {
const { contactEmail } = body
if (!contactEmail) return NextResponse.json({ error: 'E-Mail erforderlich' }, { status: 400 })
await (prisma as any).systemSetting.upsert({
where: { key: 'contact_email' },
update: { value: contactEmail },
create: { key: 'contact_email', value: contactEmail, isSecret: false, category: 'general' },
})
return NextResponse.json({ success: true })
}
if (action === 'save_setting') {
const { key, value } = body
if (!key) return NextResponse.json({ error: 'Key erforderlich' }, { status: 400 })
await (prisma as any).systemSetting.upsert({
where: { key },
update: { value: value || '' },
create: { key, value: value || '', isSecret: false, category: 'general' },
})
return NextResponse.json({ success: true })
}
return NextResponse.json({ error: 'Unbekannte Aktion' }, { status: 400 })
} catch (error) {
console.error('Error updating settings:', error)
return NextResponse.json({ error: 'Interner Fehler' }, { status: 500 })
}
}

View File

@@ -0,0 +1,77 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/db'
import { getSession, isServerAdmin } from '@/lib/auth'
import { uploadFile, getFileUrl, deleteFile } from '@/lib/minio'
export async function POST(req: NextRequest, { params }: { params: { id: string } }) {
try {
const user = await getSession()
if (!user || !isServerAdmin(user.role)) {
return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 403 })
}
const formData = await req.formData()
const file = formData.get('logo') as File
if (!file) {
return NextResponse.json({ error: 'Keine Datei hochgeladen' }, { status: 400 })
}
// Validate file type
const validTypes = ['image/png', 'image/jpeg', 'image/svg+xml', 'image/webp']
if (!validTypes.includes(file.type)) {
return NextResponse.json({ error: 'Ungültiges Dateiformat. Erlaubt: PNG, JPEG, SVG, WebP' }, { status: 400 })
}
// Max 2MB
if (file.size > 2 * 1024 * 1024) {
return NextResponse.json({ error: 'Datei zu gross (max. 2 MB)' }, { status: 400 })
}
const buffer = Buffer.from(await file.arrayBuffer())
const ext = file.name.split('.').pop() || 'png'
const fileKey = `logos/tenant-${params.id}.${ext}`
await uploadFile(fileKey, buffer, file.type)
// Store fileKey for proxy-based serving, logoUrl for backward compat
const logoServeUrl = `/api/admin/tenants/${params.id}/logo/serve`
await (prisma as any).tenant.update({
where: { id: params.id },
data: { logoFileKey: fileKey, logoUrl: logoServeUrl },
})
return NextResponse.json({ logoUrl: logoServeUrl })
} catch (error) {
console.error('Logo upload error:', error)
return NextResponse.json({ error: 'Upload fehlgeschlagen' }, { status: 500 })
}
}
export async function DELETE(req: NextRequest, { params }: { params: { id: string } }) {
try {
const user = await getSession()
if (!user || !isServerAdmin(user.role)) {
return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 403 })
}
// Get current logo
const tenant = await (prisma as any).tenant.findUnique({ where: { id: params.id } })
if (tenant?.logoUrl) {
try {
const urlParts = tenant.logoUrl.split('/')
const fileKey = `logos/${urlParts[urlParts.length - 1]}`
await deleteFile(fileKey)
} catch {}
}
await (prisma as any).tenant.update({
where: { id: params.id },
data: { logoUrl: null },
})
return NextResponse.json({ success: true })
} catch (error) {
console.error('Logo delete error:', error)
return NextResponse.json({ error: 'Löschen fehlgeschlagen' }, { status: 500 })
}
}

View File

@@ -0,0 +1,43 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/db'
import { getFileStream } from '@/lib/minio'
// Serve tenant logo via proxy (no direct MinIO URL needed)
export async function GET(req: NextRequest, { params }: { params: { id: string } }) {
try {
const tenant = await (prisma as any).tenant.findUnique({
where: { id: params.id },
select: { logoFileKey: true, logoUrl: true },
})
// Try logoFileKey first, fallback to extracting from logoUrl
let fileKey = tenant?.logoFileKey
if (!fileKey && tenant?.logoUrl) {
const match = tenant.logoUrl.match(/logos\/[^?]+/)
if (match) fileKey = match[0]
}
if (!fileKey) {
return NextResponse.json({ error: 'Kein Logo vorhanden' }, { status: 404 })
}
const { stream, contentType } = await getFileStream(fileKey)
// Collect stream into buffer
const chunks: Buffer[] = []
for await (const chunk of stream as AsyncIterable<Buffer>) {
chunks.push(chunk)
}
const buffer = Buffer.concat(chunks)
return new NextResponse(buffer, {
headers: {
'Content-Type': contentType,
'Cache-Control': 'public, max-age=3600',
},
})
} catch (error) {
console.error('Error serving logo:', error)
return NextResponse.json({ error: 'Logo konnte nicht geladen werden' }, { status: 500 })
}
}

View File

@@ -0,0 +1,178 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/db'
import { getSession, isServerAdmin } from '@/lib/auth'
import bcrypt from 'bcryptjs'
import crypto from 'crypto'
import { sendEmail, getSmtpConfig } from '@/lib/email'
function generateTempPassword(): string {
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789'
let pw = ''
for (let i = 0; i < 10; i++) pw += chars[crypto.randomInt(chars.length)]
return pw
}
// Create a NEW user and add as member to tenant
export async function POST(req: NextRequest, { params }: { params: { id: string } }) {
try {
const user = await getSession()
if (!user || !isServerAdmin(user.role)) {
return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 403 })
}
const { name, email, role } = await req.json()
if (!name || !email) {
return NextResponse.json({ error: 'Name und E-Mail sind erforderlich' }, { status: 400 })
}
// Check tenant user limit
const tenant = await (prisma as any).tenant.findUnique({
where: { id: params.id },
include: { _count: { select: { memberships: true } } },
})
if (!tenant) {
return NextResponse.json({ error: 'Mandant nicht gefunden' }, { status: 404 })
}
// User limit removed — no restriction on number of users
// Check if user with this email already exists
const existingUser = await (prisma as any).user.findUnique({ where: { email: email.toLowerCase() } })
if (existingUser) {
// Check if already member of this tenant
const existingMembership = await (prisma as any).tenantMembership.findUnique({
where: { userId_tenantId: { userId: existingUser.id, tenantId: params.id } },
})
if (existingMembership) {
return NextResponse.json({ error: 'Ein Benutzer mit dieser E-Mail ist bereits Mitglied dieses Mandanten.' }, { status: 400 })
}
return NextResponse.json({ error: 'Ein Benutzer mit dieser E-Mail existiert bereits im System. Bitte verwenden Sie eine andere E-Mail-Adresse.' }, { status: 400 })
}
// Generate temp password
const tempPassword = generateTempPassword()
const hashedPassword = await bcrypt.hash(tempPassword, 12)
// Create user + membership in transaction
const result = await (prisma as any).$transaction(async (tx: any) => {
const newUser = await tx.user.create({
data: {
email: email.toLowerCase(),
name,
password: hashedPassword,
role: role || 'OPERATOR',
},
})
const membership = await tx.tenantMembership.create({
data: {
userId: newUser.id,
tenantId: params.id,
role: role || 'OPERATOR',
},
include: { user: { select: { id: true, email: true, name: true, role: true } } },
})
return { user: newUser, membership }
})
// Send welcome email (best effort)
let emailSent = false
try {
const smtpConfig = await getSmtpConfig()
if (smtpConfig) {
const appUrl = process.env.NEXT_PUBLIC_APP_URL || process.env.NEXTAUTH_URL || 'https://app.lageplan.ch'
const html = `
<div style="font-family:sans-serif;max-width:600px;margin:0 auto;">
<div style="background:#dc2626;color:white;padding:20px 24px;border-radius:12px 12px 0 0;">
<h1 style="margin:0;font-size:22px;">Willkommen bei Lageplan</h1>
</div>
<div style="border:1px solid #e5e7eb;border-top:none;padding:24px;border-radius:0 0 12px 12px;">
<p>Hallo <strong>${name}</strong>,</p>
<p>Sie wurden als Benutzer für <strong>${tenant.name}</strong> eingerichtet.</p>
<div style="background:#f3f4f6;border-radius:8px;padding:16px;margin:20px 0;">
<p style="margin:0 0 8px;font-size:14px;color:#6b7280;">Ihre Zugangsdaten:</p>
<table style="width:100%;">
<tr><td style="padding:4px 0;font-weight:bold;width:100px;">E-Mail:</td><td>${email.toLowerCase()}</td></tr>
<tr><td style="padding:4px 0;font-weight:bold;">Passwort:</td><td style="font-family:monospace;font-size:16px;letter-spacing:1px;">${tempPassword}</td></tr>
</table>
</div>
<p style="color:#dc2626;font-weight:bold;font-size:14px;">Bitte ändern Sie Ihr Passwort nach der ersten Anmeldung.</p>
<a href="${appUrl}/login" style="display:inline-block;background:#dc2626;color:white;padding:12px 24px;border-radius:8px;text-decoration:none;font-weight:bold;margin-top:12px;">
Jetzt anmelden
</a>
<hr style="border:none;border-top:1px solid #e5e7eb;margin:24px 0;" />
<p style="font-size:12px;color:#9ca3af;">Diese E-Mail wurde automatisch von Lageplan gesendet. Gehostet in der Schweiz.</p>
</div>
</div>
`
await sendEmail(email.toLowerCase(), `Willkommen bei Lageplan — Ihre Zugangsdaten für ${tenant.name}`, html)
emailSent = true
}
} catch (e) {
console.error('Welcome email failed:', e)
}
return NextResponse.json({
membership: result.membership,
tempPassword,
emailSent,
}, { status: 201 })
} catch (error) {
console.error('Error creating member:', error)
return NextResponse.json({ error: 'Interner Fehler' }, { status: 500 })
}
}
// Remove a user from a tenant (and delete the user)
export async function DELETE(req: NextRequest, { params }: { params: { id: string } }) {
try {
const user = await getSession()
if (!user || !isServerAdmin(user.role)) {
return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 403 })
}
const { searchParams } = new URL(req.url)
const membershipId = searchParams.get('membershipId')
const deleteUser = searchParams.get('deleteUser') === 'true'
if (!membershipId) {
return NextResponse.json({ error: 'membershipId erforderlich' }, { status: 400 })
}
// Get the membership to find the userId
const membership = await (prisma as any).tenantMembership.findUnique({
where: { id: membershipId },
})
// Delete membership
await (prisma as any).tenantMembership.delete({ where: { id: membershipId } })
// Optionally delete the user too (if they have no other memberships)
if (deleteUser && membership) {
const otherMemberships = await (prisma as any).tenantMembership.count({
where: { userId: membership.userId },
})
if (otherMemberships === 0) {
// Reassign projects to tenant admin before deleting
const adminMembership = await (prisma as any).tenantMembership.findFirst({
where: {
tenantId: params.id,
userId: { not: membership.userId },
role: 'TENANT_ADMIN',
},
})
const newOwnerId = adminMembership?.userId || user.id
await (prisma as any).project.updateMany({
where: { ownerId: membership.userId },
data: { ownerId: newOwnerId },
})
await (prisma as any).user.delete({ where: { id: membership.userId } })
}
}
return NextResponse.json({ success: true })
} catch (error: any) {
console.error('Error removing member:', error)
const msg = error?.code === 'P2003'
? 'Benutzer kann nicht entfernt werden — es gibt noch abhängige Daten.'
: 'Interner Fehler'
return NextResponse.json({ error: msg }, { status: 500 })
}
}

View File

@@ -0,0 +1,88 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/db'
import { getSession, isServerAdmin } from '@/lib/auth'
export async function GET(req: NextRequest, { params }: { params: { id: string } }) {
try {
const user = await getSession()
if (!user || !isServerAdmin(user.role)) {
return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 403 })
}
const tenant = await (prisma as any).tenant.findUnique({
where: { id: params.id },
include: {
memberships: {
include: { user: { select: { id: true, email: true, name: true, role: true, createdAt: true, lastLoginAt: true } } },
orderBy: { createdAt: 'asc' },
},
_count: { select: { projects: true, memberships: true } },
},
})
if (!tenant) {
return NextResponse.json({ error: 'Mandant nicht gefunden' }, { status: 404 })
}
return NextResponse.json({ tenant })
} catch (error) {
console.error('Error fetching tenant:', error)
return NextResponse.json({ error: 'Interner Fehler' }, { status: 500 })
}
}
export async function PATCH(req: NextRequest, { params }: { params: { id: string } }) {
try {
const user = await getSession()
if (!user || !isServerAdmin(user.role)) {
return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 403 })
}
const body = await req.json()
const {
name, description, isActive, contactEmail, contactPhone, address,
plan, subscriptionStatus, trialEndsAt, subscriptionEndsAt,
maxUsers, maxProjects, notes, logoUrl,
} = body
const tenant = await (prisma as any).tenant.update({
where: { id: params.id },
data: {
...(name !== undefined && { name }),
...(description !== undefined && { description }),
...(isActive !== undefined && { isActive }),
...(contactEmail !== undefined && { contactEmail }),
...(contactPhone !== undefined && { contactPhone }),
...(address !== undefined && { address }),
...(logoUrl !== undefined && { logoUrl }),
...(plan !== undefined && { plan }),
...(subscriptionStatus !== undefined && { subscriptionStatus }),
...(trialEndsAt !== undefined && { trialEndsAt: trialEndsAt ? new Date(trialEndsAt) : null }),
...(subscriptionEndsAt !== undefined && { subscriptionEndsAt: subscriptionEndsAt ? new Date(subscriptionEndsAt) : null }),
...(maxUsers !== undefined && { maxUsers }),
...(maxProjects !== undefined && { maxProjects }),
...(notes !== undefined && { notes }),
},
})
return NextResponse.json({ tenant })
} catch (error) {
console.error('Error updating tenant:', error)
return NextResponse.json({ error: 'Interner Fehler' }, { status: 500 })
}
}
export async function DELETE(req: NextRequest, { params }: { params: { id: string } }) {
try {
const user = await getSession()
if (!user || !isServerAdmin(user.role)) {
return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 403 })
}
await (prisma as any).tenant.delete({ where: { id: params.id } })
return NextResponse.json({ success: true })
} catch (error) {
console.error('Error deleting tenant:', error)
return NextResponse.json({ error: 'Interner Fehler' }, { status: 500 })
}
}

View File

@@ -0,0 +1,65 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/db'
import { getSession, isServerAdmin } from '@/lib/auth'
import { z } from 'zod'
const createTenantSchema = z.object({
name: z.string().min(1).max(200),
slug: z.string().min(1).max(100).regex(/^[a-z0-9-]+$/),
description: z.string().optional(),
})
export async function GET() {
try {
const user = await getSession()
if (!user || !isServerAdmin(user.role)) {
return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 403 })
}
const tenants = await (prisma as any).tenant.findMany({
orderBy: { createdAt: 'desc' },
include: {
_count: {
select: { memberships: true, projects: true },
},
},
})
return NextResponse.json({ tenants })
} catch (error) {
console.error('Error fetching tenants:', error)
return NextResponse.json({ error: 'Interner Fehler' }, { status: 500 })
}
}
export async function POST(req: NextRequest) {
try {
const user = await getSession()
if (!user || !isServerAdmin(user.role)) {
return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 403 })
}
const body = await req.json()
const data = createTenantSchema.parse(body)
const existing = await (prisma as any).tenant.findUnique({ where: { slug: data.slug } })
if (existing) {
return NextResponse.json({ error: 'Slug bereits vergeben' }, { status: 400 })
}
const tenant = await (prisma as any).tenant.create({
data: {
name: data.name,
slug: data.slug,
description: data.description,
},
})
return NextResponse.json({ tenant }, { status: 201 })
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json({ error: 'Ungültige Daten' }, { status: 400 })
}
console.error('Error creating tenant:', error)
return NextResponse.json({ error: 'Interner Fehler' }, { status: 500 })
}
}

View File

@@ -0,0 +1,205 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/db'
import { getSession, isServerAdmin } from '@/lib/auth'
import { sendEmail, getSmtpConfig } from '@/lib/email'
// POST: Check all tenants in TRIAL status and send reminder emails
// Can be called manually by SERVER_ADMIN or via cron
export async function POST(req: NextRequest) {
try {
// Auth: only SERVER_ADMIN or internal cron (via secret header)
const cronSecret = req.headers.get('x-cron-secret')
const expectedSecret = process.env.CRON_SECRET
const isCron = expectedSecret && cronSecret === expectedSecret
if (!isCron) {
const user = await getSession()
if (!user || !isServerAdmin(user.role)) {
return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 403 })
}
}
const smtpConfig = await getSmtpConfig()
if (!smtpConfig) {
return NextResponse.json({ error: 'SMTP nicht konfiguriert', sent: 0 }, { status: 200 })
}
const now = new Date()
// Find all tenants in TRIAL status with trialEndsAt set
const trialTenants = await (prisma as any).tenant.findMany({
where: {
subscriptionStatus: 'TRIAL',
trialEndsAt: { not: null },
isActive: true,
},
include: {
memberships: {
where: { role: 'TENANT_ADMIN' },
include: { user: { select: { email: true, name: true } } },
},
},
})
const results: { tenant: string; daysLeft: number; emailsSent: number; action: string }[] = []
for (const tenant of trialTenants) {
const trialEnd = new Date(tenant.trialEndsAt)
const diffMs = trialEnd.getTime() - now.getTime()
const daysLeft = Math.ceil(diffMs / (1000 * 60 * 60 * 24))
// Determine which reminder to send (only specific days to avoid spam)
// Send at: 14 days, 7 days, 3 days, 1 day before, and on expiry day
const reminderDays = [14, 7, 3, 1, 0]
if (!reminderDays.includes(daysLeft)) {
results.push({ tenant: tenant.name, daysLeft, emailsSent: 0, action: 'skipped' })
continue
}
// Check if we already sent a reminder for this day (using SystemSetting as log)
const reminderKey = `trial_reminder_${tenant.id}_${daysLeft}`
const existing = await (prisma as any).systemSetting.findUnique({
where: { key: reminderKey },
})
if (existing) {
results.push({ tenant: tenant.name, daysLeft, emailsSent: 0, action: 'already_sent' })
continue
}
// Get admin emails for this tenant
const adminEmails = tenant.memberships
.filter((m: any) => m.user?.email)
.map((m: any) => ({ email: m.user.email, name: m.user.name }))
if (adminEmails.length === 0) {
results.push({ tenant: tenant.name, daysLeft, emailsSent: 0, action: 'no_admins' })
continue
}
// Build email content based on days left
let subject: string
let heading: string
let bodyText: string
let urgencyColor: string
if (daysLeft <= 0) {
subject = `Ihre Testphase bei Lageplan ist abgelaufen`
heading = 'Testphase abgelaufen'
bodyText = `Ihre 45-tägige Testphase für <strong>${tenant.name}</strong> ist heute abgelaufen. Ihr Konto wurde automatisch auf den <strong>Free-Plan</strong> umgestellt. Einige Funktionen sind nun eingeschränkt.`
urgencyColor = '#dc2626'
} else if (daysLeft === 1) {
subject = `Lageplan: Ihre Testphase endet morgen`
heading = 'Noch 1 Tag'
bodyText = `Ihre Testphase für <strong>${tenant.name}</strong> endet <strong>morgen</strong>. Danach wechselt Ihr Konto automatisch zum Free-Plan.`
urgencyColor = '#ea580c'
} else if (daysLeft <= 3) {
subject = `Lageplan: Noch ${daysLeft} Tage in Ihrer Testphase`
heading = `Noch ${daysLeft} Tage`
bodyText = `Ihre Testphase für <strong>${tenant.name}</strong> endet in <strong>${daysLeft} Tagen</strong>. Upgraden Sie rechtzeitig, um alle Funktionen zu behalten.`
urgencyColor = '#ea580c'
} else if (daysLeft <= 7) {
subject = `Lageplan: Noch ${daysLeft} Tage in Ihrer Testphase`
heading = `Noch ${daysLeft} Tage`
bodyText = `Ihre Testphase für <strong>${tenant.name}</strong> endet in ${daysLeft} Tagen. Nutzen Sie die verbleibende Zeit, um alle Funktionen auszuprobieren.`
urgencyColor = '#ca8a04'
} else {
subject = `Lageplan: Noch ${daysLeft} Tage in Ihrer Testphase`
heading = `Noch ${daysLeft} Tage`
bodyText = `Ihre Testphase für <strong>${tenant.name}</strong> läuft noch ${daysLeft} Tage. Viel Spass beim Testen!`
urgencyColor = '#2563eb'
}
const trialEndStr = trialEnd.toLocaleDateString('de-CH', {
day: '2-digit', month: '2-digit', year: 'numeric',
})
const html = `
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; max-width: 520px; margin: 0 auto;">
<div style="background: ${urgencyColor}; color: white; padding: 16px 24px; border-radius: 8px 8px 0 0;">
<h2 style="margin: 0; font-size: 18px;">${heading}</h2>
</div>
<div style="border: 1px solid #e5e7eb; border-top: none; padding: 24px; border-radius: 0 0 8px 8px;">
<p style="margin: 0 0 16px; line-height: 1.6; color: #374151;">${bodyText}</p>
<table style="width: 100%; border-collapse: collapse; margin: 16px 0;">
<tr>
<td style="padding: 8px 0; color: #6b7280; font-size: 14px;">Organisation</td>
<td style="padding: 8px 0; font-weight: 600; text-align: right;">${tenant.name}</td>
</tr>
<tr>
<td style="padding: 8px 0; color: #6b7280; font-size: 14px;">Ablaufdatum</td>
<td style="padding: 8px 0; font-weight: 600; text-align: right;">${trialEndStr}</td>
</tr>
<tr>
<td style="padding: 8px 0; color: #6b7280; font-size: 14px;">Aktueller Plan</td>
<td style="padding: 8px 0; font-weight: 600; text-align: right;">Testversion (Trial)</td>
</tr>
</table>
${daysLeft > 0 ? `
<p style="margin: 16px 0 0; font-size: 13px; color: #6b7280;">
Sie müssen nichts tun — nach Ablauf der Testphase wechselt Ihr Konto automatisch zum kostenlosen Free-Plan.
Für erweiterte Funktionen können Sie jederzeit upgraden.
</p>
` : `
<p style="margin: 16px 0 0; font-size: 13px; color: #6b7280;">
Ihre Daten bleiben erhalten. Sie können jederzeit auf einen kostenpflichtigen Plan upgraden, um alle Funktionen wieder freizuschalten.
</p>
`}
<hr style="border: none; border-top: 1px solid #e5e7eb; margin: 20px 0;" />
<p style="margin: 0; font-size: 12px; color: #9ca3af;">
Lageplan — Digitale Lagepläne für die Feuerwehr<br/>
Diese E-Mail wurde automatisch gesendet.
</p>
</div>
</div>
`
let emailsSent = 0
for (const admin of adminEmails) {
try {
await sendEmail(admin.email, subject, html)
emailsSent++
} catch (e) {
console.error(`Failed to send trial reminder to ${admin.email}:`, e)
}
}
// Log that we sent this reminder (prevent duplicates)
await (prisma as any).systemSetting.create({
data: {
key: reminderKey,
value: JSON.stringify({
sentAt: now.toISOString(),
daysLeft,
emailsSent,
recipients: adminEmails.map((a: any) => a.email),
}),
isSecret: false,
category: 'trial_reminders',
},
})
results.push({ tenant: tenant.name, daysLeft, emailsSent, action: 'sent' })
// If trial has expired, downgrade to FREE
if (daysLeft <= 0) {
await (prisma as any).tenant.update({
where: { id: tenant.id },
data: {
subscriptionStatus: 'EXPIRED',
plan: 'FREE',
},
})
results.push({ tenant: tenant.name, daysLeft, emailsSent: 0, action: 'downgraded_to_free' })
}
}
return NextResponse.json({
success: true,
processed: trialTenants.length,
results,
})
} catch (error) {
console.error('Trial reminder error:', error)
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })
}
}

View File

@@ -0,0 +1,89 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/db'
import { getSession, isAdmin } from '@/lib/auth'
import { randomBytes } from 'crypto'
import { sendEmail, getSmtpConfig } from '@/lib/email'
export async function POST(
req: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const session = await getSession()
if (!session || !isAdmin(session.role)) {
return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 403 })
}
const target = await (prisma as any).user.findUnique({
where: { id: params.id },
include: { memberships: true },
})
if (!target) {
return NextResponse.json({ error: 'Benutzer nicht gefunden' }, { status: 404 })
}
// TENANT_ADMIN: only reset users in same tenant
if (session.role !== 'SERVER_ADMIN') {
const inSameTenant = target.memberships?.some((m: any) => m.tenantId === session.tenantId)
if (!inSameTenant) {
return NextResponse.json({ error: 'Keine Berechtigung' }, { status: 403 })
}
}
// Generate reset token
const resetToken = randomBytes(32).toString('hex')
const resetTokenExpiry = new Date(Date.now() + 24 * 60 * 60 * 1000) // 24 hours for admin-initiated
await (prisma as any).user.update({
where: { id: params.id },
data: { resetToken, resetTokenExpiry },
})
const host = req.headers.get('host') || 'localhost:3000'
const protocol = host.includes('localhost') ? 'http' : 'https'
const resetUrl = `${protocol}://${host}/reset-password?token=${resetToken}`
// Try to send email
const smtpConfig = await getSmtpConfig()
let emailSent = false
if (smtpConfig) {
try {
await sendEmail(
target.email,
'Passwort zurücksetzen Lageplan',
`
<div style="font-family: sans-serif; max-width: 500px; margin: 0 auto;">
<h2 style="color: #dc2626;">Passwort zurücksetzen</h2>
<p>Hallo ${target.name},</p>
<p>Ein Administrator hat eine Passwort-Zurücksetzung für Ihr Konto angefordert.</p>
<p style="margin: 24px 0;">
<a href="${resetUrl}" style="background: #dc2626; color: white; padding: 12px 24px; border-radius: 6px; text-decoration: none; font-weight: bold;">
Neues Passwort setzen
</a>
</p>
<p style="color: #666; font-size: 14px;">Dieser Link ist 24 Stunden gültig.</p>
<hr style="border: none; border-top: 1px solid #eee; margin: 24px 0;" />
<p style="color: #999; font-size: 12px;">Lageplan Feuerwehr Krokier-App</p>
</div>
`
)
emailSent = true
} catch (emailErr) {
console.error('Failed to send reset email:', emailErr)
}
}
return NextResponse.json({
success: true,
emailSent,
resetUrl: emailSent ? undefined : resetUrl,
message: emailSent
? `Reset-Link wurde an ${target.email} gesendet.`
: 'SMTP nicht konfiguriert. Reset-Link wurde generiert.',
})
} catch (error) {
console.error('Admin reset password error:', error)
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })
}
}

View File

@@ -0,0 +1,135 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/db'
import { getSession, hashPassword, isAdmin } from '@/lib/auth'
import { z } from 'zod'
const updateUserSchema = z.object({
email: z.string().email().optional(),
name: z.string().min(1).max(100).optional(),
password: z.string().min(6).optional(),
role: z.enum(['SERVER_ADMIN', 'TENANT_ADMIN', 'OPERATOR', 'VIEWER']).optional(),
emailVerified: z.boolean().optional(),
})
export async function PATCH(
req: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const session = await getSession()
if (!session || !isAdmin(session.role)) {
return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 403 })
}
// TENANT_ADMIN: can only edit users in their own tenant
if (session.role !== 'SERVER_ADMIN') {
const target = await (prisma as any).user.findUnique({
where: { id: params.id },
include: { memberships: true },
})
if (!target) return NextResponse.json({ error: 'Benutzer nicht gefunden' }, { status: 404 })
const inSameTenant = target.memberships?.some((m: any) => m.tenantId === session.tenantId)
if (!inSameTenant) return NextResponse.json({ error: 'Keine Berechtigung' }, { status: 403 })
}
const body = await req.json()
const data = updateUserSchema.parse(body)
// TENANT_ADMIN cannot promote to SERVER_ADMIN
if (data.role === 'SERVER_ADMIN' && session.role !== 'SERVER_ADMIN') {
return NextResponse.json({ error: 'Keine Berechtigung' }, { status: 403 })
}
const updateData: any = {}
if (data.email) updateData.email = data.email
if (data.name) updateData.name = data.name
if (data.role) updateData.role = data.role
if (data.password) updateData.password = await hashPassword(data.password)
if (data.emailVerified !== undefined) updateData.emailVerified = data.emailVerified
const user = await (prisma as any).user.update({
where: { id: params.id },
data: updateData,
select: {
id: true,
email: true,
name: true,
role: true,
emailVerified: true,
createdAt: true,
updatedAt: true,
},
})
return NextResponse.json({ user })
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json({ error: 'Ungültige Daten' }, { status: 400 })
}
console.error('Error updating user:', error)
return NextResponse.json({ error: 'Interner Fehler' }, { status: 500 })
}
}
export async function DELETE(
req: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const session = await getSession()
if (!session || !isAdmin(session.role)) {
return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 403 })
}
const target = await (prisma as any).user.findUnique({
where: { id: params.id },
include: { memberships: true },
})
if (!target) {
return NextResponse.json({ error: 'Benutzer nicht gefunden' }, { status: 404 })
}
// TENANT_ADMIN: can only delete users in their own tenant
if (session.role !== 'SERVER_ADMIN') {
const inSameTenant = target.memberships?.some((m: any) => m.tenantId === session.tenantId)
if (!inSameTenant) return NextResponse.json({ error: 'Keine Berechtigung' }, { status: 403 })
}
// Prevent deleting yourself
if (session.id === params.id) {
return NextResponse.json({ error: 'Eigenen Account kann nicht gelöscht werden' }, { status: 400 })
}
// Reassign projects to tenant admin before deleting
for (const membership of (target.memberships || [])) {
// Find a TENANT_ADMIN in the same tenant (not the user being deleted)
const adminMembership = await (prisma as any).tenantMembership.findFirst({
where: {
tenantId: membership.tenantId,
userId: { not: params.id },
role: 'TENANT_ADMIN',
},
})
const newOwnerId = adminMembership?.userId || session.id
// Reassign all projects owned by this user in this tenant
await (prisma as any).project.updateMany({
where: { ownerId: params.id, tenantId: membership.tenantId },
data: { ownerId: newOwnerId },
})
}
// Also reassign any projects without a tenant
await (prisma as any).project.updateMany({
where: { ownerId: params.id },
data: { ownerId: session.id },
})
await (prisma as any).user.delete({ where: { id: params.id } })
return NextResponse.json({ success: true })
} catch (error: any) {
console.error('Error deleting user:', error)
const msg = error?.code === 'P2003'
? 'Benutzer kann nicht gelöscht werden — es gibt noch abhängige Daten.'
: 'Interner Fehler beim Löschen des Benutzers'
return NextResponse.json({ error: msg }, { status: 500 })
}
}

View File

@@ -0,0 +1,101 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/db'
import { getSession, hashPassword, isAdmin } from '@/lib/auth'
import { z } from 'zod'
const createUserSchema = z.object({
email: z.string().email(),
name: z.string().min(1).max(100),
password: z.string().min(6),
role: z.enum(['SERVER_ADMIN', 'TENANT_ADMIN', 'OPERATOR', 'VIEWER']),
tenantId: z.string().optional(),
})
export async function GET() {
try {
const user = await getSession()
if (!user || !isAdmin(user.role)) {
return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 403 })
}
const where = user.role === 'SERVER_ADMIN' ? {} : {
memberships: { some: { tenantId: user.tenantId } },
}
const users = await (prisma as any).user.findMany({
where,
orderBy: { createdAt: 'desc' },
select: {
id: true,
email: true,
name: true,
role: true,
emailVerified: true,
createdAt: true,
updatedAt: true,
memberships: {
include: { tenant: { select: { id: true, name: true, slug: true } } },
},
_count: { select: { projects: true } },
},
})
return NextResponse.json({ users })
} catch (error) {
console.error('Error fetching users:', error)
return NextResponse.json({ error: 'Interner Fehler' }, { status: 500 })
}
}
export async function POST(req: NextRequest) {
try {
const session = await getSession()
if (!session || !isAdmin(session.role)) {
return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 403 })
}
const body = await req.json()
const data = createUserSchema.parse(body)
// Only SERVER_ADMIN can create SERVER_ADMIN users
if (data.role === 'SERVER_ADMIN' && session.role !== 'SERVER_ADMIN') {
return NextResponse.json({ error: 'Keine Berechtigung' }, { status: 403 })
}
const existing = await (prisma as any).user.findUnique({ where: { email: data.email } })
if (existing) {
return NextResponse.json({ error: 'E-Mail bereits vergeben' }, { status: 400 })
}
const hashedPassword = await hashPassword(data.password)
const tenantId = data.tenantId || session.tenantId
const user = await (prisma as any).user.create({
data: {
email: data.email,
name: data.name,
password: hashedPassword,
role: data.role as any,
...(tenantId && data.role !== 'SERVER_ADMIN' ? {
memberships: {
create: { tenantId, role: data.role as any },
},
} : {}),
},
select: {
id: true,
email: true,
name: true,
role: true,
createdAt: true,
},
})
return NextResponse.json({ user }, { status: 201 })
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json({ error: 'Ungültige Daten' }, { status: 400 })
}
console.error('Error creating user:', error)
return NextResponse.json({ error: 'Interner Fehler' }, { status: 500 })
}
}

View File

@@ -0,0 +1,46 @@
import { NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { prisma } from '@/lib/db'
import bcrypt from 'bcryptjs'
export async function POST(req: NextRequest) {
try {
const user = await getSession()
if (!user) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
const { currentPassword, newPassword } = await req.json()
if (!currentPassword || !newPassword) {
return NextResponse.json({ error: 'Beide Felder sind erforderlich' }, { status: 400 })
}
if (newPassword.length < 6) {
return NextResponse.json({ error: 'Neues Kennwort muss mindestens 6 Zeichen lang sein' }, { status: 400 })
}
const dbUser = await (prisma as any).user.findUnique({
where: { id: user.id },
select: { password: true },
})
if (!dbUser) {
return NextResponse.json({ error: 'Benutzer nicht gefunden' }, { status: 404 })
}
const isValid = await bcrypt.compare(currentPassword, dbUser.password)
if (!isValid) {
return NextResponse.json({ error: 'Aktuelles Kennwort ist falsch' }, { status: 400 })
}
const hashedPassword = await bcrypt.hash(newPassword, 12)
await (prisma as any).user.update({
where: { id: user.id },
data: { password: hashedPassword },
})
return NextResponse.json({ success: true })
} catch (error) {
console.error('Change password error:', error)
return NextResponse.json({ error: 'Interner Fehler' }, { status: 500 })
}
}

View File

@@ -0,0 +1,73 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/db'
import { randomBytes } from 'crypto'
import { sendEmail, getSmtpConfig } from '@/lib/email'
export async function POST(req: NextRequest) {
try {
const { email } = await req.json()
if (!email) {
return NextResponse.json({ error: 'E-Mail erforderlich' }, { status: 400 })
}
const user = await (prisma as any).user.findUnique({ where: { email } })
// Always return success to prevent email enumeration
if (!user) {
return NextResponse.json({ success: true, message: 'Falls ein Konto mit dieser E-Mail existiert, wurde ein Link gesendet.' })
}
// Generate reset token (32 bytes hex = 64 chars)
const resetToken = randomBytes(32).toString('hex')
const resetTokenExpiry = new Date(Date.now() + 60 * 60 * 1000) // 1 hour
await (prisma as any).user.update({
where: { id: user.id },
data: { resetToken, resetTokenExpiry },
})
// Try to send email
const smtpConfig = await getSmtpConfig()
const host = req.headers.get('host') || 'localhost:3000'
const protocol = host.includes('localhost') ? 'http' : 'https'
const resetUrl = `${protocol}://${host}/reset-password?token=${resetToken}`
if (smtpConfig) {
try {
await sendEmail(
user.email,
'Passwort zurücksetzen Lageplan',
`
<div style="font-family: sans-serif; max-width: 500px; margin: 0 auto;">
<h2 style="color: #dc2626;">Passwort zurücksetzen</h2>
<p>Hallo ${user.name},</p>
<p>Sie haben eine Passwort-Zurücksetzung angefordert. Klicken Sie auf den folgenden Link:</p>
<p style="margin: 24px 0;">
<a href="${resetUrl}" style="background: #dc2626; color: white; padding: 12px 24px; border-radius: 6px; text-decoration: none; font-weight: bold;">
Passwort zurücksetzen
</a>
</p>
<p style="color: #666; font-size: 14px;">Dieser Link ist 1 Stunde gültig.</p>
<p style="color: #666; font-size: 14px;">Falls Sie diese Anfrage nicht gestellt haben, ignorieren Sie diese E-Mail.</p>
<hr style="border: none; border-top: 1px solid #eee; margin: 24px 0;" />
<p style="color: #999; font-size: 12px;">Lageplan Feuerwehr Krokier-App</p>
</div>
`
)
return NextResponse.json({ success: true, message: 'Falls ein Konto mit dieser E-Mail existiert, wurde ein Link gesendet.' })
} catch (emailErr) {
console.error('Failed to send reset email:', emailErr)
// Fall through to show token directly
}
}
// No SMTP configured or email failed → log token server-side only, never expose to client
console.log(`[Password Reset] No SMTP configured. Reset URL: ${resetUrl}`)
return NextResponse.json({
success: true,
message: 'Falls ein Konto mit dieser E-Mail existiert, wurde ein Link gesendet. (SMTP nicht konfiguriert — siehe Server-Logs)',
})
} catch (error) {
console.error('Forgot password error:', error)
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })
}
}

View File

@@ -0,0 +1,55 @@
import { NextRequest, NextResponse } from 'next/server'
import { cookies } from 'next/headers'
import { login, createToken } from '@/lib/auth'
import { loginSchema } from '@/lib/validations'
import { prisma } from '@/lib/db'
export async function POST(request: NextRequest) {
try {
const body = await request.json()
const validated = loginSchema.safeParse(body)
if (!validated.success) {
return NextResponse.json(
{ error: 'Ungültige Eingabedaten' },
{ status: 400 }
)
}
const { email, password } = validated.data
const result = await login(email, password)
if (!result.success || !result.user) {
return NextResponse.json(
{ error: result.error || 'Login fehlgeschlagen' },
{ status: 401 }
)
}
// Update lastLoginAt
try {
await (prisma as any).user.update({
where: { id: result.user.id },
data: { lastLoginAt: new Date() },
})
} catch {}
const token = await createToken(result.user)
;(await cookies()).set('auth-token', token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60 * 60 * 24, // 24 hours
path: '/',
})
return NextResponse.json({ user: result.user })
} catch (error) {
console.error('Login error:', error)
return NextResponse.json(
{ error: 'Interner Serverfehler' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,7 @@
import { NextResponse } from 'next/server'
import { cookies } from 'next/headers'
export async function POST() {
;(await cookies()).delete('auth-token')
return NextResponse.json({ success: true })
}

View File

@@ -0,0 +1,33 @@
import { NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { prisma } from '@/lib/db'
export async function GET() {
const user = await getSession()
if (!user) {
return NextResponse.json({ user: null, tenant: null })
}
// Enrich with tenant subscription info for non-server-admins
let tenant: any = null
if (user.tenantId) {
tenant = await (prisma as any).tenant.findUnique({
where: { id: user.tenantId },
select: {
id: true,
name: true,
slug: true,
plan: true,
subscriptionStatus: true,
trialEndsAt: true,
subscriptionEndsAt: true,
maxUsers: true,
maxProjects: true,
logoUrl: true,
},
})
}
return NextResponse.json({ user, tenant })
}

View File

@@ -0,0 +1,175 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/db'
import { hashPassword } from '@/lib/auth'
import { sendEmail } from '@/lib/email'
import { randomBytes } from 'crypto'
import { z } from 'zod'
const registerSchema = z.object({
organizationName: z.string().min(2, 'Organisationsname zu kurz').max(200),
name: z.string().min(2, 'Name zu kurz').max(200),
email: z.string().email('Ungültige E-Mail-Adresse'),
password: z.string().min(6, 'Passwort muss mindestens 6 Zeichen haben'),
})
export async function POST(req: NextRequest) {
try {
const body = await req.json()
const data = registerSchema.parse(body)
// Check if email already exists
const existingUser = await (prisma as any).user.findUnique({
where: { email: data.email },
include: { memberships: true },
})
if (existingUser) {
// If the user is an orphan (no memberships) or never verified their email,
// clean them up so they can re-register
const isOrphan = !existingUser.memberships || existingUser.memberships.length === 0
const isUnverified = existingUser.emailVerified === false
if (isOrphan || isUnverified) {
// Force-delete orphan/unverified user and all their remaining data
try {
await (prisma as any).upgradeRequest.deleteMany({ where: { requestedById: existingUser.id } })
await (prisma as any).iconAsset.updateMany({ where: { ownerId: existingUser.id }, data: { ownerId: null } })
await (prisma as any).project.updateMany({ where: { ownerId: existingUser.id }, data: { ownerId: null } })
await (prisma as any).tenantMembership.deleteMany({ where: { userId: existingUser.id } })
await (prisma as any).user.delete({ where: { id: existingUser.id } })
console.log(`[Register] Cleaned up orphan/unverified user: ${data.email}`)
} catch (cleanupErr) {
console.error('[Register] Failed to cleanup existing user:', cleanupErr)
return NextResponse.json({ error: 'Diese E-Mail-Adresse ist bereits registriert. Bitte kontaktieren Sie den Administrator.' }, { status: 400 })
}
} else {
return NextResponse.json({ error: 'Diese E-Mail-Adresse ist bereits registriert.' }, { status: 400 })
}
}
// Generate slug from organization name
let slug = data.organizationName
.toLowerCase()
.replace(/[äÄ]/g, 'ae').replace(/[öÖ]/g, 'oe').replace(/[üÜ]/g, 'ue')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '')
// Ensure slug is unique
const existingTenant = await (prisma as any).tenant.findUnique({ where: { slug } })
if (existingTenant) {
slug = `${slug}-${Date.now().toString(36)}`
}
// Hash password
const hashedPassword = await hashPassword(data.password)
// Generate email verification token
const verificationToken = randomBytes(32).toString('hex')
// Create tenant (no trial, directly ACTIVE) with privacy consent
const tenant = await (prisma as any).tenant.create({
data: {
name: data.organizationName,
slug,
plan: 'FREE',
subscriptionStatus: 'ACTIVE',
maxUsers: 5,
maxProjects: 10,
contactEmail: data.email,
privacyAccepted: body.privacyAccepted === true,
privacyAcceptedAt: body.privacyAccepted ? new Date() : null,
adminAccessAccepted: body.adminAccessAccepted === true,
},
})
// Create user as TENANT_ADMIN with email not yet verified
const user = await (prisma as any).user.create({
data: {
email: data.email,
password: hashedPassword,
name: data.name,
role: 'TENANT_ADMIN',
emailVerified: false,
emailVerificationToken: verificationToken,
},
})
// Create tenant membership
await (prisma as any).tenantMembership.create({
data: {
userId: user.id,
tenantId: tenant.id,
role: 'TENANT_ADMIN',
},
})
// Send verification email
let baseUrl = process.env.NEXTAUTH_URL || req.headers.get('origin') || `${req.headers.get('x-forwarded-proto') || 'https'}://${req.headers.get('host')}` || 'http://localhost:3000'
if (baseUrl && !baseUrl.startsWith('http://') && !baseUrl.startsWith('https://')) {
baseUrl = `https://${baseUrl}`
}
const verifyUrl = `${baseUrl}/api/auth/verify-email?token=${verificationToken}`
try {
await sendEmail(
data.email,
'E-Mail-Adresse bestätigen — Lageplan',
`<div style="font-family:sans-serif;max-width:600px;margin:0 auto;">
<div style="background:#dc2626;color:white;padding:20px 24px;border-radius:12px 12px 0 0;">
<h1 style="margin:0;font-size:22px;">E-Mail bestätigen</h1>
</div>
<div style="border:1px solid #e5e7eb;border-top:none;padding:24px;border-radius:0 0 12px 12px;">
<p>Hallo <strong>${data.name}</strong>,</p>
<p>Bitte bestätigen Sie Ihre E-Mail-Adresse, um Ihr Konto für <strong>${data.organizationName}</strong> zu aktivieren.</p>
<div style="text-align:center;margin:24px 0;">
<a href="${verifyUrl}" style="background:#dc2626;color:white;padding:12px 32px;text-decoration:none;border-radius:8px;font-weight:600;display:inline-block;">
E-Mail bestätigen
</a>
</div>
<p style="color:#666;font-size:13px;">Falls der Button nicht funktioniert, kopieren Sie diesen Link:<br/>
<a href="${verifyUrl}" style="word-break:break-all;">${verifyUrl}</a></p>
</div>
</div>`
)
} catch (e) {
console.warn('Failed to send verification email:', e)
}
// Notify server admin about new registration (#13)
try {
const adminSetting = await (prisma as any).systemSetting.findUnique({ where: { key: 'notify_registration_email' } })
const adminEmail = adminSetting?.value
if (adminEmail) {
await sendEmail(
adminEmail,
`Neue Registrierung — ${data.organizationName}`,
`<div style="font-family:sans-serif;max-width:600px;margin:0 auto;">
<div style="background:#1e293b;color:white;padding:20px 24px;border-radius:12px 12px 0 0;">
<h1 style="margin:0;font-size:22px;">Neue Registrierung</h1>
</div>
<div style="border:1px solid #e5e7eb;border-top:none;padding:24px;border-radius:0 0 12px 12px;">
<p><strong>Organisation:</strong> ${data.organizationName}</p>
<p><strong>Name:</strong> ${data.name}</p>
<p><strong>E-Mail:</strong> ${data.email}</p>
<p><strong>Mandant-Slug:</strong> ${slug}</p>
<p><strong>Datum:</strong> ${new Date().toLocaleString('de-CH')}</p>
</div>
</div>`
)
}
} catch (e) {
console.warn('Failed to send registration notification:', e)
}
return NextResponse.json({
success: true,
message: 'Registrierung erfolgreich! Bitte bestätigen Sie Ihre E-Mail-Adresse.',
tenantSlug: tenant.slug,
requiresVerification: true,
}, { status: 201 })
} catch (error) {
if (error instanceof z.ZodError) {
const firstError = error.errors[0]
return NextResponse.json({ error: firstError.message }, { status: 400 })
}
console.error('Registration error:', error)
return NextResponse.json({ error: 'Registrierung fehlgeschlagen. Bitte versuchen Sie es später.' }, { status: 500 })
}
}

View File

@@ -0,0 +1,43 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/db'
import { hashPassword } from '@/lib/auth'
export async function POST(req: NextRequest) {
try {
const { token, password } = await req.json()
if (!token || !password) {
return NextResponse.json({ error: 'Token und Passwort erforderlich' }, { status: 400 })
}
if (password.length < 6) {
return NextResponse.json({ error: 'Passwort muss mindestens 6 Zeichen lang sein' }, { status: 400 })
}
const user = await (prisma as any).user.findFirst({
where: {
resetToken: token,
resetTokenExpiry: { gt: new Date() },
},
})
if (!user) {
return NextResponse.json({ error: 'Ungültiger oder abgelaufener Link. Bitte fordern Sie einen neuen Link an.' }, { status: 400 })
}
const hashedPassword = await hashPassword(password)
await (prisma as any).user.update({
where: { id: user.id },
data: {
password: hashedPassword,
resetToken: null,
resetTokenExpiry: null,
},
})
return NextResponse.json({ success: true, message: 'Passwort wurde erfolgreich geändert.' })
} catch (error) {
console.error('Reset password error:', error)
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })
}
}

View File

@@ -0,0 +1,49 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/db'
function getBaseUrl(req: NextRequest): string {
// Use NEXTAUTH_URL if set, ensure it has a protocol
if (process.env.NEXTAUTH_URL) {
const url = process.env.NEXTAUTH_URL.trim()
if (url.startsWith('http://') || url.startsWith('https://')) return url
return `https://${url}`
}
const proto = req.headers.get('x-forwarded-proto') || 'https'
const host = req.headers.get('host') || 'localhost:3000'
return `${proto}://${host}`
}
export async function GET(req: NextRequest) {
const base = getBaseUrl(req)
try {
const token = req.nextUrl.searchParams.get('token')
if (!token) {
return NextResponse.redirect(`${base}/login?error=invalid-token`)
}
// Find user by verification token
const user = await (prisma as any).user.findFirst({
where: { emailVerificationToken: token },
})
if (!user) {
return NextResponse.redirect(`${base}/login?error=invalid-token`)
}
// Mark email as verified
await (prisma as any).user.update({
where: { id: user.id },
data: {
emailVerified: true,
emailVerificationToken: null,
},
})
// Redirect to login with success message
return NextResponse.redirect(`${base}/login?verified=true`)
} catch (error) {
console.error('Email verification error:', error)
return NextResponse.redirect(`${base}/login?error=verification-failed`)
}
}

View File

@@ -0,0 +1,75 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/db'
import { sendEmail, getSmtpConfig } from '@/lib/email'
import { z } from 'zod'
const contactSchema = z.object({
name: z.string().min(1).max(200),
email: z.string().email(),
message: z.string().min(1).max(5000),
})
// Get contact email from SystemSettings, fallback to app@lageplan.ch
async function getContactEmail(): Promise<string> {
try {
const setting = await (prisma as any).systemSetting.findUnique({
where: { key: 'contact_email' },
})
return setting?.value || 'app@lageplan.ch'
} catch {
return 'app@lageplan.ch'
}
}
export async function POST(req: NextRequest) {
try {
const body = await req.json()
const data = contactSchema.parse(body)
const contactEmail = await getContactEmail()
const smtpConfig = await getSmtpConfig()
if (smtpConfig) {
// Send via SMTP
const html = `
<h2>Neue Kontaktanfrage — Lageplan</h2>
<table style="border-collapse:collapse;width:100%;max-width:500px;">
<tr><td style="padding:8px;font-weight:bold;border-bottom:1px solid #eee;">Name</td><td style="padding:8px;border-bottom:1px solid #eee;">${escapeHtml(data.name)}</td></tr>
<tr><td style="padding:8px;font-weight:bold;border-bottom:1px solid #eee;">E-Mail</td><td style="padding:8px;border-bottom:1px solid #eee;"><a href="mailto:${escapeHtml(data.email)}">${escapeHtml(data.email)}</a></td></tr>
</table>
<h3 style="margin-top:20px;">Nachricht</h3>
<div style="background:#f9f9f9;padding:16px;border-radius:8px;white-space:pre-wrap;">${escapeHtml(data.message)}</div>
<p style="margin-top:20px;font-size:12px;color:#999;">Gesendet über das Kontaktformular auf lageplan.ch</p>
`
await sendEmail(contactEmail, `Kontaktanfrage von ${data.name}`, html)
} else {
// No SMTP configured — store in SystemSettings as fallback log
const logKey = `contact_msg_${Date.now()}`
await (prisma as any).systemSetting.create({
data: {
key: logKey,
value: JSON.stringify({ name: data.name, email: data.email, message: data.message, date: new Date().toISOString() }),
isSecret: false,
category: 'contact_messages',
},
})
}
return NextResponse.json({ success: true })
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json({ error: 'Bitte füllen Sie alle Felder korrekt aus.' }, { status: 400 })
}
console.error('Contact form error:', error)
return NextResponse.json({ error: 'Senden fehlgeschlagen. Bitte versuchen Sie es später.' }, { status: 500 })
}
}
function escapeHtml(str: string): string {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}

72
src/app/api/demo/route.ts Normal file
View File

@@ -0,0 +1,72 @@
export const dynamic = 'force-dynamic'
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/db'
// GET demo project data — no auth required
export async function GET() {
try {
// Find demo project ID from system settings
const setting = await (prisma as any).systemSetting.findUnique({
where: { key: 'demo_project_id' },
})
if (!setting?.value) {
return NextResponse.json({ error: 'Keine Demo konfiguriert' }, { status: 404 })
}
const project = await (prisma as any).project.findUnique({
where: { id: setting.value },
})
if (!project) {
return NextResponse.json({ error: 'Demo-Projekt nicht gefunden' }, { status: 404 })
}
const [features, journalEntries, journalCheckItems, journalPendenzen] = await Promise.all([
(prisma as any).feature.findMany({
where: { projectId: project.id },
orderBy: { createdAt: 'asc' },
}),
(prisma as any).journalEntry.findMany({
where: { projectId: project.id },
orderBy: [{ time: 'asc' }, { sortOrder: 'asc' }, { createdAt: 'asc' }],
}).catch(() => []),
(prisma as any).journalCheckItem.findMany({
where: { projectId: project.id },
orderBy: { sortOrder: 'asc' },
}).catch(() => []),
(prisma as any).journalPendenz.findMany({
where: { projectId: project.id },
orderBy: { sortOrder: 'asc' },
}).catch(() => []),
])
return NextResponse.json({
project: {
id: project.id,
title: project.title,
location: project.location,
description: project.description,
einsatzleiter: project.einsatzleiter || '',
journalfuehrer: project.journalfuehrer || '',
mapCenter: project.mapCenter || { lng: 8.2275, lat: 47.3497 },
mapZoom: project.mapZoom || 15,
},
features: features.map((f: any) => ({
id: f.id,
type: f.type,
geometry: f.geometry,
properties: f.properties || {},
})),
journal: {
entries: journalEntries,
checkItems: journalCheckItems,
pendenzen: journalPendenzen,
},
})
} catch (error) {
console.error('[Demo API] Error:', error)
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })
}
}

View File

@@ -0,0 +1,37 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/db'
import { getSession, isAdmin } from '@/lib/auth'
// DELETE: Remove a dictionary word
export async function DELETE(req: NextRequest, { params }: { params: { id: string } }) {
try {
const user = await getSession()
if (!user || !isAdmin(user.role)) {
return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 403 })
}
const entry = await (prisma as any).dictionaryEntry.findUnique({
where: { id: params.id },
})
if (!entry) {
return NextResponse.json({ error: 'Eintrag nicht gefunden' }, { status: 404 })
}
// Only SERVER_ADMIN can delete global words
if (entry.scope === 'GLOBAL' && user.role !== 'SERVER_ADMIN') {
return NextResponse.json({ error: 'Nur Server-Admin kann globale Wörter löschen' }, { status: 403 })
}
// TENANT_ADMIN can only delete their own tenant's words
if (entry.scope === 'TENANT' && entry.tenantId !== user.tenantId && user.role !== 'SERVER_ADMIN') {
return NextResponse.json({ error: 'Keine Berechtigung' }, { status: 403 })
}
await (prisma as any).dictionaryEntry.delete({ where: { id: params.id } })
return NextResponse.json({ success: true })
} catch (error) {
console.error('Error deleting dictionary word:', error)
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })
}
}

View File

@@ -0,0 +1,80 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/db'
import { getSession, isAdmin } from '@/lib/auth'
// GET: Fetch dictionary words (global + tenant-specific merged)
export async function GET(req: NextRequest) {
try {
const user = await getSession()
if (!user) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
// Global words
const globalWords = await (prisma as any).dictionaryEntry.findMany({
where: { scope: 'GLOBAL' },
orderBy: { word: 'asc' },
})
// Tenant words (if user has a tenant)
let tenantWords: any[] = []
if (user.tenantId) {
tenantWords = await (prisma as any).dictionaryEntry.findMany({
where: { scope: 'TENANT', tenantId: user.tenantId },
orderBy: { word: 'asc' },
})
}
// Merge: tenant words override global (by word)
const tenantWordSet = new Set(tenantWords.map((w: any) => w.word.toLowerCase()))
const merged = [
...tenantWords.map((w: any) => ({ ...w, source: 'tenant' })),
...globalWords
.filter((w: any) => !tenantWordSet.has(w.word.toLowerCase()))
.map((w: any) => ({ ...w, source: 'global' })),
].sort((a, b) => a.word.localeCompare(b.word, 'de'))
return NextResponse.json({ words: merged })
} catch (error) {
console.error('Error fetching dictionary:', error)
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })
}
}
// POST: Add a word to dictionary
export async function POST(req: NextRequest) {
try {
const user = await getSession()
if (!user || !isAdmin(user.role)) {
return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 403 })
}
const body = await req.json()
const { word, scope } = body
if (!word?.trim()) {
return NextResponse.json({ error: 'Wort erforderlich' }, { status: 400 })
}
// Only SERVER_ADMIN can add global words
if (scope === 'GLOBAL' && user.role !== 'SERVER_ADMIN') {
return NextResponse.json({ error: 'Nur Server-Admin kann globale Wörter hinzufügen' }, { status: 403 })
}
const tenantId = scope === 'TENANT' ? user.tenantId : null
const entry = await (prisma as any).dictionaryEntry.create({
data: {
word: word.trim(),
scope: scope || 'GLOBAL',
tenantId,
},
})
return NextResponse.json(entry)
} catch (error: any) {
if (error?.code === 'P2002') {
return NextResponse.json({ error: 'Wort existiert bereits' }, { status: 409 })
}
console.error('Error adding dictionary word:', error)
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })
}
}

View File

@@ -0,0 +1,56 @@
import { NextRequest, NextResponse } from 'next/server'
import { getStripe } from '@/lib/stripe'
export const dynamic = 'force-dynamic'
export async function POST(req: NextRequest) {
try {
const { amount, name, message } = await req.json()
if (!amount || amount < 1 || amount > 1000) {
return NextResponse.json({ error: 'Ungültiger Betrag' }, { status: 400 })
}
const stripe = await getStripe()
if (!stripe) {
return NextResponse.json({ error: 'Stripe ist nicht konfiguriert. Bitte kontaktiere den Administrator.' }, { status: 503 })
}
const amountInCents = Math.round(amount * 100)
const origin = req.headers.get('origin') || req.nextUrl.origin
const session = await stripe.checkout.sessions.create({
payment_method_types: ['card', 'twint'],
mode: 'payment',
line_items: [
{
price_data: {
currency: 'chf',
product_data: {
name: 'Spende für Lageplan',
description: `Freiwillige Spende von CHF ${amount} für die Weiterentwicklung`,
},
unit_amount: amountInCents,
},
quantity: 1,
},
],
metadata: {
donor_name: name || 'Anonym',
donor_message: message || '',
type: 'donation',
},
success_url: `${origin}/spenden/danke?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${origin}/spenden`,
})
return NextResponse.json({ url: session.url })
} catch (error) {
console.error('Stripe checkout error:', error)
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Checkout fehlgeschlagen' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,27 @@
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/db'
export const dynamic = 'force-dynamic'
// Public endpoint — returns only the publishable key
export async function GET() {
try {
const keys = await (prisma as any).systemSetting.findMany({
where: {
key: { in: ['stripe_secret_key', 'stripe_public_key', 'stripe_webhook_secret'] },
},
})
const secretKey = keys.find((k: any) => k.key === 'stripe_secret_key')?.value
const publicKey = keys.find((k: any) => k.key === 'stripe_public_key')?.value
if (!secretKey || !publicKey) {
return NextResponse.json({ configured: false })
}
return NextResponse.json({ configured: true, publicKey })
} catch (error: any) {
console.error('[Stripe Config] Error:', error?.message || error)
return NextResponse.json({ configured: false })
}
}

View File

@@ -0,0 +1,70 @@
import { NextRequest, NextResponse } from 'next/server'
import { getStripe, getStripeConfig } from '@/lib/stripe'
import { prisma } from '@/lib/db'
export const dynamic = 'force-dynamic'
export async function POST(req: NextRequest) {
try {
const stripe = await getStripe()
const config = await getStripeConfig()
if (!stripe || !config) {
return NextResponse.json({ error: 'Stripe not configured' }, { status: 503 })
}
const body = await req.text()
const sig = req.headers.get('stripe-signature')
if (!sig) {
return NextResponse.json({ error: 'Missing signature' }, { status: 400 })
}
let event
try {
if (config.webhookSecret) {
event = stripe.webhooks.constructEvent(body, sig, config.webhookSecret)
} else {
// No webhook secret configured — parse event directly (not recommended for production)
event = JSON.parse(body)
}
} catch (err) {
console.error('Webhook signature verification failed:', err)
return NextResponse.json({ error: 'Invalid signature' }, { status: 400 })
}
if (event.type === 'checkout.session.completed') {
const session = event.data.object
const metadata = session.metadata || {}
if (metadata.type === 'donation') {
// Store donation record
try {
await (prisma as any).systemSetting.create({
data: {
key: `donation_${Date.now()}`,
value: JSON.stringify({
amount: (session.amount_total || 0) / 100,
currency: session.currency,
donor: metadata.donor_name || 'Anonym',
message: metadata.donor_message || '',
paymentId: session.payment_intent,
date: new Date().toISOString(),
}),
isSecret: false,
category: 'donations',
},
})
} catch (e) {
console.error('Failed to store donation record:', e)
}
console.log(`[Donation] CHF ${(session.amount_total || 0) / 100} from ${metadata.donor_name || 'Anonym'}`)
}
}
return NextResponse.json({ received: true })
} catch (error) {
console.error('Webhook error:', error)
return NextResponse.json({ error: 'Webhook processing failed' }, { status: 500 })
}
}

View File

@@ -0,0 +1,74 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/db'
import { getSession } from '@/lib/auth'
// PUT: Update a hose type
export async function PUT(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const user = await getSession()
if (!user) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
if (user.role !== 'SERVER_ADMIN' && user.role !== 'TENANT_ADMIN') {
return NextResponse.json({ error: 'Keine Berechtigung' }, { status: 403 })
}
const body = await request.json()
const { name, diameterMm, lengthPerPieceM, flowRateLpm, frictionCoeff, description, isDefault, isActive, sortOrder } = body
// If setting as default, unset other defaults
if (isDefault) {
await prisma.hoseType.updateMany({
where: { isDefault: true, id: { not: params.id } },
data: { isDefault: false },
})
}
const hoseType = await prisma.hoseType.update({
where: { id: params.id },
data: {
...(name !== undefined && { name }),
...(diameterMm !== undefined && { diameterMm: parseInt(diameterMm) }),
...(lengthPerPieceM !== undefined && { lengthPerPieceM: parseInt(lengthPerPieceM) }),
...(flowRateLpm !== undefined && { flowRateLpm: parseFloat(flowRateLpm) }),
...(frictionCoeff !== undefined && { frictionCoeff: parseFloat(frictionCoeff) }),
...(description !== undefined && { description }),
...(isDefault !== undefined && { isDefault }),
...(isActive !== undefined && { isActive }),
...(sortOrder !== undefined && { sortOrder }),
},
})
return NextResponse.json({ hoseType })
} catch (error: any) {
if (error.code === 'P2025') {
return NextResponse.json({ error: 'Schlauchtyp nicht gefunden' }, { status: 404 })
}
console.error('Error updating hose type:', error)
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })
}
}
// DELETE: Delete a hose type
export async function DELETE(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const user = await getSession()
if (!user) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
if (user.role !== 'SERVER_ADMIN' && user.role !== 'TENANT_ADMIN') {
return NextResponse.json({ error: 'Keine Berechtigung' }, { status: 403 })
}
await prisma.hoseType.delete({ where: { id: params.id } })
return NextResponse.json({ success: true })
} catch (error: any) {
if (error.code === 'P2025') {
return NextResponse.json({ error: 'Schlauchtyp nicht gefunden' }, { status: 404 })
}
console.error('Error deleting hose type:', error)
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })
}
}

View File

@@ -0,0 +1,74 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/db'
import { getSession } from '@/lib/auth'
// GET: List all hose types (global + tenant-specific)
export async function GET() {
try {
const user = await getSession()
if (!user) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
// Show global hose types (tenantId=null) + tenant-specific ones
const where: any = { isActive: true }
if (user.role !== 'SERVER_ADMIN' && user.tenantId) {
where.OR = [{ tenantId: null }, { tenantId: user.tenantId }]
delete where.isActive
where.AND = [{ isActive: true }]
}
const hoseTypes = await (prisma as any).hoseType.findMany({
where: user.role === 'SERVER_ADMIN'
? { isActive: true }
: { isActive: true, OR: [{ tenantId: null }, { tenantId: user.tenantId }] },
orderBy: { sortOrder: 'asc' },
})
return NextResponse.json({ hoseTypes })
} catch (error) {
console.error('Error fetching hose types:', error)
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })
}
}
// POST: Create a new hose type (admin only)
export async function POST(request: NextRequest) {
try {
const user = await getSession()
if (!user) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
if (user.role !== 'SERVER_ADMIN' && user.role !== 'TENANT_ADMIN') {
return NextResponse.json({ error: 'Keine Berechtigung' }, { status: 403 })
}
const body = await request.json()
const { name, diameterMm, lengthPerPieceM, flowRateLpm, frictionCoeff, description, isDefault, sortOrder } = body
if (!name || !diameterMm || !flowRateLpm || !frictionCoeff) {
return NextResponse.json({ error: 'Name, Durchmesser, Durchfluss und Reibungskoeffizient sind erforderlich' }, { status: 400 })
}
if (isDefault) {
await (prisma as any).hoseType.updateMany({ where: { isDefault: true }, data: { isDefault: false } })
}
const hoseType = await (prisma as any).hoseType.create({
data: {
name,
diameterMm: parseInt(diameterMm),
lengthPerPieceM: parseInt(lengthPerPieceM) || 10,
flowRateLpm: parseFloat(flowRateLpm),
frictionCoeff: parseFloat(frictionCoeff),
description: description || null,
isDefault: isDefault || false,
sortOrder: sortOrder || 0,
tenantId: user.role === 'SERVER_ADMIN' ? null : user.tenantId || null,
},
})
return NextResponse.json({ hoseType }, { status: 201 })
} catch (error: any) {
if (error.code === 'P2002') {
return NextResponse.json({ error: 'Ein Schlauchtyp mit diesem Namen existiert bereits' }, { status: 409 })
}
console.error('Error creating hose type:', error)
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })
}
}

View File

@@ -0,0 +1,70 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/db'
import { getFileStream } from '@/lib/minio'
import { readFile } from 'fs/promises'
import { join } from 'path'
export async function GET(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const icon = await prisma.iconAsset.findUnique({
where: { id: params.id },
})
if (!icon) {
return NextResponse.json({ error: 'Icon nicht gefunden' }, { status: 404 })
}
// Serve system icons from public/signaturen/
if (icon.isSystem && icon.fileKey.startsWith('signaturen/')) {
const contentType = icon.mimeType || (icon.fileKey.endsWith('.svg') ? 'image/svg+xml' : 'image/png')
try {
const filePath = join(process.cwd(), 'public', icon.fileKey)
const buffer = await readFile(filePath)
return new NextResponse(buffer, {
headers: {
'Content-Type': contentType,
'Cache-Control': 'public, max-age=31536000',
},
})
} catch {
try {
const altPath = join(process.cwd(), icon.fileKey)
const buffer = await readFile(altPath)
return new NextResponse(buffer, {
headers: {
'Content-Type': contentType,
'Cache-Control': 'public, max-age=31536000',
},
})
} catch {
console.error('System icon file not found:', icon.fileKey)
}
}
}
// Stream file from MinIO through the app (no external MinIO access needed)
try {
const { stream, contentType } = await getFileStream(icon.fileKey)
const chunks: Buffer[] = []
for await (const chunk of stream) {
chunks.push(Buffer.from(chunk))
}
const buffer = Buffer.concat(chunks)
return new NextResponse(buffer, {
headers: {
'Content-Type': contentType,
'Cache-Control': 'public, max-age=86400',
},
})
} catch (streamErr) {
console.error('Error streaming icon from MinIO:', streamErr)
return NextResponse.json({ error: 'Icon-Datei nicht gefunden' }, { status: 404 })
}
} catch (error) {
console.error('Error fetching icon:', error)
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })
}
}

View File

@@ -0,0 +1,54 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/db'
import { getSession, isAdmin } from '@/lib/auth'
// Toggle icon visibility for the current tenant (adds/removes from hiddenIconIds)
export async function POST(
req: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const user = await getSession()
if (!user || !isAdmin(user.role)) {
return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 403 })
}
if (!user.tenantId) {
return NextResponse.json({ error: 'Kein Mandant zugeordnet' }, { status: 400 })
}
const iconId = params.id
// Get current tenant
const tenant = await (prisma as any).tenant.findUnique({
where: { id: user.tenantId },
select: { hiddenIconIds: true },
})
if (!tenant) {
return NextResponse.json({ error: 'Mandant nicht gefunden' }, { status: 404 })
}
const hiddenIds: string[] = tenant.hiddenIconIds || []
const isHidden = hiddenIds.includes(iconId)
// Toggle: if hidden, unhide; if visible, hide
const updatedHiddenIds = isHidden
? hiddenIds.filter((id: string) => id !== iconId)
: [...hiddenIds, iconId]
await (prisma as any).tenant.update({
where: { id: user.tenantId },
data: { hiddenIconIds: updatedHiddenIds },
})
return NextResponse.json({
success: true,
isHidden: !isHidden,
message: isHidden ? 'Symbol eingeblendet' : 'Symbol ausgeblendet',
})
} catch (error) {
console.error('Error toggling icon visibility:', error)
return NextResponse.json({ error: 'Interner Fehler' }, { status: 500 })
}
}

View File

@@ -0,0 +1,66 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/db'
import { getSession } from '@/lib/auth'
export async function GET() {
try {
const user = await getSession()
// Build icon filter: global icons (tenantId=null) + tenant-specific icons
const iconFilter: any = { isActive: true }
if (user?.tenantId) {
iconFilter.OR = [
{ tenantId: null },
{ tenantId: user.tenantId },
]
delete iconFilter.isActive
iconFilter.AND = [{ isActive: true }]
} else {
// Server admin or no tenant: show all global icons
iconFilter.tenantId = null
}
// Filter categories: global (tenantId=null) + tenant-specific
const categoryWhere: any = user?.tenantId
? { OR: [{ tenantId: null }, { tenantId: user.tenantId }] }
: {}
const categories = await (prisma as any).iconCategory.findMany({
where: categoryWhere,
orderBy: { sortOrder: 'asc' },
include: {
icons: {
where: user?.tenantId
? { isActive: true, OR: [{ tenantId: null }, { tenantId: user.tenantId }] }
: { isActive: true },
orderBy: { name: 'asc' },
},
},
})
// Get tenant's hidden icon IDs
let hiddenIconIds: string[] = []
if (user?.tenantId) {
const tenant = await (prisma as any).tenant.findUnique({
where: { id: user.tenantId },
select: { hiddenIconIds: true },
})
hiddenIconIds = tenant?.hiddenIconIds || []
}
const categoriesWithUrls = categories.map((cat: any) => ({
...cat,
icons: cat.icons
.filter((icon: any) => !hiddenIconIds.includes(icon.id))
.map((icon: any) => ({
...icon,
url: `/api/icons/${icon.id}/image`,
})),
}))
return NextResponse.json({ categories: categoriesWithUrls })
} catch (error) {
console.error('Error fetching icons:', error)
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })
}
}

View File

@@ -0,0 +1,79 @@
import { NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { prisma } from '@/lib/db'
import { uploadFile } from '@/lib/minio'
import { v4 as uuidv4 } from 'uuid'
export async function POST(request: NextRequest) {
try {
const user = await getSession()
if (!user) {
return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
}
if (user.role === 'VIEWER') {
return NextResponse.json({ error: 'Keine Berechtigung' }, { status: 403 })
}
const formData = await request.formData()
const file = formData.get('file') as File | null
const name = formData.get('name') as string | null
const categoryId = formData.get('categoryId') as string | null
if (!file || !name || !categoryId) {
return NextResponse.json(
{ error: 'Datei, Name und Kategorie sind erforderlich' },
{ status: 400 }
)
}
const allowedTypes = ['image/png', 'image/svg+xml', 'image/jpeg', 'image/webp']
if (!allowedTypes.includes(file.type)) {
return NextResponse.json(
{ error: 'Nur PNG, SVG, JPEG und WebP Dateien sind erlaubt' },
{ status: 400 }
)
}
const maxSize = 5 * 1024 * 1024 // 5MB
if (file.size > maxSize) {
return NextResponse.json(
{ error: 'Datei ist zu gross (max. 5MB)' },
{ status: 400 }
)
}
const category = await prisma.iconCategory.findUnique({
where: { id: categoryId },
})
if (!category) {
return NextResponse.json(
{ error: 'Kategorie nicht gefunden' },
{ status: 404 }
)
}
const fileExtension = file.name.split('.').pop() || 'png'
const fileKey = `icons/${user.id}/${uuidv4()}.${fileExtension}`
const buffer = Buffer.from(await file.arrayBuffer())
await uploadFile(fileKey, buffer, file.type)
const iconAsset = await prisma.iconAsset.create({
data: {
name,
categoryId,
ownerId: user.id,
fileKey,
mimeType: file.type,
isSystem: false,
},
})
return NextResponse.json({ icon: iconAsset }, { status: 201 })
} catch (error) {
console.error('Error uploading icon:', error)
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })
}
}

View File

@@ -0,0 +1,173 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/db'
import { getSession } from '@/lib/auth'
const HEARTBEAT_TIMEOUT_MS = 2 * 60 * 1000 // 2 minutes
function isEditingExpired(heartbeat: Date | null): boolean {
if (!heartbeat) return true
return Date.now() - new Date(heartbeat).getTime() > HEARTBEAT_TIMEOUT_MS
}
// GET: Check editing status of a project
export async function GET(
req: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const user = await getSession()
if (!user) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
const sessionId = req.nextUrl.searchParams.get('sessionId') || ''
const project = await (prisma as any).project.findUnique({
where: { id: params.id },
select: {
id: true,
editingById: true,
editingUserName: true,
editingSessionId: true,
editingStartedAt: true,
editingHeartbeat: true,
isLocked: true,
},
})
if (!project) {
return NextResponse.json({ error: 'Projekt nicht gefunden' }, { status: 404 })
}
// Auto-release if heartbeat expired
if (project.editingById && isEditingExpired(project.editingHeartbeat)) {
await (prisma as any).project.update({
where: { id: params.id },
data: {
editingById: null,
editingUserName: null,
editingSessionId: null,
editingStartedAt: null,
editingHeartbeat: null,
},
})
return NextResponse.json({
editing: false,
editingBy: null,
isMe: false,
isLocked: project.isLocked,
})
}
// isMe is true only if sessionId matches (same tab/device)
const isMe = !!sessionId && project.editingSessionId === sessionId
return NextResponse.json({
editing: !!project.editingById,
editingBy: project.editingById ? {
id: project.editingById,
name: project.editingUserName,
since: project.editingStartedAt,
} : null,
isMe,
isLocked: project.isLocked,
})
} catch (error) {
console.error('Error checking editing status:', error)
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })
}
}
// POST: Start editing / heartbeat / stop editing
export async function POST(
req: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const user = await getSession()
if (!user) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
const body = await req.json()
const { action, sessionId } = body // 'start', 'heartbeat', 'stop'
const project = await (prisma as any).project.findUnique({
where: { id: params.id },
select: {
id: true,
editingById: true,
editingSessionId: true,
editingHeartbeat: true,
isLocked: true,
tenantId: true,
},
})
if (!project) {
return NextResponse.json({ error: 'Projekt nicht gefunden' }, { status: 404 })
}
if (action === 'start') {
if (!sessionId) {
return NextResponse.json({ error: 'sessionId fehlt' }, { status: 400 })
}
// Check if another session is editing (and heartbeat not expired)
if (project.editingSessionId && project.editingSessionId !== sessionId && !isEditingExpired(project.editingHeartbeat)) {
return NextResponse.json({
error: 'Projekt wird gerade von jemand anderem bearbeitet',
editingBy: project.editingById,
}, { status: 409 })
}
// Start editing
await (prisma as any).project.update({
where: { id: params.id },
data: {
editingById: user.id,
editingUserName: user.name,
editingSessionId: sessionId,
editingStartedAt: new Date(),
editingHeartbeat: new Date(),
},
})
return NextResponse.json({ success: true, action: 'started' })
}
if (action === 'heartbeat') {
// Only the current session can send heartbeats
if (project.editingSessionId !== sessionId) {
return NextResponse.json({ error: 'Sie sind nicht der aktuelle Bearbeiter' }, { status: 403 })
}
await (prisma as any).project.update({
where: { id: params.id },
data: { editingHeartbeat: new Date() },
})
return NextResponse.json({ success: true, action: 'heartbeat' })
}
if (action === 'stop') {
// Only the current session or a SERVER_ADMIN can stop editing
if (project.editingSessionId !== sessionId && user.role !== 'SERVER_ADMIN') {
return NextResponse.json({ error: 'Keine Berechtigung' }, { status: 403 })
}
await (prisma as any).project.update({
where: { id: params.id },
data: {
editingById: null,
editingUserName: null,
editingSessionId: null,
editingStartedAt: null,
editingHeartbeat: null,
},
})
return NextResponse.json({ success: true, action: 'stopped' })
}
return NextResponse.json({ error: 'Ungültige Aktion' }, { status: 400 })
} catch (error) {
console.error('Error managing editing lock:', error)
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })
}
}

View File

@@ -0,0 +1,85 @@
import { NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { prisma } from '@/lib/db'
import { getProjectWithTenantCheck } from '@/lib/tenant'
export async function GET(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const user = await getSession()
if (!user) {
return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
}
// Tenant isolation check
const projectCheck = await getProjectWithTenantCheck(params.id, user)
if (!projectCheck) {
return NextResponse.json({ error: 'Projekt nicht gefunden' }, { status: 404 })
}
const { searchParams } = new URL(request.url)
const format = searchParams.get('format') || 'json'
const project = await (prisma as any).project.findUnique({
where: { id: params.id },
include: {
features: true,
owner: {
select: { name: true },
},
},
})
if (!project) {
return NextResponse.json({ error: 'Projekt nicht gefunden' }, { status: 404 })
}
if (format === 'geojson') {
const geojson = {
type: 'FeatureCollection',
properties: {
title: project.title,
location: project.location,
description: project.description,
createdAt: project.createdAt,
updatedAt: project.updatedAt,
owner: project.owner?.name ?? null,
},
features: project.features.map((f: any) => ({
type: 'Feature',
id: f.id,
geometry: f.geometry,
properties: {
type: f.type,
...f.properties as object,
},
})),
}
return new NextResponse(JSON.stringify(geojson, null, 2), {
headers: {
'Content-Type': 'application/geo+json',
'Content-Disposition': `attachment; filename="${project.title.replace(/[^a-z0-9]/gi, '_')}.geojson"`,
},
})
}
// Default: return project data for client-side PDF/PNG generation
return NextResponse.json({
project: {
...project,
features: project.features.map((f: any) => ({
id: f.id,
type: f.type,
geometry: f.geometry,
properties: f.properties,
})),
},
})
} catch (error) {
console.error('Error exporting project:', error)
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })
}
}

View File

@@ -0,0 +1,139 @@
import { NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { prisma } from '@/lib/db'
import { featureSchema } from '@/lib/validations'
import { getProjectWithTenantCheck } from '@/lib/tenant'
export async function GET(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const user = await getSession()
if (!user) {
return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
}
const project = await getProjectWithTenantCheck(params.id, user)
if (!project) {
return NextResponse.json({ error: 'Projekt nicht gefunden' }, { status: 404 })
}
const features = await (prisma as any).feature.findMany({
where: { projectId: params.id },
orderBy: { createdAt: 'asc' },
})
return NextResponse.json({ features })
} catch (error) {
console.error('Error fetching features:', error)
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })
}
}
export async function POST(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const user = await getSession()
if (!user) {
return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
}
if (user.role === 'VIEWER') {
return NextResponse.json({ error: 'Keine Berechtigung' }, { status: 403 })
}
const project = await getProjectWithTenantCheck(params.id, user)
if (!project) {
return NextResponse.json({ error: 'Projekt nicht gefunden' }, { status: 404 })
}
if (project.isLocked && user.role !== 'SERVER_ADMIN') {
return NextResponse.json({ error: 'Projekt ist gesperrt' }, { status: 403 })
}
const body = await request.json()
const validated = featureSchema.safeParse(body)
if (!validated.success) {
return NextResponse.json(
{ error: 'Ungültige Eingabedaten', details: validated.error.flatten() },
{ status: 400 }
)
}
const feature = await (prisma as any).feature.create({
data: {
projectId: params.id,
type: validated.data.type,
geometry: validated.data.geometry,
properties: validated.data.properties || {},
},
})
return NextResponse.json({ feature }, { status: 201 })
} catch (error) {
console.error('Error creating feature:', error)
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })
}
}
export async function PUT(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const user = await getSession()
if (!user) {
return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
}
if (user.role === 'VIEWER') {
return NextResponse.json({ error: 'Keine Berechtigung' }, { status: 403 })
}
const project = await getProjectWithTenantCheck(params.id, user)
if (!project) {
const exists = await (prisma as any).project.findUnique({ where: { id: params.id }, select: { id: true, tenantId: true, ownerId: true } })
if (!exists) {
console.warn(`[Features PUT] Project ${params.id} not in DB`)
return NextResponse.json({ error: 'Projekt nicht gefunden' }, { status: 404 })
}
console.warn(`[Features PUT] Access denied: user=${user.id} tenant=${user.tenantId}, project owner=${exists.ownerId} tenant=${exists.tenantId}`)
return NextResponse.json({ error: 'Keine Berechtigung für dieses Projekt' }, { status: 403 })
}
if (project.isLocked && user.role !== 'SERVER_ADMIN') {
return NextResponse.json({ error: 'Projekt ist gesperrt' }, { status: 403 })
}
const body = await request.json()
const { features } = body as { features: Array<{ id?: string; type: string; geometry: object; properties?: object }> }
await (prisma as any).feature.deleteMany({
where: { projectId: params.id },
})
if (features && features.length > 0) {
await (prisma as any).feature.createMany({
data: features.map((f: any) => ({
projectId: params.id,
type: f.type,
geometry: f.geometry,
properties: f.properties || {},
})),
})
}
const updatedFeatures = await (prisma as any).feature.findMany({
where: { projectId: params.id },
})
return NextResponse.json({ features: updatedFeatures })
} catch (error) {
console.error('Error updating features:', error)
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })
}
}

View File

@@ -0,0 +1,67 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/db'
import { getSession } from '@/lib/auth'
import { getProjectWithTenantCheck } from '@/lib/tenant'
// PUT: Toggle confirmed/ok on a check item
export async function PUT(req: NextRequest, { params }: { params: { id: string; itemId: string } }) {
try {
const user = await getSession()
if (!user) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
if (user.role === 'VIEWER') return NextResponse.json({ error: 'Keine Berechtigung' }, { status: 403 })
const project = await getProjectWithTenantCheck(params.id, user)
if (!project) return NextResponse.json({ error: 'Projekt nicht gefunden' }, { status: 404 })
// Verify item belongs to this project
const existing = await (prisma as any).journalCheckItem.findFirst({
where: { id: params.itemId, projectId: params.id },
})
if (!existing) return NextResponse.json({ error: 'Element nicht gefunden' }, { status: 404 })
const body = await req.json()
const data: any = {}
if (body.label !== undefined) data.label = body.label
if (body.confirmed !== undefined) {
data.confirmed = body.confirmed
data.confirmedAt = body.confirmed ? new Date() : null
}
if (body.ok !== undefined) {
data.ok = body.ok
data.okAt = body.ok ? new Date() : null
}
const item = await (prisma as any).journalCheckItem.update({
where: { id: params.itemId },
data,
})
return NextResponse.json(item)
} catch (error) {
console.error('Error updating check item:', error)
return NextResponse.json({ error: 'Failed to update check item' }, { status: 500 })
}
}
// DELETE
export async function DELETE(req: NextRequest, { params }: { params: { id: string; itemId: string } }) {
try {
const user = await getSession()
if (!user) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
if (user.role === 'VIEWER') return NextResponse.json({ error: 'Keine Berechtigung' }, { status: 403 })
const project = await getProjectWithTenantCheck(params.id, user)
if (!project) return NextResponse.json({ error: 'Projekt nicht gefunden' }, { status: 404 })
// Verify item belongs to this project
const existing = await (prisma as any).journalCheckItem.findFirst({
where: { id: params.itemId, projectId: params.id },
})
if (!existing) return NextResponse.json({ error: 'Element nicht gefunden' }, { status: 404 })
await (prisma as any).journalCheckItem.delete({ where: { id: params.itemId } })
return NextResponse.json({ ok: true })
} catch (error) {
console.error('Error deleting check item:', error)
return NextResponse.json({ error: 'Failed to delete check item' }, { status: 500 })
}
}

View File

@@ -0,0 +1,57 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/db'
import { getSession } from '@/lib/auth'
import { getProjectWithTenantCheck } from '@/lib/tenant'
// POST: Add check item (or initialize from templates)
export async function POST(req: NextRequest, { params }: { params: { id: string } }) {
try {
const user = await getSession()
if (!user) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
if (user.role === 'VIEWER') return NextResponse.json({ error: 'Keine Berechtigung' }, { status: 403 })
const project = await getProjectWithTenantCheck(params.id, user)
if (!project) return NextResponse.json({ error: 'Projekt nicht gefunden' }, { status: 404 })
const body = await req.json()
// If 'initFromTemplates' is true, create check items from templates (only if none exist)
if (body.initFromTemplates) {
const existing = await (prisma as any).journalCheckItem.findMany({
where: { projectId: params.id },
})
if (existing.length > 0) {
return NextResponse.json(existing)
}
const templates = await (prisma as any).journalCheckTemplate.findMany({
where: { isActive: true },
orderBy: { sortOrder: 'asc' },
})
const items = await Promise.all(
templates.map((tpl: any, i: number) =>
(prisma as any).journalCheckItem.create({
data: {
projectId: params.id,
label: tpl.label,
sortOrder: i,
},
})
)
)
return NextResponse.json(items)
}
// Single item creation
const item = await (prisma as any).journalCheckItem.create({
data: {
projectId: params.id,
label: body.label || '',
sortOrder: body.sortOrder || 0,
},
})
return NextResponse.json(item)
} catch (error) {
console.error('Error creating check item:', error)
return NextResponse.json({ error: 'Failed to create check item' }, { status: 500 })
}
}

View File

@@ -0,0 +1,101 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/db'
import { getSession } from '@/lib/auth'
import { getProjectWithTenantCheck } from '@/lib/tenant'
// PUT: Update a journal entry — only toggle done status allowed directly
export async function PUT(req: NextRequest, { params }: { params: { id: string; entryId: string } }) {
try {
const user = await getSession()
if (!user) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
if (user.role === 'VIEWER') return NextResponse.json({ error: 'Keine Berechtigung' }, { status: 403 })
const project = await getProjectWithTenantCheck(params.id, user)
if (!project) return NextResponse.json({ error: 'Projekt nicht gefunden' }, { status: 404 })
const existing = await (prisma as any).journalEntry.findFirst({
where: { id: params.entryId, projectId: params.id },
})
if (!existing) return NextResponse.json({ error: 'Eintrag nicht gefunden' }, { status: 404 })
const body = await req.json()
// Only done toggle is allowed as direct edit
if (body.done !== undefined) {
const entry = await (prisma as any).journalEntry.update({
where: { id: params.entryId },
data: { done: body.done, doneAt: body.done ? new Date() : null },
})
return NextResponse.json(entry)
}
return NextResponse.json({ error: 'Direkte Bearbeitung nicht erlaubt. Bitte Korrektur erstellen.' }, { status: 400 })
} catch (error) {
console.error('Error updating journal entry:', error)
return NextResponse.json({ error: 'Failed to update entry' }, { status: 500 })
}
}
// POST: Create a correction for a journal entry (replaces DELETE)
// Marks the original as corrected (strikethrough) and creates a new correction entry below it
export async function POST(req: NextRequest, { params }: { params: { id: string; entryId: string } }) {
try {
const user = await getSession()
if (!user) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
if (user.role === 'VIEWER') return NextResponse.json({ error: 'Keine Berechtigung' }, { status: 403 })
const project = await getProjectWithTenantCheck(params.id, user)
if (!project) return NextResponse.json({ error: 'Projekt nicht gefunden' }, { status: 404 })
const existing = await (prisma as any).journalEntry.findFirst({
where: { id: params.entryId, projectId: params.id },
})
if (!existing) return NextResponse.json({ error: 'Eintrag nicht gefunden' }, { status: 404 })
// Prevent double-correction or correcting a correction entry
if (existing.isCorrected) {
return NextResponse.json({ error: 'Dieser Eintrag wurde bereits korrigiert.' }, { status: 400 })
}
if (existing.correctionOfId) {
return NextResponse.json({ error: 'Ein Korrektureintrag kann nicht nochmals korrigiert werden.' }, { status: 400 })
}
const body = await req.json()
const correctionText = body.what || ''
if (!correctionText.trim()) {
return NextResponse.json({ error: 'Korrekturtext ist erforderlich' }, { status: 400 })
}
// Mark original as corrected
await (prisma as any).journalEntry.update({
where: { id: params.entryId },
data: { isCorrected: true },
})
// Create correction entry with same time, placed right after the original
const correction = await (prisma as any).journalEntry.create({
data: {
time: existing.time,
what: `[Korrektur] ${correctionText}`,
who: body.who || existing.who || user.name,
sortOrder: existing.sortOrder + 1,
correctionOfId: existing.id,
projectId: params.id,
},
})
return NextResponse.json({ original: existing, correction })
} catch (error) {
console.error('Error creating correction:', error)
return NextResponse.json({ error: 'Failed to create correction' }, { status: 500 })
}
}
// DELETE: Not allowed — entries cannot be deleted, only corrected
export async function DELETE() {
return NextResponse.json(
{ error: 'Journal-Einträge können nicht gelöscht werden. Bitte erstellen Sie eine Korrektur.' },
{ status: 403 }
)
}

View File

@@ -0,0 +1,31 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/db'
import { getSession } from '@/lib/auth'
import { getProjectWithTenantCheck } from '@/lib/tenant'
// POST: Add a new journal entry
export async function POST(req: NextRequest, { params }: { params: { id: string } }) {
try {
const user = await getSession()
if (!user) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
if (user.role === 'VIEWER') return NextResponse.json({ error: 'Keine Berechtigung' }, { status: 403 })
const project = await getProjectWithTenantCheck(params.id, user)
if (!project) return NextResponse.json({ error: 'Projekt nicht gefunden' }, { status: 404 })
const body = await req.json()
const entry = await (prisma as any).journalEntry.create({
data: {
projectId: params.id,
time: body.time ? new Date(body.time) : new Date(),
what: body.what || '',
who: body.who || null,
done: body.done || false,
},
})
return NextResponse.json(entry)
} catch (error) {
console.error('Error creating journal entry:', error)
return NextResponse.json({ error: 'Failed to create entry' }, { status: 500 })
}
}

View File

@@ -0,0 +1,65 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/db'
import { getSession } from '@/lib/auth'
import { getProjectWithTenantCheck } from '@/lib/tenant'
// PUT: Update a pendenz
export async function PUT(req: NextRequest, { params }: { params: { id: string; pendenzId: string } }) {
try {
const user = await getSession()
if (!user) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
if (user.role === 'VIEWER') return NextResponse.json({ error: 'Keine Berechtigung' }, { status: 403 })
const project = await getProjectWithTenantCheck(params.id, user)
if (!project) return NextResponse.json({ error: 'Projekt nicht gefunden' }, { status: 404 })
// Verify pendenz belongs to this project
const existing = await (prisma as any).journalPendenz.findFirst({
where: { id: params.pendenzId, projectId: params.id },
})
if (!existing) return NextResponse.json({ error: 'Pendenz nicht gefunden' }, { status: 404 })
const body = await req.json()
const data: any = {}
if (body.what !== undefined) data.what = body.what
if (body.who !== undefined) data.who = body.who
if (body.whenHow !== undefined) data.whenHow = body.whenHow
if (body.done !== undefined) {
data.done = body.done
data.doneAt = body.done ? new Date() : null
}
const item = await (prisma as any).journalPendenz.update({
where: { id: params.pendenzId },
data,
})
return NextResponse.json(item)
} catch (error) {
console.error('Error updating pendenz:', error)
return NextResponse.json({ error: 'Failed to update pendenz' }, { status: 500 })
}
}
// DELETE
export async function DELETE(req: NextRequest, { params }: { params: { id: string; pendenzId: string } }) {
try {
const user = await getSession()
if (!user) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
if (user.role === 'VIEWER') return NextResponse.json({ error: 'Keine Berechtigung' }, { status: 403 })
const project = await getProjectWithTenantCheck(params.id, user)
if (!project) return NextResponse.json({ error: 'Projekt nicht gefunden' }, { status: 404 })
// Verify pendenz belongs to this project
const existing = await (prisma as any).journalPendenz.findFirst({
where: { id: params.pendenzId, projectId: params.id },
})
if (!existing) return NextResponse.json({ error: 'Pendenz nicht gefunden' }, { status: 404 })
await (prisma as any).journalPendenz.delete({ where: { id: params.pendenzId } })
return NextResponse.json({ ok: true })
} catch (error) {
console.error('Error deleting pendenz:', error)
return NextResponse.json({ error: 'Failed to delete pendenz' }, { status: 500 })
}
}

View File

@@ -0,0 +1,30 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/db'
import { getSession } from '@/lib/auth'
import { getProjectWithTenantCheck } from '@/lib/tenant'
// POST: Add a new pendenz
export async function POST(req: NextRequest, { params }: { params: { id: string } }) {
try {
const user = await getSession()
if (!user) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
if (user.role === 'VIEWER') return NextResponse.json({ error: 'Keine Berechtigung' }, { status: 403 })
const project = await getProjectWithTenantCheck(params.id, user)
if (!project) return NextResponse.json({ error: 'Projekt nicht gefunden' }, { status: 404 })
const body = await req.json()
const item = await (prisma as any).journalPendenz.create({
data: {
projectId: params.id,
what: body.what || '',
who: body.who || null,
whenHow: body.whenHow || null,
},
})
return NextResponse.json(item)
} catch (error) {
console.error('Error creating pendenz:', error)
return NextResponse.json({ error: 'Failed to create pendenz' }, { status: 500 })
}
}

View File

@@ -0,0 +1,35 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/db'
import { getSession } from '@/lib/auth'
import { getProjectWithTenantCheck } from '@/lib/tenant'
// GET all journal data for a project (entries, check items, pendenzen)
export async function GET(req: NextRequest, { params }: { params: { id: string } }) {
try {
const user = await getSession()
if (!user) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
const project = await getProjectWithTenantCheck(params.id, user)
if (!project) return NextResponse.json({ error: 'Projekt nicht gefunden' }, { status: 404 })
const [entries, checkItems, pendenzen] = await Promise.all([
(prisma as any).journalEntry.findMany({
where: { projectId: params.id },
orderBy: [{ time: 'asc' }, { sortOrder: 'asc' }, { createdAt: 'asc' }],
}),
(prisma as any).journalCheckItem.findMany({
where: { projectId: params.id },
orderBy: { sortOrder: 'asc' },
}),
(prisma as any).journalPendenz.findMany({
where: { projectId: params.id },
orderBy: { sortOrder: 'asc' },
}),
])
return NextResponse.json({ entries, checkItems, pendenzen })
} catch (error) {
console.error('Error fetching journal:', error)
return NextResponse.json({ error: 'Failed to fetch journal' }, { status: 500 })
}
}

View File

@@ -0,0 +1,168 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/db'
import { getSession } from '@/lib/auth'
import { getProjectWithTenantCheck } from '@/lib/tenant'
import { sendEmail } from '@/lib/email'
export async function POST(req: NextRequest, { params }: { params: { id: string } }) {
try {
const user = await getSession()
if (!user) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
const project = await getProjectWithTenantCheck(params.id, user)
if (!project) return NextResponse.json({ error: 'Projekt nicht gefunden' }, { status: 404 })
// Load tenant logo
let tenantLogoUrl = ''
let tenantName = ''
if ((project as any).tenantId) {
const tenant = await (prisma as any).tenant.findUnique({
where: { id: (project as any).tenantId },
select: { logoUrl: true, name: true },
})
if (tenant?.logoUrl) tenantLogoUrl = tenant.logoUrl
if (tenant?.name) tenantName = tenant.name
}
const body = await req.json()
const { recipientEmail } = body
if (!recipientEmail) {
return NextResponse.json({ error: 'Empfänger-E-Mail erforderlich' }, { status: 400 })
}
// Load journal data
const entries = await (prisma as any).journalEntry.findMany({
where: { projectId: params.id },
orderBy: [{ time: 'asc' }, { sortOrder: 'asc' }],
})
const checkItems = await (prisma as any).journalCheckItem.findMany({
where: { projectId: params.id },
orderBy: { sortOrder: 'asc' },
})
const pendenzen = await (prisma as any).journalPendenz.findMany({
where: { projectId: params.id },
orderBy: { sortOrder: 'asc' },
})
// Build HTML report
const formatTime = (d: Date) => new Date(d).toLocaleTimeString('de-CH', { hour: '2-digit', minute: '2-digit' })
const formatDate = (d: Date) => new Date(d).toLocaleDateString('de-CH', { day: '2-digit', month: '2-digit', year: 'numeric' })
const p = project as any
let entriesHtml = ''
for (const e of entries) {
const correctedStyle = e.isCorrected ? 'text-decoration:line-through;opacity:0.5;' : ''
const correctionStyle = e.correctionOfId ? 'color:#b45309;font-style:italic;' : ''
const doneIcon = e.done ? '✅' : ''
const doneAtStr = e.done && e.doneAt ? ` <span style="color:#16a34a;font-size:10px;">(erledigt ${formatTime(e.doneAt)})</span>` : ''
entriesHtml += `
<tr style="${correctedStyle}${correctionStyle}">
<td style="padding:4px 8px;border-bottom:1px solid #e5e7eb;font-family:monospace;font-size:12px;white-space:nowrap;">${formatTime(e.time)}</td>
<td style="padding:4px 8px;border-bottom:1px solid #e5e7eb;font-size:13px;">${e.what}${e.isCorrected ? ' <span style="color:#ef4444;font-size:11px;">(korrigiert)</span>' : ''}${doneAtStr}</td>
<td style="padding:4px 8px;border-bottom:1px solid #e5e7eb;font-size:12px;color:#666;">${e.who || ''}</td>
<td style="padding:4px 8px;border-bottom:1px solid #e5e7eb;text-align:center;">${doneIcon}</td>
</tr>`
}
let checkHtml = ''
for (const c of checkItems) {
const confirmedTime = c.confirmed && c.confirmedAt ? ` <span style="font-size:10px;color:#666;">${formatTime(c.confirmedAt)}</span>` : ''
checkHtml += `
<tr>
<td style="padding:3px 8px;border-bottom:1px solid #e5e7eb;font-size:13px;">${c.label}${confirmedTime}</td>
<td style="padding:3px 8px;border-bottom:1px solid #e5e7eb;text-align:center;">${c.confirmed ? '✅' : ''}</td>
<td style="padding:3px 8px;border-bottom:1px solid #e5e7eb;text-align:center;">${c.ok ? '✅' : ''}</td>
</tr>`
}
let pendHtml = ''
for (const p of pendenzen) {
const pendDoneAt = p.done && p.doneAt ? ` <span style="color:#16a34a;font-size:10px;">(${formatTime(p.doneAt)})</span>` : ''
pendHtml += `
<tr>
<td style="padding:3px 8px;border-bottom:1px solid #e5e7eb;font-size:13px;">${p.what}${pendDoneAt}</td>
<td style="padding:3px 8px;border-bottom:1px solid #e5e7eb;font-size:12px;color:#666;">${p.who || ''}</td>
<td style="padding:3px 8px;border-bottom:1px solid #e5e7eb;font-size:12px;color:#666;">${p.whenHow || ''}</td>
<td style="padding:3px 8px;border-bottom:1px solid #e5e7eb;text-align:center;">${p.done ? '✅' : ''}</td>
</tr>`
}
const logoHtml = tenantLogoUrl
? `<img src="${tenantLogoUrl}" alt="${tenantName}" style="height:40px;max-width:120px;object-fit:contain;margin-right:16px;border-radius:4px;" />`
: ''
const html = `
<div style="font-family:sans-serif;max-width:800px;margin:0 auto;">
<div style="background:#dc2626;color:white;padding:20px 24px;border-radius:12px 12px 0 0;display:flex;align-items:center;">
${logoHtml}
<div>
<h1 style="margin:0;font-size:22px;">Einsatzrapport</h1>
<p style="margin:4px 0 0;opacity:0.9;">${p.title || 'Ohne Titel'}${tenantName ? `${tenantName}` : ''}</p>
</div>
</div>
<div style="border:1px solid #e5e7eb;border-top:none;padding:24px;border-radius:0 0 12px 12px;">
<div style="display:flex;gap:24px;margin-bottom:20px;flex-wrap:wrap;">
<div><strong>Standort:</strong> ${p.location || ''}</div>
<div><strong>Datum:</strong> ${p.createdAt ? formatDate(p.createdAt) : ''}</div>
</div>
<h2 style="font-size:16px;border-bottom:2px solid #dc2626;padding-bottom:4px;margin:20px 0 8px;">Journal-Einträge</h2>
<table style="width:100%;border-collapse:collapse;">
<thead>
<tr style="background:#f5f5f4;">
<th style="padding:6px 8px;text-align:left;font-size:11px;color:#666;border-bottom:2px solid #dc2626;">Zeit</th>
<th style="padding:6px 8px;text-align:left;font-size:11px;color:#666;border-bottom:2px solid #dc2626;">Was</th>
<th style="padding:6px 8px;text-align:left;font-size:11px;color:#666;border-bottom:2px solid #dc2626;">Wer</th>
<th style="padding:6px 8px;text-align:center;font-size:11px;color:#666;border-bottom:2px solid #dc2626;">Ok</th>
</tr>
</thead>
<tbody>${entriesHtml || '<tr><td colspan="4" style="padding:12px;text-align:center;color:#999;">Keine Einträge</td></tr>'}</tbody>
</table>
${checkItems.length > 0 ? `
<h2 style="font-size:16px;border-bottom:2px solid #dc2626;padding-bottom:4px;margin:20px 0 8px;">SOMA Checkliste</h2>
<table style="width:100%;border-collapse:collapse;">
<thead>
<tr style="background:#f5f5f4;">
<th style="padding:6px 8px;text-align:left;font-size:11px;color:#666;">Punkt</th>
<th style="padding:6px 8px;text-align:center;font-size:11px;color:#666;">Bestätigt</th>
<th style="padding:6px 8px;text-align:center;font-size:11px;color:#666;">Ok</th>
</tr>
</thead>
<tbody>${checkHtml}</tbody>
</table>` : ''}
${pendenzen.length > 0 ? `
<h2 style="font-size:16px;border-bottom:2px solid #dc2626;padding-bottom:4px;margin:20px 0 8px;">Pendenzen</h2>
<table style="width:100%;border-collapse:collapse;">
<thead>
<tr style="background:#f5f5f4;">
<th style="padding:6px 8px;text-align:left;font-size:11px;color:#666;">Was</th>
<th style="padding:6px 8px;text-align:left;font-size:11px;color:#666;">Wer</th>
<th style="padding:6px 8px;text-align:left;font-size:11px;color:#666;">Wann/Wie</th>
<th style="padding:6px 8px;text-align:center;font-size:11px;color:#666;">Erledigt</th>
</tr>
</thead>
<tbody>${pendHtml}</tbody>
</table>` : ''}
<hr style="margin:20px 0;border:none;border-top:1px solid #e5e7eb;" />
<p style="color:#999;font-size:11px;">Gesendet von Lageplan am ${new Date().toLocaleString('de-CH')} durch ${user.name || user.email}</p>
</div>
</div>`
await sendEmail(
recipientEmail,
`Einsatzrapport — ${p.title || 'Ohne Titel'}`,
html
)
return NextResponse.json({ success: true, message: `Rapport an ${recipientEmail} gesendet` })
} catch (error) {
console.error('Error sending report:', error)
return NextResponse.json({ error: 'Fehler beim Senden des Rapports' }, { status: 500 })
}
}

View File

@@ -0,0 +1,121 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/db'
import { getSession } from '@/lib/auth'
import { getProjectWithTenantCheck } from '@/lib/tenant'
import { uploadFile, deleteFile, getFileUrl } from '@/lib/minio'
// POST: Upload a plan image for a project
export async function POST(req: NextRequest, { params }: { params: { id: string } }) {
try {
const user = await getSession()
if (!user) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
if (user.role === 'VIEWER') return NextResponse.json({ error: 'Keine Berechtigung' }, { status: 403 })
const project = await getProjectWithTenantCheck(params.id, user)
if (!project) return NextResponse.json({ error: 'Projekt nicht gefunden' }, { status: 404 })
const formData = await req.formData()
const file = formData.get('file') as File | null
const boundsStr = formData.get('bounds') as string | null
if (!file) {
return NextResponse.json({ error: 'Keine Datei angegeben' }, { status: 400 })
}
// Validate file type
const allowedTypes = ['image/png', 'image/jpeg', 'image/webp', 'image/svg+xml']
if (!allowedTypes.includes(file.type)) {
return NextResponse.json({ error: 'Nur PNG, JPEG, WebP oder SVG erlaubt' }, { status: 400 })
}
// Delete old plan image if exists
const p = project as any
if (p.planImageKey) {
try { await deleteFile(p.planImageKey) } catch {}
}
// Upload to MinIO
const buffer = Buffer.from(await file.arrayBuffer())
const ext = file.name.split('.').pop() || 'png'
const fileKey = `plans/${params.id}/${Date.now()}.${ext}`
await uploadFile(fileKey, buffer, file.type)
// Parse bounds or use default (current map view)
let bounds = null
if (boundsStr) {
try { bounds = JSON.parse(boundsStr) } catch {}
}
// Update project
await (prisma as any).project.update({
where: { id: params.id },
data: {
planImageKey: fileKey,
planBounds: bounds,
},
})
const url = await getFileUrl(fileKey)
return NextResponse.json({
success: true,
planImageUrl: url,
planImageKey: fileKey,
planBounds: bounds,
})
} catch (error) {
console.error('Error uploading plan image:', error)
return NextResponse.json({ error: 'Fehler beim Hochladen' }, { status: 500 })
}
}
// DELETE: Remove the plan image
export async function DELETE(req: NextRequest, { params }: { params: { id: string } }) {
try {
const user = await getSession()
if (!user) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
if (user.role === 'VIEWER') return NextResponse.json({ error: 'Keine Berechtigung' }, { status: 403 })
const project = await getProjectWithTenantCheck(params.id, user)
if (!project) return NextResponse.json({ error: 'Projekt nicht gefunden' }, { status: 404 })
const p = project as any
if (p.planImageKey) {
try { await deleteFile(p.planImageKey) } catch {}
}
await (prisma as any).project.update({
where: { id: params.id },
data: { planImageKey: null, planBounds: null },
})
return NextResponse.json({ success: true })
} catch (error) {
console.error('Error deleting plan image:', error)
return NextResponse.json({ error: 'Fehler beim Löschen' }, { status: 500 })
}
}
// PATCH: Update plan bounds (repositioning)
export async function PATCH(req: NextRequest, { params }: { params: { id: string } }) {
try {
const user = await getSession()
if (!user) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
const project = await getProjectWithTenantCheck(params.id, user)
if (!project) return NextResponse.json({ error: 'Projekt nicht gefunden' }, { status: 404 })
const body = await req.json()
if (!body.bounds) return NextResponse.json({ error: 'Bounds erforderlich' }, { status: 400 })
await (prisma as any).project.update({
where: { id: params.id },
data: { planBounds: body.bounds },
})
return NextResponse.json({ success: true })
} catch (error) {
console.error('Error updating plan bounds:', error)
return NextResponse.json({ error: 'Fehler beim Aktualisieren' }, { status: 500 })
}
}

View File

@@ -0,0 +1,40 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/db'
import { getSession } from '@/lib/auth'
import { getFileStream } from '@/lib/minio'
// Serve plan image (authenticated users only)
export async function GET(req: NextRequest, { params }: { params: { id: string } }) {
try {
const user = await getSession()
if (!user) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
const project = await (prisma as any).project.findUnique({
where: { id: params.id },
select: { planImageKey: true },
})
if (!project?.planImageKey) {
return NextResponse.json({ error: 'Kein Plan vorhanden' }, { status: 404 })
}
const { stream, contentType } = await getFileStream(project.planImageKey)
// Collect stream into buffer
const chunks: Buffer[] = []
for await (const chunk of stream as AsyncIterable<Buffer>) {
chunks.push(chunk)
}
const buffer = Buffer.concat(chunks)
return new NextResponse(buffer, {
headers: {
'Content-Type': contentType,
'Cache-Control': 'public, max-age=3600',
},
})
} catch (error) {
console.error('Error serving plan image:', error)
return NextResponse.json({ error: 'Fehler beim Laden des Plans' }, { status: 500 })
}
}

View File

@@ -0,0 +1,110 @@
import { NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { prisma } from '@/lib/db'
import { projectSchema } from '@/lib/validations'
import { getProjectWithTenantCheck } from '@/lib/tenant'
export async function GET(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const user = await getSession()
if (!user) {
return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
}
const projectBase = await getProjectWithTenantCheck(params.id, user)
if (!projectBase) {
return NextResponse.json({ error: 'Projekt nicht gefunden' }, { status: 404 })
}
// Re-fetch with includes
const project = await (prisma as any).project.findUnique({
where: { id: params.id },
include: {
owner: {
select: { id: true, name: true, email: true },
},
features: true,
},
})
return NextResponse.json({ project })
} catch (error) {
console.error('Error fetching project:', error)
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })
}
}
export async function PATCH(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const user = await getSession()
if (!user) {
return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
}
if (user.role === 'VIEWER') {
return NextResponse.json({ error: 'Keine Berechtigung' }, { status: 403 })
}
const existingProject = await getProjectWithTenantCheck(params.id, user)
if (!existingProject) {
return NextResponse.json({ error: 'Projekt nicht gefunden' }, { status: 404 })
}
const body = await request.json()
const validated = projectSchema.partial().safeParse(body)
if (!validated.success) {
return NextResponse.json(
{ error: 'Ungültige Eingabedaten', details: validated.error.flatten() },
{ status: 400 }
)
}
const project = await (prisma as any).project.update({
where: { id: params.id },
data: validated.data,
})
return NextResponse.json({ project })
} catch (error) {
console.error('Error updating project:', error)
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })
}
}
export async function DELETE(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const user = await getSession()
if (!user) {
return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
}
const existingProject = await getProjectWithTenantCheck(params.id, user)
if (!existingProject) {
return NextResponse.json({ error: 'Projekt nicht gefunden' }, { status: 404 })
}
// Only owner, tenant admin, or server admin can delete
if (user.role !== 'SERVER_ADMIN' && user.role !== 'TENANT_ADMIN' && existingProject.ownerId !== user.id) {
return NextResponse.json({ error: 'Keine Berechtigung' }, { status: 403 })
}
await (prisma as any).project.delete({
where: { id: params.id },
})
return NextResponse.json({ success: true })
} catch (error) {
console.error('Error deleting project:', error)
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })
}
}

View File

@@ -0,0 +1,94 @@
import { NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { prisma } from '@/lib/db'
import { projectSchema } from '@/lib/validations'
import { getTenantFilter } from '@/lib/tenant'
export async function GET() {
try {
const user = await getSession()
if (!user) {
return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
}
// Tenant isolation: each user only sees their tenant's projects
const tenantFilter = getTenantFilter(user)
const projects = await (prisma as any).project.findMany({
where: tenantFilter,
orderBy: { updatedAt: 'desc' },
include: {
owner: {
select: { id: true, name: true, email: true },
},
_count: {
select: { features: true },
},
},
})
return NextResponse.json({ projects })
} catch (error) {
console.error('Error fetching projects:', error)
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })
}
}
export async function POST(request: NextRequest) {
try {
const user = await getSession()
if (!user) {
return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
}
if (user.role === 'VIEWER') {
return NextResponse.json({ error: 'Keine Berechtigung' }, { status: 403 })
}
const body = await request.json()
const validated = projectSchema.safeParse(body)
if (!validated.success) {
return NextResponse.json(
{ error: 'Ungültige Eingabedaten', details: validated.error.flatten() },
{ status: 400 }
)
}
// Generate unique Einsatz-Nr: E-YYYY-NNNN (auto-increment per tenant per year)
const year = new Date().getFullYear()
const einsatzPrefix = `E-${year}-`
let einsatzNr = `${einsatzPrefix}0001`
try {
const lastProject = await (prisma as any).project.findFirst({
where: {
tenantId: user.tenantId || undefined,
einsatzNr: { startsWith: einsatzPrefix },
},
orderBy: { einsatzNr: 'desc' },
select: { einsatzNr: true },
})
if (lastProject?.einsatzNr) {
const lastNum = parseInt(lastProject.einsatzNr.split('-').pop())
if (!isNaN(lastNum)) einsatzNr = `${einsatzPrefix}${String(lastNum + 1).padStart(4, '0')}`
}
} catch {}
// Always set tenantId from the logged-in user's tenant
const project = await (prisma as any).project.create({
data: {
...validated.data,
einsatzNr,
ownerId: user.id,
tenantId: user.tenantId || null,
mapCenter: validated.data.mapCenter || { lng: 8.5417, lat: 47.3769 },
mapZoom: validated.data.mapZoom || 15,
},
})
return NextResponse.json({ project }, { status: 201 })
} catch (error) {
console.error('Error creating project:', error)
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })
}
}

View 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 })
}
}

View 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 })
}
}

View 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 })
}
}

View 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 })
}
}

View File

@@ -0,0 +1,17 @@
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/db'
// GET: Public app settings (no auth required, non-sensitive values only)
export async function GET() {
try {
let defaultSymbolScale = 1.5
try {
const setting = await (prisma as any).systemSetting.findUnique({ where: { key: 'default_symbol_scale' } })
if (setting) defaultSymbolScale = parseFloat(setting.value) || 1.5
} catch {}
return NextResponse.json({ defaultSymbolScale })
} catch {
return NextResponse.json({ defaultSymbolScale: 1.5 })
}
}

View File

@@ -0,0 +1,102 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/db'
import { getSession } from '@/lib/auth'
// POST: Tenant self-deletion — TENANT_ADMIN deletes own organization with all data
export async function POST(req: NextRequest) {
try {
const user = await getSession()
if (!user) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
const body = await req.json()
const { confirmText } = body
if (!user.tenantId) {
return NextResponse.json({ error: 'Kein Mandant zugeordnet' }, { status: 400 })
}
// Verify user is TENANT_ADMIN
if (user.role !== 'TENANT_ADMIN' && user.role !== 'SERVER_ADMIN') {
return NextResponse.json({ error: 'Nur der Organisations-Administrator kann die Organisation löschen' }, { status: 403 })
}
// Get tenant info for confirmation
const tenant = await (prisma as any).tenant.findUnique({
where: { id: user.tenantId },
select: { id: true, name: true, slug: true },
})
if (!tenant) {
return NextResponse.json({ error: 'Mandant nicht gefunden' }, { status: 404 })
}
// Require confirmation text to match tenant name
if (confirmText !== tenant.name) {
return NextResponse.json({ error: `Bitte geben Sie "${tenant.name}" zur Bestätigung ein` }, { status: 400 })
}
console.log(`[Tenant Delete] User ${user.id} deleting tenant ${tenant.id} (${tenant.name})`)
// Delete everything in order (respecting foreign keys)
// 1. Rapports
await (prisma as any).rapport.deleteMany({ where: { tenantId: tenant.id } })
// 2. Journal entries (via projects)
const projectIds = await (prisma as any).project.findMany({
where: { tenantId: tenant.id },
select: { id: true },
})
const pIds = projectIds.map((p: any) => p.id)
if (pIds.length > 0) {
await (prisma as any).journalEntry.deleteMany({ where: { projectId: { in: pIds } } })
await (prisma as any).journalCheckItem.deleteMany({ where: { projectId: { in: pIds } } })
await (prisma as any).journalPendenz.deleteMany({ where: { projectId: { in: pIds } } })
}
// 3. Projects
await (prisma as any).project.deleteMany({ where: { tenantId: tenant.id } })
// 4. Icon assets & categories
await (prisma as any).iconAsset.deleteMany({ where: { tenantId: tenant.id } })
await (prisma as any).iconCategory.deleteMany({ where: { tenantId: tenant.id } })
// 5. Hose types, check templates, dictionary entries
try { await (prisma as any).hoseType.deleteMany({ where: { tenantId: tenant.id } }) } catch {}
try { await (prisma as any).journalCheckTemplate.deleteMany({ where: { tenantId: tenant.id } }) } catch {}
try { await (prisma as any).dictionaryEntry.deleteMany({ where: { tenantId: tenant.id } }) } catch {}
try { await (prisma as any).upgradeRequest.deleteMany({ where: { tenantId: tenant.id } }) } catch {}
// 6. Get all user IDs from memberships
const memberships = await (prisma as any).tenantMembership.findMany({
where: { tenantId: tenant.id },
select: { userId: true },
})
const userIds = memberships.map((m: any) => m.userId)
// 7. Delete memberships
await (prisma as any).tenantMembership.deleteMany({ where: { tenantId: tenant.id } })
// 8. Delete users that have no other memberships
for (const uid of userIds) {
const otherMemberships = await (prisma as any).tenantMembership.count({
where: { userId: uid },
})
if (otherMemberships === 0) {
try {
await (prisma as any).user.delete({ where: { id: uid } })
} catch (e) {
console.warn(`[Tenant Delete] Could not delete user ${uid}:`, e)
}
}
}
// 9. Delete tenant itself
await (prisma as any).tenant.delete({ where: { id: tenant.id } })
console.log(`[Tenant Delete] Tenant ${tenant.name} (${tenant.id}) fully deleted`)
return NextResponse.json({ success: true, message: 'Organisation und alle Daten wurden gelöscht' })
} catch (error: any) {
console.error('[Tenant Delete] Error:', error?.message || error)
return NextResponse.json({ error: error?.message || 'Löschung fehlgeschlagen' }, { status: 500 })
}
}

View File

@@ -0,0 +1,41 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/db'
import { getSession } from '@/lib/auth'
export async function GET(req: NextRequest) {
try {
const user = await getSession()
if (!user) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
if (!user.tenantId) {
return NextResponse.json({ tenant: null })
}
const tenant = await (prisma as any).tenant.findUnique({
where: { id: user.tenantId },
select: {
id: true,
name: true,
slug: true,
plan: true,
subscriptionStatus: true,
contactEmail: true,
privacyAccepted: true,
privacyAcceptedAt: true,
adminAccessAccepted: true,
createdAt: true,
_count: {
select: {
memberships: true,
projects: true,
},
},
},
})
return NextResponse.json({ tenant })
} catch (error: any) {
console.error('[Tenant Info] Error:', error?.message)
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })
}
}

View File

@@ -0,0 +1,77 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/db'
import { getSession, isAdmin } from '@/lib/auth'
// GET: Fetch journal suggestions for a tenant (global + tenant dictionary merged)
export async function GET(
req: NextRequest,
{ params }: { params: { tenantId: string } }
) {
try {
const user = await getSession()
if (!user) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
// Fetch from new Dictionary model (global + tenant)
const [globalWords, tenantWords, tenant] = await Promise.all([
(prisma as any).dictionaryEntry.findMany({
where: { scope: 'GLOBAL' },
select: { word: true },
}).catch(() => []),
(prisma as any).dictionaryEntry.findMany({
where: { scope: 'TENANT', tenantId: params.tenantId },
select: { word: true },
}).catch(() => []),
(prisma as any).tenant.findUnique({
where: { id: params.tenantId },
select: { journalSuggestions: true },
}),
])
// Merge: dictionary entries + legacy journalSuggestions (backward compat)
const wordSet = new Set<string>()
for (const w of tenantWords) wordSet.add(w.word)
for (const w of globalWords) wordSet.add(w.word)
if (tenant?.journalSuggestions) {
for (const s of tenant.journalSuggestions) wordSet.add(s)
}
const suggestions = Array.from(wordSet).sort((a, b) => a.localeCompare(b, 'de'))
return NextResponse.json({ suggestions })
} catch (error) {
console.error('Error fetching suggestions:', error)
return NextResponse.json({ error: 'Interner Fehler' }, { status: 500 })
}
}
// PUT: Replace all journal suggestions for a tenant (admin only)
export async function PUT(
req: NextRequest,
{ params }: { params: { tenantId: string } }
) {
try {
const user = await getSession()
if (!user || !isAdmin(user.role)) {
return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 403 })
}
// TENANT_ADMIN can only edit their own tenant
if (user.role !== 'SERVER_ADMIN' && user.tenantId !== params.tenantId) {
return NextResponse.json({ error: 'Keine Berechtigung' }, { status: 403 })
}
const body = await req.json()
const suggestions: string[] = Array.isArray(body.suggestions)
? body.suggestions.filter((s: any) => typeof s === 'string' && s.trim()).map((s: string) => s.trim())
: []
await (prisma as any).tenant.update({
where: { id: params.tenantId },
data: { journalSuggestions: suggestions },
})
return NextResponse.json({ suggestions })
} catch (error) {
console.error('Error updating suggestions:', error)
return NextResponse.json({ error: 'Interner Fehler' }, { status: 500 })
}
}

View File

@@ -0,0 +1,44 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/db'
// Public endpoint: get tenant info by slug (logo, name)
export async function GET(
req: NextRequest,
{ params }: { params: { slug: string } }
) {
try {
const tenant = await (prisma as any).tenant.findUnique({
where: { slug: params.slug },
select: {
id: true,
name: true,
slug: true,
logoUrl: true,
isActive: true,
},
})
if (!tenant) {
return NextResponse.json({ error: 'Mandant nicht gefunden' }, { status: 404 })
}
if (!tenant.isActive) {
return NextResponse.json({
error: 'Mandant gesperrt',
reason: 'suspended',
tenant: { name: tenant.name, slug: tenant.slug, logoUrl: tenant.logoUrl },
}, { status: 403 })
}
return NextResponse.json({
tenant: {
name: tenant.name,
slug: tenant.slug,
logoUrl: tenant.logoUrl,
},
})
} catch (error) {
console.error('Error fetching tenant by slug:', error)
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })
}
}

View File

@@ -0,0 +1,185 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/db'
import { getSession, isServerAdmin } from '@/lib/auth'
import { sendEmail, getSmtpConfig } from '@/lib/email'
import { z } from 'zod'
const processSchema = z.object({
action: z.enum(['approve', 'reject']),
adminNote: z.string().max(500).optional(),
// Only for approve: override limits
maxUsers: z.number().min(1).max(1000).optional(),
maxProjects: z.number().min(1).max(10000).optional(),
})
// PATCH: Approve or reject an upgrade request (SERVER_ADMIN only)
export async function PATCH(
req: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const user = await getSession()
if (!user || !isServerAdmin(user.role)) {
return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 403 })
}
const body = await req.json()
const validated = processSchema.safeParse(body)
if (!validated.success) {
return NextResponse.json({ error: 'Ungültige Eingabe', details: validated.error.flatten() }, { status: 400 })
}
// Get the request
const upgradeReq = await (prisma as any).upgradeRequest.findUnique({
where: { id: params.id },
include: {
tenant: { select: { id: true, name: true, plan: true, contactEmail: true } },
requestedBy: { select: { name: true, email: true } },
},
})
if (!upgradeReq) {
return NextResponse.json({ error: 'Anfrage nicht gefunden' }, { status: 404 })
}
if (upgradeReq.status !== 'PENDING') {
return NextResponse.json({ error: 'Anfrage wurde bereits bearbeitet' }, { status: 400 })
}
const planLabels: Record<string, string> = {
FREE: 'Free', PRO: 'Pro',
}
const planLimits: Record<string, { maxUsers: number; maxProjects: number }> = {
FREE: { maxUsers: 5, maxProjects: 10 },
PRO: { maxUsers: 999, maxProjects: 9999 },
}
if (validated.data.action === 'approve') {
const limits = planLimits[upgradeReq.requestedPlan] || planLimits.PRO
// Update tenant plan
await (prisma as any).tenant.update({
where: { id: upgradeReq.tenantId },
data: {
plan: upgradeReq.requestedPlan,
subscriptionStatus: 'ACTIVE',
trialEndsAt: null,
maxUsers: validated.data.maxUsers || limits.maxUsers,
maxProjects: validated.data.maxProjects || limits.maxProjects,
},
})
// Update request status
await (prisma as any).upgradeRequest.update({
where: { id: params.id },
data: {
status: 'APPROVED',
adminNote: validated.data.adminNote || null,
processedAt: new Date(),
processedById: user.id,
},
})
// Send approval email to requester
const smtpConfig = await getSmtpConfig()
if (smtpConfig) {
try {
await sendEmail(
upgradeReq.requestedBy.email,
`Upgrade bestätigt — ${planLabels[upgradeReq.requestedPlan]} Plan aktiviert`,
`
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; max-width: 520px; margin: 0 auto;">
<div style="background: #16a34a; color: white; padding: 16px 24px; border-radius: 8px 8px 0 0;">
<h2 style="margin: 0; font-size: 18px;">✓ Upgrade bestätigt</h2>
</div>
<div style="border: 1px solid #e5e7eb; border-top: none; padding: 24px; border-radius: 0 0 8px 8px;">
<p style="margin: 0 0 16px; line-height: 1.6; color: #374151;">
Ihr Upgrade für <strong>${upgradeReq.tenant.name}</strong> wurde bestätigt und ist ab sofort aktiv.
</p>
<table style="width: 100%; border-collapse: collapse; margin: 16px 0;">
<tr>
<td style="padding: 8px 0; color: #6b7280; font-size: 14px;">Neuer Plan</td>
<td style="padding: 8px 0; font-weight: 600; text-align: right; color: #16a34a;">${planLabels[upgradeReq.requestedPlan]}</td>
</tr>
<tr>
<td style="padding: 8px 0; color: #6b7280; font-size: 14px;">Max. Benutzer</td>
<td style="padding: 8px 0; font-weight: 600; text-align: right;">${validated.data.maxUsers || limits.maxUsers}</td>
</tr>
<tr>
<td style="padding: 8px 0; color: #6b7280; font-size: 14px;">Max. Projekte</td>
<td style="padding: 8px 0; font-weight: 600; text-align: right;">${validated.data.maxProjects || limits.maxProjects}</td>
</tr>
</table>
${validated.data.adminNote ? `<p style="margin: 16px 0 0; padding: 12px; background: #f0fdf4; border-radius: 6px; font-size: 14px; color: #374151;"><strong>Hinweis:</strong><br/>${validated.data.adminNote}</p>` : ''}
<p style="margin: 16px 0 0; font-size: 13px; color: #6b7280;">
Vielen Dank für Ihr Vertrauen. Bei Fragen stehen wir Ihnen gerne zur Verfügung.
</p>
<hr style="border: none; border-top: 1px solid #e5e7eb; margin: 20px 0;" />
<p style="margin: 0; font-size: 12px; color: #9ca3af;">Lageplan — Digitale Lagepläne für die Feuerwehr</p>
</div>
</div>
`
)
} catch (e) {
console.error('Failed to send approval email:', e)
}
}
} else {
// Reject
await (prisma as any).upgradeRequest.update({
where: { id: params.id },
data: {
status: 'REJECTED',
adminNote: validated.data.adminNote || null,
processedAt: new Date(),
processedById: user.id,
},
})
// Send rejection email
const smtpConfig = await getSmtpConfig()
if (smtpConfig) {
try {
await sendEmail(
upgradeReq.requestedBy.email,
`Upgrade-Anfrage — Rückmeldung`,
`
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; max-width: 520px; margin: 0 auto;">
<div style="background: #6b7280; color: white; padding: 16px 24px; border-radius: 8px 8px 0 0;">
<h2 style="margin: 0; font-size: 18px;">Upgrade-Anfrage</h2>
</div>
<div style="border: 1px solid #e5e7eb; border-top: none; padding: 24px; border-radius: 0 0 8px 8px;">
<p style="margin: 0 0 16px; line-height: 1.6; color: #374151;">
Ihre Upgrade-Anfrage für <strong>${upgradeReq.tenant.name}</strong> auf den <strong>${planLabels[upgradeReq.requestedPlan]}</strong>-Plan konnte leider nicht bestätigt werden.
</p>
${validated.data.adminNote ? `<p style="margin: 0 0 16px; padding: 12px; background: #f9fafb; border-radius: 6px; font-size: 14px; color: #374151;"><strong>Begründung:</strong><br/>${validated.data.adminNote}</p>` : ''}
<p style="margin: 0; font-size: 13px; color: #6b7280;">
Bei Fragen kontaktieren Sie uns bitte unter app@lageplan.ch. Sie können jederzeit eine neue Anfrage stellen.
</p>
<hr style="border: none; border-top: 1px solid #e5e7eb; margin: 20px 0;" />
<p style="margin: 0; font-size: 12px; color: #9ca3af;">Lageplan — Digitale Lagepläne für die Feuerwehr</p>
</div>
</div>
`
)
} catch (e) {
console.error('Failed to send rejection email:', e)
}
}
}
// Return updated request
const updated = await (prisma as any).upgradeRequest.findUnique({
where: { id: params.id },
include: {
tenant: { select: { name: true, slug: true, plan: true, subscriptionStatus: true } },
requestedBy: { select: { name: true, email: true } },
},
})
return NextResponse.json({ request: updated })
} catch (error) {
console.error('Error processing upgrade request:', error)
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })
}
}

View File

@@ -0,0 +1,204 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/db'
import { getSession } from '@/lib/auth'
import { sendEmail, getSmtpConfig } from '@/lib/email'
import { z } from 'zod'
const upgradeSchema = z.object({
requestedPlan: z.enum(['PRO']),
message: z.string().max(1000).optional(),
})
// GET: List upgrade requests for current tenant (TENANT_ADMIN) or all (SERVER_ADMIN)
export async function GET() {
try {
const user = await getSession()
if (!user) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
let where: any = {}
if (user.role === 'SERVER_ADMIN') {
// Server admin sees all
} else if (user.role === 'TENANT_ADMIN' && user.tenantId) {
where = { tenantId: user.tenantId }
} else {
return NextResponse.json({ error: 'Keine Berechtigung' }, { status: 403 })
}
const requests = await (prisma as any).upgradeRequest.findMany({
where,
include: {
tenant: { select: { name: true, slug: true, plan: true, subscriptionStatus: true } },
requestedBy: { select: { name: true, email: true } },
},
orderBy: { createdAt: 'desc' },
})
return NextResponse.json({ requests })
} catch (error) {
console.error('Error fetching upgrade requests:', error)
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })
}
}
// POST: Create a new upgrade request (TENANT_ADMIN only)
export async function POST(req: NextRequest) {
try {
const user = await getSession()
if (!user) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
if (user.role !== 'TENANT_ADMIN' || !user.tenantId) {
return NextResponse.json({ error: 'Nur Mandanten-Administratoren können Upgrades anfordern' }, { status: 403 })
}
const body = await req.json()
const validated = upgradeSchema.safeParse(body)
if (!validated.success) {
return NextResponse.json({ error: 'Ungültige Eingabe', details: validated.error.flatten() }, { status: 400 })
}
// Get current tenant
const tenant = await (prisma as any).tenant.findUnique({
where: { id: user.tenantId },
select: { id: true, name: true, plan: true, contactEmail: true },
})
if (!tenant) {
return NextResponse.json({ error: 'Mandant nicht gefunden' }, { status: 404 })
}
// Check for existing pending request
const existingPending = await (prisma as any).upgradeRequest.findFirst({
where: { tenantId: user.tenantId, status: 'PENDING' },
})
if (existingPending) {
return NextResponse.json({
error: 'Es gibt bereits eine offene Upgrade-Anfrage. Bitte warten Sie auf die Bearbeitung.',
}, { status: 409 })
}
// Don't allow "downgrade" requests or same plan
const planOrder = { FREE: 0, PRO: 1 }
if ((planOrder[validated.data.requestedPlan as keyof typeof planOrder] || 0) <= (planOrder[tenant.plan as keyof typeof planOrder] || 0)) {
return NextResponse.json({ error: 'Der gewählte Plan ist kein Upgrade gegenüber dem aktuellen Plan.' }, { status: 400 })
}
// Create request
const request = await (prisma as any).upgradeRequest.create({
data: {
tenantId: user.tenantId,
requestedById: user.id,
requestedPlan: validated.data.requestedPlan,
currentPlan: tenant.plan,
message: validated.data.message || null,
},
include: {
tenant: { select: { name: true } },
requestedBy: { select: { name: true, email: true } },
},
})
// Send emails
const smtpConfig = await getSmtpConfig()
if (smtpConfig) {
const planLabels: Record<string, string> = {
FREE: 'Free', PRO: 'Pro',
}
// 1. Confirmation to tenant admin
try {
await sendEmail(
user.email,
`Upgrade-Anfrage bestätigt — ${planLabels[validated.data.requestedPlan]}`,
`
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; max-width: 520px; margin: 0 auto;">
<div style="background: #2563eb; color: white; padding: 16px 24px; border-radius: 8px 8px 0 0;">
<h2 style="margin: 0; font-size: 18px;">Upgrade-Anfrage eingegangen</h2>
</div>
<div style="border: 1px solid #e5e7eb; border-top: none; padding: 24px; border-radius: 0 0 8px 8px;">
<p style="margin: 0 0 16px; line-height: 1.6; color: #374151;">
Ihre Upgrade-Anfrage für <strong>${tenant.name}</strong> wurde erfolgreich übermittelt.
</p>
<table style="width: 100%; border-collapse: collapse; margin: 16px 0;">
<tr>
<td style="padding: 8px 0; color: #6b7280; font-size: 14px;">Aktueller Plan</td>
<td style="padding: 8px 0; font-weight: 600; text-align: right;">${planLabels[tenant.plan] || tenant.plan}</td>
</tr>
<tr>
<td style="padding: 8px 0; color: #6b7280; font-size: 14px;">Gewünschter Plan</td>
<td style="padding: 8px 0; font-weight: 600; text-align: right; color: #2563eb;">${planLabels[validated.data.requestedPlan]}</td>
</tr>
<tr>
<td style="padding: 8px 0; color: #6b7280; font-size: 14px;">Status</td>
<td style="padding: 8px 0; font-weight: 600; text-align: right;">
<span style="background: #fef3c7; color: #92400e; padding: 2px 8px; border-radius: 4px; font-size: 12px;">Wird geprüft</span>
</td>
</tr>
</table>
${validated.data.message ? `<p style="margin: 16px 0 0; padding: 12px; background: #f9fafb; border-radius: 6px; font-size: 14px; color: #374151;"><strong>Ihre Nachricht:</strong><br/>${validated.data.message.replace(/\n/g, '<br/>')}</p>` : ''}
<p style="margin: 16px 0 0; font-size: 13px; color: #6b7280;">
Wir werden Ihre Anfrage so schnell wie möglich bearbeiten. Sie erhalten eine Benachrichtigung, sobald Ihr Plan aktiviert wurde.
</p>
<hr style="border: none; border-top: 1px solid #e5e7eb; margin: 20px 0;" />
<p style="margin: 0; font-size: 12px; color: #9ca3af;">Lageplan — Digitale Lagepläne für die Feuerwehr</p>
</div>
</div>
`
)
} catch (e) {
console.error('Failed to send upgrade confirmation email:', e)
}
// 2. Notification to all server admins
try {
const serverAdmins = await (prisma as any).user.findMany({
where: { role: 'SERVER_ADMIN' },
select: { email: true, name: true },
})
for (const admin of serverAdmins) {
await sendEmail(
admin.email,
`Neue Upgrade-Anfrage: ${tenant.name}${planLabels[validated.data.requestedPlan]}`,
`
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; max-width: 520px; margin: 0 auto;">
<div style="background: #dc2626; color: white; padding: 16px 24px; border-radius: 8px 8px 0 0;">
<h2 style="margin: 0; font-size: 18px;">Neue Upgrade-Anfrage</h2>
</div>
<div style="border: 1px solid #e5e7eb; border-top: none; padding: 24px; border-radius: 0 0 8px 8px;">
<table style="width: 100%; border-collapse: collapse; margin: 0 0 16px;">
<tr>
<td style="padding: 8px 0; color: #6b7280; font-size: 14px;">Organisation</td>
<td style="padding: 8px 0; font-weight: 600; text-align: right;">${tenant.name}</td>
</tr>
<tr>
<td style="padding: 8px 0; color: #6b7280; font-size: 14px;">Angefragt von</td>
<td style="padding: 8px 0; font-weight: 600; text-align: right;">${user.name} (${user.email})</td>
</tr>
<tr>
<td style="padding: 8px 0; color: #6b7280; font-size: 14px;">Aktueller Plan</td>
<td style="padding: 8px 0; text-align: right;">${planLabels[tenant.plan] || tenant.plan}</td>
</tr>
<tr>
<td style="padding: 8px 0; color: #6b7280; font-size: 14px;">Gewünschter Plan</td>
<td style="padding: 8px 0; font-weight: 600; text-align: right; color: #2563eb;">${planLabels[validated.data.requestedPlan]}</td>
</tr>
</table>
${validated.data.message ? `<p style="margin: 0 0 16px; padding: 12px; background: #f9fafb; border-radius: 6px; font-size: 14px; color: #374151;"><strong>Nachricht:</strong><br/>${validated.data.message.replace(/\n/g, '<br/>')}</p>` : ''}
<p style="margin: 0; font-size: 13px; color: #6b7280;">
Bitte prüfen und bestätigen Sie die Anfrage im Admin-Panel unter "Upgrade-Anfragen".
</p>
<hr style="border: none; border-top: 1px solid #e5e7eb; margin: 20px 0;" />
<p style="margin: 0; font-size: 12px; color: #9ca3af;">Lageplan — Automatische Benachrichtigung</p>
</div>
</div>
`
)
}
} catch (e) {
console.error('Failed to send admin notification email:', e)
}
}
return NextResponse.json({ request }, { status: 201 })
} catch (error) {
console.error('Error creating upgrade request:', error)
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })
}
}