737 lines
22 KiB
JavaScript
737 lines
22 KiB
JavaScript
/**
|
|
* Directus API Service for dgray.io
|
|
* Connects to 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 before expiry
|
|
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 expired - try refresh (but not for auth endpoints)
|
|
if (response.status === 401 && this.refreshToken && !endpoint.startsWith('/auth/')) {
|
|
const refreshed = await this.refreshSession()
|
|
if (refreshed) {
|
|
headers['Authorization'] = `Bearer ${this.accessToken}`
|
|
return this.request(endpoint, options)
|
|
} else {
|
|
// Refresh failed - clear tokens to prevent loops
|
|
this.clearTokens()
|
|
}
|
|
}
|
|
|
|
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 (key === 'sort' && Array.isArray(value)) {
|
|
searchParams.set(key, value.join(','))
|
|
} else if (key === 'fields' && Array.isArray(value)) {
|
|
searchParams.set(key, value.join(','))
|
|
} else 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) {
|
|
// Ignore errors - tokens will be cleared anyway
|
|
}
|
|
}
|
|
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) {
|
|
// Public registration (no verification required)
|
|
return this.post('/users/register', { email, password })
|
|
}
|
|
|
|
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 || [
|
|
'id',
|
|
'status',
|
|
'title',
|
|
'slug',
|
|
'price',
|
|
'currency',
|
|
'condition',
|
|
'date_created',
|
|
'images.directus_files_id.id',
|
|
'category.id',
|
|
'category.name',
|
|
'category.slug',
|
|
'category.icon'
|
|
],
|
|
filter: options.filter || { status: { _eq: 'published' } },
|
|
sort: options.sort || ['-date_created'],
|
|
limit: options.limit || 20,
|
|
page: options.page || 1
|
|
}
|
|
|
|
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: [
|
|
'id',
|
|
'status',
|
|
'title',
|
|
'slug',
|
|
'description',
|
|
'price',
|
|
'currency',
|
|
'price_mode',
|
|
'price_type',
|
|
'condition',
|
|
'shipping',
|
|
'shipping_cost',
|
|
'views',
|
|
'expires_at',
|
|
'monero_address',
|
|
'date_created',
|
|
'images.directus_files_id.id',
|
|
'category.id',
|
|
'category.name',
|
|
'category.slug'
|
|
]
|
|
})
|
|
return response.data
|
|
}
|
|
|
|
async createListing(data) {
|
|
const response = await this.post('/items/listings', data)
|
|
return response?.data || response
|
|
}
|
|
|
|
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() {
|
|
const response = await this.get('/items/listings', {
|
|
fields: ['*', 'images.directus_files_id.id', 'category.id', 'category.name', 'location.name'],
|
|
filter: { user_created: { _eq: '$CURRENT_USER' } },
|
|
sort: ['-date_created']
|
|
})
|
|
return response.data || []
|
|
}
|
|
|
|
async searchListings(query, options = {}) {
|
|
return this.getListings({
|
|
search: query,
|
|
...options
|
|
})
|
|
}
|
|
|
|
async getListingsByCategory(categoryId, options = {}) {
|
|
return this.getListings({
|
|
filter: {
|
|
status: { _eq: 'published' },
|
|
category: { _eq: categoryId }
|
|
},
|
|
...options
|
|
})
|
|
}
|
|
|
|
async getListingsByLocation(locationId, options = {}) {
|
|
return this.getListings({
|
|
filter: {
|
|
status: { _eq: 'published' },
|
|
location: { _eq: locationId }
|
|
},
|
|
...options
|
|
})
|
|
}
|
|
|
|
async incrementViews(id) {
|
|
const listing = await this.getListing(id)
|
|
if (listing) {
|
|
return this.patch(`/items/listings/${id}`, {
|
|
views: (listing.views || 0) + 1
|
|
})
|
|
}
|
|
}
|
|
|
|
// ==================== Categories (Kategorien) ====================
|
|
|
|
async getCategories() {
|
|
const response = await this.get('/items/categories', {
|
|
fields: ['*', 'translations.*'],
|
|
filter: { status: { _eq: 'published' } },
|
|
sort: ['sort', 'name'],
|
|
limit: -1
|
|
})
|
|
return response.data || []
|
|
}
|
|
|
|
async getCategory(idOrSlug) {
|
|
const isUuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(idOrSlug)
|
|
|
|
if (isUuid) {
|
|
const response = await this.get(`/items/categories/${idOrSlug}`, {
|
|
fields: ['*', 'translations.*', 'parent.*']
|
|
})
|
|
return response.data
|
|
}
|
|
|
|
const response = await this.get('/items/categories', {
|
|
fields: ['*', 'translations.*', 'parent.*'],
|
|
filter: { slug: { _eq: idOrSlug } },
|
|
limit: 1
|
|
})
|
|
return response.data?.[0] || null
|
|
}
|
|
|
|
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)
|
|
}))
|
|
}
|
|
|
|
async getSubcategories(parentId) {
|
|
const response = await this.get('/items/categories', {
|
|
fields: ['*', 'translations.*'],
|
|
filter: {
|
|
status: { _eq: 'published' },
|
|
parent: { _eq: parentId }
|
|
},
|
|
sort: ['sort', 'name']
|
|
})
|
|
return response.data
|
|
}
|
|
|
|
// ==================== Locations (Standorte) ====================
|
|
|
|
async getLocations(options = {}) {
|
|
const response = await this.get('/items/locations', {
|
|
fields: options.fields || ['*'],
|
|
filter: options.filter || {},
|
|
sort: options.sort || ['name'],
|
|
limit: options.limit || -1
|
|
})
|
|
return response.data
|
|
}
|
|
|
|
async getLocation(id) {
|
|
const response = await this.get(`/items/locations/${id}`)
|
|
return response.data
|
|
}
|
|
|
|
async searchLocations(query) {
|
|
const response = await this.get('/items/locations', {
|
|
search: query,
|
|
limit: 20
|
|
})
|
|
return response.data
|
|
}
|
|
|
|
async getLocationsByRegion(region) {
|
|
const response = await this.get('/items/locations', {
|
|
filter: { region: { _eq: region } },
|
|
sort: ['name']
|
|
})
|
|
return response.data
|
|
}
|
|
|
|
async getLocationsByCountry(country) {
|
|
const response = await this.get('/items/locations', {
|
|
filter: { country: { _eq: country } },
|
|
sort: ['region', 'name']
|
|
})
|
|
return response.data
|
|
}
|
|
|
|
// ==================== Conversations (Zero-Knowledge Chat) ====================
|
|
|
|
async getConversations(participantHash) {
|
|
const response = await this.get('/items/conversations', {
|
|
fields: [
|
|
'*',
|
|
'listing_id.id',
|
|
'listing_id.title',
|
|
'listing_id.images.directus_files_id.id'
|
|
],
|
|
filter: {
|
|
_or: [
|
|
{ participant_hash_1: { _eq: participantHash } },
|
|
{ participant_hash_2: { _eq: participantHash } }
|
|
]
|
|
},
|
|
sort: ['-date_updated']
|
|
})
|
|
return response.data
|
|
}
|
|
|
|
async getConversation(id) {
|
|
const response = await this.get(`/items/conversations/${id}`, {
|
|
fields: [
|
|
'*',
|
|
'listing_id.*',
|
|
'listing_id.images.directus_files_id.*'
|
|
]
|
|
})
|
|
return response.data
|
|
}
|
|
|
|
async getConversationMessages(conversationId) {
|
|
const response = await this.get('/items/messages', {
|
|
fields: ['*'],
|
|
filter: { conversation: { _eq: conversationId } },
|
|
sort: ['date_created']
|
|
})
|
|
return response.data
|
|
}
|
|
|
|
async sendMessage(conversationId, senderHash, encryptedContent, nonce, type = 'text') {
|
|
const response = await this.post('/items/messages', {
|
|
conversation: conversationId,
|
|
sender_hash: senderHash,
|
|
content_encrypted: encryptedContent,
|
|
nonce: nonce,
|
|
type: type
|
|
})
|
|
return response.data
|
|
}
|
|
|
|
async startConversation(listingId, participantHash1, participantHash2, publicKey1, publicKey2) {
|
|
const response = await this.post('/items/conversations', {
|
|
listing_id: listingId,
|
|
participant_hash_1: participantHash1,
|
|
participant_hash_2: participantHash2,
|
|
public_key_1: publicKey1,
|
|
public_key_2: publicKey2,
|
|
status: 'active'
|
|
})
|
|
return response.data
|
|
}
|
|
|
|
async findConversation(listingId, participantHash1, participantHash2) {
|
|
const response = await this.get('/items/conversations', {
|
|
filter: {
|
|
listing_id: { _eq: listingId },
|
|
_or: [
|
|
{
|
|
_and: [
|
|
{ participant_hash_1: { _eq: participantHash1 } },
|
|
{ participant_hash_2: { _eq: participantHash2 } }
|
|
]
|
|
},
|
|
{
|
|
_and: [
|
|
{ participant_hash_1: { _eq: participantHash2 } },
|
|
{ participant_hash_2: { _eq: participantHash1 } }
|
|
]
|
|
}
|
|
]
|
|
},
|
|
limit: 1
|
|
})
|
|
return response.data?.[0] || null
|
|
}
|
|
|
|
async updateConversationStatus(id, status) {
|
|
const response = await this.patch(`/items/conversations/${id}`, { status })
|
|
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 }
|