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