diff --git a/workspaces/adoption-insights/.changeset/time-saved-backend.md b/workspaces/adoption-insights/.changeset/time-saved-backend.md new file mode 100644 index 00000000000..c2a49111dc1 --- /dev/null +++ b/workspaces/adoption-insights/.changeset/time-saved-backend.md @@ -0,0 +1,5 @@ +--- +'@red-hat-developer-hub/backstage-plugin-adoption-insights-backend': minor +--- + +Add time-saved backend enrichment, notification scheduler, and frequency preferences diff --git a/workspaces/adoption-insights/packages/backend/package.json b/workspaces/adoption-insights/packages/backend/package.json index aa947e96c76..267ef2758a0 100644 --- a/workspaces/adoption-insights/packages/backend/package.json +++ b/workspaces/adoption-insights/packages/backend/package.json @@ -33,6 +33,7 @@ "@backstage/plugin-catalog-backend-module-logs": "^0.1.23", "@backstage/plugin-catalog-backend-module-scaffolder-entity-model": "^0.2.21", "@backstage/plugin-kubernetes-backend": "^0.21.5", + "@backstage/plugin-notifications-backend": "^0.6.7", "@backstage/plugin-permission-backend": "^0.7.13", "@backstage/plugin-permission-backend-module-allow-all-policy": "^0.2.20", "@backstage/plugin-permission-common": "^0.9.9", diff --git a/workspaces/adoption-insights/packages/backend/src/index.ts b/workspaces/adoption-insights/packages/backend/src/index.ts index a2dc687e08d..6f6aad36188 100644 --- a/workspaces/adoption-insights/packages/backend/src/index.ts +++ b/workspaces/adoption-insights/packages/backend/src/index.ts @@ -60,6 +60,8 @@ backend.add(import('@backstage/plugin-search-backend-module-techdocs')); // kubernetes backend.add(import('@backstage/plugin-kubernetes-backend')); +backend.add(import('@backstage/plugin-notifications-backend')); + backend.add( import('@red-hat-developer-hub/backstage-plugin-adoption-insights-backend'), ); diff --git a/workspaces/adoption-insights/plugins/adoption-insights-backend/migrations/20260716000000_notification_preferences.js b/workspaces/adoption-insights/plugins/adoption-insights-backend/migrations/20260716000000_notification_preferences.js new file mode 100644 index 00000000000..449d69c54d4 --- /dev/null +++ b/workspaces/adoption-insights/plugins/adoption-insights-backend/migrations/20260716000000_notification_preferences.js @@ -0,0 +1,31 @@ +/* + * Copyright Red Hat, Inc. + * + * 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. + */ + +exports.up = function (knex) { + return knex.schema.createTable('notification_preferences', function (table) { + table.text('user_ref').primary(); + table.text('frequency').notNullable().defaultTo('weekly'); + table.timestamp('updated_at').defaultTo(knex.fn.now()); + }); +}; + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.down = function (knex) { + return knex.schema.dropTable('notification_preferences'); +}; diff --git a/workspaces/adoption-insights/plugins/adoption-insights-backend/package.json b/workspaces/adoption-insights/plugins/adoption-insights-backend/package.json index 7df488ae502..71e5a06e169 100644 --- a/workspaces/adoption-insights/plugins/adoption-insights-backend/package.json +++ b/workspaces/adoption-insights/plugins/adoption-insights-backend/package.json @@ -38,6 +38,7 @@ "@backstage/core-plugin-api": "^1.12.7", "@backstage/errors": "^1.3.1", "@backstage/plugin-catalog-node": "^2.2.2", + "@backstage/plugin-notifications-node": "^0.2.28", "@backstage/plugin-permission-common": "^0.9.9", "@red-hat-developer-hub/backstage-plugin-adoption-insights-common": "workspace:^", "express": "^4.17.1", diff --git a/workspaces/adoption-insights/plugins/adoption-insights-backend/src/controllers/EventApiController.test.ts b/workspaces/adoption-insights/plugins/adoption-insights-backend/src/controllers/EventApiController.test.ts index 095bbeabbd4..dc6ac8ee6f2 100644 --- a/workspaces/adoption-insights/plugins/adoption-insights-backend/src/controllers/EventApiController.test.ts +++ b/workspaces/adoption-insights/plugins/adoption-insights-backend/src/controllers/EventApiController.test.ts @@ -26,8 +26,17 @@ import { QUERY_TYPES, QueryParams } from '../types/event-request'; import type { AuditorServiceEvent } from '@backstage/backend-plugin-api'; import { toEndOfDayUTC, toStartOfDayUTC } from '../utils/date'; import { TechDocsCount, TopTechDocsCount } from '../types/event'; +import { Event } from '../models/Event'; let controller: EventApiController; + +const mockCatalog = { + getEntityByRef: jest.fn(), +} as any; + +const mockAuth = { + getOwnServiceCredentials: jest.fn().mockResolvedValue({}), +} as any; let req: Partial; let res: Partial; @@ -42,6 +51,7 @@ const mockEventDb = { getTopTechDocsViews: jest.fn(), getTopTemplateViews: jest.fn(), getTopCatalogEntitiesViews: jest.fn(), + getTimeSavedTotals: jest.fn(), getTechdocsMetadata: jest.fn(), } as unknown as jest.Mocked; @@ -82,6 +92,8 @@ describe('trackEvents', () => { mockProcessor, mockServices.rootConfig.mock(), mockAuditor, + mockCatalog, + mockAuth, ); mockProcessIncomingEvents = jest.spyOn( @@ -228,6 +240,8 @@ describe('GetInsights', () => { mockProcessor, mockServices.rootConfig.mock(), mockAuditor, + mockCatalog, + mockAuth, ); global.fetch = jest.fn().mockResolvedValue({} as any); @@ -301,6 +315,7 @@ describe('GetInsights', () => { mockEventDb.getTopTemplateViews.mockResolvedValue({} as any); mockEventDb.getTopTechDocsViews.mockResolvedValue({} as any); mockEventDb.getTopCatalogEntitiesViews.mockResolvedValue({} as any); + mockEventDb.getTimeSavedTotals.mockResolvedValue({} as any); for (const type of QUERY_TYPES) { req.query = { @@ -321,6 +336,7 @@ describe('GetInsights', () => { expect(mockEventDb.getTopTemplateViews).toHaveBeenCalled(); expect(mockEventDb.getTopTechDocsViews).toHaveBeenCalled(); expect(mockEventDb.getTopCatalogEntitiesViews).toHaveBeenCalled(); + expect(mockEventDb.getTimeSavedTotals).toHaveBeenCalled(); }); it('should call getTechdocsMetadata method', async () => { @@ -474,4 +490,159 @@ describe('GetInsights', () => { expect(res.status).toHaveBeenCalledWith(500); }); + + it('should return time_saved_totals data', async () => { + const timeSavedData = { + data: { + total_time_saved_minutes: 540, + templates: [ + { + entityref: 'template:default/example-nodejs-template', + execution_count: 3, + time_saved_per_execution: 180, + total_time_saved_minutes: 540, + }, + ], + }, + }; + mockEventDb.getTimeSavedTotals.mockResolvedValue(timeSavedData as any); + + req.query = { + ...req.query, + type: 'time_saved_totals', + }; + + await controller.getInsights( + req as unknown as Request<{}, {}, {}, QueryParams>, + res as Response, + ); + + expect(mockEventDb.getTimeSavedTotals).toHaveBeenCalled(); + expect(res.json).toHaveBeenCalledWith(timeSavedData); + }); +}); + +describe('enrichScaffolderEvent', () => { + let enrichFn: (event: Event) => Promise; + + beforeEach(() => { + controller = new EventApiController( + mockEventDb, + mockProcessor, + mockServices.rootConfig.mock(), + mockAuditor, + mockCatalog, + mockAuth, + ); + enrichFn = (controller as any).enrichScaffolderEvent.bind(controller); + jest.clearAllMocks(); + }); + + const makeScaffolderEvent = (entityRef: string): Event => { + const analyticsEvent: AnalyticsEvent = { + action: 'create', + subject: 'Task has been created', + context: { + routeRef: 'scaffolder', + pluginId: 'scaffolder', + extension: 'routeRef', + userName: 'user:default/guest', + userId: 'user:default/guest', + entityRef, + }, + }; + return new Event(analyticsEvent, false); + }; + + it('should enrich scaffolder event with time-saved value from catalog', async () => { + mockCatalog.getEntityByRef.mockResolvedValue({ + metadata: { + annotations: { 'rhdh.redhat.com/time-saved': '180' }, + }, + }); + + const event = makeScaffolderEvent('template:default/my-template'); + const result = await enrichFn(event); + + expect(result.value).toBe(180); + expect(mockCatalog.getEntityByRef).toHaveBeenCalledWith( + 'template:default/my-template', + expect.any(Object), + ); + }); + + it('should skip non-scaffolder events', async () => { + const analyticsEvent: AnalyticsEvent = { + action: 'navigate', + subject: 'some page', + context: { + routeRef: 'catalog', + pluginId: 'catalog', + extension: 'routeRef', + }, + }; + const event = new Event(analyticsEvent, false); + const result = await enrichFn(event); + + expect(result.value).toBeUndefined(); + expect(mockCatalog.getEntityByRef).not.toHaveBeenCalled(); + }); + + it('should return event unchanged when entity is not found', async () => { + mockCatalog.getEntityByRef.mockResolvedValue(null); + + const event = makeScaffolderEvent('template:default/missing'); + const result = await enrichFn(event); + + expect(result.value).toBeUndefined(); + }); + + it('should return event unchanged when annotation is missing', async () => { + mockCatalog.getEntityByRef.mockResolvedValue({ + metadata: { annotations: {} }, + }); + + const event = makeScaffolderEvent('template:default/no-annotation'); + const result = await enrichFn(event); + + expect(result.value).toBeUndefined(); + }); + + it('should return event unchanged when annotation is not a positive number', async () => { + mockCatalog.getEntityByRef.mockResolvedValue({ + metadata: { + annotations: { 'rhdh.redhat.com/time-saved': '-10' }, + }, + }); + + const event = makeScaffolderEvent('template:default/negative'); + const result = await enrichFn(event); + + expect(result.value).toBeUndefined(); + }); + + it('should handle catalog errors gracefully', async () => { + mockCatalog.getEntityByRef.mockRejectedValue(new Error('catalog down')); + + const event = makeScaffolderEvent('template:default/error'); + const result = await enrichFn(event); + + expect(result.value).toBeUndefined(); + }); + + it('should parse context when stored as JSON string', async () => { + mockCatalog.getEntityByRef.mockResolvedValue({ + metadata: { + annotations: { 'rhdh.redhat.com/time-saved': '60' }, + }, + }); + + const event = makeScaffolderEvent('template:default/stringified'); + (event as any).context = JSON.stringify({ + entityRef: 'template:default/stringified', + }); + const result = await enrichFn(event); + + expect(result.value).toBe(60); + }); }); diff --git a/workspaces/adoption-insights/plugins/adoption-insights-backend/src/controllers/EventApiController.ts b/workspaces/adoption-insights/plugins/adoption-insights-backend/src/controllers/EventApiController.ts index dfe32c1bd1c..f3085b1628d 100644 --- a/workspaces/adoption-insights/plugins/adoption-insights-backend/src/controllers/EventApiController.ts +++ b/workspaces/adoption-insights/plugins/adoption-insights-backend/src/controllers/EventApiController.ts @@ -28,42 +28,86 @@ import { ValidationError } from '../validation/ValidationError'; import { AuditorService, AuditorServiceEvent, + AuthService, RootConfigService, } from '@backstage/backend-plugin-api'; +import { CatalogService } from '@backstage/plugin-catalog-node'; import { getLicensedUsersCount } from '../utils/config'; import { TechDocsCount, TopTechDocsCount } from '../types/event'; +const TIME_SAVED_ANNOTATION = 'rhdh.redhat.com/time-saved'; + class EventApiController { private readonly database: EventDatabase; private readonly config: RootConfigService; private readonly processor: EventBatchProcessor; private readonly auditor: AuditorService; + private readonly catalog: CatalogService; + private readonly auth: AuthService; constructor( eventDatabase: EventDatabase, processor: EventBatchProcessor, config: RootConfigService, auditor: AuditorService, + catalog: CatalogService, + auth: AuthService, ) { this.database = eventDatabase; this.processor = processor; this.config = config; this.auditor = auditor; + this.catalog = catalog; + this.auth = auth; } async getBaseUrl(pluginId: string): Promise { return `${this.config.getString('backend.baseUrl')}/api/${pluginId}`; } - private processIncomingEvents( + private async enrichScaffolderEvent(event: Event): Promise { + if ( + event.action !== 'create' || + event.subject !== 'Task has been created' || + event.plugin_id !== 'scaffolder' + ) { + return event; + } + + try { + const context = + typeof event.context === 'string' + ? JSON.parse(event.context) + : event.context; + const entityRef = context?.entityRef; + if (!entityRef) return event; + + const entity = await this.catalog.getEntityByRef(entityRef, { + credentials: await this.auth.getOwnServiceCredentials(), + }); + if (!entity) return event; + + const timeSaved = entity.metadata?.annotations?.[TIME_SAVED_ANNOTATION]; + if (!timeSaved) return event; + + const parsed = Number(timeSaved); + if (!Number.isFinite(parsed) || parsed <= 0) return event; + + return Object.assign(event, { value: parsed }); + } catch { + return event; + } + } + + private async processIncomingEvents( events: AnalyticsEvent[], auditEvent: AuditorServiceEvent, - ): void { - const proccessedEvents = events + ): Promise { + const processedEvents = events .filter(e => !!e.context?.userId) .map(event => new Event(event, this.database.isJsonSupported())); - proccessedEvents.forEach(event => { + for (const event of processedEvents) { const result = EventSchema.safeParse(event); if (!result.success) { @@ -73,8 +117,9 @@ class EventApiController { throw new ValidationError('Invalid event data', result.error.flatten()); } auditEvent.success({ meta: { eventId: event.id } }); - return this.processor.addEvent(event); - }); + const enriched = await this.enrichScaffolderEvent(event); + this.processor.addEvent(enriched); + } } async trackEvents( @@ -89,7 +134,7 @@ class EventApiController { }); try { - this.processIncomingEvents(events, auditEvent); + await this.processIncomingEvents(events, auditEvent); res.status(200).json({ success: true, message: 'Event received' }); } catch (error) { auditEvent.fail({ @@ -146,6 +191,7 @@ class EventApiController { top_techdocs: () => db.getTopTechDocsViews(), top_templates: () => db.getTopTemplateViews(), top_catalog_entities: () => db.getTopCatalogEntitiesViews(), + time_saved_totals: () => db.getTimeSavedTotals(), }; try { diff --git a/workspaces/adoption-insights/plugins/adoption-insights-backend/src/database/adapters/BaseAdapter.test.ts b/workspaces/adoption-insights/plugins/adoption-insights-backend/src/database/adapters/BaseAdapter.test.ts index 460057151e5..d4a24b7b6f4 100644 --- a/workspaces/adoption-insights/plugins/adoption-insights-backend/src/database/adapters/BaseAdapter.test.ts +++ b/workspaces/adoption-insights/plugins/adoption-insights-backend/src/database/adapters/BaseAdapter.test.ts @@ -31,6 +31,7 @@ describe('BaseAdapter', () => { select: jest.fn().mockReturnThis(), from: jest.fn().mockReturnThis(), where: jest.fn().mockReturnThis(), + whereNotNull: jest.fn().mockReturnThis(), whereBetween: jest.fn().mockReturnThis(), groupBy: jest.fn().mockReturnThis(), groupByRaw: jest.fn().mockReturnThis(), @@ -62,6 +63,8 @@ describe('BaseAdapter', () => { db.with = jest.fn().mockReturnThis(); db.select = jest.fn().mockReturnThis(); db.andWhere = jest.fn().mockReturnThis(); + db.where = jest.fn().mockReturnThis(); + db.whereNotNull = jest.fn().mockReturnThis(); db.whereBetween = jest.fn().mockReturnThis(); db.leftJoin = jest.fn().mockReturnThis(); db.groupBy = jest.fn().mockReturnThis(); @@ -427,6 +430,108 @@ describe('BaseAdapter', () => { }); }); + describe('getTimeSavedTotals', () => { + const setupTimeSavedDb = (rows: any[]) => { + const dbFn = jest.fn().mockReturnValue(mockKnex) as any; + dbFn.raw = jest.fn().mockReturnValue('mocked_raw_sql'); + mockKnex.groupByRaw.mockReturnThis(); + mockKnex.orderBy.mockResolvedValue(rows); + return dbFn; + }; + + it('should query scaffolder create events with value and return aggregated totals', async () => { + const mockRows = [ + { + entityref: 'template:default/my-template', + execution_count: 3, + time_saved_per_execution: 180, + total_time_saved_minutes: 540, + }, + ]; + + const dbFn = setupTimeSavedDb(mockRows); + const db = new PostgresAdapter(dbFn, logger); + db.setFilters({ + timezone: 'UTC', + start_date: '2026-07-01T00:00:00.000Z', + end_date: '2026-07-31T23:59:59.999Z', + }); + + const result = await db.getTimeSavedTotals(); + + expect(dbFn).toHaveBeenCalledWith('events'); + expect(mockKnex.where).toHaveBeenCalledWith({ + action: 'create', + subject: 'Task has been created', + plugin_id: 'scaffolder', + }); + expect(mockKnex.whereNotNull).toHaveBeenCalledWith('value'); + expect(mockKnex.whereBetween).toHaveBeenCalledWith('created_at', [ + '2026-07-01T00:00:00.000Z', + '2026-07-31T23:59:59.999Z', + ]); + expect(result).toEqual({ + data: { + total_time_saved_minutes: 540, + templates: mockRows, + }, + }); + }); + + it('should return zero totals when no events have value', async () => { + const dbFn = setupTimeSavedDb([]); + const db = new PostgresAdapter(dbFn, logger); + db.setFilters({ + timezone: 'UTC', + start_date: '2026-07-01T00:00:00.000Z', + end_date: '2026-07-31T23:59:59.999Z', + }); + + const result = await db.getTimeSavedTotals(); + + expect(result).toEqual({ + data: { + total_time_saved_minutes: 0, + templates: [], + }, + }); + }); + + it('should sum totals across multiple templates', async () => { + const mockRows = [ + { + entityref: 'template:default/template-a', + execution_count: 5, + time_saved_per_execution: 60, + total_time_saved_minutes: 300, + }, + { + entityref: 'template:default/template-b', + execution_count: 2, + time_saved_per_execution: 180, + total_time_saved_minutes: 360, + }, + ]; + + const dbFn = setupTimeSavedDb(mockRows); + const db = new PostgresAdapter(dbFn, logger); + db.setFilters({ + timezone: 'UTC', + start_date: '2026-07-01T00:00:00.000Z', + end_date: '2026-07-31T23:59:59.999Z', + }); + + const result = await db.getTimeSavedTotals(); + + expect(result).toEqual({ + data: { + total_time_saved_minutes: 660, + templates: mockRows, + }, + }); + }); + }); + describe('ensureFiltersSet', () => { it('should throw error if the filters are not', () => { const db = new PostgresAdapter(mockKnex, logger); diff --git a/workspaces/adoption-insights/plugins/adoption-insights-backend/src/database/adapters/BaseAdapter.ts b/workspaces/adoption-insights/plugins/adoption-insights-backend/src/database/adapters/BaseAdapter.ts index 17a940d69e3..37c3f2dba9c 100644 --- a/workspaces/adoption-insights/plugins/adoption-insights-backend/src/database/adapters/BaseAdapter.ts +++ b/workspaces/adoption-insights/plugins/adoption-insights-backend/src/database/adapters/BaseAdapter.ts @@ -22,6 +22,8 @@ import { Grouping, ResponseData, ResponseWithGrouping, + UserTimeSaved, + NotificationFrequency, } from '../../types/event'; import { convertToTargetTimezone } from '../../utils/date'; @@ -429,6 +431,89 @@ export abstract class BaseDatabaseAdapter implements EventDatabase { }; }; + async getTimeSavedTotals(): Promise { + this.ensureFiltersSet(); + const { start_date, end_date } = this.filters!; + const db = this.db; + + const rows = await db('events') + .select( + db.raw(`context->>'entityRef' AS entityref`), + db.raw('CAST(COUNT(*) AS INTEGER) AS execution_count'), + db.raw( + 'CAST(COALESCE(AVG(CAST(value AS REAL)), 0) AS INTEGER) AS time_saved_per_execution', + ), + db.raw( + 'CAST(COALESCE(SUM(CAST(value AS REAL)), 0) AS INTEGER) AS total_time_saved_minutes', + ), + ) + .where({ + action: 'create', + subject: 'Task has been created', + plugin_id: 'scaffolder', + }) + .whereNotNull('value') + .whereBetween('created_at', [start_date, end_date]) + .groupByRaw('entityref') + .orderBy('total_time_saved_minutes', 'desc'); + + const grandTotal = rows.reduce( + (sum: number, row: { total_time_saved_minutes: number }) => + sum + (row.total_time_saved_minutes || 0), + 0, + ); + + return { + data: { + total_time_saved_minutes: grandTotal, + templates: rows, + }, + } as any; + } + + async getNotificationPreference( + userRef: string, + ): Promise { + const row = await this.db('notification_preferences') + .select('frequency') + .where({ user_ref: userRef }) + .first(); + return (row?.frequency as NotificationFrequency) ?? 'weekly'; + } + + async setNotificationPreference( + userRef: string, + frequency: NotificationFrequency, + ): Promise { + const now = new Date().toISOString(); + await this.db('notification_preferences') + .insert({ user_ref: userRef, frequency, updated_at: now }) + .onConflict('user_ref') + .merge({ frequency, updated_at: now }); + } + + async getTimeSavedPerUser(since: string): Promise { + const db = this.db; + + return db('events') + .select( + 'user_ref', + db.raw('CAST(COUNT(*) AS INTEGER) AS execution_count'), + db.raw( + 'CAST(COALESCE(SUM(CAST(value AS REAL)), 0) AS INTEGER) AS total_time_saved_minutes', + ), + ) + .where({ + action: 'create', + subject: 'Task has been created', + plugin_id: 'scaffolder', + }) + .whereNotNull('value') + .where('created_at', '>=', since) + .groupBy('user_ref') + .orderBy('total_time_saved_minutes', 'desc'); + } + ensureFiltersSet() { if (!this.filters) { throw new Error( diff --git a/workspaces/adoption-insights/plugins/adoption-insights-backend/src/database/event-database.ts b/workspaces/adoption-insights/plugins/adoption-insights-backend/src/database/event-database.ts index 5732d8cfad5..a54e09072bf 100644 --- a/workspaces/adoption-insights/plugins/adoption-insights-backend/src/database/event-database.ts +++ b/workspaces/adoption-insights/plugins/adoption-insights-backend/src/database/event-database.ts @@ -22,7 +22,10 @@ import { TopSearches, TopTechDocsCount, TopTemplatesCount, + TimeSavedTotals, TotalUsers, + UserTimeSaved, + NotificationFrequency, } from '../types/event'; import { Event } from '../models/Event'; @@ -60,4 +63,11 @@ export interface EventDatabase { getTopCatalogEntitiesViews(): Promise< Knex.QueryBuilder >; + getTimeSavedTotals(): Promise>; + getTimeSavedPerUser(since: string): Promise; + getNotificationPreference(userRef: string): Promise; + setNotificationPreference( + userRef: string, + frequency: NotificationFrequency, + ): Promise; } diff --git a/workspaces/adoption-insights/plugins/adoption-insights-backend/src/domain/EventBatchProcessor.test.ts b/workspaces/adoption-insights/plugins/adoption-insights-backend/src/domain/EventBatchProcessor.test.ts index 59ee694fbcc..182f8c94f42 100644 --- a/workspaces/adoption-insights/plugins/adoption-insights-backend/src/domain/EventBatchProcessor.test.ts +++ b/workspaces/adoption-insights/plugins/adoption-insights-backend/src/domain/EventBatchProcessor.test.ts @@ -44,6 +44,10 @@ describe('EventBatchProcessor', () => { getTopCatalogEntitiesViews: jest .fn() .mockReturnValue({} as Knex.QueryBuilder), + getTimeSavedTotals: jest.fn().mockReturnValue({} as Knex.QueryBuilder), + getTimeSavedPerUser: jest.fn().mockResolvedValue([]), + getNotificationPreference: jest.fn().mockResolvedValue('weekly'), + setNotificationPreference: jest.fn().mockResolvedValue(undefined), }; const mockLogger: jest.Mocked = { diff --git a/workspaces/adoption-insights/plugins/adoption-insights-backend/src/notifications/scheduleTimeSavedNotifications.ts b/workspaces/adoption-insights/plugins/adoption-insights-backend/src/notifications/scheduleTimeSavedNotifications.ts new file mode 100644 index 00000000000..16caba2793b --- /dev/null +++ b/workspaces/adoption-insights/plugins/adoption-insights-backend/src/notifications/scheduleTimeSavedNotifications.ts @@ -0,0 +1,129 @@ +/* + * Copyright Red Hat, Inc. + * + * 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, + RootConfigService, + SchedulerService, +} from '@backstage/backend-plugin-api'; +import { NotificationService } from '@backstage/plugin-notifications-node'; +import { EventDatabase } from '../database/event-database'; +import { NotificationFrequency } from '../types/event'; + +function formatTimeSaved(totalMinutes: number): string { + const days = Math.floor(totalMinutes / 1440); + const hours = Math.floor((totalMinutes % 1440) / 60); + const minutes = Math.round(totalMinutes % 60); + + if (days > 0 && hours > 0) return `${days} days ${hours} hours`; + if (days > 0) return `${days} days`; + if (hours > 0 && minutes > 0) return `${hours} hours ${minutes} minutes`; + if (hours > 0) return `${hours} hours`; + return `${minutes} minutes`; +} + +function getPeriodLabel(frequency: NotificationFrequency): string { + switch (frequency) { + case 'daily': + return 'today'; + case 'weekly': + return 'this week'; + case 'monthly': + return 'this month'; + default: + return 'this week'; + } +} + +function shouldSendForFrequency(frequency: NotificationFrequency): boolean { + if (frequency === 'none') return false; + if (frequency === 'daily') return true; + + const now = new Date(); + if (frequency === 'weekly') return now.getDay() === 1; + if (frequency === 'monthly') return now.getDate() === 1; + + return false; +} + +export async function scheduleTimeSavedNotifications(options: { + scheduler: SchedulerService; + db: EventDatabase; + notification: NotificationService; + logger: LoggerService; + config: RootConfigService; +}): Promise { + const { scheduler, db, notification, logger, config } = options; + + const enabled = + config.getOptionalBoolean('adoptionInsights.notifications.enabled') ?? true; + if (!enabled) { + logger.info('[TIME-SAVED-NOTIFICATIONS] Notifications disabled via config'); + return; + } + + const baseUrl = config.getOptionalString('app.baseUrl') ?? ''; + + const runner = scheduler.createScheduledTaskRunner({ + frequency: { cron: '0 9 * * *' }, + timeout: { minutes: 5 }, + }); + + runner.run({ + id: 'adoption-insights:time-saved-notifications', + fn: async () => { + const lookbacks: { + frequency: NotificationFrequency; + days: number; + }[] = [ + { frequency: 'daily', days: 1 }, + { frequency: 'weekly', days: 7 }, + { frequency: 'monthly', days: 30 }, + ]; + + let sentCount = 0; + + for (const { frequency, days } of lookbacks) { + if (!shouldSendForFrequency(frequency)) continue; + + const since = new Date(); + since.setDate(since.getDate() - days); + const users = await db.getTimeSavedPerUser(since.toISOString()); + const period = getPeriodLabel(frequency); + + for (const user of users) { + if (user.total_time_saved_minutes <= 0) continue; + + const pref = await db.getNotificationPreference(user.user_ref); + if (pref !== frequency) continue; + + const timeSaved = formatTimeSaved(user.total_time_saved_minutes); + + await notification.send({ + recipients: { type: 'entity', entityRef: user.user_ref }, + payload: { + title: `You completed ${user.execution_count} self-service actions ${period}, saving an estimated ${timeSaved} of time.`, + link: `${baseUrl}/adoption-insights`, + topic: 'time-saved-summary', + }, + }); + sentCount++; + } + } + + logger.info(`[TIME-SAVED-NOTIFICATIONS] Sent ${sentCount} notifications`); + }, + }); +} diff --git a/workspaces/adoption-insights/plugins/adoption-insights-backend/src/plugin.test.ts b/workspaces/adoption-insights/plugins/adoption-insights-backend/src/plugin.test.ts index 9c51a9aa86b..509ad46af1b 100644 --- a/workspaces/adoption-insights/plugins/adoption-insights-backend/src/plugin.test.ts +++ b/workspaces/adoption-insights/plugins/adoption-insights-backend/src/plugin.test.ts @@ -41,7 +41,7 @@ describe('plugin', () => { ], end_date: ['end_date is required. Use YYYY-MM-DD (e.g., 2025-03-02)'], type: [ - 'Invalid type. Allowed values: total_users,active_users,top_plugins,top_templates,top_techdocs,top_searches,top_catalog_entities', + 'Invalid type. Allowed values: total_users,active_users,top_plugins,top_templates,top_techdocs,top_searches,top_catalog_entities,time_saved_totals', ], }, }); diff --git a/workspaces/adoption-insights/plugins/adoption-insights-backend/src/plugin.ts b/workspaces/adoption-insights/plugins/adoption-insights-backend/src/plugin.ts index f5a34ac0eec..f1d6f08d408 100644 --- a/workspaces/adoption-insights/plugins/adoption-insights-backend/src/plugin.ts +++ b/workspaces/adoption-insights/plugins/adoption-insights-backend/src/plugin.ts @@ -17,6 +17,8 @@ import { coreServices, createBackendPlugin, } from '@backstage/backend-plugin-api'; +import { catalogServiceRef } from '@backstage/plugin-catalog-node'; +import { notificationService } from '@backstage/plugin-notifications-node'; import { adoptionInsightsEventsReadPermission } from '@red-hat-developer-hub/backstage-plugin-adoption-insights-common'; import { createRouter } from './router'; import { migrate } from './database/migration'; @@ -24,6 +26,7 @@ import { DatabaseFactory } from './database/DatabaseFactory'; import { EventBatchProcessor } from './domain/EventBatchProcessor'; import EventApiController from './controllers/EventApiController'; import { schedulePartition } from './database/partition'; +import { scheduleTimeSavedNotifications } from './notifications/scheduleTimeSavedNotifications'; import { getConfigurationOptions } from './utils/config'; /** @@ -45,6 +48,9 @@ export const adoptionInsightsPlugin = createBackendPlugin({ scheduler: coreServices.scheduler, permissions: coreServices.permissions, permissionsRegistry: coreServices.permissionsRegistry, + auth: coreServices.auth, + catalog: catalogServiceRef, + notification: notificationService, }, async init({ auditor, @@ -56,6 +62,9 @@ export const adoptionInsightsPlugin = createBackendPlugin({ scheduler, permissions, permissionsRegistry, + auth, + catalog, + notification, }) { // Register plugin permission permissionsRegistry.addPermissions([ @@ -72,6 +81,8 @@ export const adoptionInsightsPlugin = createBackendPlugin({ processor, config, auditor, + catalog, + auth, ); // Migrate database @@ -82,11 +93,21 @@ export const adoptionInsightsPlugin = createBackendPlugin({ schedulePartition(client, { logger, scheduler }); } + // Schedule weekly time-saved notification summary + await scheduleTimeSavedNotifications({ + scheduler, + db, + notification, + logger, + config, + }); + httpRouter.use( await createRouter({ httpAuth, permissions, eventApiController, + db, }), ); diff --git a/workspaces/adoption-insights/plugins/adoption-insights-backend/src/router.test.ts b/workspaces/adoption-insights/plugins/adoption-insights-backend/src/router.test.ts index e9670265254..bf880f0d190 100644 --- a/workspaces/adoption-insights/plugins/adoption-insights-backend/src/router.test.ts +++ b/workspaces/adoption-insights/plugins/adoption-insights-backend/src/router.test.ts @@ -64,16 +64,24 @@ describe('createRouter', () => { }), }; + const mockCatalog = { getEntityByRef: jest.fn() } as any; + const mockAuth = { + getPluginRequestToken: jest.fn().mockResolvedValue({ token: 'mock' }), + getOwnServiceCredentials: jest.fn().mockResolvedValue({}), + } as any; const eventApiController = new EventApiController( mockEventDatabase as any, mockEventBatchProcessor as any, mockServices.rootConfig.mock(), mockAuditor as any, + mockCatalog, + mockAuth, ); const router = await createRouter({ httpAuth: mockServices.httpAuth(), eventApiController, + db: mockEventDatabase as any, permissions: mockServices.permissions.mock({ authorize: async () => [{ result: authorizeResult }], }), diff --git a/workspaces/adoption-insights/plugins/adoption-insights-backend/src/router.ts b/workspaces/adoption-insights/plugins/adoption-insights-backend/src/router.ts index d0975437104..3c56dd790bd 100644 --- a/workspaces/adoption-insights/plugins/adoption-insights-backend/src/router.ts +++ b/workspaces/adoption-insights/plugins/adoption-insights-backend/src/router.ts @@ -24,16 +24,27 @@ import express, { Request, Response } from 'express'; import Router from 'express-promise-router'; import EventApiController from './controllers/EventApiController'; +import { EventDatabase } from './database/event-database'; import { QueryParams } from './types/event-request'; +import { NotificationFrequency } from './types/event'; + +const VALID_FREQUENCIES: NotificationFrequency[] = [ + 'daily', + 'weekly', + 'monthly', + 'none', +]; export async function createRouter({ httpAuth, permissions, eventApiController, + db, }: { httpAuth: HttpAuthService; permissions: PermissionsService; eventApiController: EventApiController; + db: EventDatabase; }): Promise { const router = Router(); @@ -77,6 +88,29 @@ export async function createRouter({ return eventApiController.trackEvents(req, res); }); + router.get('/notification-preferences', async (req, res) => { + const userRef = await getUserEntityRef(req); + const frequency = await db.getNotificationPreference(userRef); + res.json({ frequency }); + }); + + router.put('/notification-preferences', async (req, res) => { + const userRef = await getUserEntityRef(req); + const { frequency } = req.body; + + if (!VALID_FREQUENCIES.includes(frequency)) { + res.status(400).json({ + error: `Invalid frequency. Allowed values: ${VALID_FREQUENCIES.join( + ', ', + )}`, + }); + return; + } + + await db.setNotificationPreference(userRef, frequency); + res.json({ frequency }); + }); + router.get('/health', (_, response) => { response.json({ status: 'ok' }); }); diff --git a/workspaces/adoption-insights/plugins/adoption-insights-backend/src/types/event-request.ts b/workspaces/adoption-insights/plugins/adoption-insights-backend/src/types/event-request.ts index 68f12aaff23..f73c51ee57f 100644 --- a/workspaces/adoption-insights/plugins/adoption-insights-backend/src/types/event-request.ts +++ b/workspaces/adoption-insights/plugins/adoption-insights-backend/src/types/event-request.ts @@ -21,6 +21,7 @@ export const QUERY_TYPES = [ 'top_techdocs', 'top_searches', 'top_catalog_entities', + 'time_saved_totals', ] as const; export type QueryType = (typeof QUERY_TYPES)[number]; diff --git a/workspaces/adoption-insights/plugins/adoption-insights-backend/src/types/event.ts b/workspaces/adoption-insights/plugins/adoption-insights-backend/src/types/event.ts index c16a3d91cef..2dc9fd52129 100644 --- a/workspaces/adoption-insights/plugins/adoption-insights-backend/src/types/event.ts +++ b/workspaces/adoption-insights/plugins/adoption-insights-backend/src/types/event.ts @@ -75,3 +75,23 @@ export type TopPluginCount = ResponseWithGrouping; export type TopTechDocsCount = ResponseData; export type TopTemplatesCount = ResponseData; export type TopCatalogEntitiesCount = ResponseData; + +export interface TimeSavedTemplate { + entityref: string; + execution_count: number; + time_saved_per_execution: number; + total_time_saved_minutes: number; +} + +export type TimeSavedTotals = ResponseData<{ + total_time_saved_minutes: number; + templates: TimeSavedTemplate[]; +}>; + +export type NotificationFrequency = 'daily' | 'weekly' | 'monthly' | 'none'; + +export interface UserTimeSaved { + user_ref: string; + execution_count: number; + total_time_saved_minutes: number; +} diff --git a/workspaces/adoption-insights/yarn.lock b/workspaces/adoption-insights/yarn.lock index 79dbc9cb5ae..94786e8d758 100644 --- a/workspaces/adoption-insights/yarn.lock +++ b/workspaces/adoption-insights/yarn.lock @@ -3671,6 +3671,48 @@ __metadata: languageName: node linkType: hard +"@backstage/plugin-notifications-backend@npm:^0.6.7": + version: 0.6.7 + resolution: "@backstage/plugin-notifications-backend@npm:0.6.7" + dependencies: + "@backstage/backend-openapi-utils": "npm:^0.7.0" + "@backstage/backend-plugin-api": "npm:^1.9.3" + "@backstage/catalog-model": "npm:^1.9.0" + "@backstage/config": "npm:^1.3.8" + "@backstage/errors": "npm:^1.3.1" + "@backstage/plugin-catalog-node": "npm:^2.2.3" + "@backstage/plugin-notifications-common": "npm:^0.2.3" + "@backstage/plugin-notifications-node": "npm:^0.2.28" + "@backstage/plugin-signals-node": "npm:^0.2.3" + "@backstage/types": "npm:^1.2.2" + express: "npm:^4.22.0" + express-promise-router: "npm:^4.1.0" + knex: "npm:^3.0.0" + p-throttle: "npm:^4.1.1" + checksum: 10c0/6b3117bf28b3afda33e4bced56fb933558ff497f5b0bffc3dcf6d103ee9c721b384235dd0f1b859b35a1eba2d5291251601a576e31507ba779f4a5daf78c35a8 + languageName: node + linkType: hard + +"@backstage/plugin-notifications-common@npm:^0.2.3": + version: 0.2.3 + resolution: "@backstage/plugin-notifications-common@npm:0.2.3" + dependencies: + "@backstage/config": "npm:^1.3.8" + "@backstage/types": "npm:^1.2.2" + checksum: 10c0/d8a64fca1771d6b8b59d24f0fa06610484e5842c757b1fde4b7cdbd7ee98c6a35cbba0302a5208fcc6e9981b10a64123f8d73b8c0c07f400500f8fa749b28436 + languageName: node + linkType: hard + +"@backstage/plugin-notifications-node@npm:^0.2.28": + version: 0.2.28 + resolution: "@backstage/plugin-notifications-node@npm:0.2.28" + dependencies: + "@backstage/backend-plugin-api": "npm:^1.9.3" + "@backstage/plugin-notifications-common": "npm:^0.2.3" + checksum: 10c0/23a244bfdf1b39712dd08a77f3620fb7d06bd9ce07478447b2c4b81a4d1ed2daad0cc5404c5e7a2d3c282d16eefb88256c3d2ea3f69b8c78368609b22258e837 + languageName: node + linkType: hard + "@backstage/plugin-org@npm:^0.7.5": version: 0.7.6 resolution: "@backstage/plugin-org@npm:0.7.6" @@ -4211,6 +4253,17 @@ __metadata: languageName: node linkType: hard +"@backstage/plugin-signals-node@npm:^0.2.3": + version: 0.2.3 + resolution: "@backstage/plugin-signals-node@npm:0.2.3" + dependencies: + "@backstage/backend-plugin-api": "npm:^1.9.3" + "@backstage/plugin-events-node": "npm:^0.4.24" + "@backstage/types": "npm:^1.2.2" + checksum: 10c0/3f7d8a63c71c9c2f102f553bdeb667e81786b5f427e12819bf4a2f1eb513bb672e3f21883f52c205681859fdfecce144e5db43cf4cf13e4b7e707c0a39bbe84a + languageName: node + linkType: hard + "@backstage/plugin-signals-react@npm:^0.0.24": version: 0.0.24 resolution: "@backstage/plugin-signals-react@npm:0.0.24" @@ -9441,6 +9494,7 @@ __metadata: "@backstage/core-plugin-api": "npm:^1.12.7" "@backstage/errors": "npm:^1.3.1" "@backstage/plugin-catalog-node": "npm:^2.2.2" + "@backstage/plugin-notifications-node": "npm:^0.2.28" "@backstage/plugin-permission-common": "npm:^0.9.9" "@red-hat-developer-hub/backstage-plugin-adoption-insights-common": "workspace:^" "@types/express": "npm:^4.17.6" @@ -14936,6 +14990,7 @@ __metadata: "@backstage/plugin-catalog-backend-module-logs": "npm:^0.1.23" "@backstage/plugin-catalog-backend-module-scaffolder-entity-model": "npm:^0.2.21" "@backstage/plugin-kubernetes-backend": "npm:^0.21.5" + "@backstage/plugin-notifications-backend": "npm:^0.6.7" "@backstage/plugin-permission-backend": "npm:^0.7.13" "@backstage/plugin-permission-backend-module-allow-all-policy": "npm:^0.2.20" "@backstage/plugin-permission-common": "npm:^0.9.9"