| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| import { createLogger } from '@automaker/utils'; |
| import * as secureFs from '../lib/secure-fs.js'; |
| import { getNotificationsPath, ensureAutomakerDir } from '@automaker/platform'; |
| import type { Notification, NotificationsFile, NotificationType } from '@automaker/types'; |
| import { DEFAULT_NOTIFICATIONS_FILE } from '@automaker/types'; |
| import type { EventEmitter } from '../lib/events.js'; |
| import { randomUUID } from 'crypto'; |
|
|
| const logger = createLogger('NotificationService'); |
|
|
| |
| |
| |
| async function atomicWriteJson(filePath: string, data: unknown): Promise<void> { |
| const tempPath = `${filePath}.tmp.${Date.now()}`; |
| const content = JSON.stringify(data, null, 2); |
|
|
| try { |
| await secureFs.writeFile(tempPath, content, 'utf-8'); |
| await secureFs.rename(tempPath, filePath); |
| } catch (error) { |
| |
| try { |
| await secureFs.unlink(tempPath); |
| } catch { |
| |
| } |
| throw error; |
| } |
| } |
|
|
| |
| |
| |
| async function readJsonFile<T>(filePath: string, defaultValue: T): Promise<T> { |
| try { |
| const content = (await secureFs.readFile(filePath, 'utf-8')) as string; |
| return JSON.parse(content) as T; |
| } catch (error) { |
| if ((error as NodeJS.ErrnoException).code === 'ENOENT') { |
| return defaultValue; |
| } |
| logger.error(`Error reading ${filePath}:`, error); |
| return defaultValue; |
| } |
| } |
|
|
| |
| |
| |
| export interface CreateNotificationInput { |
| type: NotificationType; |
| title: string; |
| message: string; |
| featureId?: string; |
| projectPath: string; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| export class NotificationService { |
| private events: EventEmitter | null = null; |
|
|
| |
| |
| |
| setEventEmitter(events: EventEmitter): void { |
| this.events = events; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| async getNotifications(projectPath: string): Promise<Notification[]> { |
| const notificationsPath = getNotificationsPath(projectPath); |
| const file = await readJsonFile<NotificationsFile>( |
| notificationsPath, |
| DEFAULT_NOTIFICATIONS_FILE |
| ); |
| |
| return file.notifications |
| .filter((n) => !n.dismissed) |
| .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| async getUnreadCount(projectPath: string): Promise<number> { |
| const notifications = await this.getNotifications(projectPath); |
| return notifications.filter((n) => !n.read).length; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| async createNotification(input: CreateNotificationInput): Promise<Notification> { |
| const { projectPath, type, title, message, featureId } = input; |
|
|
| |
| await ensureAutomakerDir(projectPath); |
|
|
| const notificationsPath = getNotificationsPath(projectPath); |
| const file = await readJsonFile<NotificationsFile>( |
| notificationsPath, |
| DEFAULT_NOTIFICATIONS_FILE |
| ); |
|
|
| const notification: Notification = { |
| id: randomUUID(), |
| type, |
| title, |
| message, |
| createdAt: new Date().toISOString(), |
| read: false, |
| dismissed: false, |
| featureId, |
| projectPath, |
| }; |
|
|
| file.notifications.push(notification); |
| await atomicWriteJson(notificationsPath, file); |
|
|
| logger.info(`Created notification: ${title} for project ${projectPath}`); |
|
|
| |
| if (this.events) { |
| this.events.emit('notification:created', notification); |
| } |
|
|
| return notification; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| async markAsRead(projectPath: string, notificationId: string): Promise<Notification | null> { |
| const notificationsPath = getNotificationsPath(projectPath); |
| const file = await readJsonFile<NotificationsFile>( |
| notificationsPath, |
| DEFAULT_NOTIFICATIONS_FILE |
| ); |
|
|
| const notification = file.notifications.find((n) => n.id === notificationId); |
| if (!notification) { |
| return null; |
| } |
|
|
| notification.read = true; |
| await atomicWriteJson(notificationsPath, file); |
|
|
| logger.info(`Marked notification ${notificationId} as read`); |
| return notification; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| async markAllAsRead(projectPath: string): Promise<number> { |
| const notificationsPath = getNotificationsPath(projectPath); |
| const file = await readJsonFile<NotificationsFile>( |
| notificationsPath, |
| DEFAULT_NOTIFICATIONS_FILE |
| ); |
|
|
| let count = 0; |
| for (const notification of file.notifications) { |
| if (!notification.read && !notification.dismissed) { |
| notification.read = true; |
| count++; |
| } |
| } |
|
|
| if (count > 0) { |
| await atomicWriteJson(notificationsPath, file); |
| logger.info(`Marked ${count} notifications as read`); |
| } |
|
|
| return count; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| async dismissNotification(projectPath: string, notificationId: string): Promise<boolean> { |
| const notificationsPath = getNotificationsPath(projectPath); |
| const file = await readJsonFile<NotificationsFile>( |
| notificationsPath, |
| DEFAULT_NOTIFICATIONS_FILE |
| ); |
|
|
| const notification = file.notifications.find((n) => n.id === notificationId); |
| if (!notification) { |
| return false; |
| } |
|
|
| notification.dismissed = true; |
| await atomicWriteJson(notificationsPath, file); |
|
|
| logger.info(`Dismissed notification ${notificationId}`); |
| return true; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| async dismissAll(projectPath: string): Promise<number> { |
| const notificationsPath = getNotificationsPath(projectPath); |
| const file = await readJsonFile<NotificationsFile>( |
| notificationsPath, |
| DEFAULT_NOTIFICATIONS_FILE |
| ); |
|
|
| let count = 0; |
| for (const notification of file.notifications) { |
| if (!notification.dismissed) { |
| notification.dismissed = true; |
| count++; |
| } |
| } |
|
|
| if (count > 0) { |
| await atomicWriteJson(notificationsPath, file); |
| logger.info(`Dismissed ${count} notifications`); |
| } |
|
|
| return count; |
| } |
| } |
|
|
| |
| let notificationServiceInstance: NotificationService | null = null; |
|
|
| |
| |
| |
| export function getNotificationService(): NotificationService { |
| if (!notificationServiceInstance) { |
| notificationServiceInstance = new NotificationService(); |
| } |
| return notificationServiceInstance; |
| } |
|
|