diff --git a/docs/DIRECTUS-SETUP.md b/docs/DIRECTUS-SETUP.md new file mode 100644 index 0000000..b09cb9c --- /dev/null +++ b/docs/DIRECTUS-SETUP.md @@ -0,0 +1,704 @@ +# Directus Setup für dgray.io + +Komplette Anleitung zur Einrichtung von Directus als Backend für die dgray Kleinanzeigen-PWA. + +**API URL**: https://api.dgray.io/ + +--- + +## Inhaltsverzeichnis + +1. [Data Models (Collections)](#1-data-models-collections) +2. [User Roles](#2-user-roles) +3. [Access Policies](#3-access-policies) +4. [Flows (Automatisierungen)](#4-flows-automatisierungen) +5. [Einstellungen](#5-einstellungen) +6. [Frontend-Integration](#6-frontend-integration) + +--- + +## 1. Data Models (Collections) + +### 1.1 listings (Anzeigen) + +Die Haupt-Collection für alle Kleinanzeigen. + +| Feld | Typ | Einstellungen | +|------|-----|---------------| +| `id` | UUID | Primary Key, auto-generated | +| `status` | String (Dropdown) | `draft`, `published`, `sold`, `expired`, `deleted` | +| `sort` | Integer | Für manuelle Sortierung | +| `user_created` | User (M2O) | Auto, Read-only | +| `date_created` | DateTime | Auto, Read-only | +| `user_updated` | User (M2O) | Auto | +| `date_updated` | DateTime | Auto | +| `title` | String | Required, max 100 chars | +| `slug` | String | Unique, auto-generated from title | +| `description` | Text (WYSIWYG) | Required | +| `price` | Decimal | Required, precision 10, scale 2 | +| `currency` | String | Default: `XMR`, Options: `XMR`, `EUR` | +| `price_type` | String | `fixed`, `negotiable`, `free`, `on_request` | +| `category` | Categories (M2O) | Required | +| `condition` | String | `new`, `like_new`, `good`, `fair`, `poor` | +| `images` | Files (M2M) | Junction: `listings_files` | +| `location` | Locations (M2O) | Optional | +| `shipping` | Boolean | Versand möglich? | +| `shipping_cost` | Decimal | Optional | +| `views` | Integer | Default: 0 | +| `expires_at` | DateTime | Auto-set, 30 Tage nach Erstellung | +| `monero_address` | String | Für Direktzahlung | +| `contact_method` | String | `chat`, `email`, `both` | + +**Erstellen in Directus Admin:** +``` +Settings > Data Model > + Create Collection +Name: listings +Primary Key: UUID (auto-generated) +``` + +--- + +### 1.2 categories (Kategorien) + +Hierarchische Kategorien mit Übersetzungen. + +| Feld | Typ | Einstellungen | +|------|-----|---------------| +| `id` | UUID | Primary Key | +| `status` | String | `draft`, `published` | +| `sort` | Integer | Für Sortierung | +| `name` | String | Required (Fallback-Name) | +| `slug` | String | Unique | +| `icon` | String | Icon-Name (z.B. `laptop`, `car`) | +| `parent` | Categories (M2O) | Self-referencing | +| `translations` | Translations | Junction: `categories_translations` | + +**Translations Fields:** +| Feld | Typ | +|------|-----| +| `languages_code` | String (de, en, fr) | +| `name` | String | +| `description` | Text | + +--- + +### 1.3 conversations (Konversationen) + +Chat zwischen Käufer und Verkäufer. + +| Feld | Typ | Einstellungen | +|------|-----|---------------| +| `id` | UUID | Primary Key | +| `date_created` | DateTime | Auto | +| `date_updated` | DateTime | Auto | +| `listing` | Listings (M2O) | Required | +| `buyer` | User (M2O) | Wer die Konversation gestartet hat | +| `seller` | User (M2O) | Auto from listing.user_created | +| `status` | String | `active`, `archived`, `blocked` | +| `messages` | Messages (O2M) | | + +--- + +### 1.4 messages (Nachrichten) + +| Feld | Typ | Einstellungen | +|------|-----|---------------| +| `id` | UUID | Primary Key | +| `date_created` | DateTime | Auto | +| `conversation` | Conversations (M2O) | Required | +| `sender` | User (M2O) | Auto (user_created) | +| `content` | Text | Required, max 2000 chars | +| `read_at` | DateTime | Null = ungelesen | +| `type` | String | `text`, `offer`, `system` | + +--- + +### 1.5 favorites (Favoriten/Merkliste) + +| Feld | Typ | Einstellungen | +|------|-----|---------------| +| `id` | UUID | Primary Key | +| `date_created` | DateTime | Auto | +| `user` | User (M2O) | Auto (user_created) | +| `listing` | Listings (M2O) | Required | + +**Unique Constraint:** `user` + `listing` + +--- + +### 1.6 reports (Meldungen) + +| Feld | Typ | Einstellungen | +|------|-----|---------------| +| `id` | UUID | Primary Key | +| `date_created` | DateTime | Auto | +| `reporter` | User (M2O) | Auto | +| `listing` | Listings (M2O) | Optional | +| `reported_user` | User (M2O) | Optional | +| `reason` | String | `spam`, `fraud`, `inappropriate`, `illegal`, `other` | +| `details` | Text | | +| `status` | String | `pending`, `reviewed`, `resolved`, `dismissed` | +| `admin_notes` | Text | Nur für Admins sichtbar | +| `resolved_by` | User (M2O) | | +| `resolved_at` | DateTime | | + +--- + +### 1.7 locations (Orte) + +| Feld | Typ | Einstellungen | +|------|-----|---------------| +| `id` | UUID | Primary Key | +| `name` | String | Stadt/Ort | +| `postal_code` | String | PLZ | +| `region` | String | Bundesland/Kanton | +| `country` | String | Default: `DE` | +| `latitude` | Float | Optional, für Kartenansicht | +| `longitude` | Float | Optional | + +--- + +### 1.8 listings_files (Junction Table) + +Verknüpfung Listings ↔ Dateien (für mehrere Bilder pro Anzeige). + +| Feld | Typ | +|------|-----| +| `id` | Integer (auto) | +| `listings_id` | Listings (M2O) | +| `directus_files_id` | Files (M2O) | +| `sort` | Integer | + +--- + +## 2. User Roles + +### 2.1 Rollen-Übersicht + +| Rolle | Beschreibung | App Access | Admin Access | +|-------|--------------|------------|--------------| +| **Administrator** | Vollzugriff | ✅ | ✅ | +| **Moderator** | Content-Moderation | ✅ | ✅ (eingeschränkt) | +| **User** | Registrierte Nutzer | ✅ | ❌ | +| **Public** | Nicht angemeldet | ❌ | ❌ | + +### 2.2 Rollen erstellen + +**Settings > Access Control > Roles > + Create Role** + +#### Administrator +``` +Name: Administrator +Description: Vollzugriff auf alle Funktionen +Icon: shield +Admin Access: ON +App Access: ON +``` + +#### Moderator +``` +Name: Moderator +Description: Kann Inhalte moderieren und Reports bearbeiten +Icon: eye +Admin Access: ON (mit eingeschränkten Permissions) +App Access: ON +``` + +#### User +``` +Name: User +Description: Registrierte Benutzer +Icon: account_circle +Admin Access: OFF +App Access: ON +``` + +--- + +## 3. Access Policies + +### 3.1 Public (Nicht angemeldet) + +**listings:** +```json +{ + "read": { + "_and": [ + { "status": { "_eq": "published" } } + ] + }, + "fields": ["id", "title", "slug", "description", "price", "currency", "price_type", "category", "condition", "images", "location", "shipping", "date_created", "views"] +} +``` + +**categories:** +```json +{ + "read": { + "status": { "_eq": "published" } + }, + "fields": "*" +} +``` + +**locations:** +```json +{ + "read": true, + "fields": "*" +} +``` + +**directus_files:** +```json +{ + "read": true +} +``` + +--- + +### 3.2 User Role Permissions + +**listings:** +```json +{ + "create": true, + "read": { + "_or": [ + { "status": { "_eq": "published" } }, + { "user_created": { "_eq": "$CURRENT_USER" } } + ] + }, + "update": { + "user_created": { "_eq": "$CURRENT_USER" } + }, + "delete": { + "_and": [ + { "user_created": { "_eq": "$CURRENT_USER" } }, + { "status": { "_in": ["draft", "expired"] } } + ] + } +} +``` + +**conversations:** +```json +{ + "create": true, + "read": { + "_or": [ + { "buyer": { "_eq": "$CURRENT_USER" } }, + { "seller": { "_eq": "$CURRENT_USER" } } + ] + }, + "update": { + "_or": [ + { "buyer": { "_eq": "$CURRENT_USER" } }, + { "seller": { "_eq": "$CURRENT_USER" } } + ] + } +} +``` + +**messages:** +```json +{ + "create": { + "conversation": { + "_or": [ + { "buyer": { "_eq": "$CURRENT_USER" } }, + { "seller": { "_eq": "$CURRENT_USER" } } + ] + } + }, + "read": { + "conversation": { + "_or": [ + { "buyer": { "_eq": "$CURRENT_USER" } }, + { "seller": { "_eq": "$CURRENT_USER" } } + ] + } + } +} +``` + +**favorites:** +```json +{ + "create": true, + "read": { + "user": { "_eq": "$CURRENT_USER" } + }, + "delete": { + "user": { "_eq": "$CURRENT_USER" } + } +} +``` + +**reports:** +```json +{ + "create": true +} +``` + +**directus_files:** +```json +{ + "create": true, + "read": true, + "update": { + "uploaded_by": { "_eq": "$CURRENT_USER" } + }, + "delete": { + "uploaded_by": { "_eq": "$CURRENT_USER" } + } +} +``` + +**directus_users (eigenes Profil):** +```json +{ + "read": { + "id": { "_eq": "$CURRENT_USER" } + }, + "update": { + "id": { "_eq": "$CURRENT_USER" } + }, + "fields": ["id", "email", "first_name", "last_name", "avatar", "status"] +} +``` + +--- + +### 3.3 Moderator Role Permissions + +Zusätzlich zu User-Permissions: + +**listings:** +```json +{ + "read": true, + "update": { + "fields": ["status", "admin_notes"] + } +} +``` + +**reports:** +```json +{ + "read": true, + "update": { + "fields": ["status", "admin_notes", "resolved_by", "resolved_at"] + } +} +``` + +**directus_users (öffentliche Felder anderer User):** +```json +{ + "read": { + "fields": ["id", "first_name", "avatar", "status"] + } +} +``` + +--- + +## 4. Flows (Automatisierungen) + +### 4.1 Auto-Slug für Listings + +**Trigger:** `items.create` auf `listings` + +```javascript +// Operation: Run Script +module.exports = async function(data) { + const title = data.title; + const slug = title + .toLowerCase() + .replace(/[äöüß]/g, match => ({ä:'ae',ö:'oe',ü:'ue',ß:'ss'}[match])) + .replace(/[^a-z0-9]+/g, '-') + .replace(/(^-|-$)/g, ''); + + return { slug: `${slug}-${Date.now().toString(36)}` }; +} +``` + +**Operation:** Update Data → `listings` mit Ergebnis + +--- + +### 4.2 Auto-Set Expiry Date + +**Trigger:** `items.create` auf `listings` + +```javascript +module.exports = async function(data) { + const expiresAt = new Date(); + expiresAt.setDate(expiresAt.getDate() + 30); + return { expires_at: expiresAt.toISOString() }; +} +``` + +--- + +### 4.3 Set Seller on Conversation Create + +**Trigger:** `items.create` auf `conversations` + +```javascript +module.exports = async function(data, { database }) { + const listing = await database('listings') + .where('id', data.listing) + .first(); + + return { seller: listing.user_created }; +} +``` + +--- + +### 4.4 Increment View Counter + +**Trigger:** Custom Endpoint oder Hook + +```javascript +// Webhook/Endpoint für View-Tracking +module.exports = async function(data, { database }) { + await database('listings') + .where('id', data.listing_id) + .increment('views', 1); +} +``` + +--- + +### 4.5 Auto-Expire Listings (Scheduled) + +**Trigger:** Schedule (täglich um 00:00) + +```javascript +module.exports = async function(data, { database }) { + const now = new Date().toISOString(); + + await database('listings') + .where('expires_at', '<', now) + .where('status', 'published') + .update({ status: 'expired' }); +} +``` + +--- + +### 4.6 Welcome Email bei Registrierung + +**Trigger:** `users.create` + +**Operation:** Send Email +``` +To: {{$trigger.email}} +Subject: Willkommen bei dgray.io +Template: welcome-email +``` + +--- + +## 5. Einstellungen + +### 5.1 Project Settings + +**Settings > Project Settings** + +``` +Project Name: dgray.io +Project URL: https://dgray.io +Project Color: #555555 +``` + +### 5.2 CORS Settings + +**Environment Variables (.env):** + +```env +CORS_ENABLED=true +CORS_ORIGIN=https://dgray.io,https://www.dgray.io,http://localhost:8080 +CORS_METHODS=GET,POST,PATCH,DELETE +CORS_ALLOWED_HEADERS=Content-Type,Authorization +CORS_CREDENTIALS=true +``` + +### 5.3 Auth Settings + +```env +# Token Expiry +ACCESS_TOKEN_TTL=15m +REFRESH_TOKEN_TTL=7d + +# Password Policy +AUTH_PASSWORD_POLICY=/^.{8,}$/ + +# Disable Public Registration (optional) +# USER_REGISTER_URL_ALLOW_LIST=https://dgray.io +``` + +### 5.4 File Storage + +```env +STORAGE_LOCATIONS=local +STORAGE_LOCAL_ROOT=./uploads + +# Für S3/Cloudflare R2: +# STORAGE_LOCATIONS=s3 +# STORAGE_S3_DRIVER=s3 +# STORAGE_S3_KEY=xxx +# STORAGE_S3_SECRET=xxx +# STORAGE_S3_BUCKET=dgray-files +# STORAGE_S3_REGION=auto +# STORAGE_S3_ENDPOINT=https://xxx.r2.cloudflarestorage.com +``` + +### 5.5 Rate Limiting + +```env +RATE_LIMITER_ENABLED=true +RATE_LIMITER_STORE=memory +RATE_LIMITER_POINTS=100 +RATE_LIMITER_DURATION=60 +``` + +### 5.6 Email Settings + +```env +EMAIL_FROM=noreply@dgray.io +EMAIL_TRANSPORT=smtp +EMAIL_SMTP_HOST=smtp.example.com +EMAIL_SMTP_PORT=587 +EMAIL_SMTP_USER=xxx +EMAIL_SMTP_PASSWORD=xxx +EMAIL_SMTP_SECURE=false +``` + +--- + +## 6. Frontend-Integration + +### 6.1 Service einbinden + +```javascript +import { directus, DirectusError } from './services/directus.js'; + +// Login +try { + await directus.login('user@example.com', 'password'); + console.log('Logged in!'); +} catch (error) { + if (error instanceof DirectusError) { + console.error('Login failed:', error.message); + } +} + +// Listings laden +const { items, meta } = await directus.getListings({ + limit: 20, + page: 1 +}); + +// Listing erstellen +const newListing = await directus.createListing({ + title: 'iPhone 15 Pro', + description: 'Neuwertig, OVP', + price: 0.5, + currency: 'XMR', + category: 'electronics-id', + condition: 'like_new' +}); +``` + +### 6.2 Bilder anzeigen + +```javascript +// Thumbnail URL +const thumbUrl = directus.getThumbnailUrl(fileId, 300); + +// Optimiertes Bild +const imageUrl = directus.getFileUrl(fileId, { + width: 800, + height: 600, + fit: 'cover', + quality: 80, + format: 'webp' +}); +``` + +### 6.3 Auth State prüfen + +```javascript +if (directus.isAuthenticated()) { + const user = await directus.getCurrentUser(); + console.log('Logged in as:', user.email); +} else { + // Redirect to login +} +``` + +--- + +## 7. Initiale Daten + +### 7.1 Kategorien (Beispiel) + +```json +[ + { "name": "Elektronik", "slug": "elektronik", "icon": "devices" }, + { "name": "Fahrzeuge", "slug": "fahrzeuge", "icon": "directions_car" }, + { "name": "Immobilien", "slug": "immobilien", "icon": "home" }, + { "name": "Mode & Accessoires", "slug": "mode", "icon": "checkroom" }, + { "name": "Haus & Garten", "slug": "haus-garten", "icon": "yard" }, + { "name": "Freizeit & Hobby", "slug": "freizeit", "icon": "sports_esports" }, + { "name": "Jobs", "slug": "jobs", "icon": "work" }, + { "name": "Dienstleistungen", "slug": "dienstleistungen", "icon": "handyman" }, + { "name": "Sonstiges", "slug": "sonstiges", "icon": "more_horiz" } +] +``` + +### 7.2 Sub-Kategorien Elektronik + +```json +[ + { "name": "Smartphones", "slug": "smartphones", "parent": "elektronik-id" }, + { "name": "Computer & Laptops", "slug": "computer", "parent": "elektronik-id" }, + { "name": "TV & Audio", "slug": "tv-audio", "parent": "elektronik-id" }, + { "name": "Foto & Video", "slug": "foto-video", "parent": "elektronik-id" }, + { "name": "Gaming", "slug": "gaming", "parent": "elektronik-id" }, + { "name": "Zubehör", "slug": "zubehoer", "parent": "elektronik-id" } +] +``` + +--- + +## 8. Checkliste + +- [ ] Collections erstellen (listings, categories, etc.) +- [ ] Felder konfigurieren (Typen, Validierung) +- [ ] Rollen anlegen (Admin, Moderator, User) +- [ ] Access Policies für jede Rolle setzen +- [ ] Public Access für Listings/Categories aktivieren +- [ ] Flows für Auto-Slug, Expiry, etc. erstellen +- [ ] CORS für Frontend-Domain konfigurieren +- [ ] Email-Transport einrichten +- [ ] Kategorien importieren +- [ ] Test-User erstellen +- [ ] Frontend mit `directus.js` Service verbinden + +--- + +## 9. Sicherheitshinweise + +1. **Monero-Adressen** werden nur dem Listing-Ersteller und authentifizierten Nutzern angezeigt +2. **Keine echten E-Mails** in Public-Responses - nur über Chat-System +3. **Rate Limiting** für API-Endpunkte aktivieren +4. **Bilder-Upload** auf max. 10MB und erlaubte Typen beschränken +5. **XSS-Schutz** für WYSIWYG-Felder in Directus aktivieren diff --git a/js/services/directus.js b/js/services/directus.js new file mode 100644 index 0000000..51cdc25 --- /dev/null +++ b/js/services/directus.js @@ -0,0 +1,589 @@ +/** + * Directus API Service für dgray.io + * Verbindet sich mit https://api.dgray.io/ + */ + +const DIRECTUS_URL = 'https://api.dgray.io'; + +class DirectusService { + constructor() { + this.baseUrl = DIRECTUS_URL; + this.accessToken = null; + this.refreshToken = null; + this.tokenExpiry = null; + this.refreshTimeout = null; + + this.loadTokens(); + } + + // ==================== Token Management ==================== + + loadTokens() { + const stored = localStorage.getItem('dgray_auth'); + if (stored) { + try { + const { accessToken, refreshToken, expiry } = JSON.parse(stored); + this.accessToken = accessToken; + this.refreshToken = refreshToken; + this.tokenExpiry = expiry; + this.scheduleTokenRefresh(); + } catch (e) { + this.clearTokens(); + } + } + } + + saveTokens(accessToken, refreshToken, expiresIn) { + this.accessToken = accessToken; + this.refreshToken = refreshToken; + this.tokenExpiry = Date.now() + (expiresIn * 1000); + + localStorage.setItem('dgray_auth', JSON.stringify({ + accessToken: this.accessToken, + refreshToken: this.refreshToken, + expiry: this.tokenExpiry + })); + + this.scheduleTokenRefresh(); + } + + clearTokens() { + this.accessToken = null; + this.refreshToken = null; + this.tokenExpiry = null; + localStorage.removeItem('dgray_auth'); + + if (this.refreshTimeout) { + clearTimeout(this.refreshTimeout); + this.refreshTimeout = null; + } + } + + scheduleTokenRefresh() { + if (this.refreshTimeout) { + clearTimeout(this.refreshTimeout); + } + + if (!this.tokenExpiry || !this.refreshToken) return; + + // Refresh 1 Minute vor Ablauf + const refreshIn = this.tokenExpiry - Date.now() - 60000; + + if (refreshIn > 0) { + this.refreshTimeout = setTimeout(() => this.refreshSession(), refreshIn); + } else if (this.refreshToken) { + this.refreshSession(); + } + } + + isAuthenticated() { + return !!this.accessToken && (!this.tokenExpiry || Date.now() < this.tokenExpiry); + } + + // ==================== HTTP Methods ==================== + + async request(endpoint, options = {}) { + const url = `${this.baseUrl}${endpoint}`; + + const headers = { + 'Content-Type': 'application/json', + ...options.headers + }; + + if (this.accessToken) { + headers['Authorization'] = `Bearer ${this.accessToken}`; + } + + try { + const response = await fetch(url, { + ...options, + headers + }); + + // Token abgelaufen - versuche Refresh + if (response.status === 401 && this.refreshToken) { + const refreshed = await this.refreshSession(); + if (refreshed) { + headers['Authorization'] = `Bearer ${this.accessToken}`; + return this.request(endpoint, options); + } + } + + if (!response.ok) { + const error = await response.json().catch(() => ({})); + throw new DirectusError(response.status, error.errors?.[0]?.message || 'Request failed', error); + } + + if (response.status === 204) { + return null; + } + + return await response.json(); + } catch (error) { + if (error instanceof DirectusError) throw error; + throw new DirectusError(0, 'Network error', { originalError: error }); + } + } + + async get(endpoint, params = {}) { + const queryString = this.buildQueryString(params); + const url = queryString ? `${endpoint}?${queryString}` : endpoint; + return this.request(url, { method: 'GET' }); + } + + async post(endpoint, data) { + return this.request(endpoint, { + method: 'POST', + body: JSON.stringify(data) + }); + } + + async patch(endpoint, data) { + return this.request(endpoint, { + method: 'PATCH', + body: JSON.stringify(data) + }); + } + + async delete(endpoint) { + return this.request(endpoint, { method: 'DELETE' }); + } + + buildQueryString(params) { + const searchParams = new URLSearchParams(); + + for (const [key, value] of Object.entries(params)) { + if (value === undefined || value === null) continue; + + if (typeof value === 'object') { + searchParams.set(key, JSON.stringify(value)); + } else { + searchParams.set(key, value); + } + } + + return searchParams.toString(); + } + + // ==================== Authentication ==================== + + async login(email, password) { + const response = await this.post('/auth/login', { email, password }); + + if (response.data) { + this.saveTokens( + response.data.access_token, + response.data.refresh_token, + response.data.expires + ); + } + + return response.data; + } + + async logout() { + if (this.refreshToken) { + try { + await this.post('/auth/logout', { refresh_token: this.refreshToken }); + } catch (e) { + // Ignorieren - Token wird trotzdem gelöscht + } + } + this.clearTokens(); + } + + async refreshSession() { + if (!this.refreshToken) return false; + + try { + const response = await this.post('/auth/refresh', { + refresh_token: this.refreshToken, + mode: 'json' + }); + + if (response.data) { + this.saveTokens( + response.data.access_token, + response.data.refresh_token, + response.data.expires + ); + return true; + } + } catch (e) { + this.clearTokens(); + } + + return false; + } + + async register(email, password, userData = {}) { + return this.post('/users', { + email, + password, + ...userData, + role: null // Wird durch Directus Flow/Policy gesetzt + }); + } + + async requestPasswordReset(email) { + return this.post('/auth/password/request', { email }); + } + + async resetPassword(token, password) { + return this.post('/auth/password/reset', { token, password }); + } + + async getCurrentUser() { + const response = await this.get('/users/me', { + fields: ['id', 'email', 'first_name', 'last_name', 'avatar', 'role.name', 'status'] + }); + return response.data; + } + + async updateCurrentUser(data) { + const response = await this.patch('/users/me', data); + return response.data; + } + + // ==================== Listings (Anzeigen) ==================== + + async getListings(options = {}) { + const params = { + fields: options.fields || [ + '*', + 'user_created.id', + 'user_created.first_name', + 'category.id', + 'category.name', + 'category.translations.*', + 'images.directus_files_id.*' + ], + filter: options.filter || { status: { _eq: 'published' } }, + sort: options.sort || ['-date_created'], + limit: options.limit || 20, + page: options.page || 1, + meta: 'total_count,filter_count' + }; + + if (options.search) { + params.search = options.search; + } + + const response = await this.get('/items/listings', params); + return { + items: response.data, + meta: response.meta + }; + } + + async getListing(id) { + const response = await this.get(`/items/listings/${id}`, { + fields: [ + '*', + 'user_created.id', + 'user_created.first_name', + 'user_created.avatar', + 'category.*', + 'category.translations.*', + 'images.directus_files_id.*', + 'location.*' + ] + }); + return response.data; + } + + async createListing(data) { + const response = await this.post('/items/listings', data); + return response.data; + } + + async updateListing(id, data) { + const response = await this.patch(`/items/listings/${id}`, data); + return response.data; + } + + async deleteListing(id) { + return this.delete(`/items/listings/${id}`); + } + + async getMyListings(options = {}) { + return this.getListings({ + ...options, + filter: { user_created: { _eq: '$CURRENT_USER' } } + }); + } + + async searchListings(query, options = {}) { + return this.getListings({ + ...options, + search: query + }); + } + + async getListingsByCategory(categoryId, options = {}) { + return this.getListings({ + ...options, + filter: { + status: { _eq: 'published' }, + category: { _eq: categoryId } + } + }); + } + + // ==================== Categories (Kategorien) ==================== + + async getCategories() { + const response = await this.get('/items/categories', { + fields: ['*', 'translations.*', 'parent.*'], + filter: { status: { _eq: 'published' } }, + sort: ['sort', 'name'] + }); + return response.data; + } + + async getCategoryTree() { + const categories = await this.getCategories(); + return this.buildCategoryTree(categories); + } + + buildCategoryTree(categories, parentId = null) { + return categories + .filter(cat => (cat.parent?.id || cat.parent) === parentId) + .map(cat => ({ + ...cat, + children: this.buildCategoryTree(categories, cat.id) + })); + } + + // ==================== Messages (Nachrichten) ==================== + + async getConversations() { + const response = await this.get('/items/conversations', { + fields: [ + '*', + 'listing.id', + 'listing.title', + 'listing.images.directus_files_id.id', + 'buyer.id', + 'buyer.first_name', + 'seller.id', + 'seller.first_name', + 'messages.*' + ], + filter: { + _or: [ + { buyer: { _eq: '$CURRENT_USER' } }, + { seller: { _eq: '$CURRENT_USER' } } + ] + }, + sort: ['-date_updated'] + }); + return response.data; + } + + async getConversation(id) { + const response = await this.get(`/items/conversations/${id}`, { + fields: [ + '*', + 'listing.*', + 'listing.images.directus_files_id.*', + 'buyer.*', + 'seller.*', + 'messages.*', + 'messages.sender.*' + ] + }); + return response.data; + } + + async sendMessage(conversationId, content) { + const response = await this.post('/items/messages', { + conversation: conversationId, + content + }); + return response.data; + } + + async startConversation(listingId, message) { + const response = await this.post('/items/conversations', { + listing: listingId, + messages: { + create: [{ content: message }] + } + }); + return response.data; + } + + // ==================== Favorites (Favoriten) ==================== + + async getFavorites() { + const response = await this.get('/items/favorites', { + fields: ['*', 'listing.*', 'listing.images.directus_files_id.*'], + filter: { user: { _eq: '$CURRENT_USER' } } + }); + return response.data; + } + + async addFavorite(listingId) { + const response = await this.post('/items/favorites', { + listing: listingId + }); + return response.data; + } + + async removeFavorite(favoriteId) { + return this.delete(`/items/favorites/${favoriteId}`); + } + + async isFavorite(listingId) { + const response = await this.get('/items/favorites', { + filter: { + user: { _eq: '$CURRENT_USER' }, + listing: { _eq: listingId } + }, + limit: 1 + }); + return response.data.length > 0 ? response.data[0] : null; + } + + // ==================== Reports (Meldungen) ==================== + + async reportListing(listingId, reason, details = '') { + const response = await this.post('/items/reports', { + listing: listingId, + reason, + details + }); + return response.data; + } + + async reportUser(userId, reason, details = '') { + const response = await this.post('/items/reports', { + reported_user: userId, + reason, + details + }); + return response.data; + } + + // ==================== Files (Dateien/Bilder) ==================== + + async uploadFile(file, options = {}) { + const formData = new FormData(); + formData.append('file', file); + + if (options.folder) { + formData.append('folder', options.folder); + } + + const response = await fetch(`${this.baseUrl}/files`, { + method: 'POST', + headers: this.accessToken ? { 'Authorization': `Bearer ${this.accessToken}` } : {}, + body: formData + }); + + if (!response.ok) { + const error = await response.json().catch(() => ({})); + throw new DirectusError(response.status, 'Upload failed', error); + } + + const result = await response.json(); + return result.data; + } + + async uploadMultipleFiles(files, options = {}) { + const uploads = Array.from(files).map(file => this.uploadFile(file, options)); + return Promise.all(uploads); + } + + getFileUrl(fileId, options = {}) { + if (!fileId) return null; + + const params = new URLSearchParams(); + + if (options.width) params.set('width', options.width); + if (options.height) params.set('height', options.height); + if (options.fit) params.set('fit', options.fit); + if (options.quality) params.set('quality', options.quality); + if (options.format) params.set('format', options.format); + + const queryString = params.toString(); + return `${this.baseUrl}/assets/${fileId}${queryString ? '?' + queryString : ''}`; + } + + getThumbnailUrl(fileId, size = 300) { + return this.getFileUrl(fileId, { width: size, height: size, fit: 'cover' }); + } + + // ==================== Search ==================== + + async globalSearch(query, options = {}) { + const [listings, categories] = await Promise.all([ + this.searchListings(query, { limit: options.listingLimit || 10 }), + this.get('/items/categories', { + search: query, + limit: options.categoryLimit || 5 + }) + ]); + + return { + listings: listings.items, + categories: categories.data + }; + } + + // ==================== Stats / Dashboard ==================== + + async getUserStats() { + const [listings, favorites, conversations] = await Promise.all([ + this.get('/items/listings', { + filter: { user_created: { _eq: '$CURRENT_USER' } }, + aggregate: { count: '*' } + }), + this.get('/items/favorites', { + filter: { user: { _eq: '$CURRENT_USER' } }, + aggregate: { count: '*' } + }), + this.get('/items/conversations', { + filter: { + _or: [ + { buyer: { _eq: '$CURRENT_USER' } }, + { seller: { _eq: '$CURRENT_USER' } } + ] + }, + aggregate: { count: '*' } + }) + ]); + + return { + listingsCount: listings.data?.[0]?.count || 0, + favoritesCount: favorites.data?.[0]?.count || 0, + conversationsCount: conversations.data?.[0]?.count || 0 + }; + } +} + +class DirectusError extends Error { + constructor(status, message, data = {}) { + super(message); + this.name = 'DirectusError'; + this.status = status; + this.data = data; + } + + isAuthError() { + return this.status === 401 || this.status === 403; + } + + isNotFound() { + return this.status === 404; + } + + isValidationError() { + return this.status === 400; + } +} + +// Singleton Export +export const directus = new DirectusService(); +export { DirectusError };