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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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