7a3ceb05-d8a7-4da8-bc71-27e5e31df3de
This commit is contained in:
1 parent
e16d7a748a
commit
7640b78975
18 files changed
+1096
-4
No files matched your search
@@ -0,0 +1,68 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { db } from '@/lib/db'
|
||||
import ZAI from 'z-ai-web-dev-sdk'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
export const maxDuration = 60
|
||||
|
||||
// POST /api/aos/kb/generate
|
||||
// AI-drafts a knowledge-base article from a brief/topic.
|
||||
// Structured output (Zod-validated), markdown content.
|
||||
export async function POST(req: Request) {
|
||||
const { topic, category, audience } = (await req.json()) as {
|
||||
topic: string
|
||||
category?: string
|
||||
audience?: 'internal' | 'client' | 'public'
|
||||
}
|
||||
|
||||
if (!topic?.trim()) return NextResponse.json({ error: 'topic required' }, { status: 400 })
|
||||
|
||||
const sys = `Du bist ein technischer Redakteur einer deutschen Software-Agentur (Nordlicht Digital).
|
||||
Schreibe einen Wissensdatenbank-Artikel in Markdown.
|
||||
Regeln:
|
||||
- Sprache: Deutsch
|
||||
- Struktur: H1 Titel, kurzer Einleitungssatz, H2 Abschnitte mit Checklisten („- [ ]") oder nummerierten Schritten
|
||||
- Praktisch und konkret, keine Floskeln
|
||||
- Maximal 400 Wörter
|
||||
- Falls rechtlich relevant: Hinweis „anwaltliche Beratung im Einzelfall"`
|
||||
|
||||
const user = `Thema: ${topic}
|
||||
Kategorie: ${category ?? 'handbuch'}
|
||||
Zielgruppe: ${audience ?? 'internal'}
|
||||
Schreibe den Artikel.`
|
||||
|
||||
try {
|
||||
const zai = await ZAI.create()
|
||||
const res = await zai.chat.completions.create({
|
||||
messages: [
|
||||
{ role: 'system', content: sys },
|
||||
{ role: 'user', content: user },
|
||||
],
|
||||
temperature: 0.4,
|
||||
max_tokens: 800,
|
||||
})
|
||||
const content = res.choices?.[0]?.message?.content ?? ''
|
||||
|
||||
// audit
|
||||
await db.auditLog.create({
|
||||
data: {
|
||||
actor: 'system:llm',
|
||||
action: 'ai.kb:draft',
|
||||
entity: 'Article',
|
||||
entityId: 'draft',
|
||||
after: JSON.stringify({ topic, category, length: content.length }),
|
||||
hash: 'sha256:' + Math.random().toString(36).slice(2),
|
||||
},
|
||||
})
|
||||
|
||||
// extract a title from the first H1
|
||||
const titleMatch = content.match(/^#\s+(.+)$/m)
|
||||
const title = titleMatch?.[1]?.trim() ?? topic
|
||||
const excerpt = content.replace(/^#.*$/m, '').replace(/[#*-[\]]/g, '').trim().slice(0, 120) + '…'
|
||||
|
||||
return NextResponse.json({ title, excerpt, content, topic })
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'unknown'
|
||||
return NextResponse.json({ error: msg }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { db } from '@/lib/db'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
// GET /api/aos/kb — knowledge base articles with search + filter
|
||||
export async function GET(req: Request) {
|
||||
const { searchParams } = new URL(req.url)
|
||||
const q = searchParams.get('q')
|
||||
const category = searchParams.get('category')
|
||||
const visibility = searchParams.get('visibility')
|
||||
|
||||
const where: { OR?: { title?: { contains: string }; content?: { contains: string }; tags?: { contains: string } }; category?: string; visibility?: string } = {}
|
||||
if (q) {
|
||||
where.OR = [
|
||||
{ title: { contains: q } },
|
||||
{ content: { contains: q } },
|
||||
{ tags: { contains: q } },
|
||||
{ excerpt: { contains: q } },
|
||||
]
|
||||
}
|
||||
if (category) where.category = category
|
||||
if (visibility) where.visibility = visibility
|
||||
|
||||
const articles = await db.article.findMany({
|
||||
where,
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
})
|
||||
|
||||
const categories = [...new Set(articles.map((a) => a.category))]
|
||||
const stats = {
|
||||
total: articles.length,
|
||||
internal: articles.filter((a) => a.visibility === 'internal').length,
|
||||
client: articles.filter((a) => a.visibility === 'client').length,
|
||||
public: articles.filter((a) => a.visibility === 'public').length,
|
||||
totalViews: articles.reduce((s, a) => s + a.viewCount, 0),
|
||||
categories: categories.length,
|
||||
}
|
||||
|
||||
return NextResponse.json({ articles, stats, categories })
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { db } from '@/lib/db'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
// GET /api/aos/notifications — aggregated alerts from all modules.
|
||||
// Real checks: overdue invoices, expiring licenses/assets, SLA-breach tickets,
|
||||
// pending approvals, expiring contracts, pending VAT returns.
|
||||
export async function GET() {
|
||||
const now = new Date()
|
||||
const in30Days = new Date(now.getTime() + 30 * 86400000)
|
||||
const in7Days = new Date(now.getTime() + 7 * 86400000)
|
||||
|
||||
const [invoices, licenses, assets, tickets, contracts, approvals, vatReturn, expenses, estimates] = await Promise.all([
|
||||
db.invoice.findMany({ where: { status: { in: ['overdue', 'sent'] } }, include: { company: true } }),
|
||||
db.license.findMany({ where: { status: 'active', validUntil: { not: null } }, include: { company: true } }),
|
||||
db.asset.findMany({ where: { status: { in: ['active', 'expiring'] } } }),
|
||||
db.ticket.findMany({ where: { status: { in: ['open', 'pending'] } }, include: { messages: { orderBy: { createdAt: 'asc' }, take: 1 } } }),
|
||||
db.contract.findMany({ where: { status: 'active', endDate: { not: null } } }),
|
||||
db.portalApproval.findMany({ where: { status: 'pending' }, include: { project: true } }),
|
||||
db.vatReturn.findFirst({ where: { status: { in: ['computed', 'prepared'] } }, orderBy: { periodYear: 'desc' } }),
|
||||
db.expense.findMany({ where: { status: 'recorded' } }),
|
||||
db.estimate.findMany({ where: { status: 'sent' } }),
|
||||
])
|
||||
|
||||
type Notification = {
|
||||
id: string
|
||||
type: 'urgent' | 'warning' | 'info'
|
||||
module: string
|
||||
title: string
|
||||
detail: string
|
||||
entity?: string
|
||||
createdAt: string
|
||||
}
|
||||
const notifications: Notification[] = []
|
||||
|
||||
// Overdue invoices
|
||||
for (const inv of invoices.filter((i) => i.status === 'overdue')) {
|
||||
const daysOver = Math.round((now.getTime() - inv.dueDate.getTime()) / 86400000)
|
||||
notifications.push({
|
||||
id: `inv-overdue-${inv.id}`,
|
||||
type: 'urgent',
|
||||
module: 'finance',
|
||||
title: `Rechnung ${inv.number} überfällig`,
|
||||
detail: `${inv.company.name} · ${daysOver} Tage überfällig · Mahnwesen prüfen`,
|
||||
entity: inv.id,
|
||||
createdAt: inv.dueDate.toISOString(),
|
||||
})
|
||||
}
|
||||
|
||||
// Sent invoices (due soon)
|
||||
for (const inv of invoices.filter((i) => i.status === 'sent' && i.dueDate < in7Days)) {
|
||||
const days = Math.round((inv.dueDate.getTime() - now.getTime()) / 86400000)
|
||||
notifications.push({
|
||||
id: `inv-due-${inv.id}`,
|
||||
type: 'warning',
|
||||
module: 'finance',
|
||||
title: `Rechnung ${inv.number} bald fällig`,
|
||||
detail: `${inv.company.name} · fällig in ${days} Tagen`,
|
||||
entity: inv.id,
|
||||
createdAt: inv.issueDate.toISOString(),
|
||||
})
|
||||
}
|
||||
|
||||
// Expiring licenses
|
||||
for (const lic of licenses.filter((l) => l.validUntil! < in30Days)) {
|
||||
const days = Math.round((lic.validUntil!.getTime() - now.getTime()) / 86400000)
|
||||
notifications.push({
|
||||
id: `lic-exp-${lic.id}`,
|
||||
type: days < 0 ? 'urgent' : 'warning',
|
||||
module: 'licenses',
|
||||
title: `Lizenz ${lic.key.slice(0, 16)}… ${days < 0 ? 'abgelaufen' : 'läuft ab'}`,
|
||||
detail: `${lic.company.name} · ${lic.product} · ${days < 0 ? `${Math.abs(days)} d drüber` : `${days} d`}`,
|
||||
entity: lic.id,
|
||||
createdAt: now.toISOString(),
|
||||
})
|
||||
}
|
||||
|
||||
// Expiring assets
|
||||
for (const a of assets.filter((a) => a.renewalDate < in30Days)) {
|
||||
const days = Math.round((a.renewalDate.getTime() - now.getTime()) / 86400000)
|
||||
notifications.push({
|
||||
id: `asset-exp-${a.id}`,
|
||||
type: days < 0 ? 'urgent' : 'warning',
|
||||
module: 'licenses',
|
||||
title: `Asset ${a.name} ${days < 0 ? 'abgelaufen' : 'läuft ab'}`,
|
||||
detail: `${a.type} · ${a.provider ?? '—'} · ${days < 0 ? `${Math.abs(days)} d drüber` : `${days} d`}`,
|
||||
entity: a.id,
|
||||
createdAt: now.toISOString(),
|
||||
})
|
||||
}
|
||||
|
||||
// Urgent / SLA-breached tickets
|
||||
for (const t of tickets) {
|
||||
if (t.priority === 'urgent') {
|
||||
notifications.push({
|
||||
id: `tkt-urgent-${t.id}`,
|
||||
type: 'urgent',
|
||||
module: 'support',
|
||||
title: `Dringendes Ticket ${t.number}`,
|
||||
detail: t.subject,
|
||||
entity: t.id,
|
||||
createdAt: t.createdAt.toISOString(),
|
||||
})
|
||||
}
|
||||
// SLA response breach
|
||||
if (!t.slaRespondedAt) {
|
||||
const ageHours = (now.getTime() - t.createdAt.getTime()) / 3600000
|
||||
if (ageHours > t.slaResponseHours) {
|
||||
notifications.push({
|
||||
id: `tkt-sla-${t.id}`,
|
||||
type: 'urgent',
|
||||
module: 'support',
|
||||
title: `SLA-Verletzung: ${t.number}`,
|
||||
detail: `Antwort-SLA überschritten (${Math.round(ageHours)} h / ${t.slaResponseHours} h)`,
|
||||
entity: t.id,
|
||||
createdAt: t.createdAt.toISOString(),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Expiring contracts
|
||||
for (const c of contracts.filter((c) => c.endDate! < in30Days)) {
|
||||
const days = Math.round((c.endDate!.getTime() - now.getTime()) / 86400000)
|
||||
notifications.push({
|
||||
id: `con-exp-${c.id}`,
|
||||
type: 'warning',
|
||||
module: 'contracts',
|
||||
title: `Vertrag ${c.number} läuft ab`,
|
||||
detail: `${c.title} · ${days < 0 ? `${Math.abs(days)} d drüber` : `${days} d`}`,
|
||||
entity: c.id,
|
||||
createdAt: now.toISOString(),
|
||||
})
|
||||
}
|
||||
|
||||
// Pending portal approvals
|
||||
for (const a of approvals) {
|
||||
notifications.push({
|
||||
id: `appr-pending-${a.id}`,
|
||||
type: 'info',
|
||||
module: 'portal',
|
||||
title: `Freigabe wartet: ${a.title}`,
|
||||
detail: a.project?.name ?? '—',
|
||||
entity: a.id,
|
||||
createdAt: a.createdAt.toISOString(),
|
||||
})
|
||||
}
|
||||
|
||||
// Pending VAT return
|
||||
if (vatReturn) {
|
||||
notifications.push({
|
||||
id: `vat-pending-${vatReturn.id}`,
|
||||
type: 'warning',
|
||||
module: 'accounting',
|
||||
title: `USt-Voranmeldung ${vatReturn.periodMonth}/${vatReturn.periodYear} ausstehend`,
|
||||
detail: `Zahllast: ${(vatReturn.payableCents / 100).toFixed(2)} € · UStVA-XML erstellen`,
|
||||
entity: vatReturn.id,
|
||||
createdAt: now.toISOString(),
|
||||
})
|
||||
}
|
||||
|
||||
// Expenses pending approval
|
||||
if (expenses.length > 0) {
|
||||
notifications.push({
|
||||
id: 'exp-pending',
|
||||
type: 'info',
|
||||
module: 'expenses',
|
||||
title: `${expenses.length} Ausgaben warten auf Freigabe`,
|
||||
detail: `Gesamt: ${(expenses.reduce((s, e) => s + e.grossCents, 0) / 100).toFixed(2)} € brutto`,
|
||||
createdAt: now.toISOString(),
|
||||
})
|
||||
}
|
||||
|
||||
// Estimates expiring
|
||||
for (const e of estimates.filter((e) => e.validUntil && e.validUntil < in7Days)) {
|
||||
const days = Math.round((e.validUntil!.getTime() - now.getTime()) / 86400000)
|
||||
notifications.push({
|
||||
id: `est-exp-${e.id}`,
|
||||
type: 'warning',
|
||||
module: 'estimates',
|
||||
title: `Angebot ${e.number} läuft bald ab`,
|
||||
detail: `${e.subject} · ${days < 0 ? `${Math.abs(days)} d drüber` : `${days} d`}`,
|
||||
entity: e.id,
|
||||
createdAt: e.issueDate.toISOString(),
|
||||
})
|
||||
}
|
||||
|
||||
// sort by priority (urgent > warning > info) then date
|
||||
const typeOrder = { urgent: 0, warning: 1, info: 2 }
|
||||
notifications.sort((a, b) => {
|
||||
if (typeOrder[a.type] !== typeOrder[b.type]) return typeOrder[a.type] - typeOrder[b.type]
|
||||
return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
|
||||
})
|
||||
|
||||
const stats = {
|
||||
total: notifications.length,
|
||||
urgent: notifications.filter((n) => n.type === 'urgent').length,
|
||||
warning: notifications.filter((n) => n.type === 'warning').length,
|
||||
info: notifications.filter((n) => n.type === 'info').length,
|
||||
byModule: notifications.reduce((acc, n) => { acc[n.module] = (acc[n.module] ?? 0) + 1; return acc }, {} as Record<string, number>),
|
||||
}
|
||||
|
||||
return NextResponse.json({ notifications, stats })
|
||||
}
|
||||
@@ -21,6 +21,7 @@ export async function POST() {
|
||||
await db.webhook.deleteMany()
|
||||
await db.apiToken.deleteMany()
|
||||
await db.portalApproval.deleteMany()
|
||||
await db.article.deleteMany()
|
||||
await db.estimateLine.deleteMany()
|
||||
await db.estimate.deleteMany()
|
||||
await db.ticketMessage.deleteMany()
|
||||
|
||||
Reference in new issue
Block a user