refactor: professional code quality audit

- Fix all TypeScript compilation errors (0 errors in src/)
- Add try/catch error handling to ALL API routes (38 handlers)
- Fix empty catch blocks (api-auth.ts now logs warnings)
- Add aria-labels to all icon-only buttons (WCAG 2.1 AA)
- Fix Prisma type mismatches (OR filter, Person.companyId, Estimate.projectId, Ticket.createdAt)
- Fix webhook PATCH route (moved to [id]/route.ts)
- Fix mobile-bottom-nav type safety
- Fix KB route where-clause type
- Fix test-mail banner null vs undefined
- Fix task comment fetch: add cancellation + error toast
- ESLint: 0 errors, 0 warnings
- 175 files, 24.741 lines of code
This commit is contained in:
Z User committed 2026-10-27 12:23:32 +00:00
1 parent 969202764a
commit fa8dd46571
175 files changed
+24741 -2

No files matched your search

+75
View File
@@ -0,0 +1,75 @@
import { db } from '@/lib/db'
import { createHash } from 'crypto'
// AOS Public API authentication + authorization.
// Tokens are SHA-256 hashed at rest (never stored plaintext).
export interface AuthResult {
ok: boolean
token?: { id: string; name: string; scopes: string[] }
error?: string
status?: number
}
// Verify a Bearer token from the Authorization header.
// Returns the token record if valid + not revoked + not expired.
export async function verifyToken(authHeader: string | null): Promise<AuthResult> {
if (!authHeader) {
return { ok: false, error: 'Authorization header required', status: 401 }
}
const match = authHeader.match(/^Bearer\s+(aos_[a-z0-9]+)$/i)
if (!match) {
return { ok: false, error: 'Invalid token format (expected: Bearer aos_…)', status: 401 }
}
const token = match[1]!
// Hash the token to look it up (constant-time comparison via DB unique index)
const tokenHash = createHash('sha256').update(token).digest('hex')
const record = await db.apiToken.findUnique({ where: { tokenHash } })
if (!record) {
return { ok: false, error: 'Invalid or unknown token', status: 401 }
}
if (record.revokedAt) {
return { ok: false, error: 'Token revoked', status: 401 }
}
if (record.expiresAt && record.expiresAt < new Date()) {
return { ok: false, error: 'Token expired', status: 401 }
}
// update lastUsedAt (fire-and-forget)
db.apiToken.update({ where: { id: record.id }, data: { lastUsedAt: new Date() } }).catch((err) => console.warn('Failed to update lastUsedAt:', err))
const scopes: string[] = JSON.parse(record.scopes)
return { ok: true, token: { id: record.id, name: record.name, scopes } }
}
// Check if a token has a specific scope (resource:action).
// Supports wildcard: "crm.*:read" matches "crm.company:read".
export function hasScope(token: AuthResult['token'], required: string): boolean {
if (!token) return false
return token.scopes.some((s) => {
if (s === required) return true
// wildcard: "crm.*:read" matches any crm resource read
const [res, action] = s.split(':')
if (res && res.endsWith('.*')) {
const prefix = res.slice(0, -2)
return required.startsWith(prefix + '.') && required.endsWith(':' + action)
}
return false
})
}
// Require a scope or return a 403 response.
export function requireScope(auth: AuthResult, scope: string): { allowed: boolean; error?: Response } {
if (!auth.ok || !auth.token) {
return {
allowed: false,
error: Response.json({ error: auth.error ?? 'unauthorized' }, { status: auth.status ?? 401 }),
}
}
if (!hasScope(auth.token, scope)) {
return {
allowed: false,
error: Response.json({ error: `insufficient scope — requires "${scope}"`, token: auth.token.name, scopes: auth.token.scopes }, { status: 403 }),
}
}
return { allowed: true }
}
Executable
+13
View File
@@ -0,0 +1,13 @@
import { PrismaClient } from '@prisma/client'
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined
}
export const db =
globalForPrisma.prisma ??
new PrismaClient({
log: ['error', 'warn'],
})
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = db
+56
View File
@@ -0,0 +1,56 @@
// German formatting helpers — money in integer cents, dates in de-DE
export function formatEuro(cents: number, opts?: { sign?: boolean }): string {
const sign = opts?.sign && cents > 0 ? '+' : ''
const neg = cents < 0 ? '−' : ''
const abs = Math.abs(cents) / 100
return (
sign +
neg +
abs.toLocaleString('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) +
' €'
)
}
export function formatEuroCompact(cents: number): string {
const abs = Math.abs(cents)
const neg = cents < 0 ? '−' : ''
if (abs >= 1_000_000) return `${neg}${(abs / 1_000_000 / 100).toLocaleString('de-DE', { maximumFractionDigits: 1 })} M€`
if (abs >= 10_000) return `${neg}${(abs / 1_000 / 100).toLocaleString('de-DE', { maximumFractionDigits: 1 })} k€`
return formatEuro(cents)
}
export function formatDate(d: Date | string, opts?: Intl.DateTimeFormatOptions): string {
const date = typeof d === 'string' ? new Date(d) : d
return date.toLocaleDateString('de-DE', opts ?? { day: '2-digit', month: '2-digit', year: 'numeric' })
}
export function formatDateTime(d: Date | string): string {
const date = typeof d === 'string' ? new Date(d) : d
return date.toLocaleString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' })
}
export function formatRelative(d: Date | string): string {
const date = typeof d === 'string' ? new Date(d) : d
const diff = date.getTime() - Date.now()
const days = Math.round(diff / 86400000)
const rtf = new Intl.RelativeTimeFormat('de-DE', { numeric: 'auto' })
if (Math.abs(days) >= 1) return rtf.format(days, 'day')
const hours = Math.round(diff / 3600000)
if (Math.abs(hours) >= 1) return rtf.format(hours, 'hour')
const mins = Math.round(diff / 60000)
return rtf.format(mins, 'minute')
}
export function daysUntil(d: Date | string): number {
const date = typeof d === 'string' ? new Date(d) : d
return Math.round((date.getTime() - Date.now()) / 86400000)
}
export function formatMinutes(min: number): string {
const h = Math.floor(min / 60)
const m = min % 60
if (h === 0) return `${m} min`
if (m === 0) return `${h} h`
return `${h} h ${m} min`
}
+180
View File
@@ -0,0 +1,180 @@
// AOS i18n — German (default), English, Turkish (PRD §5.5)
// All UI strings keyed, no literals in components.
export type Locale = 'de' | 'en' | 'tr'
export const LOCALES: { id: Locale; label: string; flag: string }[] = [
{ id: 'de', label: 'Deutsch', flag: '🇩🇪' },
{ id: 'en', label: 'English', flag: '🇬🇧' },
{ id: 'tr', label: 'Türkçe', flag: '🇹🇷' },
]
type Dict = Record<string, string>
const de: Dict = {
// Nav groups
'nav.overview': 'Übersicht',
'nav.sales': 'Vertrieb',
'nav.delivery': 'Lieferung',
'nav.operations': 'Betrieb',
'nav.system': 'System',
// Nav items
'nav.dashboard': 'Dashboard',
'nav.calendar': 'Fristen',
'nav.reporting': 'Reporting',
'nav.crm': 'CRM',
'nav.leads': 'Leads Finder',
'nav.estimates': 'Angebote',
'nav.projects': 'Projekte',
'nav.support': 'Support',
'nav.licenses': 'Lizenzen',
'nav.contracts': 'Verträge',
'nav.expenses': 'Ausgaben',
'nav.finance': 'Finanzen',
'nav.accounting': 'Buchhaltung',
'nav.audit': 'Audit-Protokoll',
'nav.kb': 'Wissensdatenbank',
'nav.api': 'API & Webhooks',
'nav.settings': 'Erweiterungen',
'nav.setup': 'Setup-Wizard',
'nav.settings_global': 'Einstellungen',
// Common
'common.search': 'Suchen…',
'common.new': 'Neu',
'common.save': 'Speichern',
'common.cancel': 'Abbrechen',
'common.close': 'Schließen',
'common.all': 'Alle',
'common.status': 'Status',
'common.actions': 'Aktion',
'common.date': 'Datum',
'common.amount': 'Betrag',
'common.customer': 'Kunde',
'common.loading': 'Lädt…',
// KPIs
'kpi.pipelineValue': 'Offene Pipeline',
'kpi.wonDeals': 'Gewonnene Deals',
'kpi.pendingLeads': 'Leads in Review',
'kpi.openProjects': 'Aktive Projekte',
'kpi.activeLicenses': 'Aktive Lizenzen',
'kpi.outstanding': 'Offene Forderungen',
'kpi.team': 'Team',
// Footer
'footer.compliance': 'GoBD / DSGVO',
'footer.selfHosted': 'self-hosted',
'footer.version': 'Version',
// Portal
'portal.title': 'Client-Portal',
'portal.selectCompany': 'Wähle dein Unternehmen',
'portal.isolated': 'projektbezogener Zugriff',
}
const en: Dict = {
'nav.overview': 'Overview',
'nav.sales': 'Sales',
'nav.delivery': 'Delivery',
'nav.operations': 'Operations',
'nav.system': 'System',
'nav.dashboard': 'Dashboard',
'nav.calendar': 'Fristen',
'nav.reporting': 'Reporting',
'nav.crm': 'CRM',
'nav.leads': 'Leads Finder',
'nav.estimates': 'Estimates',
'nav.projects': 'Projects',
'nav.support': 'Support',
'nav.licenses': 'Licenses',
'nav.contracts': 'Contracts',
'nav.expenses': 'Expenses',
'nav.finance': 'Finance',
'nav.accounting': 'Accounting',
'nav.audit': 'Audit Log',
'nav.kb': 'Knowledge Base',
'nav.api': 'API & Webhooks',
'nav.settings': 'Extensions',
'nav.setup': 'Setup Wizard',
'nav.settings_global': 'Settings',
'common.search': 'Search…',
'common.new': 'New',
'common.save': 'Save',
'common.cancel': 'Cancel',
'common.close': 'Close',
'common.all': 'All',
'common.status': 'Status',
'common.actions': 'Action',
'common.date': 'Date',
'common.amount': 'Amount',
'common.customer': 'Customer',
'common.loading': 'Loading…',
'kpi.pipelineValue': 'Open Pipeline',
'kpi.wonDeals': 'Won Deals',
'kpi.pendingLeads': 'Leads in Review',
'kpi.openProjects': 'Active Projects',
'kpi.activeLicenses': 'Active Licenses',
'kpi.outstanding': 'Outstanding',
'kpi.team': 'Team',
'footer.compliance': 'GoBD / GDPR',
'footer.selfHosted': 'self-hosted',
'footer.version': 'Version',
'portal.title': 'Client Portal',
'portal.selectCompany': 'Select your company',
'portal.isolated': 'project-scoped access',
}
const tr: Dict = {
'nav.overview': 'Genel Bakış',
'nav.sales': 'Satış',
'nav.delivery': 'Teslimat',
'nav.operations': 'Operasyon',
'nav.system': 'Sistem',
'nav.dashboard': 'Panel',
'nav.calendar': 'Son Tarihler',
'nav.reporting': 'Raporlama',
'nav.crm': 'CRM',
'nav.leads': 'Müşteri Adayı Bulucu',
'nav.estimates': 'Teklifler',
'nav.projects': 'Projeler',
'nav.support': 'Destek',
'nav.licenses': 'Lisanslar',
'nav.contracts': 'Sözleşmeler',
'nav.expenses': 'Giderler',
'nav.finance': 'Finans',
'nav.accounting': 'Muhasebe',
'nav.audit': 'Denetim Günlüğü',
'nav.kb': 'Bilgi Bankası',
'nav.api': 'API & Webhook',
'nav.settings': 'Eklentiler',
'nav.setup': 'Kurulum Sihirbazı',
'nav.settings_global': 'Ayarlar',
'common.search': 'Ara…',
'common.new': 'Yeni',
'common.save': 'Kaydet',
'common.cancel': 'İptal',
'common.close': 'Kapat',
'common.all': 'Tümü',
'common.status': 'Durum',
'common.actions': 'İşlem',
'common.date': 'Tarih',
'common.amount': 'Tutar',
'common.customer': 'Müşteri',
'common.loading': 'Yükleniyor…',
'kpi.pipelineValue': 'Açık Pipeline',
'kpi.wonDeals': 'Kazanılan Anlaşmalar',
'kpi.pendingLeads': 'İncelemede Adaylar',
'kpi.openProjects': 'Aktif Projeler',
'kpi.activeLicenses': 'Aktif Lisanslar',
'kpi.outstanding': 'Açık Alacaklar',
'kpi.team': 'Ekip',
'footer.compliance': 'GoBD / KVKK',
'footer.selfHosted': 'self-hosted',
'footer.version': 'Sürüm',
'portal.title': 'Müşteri Portalı',
'portal.selectCompany': 'Şirketinizi seçin',
'portal.isolated': 'proje kapsamlı erişim',
}
const DICTS: Record<Locale, Dict> = { de, en, tr }
export function translate(key: string, locale: Locale): string {
return DICTS[locale]?.[key] ?? DICTS.de[key] ?? key
}
+1052
View File
File diff suppressed because it is too large. Load diff
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
+57
View File
@@ -0,0 +1,57 @@
import { db } from '@/lib/db'
import { createHmac } from 'crypto'
// Deliver a webhook event to all registered endpoints that subscribe to it.
// Each delivery is signed with HMAC-SHA256 using the webhook's secret so the
// receiver can verify authenticity.
export async function dispatchWebhook(event: string, payload: unknown): Promise<void> {
const webhooks = await db.webhook.findMany({ where: { active: true } })
const matching = webhooks.filter((w) => {
const events: string[] = JSON.parse(w.events)
return events.includes(event) || events.includes('*')
})
// Fire-and-forget: each delivery is independent
for (const hook of matching) {
void deliver(hook, event, payload)
}
}
async function deliver(
hook: { id: string; url: string; secret: string },
event: string,
payload: unknown
): Promise<void> {
const body = JSON.stringify({ event, deliveredAt: new Date().toISOString(), data: payload })
const timestamp = Math.floor(Date.now() / 1000)
const signatureBase = `${timestamp}.${body}`
const signature = createHmac('sha256', hook.secret).update(signatureBase).digest('hex')
try {
const res = await fetch(hook.url, {
method: 'POST',
headers: {
'content-type': 'application/json',
'x-aos-event': event,
'x-aos-timestamp': String(timestamp),
'x-aos-signature': `sha256=${signature}`,
},
body,
signal: AbortSignal.timeout(10000),
})
await db.webhook.update({
where: { id: hook.id },
data: {
sentCount: { increment: 1 },
lastSentAt: new Date(),
lastStatus: res.status,
failCount: res.ok ? undefined : { increment: 1 },
},
})
} catch {
await db.webhook.update({
where: { id: hook.id },
data: { sentCount: { increment: 1 }, failCount: { increment: 1 }, lastSentAt: new Date() },
})
}
}