diff --git a/docs/REPUTATION.md b/docs/REPUTATION.md new file mode 100644 index 0000000..563c45c --- /dev/null +++ b/docs/REPUTATION.md @@ -0,0 +1,380 @@ +# Reputation-System — dgray.io + +## Ziel + +Vertrauen zwischen anonymen Nutzern aufbauen, ohne KYC, ohne Escrow. +Trust durch nachweisbares Verhalten statt durch Identität. + +--- + +## Stufensystem + +| Level | Bedingung | Badge | Effekt | +|-------|-----------|-------|--------| +| **Neu** | 0 bestätigte Deals | ⚪ | Hinweis "Neuer Account" auf Anzeigen | +| **Aktiv** | 3+ Deals, Account 30+ Tage | 🔵 | Kein Hinweis | +| **Vertrauenswürdig** | 10+ Deals, ⌀ 4.0+ Sterne | 🟢 | Badge auf Anzeigen | +| **Power Seller** | 30+ Deals, ⌀ 4.5+ Sterne | 🟣 | Badge + bevorzugte Platzierung | + +--- + +## Datenmodell + +### Directus: Collection `deals` + +Bestätigte Transaktionen zwischen zwei Nutzern. + +| Feld | Typ | Beschreibung | +|------|-----|--------------| +| `id` | UUID | Primary Key | +| `listing_id` | UUID, FK → listings | Bezug zur Anzeige | +| `conversation_id` | UUID, FK → conversations | Bezug zur Konversation | +| `seller_hash` | string(64) | SHA-256 Hash des Verkäufers | +| `buyer_hash` | string(64) | SHA-256 Hash des Käufers | +| `seller_confirmed` | boolean, default false | Verkäufer hat Deal bestätigt | +| `buyer_confirmed` | boolean, default false | Käufer hat Deal bestätigt | +| `status` | string | `pending`, `confirmed`, `disputed` | +| `date_created` | datetime | Erstellungsdatum | +| `date_confirmed` | datetime, nullable | Zeitpunkt der beidseitigen Bestätigung | + +Ein Deal gilt als `confirmed`, wenn **beide Seiten** bestätigt haben. + +### Directus: Collection `ratings` + +Bewertungen nach bestätigtem Deal. + +| Feld | Typ | Beschreibung | +|------|-----|--------------| +| `id` | UUID | Primary Key | +| `deal_id` | UUID, FK → deals | Bezug zum Deal | +| `rater_hash` | string(64) | Wer bewertet | +| `rated_hash` | string(64) | Wer bewertet wird | +| `score` | integer | 1–5 Sterne | +| `date_created` | datetime | Zeitpunkt | + +Pro Deal kann jeder Teilnehmer **einmal** bewerten (nach Bestätigung). +Kein Freitext-Kommentar — nur Sterne (Privacy). + +### Directus: Permissions + +| Collection | Public Read | Public Create | Public Update | +|------------|:-----------:|:------------:|:------------:| +| `deals` | Ja (Filter: eigener Hash) | Ja | Ja (nur eigene `*_confirmed`) | +| `ratings` | Ja (Filter: `rated_hash`) | Ja (nur nach bestätigtem Deal) | Nein | + +### Computed Fields (Frontend) + +Diese Werte werden im Frontend aus `deals` + `ratings` berechnet: + +``` +reputation = { + deals_completed: count(deals WHERE status = 'confirmed' AND user = seller OR buyer), + avg_rating: avg(ratings WHERE rated_hash = user), + account_age_days: now() - user.date_created, + level: computed from above +} +``` + +--- + +## User Flow + +### Deal bestätigen (im Chat) + +``` +1. Käufer und Verkäufer handeln im Chat +2. Eine Seite klickt "Deal abschliessen" → Deal wird erstellt (status: pending) +3. Andere Seite sieht Bestätigungsanfrage im Chat +4. Andere Seite klickt "Deal bestätigen" → status: confirmed +5. Beide Seiten können danach bewerten (1–5 Sterne) +``` + +### UI im Chat-Widget + +``` +┌─────────────────────────────────────┐ +│ 💬 Chat mit Verkäufer │ +│ │ +│ ... Nachrichten ... │ +│ │ +│ ┌─────────────────────────────┐ │ +│ │ 🤝 Deal abschliessen │ │ +│ └─────────────────────────────┘ │ +│ │ +│ Nach beidseitiger Bestätigung: │ +│ ┌─────────────────────────────┐ │ +│ │ ⭐⭐⭐⭐☆ Bewertung abgeben │ │ +│ └─────────────────────────────┘ │ +└─────────────────────────────────────┘ +``` + +### Badge auf Listing-Card + +``` +┌──────────────────────┐ +│ 📷 Bild │ +│ Titel der Anzeige │ +│ 50.00 EUR │ +│ 📍 Zürich 🟢 10+ │ ← Badge + Deal-Count +└──────────────────────┘ +``` + +### Seller-Card auf Listing-Seite + +``` +┌──────────────────────────┐ +│ ? Anonymer Anbieter │ +│ Mitglied seit 2025 │ +│ 🟢 Vertrauenswürdig │ +│ 12 Deals · ⌀ 4.3 ⭐ │ +└──────────────────────────┘ +``` + +--- + +## Scam-Prävention + +| Massnahme | Beschreibung | +|-----------|--------------| +| **Neuer-Account-Warnung** | "Neuer Account — starte mit kleinen Beträgen" | +| **Preis-Limit** | Max. Anzeigenpreis für Level "Neu": 100 EUR/USD/CHF | +| **Cooldown** | Neue Accounts: max. 3 Anzeigen pro Tag | +| **Report-Konsequenz** | 3+ Reports → automatische Prüfung, Anzeigen ausgeblendet | +| **Self-Rating-Schutz** | Nur nach beidseitig bestätigtem Deal bewertbar | + +--- + +## Implementierung + +### Phase 1: Backend (Directus) +- [ ] Collection `deals` anlegen (siehe Anleitung unten) +- [ ] Collection `ratings` anlegen (siehe Anleitung unten) +- [ ] Permissions setzen (Public-Rolle, siehe unten) +- [ ] Flow: Notification bei Deal-Bestätigung (optional) + +--- + +## Directus: Collection `deals` anlegen + +``` +Settings > Data Model > + Create Collection +Name: deals +Primary Key: UUID (auto-generated) +``` + +| Feld | Typ | Interface | Einstellungen | +|------|-----|-----------|---------------| +| `id` | UUID | – | Primary Key, auto-generated | +| `listing` | UUID (String) | Input | Listing-ID (kein FK, da Public-Rolle keinen Zugriff auf M2O hat) | +| `conversation` | UUID (String) | Input | Conversation-ID | +| `seller_hash` | String | Input | Required, max 64 chars | +| `buyer_hash` | String | Input | Required, max 64 chars | +| `seller_confirmed` | Boolean | Toggle | Default: `false` | +| `buyer_confirmed` | Boolean | Toggle | Default: `false` | +| `status` | String | Dropdown | `pending`, `confirmed`, `disputed` (Default: `pending`) | +| `date_created` | DateTime | DateTime | Auto, Read-only (Directus: "On Create" → Save Current Date/Time) | +| `date_confirmed` | DateTime | DateTime | Nullable, kein Default | + +### Schritt-für-Schritt + +1. **Collection erstellen:** + - Settings → Data Model → `+ Create Collection` + - Name: `deals` + - Primary Key Field: Type `UUID`, Auto-generated: ✓ + - Optional Fields: ✓ `date_created` + +2. **Felder anlegen (in dieser Reihenfolge):** + + **listing** (String): + - Interface: Input + - Schema: String, max length 36 + - Nicht nullable + + **conversation** (String): + - Interface: Input + - Schema: String, max length 36 + - Nicht nullable + + **seller_hash** (String): + - Interface: Input + - Schema: String, max length 64 + - Nicht nullable + + **buyer_hash** (String): + - Interface: Input + - Schema: String, max length 64 + - Nicht nullable + + **seller_confirmed** (Boolean): + - Interface: Toggle + - Schema: Boolean, Default: `false` + + **buyer_confirmed** (Boolean): + - Interface: Toggle + - Schema: Boolean, Default: `false` + + **status** (String): + - Interface: Dropdown + - Options: `pending`, `confirmed`, `disputed` + - Schema: String, Default: `pending` + + **date_confirmed** (DateTime): + - Interface: DateTime + - Schema: Timestamp, Nullable: ✓ + +--- + +## Directus: Collection `ratings` anlegen + +``` +Settings > Data Model > + Create Collection +Name: ratings +Primary Key: UUID (auto-generated) +``` + +| Feld | Typ | Interface | Einstellungen | +|------|-----|-----------|---------------| +| `id` | UUID | – | Primary Key, auto-generated | +| `deal` | UUID (String) | Input | Deal-ID | +| `rater_hash` | String | Input | Required, max 64 chars, wer bewertet | +| `rated_hash` | String | Input | Required, max 64 chars, wer bewertet wird | +| `score` | Integer | Slider | Min: 1, Max: 5 | +| `date_created` | DateTime | DateTime | Auto, Read-only | + +### Schritt-für-Schritt + +1. **Collection erstellen:** + - Settings → Data Model → `+ Create Collection` + - Name: `ratings` + - Primary Key Field: Type `UUID`, Auto-generated: ✓ + - Optional Fields: ✓ `date_created` + +2. **Felder anlegen:** + + **deal** (String): + - Interface: Input + - Schema: String, max length 36 + - Nicht nullable + + **rater_hash** (String): + - Interface: Input + - Schema: String, max length 64 + - Nicht nullable + + **rated_hash** (String): + - Interface: Input + - Schema: String, max length 64 + - Nicht nullable + + **score** (Integer): + - Interface: Slider + - Options: Min `1`, Max `5`, Step `1` + - Schema: Integer, nicht nullable + +--- + +## Directus: Permissions (Public-Rolle) + +``` +Settings > Access Control > Public +``` + +### Collection `deals` + +**Read:** +- ✓ Erlaubt +- Felder: Alle +- Filter: Eigene Deals sehen + ```json + { + "_or": [ + { "seller_hash": { "_eq": "$CURRENT_USER" } }, + { "buyer_hash": { "_eq": "$CURRENT_USER" } } + ] + } + ``` + **Hinweis:** Da wir SHA-256 Hashes nutzen (nicht Directus-User-IDs), funktioniert `$CURRENT_USER` hier NICHT. + Stattdessen: **Keine Read-Filter setzen** (alle Deals lesbar). Die Filterung passiert im Frontend via API-Query. + Alternativ: Kein Filter, aber nur bestimmte Felder freigeben (z.B. `seller_hash`, `buyer_hash`, `status`, `date_confirmed` — ohne `conversation`, `listing`). + +**Empfohlene Read-Permissions (ohne Filter, mit Feld-Einschränkung):** +- Felder: `id`, `listing`, `conversation`, `seller_hash`, `buyer_hash`, `seller_confirmed`, `buyer_confirmed`, `status`, `date_created`, `date_confirmed` + +**Create:** +- ✓ Erlaubt +- Felder: `listing`, `conversation`, `seller_hash`, `buyer_hash`, `seller_confirmed`, `buyer_confirmed`, `status` +- Kein Validation-Filter nötig (Frontend validiert) + +**Update:** +- ✓ Erlaubt +- Felder: **Nur** `seller_confirmed`, `buyer_confirmed`, `status`, `date_confirmed` +- **Kein Custom-Filter** (da `$CURRENT_USER` nicht mit Hashes funktioniert) +- Schutz: Im Frontend wird geprüft ob der User Teilnehmer ist + +**Delete:** +- ✗ Nicht erlaubt + +### Collection `ratings` + +**Read:** +- ✓ Erlaubt +- Felder: `id`, `deal`, `rater_hash`, `rated_hash`, `score`, `date_created` +- Kein Filter (Ratings sind öffentlich sichtbar für Reputation) + +**Create:** +- ✓ Erlaubt +- Felder: `deal`, `rater_hash`, `rated_hash`, `score` + +**Update:** +- ✗ Nicht erlaubt (Bewertungen sind final) + +**Delete:** +- ✗ Nicht erlaubt + +--- + +## Directus: DIRECTUS-SCHEMA.md aktualisieren + +Nach dem Anlegen diese Tabellen in `docs/DIRECTUS-SCHEMA.md` ergänzen und die Permissions-Tabelle erweitern: + +``` +| `deals` | ✓ | ✓ | ✓ | - | Update nur confirmed/status Felder | +| `ratings` | ✓ | ✓ | - | - | Bewertungen sind final | +``` + +### Phase 2: Service (Frontend) +- [x] `js/services/reputation.js` — Deals/Ratings CRUD, Level-Berechnung, Caching +- [x] i18n-Keys in allen 7 Sprachen +- [ ] Integration in `conversations.js` — Deal-Bestätigung im Chat + +### Phase 3: UI +- [x] Chat-Widget: "Deal abschliessen" Button + Bestätigungsanfrage +- [x] Chat-Widget: Sterne-Bewertung nach bestätigtem Deal +- [ ] Listing-Card: Badge + Deal-Count +- [x] Listing-Page Seller-Card: Level, Deals, Rating +- [x] Neuer-Account-Warnung auf Listing-Page + +### Phase 4: Scam-Prävention +- [ ] Preis-Limit für neue Accounts +- [ ] Anzeigen-Cooldown für neue Accounts +- [ ] Report-Integration mit Konsequenzen + +--- + +## i18n Keys (benötigt) + +``` +reputation.level.new = "Neuer Account" +reputation.level.active = "Aktiv" +reputation.level.trusted = "Vertrauenswürdig" +reputation.level.power = "Power Seller" +reputation.deals = "{{count}} Deals" +reputation.dealsSingular = "1 Deal" +reputation.avgRating = "⌀ {{rating}} ⭐" +reputation.newWarning = "Neuer Account — starte mit kleinen Beträgen" +reputation.confirmDeal = "Deal abschliessen" +reputation.dealPending = "Warte auf Bestätigung" +reputation.dealConfirmed = "Deal bestätigt" +reputation.rate = "Bewertung abgeben" +reputation.rated = "Bewertet" +``` diff --git a/js/components/chat-widget.js b/js/components/chat-widget.js index 1bf8045..1386c7e 100644 --- a/js/components/chat-widget.js +++ b/js/components/chat-widget.js @@ -7,6 +7,7 @@ import { t, i18n } from '../i18n.js' import { conversationsService } from '../services/conversations.js' import { cryptoService } from '../services/crypto.js' import { escapeHTML } from '../utils/helpers.js' +import { reputationService } from '../services/reputation.js' class ChatWidget extends HTMLElement { static get observedAttributes() { @@ -21,6 +22,8 @@ class ChatWidget extends HTMLElement { this.loading = true this.error = null this._initialized = false + this.deal = null + this.hasRated = false } connectedCallback() { @@ -63,6 +66,7 @@ class ChatWidget extends HTMLElement { try { this.conversation = await conversationsService.startOrFindByListing(this.listingId) await this.loadMessages() + await this.loadDealState() } catch (e) { console.error('Failed to init conversation:', e) this.error = 'init-failed' @@ -86,6 +90,24 @@ class ChatWidget extends HTMLElement { this.scrollToBottom() } + async loadDealState() { + if (!this.conversation) return + try { + const deals = await reputationService.getDealsForConversation(this.conversation.id) + this.deal = deals[0] || null + this._cachedUserHash = await reputationService.getUserHash() + if (this.deal && this.deal.status === 'confirmed') { + const userHash = this._cachedUserHash + const ratings = await reputationService.getRatingsForUser( + this.deal.seller_hash === userHash ? this.deal.buyer_hash : this.deal.seller_hash + ) + this.hasRated = ratings.some(r => r.deal === this.deal.id && r.rater_hash === userHash) + } + } catch (e) { + console.error('Failed to load deal state:', e) + } + } + render() { if (this.loading) { this.innerHTML = /* html */` @@ -124,6 +146,8 @@ class ChatWidget extends HTMLElement { : this.renderMessagesHtml()} + ${this.renderDealSection()} +