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:
1 parent
969202764a
commit
fa8dd46571
175 files changed
+24741
-2
No files matched your search
Executable
+75
@@ -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
@@ -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
|
||||
Executable
+56
@@ -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`
|
||||
}
|
||||
Executable
+180
@@ -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
|
||||
}
|
||||
Executable
+1052
File diff suppressed because it is too large.
Load diff
Executable
+6
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
Executable
+57
@@ -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() },
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user