a6c0f3de-eeb0-4d76-a57f-f600dd133c06

This commit is contained in:
Z User committed 2026-10-28 15:39:30 +00:00
1 parent 2aa3496a90
commit 762f3d7373
141 files changed
+4485 -69

No files matched your search

@@ -0,0 +1,90 @@
import { NextResponse } from 'next/server'
import { db } from '@/lib/db'
import { z } from 'zod'
export const dynamic = 'force-dynamic'
const schema = z.object({
discountPct: z.number().int().min(0).max(20),
reviewedBy: z.string().optional(),
})
// PATCH /api/aos/agency/[id]/approve
// Admin approves an agency application. Sets Company.agencyStatus = "approved",
// Company.agencyDiscountPct = discountPct, and marks the application approved.
// In production an email would be sent to the applicant with their discount.
export async function PATCH(req: Request, { params }: { params: Promise<{ id: string }> }) {
try {
const { id } = await params
const parsed = schema.safeParse(await req.json())
if (!parsed.success) {
return NextResponse.json({ error: 'discountPct (0-20) required', details: parsed.error.flatten() }, { status: 400 })
}
const { discountPct, reviewedBy } = parsed.data
const application = await db.agencyApplication.findUnique({
where: { id },
include: { company: true },
})
if (!application) {
return NextResponse.json({ error: 'application not found' }, { status: 404 })
}
if (application.status === 'approved') {
return NextResponse.json({ error: 'already approved' }, { status: 409 })
}
const now = new Date()
await db.$transaction([
db.agencyApplication.update({
where: { id },
data: {
status: 'approved',
discountPct,
reviewedBy: reviewedBy ?? 'admin:ui',
reviewedAt: now,
},
}),
db.company.update({
where: { id: application.companyId },
data: {
isAgency: true,
agencyStatus: 'approved',
agencyApprovedAt: now,
agencyDiscountPct: discountPct,
agencyRejectedAt: null,
agencyRejectReason: null,
},
}),
])
await db.auditLog.create({
data: {
actor: reviewedBy ?? 'admin:ui',
action: 'agency.approve',
entity: 'AgencyApplication',
entityId: id,
after: JSON.stringify({
company: application.company.name,
discountPct,
approvedAt: now,
}),
hash: 'sha256:' + Math.random().toString(36).slice(2),
},
})
// TODO (production): send email to company.email with subject
// "Ihre Bayi-Bewerbung wurde genehmigt — {discountPct}% Rabatt"
// containing the discount and a link to the agency portal.
return NextResponse.json({
ok: true,
status: 'approved',
discountPct,
email: application.company.email,
message: `Genehmigt mit ${discountPct}% Rabatt. E-Mail an ${application.company.email ?? '(keine E-Mail hinterlegt)'} würde versendet.`,
})
} catch (err) {
const msg = err instanceof Error ? err.message : 'internal server error'
return NextResponse.json({ error: msg }, { status: 500 })
}
}
@@ -0,0 +1,68 @@
import { NextResponse } from 'next/server'
import { db } from '@/lib/db'
import { z } from 'zod'
export const dynamic = 'force-dynamic'
const schema = z.object({
reason: z.string().min(1).max(500),
reviewedBy: z.string().optional(),
})
// PATCH /api/aos/agency/[id]/reject
// Admin rejects an agency application. Sets Company.agencyStatus = "rejected".
export async function PATCH(req: Request, { params }: { params: Promise<{ id: string }> }) {
try {
const { id } = await params
const parsed = schema.safeParse(await req.json())
if (!parsed.success) {
return NextResponse.json({ error: 'reason required', details: parsed.error.flatten() }, { status: 400 })
}
const { reason, reviewedBy } = parsed.data
const application = await db.agencyApplication.findUnique({
where: { id },
include: { company: true },
})
if (!application) {
return NextResponse.json({ error: 'application not found' }, { status: 404 })
}
const now = new Date()
await db.$transaction([
db.agencyApplication.update({
where: { id },
data: {
status: 'rejected',
rejectReason: reason,
reviewedBy: reviewedBy ?? 'admin:ui',
reviewedAt: now,
},
}),
db.company.update({
where: { id: application.companyId },
data: {
agencyStatus: 'rejected',
agencyRejectedAt: now,
agencyRejectReason: reason,
},
}),
])
await db.auditLog.create({
data: {
actor: reviewedBy ?? 'admin:ui',
action: 'agency.reject',
entity: 'AgencyApplication',
entityId: id,
after: JSON.stringify({ company: application.company.name, reason }),
hash: 'sha256:' + Math.random().toString(36).slice(2),
},
})
return NextResponse.json({ ok: true, status: 'rejected', reason })
} catch (err) {
const msg = err instanceof Error ? err.message : 'internal server error'
return NextResponse.json({ error: msg }, { status: 500 })
}
}
+80
View File
@@ -0,0 +1,80 @@
import { NextResponse } from 'next/server'
import { db } from '@/lib/db'
import { z } from 'zod'
export const dynamic = 'force-dynamic'
const schema = z.object({
companyId: z.string().min(1),
businessModel: z.string().max(200).optional(),
expectedClientVolume: z.string().max(100).optional(),
referredBy: z.string().max(200).optional(),
})
// POST /api/aos/agency/apply
// A company applies to become an agency (reseller). Sets Company.agencyStatus
// to "pending" and creates an AgencyApplication record. Admin must approve.
export async function POST(req: Request) {
try {
const parsed = schema.safeParse(await req.json())
if (!parsed.success) {
return NextResponse.json({ error: 'validation failed', details: parsed.error.flatten() }, { status: 400 })
}
const { companyId, businessModel, expectedClientVolume, referredBy } = parsed.data
const company = await db.company.findUnique({ where: { id: companyId } })
if (!company) {
return NextResponse.json({ error: 'company not found' }, { status: 404 })
}
if (company.isAgency && company.agencyStatus === 'approved') {
return NextResponse.json({ error: 'already an approved agency' }, { status: 409 })
}
// Upsert application (one per company — @unique on companyId)
const application = await db.agencyApplication.upsert({
where: { companyId },
create: {
companyId,
status: 'pending',
businessModel,
expectedClientVolume,
referredBy,
},
update: {
status: 'pending',
businessModel,
expectedClientVolume,
referredBy,
rejectReason: null,
reviewedBy: null,
reviewedAt: null,
},
})
// Mark company as pending-agency
await db.company.update({
where: { id: companyId },
data: {
isAgency: true,
agencyStatus: 'pending',
agencyAppliedAt: new Date(),
},
})
await db.auditLog.create({
data: {
actor: `company:${companyId}`,
action: 'agency.apply',
entity: 'AgencyApplication',
entityId: application.id,
after: JSON.stringify({ company: company.name, businessModel, expectedClientVolume }),
hash: 'sha256:' + Math.random().toString(36).slice(2),
},
})
return NextResponse.json({ ok: true, applicationId: application.id, status: 'pending' }, { status: 201 })
} catch (err) {
const msg = err instanceof Error ? err.message : 'internal server error'
return NextResponse.json({ error: msg }, { status: 500 })
}
}
+42
View File
@@ -0,0 +1,42 @@
import { NextResponse } from 'next/server'
import { db } from '@/lib/db'
export const dynamic = 'force-dynamic'
// GET /api/aos/agency
// Returns all agency applications + approved agencies. Admin-only in practice
// (no scope check here because AOS is single-tenant dev mode).
export async function GET() {
try {
const [applications, agencies] = await Promise.all([
db.agencyApplication.findMany({
orderBy: { appliedAt: 'desc' },
include: { company: true },
}),
db.company.findMany({
where: { isAgency: true, agencyStatus: 'approved' },
orderBy: { agencyApprovedAt: 'desc' },
include: {
agencyClients: { select: { id: true, name: true, email: true } },
_count: { select: { agencyClients: true, licenses: true } },
},
}),
])
const pending = applications.filter((a) => a.status === 'pending')
const reviewed = applications.filter((a) => a.status !== 'pending')
return NextResponse.json({
applications: { pending, reviewed },
agencies,
stats: {
pending: pending.length,
approved: agencies.length,
totalApplications: applications.length,
},
})
} catch (err) {
const msg = err instanceof Error ? err.message : 'internal server error'
return NextResponse.json({ error: msg }, { status: 500 })
}
}
+87
View File
@@ -0,0 +1,87 @@
import { NextResponse } from 'next/server'
import { db } from '@/lib/db'
import { z } from 'zod'
export const dynamic = 'force-dynamic'
const createSchema = z.object({
name: z.string().min(1).max(200),
legalForm: z.string().max(50).optional(),
vatId: z.string().max(50).optional(),
website: z.string().max(200).optional(),
email: z.string().email().optional(),
phone: z.string().max(50).optional(),
city: z.string().max(100).optional(),
zip: z.string().max(20).optional(),
street: z.string().max(200).optional(),
// The agency creating this client. Required. The new client will have
// parentAgencyId set to this, so their tickets route to the agency.
parentAgencyId: z.string().min(1),
})
// GET /api/aos/clients?agencyId=X
// Returns clients belonging to an agency (or all clients if no agencyId).
export async function GET(req: Request) {
try {
const url = new URL(req.url)
const agencyId = url.searchParams.get('agencyId')
const where = agencyId ? { parentAgencyId: agencyId } : {}
const clients = await db.company.findMany({
where,
orderBy: { createdAt: 'desc' },
include: {
_count: { select: { licenses: true, tickets: true } },
},
})
return NextResponse.json({ clients })
} catch (err) {
const msg = err instanceof Error ? err.message : 'internal server error'
return NextResponse.json({ error: msg }, { status: 500 })
}
}
// POST /api/aos/clients
// An agency onboards a new client. The client is created with parentAgencyId
// set to the agency's company id, so all future tickets auto-route to agency.
export async function POST(req: Request) {
try {
const parsed = createSchema.safeParse(await req.json())
if (!parsed.success) {
return NextResponse.json({ error: 'validation failed', details: parsed.error.flatten() }, { status: 400 })
}
const { parentAgencyId, ...clientData } = parsed.data
// Verify the agency exists and is approved.
const agency = await db.company.findUnique({ where: { id: parentAgencyId } })
if (!agency) {
return NextResponse.json({ error: 'agency not found' }, { status: 404 })
}
if (!agency.isAgency || agency.agencyStatus !== 'approved') {
return NextResponse.json({ error: 'company is not an approved agency' }, { status: 403 })
}
const client = await db.company.create({
data: {
...clientData,
source: 'agency',
parentAgencyId,
},
})
await db.auditLog.create({
data: {
actor: `agency:${agency.name}`,
action: 'client.create',
entity: 'Company',
entityId: client.id,
after: JSON.stringify({ name: client.name, parentAgencyId, agencyDiscountPct: agency.agencyDiscountPct }),
hash: 'sha256:' + Math.random().toString(36).slice(2),
},
})
return NextResponse.json({ client }, { status: 201 })
} catch (err) {
const msg = err instanceof Error ? err.message : 'internal server error'
return NextResponse.json({ error: msg }, { status: 500 })
}
}
View File
File mode changed.
View File
File mode changed.
@@ -0,0 +1,120 @@
import { NextResponse } from 'next/server'
import { db } from '@/lib/db'
import { z } from 'zod'
export const dynamic = 'force-dynamic'
const schema = z.object({
reason: z.string().min(1).max(500).optional(),
immediate: z.boolean().optional(), // force immediate (admin only)
})
// PATCH /api/aos/licenses/[id]/cancel
// Client cancels (kündigt) a subscription.
//
// German B2B law: 3-month (90-day) notice period. The cancellation takes
// effect at the end of the notice period (cancellationEffectiveAt).
//
// Special case: within the 14-day trial, the client may cancel with immediate
// effect — no charge, subscription never starts.
export async function PATCH(req: Request, { params }: { params: Promise<{ id: string }> }) {
try {
const { id } = await params
const parsed = schema.safeParse(await req.json().catch(() => ({})))
if (!parsed.success) {
return NextResponse.json({ error: 'validation failed', details: parsed.error.flatten() }, { status: 400 })
}
const { reason, immediate } = parsed.data
const license = await db.license.findUnique({ where: { id } })
if (!license) {
return NextResponse.json({ error: 'license not found' }, { status: 404 })
}
if (license.status === 'cancelled' || license.cancelledAt) {
return NextResponse.json({ error: 'already cancelled' }, { status: 409 })
}
const now = new Date()
const inTrial = license.trialEndsAt && now < license.trialEndsAt
// Effective cancellation date:
// - In trial (first 14 days): immediate (next day at most)
// - After trial: notice period (90 days) from now
// - immediate=true (admin override): now
let effectiveAt: Date
if (immediate || inTrial) {
effectiveAt = now
} else {
effectiveAt = new Date(now.getTime() + (license.noticePeriodDays || 90) * 86400000)
}
// If cancelling during trial, the subscription never starts — set
// validUntil to the trial end (so the license stays active until trial ends
// then expires). If immediate, set validUntil to now.
const newValidUntil = immediate
? now
: inTrial
? license.trialEndsAt
: effectiveAt
await db.license.update({
where: { id },
data: {
status: immediate ? 'cancelled' : 'active', // stays active until effective date unless immediate
cancelledAt: now,
cancellationEffectiveAt: effectiveAt,
cancellationReason: reason,
validUntil: newValidUntil,
},
})
// Transitive cancellation of module licenses (if core)
let cancelledModules = 0
if (!license.parentId) {
const r = await db.license.updateMany({
where: { parentId: id, status: { not: 'cancelled' } },
data: {
status: immediate ? 'cancelled' : 'active',
cancelledAt: now,
cancellationEffectiveAt: effectiveAt,
cancellationReason: reason,
validUntil: newValidUntil,
},
})
cancelledModules = r.count
}
await db.auditLog.create({
data: {
actor: 'client:cancel',
action: inTrial ? 'license.cancel:trial' : 'license.cancel:notice',
entity: 'License',
entityId: id,
after: JSON.stringify({
key: license.key.slice(0, 16) + '…',
inTrial,
immediate: immediate || inTrial,
effectiveAt,
cancelledModules,
reason,
}),
hash: 'sha256:' + Math.random().toString(36).slice(2),
},
})
return NextResponse.json({
ok: true,
cancelledAt: now,
effectiveAt,
inTrial: !!inTrial,
immediate: immediate || inTrial,
cancelledModules,
message: inTrial
? 'Probezeit-Kündigung — sofort wirksam, keine Abbuchung.'
: `Kündigung eingegangen. Wirksam zum ${effectiveAt.toLocaleDateString('de-DE')} (3-Monate-Frist).`,
})
} catch (err) {
const msg = err instanceof Error ? err.message : 'internal server error'
return NextResponse.json({ error: msg }, { status: 500 })
}
}
@@ -0,0 +1,62 @@
import { NextResponse } from 'next/server'
import { db } from '@/lib/db'
export const dynamic = 'force-dynamic'
// PATCH /api/aos/licenses/[id]/promote
// Body: { environment: "demo" | "production" }
// Reactivates a suspended/cancelled license AND/OR promotes demo -> production.
// PRD §6.4: "reactivate on payment".
export async function PATCH(req: Request, { params }: { params: Promise<{ id: string }> }) {
try {
const { id } = await params
const body = await req.json().catch(() => ({}))
const environment = body.environment === 'demo' ? 'demo' : 'production'
const license = await db.license.findUnique({ where: { id } })
if (!license) {
return NextResponse.json({ error: 'license not found' }, { status: 404 })
}
let validUntil = license.validUntil
if (environment === 'production' && license.environment === 'demo') {
const fourteenDays = 14 * 86400000
if (license.validUntil && license.validUntil.getTime() - license.createdAt.getTime() <= fourteenDays + 86400000) {
validUntil = null
}
} else if (environment === 'demo') {
validUntil = new Date(Date.now() + 14 * 86400000)
}
await db.license.update({
where: { id },
data: {
status: 'active',
environment,
suspendReason: null,
suspendedAt: null,
// Clear cancellation if reactivating
cancelledAt: null,
cancellationEffectiveAt: null,
cancellationReason: null,
validUntil,
},
})
await db.auditLog.create({
data: {
actor: 'admin:ui',
action: 'license.reactivate',
entity: 'License',
entityId: id,
after: JSON.stringify({ key: license.key.slice(0, 16) + '…', environment, status: 'active' }),
hash: 'sha256:' + Math.random().toString(36).slice(2),
},
})
return NextResponse.json({ ok: true, status: 'active', environment })
} catch (err) {
const msg = err instanceof Error ? err.message : 'internal server error'
return NextResponse.json({ error: msg }, { status: 500 })
}
}
@@ -0,0 +1,58 @@
import { NextResponse } from 'next/server'
import { db } from '@/lib/db'
import { z } from 'zod'
export const dynamic = 'force-dynamic'
const schema = z.object({
reason: z.string().min(1).max(300),
})
// PATCH /api/aos/licenses/[id]/suspend
// Suspend (bloke) a license. Transitive suspension of module licenses.
export async function PATCH(req: Request, { params }: { params: Promise<{ id: string }> }) {
try {
const { id } = await params
const parsed = schema.safeParse(await req.json())
if (!parsed.success) {
return NextResponse.json({ error: 'reason required', details: parsed.error.flatten() }, { status: 400 })
}
const { reason } = parsed.data
const license = await db.license.findUnique({ where: { id } })
if (!license) {
return NextResponse.json({ error: 'license not found' }, { status: 404 })
}
const now = new Date()
await db.license.update({
where: { id },
data: { status: 'suspended', suspendReason: reason, suspendedAt: now },
})
let suspendedModules = 0
if (!license.parentId) {
const r = await db.license.updateMany({
where: { parentId: id, status: { not: 'suspended' } },
data: { status: 'suspended', suspendReason: `parent suspended: ${reason}`, suspendedAt: now },
})
suspendedModules = r.count
}
await db.auditLog.create({
data: {
actor: 'admin:ui',
action: 'license.suspend',
entity: 'License',
entityId: id,
after: JSON.stringify({ key: license.key.slice(0, 16) + '…', reason, suspendedModules }),
hash: 'sha256:' + Math.random().toString(36).slice(2),
},
})
return NextResponse.json({ ok: true, status: 'suspended', reason, suspendedModules })
} catch (err) {
const msg = err instanceof Error ? err.message : 'internal server error'
return NextResponse.json({ error: msg }, { status: 500 })
}
}
+213
View File
@@ -0,0 +1,213 @@
import { NextResponse } from 'next/server'
import { db } from '@/lib/db'
import { z } from 'zod'
import { checkRateLimit } from '@/lib/rate-limit'
export const dynamic = 'force-dynamic'
const schema = z.object({
key: z.string().min(1),
module: z.string().optional(),
domain: z.string().optional(),
version: z.string().optional(),
fingerprint: z.string().optional(),
})
// POST /api/aos/licenses/check
// License-server check endpoint for client apps (restov1, aos-cms, ...).
// Returns the full license bundle (core + modules) with feature flags the
// client app should gate, plus billing/trial/cancellation state.
export async function POST(req: Request) {
try {
const rateLimited = checkRateLimit(req, 'license')
if (rateLimited) return rateLimited
const parsed = schema.safeParse(await req.json())
if (!parsed.success) {
return NextResponse.json({ error: 'validation failed', details: parsed.error.flatten() }, { status: 400 })
}
const { key, module: moduleKey, domain, version, fingerprint } = parsed.data
const license = await db.license.findUnique({
where: { key },
include: { activations: true, company: true },
})
if (!license) {
return NextResponse.json({
valid: false,
error: 'license_not_found',
message: 'License key not found',
}, { status: 404 })
}
// If a module is requested, this key must be either the module license itself
// or a core license with a child module license of that module key.
// We type loosely to accommodate core vs. module shape differences (module
// has its own `module` field, core has nested `modules`).
let targetLicense: {
status: string; validUntil: Date | null; trialEndsAt: Date | null;
environment: string; product: string; plan: string; module: string | null;
maxActivations: number; suspendReason: string | null; id: string;
billingCycle: string; priceCents: number; discountPct: number;
netPriceCents: number; cancelledAt: Date | null;
cancellationEffectiveAt: Date | null; cancellationReason: string | null;
noticePeriodDays: number; subscriptionStartsAt: Date | null;
} = license
if (moduleKey) {
if (license.module === moduleKey) {
// direct module license key
} else if (!license.parentId) {
// core license — look up child module (include company for consistency)
const child = await db.license.findFirst({
where: { parentId: license.id, module: moduleKey },
include: { company: true, activations: true },
})
if (!child) {
return NextResponse.json({
valid: false,
error: 'module_not_licensed',
message: `Module "${moduleKey}" is not licensed for this core license`,
core: { key: license.key.slice(0, 16) + '…', product: license.product, status: license.status },
}, { status: 403 })
}
targetLicense = child
} else {
return NextResponse.json({
valid: false,
error: 'module_mismatch',
message: `This key is a module license for "${license.module}", not "${moduleKey}"`,
}, { status: 403 })
}
}
const now = new Date()
// Status computation (with cancellation handling)
let status: 'active' | 'suspended' | 'expired' | 'revoked' | 'cancelled' | 'demo' | 'trial'
const isRevoked = targetLicense.status === 'revoked'
const isExpired =
targetLicense.status === 'expired' ||
(targetLicense.validUntil && targetLicense.validUntil < now)
const isSuspended = targetLicense.status === 'suspended'
const isCancelled = targetLicense.status === 'cancelled' ||
(targetLicense.cancellationEffectiveAt && targetLicense.cancellationEffectiveAt <= now)
const inTrial = targetLicense.trialEndsAt && now < targetLicense.trialEndsAt
const coreBlocked =
!license.parentId &&
(license.status === 'suspended' || license.status === 'expired' ||
license.status === 'revoked' || license.status === 'cancelled')
if (isRevoked) status = 'revoked'
else if (isExpired) status = 'expired'
else if (isCancelled) status = 'cancelled'
else if (isSuspended || (targetLicense !== license && coreBlocked)) status = 'suspended'
else if (inTrial) status = 'trial'
else if (targetLicense.environment === 'demo') status = 'demo'
else status = 'active'
// Granted modules (only for core licenses)
const childModules = !license.parentId
? await db.license.findMany({ where: { parentId: license.id } })
: []
const grantedModules = !license.parentId
? childModules.map((m) => ({
module: m.module,
key: m.key.slice(0, 16) + '…',
status:
m.status === 'revoked' ? 'revoked'
: m.status === 'cancelled' || (m.cancellationEffectiveAt && m.cancellationEffectiveAt <= now) ? 'cancelled'
: m.status === 'expired' || (m.validUntil && m.validUntil < now) ? 'expired'
: m.status === 'suspended' || coreBlocked ? 'suspended'
: m.trialEndsAt && now < m.trialEndsAt ? 'trial'
: m.environment === 'demo' ? 'demo'
: 'active',
environment: m.environment,
validUntil: m.validUntil,
}))
: undefined
// Touch activation if domain matches
if (domain) {
const activation = license.activations.find((a) => a.domain === domain)
if (activation) {
await db.licenseActivation.update({
where: { id: activation.id },
data: {
lastCheck: now,
version: version ?? activation.version,
fingerprint: fingerprint ?? activation.fingerprint,
},
})
}
}
await db.auditLog.create({
data: {
actor: `client:${domain ?? 'unknown'}`,
action: ['active', 'trial', 'demo'].includes(status) ? 'license.check:ok' : 'license.check:blocked',
entity: 'License',
entityId: targetLicense.id,
after: JSON.stringify({
key: key.slice(0, 16) + '…',
module: moduleKey ?? null,
domain,
status,
environment: targetLicense.environment,
}),
hash: 'sha256:' + Math.random().toString(36).slice(2),
},
})
// Feature flags the client app must honor
const validStatuses = ['active', 'trial', 'demo']
const features = {
enabled: status === 'active',
demoMode: status === 'demo',
trialMode: status === 'trial',
watermark: status === 'demo' || status === 'trial',
blocked: ['suspended', 'expired', 'revoked', 'cancelled'].includes(status),
banner:
status === 'suspended' ? 'license_suspended'
: status === 'expired' ? 'license_expired'
: status === 'revoked' ? 'license_revoked'
: status === 'cancelled' ? 'license_cancelled'
: status === 'demo' ? 'license_demo'
: status === 'trial' ? 'license_trial'
: null,
}
// Billing info (so the client portal can show subscription status)
const billing = !license.parentId ? {
billingCycle: license.billingCycle,
priceCents: license.priceCents,
discountPct: license.discountPct,
netPriceCents: license.netPriceCents,
trialEndsAt: license.trialEndsAt,
subscriptionStartsAt: license.subscriptionStartsAt,
cancelledAt: license.cancelledAt,
cancellationEffectiveAt: license.cancellationEffectiveAt,
cancellationReason: license.cancellationReason,
noticePeriodDays: license.noticePeriodDays,
validUntil: license.validUntil,
} : undefined
return NextResponse.json({
valid: validStatuses.includes(status),
status,
environment: targetLicense.environment,
product: targetLicense.product,
plan: targetLicense.plan,
module: targetLicense.module,
validUntil: targetLicense.validUntil,
suspendReason: targetLicense.suspendReason ?? (coreBlocked && targetLicense !== license ? license.suspendReason : null),
maxActivations: targetLicense.maxActivations,
features,
modules: grantedModules,
billing,
checkedAt: now.toISOString(),
})
} catch (err) {
const msg = err instanceof Error ? err.message : 'internal server error'
return NextResponse.json({ error: msg }, { status: 500 })
}
}
+265 -1
View File
@@ -1,8 +1,80 @@
import { NextResponse } from 'next/server'
import { db } from '@/lib/db'
import { z } from 'zod'
export const dynamic = 'force-dynamic'
const PLANS = ['starter', 'pro', 'enterprise'] as const
const ENVIRONMENTS = ['demo', 'production'] as const
const BILLING_CYCLES = ['monthly', 'yearly'] as const
const PLAN_PREFIX: Record<string, string> = { starter: 'ST', pro: 'PRO', enterprise: 'ENT' }
// Default monthly prices per plan (cents). Used when no explicit priceCents given.
const PLAN_PRICES: Record<string, number> = { starter: 2900, pro: 5000, enterprise: 15000 }
// Yearly discount applied on top of any agency discount (10% off yearly total).
const YEARLY_DISCOUNT_PCT = 10
// Default notice period (Kündigung frist): 90 days (3 months, German B2B law).
const DEFAULT_NOTICE_PERIOD_DAYS = 90
// Trial duration: 14 days free.
const TRIAL_DAYS = 14
// Compute effective net price given base monthly price, billing cycle, and
// agency discount pct. Returns the per-cycle price + discount breakdown.
function computePricing(input: {
monthlyPriceCents: number
billingCycle: 'monthly' | 'yearly'
agencyDiscountPct: number
}) {
const { monthlyPriceCents, billingCycle, agencyDiscountPct } = input
const baseCents = billingCycle === 'yearly' ? monthlyPriceCents * 12 : monthlyPriceCents
const yearlyPct = billingCycle === 'yearly' ? YEARLY_DISCOUNT_PCT : 0
const totalDiscountPct = agencyDiscountPct + yearlyPct
const discountAmount = Math.round((baseCents * totalDiscountPct) / 100)
const netPriceCents = baseCents - discountAmount
return {
priceCents: baseCents,
discountPct: totalDiscountPct,
netPriceCents,
breakdown: {
baseCents,
agencyDiscountPct,
yearlyDiscountPct: yearlyPct,
totalDiscountPct,
discountAmount,
netPriceCents,
},
}
}
function generateKey(plan: string, module?: string): string {
const prefix = PLAN_PREFIX[plan] ?? 'ST'
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'
const block = (n: number) =>
Array.from({ length: n }, () => chars[Math.floor(Math.random() * chars.length)]).join('')
const modTag = module ? '-M' : ''
return `AOS-${prefix}${modTag}-${block(4)}-${block(4)}-${block(4)}`
}
async function generateToken(): Promise<string> {
const crypto = await import('crypto')
return 'sig_' + crypto.randomBytes(32).toString('hex')
}
const createSchema = z.object({
companyId: z.string().min(1),
product: z.string().min(1).max(120),
plan: z.enum(PLANS),
environment: z.enum(ENVIRONMENTS).default('production'),
billingCycle: z.enum(BILLING_CYCLES).default('monthly'),
module: z.string().max(60).optional(),
parentId: z.string().optional(),
maxActivations: z.number().int().min(1).max(100).default(1),
validUntil: z.string().optional(),
monthlyPriceCents: z.number().int().min(0).optional(),
startTrial: z.boolean().optional(),
noticePeriodDays: z.number().int().min(0).max(365).optional(),
})
// GET /api/aos/licenses — issued licenses + asset/renewal register
export async function GET() {
try {
@@ -14,10 +86,44 @@ export async function GET() {
db.asset.findMany({ orderBy: { renewalDate: 'asc' } }),
])
// Fetch module licenses separately (avoids relation include issues
// when Prisma client is regenerated mid-process).
const coreIds = licenses.filter((l) => !l.parentId).map((l) => l.id)
const allModules = coreIds.length
? await db.license.findMany({
where: { parentId: { in: coreIds } },
include: { company: true, activations: true },
})
: []
const modulesByParent = new Map<string, typeof allModules>()
for (const m of allModules) {
if (!m.parentId) continue
const arr = modulesByParent.get(m.parentId) ?? []
arr.push(m)
modulesByParent.set(m.parentId, arr)
}
const licensesWithModules = licenses.map((l) => ({
...l,
modules: !l.parentId ? (modulesByParent.get(l.id) ?? []) : [],
}))
const activeLicenses = licenses.filter((l) => l.status === 'active').length
const suspended = licenses.filter((l) => l.status === 'suspended').length
const expired = licenses.filter((l) => l.status === 'expired').length
const cancelled = licenses.filter((l) => l.status === 'cancelled').length
const inTrial = licenses.filter(
(l) => l.trialEndsAt && new Date(l.trialEndsAt) > new Date()
).length
const totalActivations = licenses.reduce((s, l) => s + l.activations.length, 0)
const demoLicenses = licenses.filter((l) => l.environment === 'demo').length
// MRR estimate (sum of net monthly-equivalent of active non-cancelled licenses)
const mrrCents = licenses
.filter((l) => l.status === 'active' && !l.cancelledAt)
.reduce((s, l) => {
const monthly = l.billingCycle === 'yearly' ? Math.round(l.netPriceCents / 12) : l.netPriceCents
return s + monthly
}, 0)
const expiringSoon = assets.filter((a) => {
const days = (a.renewalDate.getTime() - Date.now()) / 86400000
@@ -29,16 +135,20 @@ export async function GET() {
.reduce((s, a) => s + (a.costCentsPerMonth ?? 0), 0)
return NextResponse.json({
licenses,
licenses: licensesWithModules,
assets,
stats: {
activeLicenses,
suspended,
expired,
cancelled,
inTrial,
totalActivations,
expiringSoon: expiringSoon.length,
expiredAssets: expiredAssets.length,
monthlyRebill,
demoLicenses,
mrrCents,
},
})
} catch (err) {
@@ -46,3 +156,157 @@ export async function GET() {
return NextResponse.json({ error: msg }, { status: 500 })
}
}
// POST /api/aos/licenses — issue a new license (core or module add-on)
export async function POST(req: Request) {
try {
const parsed = createSchema.safeParse(await req.json())
if (!parsed.success) {
return NextResponse.json(
{ error: 'validation failed', details: parsed.error.flatten() },
{ status: 400 }
)
}
const {
companyId, product, plan, environment, billingCycle, module: moduleKey,
parentId, maxActivations, validUntil, monthlyPriceCents, startTrial, noticePeriodDays,
} = parsed.data
const company = await db.company.findUnique({ where: { id: companyId } })
if (!company) {
return NextResponse.json({ error: 'company not found' }, { status: 404 })
}
// If parentId given, validate the parent is a core license of same product+company.
if (parentId) {
const parent = await db.license.findUnique({ where: { id: parentId } })
if (!parent) {
return NextResponse.json({ error: 'parent license not found' }, { status: 404 })
}
if (parent.parentId) {
return NextResponse.json({ error: 'parent must be a core license, not a module' }, { status: 400 })
}
if (parent.product !== product) {
return NextResponse.json({ error: 'module product must match parent product' }, { status: 400 })
}
if (parent.companyId !== companyId) {
return NextResponse.json({ error: 'module must belong to the same company as the parent' }, { status: 400 })
}
if (!moduleKey) {
return NextResponse.json({ error: 'module key required for module add-on licenses' }, { status: 400 })
}
}
// Generate unique key (retry on collision)
let key = generateKey(plan, moduleKey)
for (let i = 0; i < 5; i++) {
const existing = await db.license.findUnique({ where: { key } })
if (!existing) break
key = generateKey(plan, moduleKey)
}
const token = await generateToken()
const now = new Date()
// --- Pricing computation ---
// Module licenses inherit the parent's pricing (no separate billing).
const isModule = !!parentId
const monthlyCents = monthlyPriceCents ?? PLAN_PRICES[plan] ?? 0
// Agency discount (only for non-module, agency-owned clients).
// If the company's parentAgency is an approved agency, use its discount.
let agencyDiscountPct = 0
if (!isModule && company.parentAgencyId) {
const agency = await db.company.findUnique({ where: { id: company.parentAgencyId } })
if (agency?.isAgency && agency.agencyStatus === 'approved') {
agencyDiscountPct = agency.agencyDiscountPct ?? 0
}
}
const pricing = isModule
? { priceCents: 0, discountPct: 0, netPriceCents: 0, breakdown: null }
: computePricing({
monthlyPriceCents: monthlyCents,
billingCycle,
agencyDiscountPct,
})
// --- Trial + subscription start ---
// Trial applies to: production core licenses issued to end clients (not modules,
// not demos, not agency-internal). Can be overridden via startTrial param.
const wantsTrial = startTrial ?? (!isModule && environment === 'production')
const trialEndsAt = wantsTrial ? new Date(now.getTime() + TRIAL_DAYS * 86400000) : null
const subscriptionStartsAt = wantsTrial
? new Date(trialEndsAt!.getTime() + 86400000) // day 15
: now
// --- validUntil ---
// - explicit validUntil wins
// - demo: 14 days from now
// - trial + monthly: 1 month from subscription start (first billing cycle)
// - trial + yearly: 1 year from subscription start
// - no trial + monthly: 1 month from now
// - no trial + yearly: 1 year from now
const validUntilDate = validUntil
? new Date(validUntil)
: environment === 'demo'
? new Date(now.getTime() + TRIAL_DAYS * 86400000)
: billingCycle === 'yearly'
? new Date(subscriptionStartsAt.getTime() + 365 * 86400000)
: new Date(subscriptionStartsAt.getTime() + 30 * 86400000)
const license = await db.license.create({
data: {
key,
companyId,
product,
plan,
status: 'active',
environment,
module: moduleKey ?? null,
parentId: parentId ?? null,
maxActivations,
validUntil: validUntilDate,
token,
noticePeriodDays: noticePeriodDays ?? DEFAULT_NOTICE_PERIOD_DAYS,
trialEndsAt,
subscriptionStartsAt,
billingCycle,
priceCents: pricing.priceCents,
discountPct: pricing.discountPct,
netPriceCents: pricing.netPriceCents,
},
include: { company: true, activations: true },
})
// Audit log (GoBD) — include full pricing breakdown
await db.auditLog.create({
data: {
actor: 'admin:ui',
action: 'license.issue',
entity: 'License',
entityId: license.id,
after: JSON.stringify({
key,
product,
plan,
environment,
billingCycle,
module: moduleKey ?? null,
parentId: parentId ?? null,
company: company.name,
agencyDiscountPct,
maxActivations,
trialEndsAt,
subscriptionStartsAt,
validUntil: validUntilDate,
pricing: pricing.breakdown,
}),
hash: 'sha256:' + Math.random().toString(36).slice(2),
},
})
return NextResponse.json({ license, pricing: pricing.breakdown }, { status: 201 })
} catch (err) {
const msg = err instanceof Error ? err.message : 'internal server error'
return NextResponse.json({ error: msg }, { status: 500 })
}
}
View File
File mode changed.
@@ -0,0 +1,45 @@
import { NextResponse } from 'next/server'
import { db } from '@/lib/db'
export const dynamic = 'force-dynamic'
// PATCH /api/aos/tickets/[id]/escalate
// An agency escalates a ticket to us (the platform admin). After escalation,
// the ticket appears in our admin inbox (scope=admin sees escalated tickets).
export async function PATCH(_req: Request, { params }: { params: Promise<{ id: string }> }) {
try {
const { id } = await params
const ticket = await db.ticket.findUnique({ where: { id } })
if (!ticket) {
return NextResponse.json({ error: 'ticket not found' }, { status: 404 })
}
if (ticket.escalatedToAdmin) {
return NextResponse.json({ error: 'already escalated' }, { status: 409 })
}
await db.ticket.update({
where: { id },
data: {
escalatedToAdmin: true,
escalatedAt: new Date(),
status: 'open', // reopen for admin attention
},
})
await db.auditLog.create({
data: {
actor: 'agency:escalate',
action: 'ticket.escalate',
entity: 'Ticket',
entityId: id,
after: JSON.stringify({ number: ticket.number, escalatedAt: new Date() }),
hash: 'sha256:' + Math.random().toString(36).slice(2),
},
})
return NextResponse.json({ ok: true, escalatedToAdmin: true })
} catch (err) {
const msg = err instanceof Error ? err.message : 'internal server error'
return NextResponse.json({ error: msg }, { status: 500 })
}
}
+124 -13
View File
@@ -1,28 +1,38 @@
import { NextResponse } from 'next/server'
import { db } from '@/lib/db'
import { z } from 'zod'
export const dynamic = 'force-dynamic'
// GET /api/aos/tickets — ticket inbox with SLA status
export async function GET() {
// GET /api/aos/tickets — ticket inbox with SLA status + agency routing
export async function GET(req: Request) {
try {
const url = new URL(req.url)
const scope = url.searchParams.get('scope') // 'all' | 'agency' | 'admin'
const agencyId = url.searchParams.get('agencyId') // company id of an agency
const tickets = await db.ticket.findMany({
orderBy: [{ status: 'asc' }, { updatedAt: 'desc' }],
include: { messages: { orderBy: { createdAt: 'asc' } } },
})
const companyIds = [...new Set(tickets.map((t) => t.companyId))]
const companies = await db.company.findMany({ where: { id: { in: companyIds } }, select: { id: true, name: true } })
const companyMap = new Map(companies.map((c) => [c.id, c.name]))
const companies = await db.company.findMany({
where: { id: { in: companyIds } },
select: { id: true, name: true, parentAgencyId: true, isAgency: true, agencyStatus: true },
})
const companyMap = new Map(companies.map((c) => [c.id, c]))
const enriched = tickets.map((t) => {
let enriched = tickets.map((t) => {
const lastMsg = t.messages[t.messages.length - 1]
const ageHours = (Date.now() - t.createdAt.getTime()) / 3600000
const slaBreached = !t.slaRespondedAt && ageHours > t.slaResponseHours
const resolveBreached = !t.slaResolvedAt && ageHours > t.slaResolveHours
const company = companyMap.get(t.companyId)
return {
...t,
companyName: companyMap.get(t.companyId) ?? '—',
companyName: company?.name ?? '—',
companyParentAgencyId: company?.parentAgencyId ?? null,
lastMessage: lastMsg ? { fromName: lastMsg.fromName, fromType: lastMsg.fromType, body: lastMsg.body.slice(0, 120), createdAt: lastMsg.createdAt } : null,
messageCount: t.messages.length,
sla: {
@@ -37,15 +47,36 @@ export async function GET() {
}
})
// --- Agency routing filter ---
// scope=agency + agencyId=X → tickets belonging to this agency's clients
// (NOT escalated to admin), plus tickets where agencyCompanyId === X.
// scope=admin → only tickets that reached us (no agency, or escalated).
if (scope === 'agency' && agencyId) {
enriched = enriched.filter((t) => {
const belongsToAgency = t.companyParentAgencyId === agencyId
const routedToThisAgency = t.agencyCompanyId === agencyId
const escalatedAway = t.escalatedToAdmin
return (belongsToAgency || routedToThisAgency) && !escalatedAway
})
} else if (scope === 'admin') {
// Admin sees: direct clients (no parent agency) + escalated tickets.
enriched = enriched.filter((t) => {
const isDirectClient = !t.companyParentAgencyId
const escalated = t.escalatedToAdmin
return isDirectClient || escalated
})
}
const stats = {
total: tickets.length,
open: tickets.filter((t) => t.status === 'open').length,
pending: tickets.filter((t) => t.status === 'pending').length,
resolved: tickets.filter((t) => t.status === 'resolved').length,
closed: tickets.filter((t) => t.status === 'closed').length,
urgent: tickets.filter((t) => t.priority === 'urgent' && t.status !== 'closed' && t.status !== 'resolved').length,
total: enriched.length,
open: enriched.filter((t) => t.status === 'open').length,
pending: enriched.filter((t) => t.status === 'pending').length,
resolved: enriched.filter((t) => t.status === 'resolved').length,
closed: enriched.filter((t) => t.status === 'closed').length,
urgent: enriched.filter((t) => t.priority === 'urgent' && t.status !== 'closed' && t.status !== 'resolved').length,
slaBreached: enriched.filter((t) => t.sla.responseBreached || t.sla.resolveBreached).length,
unresponded: tickets.filter((t) => !t.slaRespondedAt && t.status === 'open').length,
unresponded: enriched.filter((t) => !t.slaRespondedAt && t.status === 'open').length,
escalated: enriched.filter((t) => t.escalatedToAdmin).length,
}
return NextResponse.json({ tickets: enriched, stats })
@@ -54,3 +85,83 @@ export async function GET() {
return NextResponse.json({ error: msg }, { status: 500 })
}
}
const createSchema = z.object({
companyId: z.string().min(1),
subject: z.string().min(1).max(200),
categoryId: z.string().min(1),
priority: z.enum(['low', 'normal', 'high', 'urgent']).default('normal'),
projectId: z.string().optional(),
licenseId: z.string().optional(),
message: z.string().min(1).max(8000),
fromName: z.string().min(1).max(100),
})
// POST /api/aos/tickets
// Create a support ticket. Auto-routes to the client's agency if the client
// belongs to one (routedToAgency=true); otherwise reaches admin directly.
export async function POST(req: Request) {
try {
const parsed = createSchema.safeParse(await req.json())
if (!parsed.success) {
return NextResponse.json({ error: 'validation failed', details: parsed.error.flatten() }, { status: 400 })
}
const { companyId, subject, categoryId, priority, projectId, licenseId, message, fromName } = parsed.data
const company = await db.company.findUnique({ where: { id: companyId } })
if (!company) {
return NextResponse.json({ error: 'company not found' }, { status: 404 })
}
// Determine routing: if the client belongs to an approved agency, route
// to that agency (routedToAgency=true, agencyCompanyId=parentAgencyId).
// Otherwise the ticket reaches us directly.
const agencyId = company.parentAgencyId
const routedToAgency = !!agencyId
// Gapless ticket number
const count = await db.ticket.count()
const year = new Date().getFullYear()
const number = `TKT-${year}-${String(count + 1).padStart(4, '0')}`
const ticket = await db.ticket.create({
data: {
number,
subject,
status: 'open',
priority,
categoryId,
companyId,
projectId: projectId ?? null,
licenseId: licenseId ?? null,
agencyCompanyId: agencyId,
routedToAgency,
escalatedToAdmin: false,
messages: {
create: {
fromType: 'customer',
fromName,
body: message,
},
},
},
include: { messages: true },
})
await db.auditLog.create({
data: {
actor: `client:${company.name}`,
action: routedToAgency ? 'ticket.create:routed-to-agency' : 'ticket.create:direct',
entity: 'Ticket',
entityId: ticket.id,
after: JSON.stringify({ number, subject, companyId, routedToAgency, agencyId }),
hash: 'sha256:' + Math.random().toString(36).slice(2),
},
})
return NextResponse.json({ ticket }, { status: 201 })
} catch (err) {
const msg = err instanceof Error ? err.message : 'internal server error'
return NextResponse.json({ error: msg }, { status: 500 })
}
}