Compare commits
2 Commits
32c2842dbd
...
305af64fd6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
305af64fd6 | ||
|
|
72852a6b08 |
144
funktionen.md
Normal file
144
funktionen.md
Normal file
@@ -0,0 +1,144 @@
|
||||
# Projekt-Dokumentation: CASPOS Webshop
|
||||
|
||||
Diese Datei beschreibt die Architektur, das Datenmodell, die Kernprozesse und das Sicherheitskonzept des CASPOS Webshops.
|
||||
|
||||
---
|
||||
|
||||
## 1. Projekt-Überblick
|
||||
|
||||
Der CASPOS Webshop ist ein B2B-Portal für Vertriebspartner. Partner können hier Software-Pakete und Zusatzmodule (für POS/Kassenlösungen) konfigurieren, Anfragen für ihre Endkunden erstellen und Lizenzen verwalten.
|
||||
|
||||
---
|
||||
|
||||
## 2. Technologie-Stack
|
||||
|
||||
- **Frontend/Backend**: Next.js 15+ (App Router, React Server Components, Server Actions).
|
||||
- **Styling**: Tailwind CSS & Vanilla CSS (modernes Dark-Theme).
|
||||
- **Datenbank & Auth**: Supabase (PostgreSQL, Row Level Security - RLS, Supabase Auth).
|
||||
- **E-Mail-Versand**: SMTP-Integration für automatisierte Bestätigungen.
|
||||
- **PDF-Generierung**: `@react-pdf/renderer` zur Erzeugung von Anfragebestätigungen als PDF.
|
||||
- **Storage**: Supabase Storage (`invoices` Bucket) zur Archivierung der PDF-Dokumente.
|
||||
|
||||
---
|
||||
|
||||
## 3. Datenmodell & Beziehungen (Schema)
|
||||
|
||||
Das Datenbankschema besteht aus folgenden Tabellen im Schema `public`:
|
||||
|
||||
### `companies` (Unternehmen)
|
||||
- Repräsentiert die Partner-Unternehmen (Retailer).
|
||||
- `id` (UUID, Primary Key)
|
||||
- `name` (TEXT)
|
||||
- `street`, `zip`, `city`, `email` (Adressdaten)
|
||||
|
||||
### `users` (Systembenutzer)
|
||||
- Erweitert die Authentifizierungsdaten aus `auth.users`.
|
||||
- `id` (UUID, References `auth.users(id)`)
|
||||
- `role` (TEXT, standardmäßig `'partner'`, oder `'admin'`)
|
||||
- `company_id` (UUID, References `public.companies(id)`)
|
||||
|
||||
### `profiles` (Benutzerprofile)
|
||||
- Stammdaten der einzelnen Benutzer (Ansprechpartner).
|
||||
- `id` (UUID, References `auth.users(id)`)
|
||||
- `first_name`, `last_name`, `email` etc.
|
||||
|
||||
### `end_customers` (Endkunden)
|
||||
- Die Endkunden, für die die Partner Lizenzen bestellen.
|
||||
- `id` (UUID, Primary Key)
|
||||
- `partner_id` (UUID, References `public.companies(id)`) <-- *Direkte Zuordnung zur Firma des Partners*
|
||||
- `company_name` (TEXT)
|
||||
- `first_name`, `last_name`, `street`, `zip`, `city`, `email` (Stammdaten)
|
||||
- `bank_iban`, `bank_bic`, `bank_name`, `bank_owner` (Bankdaten)
|
||||
- `is_anonymized` (BOOLEAN) <-- *Für DSGVO-Löschung*
|
||||
|
||||
### `products` (Katalogprodukte)
|
||||
- Hauptlösungen (z.B. Basic-Kasse, Backoffice).
|
||||
- `id`, `name`, `base_price`, `tax_rate`, `billing_interval` (`one_time` / `monthly`).
|
||||
- `requirements` (UUID[] von anderen Produkten)
|
||||
- `exclusions` (UUID[] von inkompatiblen Produkten)
|
||||
|
||||
### `product_modules` (Zusatzmodule)
|
||||
- Optionale Erweiterungen für Produkte.
|
||||
- `id`, `product_id` (References `products`), `name`, `price`, `has_quantity` (BOOLEAN).
|
||||
- `requirements` (UUID[] von Modulen)
|
||||
- `exclusions` (UUID[] von Modulen)
|
||||
|
||||
### `orders` (Bestellungen / Anfragen)
|
||||
- Gespeicherte Snapshots von Konfigurationen.
|
||||
- `id` (UUID, Primary Key)
|
||||
- `user_id` (UUID, References `auth.users(id)`) <-- *Ersteller der Bestellung*
|
||||
- `order_number` (TEXT, Format: `AE-YYYY-NNNNN`)
|
||||
- `order_hash` (TEXT, Idempotenz-Guard gegen Doppelübermittlung)
|
||||
- `end_customer_id` (UUID, References `end_customers(id)`)
|
||||
- `total_price` (DECIMAL)
|
||||
- `customer_data` (JSONB) <-- *Eingefrorener Snapshot des Endkunden*
|
||||
- `order_data` (JSONB) <-- *Eingefrorener Snapshot der Produktkonfiguration*
|
||||
- `pdf_url` (TEXT, Link zur PDF-Rechnung im Storage)
|
||||
- `status` (TEXT: `'pending'`, `'active'`, `'completed'`, `'cancelled'`)
|
||||
|
||||
---
|
||||
|
||||
## 4. Kernprozesse
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
A[Partner loggt sich ein] --> B{Firma zugewiesen?}
|
||||
B -- Nein --> C[Fehlermeldung: Kein Zutritt zum Wizard]
|
||||
B -- Ja --> D[Wizard öffnen / Konfigurieren]
|
||||
D --> E[Endkunden auswählen/anlegen]
|
||||
E --> F[Produkte & Module wählen]
|
||||
F --> G{Validierung erfolgreich?}
|
||||
G -- Nein --> H[Validierungsfehler anzeigen]
|
||||
G -- Ja --> I[Bestellung absenden]
|
||||
I --> J[Snapshot einfrieren & in DB speichern]
|
||||
J --> K[Rechnungs-PDF generieren & in Storage laden]
|
||||
K --> L[E-Mail mit PDF an Partner senden]
|
||||
L --> M[Bestellung in der Übersicht anzeigen]
|
||||
```
|
||||
|
||||
### A. Partner- & Unternehmens-Hierarchie
|
||||
1. Ein neu registrierter User hat die Rolle `partner` und ist zunächst keiner Firma zugeordnet.
|
||||
2. Der Administrator ordnet den User in der Admin-Oberfläche (`/admin/users`) einem Unternehmen (`companies`) zu.
|
||||
3. Nur wenn der User einer Firma zugewiesen ist (oder Admin ist), kann er Endkunden anlegen und Bestellungen aufgeben.
|
||||
|
||||
### B. Endkunden-Verwaltung (`/my-customers`)
|
||||
- **Firmenweite Sicht**: Da `end_customers.partner_id` auf `companies.id` verweist, sehen alle Mitarbeiter desselben Unternehmens dieselben Endkunden.
|
||||
- **DSGVO Anonymisierung**: Endkunden können unwiderruflich anonymisiert werden. Dabei werden sensible Daten mit `[GELÖSCHT]` überschrieben und `is_anonymized` auf `true` gesetzt. In existierenden Bestellungen (`orders.customer_data`) bleiben die Daten zu steuerlichen Zwecken unverändert.
|
||||
|
||||
### C. Bestell-Wizard & Validierung (`/order`)
|
||||
- **Kategorie-Pflichten**: Wenn eine Produktkategorie als `is_required` definiert ist, muss ein Produkt ausgewählt werden.
|
||||
- **Abhängigkeiten (Requirements)**: Ein Modul oder Produkt kann andere Module/Produkte voraussetzen (z.B. Modul B erfordert Modul A). Das System prüft dies client- und serverseitig.
|
||||
- **Ausschlüsse (Exclusions)**: Inkompatible Produkte oder Module blockieren sich gegenseitig.
|
||||
- **Snapshot-Architektur**: Sobald eine Bestellung aufgegeben wird, werden die Kundendaten (`CustomerSnapshot`) und Produktkonfigurationen (`OrderSnapshot` samt Preisen und Modulversionen) in `orders` als JSONB-Snapshots eingefroren. Preisänderungen im Katalog haben keinen Einfluss auf bestehende Bestellungen.
|
||||
|
||||
### D. Bestellungs-Verwaltung & Statusübergang
|
||||
- **Sichtbarkeit**: Partner sehen in `/my-orders` alle Bestellungen aller Mitarbeiter ihrer Firma.
|
||||
- **Bearbeiten**: Unvollständige Bestellungen (`status != 'completed'`) können von jedem Mitarbeiter der jeweiligen Firma nachträglich editiert werden.
|
||||
- **Admin-Workflow**: Admins sehen in `/admin/orders` alle Bestellungen global, können den Status ändern (z.B. von *Eingegangen* auf *In Bearbeitung* oder *Abgeschlossen*) und PDF-Rechnungen manuell herunterladen.
|
||||
- **Statusänderungs-Mails**: Bei jedem Statusübergang wird automatisch eine Benachrichtigungs-E-Mail an den Besteller geschickt.
|
||||
|
||||
---
|
||||
|
||||
## 5. Sicherheitskonzept (RLS - Row Level Security)
|
||||
|
||||
RLS ist auf Datenbankebene in Postgres implementiert und erzwingt Datenisolierung:
|
||||
|
||||
- **Unternehmen (`companies`)**: Authentifizierte Benutzer dürfen nur die Unternehmen lesen.
|
||||
- **Endkunden (`end_customers`)**:
|
||||
- `USING (partner_id = (SELECT company_id FROM public.users WHERE id = auth.uid()))`
|
||||
- Partner sehen und modifizieren nur Endkunden, deren `partner_id` mit der `company_id` des angemeldeten Benutzers übereinstimmt.
|
||||
- **Bestellungen (`orders`)**:
|
||||
- Partner sehen und modifizieren nur Bestellungen von Benutzern, die derselben Company angehören (sie müssen einer company zugewiesen sein).
|
||||
- Admins haben uneingeschränkten Zugriff.
|
||||
- **Lizenzen (`licenses`)**:
|
||||
- Partner sehen nur Lizenzen von Endkunden, die ihrer Company zugeordnet sind.
|
||||
|
||||
- **Erstellung: jede bestellung wird der firma des erstellers zugewiesen. Ein admin ist aber fähig einer bestellung nachträglich eine andere firma zuzuweisen.
|
||||
|
||||
---
|
||||
|
||||
## Kommentarbereich
|
||||
*Bitte nutze diesen Bereich oder füge Zeilenkommentare hinzu, um Änderungen oder Ergänzungen vorzuschlagen.*
|
||||
|
||||
- **Kommentar 1**:
|
||||
- **Kommentar 2**:
|
||||
@@ -36,13 +36,7 @@ export default async function MyOrdersPage() {
|
||||
let query = supabase.from('orders').select('*')
|
||||
|
||||
if (dbUser?.company_id) {
|
||||
const { data: companyUsers } = await supabase
|
||||
.from('users')
|
||||
.select('id')
|
||||
.eq('company_id', dbUser.company_id)
|
||||
|
||||
const userIds = (companyUsers ?? []).map(u => u.id)
|
||||
query = query.in('user_id', userIds)
|
||||
query = query.eq('company_id', dbUser.company_id)
|
||||
} else {
|
||||
query = query.eq('user_id', user.id)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { createAdminClient } from '@/lib/supabase/admin'
|
||||
import { getProducts, getCategories } from '@/lib/actions/products'
|
||||
import { getEndCustomers } from '@/lib/actions/end-customers'
|
||||
import { OrderWizard } from '@/components/order-wizard'
|
||||
import { getCompanies } from '@/lib/actions/companies'
|
||||
import { redirect } from 'next/navigation'
|
||||
import { Suspense } from 'react'
|
||||
|
||||
@@ -121,5 +122,21 @@ async function OrderDataWrapper({ orderId }: { orderId?: string }) {
|
||||
console.error('Fehler beim Laden des Profils:', e)
|
||||
}
|
||||
|
||||
return <OrderWizard products={products} categories={categories} initialProfile={profile} initialEndCustomers={endCustomers} initialOrder={initialOrder} />
|
||||
// Fetch companies list for admin
|
||||
let companies: any[] = []
|
||||
if (userData?.role === 'admin') {
|
||||
companies = await getCompanies().catch(() => [])
|
||||
}
|
||||
|
||||
return (
|
||||
<OrderWizard
|
||||
products={products}
|
||||
categories={categories}
|
||||
initialProfile={profile}
|
||||
initialEndCustomers={endCustomers}
|
||||
initialOrder={initialOrder}
|
||||
isAdmin={userData?.role === 'admin'}
|
||||
companies={companies}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -81,16 +81,23 @@ export function OrderWizard({
|
||||
initialProfile,
|
||||
initialEndCustomers,
|
||||
initialOrder,
|
||||
isAdmin = false,
|
||||
companies = [],
|
||||
}: {
|
||||
products: Product[]
|
||||
categories: Category[]
|
||||
initialProfile: Profile | null
|
||||
initialEndCustomers: EndCustomer[]
|
||||
initialOrder?: Order | null
|
||||
isAdmin?: boolean
|
||||
companies?: any[]
|
||||
}) {
|
||||
const router = useRouter()
|
||||
const [step, setStep] = useState(initialOrder ? 3 : 1)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [selectedCompanyId, setSelectedCompanyId] = useState<string | null>(
|
||||
initialOrder?.company_id ?? null
|
||||
)
|
||||
|
||||
// Partner-Profil (Fallback)
|
||||
const [customerData] = useState<Partial<Profile>>(initialProfile || {})
|
||||
@@ -414,6 +421,7 @@ export function OrderWizard({
|
||||
endCustomer: selectedEndCustomer,
|
||||
billingInterval: selectedBillingInterval,
|
||||
lastLicenseDate: lastLicenseDate || null,
|
||||
companyId: selectedCompanyId,
|
||||
})
|
||||
} else {
|
||||
order = await submitOrder({
|
||||
@@ -482,6 +490,29 @@ export function OrderWizard({
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{/* Admin Company Selector */}
|
||||
{isAdmin && (
|
||||
<div className="p-4 rounded-xl bg-white/5 border border-white/10 space-y-3">
|
||||
<Label htmlFor="order-company" className="text-white font-medium flex items-center gap-2">
|
||||
<Building2 className="w-4 h-4 text-primary" />
|
||||
Zugeordnetes Unternehmen (Admin-Option)
|
||||
</Label>
|
||||
<select
|
||||
id="order-company"
|
||||
value={selectedCompanyId || ''}
|
||||
onChange={(e) => setSelectedCompanyId(e.target.value || null)}
|
||||
className="flex h-9 w-full rounded-md border border-white/10 bg-slate-900 px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary text-white"
|
||||
>
|
||||
<option value="">Keine Firma zugewiesen</option>
|
||||
{companies.map((c: any) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Modus-Toggle */}
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
|
||||
@@ -193,6 +193,7 @@ export async function submitOrder(params: {
|
||||
.from('orders')
|
||||
.insert([{
|
||||
user_id: user.id,
|
||||
company_id: dbUser?.company_id || null,
|
||||
order_number: orderNumber,
|
||||
order_hash: orderHash,
|
||||
end_customer_id: endCustomerId ?? null,
|
||||
@@ -481,9 +482,10 @@ export async function updateOrder(
|
||||
endCustomer?: EndCustomer | null
|
||||
billingInterval?: 'one_time' | 'monthly'
|
||||
lastLicenseDate?: string | null
|
||||
companyId?: string | null
|
||||
}
|
||||
): Promise<Order> {
|
||||
const { selections, moduleQuantities, customerProfile, endCustomerId, endCustomer, billingInterval, lastLicenseDate } = params
|
||||
const { selections, moduleQuantities, customerProfile, endCustomerId, endCustomer, billingInterval, lastLicenseDate, companyId } = params
|
||||
const supabase = await createClient()
|
||||
const admin = createAdminClient()
|
||||
|
||||
@@ -612,15 +614,21 @@ export async function updateOrder(
|
||||
const orderHash = hashOrderSnapshot(orderSnapshot)
|
||||
|
||||
// 2. Bestellung in DB aktualisieren
|
||||
const { data: order, error: orderError } = await admin
|
||||
.from('orders')
|
||||
.update({
|
||||
const updatePayload: any = {
|
||||
end_customer_id: endCustomerId ?? null,
|
||||
total_price: orderSnapshot.total,
|
||||
customer_data: customerSnapshot,
|
||||
order_data: orderSnapshot,
|
||||
order_hash: orderHash,
|
||||
})
|
||||
}
|
||||
|
||||
if (isAdmin && companyId !== undefined) {
|
||||
updatePayload.company_id = companyId
|
||||
}
|
||||
|
||||
const { data: order, error: orderError } = await admin
|
||||
.from('orders')
|
||||
.update(updatePayload)
|
||||
.eq('id', orderId)
|
||||
.select()
|
||||
.single()
|
||||
|
||||
@@ -149,6 +149,7 @@ export type OrderSnapshot = {
|
||||
export type Order = {
|
||||
id: string
|
||||
user_id: string | null
|
||||
company_id: string | null
|
||||
order_number: string
|
||||
order_hash: string | null
|
||||
end_customer_id: string | null
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
-- Migration: Add company_id to public.orders and update RLS policies
|
||||
|
||||
-- 1. Add company_id column to orders table
|
||||
ALTER TABLE public.orders
|
||||
ADD COLUMN IF NOT EXISTS company_id UUID REFERENCES public.companies(id) ON DELETE SET NULL;
|
||||
|
||||
-- 2. Backfill company_id from users for existing orders
|
||||
UPDATE public.orders o
|
||||
SET company_id = (SELECT company_id FROM public.users u WHERE u.id = o.user_id)
|
||||
WHERE o.company_id IS NULL AND o.user_id IS NOT NULL;
|
||||
|
||||
-- 3. Recreate RLS policies for public.orders to use orders.company_id
|
||||
DROP POLICY IF EXISTS "Users can view their own orders" ON public.orders;
|
||||
CREATE POLICY "Users can view their own orders" ON public.orders FOR SELECT USING (
|
||||
(SELECT role FROM public.users WHERE id = auth.uid()) = 'admin'
|
||||
OR company_id = (SELECT company_id FROM public.users WHERE id = auth.uid())
|
||||
OR auth.uid() = user_id
|
||||
);
|
||||
|
||||
DROP POLICY IF EXISTS "Users can update their own orders" ON public.orders;
|
||||
CREATE POLICY "Users can update their own orders" ON public.orders FOR UPDATE USING (
|
||||
(SELECT role FROM public.users WHERE id = auth.uid()) = 'admin'
|
||||
OR company_id = (SELECT company_id FROM public.users WHERE id = auth.uid())
|
||||
OR auth.uid() = user_id
|
||||
);
|
||||
|
||||
-- Reload schema cache
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
Reference in New Issue
Block a user