/** * Directus API Service for dgray.io * Connects to https://api.dgray.io/ * @module services/directus */ /** @type {string} */ const DIRECTUS_URL = 'https://api.dgray.io' /** * @typedef {Object} AuthTokens * @property {string} access_token - JWT access token * @property {string} refresh_token - Refresh token for session renewal * @property {number} expires - Token expiry in seconds */ /** * @typedef {Object} Listing * @property {string} id - UUID * @property {string} title - Listing title * @property {string} [description] - Listing description * @property {number} price - Price value * @property {string} currency - Currency code (EUR, USD, CHF, XMR) * @property {string} status - Status (draft, published, sold, expired) * @property {Object} [location] - Location object * @property {Array} [images] - Array of image objects */ /** * @typedef {Object} Category * @property {string} id - UUID * @property {string} slug - URL-safe slug * @property {string} [parent] - Parent category ID * @property {Array} [translations] - Category translations */ /** * Main Directus API client * @class */ class DirectusService { constructor() { this.baseUrl = DIRECTUS_URL this.accessToken = null this.refreshToken = null this.tokenExpiry = null this.refreshTimeout = null this.loadTokens() this.setupVisibilityRefresh() } // ==================== 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() } } setupVisibilityRefresh() { document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible' && this.refreshToken) { const timeLeft = this.tokenExpiry - Date.now() if (timeLeft < 120000) { this.refreshSession() } this.scheduleTokenRefresh() } }) } isAuthenticated() { return !!this.accessToken && (!this.tokenExpiry || Date.now() < this.tokenExpiry) } // ==================== HTTP Methods ==================== /** * Make an HTTP request to the Directus API * @param {string} endpoint - API endpoint path * @param {RequestInit} [options={}] - Fetch options * @returns {Promise} Response data or null for 204 * @throws {DirectusError} On request failure */ async request(endpoint, options = {}, _retryCount = 0) { 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) { return this.request(endpoint, options) } else { this.clearTokens() return this.request(endpoint, options) } } if (response.status === 429 && _retryCount < 3) { const retryAfterHeader = response.headers.get('Retry-After') || '1' let waitMs if (parseInt(retryAfterHeader, 10) > 100) { waitMs = parseInt(retryAfterHeader, 10) } else { waitMs = parseInt(retryAfterHeader, 10) * 1000 } await new Promise(r => setTimeout(r, waitMs)) return this.request(endpoint, options, _retryCount + 1) } 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 }) } } /** * GET request with query parameters * @param {string} endpoint - API endpoint * @param {Object} [params={}] - Query parameters * @returns {Promise} Response data */ async get(endpoint, params = {}) { const queryString = this.buildQueryString(params) const url = queryString ? `${endpoint}?${queryString}` : endpoint return this.request(url, { method: 'GET' }) } /** * POST request with JSON body * @param {string} endpoint - API endpoint * @param {Object} data - Request body * @returns {Promise} Response data */ async post(endpoint, data) { return this.request(endpoint, { method: 'POST', body: JSON.stringify(data) }) } /** * PATCH request with JSON body * @param {string} endpoint - API endpoint * @param {Object} data - Request body * @returns {Promise} Response 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(_retryCount = 0) { if (!this.refreshToken) return false try { const response = await fetch(`${this.baseUrl}/auth/refresh`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ refresh_token: this.refreshToken, mode: 'json' }) }) if (!response.ok) throw new Error('Refresh failed') const result = await response.json() if (result.data) { this.saveTokens( result.data.access_token, result.data.refresh_token, result.data.expires ) return true } } catch (e) { if (_retryCount < 2) { await new Promise(r => setTimeout(r, 2000)) return this.refreshSession(_retryCount + 1) } 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', 'preferred_currency', 'preferred_locale'] }) return response.data } async updateCurrentUser(data) { const response = await this.patch('/users/me', data) return response.data } // ==================== Listings (Anzeigen) ==================== /** * Get listings with filters and pagination * @param {Object} [options={}] - Query options * @param {number} [options.limit=20] - Max items to return * @param {number} [options.page=1] - Page number * @param {string} [options.category] - Filter by category slug * @param {string} [options.search] - Search query * @returns {Promise<{items: Listing[], total: number}>} Paginated listings */ async getListings(options = {}) { const params = { fields: options.fields || [ 'id', 'status', 'title', 'slug', 'price', 'currency', 'condition', 'expires_at', 'date_created', 'user_created', 'images.directus_files_id.id', 'category.id', 'category.name', 'category.slug', 'category.icon', 'location.id', 'location.name', 'location.postal_code', 'location.country', 'location.latitude', 'location.longitude' ], filter: options.filter || { status: { _eq: 'published' }, _or: [ { expires_at: { _null: true } }, { expires_at: { _gt: '$NOW' } } ] }, 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 } } /** * Get a single listing by ID * @param {string} id - Listing UUID * @returns {Promise} Listing object or null */ 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', 'paid_at', 'payment_status', 'expires_at', 'date_created', 'user_created', 'images.directus_files_id.id', 'category.id', 'category.name', 'category.slug', 'category.translations.*', 'location.id', 'location.name', 'location.postal_code', 'location.country' ] }) return response.data } /** * Create a new listing * @param {Object} data - Listing data * @returns {Promise} Created listing */ 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 incrementListingViews(id) { // Check if user already viewed this listing (session-based) const viewedKey = `dgray_viewed_${id}` if (sessionStorage.getItem(viewedKey)) { return null } try { // Get current views const listing = await this.get(`/items/listings/${id}`, { fields: ['views'] }) const currentViews = listing.data?.views || 0 const newViews = currentViews + 1 // Increment views await this.patch(`/items/listings/${id}`, { views: newViews }) // Mark as viewed for this session sessionStorage.setItem(viewedKey, 'true') return newViews } catch (error) { console.error('Failed to increment views:', error) return null } } 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 }) } // ==================== 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.status', '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 } // ==================== Files (Dateien/Bilder) ==================== /** * Upload a file to Directus * @param {File} file - File to upload * @param {Object} [options={}] - Upload options * @param {string} [options.folder] - Target folder ID * @returns {Promise} Uploaded file data */ 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) } /** * Get URL for a file with optional transformations * @param {string} fileId - File UUID * @param {Object} [options={}] - Transform options * @param {number} [options.width] - Resize width * @param {number} [options.height] - Resize height * @param {string} [options.fit] - Fit mode (cover, contain, etc.) * @returns {string|null} File URL or null */ 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 : ''}` } // ── Notifications ── async getNotifications(userHash, options = {}) { const params = { fields: ['id', 'type', 'reference_id', 'read', 'date_created', 'user_hash'], filter: { user_hash: { _eq: userHash } }, sort: ['-date_created'], limit: options.limit || 50 } if (options.unreadOnly) { params.filter.read = { _eq: false } } const response = await this.get('/items/notifications', params) return response.data } async getUnreadCount(userHash) { const response = await this.get('/items/notifications', { filter: { user_hash: { _eq: userHash }, read: { _eq: false } }, aggregate: { count: 'id' } }) return parseInt(response.data?.[0]?.count?.id || '0', 10) } async markNotificationRead(id) { return this.patch(`/items/notifications/${id}`, { read: true }) } async markAllNotificationsRead(userHash) { const unread = await this.getNotifications(userHash, { unreadOnly: true }) const updates = unread.map(n => this.markNotificationRead(n.id)) return Promise.all(updates) } async deleteNotification(id) { return this.delete(`/items/notifications/${id}`) } getThumbnailUrl(fileId, size = 300) { return this.getFileUrl(fileId, { width: size, height: size, fit: 'cover', format: 'webp', quality: 80 }) } } 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 }