Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 29 additions & 5 deletions apps/web/api/_lib/report.ts
Original file line number Diff line number Diff line change
Expand Up @@ -345,6 +345,29 @@ export async function generateExcel(
return Buffer.from(buf)
}

// ─── Recipients ──────────────────────────────────────────────────────────────

// Reads report_recipients from the settings table; falls back to ADMIN_EMAIL.
export async function fetchRecipients(): Promise<string[]> {
try {
const supabase = makeSupabase()
const { data } = await supabase
.from('admin_settings')
.select('value')
.eq('key', 'report_recipients')
.single()
const dbValue = data?.value?.trim() ?? ''
if (dbValue) {
return dbValue.split(',').map(e => e.trim()).filter(e => e.length > 0)
}
} catch {
// fall through to env-var fallback
}
const adminEmail = process.env.ADMIN_EMAIL
if (!adminEmail) throw new Error('Missing ADMIN_EMAIL and no recipients configured in settings')
return [adminEmail]
}

// ─── Email ────────────────────────────────────────────────────────────────────

export async function sendEmail(
Expand All @@ -354,11 +377,11 @@ export async function sendEmail(
transactions: EnrichedTransaction[],
monthLabel: string,
reportMonth: string,
recipients: string[],
): Promise<void> {
const resendKey = process.env.RESEND_API_KEY
const adminEmail = process.env.ADMIN_EMAIL
if (!resendKey) throw new Error('Missing RESEND_API_KEY')
if (!adminEmail) throw new Error('Missing ADMIN_EMAIL')
if (recipients.length === 0) throw new Error('No report recipients configured')

const resend = new Resend(resendKey)
const totalCents = transactions.reduce((s, t) => s + t.total_cents, 0)
Expand Down Expand Up @@ -436,7 +459,7 @@ export async function sendEmail(

await resend.emails.send({
from: 'Kaffeelisten <onboarding@resend.dev>',
to: [adminEmail],
to: recipients,
subject: `Kaffeelisten – Monatsbericht ${monthName} ${yearStr}`,
html,
attachments: [
Expand Down Expand Up @@ -545,11 +568,12 @@ export async function deactivateInactiveMembers(): Promise<void> {
export async function runMonthlyReport(forMonth?: string): Promise<void> {
const { transactions, reportMonth, monthLabel } = await fetchAndEnrich(forMonth)
const summaries = computeSummary(transactions)
const [pdfBuffer, xlsxBuffer] = await Promise.all([
const [pdfBuffer, xlsxBuffer, recipients] = await Promise.all([
generatePdf(summaries, transactions, monthLabel, reportMonth),
generateExcel(summaries, transactions),
fetchRecipients(),
])
await sendEmail(pdfBuffer, xlsxBuffer, summaries, transactions, monthLabel, reportMonth)
await sendEmail(pdfBuffer, xlsxBuffer, summaries, transactions, monthLabel, reportMonth, recipients)
await archiveTransactions(transactions, reportMonth)
await pruneOldTransactions()
await deactivateInactiveMembers()
Expand Down
25 changes: 25 additions & 0 deletions apps/web/api/admin/archive.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import type { VercelRequest, VercelResponse } from '@vercel/node'
import { fetchAndEnrich, archiveTransactions, pruneOldTransactions } from '../_lib/report'

export const config = { maxDuration: 60 }

export default async function handler(req: VercelRequest, res: VercelResponse) {
if (req.method !== 'POST') return res.status(405).json({ error: 'Method not allowed' })

const adminPin = process.env.ADMIN_PIN
if (!adminPin || req.headers['x-admin-pin'] !== adminPin) {
return res.status(401).json({ error: 'Unauthorized' })
}

try {
const { month } = (req.body ?? {}) as { month?: string }
const { transactions, reportMonth } = await fetchAndEnrich(month)
await archiveTransactions(transactions, reportMonth)
await pruneOldTransactions()
return res.status(200).json({ ok: true, archivedCount: transactions.length, reportMonth })
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error'
console.error('[admin/archive]', message)
return res.status(500).json({ error: message })
}
}
50 changes: 50 additions & 0 deletions apps/web/api/admin/change-pin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import type { VercelRequest, VercelResponse } from '@vercel/node'
import { createClient, type SupabaseClient } from '@supabase/supabase-js'

function makeSupabase() {
const url = process.env.VITE_SUPABASE_URL
const key = process.env.SUPABASE_SERVICE_ROLE_KEY
if (!url || !key) throw new Error('Missing Supabase credentials')
return createClient(url, key)
}

async function getEffectivePin(supabase: SupabaseClient): Promise<string | null> {
try {
const { data } = await supabase
.from('admin_settings')
.select('value')
.eq('key', 'admin_pin')
.single()
const dbPin = data?.value?.trim() ?? ''
return dbPin || (process.env.ADMIN_PIN ?? null)
} catch {
return process.env.ADMIN_PIN ?? null
}
}

export default async function handler(req: VercelRequest, res: VercelResponse) {
if (req.method !== 'POST') return res.status(405).end()

const { currentPin, newPin } = (req.body ?? {}) as { currentPin?: string; newPin?: string }
if (typeof currentPin !== 'string' || typeof newPin !== 'string') {
return res.status(400).json({ error: 'currentPin and newPin required' })
}
if (!/^\d{4}$/.test(newPin)) {
return res.status(400).json({ error: 'PIN must be exactly 4 digits' })
}

const supabase = makeSupabase()
const effectivePin = await getEffectivePin(supabase)

if (!effectivePin || currentPin !== effectivePin) {
return res.status(403).json({ error: 'Aktuelle PIN ist falsch' })
}

const { error } = await supabase
.from('admin_settings')
.upsert({ key: 'admin_pin', value: newPin, updated_at: new Date().toISOString() }, { onConflict: 'key' })

if (error) return res.status(500).json({ error: error.message })

return res.status(200).json({ ok: true })
}
48 changes: 48 additions & 0 deletions apps/web/api/admin/settings.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import type { VercelRequest, VercelResponse } from '@vercel/node'
import { createClient } from '@supabase/supabase-js'

function makeSupabase() {
const url = process.env.VITE_SUPABASE_URL
const key = process.env.SUPABASE_SERVICE_ROLE_KEY
if (!url || !key) throw new Error('Missing VITE_SUPABASE_URL or SUPABASE_SERVICE_ROLE_KEY')
return createClient(url, key)
}

function pinOk(req: VercelRequest): boolean {
const adminPin = process.env.ADMIN_PIN
return Boolean(adminPin) && req.headers['x-admin-pin'] === adminPin
}

export default async function handler(req: VercelRequest, res: VercelResponse) {
if (!pinOk(req)) return res.status(401).json({ error: 'Unauthorized' })

const supabase = makeSupabase()

// GET /api/admin/settings?key=<key>
if (req.method === 'GET') {
const { key } = req.query
if (typeof key !== 'string') return res.status(400).json({ error: 'Missing key' })
const { data, error } = await supabase
.from('admin_settings')
.select('value, updated_at')
.eq('key', key)
.single()
if (error) return res.status(500).json({ error: error.message })
return res.status(200).json({ key, value: data?.value ?? '', updated_at: data?.updated_at ?? null })
}

// POST /api/admin/settings body: { key, value }
if (req.method === 'POST') {
const { key, value } = (req.body ?? {}) as { key?: string; value?: string }
if (typeof key !== 'string' || typeof value !== 'string') {
return res.status(400).json({ error: 'Missing key or value' })
}
const { error } = await supabase
.from('admin_settings')
.upsert({ key, value, updated_at: new Date().toISOString() }, { onConflict: 'key' })
if (error) return res.status(500).json({ error: error.message })
return res.status(200).json({ ok: true })
}

return res.status(405).json({ error: 'Method not allowed' })
}
32 changes: 26 additions & 6 deletions apps/web/api/admin/verify-pin.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,38 @@
import type { VercelRequest, VercelResponse } from '@vercel/node'
import { createClient } from '@supabase/supabase-js'

export default function handler(req: VercelRequest, res: VercelResponse) {
if (req.method !== 'POST') {
return res.status(405).end()
// DB admin_pin takes precedence; falls back to ADMIN_PIN env var so existing
// deployments keep working without any manual migration step.
async function getEffectivePin(): Promise<string | null> {
const url = process.env.VITE_SUPABASE_URL
const key = process.env.SUPABASE_SERVICE_ROLE_KEY
if (!url || !key) return process.env.ADMIN_PIN ?? null

try {
const supabase = createClient(url, key)
const { data } = await supabase
.from('admin_settings')
.select('value')
.eq('key', 'admin_pin')
.single()
const dbPin = data?.value?.trim() ?? ''
return dbPin || (process.env.ADMIN_PIN ?? null)
} catch {
return process.env.ADMIN_PIN ?? null
}
}

export default async function handler(req: VercelRequest, res: VercelResponse) {
if (req.method !== 'POST') return res.status(405).end()

const { pin } = req.body as { pin?: string }
const adminPin = process.env.ADMIN_PIN
const effectivePin = await getEffectivePin()

if (!adminPin) {
if (!effectivePin) {
return res.status(500).json({ error: 'ADMIN_PIN not configured' })
}

if (typeof pin === 'string' && pin === adminPin) {
if (typeof pin === 'string' && pin === effectivePin) {
return res.status(200).json({ ok: true })
}

Expand Down
21 changes: 21 additions & 0 deletions apps/web/src/lib/database.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,27 @@ export type Database = {
}
]
}
admin_settings: {
Row: {
id: string
key: string
value: string
updated_at: string
}
Insert: {
id?: string
key: string
value?: string
updated_at?: string
}
Update: {
id?: string
key?: string
value?: string
updated_at?: string
}
Relationships: []
}
transactions_archive: {
Row: {
id: string
Expand Down
12 changes: 2 additions & 10 deletions apps/web/src/pages/AdminDashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import AdminIcon from '../components/admin/AdminIcon'
import ItemsPage from './admin/ItemsPage'
import CompaniesPage from './admin/CompaniesPage'
import MembersPage from './admin/MembersPage'
import SettingsPage from './admin/SettingsPage'

type PageId = 'dashboard' | 'log' | 'companies' | 'members' | 'items' | 'settings'

Expand Down Expand Up @@ -467,16 +468,7 @@ export default function AdminDashboard() {

{/* ── Settings ── */}
{activePage === 'settings' && (
<>
<Topbar title="Einstellungen" onMenuClick={() => setSidebarOpen(true)} />
<div className="p-4 md:p-8">
<DataTable
columns={[{ key: 'k', label: '' }]}
rows={[]}
empty={{ title: 'Einstellungen', body: 'Konfiguration folgt in einer späteren Version.' }}
/>
</div>
</>
<SettingsPage onToast={showToast} onMenuClick={() => setSidebarOpen(true)} />
)}
</main>

Expand Down
Loading
Loading