Merge branch 'master' into default_notification_settings

Signed-off-by: Ben Lambert <blam@spotify.com>
This commit is contained in:
Ben Lambert
2025-07-15 10:52:30 +02:00
committed by GitHub
1540 changed files with 60070 additions and 36152 deletions
@@ -1,5 +1,88 @@
# @backstage/plugin-notifications-backend
## 0.5.8-next.1
### Patch Changes
- Updated dependencies
- @backstage/config@1.3.3-next.0
- @backstage/catalog-model@1.7.5-next.0
- @backstage/backend-plugin-api@1.4.1-next.0
- @backstage/plugin-auth-node@0.6.5-next.0
- @backstage/plugin-notifications-common@0.0.10-next.0
- @backstage/plugin-signals-node@0.1.22-next.0
- @backstage/plugin-catalog-node@1.17.2-next.0
- @backstage/plugin-notifications-node@0.2.17-next.0
- @backstage/plugin-events-node@0.4.13-next.0
## 0.5.8-next.0
### Patch Changes
- 9a5a73f: Fix `addTopic` migration when `user_settings` present
- Updated dependencies
- @backstage/plugin-auth-node@0.6.4
- @backstage/backend-plugin-api@1.4.0
- @backstage/plugin-catalog-node@1.17.1
- @backstage/plugin-events-node@0.4.12
- @backstage/plugin-notifications-node@0.2.16
- @backstage/catalog-model@1.7.4
- @backstage/config@1.3.2
- @backstage/errors@1.2.7
- @backstage/types@1.2.1
- @backstage/plugin-notifications-common@0.0.9
- @backstage/plugin-signals-node@0.1.21
## 0.5.7
### Patch Changes
- 41d4d6e: Notifications are now automatically deleted after 1 year by default.
There is a new scheduled task that runs every 24 hours to delete notifications older than 1 year.
This can be configured by setting the `notifications.retention` in the `app-config.yaml` file.
```yaml
notifications:
retention: 1y
```
If the retention is set to false, notifications will not be automatically deleted.
- 8a150bf: Internal changes to switch to the non-alpha `catalogServiceRef`
- 1fb5f06: Adds ability for user to turn on/off notifications for specific topics within an origin.
- ef9ab82: Notifications API will now return user as null always for broadcast notifications
- Updated dependencies
- @backstage/plugin-notifications-common@0.0.9
- @backstage/plugin-catalog-node@1.17.1
- @backstage/plugin-auth-node@0.6.4
- @backstage/backend-plugin-api@1.4.0
- @backstage/plugin-notifications-node@0.2.16
- @backstage/catalog-model@1.7.4
- @backstage/config@1.3.2
- @backstage/errors@1.2.7
- @backstage/types@1.2.1
- @backstage/plugin-events-node@0.4.12
- @backstage/plugin-signals-node@0.1.21
## 0.5.7-next.2
### Patch Changes
- 8a150bf: Internal changes to switch to the non-alpha `catalogServiceRef`
- Updated dependencies
- @backstage/backend-plugin-api@1.4.0-next.1
- @backstage/catalog-model@1.7.4
- @backstage/config@1.3.2
- @backstage/errors@1.2.7
- @backstage/types@1.2.1
- @backstage/plugin-auth-node@0.6.4-next.1
- @backstage/plugin-catalog-node@1.17.1-next.1
- @backstage/plugin-events-node@0.4.12-next.1
- @backstage/plugin-notifications-common@0.0.9-next.0
- @backstage/plugin-notifications-node@0.2.16-next.1
- @backstage/plugin-signals-node@0.1.21-next.1
## 0.5.7-next.1
### Patch Changes
+5
View File
@@ -44,5 +44,10 @@ export interface Config {
}[];
}[];
};
/*
* Time to keep the notifications in the database, defaults to 365 days.
* Can be disabled by setting to false.
*/
retention?: HumanDuration | string | false;
};
}
@@ -18,14 +18,10 @@ const crypto = require('crypto');
exports.up = async function up(knex) {
await knex.schema.alterTable('user_settings', table => {
table.string('topic').nullable().after('origin');
table.string('settings_key_hash', 64).notNullable();
table.string('settings_key_hash', 64).nullable();
table.dropUnique([], 'user_settings_unique_idx');
});
await knex.schema.alterTable('user_settings', table => {
table.unique(['settings_key_hash'], 'user_settings_unique_idx');
});
const rows = await knex('user_settings').select('user', 'channel', 'origin');
for (const row of rows) {
const rawKey = `${row.user}|${row.channel}|${row.origin}|}`;
@@ -35,10 +31,14 @@ exports.up = async function up(knex) {
user: row.user,
channel: row.channel,
origin: row.origin,
topic: row.topic,
})
.update({ settings_key_hash: hash });
}
await knex.schema.alterTable('user_settings', table => {
table.string('settings_key_hash', 64).notNullable().alter();
table.unique(['settings_key_hash'], 'user_settings_unique_idx');
});
};
exports.down = async function down(knex) {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@backstage/plugin-notifications-backend",
"version": "0.5.7-next.1",
"version": "0.5.8-next.1",
"backstage": {
"role": "backend-plugin",
"pluginId": "notifications",
@@ -228,6 +228,13 @@ describe.each(databases.eachSupportedId())(
]);
});
it('should return null value for user for broadcast notifications', async () => {
await storage.saveBroadcast({ ...testNotification1, read: new Date() });
const notifications = await storage.getNotifications({ user });
expect(notifications).toHaveLength(1);
expect(notifications[0].user).toBeNull();
});
it('should return read notifications for user', async () => {
await storage.saveNotification(testNotification1);
await storage.saveBroadcast(testNotification2);
@@ -298,7 +305,7 @@ describe.each(databases.eachSupportedId())(
user: otherUser,
});
expect(otherUserNotifications.map(idOnly)).toEqual([id0, id2]);
expect(otherUserNotifications[1].user).toBe(otherUser);
expect(otherUserNotifications[1].user).toBeNull();
});
it('should allow searching for notifications', async () => {
@@ -635,7 +642,7 @@ describe.each(databases.eachSupportedId())(
expect(notification?.user).toBeNull();
await storage.markRead({ ids: [id1], user });
notification = await storage.getNotification({ id: id1, user });
expect(notification?.user).toBe(user);
expect(notification?.user).toBeNull();
const otherNotification = await storage.getNotification({
id: id1,
@@ -798,5 +805,24 @@ describe.each(databases.eachSupportedId())(
});
});
});
describe('clearNotifications', () => {
it('should clear notifications older than specified days', async () => {
const oldDate = new Date();
oldDate.setDate(oldDate.getDate() - 10); // 10 days ago
await storage.saveNotification({
...testNotification1,
created: oldDate,
});
await storage.saveNotification(testNotification2);
const result = await storage.clearNotifications({
maxAge: { days: 5 },
}); // Clear notifications older than 5 days
expect(result.deletedCount).toBe(1); // Only the first notification should be cleared
const remainingNotifications = await storage.getNotifications({ user });
expect(remainingNotifications.map(idOnly)).toEqual([id2]);
});
});
},
);
@@ -31,6 +31,7 @@ import {
} from '@backstage/plugin-notifications-common';
import { Knex } from 'knex';
import crypto from 'crypto';
import { durationToMilliseconds, HumanDuration } from '@backstage/types';
const migrationsDir = resolvePackagePath(
'@backstage/plugin-notifications-backend',
@@ -149,7 +150,7 @@ export class DatabaseNotificationsStore implements NotificationsStore {
private mapToNotifications = (rows: any[]): Notification[] => {
return rows.map(row => ({
id: row.id,
user: row.user,
user: row.type === 'broadcast' ? null : row.user,
created: new Date(row.created),
saved: row.saved,
read: row.read,
@@ -252,7 +253,7 @@ export class DatabaseNotificationsStore implements NotificationsStore {
join.andOnVal('user', '=', user);
}
})
.select(NOTIFICATION_COLUMNS);
.select([...NOTIFICATION_COLUMNS, this.db.raw("'broadcast' as type")]);
};
private getNotificationsBaseQuery = (
@@ -261,7 +262,7 @@ export class DatabaseNotificationsStore implements NotificationsStore {
const { user, orderField } = options;
const subQuery = this.db<NotificationRowType>('notification')
.select(NOTIFICATION_COLUMNS)
.select([...NOTIFICATION_COLUMNS, this.db.raw("'entity' as type")])
.unionAll([this.getBroadcastUnion(user)])
.as('notifications');
@@ -331,7 +332,10 @@ export class DatabaseNotificationsStore implements NotificationsStore {
async getNotifications(options: NotificationGetOptions) {
const notificationQuery = this.getNotificationsBaseQuery(options);
const notifications = await notificationQuery.select(NOTIFICATION_COLUMNS);
const notifications = await notificationQuery.select([
...NOTIFICATION_COLUMNS,
'type',
]);
return this.mapToNotifications(notifications);
}
@@ -462,7 +466,7 @@ export class DatabaseNotificationsStore implements NotificationsStore {
.select('*')
.from(
this.db<NotificationRowType>('notification')
.select(NOTIFICATION_COLUMNS)
.select([...NOTIFICATION_COLUMNS, this.db.raw("'entity' as type")])
.unionAll([this.getBroadcastUnion(options.user)])
.as('notifications'),
)
@@ -653,4 +657,24 @@ export class DatabaseNotificationsStore implements NotificationsStore {
.distinct(['topic']);
return { topics: topics.map(row => row.topic) };
}
async clearNotifications(options: {
maxAge: HumanDuration;
}): Promise<{ deletedCount: number }> {
const ms = durationToMilliseconds(options.maxAge);
const now = new Date(new Date().getTime() - ms);
const notificationsCount = await this.db('notification')
.where(builder => {
builder.where('created', '<=', now).whereNull('updated');
})
.orWhere('updated', '<=', now)
.delete();
const broadcastsCount = await this.db('broadcast')
.where(builder => {
builder.where('created', '<=', now).whereNull('updated');
})
.orWhere('updated', '<=', now)
.delete();
return { deletedCount: notificationsCount + broadcastsCount };
}
}
@@ -20,6 +20,7 @@ import {
NotificationSeverity,
NotificationStatus,
} from '@backstage/plugin-notifications-common';
import { HumanDuration } from '@backstage/types';
/** @internal */
export type EntityOrder = {
@@ -99,6 +100,10 @@ export interface NotificationsStore {
user: string;
}): Promise<{ origins: string[] }>;
getUserNotificationTopics(options: {
user: string;
}): Promise<{ topics: { origin: string; topic: string }[] }>;
getNotificationSettings(options: {
user: string;
}): Promise<NotificationSettings>;
@@ -109,4 +114,8 @@ export interface NotificationsStore {
}): Promise<void>;
getTopics(options: TopicGetOptions): Promise<{ topics: string[] }>;
clearNotifications(options: {
maxAge: HumanDuration;
}): Promise<{ deletedCount: number }>;
}
+15 -1
View File
@@ -26,6 +26,8 @@ import {
NotificationsProcessingExtensionPoint,
} from '@backstage/plugin-notifications-node';
import { catalogServiceRef } from '@backstage/plugin-catalog-node';
import { DatabaseNotificationsStore } from './database';
import { NotificationCleaner } from './service/NotificationCleaner.ts';
class NotificationsProcessingExtensionPointImpl
implements NotificationsProcessingExtensionPoint
@@ -69,6 +71,7 @@ export const notificationsPlugin = createBackendPlugin({
signals: signalsServiceRef,
config: coreServices.rootConfig,
catalog: catalogServiceRef,
scheduler: coreServices.scheduler,
},
async init({
auth,
@@ -80,7 +83,10 @@ export const notificationsPlugin = createBackendPlugin({
signals,
config,
catalog,
scheduler,
}) {
const store = await DatabaseNotificationsStore.create({ database });
httpRouter.use(
await createRouter({
auth,
@@ -88,7 +94,7 @@ export const notificationsPlugin = createBackendPlugin({
userInfo,
logger,
config,
database,
store,
catalog,
signals,
processors: processingExtensions.processors,
@@ -98,6 +104,14 @@ export const notificationsPlugin = createBackendPlugin({
path: '/health',
allow: 'unauthenticated',
});
const cleaner = new NotificationCleaner(
config,
scheduler,
logger,
store,
);
await cleaner.initTaskRunner();
},
});
},
@@ -0,0 +1,118 @@
/*
* Copyright 2025 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { LoggerService, SchedulerService } from '@backstage/backend-plugin-api';
import { Config } from '@backstage/config';
import { mockServices } from '@backstage/backend-test-utils';
import { NotificationsStore } from '../database';
import { NotificationCleaner } from './NotificationCleaner.ts';
describe('NotificationCleaner', () => {
let mockConfig: Config;
let mockScheduler: SchedulerService;
let mockLogger: LoggerService;
let mockDatabase: NotificationsStore;
beforeEach(() => {
mockConfig = mockServices.rootConfig();
mockScheduler = mockServices.scheduler.mock();
mockLogger = mockServices.logger.mock();
mockDatabase = {
clearNotifications: jest.fn(),
} as unknown as NotificationsStore;
});
afterEach(() => {
jest.clearAllMocks();
});
describe('initNotificationCleaner', () => {
it('should initialize the notification cleaner with the correct schedule', async () => {
const mockTaskRunner = {
run: jest.fn(),
};
mockScheduler.createScheduledTaskRunner = jest
.fn()
.mockReturnValue(mockTaskRunner);
const cleaner = new NotificationCleaner(
mockConfig,
mockScheduler,
mockLogger,
mockDatabase,
);
expect(cleaner).toBeInstanceOf(NotificationCleaner);
await cleaner.initTaskRunner();
expect(mockScheduler.createScheduledTaskRunner).toHaveBeenCalled();
expect(mockTaskRunner.run).toHaveBeenCalledWith(
expect.objectContaining({
id: 'notification-cleaner',
fn: expect.any(Function),
}),
);
});
it('should not create a task runner if retention is disabled', async () => {
mockConfig = mockServices.rootConfig({
data: { notifications: { retention: false } },
});
const cleaner = new NotificationCleaner(
mockConfig,
mockScheduler,
mockLogger,
mockDatabase,
);
await cleaner.initTaskRunner();
expect(mockScheduler.createScheduledTaskRunner).not.toHaveBeenCalled();
expect(mockLogger.info).toHaveBeenCalledWith(
'Notification retention is disabled, skipping notification cleaner task',
);
});
});
describe('clearNotifications', () => {
it('should clear notifications', async () => {
mockDatabase.clearNotifications = jest
.fn()
.mockResolvedValue({ deletedCount: 1 });
const mockTaskRunner = {
run: jest.fn().mockImplementation(({ fn }) => fn()),
};
mockScheduler.createScheduledTaskRunner = jest
.fn()
.mockReturnValue(mockTaskRunner);
const cleaner = new NotificationCleaner(
mockConfig,
mockScheduler,
mockLogger,
mockDatabase,
);
await cleaner.initTaskRunner();
expect(mockLogger.info).toHaveBeenCalledWith(
'Starting notification cleaner task',
);
expect(mockLogger.info).toHaveBeenCalledWith(
'Notification cleaner task completed successfully, deleted 1 notifications',
);
expect(mockDatabase.clearNotifications).toHaveBeenCalledWith({
maxAge: { years: 1 },
});
});
});
});
@@ -0,0 +1,91 @@
/*
* Copyright 2025 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
LoggerService,
SchedulerService,
SchedulerServiceTaskScheduleDefinition,
} from '@backstage/backend-plugin-api';
import { Config, readDurationFromConfig } from '@backstage/config';
import { NotificationsStore } from '../database';
import { HumanDuration } from '@backstage/types';
import { ForwardedError } from '@backstage/errors';
export class NotificationCleaner {
private readonly retention: HumanDuration = { years: 1 };
private readonly enabled: boolean = true;
constructor(
config: Config,
private readonly scheduler: SchedulerService,
private readonly logger: LoggerService,
private readonly database: NotificationsStore,
) {
if (config.has('notifications.retention')) {
const retentionConfig = config.get('notifications.retention');
if (typeof retentionConfig === 'boolean' && !retentionConfig) {
logger.info(
'Notification retention is disabled, skipping notification cleaner task',
);
this.enabled = false;
return;
}
this.retention = readDurationFromConfig(config, {
key: 'notifications.retention',
});
}
}
async initTaskRunner() {
if (!this.enabled) {
return;
}
const schedule: SchedulerServiceTaskScheduleDefinition = {
frequency: { cron: '0 0 * * *' },
timeout: { hours: 1 },
initialDelay: { hours: 1 },
scope: 'global',
};
const taskRunner = this.scheduler.createScheduledTaskRunner(schedule);
await taskRunner.run({
id: 'notification-cleaner',
fn: async () => {
await this.clearNotifications(
this.logger,
this.database,
this.retention,
);
},
});
}
private async clearNotifications(
logger: LoggerService,
database: NotificationsStore,
retention: HumanDuration,
) {
logger.info('Starting notification cleaner task');
try {
const result = await database.clearNotifications({ maxAge: retention });
logger.info(
`Notification cleaner task completed successfully, deleted ${result.deletedCount} notifications`,
);
} catch (error) {
throw new ForwardedError('Notification cleaner task failed', error);
}
}
}
@@ -29,8 +29,10 @@ import { NotificationSendOptions } from '@backstage/plugin-notifications-node';
import { catalogServiceMock } from '@backstage/plugin-catalog-node/testUtils';
import { DatabaseService } from '@backstage/backend-plugin-api';
import { v4 as uuid } from 'uuid';
import { DatabaseNotificationsStore } from '../database';
const databases = TestDatabases.create();
let store: DatabaseNotificationsStore;
async function createDatabase(
databaseId: TestDatabaseId,
@@ -100,6 +102,9 @@ describe.each(databases.eachSupportedId())('createRouter (%s)', databaseId => {
beforeAll(async () => {
database = await createDatabase(databaseId);
store = await DatabaseNotificationsStore.create({
database,
});
});
describe('POST /notifications', () => {
@@ -110,7 +115,7 @@ describe.each(databases.eachSupportedId())('createRouter (%s)', databaseId => {
beforeAll(async () => {
const router = await createRouter({
logger: mockServices.logger.mock(),
database,
store,
signals: signalService,
userInfo,
config,
@@ -498,7 +503,7 @@ describe.each(databases.eachSupportedId())('createRouter (%s)', databaseId => {
beforeAll(async () => {
const router = await createRouter({
logger: mockServices.logger.mock(),
database,
store,
signals: signalService,
userInfo,
config,
@@ -588,7 +593,7 @@ describe.each(databases.eachSupportedId())('createRouter (%s)', databaseId => {
beforeAll(async () => {
const router = await createRouter({
logger: mockServices.logger.mock(),
database,
store,
signals: signalService,
userInfo,
config,
@@ -726,7 +731,7 @@ describe.each(databases.eachSupportedId())('createRouter (%s)', databaseId => {
beforeAll(async () => {
const router = await createRouter({
logger: mockServices.logger.mock(),
database,
store,
signals: signalService,
userInfo,
config,
@@ -17,9 +17,9 @@
import express, { Request, Response } from 'express';
import Router from 'express-promise-router';
import {
DatabaseNotificationsStore,
normalizeSeverity,
NotificationGetOptions,
NotificationsStore,
TopicGetOptions,
} from '../database';
import { v4 as uuid } from 'uuid';
@@ -31,7 +31,6 @@ import {
import { InputError, NotFoundError } from '@backstage/errors';
import {
AuthService,
DatabaseService,
HttpAuthService,
LoggerService,
UserInfoService,
@@ -58,7 +57,7 @@ import pThrottle from 'p-throttle';
export interface RouterOptions {
logger: LoggerService;
config: Config;
database: DatabaseService;
store: NotificationsStore;
auth: AuthService;
httpAuth: HttpAuthService;
userInfo: UserInfoService;
@@ -74,7 +73,7 @@ export async function createRouter(
const {
config,
logger,
database,
store,
auth,
httpAuth,
userInfo,
@@ -84,7 +83,6 @@ export async function createRouter(
} = options;
const WEB_NOTIFICATION_CHANNEL = 'Web';
const store = await DatabaseNotificationsStore.create({ database });
const frontendBaseUrl = config.getString('app.baseUrl');
const concurrencyLimit =
config.getOptionalNumber('notifications.concurrencyLimit') ?? 10;
@@ -0,0 +1,99 @@
/*
* Copyright 2025 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Knex } from 'knex';
import { TestDatabases } from '@backstage/backend-test-utils';
import fs from 'fs';
const migrationsDir = `${__dirname}/../../migrations`;
const migrationsFiles = fs.readdirSync(migrationsDir).sort();
async function migrateUpOnce(knex: Knex): Promise<void> {
await knex.migrate.up({ directory: migrationsDir });
}
async function migrateDownOnce(knex: Knex): Promise<void> {
await knex.migrate.down({ directory: migrationsDir });
}
async function migrateUntilBefore(knex: Knex, target: string): Promise<void> {
const index = migrationsFiles.indexOf(target);
if (index === -1) {
throw new Error(`Migration ${target} not found`);
}
for (let i = 0; i < index; i++) {
await migrateUpOnce(knex);
}
}
jest.setTimeout(60_000);
describe('migrations', () => {
const databases = TestDatabases.create();
it.each(databases.eachSupportedId())(
'20221109192547_search_add_original_value_column.js, %p',
async databaseId => {
const knex = await databases.init(databaseId);
await migrateUntilBefore(knex, '20250317_addTopic.js');
await knex
.insert({
user: 'user1',
channel: 'channel1',
origin: 'origin1',
enabled: true,
})
.into('user_settings');
await migrateUpOnce(knex);
let rows = await knex('user_settings');
let normalized = rows.map(r => ({ ...r, enabled: !!r.enabled }));
expect(normalized).toEqual(
expect.arrayContaining([
expect.objectContaining({
user: 'user1',
channel: 'channel1',
origin: 'origin1',
enabled: true,
settings_key_hash:
'73f97aff883b8b08a7f4e366234ef4f86827702b0016574ac4c1bf313c703d15',
topic: null,
}),
]),
);
await migrateDownOnce(knex);
rows = await knex('user_settings');
normalized = rows.map(r => ({ ...r, enabled: !!r.enabled }));
expect(normalized).toEqual(
expect.arrayContaining([
expect.objectContaining({
user: 'user1',
channel: 'channel1',
origin: 'origin1',
enabled: true,
}),
]),
);
},
);
});