@serhii.mazur/directus-gu-logs 1.0.8 → 1.0.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.MD CHANGED
@@ -1,12 +1,12 @@
1
1
  # 📦 Custom Directus Logs Utility
2
2
 
3
- A lightweight utility class for logging errors, creating activity logs, and sending notifications in Directus extensions.
3
+ A modular utility for logging errors, tracking activity, and sending notifications (Directus in-app + Slack) from Directus extensions.
4
4
 
5
- ## 📋 Requirements:
5
+ ## 📋 Requirements
6
6
 
7
- ### 1. Logs Collection (logs)
7
+ ### 1. Logs Collection (`logs`)
8
8
 
9
- You need to create a collection named `logs` with the following fields:
9
+ Create a collection named `logs` with the following fields:
10
10
 
11
11
  | Field Name | Type | Description |
12
12
  | --------------- | --------- | ---------------------------- |
@@ -16,24 +16,37 @@ You need to create a collection named `logs` with the following fields:
16
16
  | `function_name` | string | Name of the function |
17
17
  | `error` | text/code | Error message or stack trace |
18
18
 
19
- ### 2. Global Collection (global)
19
+ ### 2. Global Collection (`global`)
20
20
 
21
- Add the following field to the `global` collection:
21
+ Add the following field to the `global` singleton collection:
22
22
 
23
- | Field Name | Type | Description |
24
- | ------------------------ | ------------------------------- | ---------------------------------- |
25
- | `error_notice_recipient` | m2m → directus_users (junction) | Recipients for error notifications |
23
+ | Field Name | Type | Description |
24
+ | ------------------------ | ------------------------------- | -------------------------------------------- |
25
+ | `error_notice_recipient` | m2m → directus_users (junction) | Recipients for error notifications |
26
+ | `slack_webhook_url` | input | Incoming webhook URL for Slack notifications |
26
27
 
27
- All linked users from this field will receive internal notifications when errors occur.
28
+ All linked users will receive internal Directus notifications when errors occur (unless a recipient override is passed).
29
+
30
+ ### 3. Environment Variables
31
+
32
+ | Variable | Required | Description |
33
+ | ------------- | -------- | ------------------------------------------------------------- |
34
+ | `BACKEND_URL` | Optional | Shown in notifications; falls back to `PUBLIC_URL` |
35
+ | `BRANCH` | Optional | Environment label (e.g. `main`, `staging`); defaults to `dev` |
36
+
37
+ ---
28
38
 
29
39
  ## 🚀 Features
30
40
 
31
- - Save structured error logs to a collection
41
+ - Persist structured error logs to a Directus collection
42
+ - Send Slack Block Kit notifications via incoming webhook
43
+ - Send Directus in-app notifications with automatic recipient resolution
32
44
  - Create Directus activity records
33
- - Send internal Directus notifications
34
- - Automatic recipient resolution from global settings
35
- - Support for manual recipient override
36
- - Includes project metadata in notifications (environment, URL, timestamp)
45
+ - Shared project metadata (name, URL, environment) injected into all notifications
46
+ - Recipient deduplication
47
+ - Graceful fallbacks all methods catch and log their own errors
48
+
49
+ ---
37
50
 
38
51
  ## 📦 Installation
39
52
 
@@ -41,36 +54,84 @@ All linked users from this field will receive internal notifications when errors
41
54
  npm i @serhii.mazur/directus-gu-logs
42
55
  ```
43
56
 
57
+ ---
58
+
59
+ ## 🗂 Architecture
60
+
61
+ The package is split into three focused classes:
62
+
63
+ ```
64
+ Logs ← main entry point; orchestrates everything
65
+ ├── SlackNotifier ← formats and sends Slack Block Kit payloads
66
+ └── DirectusNotifier ← resolves recipients, sends in-app notifications
67
+ ```
68
+
69
+ All three are exported and can be used independently if needed.
70
+
71
+ ---
72
+
44
73
  ## 🧩 Usage
45
74
 
75
+ ### Basic (via `Logs`)
76
+
46
77
  ```ts
47
78
  import { Logs } from "@serhii.mazur/directus-gu-logs";
48
79
 
49
80
  const logs = new Logs(context, "my-extension");
50
81
 
51
- // Save error log
82
+ // Persist error log to DB + notify Slack
52
83
  await logs.printLogs("myFunction", "Something went wrong");
53
84
 
54
- // Create activity record
85
+ // Persist error log without Slack notification
86
+ await logs.printLogs("myFunction", "Something went wrong", false);
87
+
88
+ // Create a Directus activity record
55
89
  await logs.createActivity("create", "collection_name", "item_id");
56
90
 
57
- // Send notification (auto recipients from global settings)
91
+ // Send Directus in-app notification (recipients from global settings)
58
92
  await logs.createNotification("An error occurred");
59
93
 
60
- // Send notification with custom subject
94
+ // With custom subject
61
95
  await logs.createNotification("An error occurred", "Custom Subject");
62
96
 
63
- // Send notification to specific recipient
97
+ // Override recipient
64
98
  await logs.createNotification("An error occurred", "Custom Subject", "user_id");
65
99
 
66
- // Send notification with related collection + item
100
+ // With linked collection + item
67
101
  await logs.createNotification("An error occurred", "Custom Subject", null, "collection_name", "item_id");
102
+
103
+ // Send Slack notification directly
104
+ await logs.createSlackNotification("Deploy failed on main", "CI Alert");
105
+ ```
106
+
107
+ ### Advanced (direct class usage)
108
+
109
+ ```ts
110
+ import { SlackNotifier, DirectusNotifier } from "@serhii.mazur/directus-gu-logs";
111
+
112
+ // Use SlackNotifier standalone
113
+ const slack = new SlackNotifier(context, "my-extension");
114
+ await slack.notify("Custom message", "Alert Title", projectMeta);
115
+
116
+ // Send a raw Slack payload
117
+ await slack.send({ text: "plain fallback", blocks: [...] });
118
+
119
+ // Use DirectusNotifier standalone
120
+ const directus = new DirectusNotifier(context, "my-extension");
121
+ await directus.notify("Message", projectMeta, {
122
+ subject: "Custom Subject",
123
+ recipientOverride: "user-uuid",
124
+ collection: "pages",
125
+ item: "42",
126
+ });
68
127
  ```
69
128
 
70
129
  ---
71
130
 
72
131
  ## ⚙️ Constructor
73
132
 
133
+ ### `Logs`
134
+
74
135
  ```ts
75
136
  constructor(
76
137
  context: ApiExtensionContext,
@@ -79,44 +140,42 @@ constructor(
79
140
  )
80
141
  ```
81
142
 
82
- | Param | Type | Description |
83
- | ---------------- | ------------------- | ------------------------------------ |
84
- | `context` | ApiExtensionContext | Directus extension context |
85
- | `extension` | string | Name of your extension |
86
- | `collectionName` | string (optional) | Logs collection name (default: logs) |
143
+ | Param | Type | Default | Description |
144
+ | ---------------- | ------------------- | ------- | -------------------------------- |
145
+ | `context` | ApiExtensionContext | — | Directus extension context |
146
+ | `extension` | string | — | Name of your extension |
147
+ | `collectionName` | string | `logs` | Target collection for error logs |
148
+
149
+ ---
87
150
 
88
151
  ## 🧠 How It Works
89
152
 
90
- ### Logging
153
+ ### `printLogs(functionName, error, notifySlack?)`
91
154
 
92
- - Saves structured logs into the logs collection
93
- - Automatically attaches timestamp and extension name
94
- - Fallback to console if DB write fails
155
+ 1. Writes a structured entry to the logs collection
156
+ 2. If `notifySlack` is `true` (default), sends a Slack Block Kit message with extension name, function, and error
157
+ 3. Always writes to `context.logger.error` as well
158
+ 4. Catches its own failure — a broken DB write won't throw
95
159
 
96
- ---
160
+ ### `createActivity(action, collection, id)`
97
161
 
98
- ### Activity Tracking
162
+ Creates a Directus activity record via `ActivityService`. User, IP, and user agent are set to `null` in extension hook context where no accountability is available.
99
163
 
100
- - Uses Directus ActivityService
101
- - Automatically includes:
102
- - user
103
- - IP address
104
- - user agent
105
- - origin
164
+ ### `createNotification(message, subject?, recipientOverride?, collection?, item?)`
106
165
 
107
- ---
166
+ Resolves recipients in this order:
167
+
168
+ 1. `recipientOverride` (if provided)
169
+ 2. `global.error_notice_recipient` linked users
170
+
171
+ Then sends one in-app notification per recipient. Each notification includes:
108
172
 
109
- ### Notifications
110
-
111
- - Resolves recipients in this order:
112
- 1. Explicit recipientOverride
113
- 2. global.notice_recipient
114
- 3. global.developer_notice_recipient
115
- - Automatically:
116
- - Deduplicates recipients
117
- - Includes project metadata:
118
- - Project name
119
- - Backend URL
120
- - Environment (branch)
121
- - UTC timestamp
122
- - Sends one notification per recipient
173
+ - Custom or default subject with the project name appended
174
+ - The message body
175
+ - Environment label, backend URL, and UTC timestamp
176
+
177
+ ### `createSlackNotification(message, subject?)`
178
+
179
+ Sends a formatted Slack Block Kit message via `slack_webhook_url`. Silently skips if the var is not set. The payload includes project name, environment, extension name, backend URL, and the message body.
180
+
181
+ ---
package/dist/index.d.ts CHANGED
@@ -1,13 +1,3 @@
1
- import { ApiExtensionContext } from "@directus/extensions";
2
- import type { PrimaryKey } from "@directus/types";
3
- export declare class Logs {
4
- protected context: ApiExtensionContext;
5
- protected extension: string;
6
- protected collectionName: string;
7
- constructor(context: ApiExtensionContext, extension: string, collectionName?: string);
8
- private getSchema;
9
- private createOne;
10
- printLogs(functionName: string, error: string): Promise<void>;
11
- createActivity(action: string, collection: string, id: PrimaryKey): Promise<void>;
12
- createNotification(message: string, customSubject?: string | null, recipientOverride?: string | null, collection?: string | null, item?: string | null): Promise<void>;
13
- }
1
+ export { Logs } from "./services/Logs";
2
+ export { SlackNotifier } from "./services/SlackNotifier";
3
+ export type { LogEntry, RecipientLinkRow, ProjectMeta, SlackBlock, SlackPayload, ApiExtensionContext, PrimaryKey, } from "./types";
package/dist/index.js CHANGED
@@ -1,133 +1,2 @@
1
- function extractRecipientIds(rows) {
2
- const recipients = rows
3
- ?.map(row => {
4
- const user = row?.directus_users_id;
5
- if (typeof user === "string")
6
- return user;
7
- return typeof user?.id === "string" ? user.id : null;
8
- })
9
- .filter((value) => Boolean(value)) ?? [];
10
- return Array.from(new Set(recipients));
11
- }
12
- export class Logs {
13
- constructor(context, extension, collectionName = "logs") {
14
- this.context = context;
15
- this.extension = extension;
16
- this.collectionName = collectionName;
17
- }
18
- async getSchema() {
19
- return await this.context.getSchema();
20
- }
21
- async createOne(data) {
22
- const schema = await this.getSchema();
23
- const itemsService = new this.context.services.ItemsService(this.collectionName, {
24
- database: this.context.database,
25
- schema,
26
- });
27
- return await itemsService.createOne(data);
28
- }
29
- async printLogs(functionName, error) {
30
- const data = {
31
- collection: this.collectionName,
32
- date_created: new Date().toISOString(),
33
- extension: this.extension,
34
- function_name: functionName,
35
- error,
36
- };
37
- try {
38
- await this.createOne(data);
39
- console.error(`🚀 [${this.extension}] ${functionName}:`, error);
40
- }
41
- catch (error) {
42
- console.error("❌ Failed to save logs:", error);
43
- }
44
- }
45
- async createActivity(action, collection, id) {
46
- try {
47
- const schema = await this.getSchema();
48
- const services = this.context.services;
49
- const accountability = services.accountability;
50
- const activityService = new services.ActivityService({
51
- schema: schema,
52
- accountability: accountability,
53
- knex: this.context.database,
54
- });
55
- await activityService.createOne({
56
- action,
57
- user: accountability?.user ?? null,
58
- collection,
59
- ip: accountability?.ip ?? null,
60
- user_agent: accountability?.userAgent ?? null,
61
- origin: accountability?.origin ?? null,
62
- item: id,
63
- });
64
- }
65
- catch (error) {
66
- console.error("❌ Failed to create activity log:", error);
67
- }
68
- }
69
- async createNotification(message, customSubject = null, recipientOverride = null, collection = null, item = null) {
70
- try {
71
- const schema = await this.getSchema();
72
- const { database, services } = this.context;
73
- const globalService = new services.ItemsService("global", {
74
- database,
75
- schema,
76
- });
77
- // Check for passed recipient, fallback to global settings
78
- let recipients = [];
79
- if (recipientOverride) {
80
- recipients = [recipientOverride];
81
- }
82
- else {
83
- const globalSettings = await globalService.readSingleton({
84
- fields: ["error_notice_recipient.directus_users_id.id"],
85
- });
86
- recipients = extractRecipientIds(globalSettings?.error_notice_recipient);
87
- }
88
- if (recipients.length === 0) {
89
- this.printLogs(this.extension, "No recipients defined (override or global settings)");
90
- return;
91
- }
92
- const notificationService = new services.NotificationsService({ schema });
93
- // Project Data
94
- const settings = await database.select("project_name").from("directus_settings").first();
95
- const projectName = settings?.project_name || "Unknown Project";
96
- const backendUrl = process.env.BACKEND_URL || this.context.env?.PUBLIC_URL || "Unknown URL";
97
- const environment = process.env.BRANCH || "dev";
98
- const now = new Date();
99
- const timestamp = new Intl.DateTimeFormat("en-US", {
100
- month: "2-digit",
101
- day: "2-digit",
102
- year: "numeric",
103
- hour: "2-digit",
104
- minute: "2-digit",
105
- hour12: false,
106
- timeZone: "UTC",
107
- }).format(now);
108
- // Compose subject & message
109
- const subject = customSubject
110
- ? `${customSubject} - ${projectName}`
111
- : `Directus Error Notification - ${projectName}`;
112
- const fullMessage = `
113
- ${message}<br><br>
114
- <strong>Environment:</strong> ${environment}<br>
115
- <strong>Backend URL:</strong> <a href="${backendUrl}" target="_blank">${backendUrl}</a><br>
116
- <strong>Date/Time (UTC):</strong> ${timestamp}
117
- `.trim();
118
- for (const recipient of recipients) {
119
- await notificationService.createOne({
120
- recipient,
121
- sender: recipient,
122
- subject,
123
- message: fullMessage,
124
- collection,
125
- item,
126
- });
127
- }
128
- }
129
- catch (error) {
130
- console.error("❌ Failed to create notification:", error);
131
- }
132
- }
133
- }
1
+ export { Logs } from "./services/Logs";
2
+ export { SlackNotifier } from "./services/SlackNotifier";
@@ -0,0 +1,3 @@
1
+ export { Logs } from "./services/Logs";
2
+ export { SlackNotifier } from "./services/SlackNotifier";
3
+ export type { LogEntry, RecipientLinkRow, ProjectMeta, SlackBlock, SlackPayload, ApiExtensionContext, PrimaryKey, } from "./types";
@@ -0,0 +1,2 @@
1
+ export { Logs } from "./services/Logs";
2
+ export { SlackNotifier } from "./services/SlackNotifier";
@@ -0,0 +1,13 @@
1
+ import type { ApiExtensionContext, ProjectMeta } from "../types";
2
+ export declare class DirectusNotifier {
3
+ private readonly context;
4
+ private readonly extension;
5
+ constructor(context: ApiExtensionContext, extension: string);
6
+ private getSchema;
7
+ notify(message: string, meta: ProjectMeta, options?: {
8
+ subject?: string | null;
9
+ recipientOverride?: string | null;
10
+ collection?: string | null;
11
+ item?: string | null;
12
+ }): Promise<void>;
13
+ }
@@ -0,0 +1,77 @@
1
+ function extractRecipientIds(rows) {
2
+ const recipients = rows
3
+ ?.map(row => {
4
+ const user = row?.directus_users_id;
5
+ if (typeof user === "string")
6
+ return user;
7
+ return typeof user?.id === "string" ? user.id : null;
8
+ })
9
+ .filter((v) => Boolean(v)) ?? [];
10
+ return Array.from(new Set(recipients));
11
+ }
12
+ export class DirectusNotifier {
13
+ constructor(context, extension) {
14
+ this.context = context;
15
+ this.extension = extension;
16
+ }
17
+ async getSchema() {
18
+ return this.context.getSchema();
19
+ }
20
+ async notify(message, meta, options = {}) {
21
+ const { subject = null, recipientOverride = null, collection = null, item = null } = options;
22
+ try {
23
+ const schema = await this.getSchema();
24
+ const { database, services } = this.context;
25
+ let recipients = [];
26
+ if (recipientOverride) {
27
+ recipients = [recipientOverride];
28
+ }
29
+ else {
30
+ const globalService = new services.ItemsService("global", { database, schema });
31
+ const globalSettings = await globalService.readSingleton({
32
+ fields: ["error_notice_recipient.directus_users_id.id"],
33
+ });
34
+ recipients = extractRecipientIds(globalSettings?.error_notice_recipient);
35
+ }
36
+ if (recipients.length === 0) {
37
+ this.context.logger.warn({
38
+ msg: `[${this.extension}] No notification recipients defined (override or global settings)`,
39
+ });
40
+ return;
41
+ }
42
+ const notificationService = new services.NotificationsService({ schema });
43
+ const now = new Date();
44
+ const timestamp = new Intl.DateTimeFormat("en-US", {
45
+ month: "2-digit",
46
+ day: "2-digit",
47
+ year: "numeric",
48
+ hour: "2-digit",
49
+ minute: "2-digit",
50
+ hour12: false,
51
+ timeZone: "UTC",
52
+ }).format(now);
53
+ const resolvedSubject = subject
54
+ ? `${subject} - ${meta.projectName}`
55
+ : `Directus Error Notification - ${meta.projectName}`;
56
+ const fullMessage = [
57
+ message,
58
+ `<strong>Environment:</strong> ${meta.environment}`,
59
+ `<strong>Backend URL:</strong> <a href="${meta.backendUrl}" target="_blank">${meta.backendUrl}</a>`,
60
+ `<strong>Date/Time (UTC):</strong> ${timestamp}`,
61
+ ].join("<br><br>");
62
+ for (const recipient of recipients) {
63
+ await notificationService.createOne({
64
+ recipient,
65
+ sender: recipient,
66
+ subject: resolvedSubject,
67
+ message: fullMessage,
68
+ collection,
69
+ item,
70
+ });
71
+ }
72
+ }
73
+ catch (error) {
74
+ this.context.logger.error({ msg: "❌ Failed to send Directus notification", error });
75
+ }
76
+ }
77
+ }
@@ -0,0 +1,21 @@
1
+ import type { ApiExtensionContext, PrimaryKey } from "../types";
2
+ import { SlackNotifier } from "../services/SlackNotifier";
3
+ import { DirectusNotifier } from "./DirectusNotifier";
4
+ export declare class Logs {
5
+ protected readonly context: ApiExtensionContext;
6
+ protected readonly extension: string;
7
+ protected readonly collectionName: string;
8
+ protected readonly slack: SlackNotifier;
9
+ protected readonly directus: DirectusNotifier;
10
+ constructor(context: ApiExtensionContext, extension: string, collectionName?: string);
11
+ private getSchema;
12
+ private getProjectMeta;
13
+ private createOne;
14
+ /**
15
+ * Persists an error entry to the logs collection, optionally notifying via Slack.
16
+ */
17
+ printLogs(functionName: string, error: string, notifySlack?: boolean): Promise<void>;
18
+ createActivity(action: string, collection: string, id: PrimaryKey): Promise<void>;
19
+ createNotification(message: string, customSubject?: string | null, recipientOverride?: string | null, collection?: string | null, item?: string | null): Promise<void>;
20
+ createSlackNotification(message: string, customSubject?: string | null): Promise<void>;
21
+ }
@@ -0,0 +1,96 @@
1
+ import { SlackNotifier } from "../services/SlackNotifier";
2
+ import { DirectusNotifier } from "./DirectusNotifier";
3
+ export class Logs {
4
+ constructor(context, extension, collectionName = "logs") {
5
+ this.context = context;
6
+ this.extension = extension;
7
+ this.collectionName = collectionName;
8
+ this.slack = new SlackNotifier(context, extension);
9
+ this.directus = new DirectusNotifier(context, extension);
10
+ }
11
+ async getSchema() {
12
+ return this.context.getSchema();
13
+ }
14
+ async getProjectMeta() {
15
+ const settings = await this.context.database.select("project_name").from("directus_settings").first();
16
+ return {
17
+ projectName: settings?.project_name ?? "Unknown Project",
18
+ backendUrl: process.env.BACKEND_URL ?? this.context.env?.PUBLIC_URL ?? "Unknown URL",
19
+ environment: process.env.BRANCH ?? "dev",
20
+ };
21
+ }
22
+ async createOne(data) {
23
+ const schema = await this.getSchema();
24
+ const itemsService = new this.context.services.ItemsService(this.collectionName, {
25
+ database: this.context.database,
26
+ schema,
27
+ });
28
+ return await itemsService.createOne(data);
29
+ }
30
+ /**
31
+ * Persists an error entry to the logs collection, optionally notifying via Slack.
32
+ */
33
+ async printLogs(functionName, error, notifySlack = true) {
34
+ const data = {
35
+ collection: this.collectionName,
36
+ date_created: new Date().toISOString(),
37
+ extension: this.extension,
38
+ function_name: functionName,
39
+ error,
40
+ };
41
+ try {
42
+ await this.createOne(data);
43
+ this.context.logger.info({ msg: `🚀 [${this.extension}] ${functionName}:`, error });
44
+ }
45
+ catch (err) {
46
+ this.context.logger.error({ msg: "❌ Failed to save log entry", error: err });
47
+ return;
48
+ }
49
+ try {
50
+ if (notifySlack) {
51
+ const meta = await this.getProjectMeta();
52
+ await this.slack.notify(`*Function:* ${functionName}\n*Error:* ${error}`, "Extension Error", meta);
53
+ }
54
+ }
55
+ catch (err) {
56
+ this.context.logger.error({ msg: "Slack failed", err });
57
+ }
58
+ }
59
+ async createActivity(action, collection, id) {
60
+ try {
61
+ const schema = await this.getSchema();
62
+ const { services, database } = this.context;
63
+ const accountability = services.accountability;
64
+ const activityService = new services.ActivityService({
65
+ schema,
66
+ accountability,
67
+ knex: database,
68
+ });
69
+ await activityService.createOne({
70
+ action,
71
+ user: accountability?.user ?? null,
72
+ collection,
73
+ ip: accountability?.ip ?? null,
74
+ user_agent: accountability?.userAgent ?? null,
75
+ origin: accountability?.origin ?? null,
76
+ item: id,
77
+ });
78
+ }
79
+ catch (error) {
80
+ this.context.logger.error({ msg: "❌ Failed to create activity log", error });
81
+ }
82
+ }
83
+ async createNotification(message, customSubject = null, recipientOverride = null, collection = null, item = null) {
84
+ const meta = await this.getProjectMeta();
85
+ await this.directus.notify(message, meta, {
86
+ subject: customSubject,
87
+ recipientOverride,
88
+ collection,
89
+ item,
90
+ });
91
+ }
92
+ async createSlackNotification(message, customSubject = null) {
93
+ const meta = await this.getProjectMeta();
94
+ await this.slack.notify(message, customSubject, meta);
95
+ }
96
+ }
@@ -0,0 +1,9 @@
1
+ import type { ApiExtensionContext, ProjectMeta, SlackPayload } from "../types";
2
+ export declare class SlackNotifier {
3
+ private readonly context;
4
+ private readonly extension;
5
+ constructor(context: ApiExtensionContext, extension: string);
6
+ getWebHookUrl(): Promise<any>;
7
+ send(payload: SlackPayload): Promise<void>;
8
+ notify(message: string, subject: string | null | undefined, meta: ProjectMeta): Promise<void>;
9
+ }
@@ -0,0 +1,53 @@
1
+ export class SlackNotifier {
2
+ constructor(context, extension) {
3
+ this.context = context;
4
+ this.extension = extension;
5
+ }
6
+ async getWebHookUrl() {
7
+ const settings = await this.context.database.select("slack_webhook_url").from("global").first();
8
+ return settings?.slack_webhook_url;
9
+ }
10
+ async send(payload) {
11
+ const webhook = await this.getWebHookUrl();
12
+ if (!webhook) {
13
+ this.context.logger.info({ msg: "⚠️ SLACK_WEBHOOK_URL is not configured" });
14
+ return;
15
+ }
16
+ try {
17
+ await fetch(webhook, {
18
+ method: "POST",
19
+ headers: { "Content-Type": "application/json" },
20
+ body: JSON.stringify(payload),
21
+ });
22
+ }
23
+ catch (error) {
24
+ this.context.logger.error({ msg: "❌ Failed to send Slack message", error });
25
+ }
26
+ }
27
+ async notify(message, subject = null, meta) {
28
+ const title = subject ?? "Directus Error Notification";
29
+ await this.send({
30
+ text: title,
31
+ blocks: [
32
+ {
33
+ type: "header",
34
+ text: { type: "plain_text", text: `🚨 ${title}` },
35
+ },
36
+ {
37
+ type: "section",
38
+ fields: [
39
+ { type: "mrkdwn", text: `*Project*\n${meta.projectName}` },
40
+ { type: "mrkdwn", text: `*Environment*\n${meta.environment}` },
41
+ { type: "mrkdwn", text: `*Extension*\n${this.extension}` },
42
+ { type: "mrkdwn", text: `*Backend*\n${meta.backendUrl}` },
43
+ ],
44
+ },
45
+ { type: "divider" },
46
+ {
47
+ type: "section",
48
+ text: { type: "mrkdwn", text: message },
49
+ },
50
+ ],
51
+ });
52
+ }
53
+ }
@@ -0,0 +1,37 @@
1
+ import type { ApiExtensionContext } from "@directus/extensions";
2
+ import type { PrimaryKey } from "@directus/types";
3
+ export interface LogEntry {
4
+ collection: string;
5
+ date_created: string;
6
+ extension: string;
7
+ function_name: string;
8
+ error: string;
9
+ }
10
+ export interface RecipientLinkRow {
11
+ directus_users_id?: string | {
12
+ id?: string | null;
13
+ } | null;
14
+ }
15
+ export interface ProjectMeta {
16
+ projectName: string;
17
+ backendUrl: string;
18
+ environment: string;
19
+ }
20
+ export interface SlackBlock {
21
+ type: string;
22
+ text?: {
23
+ type: string;
24
+ text: string;
25
+ emoji?: boolean;
26
+ };
27
+ fields?: {
28
+ type: string;
29
+ text: string;
30
+ }[];
31
+ }
32
+ export interface SlackPayload {
33
+ text: string;
34
+ blocks?: SlackBlock[];
35
+ }
36
+ export type { ApiExtensionContext };
37
+ export type { PrimaryKey };
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@serhii.mazur/directus-gu-logs",
3
- "version": "1.0.8",
3
+ "version": "1.0.9",
4
4
  "description": "Helper class Logs for using in Directus extensions",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
package/src/index.ts CHANGED
@@ -1,177 +1,11 @@
1
- import { ApiExtensionContext } from "@directus/extensions";
2
- import type { PrimaryKey } from "@directus/types";
3
-
4
- interface LogEntry {
5
- collection: string;
6
- date_created: string;
7
- extension: string;
8
- function_name: string;
9
- error: string;
10
- }
11
-
12
- interface RecipientLinkRow {
13
- directus_users_id?: string | { id?: string | null } | null;
14
- }
15
-
16
- function extractRecipientIds(rows: RecipientLinkRow[] | undefined): string[] {
17
- const recipients =
18
- rows
19
- ?.map(row => {
20
- const user = row?.directus_users_id;
21
- if (typeof user === "string") return user;
22
- return typeof user?.id === "string" ? user.id : null;
23
- })
24
- .filter((value): value is string => Boolean(value)) ?? [];
25
-
26
- return Array.from(new Set(recipients));
27
- }
28
-
29
- export class Logs {
30
- protected context: ApiExtensionContext;
31
- protected extension: string;
32
- protected collectionName: string;
33
-
34
- constructor(context: ApiExtensionContext, extension: string, collectionName: string = "logs") {
35
- this.context = context;
36
- this.extension = extension;
37
- this.collectionName = collectionName;
38
- }
39
-
40
- private async getSchema() {
41
- return await this.context.getSchema();
42
- }
43
-
44
- private async createOne(data: Record<string, any>) {
45
- const schema = await this.getSchema();
46
-
47
- const itemsService = new this.context.services.ItemsService(this.collectionName, {
48
- database: this.context.database,
49
- schema,
50
- });
51
-
52
- return await itemsService.createOne(data);
53
- }
54
-
55
- async printLogs(functionName: string, error: string) {
56
- const data: LogEntry = {
57
- collection: this.collectionName,
58
- date_created: new Date().toISOString(),
59
- extension: this.extension,
60
- function_name: functionName,
61
- error,
62
- };
63
-
64
- try {
65
- await this.createOne(data);
66
- console.error(`🚀 [${this.extension}] ${functionName}:`, error);
67
- } catch (error) {
68
- console.error("❌ Failed to save logs:", error);
69
- }
70
- }
71
-
72
- async createActivity(action: string, collection: string, id: PrimaryKey) {
73
- try {
74
- const schema = await this.getSchema();
75
- const services = this.context.services;
76
- const accountability = services.accountability;
77
-
78
- const activityService = new services.ActivityService({
79
- schema: schema,
80
- accountability: accountability,
81
- knex: this.context.database,
82
- });
83
-
84
- await activityService.createOne({
85
- action,
86
- user: accountability?.user ?? null,
87
- collection,
88
- ip: accountability?.ip ?? null,
89
- user_agent: accountability?.userAgent ?? null,
90
- origin: accountability?.origin ?? null,
91
- item: id,
92
- });
93
- } catch (error) {
94
- console.error("❌ Failed to create activity log:", error);
95
- }
96
- }
97
-
98
- async createNotification(
99
- message: string,
100
- customSubject: string | null = null,
101
- recipientOverride: string | null = null,
102
- collection: string | null = null,
103
- item: string | null = null
104
- ) {
105
- try {
106
- const schema = await this.getSchema();
107
- const { database, services } = this.context;
108
- const globalService = new services.ItemsService("global", {
109
- database,
110
- schema,
111
- });
112
-
113
- // Check for passed recipient, fallback to global settings
114
- let recipients: string[] = [];
115
-
116
- if (recipientOverride) {
117
- recipients = [recipientOverride];
118
- } else {
119
- const globalSettings = await globalService.readSingleton({
120
- fields: ["error_notice_recipient.directus_users_id.id"],
121
- });
122
-
123
- recipients = extractRecipientIds(globalSettings?.error_notice_recipient);
124
- }
125
-
126
- if (recipients.length === 0) {
127
- this.printLogs(this.extension, "No recipients defined (override or global settings)");
128
- return;
129
- }
130
-
131
- const notificationService = new services.NotificationsService({ schema });
132
-
133
- // Project Data
134
- const settings = await database.select("project_name").from("directus_settings").first();
135
-
136
- const projectName = settings?.project_name || "Unknown Project";
137
- const backendUrl = process.env.BACKEND_URL || this.context.env?.PUBLIC_URL || "Unknown URL";
138
- const environment = process.env.BRANCH || "dev";
139
-
140
- const now = new Date();
141
- const timestamp = new Intl.DateTimeFormat("en-US", {
142
- month: "2-digit",
143
- day: "2-digit",
144
- year: "numeric",
145
- hour: "2-digit",
146
- minute: "2-digit",
147
- hour12: false,
148
- timeZone: "UTC",
149
- }).format(now);
150
-
151
- // Compose subject & message
152
- const subject = customSubject
153
- ? `${customSubject} - ${projectName}`
154
- : `Directus Error Notification - ${projectName}`;
155
-
156
- const fullMessage = `
157
- ${message}<br><br>
158
- <strong>Environment:</strong> ${environment}<br>
159
- <strong>Backend URL:</strong> <a href="${backendUrl}" target="_blank">${backendUrl}</a><br>
160
- <strong>Date/Time (UTC):</strong> ${timestamp}
161
- `.trim();
162
-
163
- for (const recipient of recipients) {
164
- await notificationService.createOne({
165
- recipient,
166
- sender: recipient,
167
- subject,
168
- message: fullMessage,
169
- collection,
170
- item,
171
- });
172
- }
173
- } catch (error) {
174
- console.error("❌ Failed to create notification:", error);
175
- }
176
- }
177
- }
1
+ export { Logs } from "./services/Logs";
2
+ export { SlackNotifier } from "./services/SlackNotifier";
3
+ export type {
4
+ LogEntry,
5
+ RecipientLinkRow,
6
+ ProjectMeta,
7
+ SlackBlock,
8
+ SlackPayload,
9
+ ApiExtensionContext,
10
+ PrimaryKey,
11
+ } from "./types";
@@ -0,0 +1,100 @@
1
+ import type { ApiExtensionContext, ProjectMeta, RecipientLinkRow } from "../types";
2
+
3
+ function extractRecipientIds(rows: RecipientLinkRow[] | undefined): string[] {
4
+ const recipients =
5
+ rows
6
+ ?.map(row => {
7
+ const user = row?.directus_users_id;
8
+ if (typeof user === "string") return user;
9
+ return typeof user?.id === "string" ? user.id : null;
10
+ })
11
+ .filter((v): v is string => Boolean(v)) ?? [];
12
+
13
+ return Array.from(new Set(recipients));
14
+ }
15
+
16
+ export class DirectusNotifier {
17
+ constructor(
18
+ private readonly context: ApiExtensionContext,
19
+ private readonly extension: string,
20
+ ) {}
21
+
22
+ private async getSchema() {
23
+ return this.context.getSchema();
24
+ }
25
+
26
+ async notify(
27
+ message: string,
28
+ meta: ProjectMeta,
29
+ options: {
30
+ subject?: string | null;
31
+ recipientOverride?: string | null;
32
+ collection?: string | null;
33
+ item?: string | null;
34
+ } = {},
35
+ ): Promise<void> {
36
+ const { subject = null, recipientOverride = null, collection = null, item = null } = options;
37
+
38
+ try {
39
+ const schema = await this.getSchema();
40
+ const { database, services } = this.context;
41
+
42
+ let recipients: string[] = [];
43
+
44
+ if (recipientOverride) {
45
+ recipients = [recipientOverride];
46
+ } else {
47
+ const globalService = new services.ItemsService("global", { database, schema });
48
+ const globalSettings = await globalService.readSingleton({
49
+ fields: ["error_notice_recipient.directus_users_id.id"],
50
+ });
51
+
52
+ recipients = extractRecipientIds(globalSettings?.error_notice_recipient);
53
+ }
54
+
55
+ if (recipients.length === 0) {
56
+ this.context.logger.warn({
57
+ msg: `[${this.extension}] No notification recipients defined (override or global settings)`,
58
+ });
59
+ return;
60
+ }
61
+
62
+ const notificationService = new services.NotificationsService({ schema });
63
+
64
+ const now = new Date();
65
+ const timestamp = new Intl.DateTimeFormat("en-US", {
66
+ month: "2-digit",
67
+ day: "2-digit",
68
+ year: "numeric",
69
+ hour: "2-digit",
70
+ minute: "2-digit",
71
+ hour12: false,
72
+ timeZone: "UTC",
73
+ }).format(now);
74
+
75
+ const resolvedSubject = subject
76
+ ? `${subject} - ${meta.projectName}`
77
+ : `Directus Error Notification - ${meta.projectName}`;
78
+
79
+ const fullMessage = [
80
+ message,
81
+ `<strong>Environment:</strong> ${meta.environment}`,
82
+ `<strong>Backend URL:</strong> <a href="${meta.backendUrl}" target="_blank">${meta.backendUrl}</a>`,
83
+ `<strong>Date/Time (UTC):</strong> ${timestamp}`,
84
+ ].join("<br><br>");
85
+
86
+ for (const recipient of recipients) {
87
+ await notificationService.createOne({
88
+ recipient,
89
+ sender: recipient,
90
+ subject: resolvedSubject,
91
+ message: fullMessage,
92
+ collection,
93
+ item,
94
+ });
95
+ }
96
+ } catch (error) {
97
+ this.context.logger.error({ msg: "❌ Failed to send Directus notification", error });
98
+ }
99
+ }
100
+ }
@@ -0,0 +1,122 @@
1
+ import type { ApiExtensionContext, LogEntry, PrimaryKey, ProjectMeta } from "../types";
2
+ import { SlackNotifier } from "../services/SlackNotifier";
3
+ import { DirectusNotifier } from "./DirectusNotifier";
4
+
5
+ export class Logs {
6
+ protected readonly slack: SlackNotifier;
7
+ protected readonly directus: DirectusNotifier;
8
+
9
+ constructor(
10
+ protected readonly context: ApiExtensionContext,
11
+ protected readonly extension: string,
12
+ protected readonly collectionName: string = "logs",
13
+ ) {
14
+ this.slack = new SlackNotifier(context, extension);
15
+ this.directus = new DirectusNotifier(context, extension);
16
+ }
17
+
18
+ private async getSchema() {
19
+ return this.context.getSchema();
20
+ }
21
+
22
+ private async getProjectMeta(): Promise<ProjectMeta> {
23
+ const settings = await this.context.database.select("project_name").from("directus_settings").first();
24
+
25
+ return {
26
+ projectName: settings?.project_name ?? "Unknown Project",
27
+ backendUrl: process.env.BACKEND_URL ?? this.context.env?.PUBLIC_URL ?? "Unknown URL",
28
+ environment: process.env.BRANCH ?? "dev",
29
+ };
30
+ }
31
+
32
+ private async createOne(data: Record<string, any>) {
33
+ const schema = await this.getSchema();
34
+
35
+ const itemsService = new this.context.services.ItemsService(this.collectionName, {
36
+ database: this.context.database,
37
+ schema,
38
+ });
39
+
40
+ return await itemsService.createOne(data);
41
+ }
42
+
43
+ /**
44
+ * Persists an error entry to the logs collection, optionally notifying via Slack.
45
+ */
46
+ async printLogs(functionName: string, error: string, notifySlack = true): Promise<void> {
47
+ const data: LogEntry = {
48
+ collection: this.collectionName,
49
+ date_created: new Date().toISOString(),
50
+ extension: this.extension,
51
+ function_name: functionName,
52
+ error,
53
+ };
54
+
55
+ try {
56
+ await this.createOne(data);
57
+
58
+ this.context.logger.info({ msg: `🚀 [${this.extension}] ${functionName}:`, error });
59
+ } catch (err) {
60
+ this.context.logger.error({ msg: "❌ Failed to save log entry", error: err });
61
+ return;
62
+ }
63
+
64
+ try {
65
+ if (notifySlack) {
66
+ const meta = await this.getProjectMeta();
67
+ await this.slack.notify(`*Function:* ${functionName}\n*Error:* ${error}`, "Extension Error", meta);
68
+ }
69
+ } catch (err) {
70
+ this.context.logger.error({ msg: "Slack failed", err });
71
+ }
72
+ }
73
+
74
+ async createActivity(action: string, collection: string, id: PrimaryKey): Promise<void> {
75
+ try {
76
+ const schema = await this.getSchema();
77
+ const { services, database } = this.context;
78
+ const accountability = services.accountability;
79
+
80
+ const activityService = new services.ActivityService({
81
+ schema,
82
+ accountability,
83
+ knex: database,
84
+ });
85
+
86
+ await activityService.createOne({
87
+ action,
88
+ user: accountability?.user ?? null,
89
+ collection,
90
+ ip: accountability?.ip ?? null,
91
+ user_agent: accountability?.userAgent ?? null,
92
+ origin: accountability?.origin ?? null,
93
+ item: id,
94
+ });
95
+ } catch (error) {
96
+ this.context.logger.error({ msg: "❌ Failed to create activity log", error });
97
+ }
98
+ }
99
+
100
+ async createNotification(
101
+ message: string,
102
+ customSubject: string | null = null,
103
+ recipientOverride: string | null = null,
104
+ collection: string | null = null,
105
+ item: string | null = null,
106
+ ): Promise<void> {
107
+ const meta = await this.getProjectMeta();
108
+
109
+ await this.directus.notify(message, meta, {
110
+ subject: customSubject,
111
+ recipientOverride,
112
+ collection,
113
+ item,
114
+ });
115
+ }
116
+
117
+ async createSlackNotification(message: string, customSubject: string | null = null): Promise<void> {
118
+ const meta = await this.getProjectMeta();
119
+
120
+ await this.slack.notify(message, customSubject, meta);
121
+ }
122
+ }
@@ -0,0 +1,61 @@
1
+ import type { ApiExtensionContext, ProjectMeta, SlackPayload } from "../types";
2
+
3
+ export class SlackNotifier {
4
+ constructor(
5
+ private readonly context: ApiExtensionContext,
6
+ private readonly extension: string,
7
+ ) {}
8
+
9
+ async getWebHookUrl() {
10
+ const settings = await this.context.database.select("slack_webhook_url").from("global").first();
11
+
12
+ return settings?.slack_webhook_url;
13
+ }
14
+
15
+ async send(payload: SlackPayload): Promise<void> {
16
+ const webhook = await this.getWebHookUrl();
17
+
18
+ if (!webhook) {
19
+ this.context.logger.info({ msg: "⚠️ SLACK_WEBHOOK_URL is not configured" });
20
+ return;
21
+ }
22
+
23
+ try {
24
+ await fetch(webhook, {
25
+ method: "POST",
26
+ headers: { "Content-Type": "application/json" },
27
+ body: JSON.stringify(payload),
28
+ });
29
+ } catch (error) {
30
+ this.context.logger.error({ msg: "❌ Failed to send Slack message", error });
31
+ }
32
+ }
33
+
34
+ async notify(message: string, subject: string | null = null, meta: ProjectMeta): Promise<void> {
35
+ const title = subject ?? "Directus Error Notification";
36
+
37
+ await this.send({
38
+ text: title,
39
+ blocks: [
40
+ {
41
+ type: "header",
42
+ text: { type: "plain_text", text: `🚨 ${title}` },
43
+ },
44
+ {
45
+ type: "section",
46
+ fields: [
47
+ { type: "mrkdwn", text: `*Project*\n${meta.projectName}` },
48
+ { type: "mrkdwn", text: `*Environment*\n${meta.environment}` },
49
+ { type: "mrkdwn", text: `*Extension*\n${this.extension}` },
50
+ { type: "mrkdwn", text: `*Backend*\n${meta.backendUrl}` },
51
+ ],
52
+ },
53
+ { type: "divider" },
54
+ {
55
+ type: "section",
56
+ text: { type: "mrkdwn", text: message },
57
+ },
58
+ ],
59
+ });
60
+ }
61
+ }
package/src/types.ts ADDED
@@ -0,0 +1,35 @@
1
+ import type { ApiExtensionContext } from "@directus/extensions";
2
+ import type { PrimaryKey } from "@directus/types";
3
+
4
+ export interface LogEntry {
5
+ collection: string;
6
+ date_created: string;
7
+ extension: string;
8
+ function_name: string;
9
+ error: string;
10
+ }
11
+
12
+ export interface RecipientLinkRow {
13
+ directus_users_id?: string | { id?: string | null } | null;
14
+ }
15
+
16
+ export interface ProjectMeta {
17
+ projectName: string;
18
+ backendUrl: string;
19
+ environment: string;
20
+ }
21
+
22
+ export interface SlackBlock {
23
+ type: string;
24
+ text?: { type: string; text: string; emoji?: boolean };
25
+ fields?: { type: string; text: string }[];
26
+ }
27
+
28
+ export interface SlackPayload {
29
+ text: string;
30
+ blocks?: SlackBlock[];
31
+ }
32
+
33
+ export type { ApiExtensionContext };
34
+
35
+ export type { PrimaryKey };