Розробка кастомних хуків (Hooks) Directus
Hook Extension в Directus — TypeScript-функція, яка підписується на події життєвого циклу: створення, оновлення, видалення записів, аутентифікація, запити до API. Використовується для побічних ефектів, валідації, інтеграцій з зовнішніми системами.
Типи подій
// Типи хуків:
action('items.create', handler) // після створення
action('items.update', handler) // після оновлення
action('items.delete', handler) // після видалення
filter('items.create', handler) // перед створенням (можна змінити дані)
filter('items.update', handler) // перед оновленням
action('auth.login', handler) // після успішного входу
action('auth.logout', handler) // після виходу
action('files.upload', handler) // після завантаження файлу
schedule('0 * * * *', handler) // cron
init('app.before', handler) // при запуску сервера
Повний приклад Hook Extension
// extensions/hooks/business-logic/index.ts
import type { HookExtensionContext } from '@directus/types'
import type { EventContext } from '@directus/types'
export default ({ action, filter, schedule }: HookExtensionContext) => {
// ===== АВТОМАТИЧНА ГЕНЕРАЦІЯ SLUG =====
filter('items.create', (payload, meta) => {
if (meta.collection === 'articles' && payload.title && !payload.slug) {
payload.slug = generateSlug(payload.title as string)
}
if (meta.collection === 'products' && payload.name && !payload.slug) {
payload.slug = generateSlug(payload.name as string)
}
return payload
})
// ===== ВАЛІДАЦІЯ =====
filter('items.create', async (payload, meta, context) => {
if (meta.collection !== 'orders') return payload
const { database } = context as EventContext & { database: any }
// Перевірити наявність товару
if (payload.items && Array.isArray(payload.items)) {
for (const item of payload.items) {
const product = await database('products')
.where({ id: item.product_id })
.first()
if (!product) {
throw new Error(`Product ${item.product_id} not found`)
}
if (product.stock < item.quantity) {
throw new Error(`Insufficient stock for "${product.name}"`)
}
}
}
return payload
})
// ===== ІНВАЛІДАЦІЯ КЕША =====
action('items.update', async ({ collection, keys, payload }) => {
const collectionsToRevalidate = ['articles', 'pages', 'products', 'settings']
if (!collectionsToRevalidate.includes(collection)) return
try {
await fetch(`${process.env.NEXTJS_URL}/api/revalidate`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-revalidate-secret': process.env.REVALIDATE_SECRET!,
},
body: JSON.stringify({ collection, keys }),
})
} catch (err) {
console.error('Failed to revalidate cache:', err)
}
})
// ===== СПОВІЩЕННЯ =====
action('items.create', async ({ collection, key, payload }, context) => {
if (collection !== 'contact_submissions') return
// Сповістити команду в Slack
await fetch(process.env.SLACK_WEBHOOK!, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
text: `📬 Нова заявка від ${payload.name} <${payload.email}>\n${payload.message}`,
}),
})
})
// ===== ЖУРНАЛ АУДИТУ =====
action('items.update', async ({ collection, keys, payload, accountability }) => {
if (!accountability?.user) return
// Логувати зміни в колекцію audit_logs
const { getSchema } = context as any
const schema = await getSchema()
// ... записати в audit_logs
})
// ===== СИНХРОНІЗАЦІЯ ЗАЛИШКІВ =====
action('items.update', async ({ collection, keys, payload }, context) => {
if (collection !== 'orders') return
if (payload.status !== 'paid') return
const { database } = context as EventContext & { database: any }
const order = await database('orders')
.where({ id: keys[0] })
.first()
if (order?.items) {
const items = JSON.parse(order.items)
for (const item of items) {
await database('products')
.where({ id: item.product_id })
.decrement('stock', item.quantity)
}
}
})
// ===== CRON — щоденний звіт =====
schedule('0 9 * * 1-5', async () => {
const response = await fetch(`${process.env.API_URL}/custom/reports/sales`)
const stats = await response.json()
await fetch(process.env.SLACK_WEBHOOK!, {
method: 'POST',
body: JSON.stringify({
text: `📊 Звіт за вчора: замовлень ${stats.count}, доходу ${stats.revenue.toLocaleString()} ₴`,
}),
})
})
}
function generateSlug(text: string): string {
const translitMap: Record<string, string> = {
а: 'a', б: 'b', в: 'v', г: 'g', д: 'd', е: 'e', ё: 'yo',
ж: 'zh', з: 'z', и: 'i', й: 'y', к: 'k', л: 'l', м: 'm',
н: 'n', о: 'o', п: 'p', р: 'r', с: 's', т: 't', у: 'u',
ф: 'f', х: 'h', ц: 'ts', ч: 'ch', ш: 'sh', щ: 'sch',
ъ: '', ы: 'y', ь: '', э: 'e', ю: 'yu', я: 'ya',
}
return text
.toLowerCase()
.replace(/[а-яё]/g, char => translitMap[char] || char)
.replace(/\s+/g, '-')
.replace(/[^\w-]/g, '')
.replace(/-+/g, '-')
.slice(0, 100)
}
Доступ до даних з хуків
// Через context.database (knex instance)
action('items.create', async (meta, context) => {
const { database, getSchema } = context as any
// Прямий SQL через Knex
const related = await database('categories')
.where({ id: meta.payload.category_id })
.first()
// Через ItemsService
const schema = await getSchema()
const { ItemsService } = context as any
const service = new ItemsService('articles', { schema, accountability: meta.accountability })
const items = await service.readByQuery({ filter: { status: { _eq: 'published' } } })
})
Терміни
Розробка набору хуків для бізнес-логіки (slug, валідація, сповіщення, інвалідація кеша) — 2–3 дні.







