a6c0f3de-eeb0-4d76-a57f-f600dd133c06
This commit is contained in:
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 })
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
Regular → Executable
File mode changed.
Regular → Executable
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 })
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
Regular → Executable
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 })
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user