add service for directus; add setup for directus
This commit is contained in:
704
docs/DIRECTUS-SETUP.md
Normal file
704
docs/DIRECTUS-SETUP.md
Normal file
@@ -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
|
||||||
589
js/services/directus.js
Normal file
589
js/services/directus.js
Normal file
@@ -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 };
|
||||||
Reference in New Issue
Block a user