@serhii.mazur/directus-gu-logs 1.0.7 → 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,39 +1,52 @@
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
- | Field Name | Type | Description |
12
- | --------------- | -------- | ------------------------ |
13
- | `collection` | string | Name of the collection |
14
- | `date_created` | datetime | Timestamp of the log |
15
- | `extension` | string | Extension identifier |
16
- | `function_name` | string | Name of the function |
17
- | `error` | code | Error message/code block |
11
+ | Field Name | Type | Description |
12
+ | --------------- | --------- | ---------------------------- |
13
+ | `collection` | string | Name of the collection |
14
+ | `date_created` | datetime | Timestamp of the log |
15
+ | `extension` | string | Extension identifier |
16
+ | `function_name` | string | Name of the function |
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
- | `notice_recipient` | m2o → directus_users | Recipient for error notifications |
26
- | `developer_notice_recipient` | m2o directus_users | Recipient 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 |
27
27
 
28
- This recipient will be used to send internal notifications when errors occur.
28
+ All linked users will receive internal Directus notifications when errors occur (unless a recipient override is passed).
29
29
 
30
- ## 🚀 Features
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
+ ---
31
38
 
32
- - Save error logs to the logs collection
39
+ ## 🚀 Features
33
40
 
34
- - Create Directus activity records
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
44
+ - Create Directus activity records
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
35
48
 
36
- - Send internal Directus notifications
49
+ ---
37
50
 
38
51
  ## 📦 Installation
39
52
 
@@ -41,15 +54,128 @@ This recipient will be used to send internal notifications when errors occur.
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
- const logs = new Logs(context, "my-extension", "logs");
80
+ const logs = new Logs(context, "my-extension");
81
+
82
+ // Persist error log to DB + notify Slack
83
+ await logs.printLogs("myFunction", "Something went wrong");
84
+
85
+ // Persist error log without Slack notification
86
+ await logs.printLogs("myFunction", "Something went wrong", false);
50
87
 
51
- await logs.printLogs("myFunction", "message");
52
- await logs.createActivity("create", "collection", "id");
88
+ // Create a Directus activity record
89
+ await logs.createActivity("create", "collection_name", "item_id");
90
+
91
+ // Send Directus in-app notification (recipients from global settings)
53
92
  await logs.createNotification("An error occurred");
54
- await logs.createNotification("An error occurred", "Custom subject", "recipient_id");
93
+
94
+ // With custom subject
95
+ await logs.createNotification("An error occurred", "Custom Subject");
96
+
97
+ // Override recipient
98
+ await logs.createNotification("An error occurred", "Custom Subject", "user_id");
99
+
100
+ // With linked collection + item
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");
55
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
+ });
127
+ ```
128
+
129
+ ---
130
+
131
+ ## ⚙️ Constructor
132
+
133
+ ### `Logs`
134
+
135
+ ```ts
136
+ constructor(
137
+ context: ApiExtensionContext,
138
+ extension: string,
139
+ collectionName: string = "logs"
140
+ )
141
+ ```
142
+
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
+ ---
150
+
151
+ ## 🧠 How It Works
152
+
153
+ ### `printLogs(functionName, error, notifySlack?)`
154
+
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
159
+
160
+ ### `createActivity(action, collection, id)`
161
+
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.
163
+
164
+ ### `createNotification(message, subject?, recipientOverride?, collection?, item?)`
165
+
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:
172
+
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,119 +1,2 @@
1
- export class Logs {
2
- constructor(context, extension, collectionName = "logs") {
3
- this.context = context;
4
- this.extension = extension;
5
- this.collectionName = collectionName;
6
- }
7
- async getSchema() {
8
- return await this.context.getSchema();
9
- }
10
- async createOne(data) {
11
- const schema = await this.getSchema();
12
- const itemsService = new this.context.services.ItemsService(this.collectionName, {
13
- database: this.context.database,
14
- schema,
15
- });
16
- return await itemsService.createOne(data);
17
- }
18
- async printLogs(functionName, error) {
19
- const data = {
20
- collection: this.collectionName,
21
- date_created: new Date().toISOString(),
22
- extension: this.extension,
23
- function_name: functionName,
24
- error,
25
- };
26
- try {
27
- await this.createOne(data);
28
- console.error(`🚀 [${this.extension}] ${functionName}:`, error);
29
- }
30
- catch (error) {
31
- console.error("❌ Failed to save logs:", error);
32
- }
33
- }
34
- async createActivity(action, collection, id) {
35
- try {
36
- const schema = await this.getSchema();
37
- const services = this.context.services;
38
- const accountability = services.accountability;
39
- const activityService = new services.ActivityService({
40
- schema: schema,
41
- accountability: accountability,
42
- knex: this.context.database,
43
- });
44
- await activityService.createOne({
45
- action,
46
- user: accountability?.user ?? null,
47
- collection,
48
- ip: accountability?.ip ?? null,
49
- user_agent: accountability?.userAgent ?? null,
50
- origin: accountability?.origin ?? null,
51
- item: id,
52
- });
53
- }
54
- catch (error) {
55
- console.error("❌ Failed to create activity log:", error);
56
- }
57
- }
58
- async createNotification(message, customSubject = null, recipientOverride = null, collection = null, item = null) {
59
- try {
60
- const schema = await this.getSchema();
61
- const { database, services } = this.context;
62
- // Check for passed recipient, fallback to global settings
63
- let recipients = [];
64
- if (recipientOverride) {
65
- recipients = [recipientOverride];
66
- }
67
- else {
68
- const globalSettings = await database
69
- .select("notice_recipient", "developer_notice_recipient")
70
- .from("global")
71
- .first();
72
- recipients = [globalSettings?.notice_recipient, globalSettings?.developer_notice_recipient].filter(Boolean);
73
- }
74
- if (recipients.length === 0) {
75
- this.printLogs(this.extension, "No recipients defined (override or global settings)");
76
- return;
77
- }
78
- const notificationService = new services.NotificationsService({ schema });
79
- // Project Data
80
- const settings = await database.select("project_name").from("directus_settings").first();
81
- const projectName = settings?.project_name || "Unknown Project";
82
- const backendUrl = process.env.BACKEND_URL || this.context.env?.PUBLIC_URL || "Unknown URL";
83
- const environment = process.env.BRANCH || "dev";
84
- const now = new Date();
85
- const timestamp = new Intl.DateTimeFormat("en-US", {
86
- month: "2-digit",
87
- day: "2-digit",
88
- year: "numeric",
89
- hour: "2-digit",
90
- minute: "2-digit",
91
- hour12: false,
92
- timeZone: "UTC",
93
- }).format(now);
94
- // Compose subject & message
95
- const subject = customSubject
96
- ? `${customSubject} - ${projectName}`
97
- : `Directus Error Notification - ${projectName}`;
98
- const fullMessage = `
99
- ${message}<br><br>
100
- <strong>Environment:</strong> ${environment}<br>
101
- <strong>Backend URL:</strong> <a href="${backendUrl}" target="_blank">${backendUrl}</a><br>
102
- <strong>Date/Time (UTC):</strong> ${timestamp}
103
- `.trim();
104
- for (const recipient of recipients) {
105
- await notificationService.createOne({
106
- recipient,
107
- sender: recipient,
108
- subject,
109
- message: fullMessage,
110
- collection,
111
- item,
112
- });
113
- }
114
- }
115
- catch (error) {
116
- console.error("❌ Failed to create notification:", error);
117
- }
118
- }
119
- }
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.7",
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,159 +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
- export class Logs {
13
- protected context: ApiExtensionContext;
14
- protected extension: string;
15
- protected collectionName: string;
16
-
17
- constructor(context: ApiExtensionContext, extension: string, collectionName: string = "logs") {
18
- this.context = context;
19
- this.extension = extension;
20
- this.collectionName = collectionName;
21
- }
22
-
23
- private async getSchema() {
24
- return await this.context.getSchema();
25
- }
26
-
27
- private async createOne(data: Record<string, any>) {
28
- const schema = await this.getSchema();
29
-
30
- const itemsService = new this.context.services.ItemsService(this.collectionName, {
31
- database: this.context.database,
32
- schema,
33
- });
34
-
35
- return await itemsService.createOne(data);
36
- }
37
-
38
- async printLogs(functionName: string, error: string) {
39
- const data: LogEntry = {
40
- collection: this.collectionName,
41
- date_created: new Date().toISOString(),
42
- extension: this.extension,
43
- function_name: functionName,
44
- error,
45
- };
46
-
47
- try {
48
- await this.createOne(data);
49
- console.error(`🚀 [${this.extension}] ${functionName}:`, error);
50
- } catch (error) {
51
- console.error("❌ Failed to save logs:", error);
52
- }
53
- }
54
-
55
- async createActivity(action: string, collection: string, id: PrimaryKey) {
56
- try {
57
- const schema = await this.getSchema();
58
- const services = this.context.services;
59
- const accountability = services.accountability;
60
-
61
- const activityService = new services.ActivityService({
62
- schema: schema,
63
- accountability: accountability,
64
- knex: this.context.database,
65
- });
66
-
67
- await activityService.createOne({
68
- action,
69
- user: accountability?.user ?? null,
70
- collection,
71
- ip: accountability?.ip ?? null,
72
- user_agent: accountability?.userAgent ?? null,
73
- origin: accountability?.origin ?? null,
74
- item: id,
75
- });
76
- } catch (error) {
77
- console.error("❌ Failed to create activity log:", error);
78
- }
79
- }
80
-
81
- async createNotification(
82
- message: string,
83
- customSubject: string | null = null,
84
- recipientOverride: string | null = null,
85
- collection: string | null = null,
86
- item: string | null = null
87
- ) {
88
- try {
89
- const schema = await this.getSchema();
90
- const { database, services } = this.context;
91
-
92
- // Check for passed recipient, fallback to global settings
93
- let recipients: string[] = [];
94
-
95
- if (recipientOverride) {
96
- recipients = [recipientOverride];
97
- } else {
98
- const globalSettings = await database
99
- .select("notice_recipient", "developer_notice_recipient")
100
- .from("global")
101
- .first();
102
-
103
- recipients = [globalSettings?.notice_recipient, globalSettings?.developer_notice_recipient].filter(
104
- Boolean
105
- );
106
- }
107
-
108
- if (recipients.length === 0) {
109
- this.printLogs(this.extension, "No recipients defined (override or global settings)");
110
- return;
111
- }
112
-
113
- const notificationService = new services.NotificationsService({ schema });
114
-
115
- // Project Data
116
- const settings = await database.select("project_name").from("directus_settings").first();
117
-
118
- const projectName = settings?.project_name || "Unknown Project";
119
- const backendUrl = process.env.BACKEND_URL || this.context.env?.PUBLIC_URL || "Unknown URL";
120
- const environment = process.env.BRANCH || "dev";
121
-
122
- const now = new Date();
123
- const timestamp = new Intl.DateTimeFormat("en-US", {
124
- month: "2-digit",
125
- day: "2-digit",
126
- year: "numeric",
127
- hour: "2-digit",
128
- minute: "2-digit",
129
- hour12: false,
130
- timeZone: "UTC",
131
- }).format(now);
132
-
133
- // Compose subject & message
134
- const subject = customSubject
135
- ? `${customSubject} - ${projectName}`
136
- : `Directus Error Notification - ${projectName}`;
137
-
138
- const fullMessage = `
139
- ${message}<br><br>
140
- <strong>Environment:</strong> ${environment}<br>
141
- <strong>Backend URL:</strong> <a href="${backendUrl}" target="_blank">${backendUrl}</a><br>
142
- <strong>Date/Time (UTC):</strong> ${timestamp}
143
- `.trim();
144
-
145
- for (const recipient of recipients) {
146
- await notificationService.createOne({
147
- recipient,
148
- sender: recipient,
149
- subject,
150
- message: fullMessage,
151
- collection,
152
- item,
153
- });
154
- }
155
- } catch (error) {
156
- console.error("❌ Failed to create notification:", error);
157
- }
158
- }
159
- }
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 };