refactor: modularize directus.js into 8 focused submodules with backward-compatible facade

This commit is contained in:
2026-02-08 13:57:46 +01:00
parent 9f48e073b8
commit 8073003460
10 changed files with 829 additions and 843 deletions

View File

@@ -1,850 +1,87 @@
/**
* Directus API Service for dgray.io
* Connects to https://api.dgray.io/
* Directus API Service for dgray.io — Facade
* Re-exports modular sub-services as a single backward-compatible singleton.
* @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._refreshPromise = 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<Object|null>} 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
})
if (response.status === 401 && this.refreshToken && !endpoint.startsWith('/auth/') && _retryCount < 1) {
if (!this._refreshPromise) {
this._refreshPromise = this.refreshSession().finally(() => {
this._refreshPromise = null
})
}
const refreshed = await this._refreshPromise
if (!refreshed) {
this.clearTokens()
}
return this.request(endpoint, options, _retryCount + 1)
}
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<Object>} 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<Object>} 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<Object>} 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|null>} 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<Listing>} 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<Object>} 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 : ''}`
}
import { client, DirectusError } from './directus/client.js'
import * as authApi from './directus/auth.js'
import * as listingsApi from './directus/listings.js'
import * as categoriesApi from './directus/categories.js'
import * as locationsApi from './directus/locations.js'
import * as conversationsApi from './directus/conversations.js'
import * as filesApi from './directus/files.js'
import * as notificationsApi from './directus/notifications.js'
const directus = {
// ── Client / Token ──
get baseUrl() { return client.baseUrl },
get accessToken() { return client.accessToken },
isAuthenticated: () => client.isAuthenticated(),
clearTokens: () => client.clearTokens(),
// ── Generic HTTP (used by external services like favorites) ──
get: (endpoint, params) => client.get(endpoint, params),
post: (endpoint, data) => client.post(endpoint, data),
patch: (endpoint, data) => client.patch(endpoint, data),
delete: (endpoint) => client.delete(endpoint),
// ── Auth ──
login: authApi.login,
logout: authApi.logout,
register: authApi.register,
requestPasswordReset: authApi.requestPasswordReset,
resetPassword: authApi.resetPassword,
getCurrentUser: authApi.getCurrentUser,
updateCurrentUser: authApi.updateCurrentUser,
// ── Listings ──
getListings: listingsApi.getListings,
getListing: listingsApi.getListing,
createListing: listingsApi.createListing,
updateListing: listingsApi.updateListing,
incrementListingViews: listingsApi.incrementListingViews,
deleteListing: listingsApi.deleteListing,
getMyListings: listingsApi.getMyListings,
searchListings: listingsApi.searchListings,
getListingsByCategory: listingsApi.getListingsByCategory,
getListingsByLocation: listingsApi.getListingsByLocation,
// ── Categories ──
getCategories: categoriesApi.getCategories,
getCategory: categoriesApi.getCategory,
getCategoryTree: categoriesApi.getCategoryTree,
buildCategoryTree: categoriesApi.buildCategoryTree,
getSubcategories: categoriesApi.getSubcategories,
// ── Locations ──
getLocations: locationsApi.getLocations,
getLocation: locationsApi.getLocation,
searchLocations: locationsApi.searchLocations,
getLocationsByRegion: locationsApi.getLocationsByRegion,
getLocationsByCountry: locationsApi.getLocationsByCountry,
// ── Conversations ──
getConversations: conversationsApi.getConversations,
getConversation: conversationsApi.getConversation,
getConversationMessages: conversationsApi.getConversationMessages,
sendMessage: conversationsApi.sendMessage,
startConversation: conversationsApi.startConversation,
findConversation: conversationsApi.findConversation,
updateConversationStatus: conversationsApi.updateConversationStatus,
// ── Files ──
uploadFile: filesApi.uploadFile,
uploadMultipleFiles: filesApi.uploadMultipleFiles,
getFileUrl: filesApi.getFileUrl,
getThumbnailUrl: filesApi.getThumbnailUrl,
// ── 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 })
}
getNotifications: notificationsApi.getNotifications,
getUnreadCount: notificationsApi.getUnreadCount,
markNotificationRead: notificationsApi.markNotificationRead,
markAllNotificationsRead: notificationsApi.markAllNotificationsRead,
deleteNotification: notificationsApi.deleteNotification
}
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 }
export { directus, DirectusError }