diff --git a/app/actions/contact.ts b/app/actions/contact.ts index 18f327d1..542441a9 100644 --- a/app/actions/contact.ts +++ b/app/actions/contact.ts @@ -69,9 +69,19 @@ export async function sendContactFormAction(formData: FormData) { if (result.success) { logger.info('Contact form email sent successfully', { messageId: result.messageId }); + await services.notifications.notify({ + title: `📩 ${subject}`, + message: `New message from ${name} (${email}):\n\n${message}`, + priority: 5, + }); } else { logger.error('Failed to send contact form email', { error: result.error }); services.errors.captureException(result.error, { action: 'sendContactFormAction', email }); + await services.notifications.notify({ + title: '🚨 Contact Form Error', + message: `Failed to send email for ${name} (${email}). Error: ${JSON.stringify(result.error)}`, + priority: 8, + }); } return result; diff --git a/lib/config.ts b/lib/config.ts index 88d2517d..e39e2626 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -69,6 +69,13 @@ function createConfig() { internalUrl: env.INTERNAL_DIRECTUS_URL, proxyPath: '/cms', }, + notifications: { + gotify: { + url: env.GOTIFY_URL, + token: env.GOTIFY_TOKEN, + enabled: Boolean(env.GOTIFY_URL && env.GOTIFY_TOKEN), + }, + }, } as const; } @@ -127,6 +134,9 @@ export const config = { get directus() { return getConfig().directus; }, + get notifications() { + return getConfig().notifications; + }, }; /** @@ -171,5 +181,12 @@ export function getMaskedConfig() { password: mask(c.directus.password), token: mask(c.directus.token), }, + notifications: { + gotify: { + url: c.notifications.gotify.url, + token: mask(c.notifications.gotify.token), + enabled: c.notifications.gotify.enabled, + }, + }, }; } diff --git a/lib/env.ts b/lib/env.ts index b90037d9..5f75fed9 100644 --- a/lib/env.ts +++ b/lib/env.ts @@ -49,6 +49,9 @@ export const envSchema = z.object({ // Deploy Target TARGET: z.enum(['development', 'testing', 'staging', 'production']).optional(), + // Gotify + GOTIFY_URL: z.preprocess(preprocessEmptyString, z.string().url().optional()), + GOTIFY_TOKEN: z.preprocess(preprocessEmptyString, z.string().optional()), }); export type Env = z.infer; @@ -78,5 +81,7 @@ export function getRawEnv() { DIRECTUS_API_TOKEN: process.env.DIRECTUS_API_TOKEN, INTERNAL_DIRECTUS_URL: process.env.INTERNAL_DIRECTUS_URL, TARGET: process.env.TARGET, + GOTIFY_URL: process.env.GOTIFY_URL, + GOTIFY_TOKEN: process.env.GOTIFY_TOKEN, }; } diff --git a/lib/services/app-services.ts b/lib/services/app-services.ts index 157105e0..9bda7d13 100644 --- a/lib/services/app-services.ts +++ b/lib/services/app-services.ts @@ -2,6 +2,7 @@ import type { AnalyticsService } from './analytics/analytics-service'; import type { CacheService } from './cache/cache-service'; import type { ErrorReportingService } from './errors/error-reporting-service'; import type { LoggerService } from './logging/logger-service'; +import type { NotificationService } from './notifications/notification-service'; // Simple constructor-based DI container. export class AppServices { @@ -9,6 +10,7 @@ export class AppServices { public readonly analytics: AnalyticsService, public readonly errors: ErrorReportingService, public readonly cache: CacheService, - public readonly logger: LoggerService + public readonly logger: LoggerService, + public readonly notifications: NotificationService, ) {} } diff --git a/lib/services/create-services.server.ts b/lib/services/create-services.server.ts index 2a5a881e..c33c7d15 100644 --- a/lib/services/create-services.server.ts +++ b/lib/services/create-services.server.ts @@ -4,6 +4,10 @@ import { UmamiAnalyticsService } from './analytics/umami-analytics-service'; import { MemoryCacheService } from './cache/memory-cache-service'; import { GlitchtipErrorReportingService } from './errors/glitchtip-error-reporting-service'; import { NoopErrorReportingService } from './errors/noop-error-reporting-service'; +import { + GotifyNotificationService, + NoopNotificationService, +} from './notifications/gotify-notification-service'; import { PinoLoggerService } from './logging/pino-logger-service'; import { config, getMaskedConfig } from '../config'; @@ -23,6 +27,7 @@ export function getServerAppServices(): AppServices { umamiEnabled: config.analytics.umami.enabled, sentryEnabled: config.errors.glitchtip.enabled, mailEnabled: Boolean(config.mail.host && config.mail.user), + gotifyEnabled: config.notifications.gotify.enabled, }); const analytics = config.analytics.umami.enabled @@ -35,8 +40,22 @@ export function getServerAppServices(): AppServices { logger.info('Noop analytics service initialized (analytics disabled)'); } + const notifications = config.notifications.gotify.enabled + ? new GotifyNotificationService({ + url: config.notifications.gotify.url!, + token: config.notifications.gotify.token!, + enabled: true, + }) + : new NoopNotificationService(); + + if (config.notifications.gotify.enabled) { + logger.info('Gotify notification service initialized'); + } else { + logger.info('Noop notification service initialized (notifications disabled)'); + } + const errors = config.errors.glitchtip.enabled - ? new GlitchtipErrorReportingService({ enabled: true }) + ? new GlitchtipErrorReportingService({ enabled: true }, notifications) : new NoopErrorReportingService(); if (config.errors.glitchtip.enabled) { @@ -55,7 +74,7 @@ export function getServerAppServices(): AppServices { level: config.logging.level, }); - singleton = new AppServices(analytics, errors, cache, logger); + singleton = new AppServices(analytics, errors, cache, logger, notifications); logger.info('All application services initialized successfully'); diff --git a/lib/services/create-services.ts b/lib/services/create-services.ts index f3982bfc..31a3b876 100644 --- a/lib/services/create-services.ts +++ b/lib/services/create-services.ts @@ -5,6 +5,7 @@ import { GlitchtipErrorReportingService } from './errors/glitchtip-error-reporti import { NoopErrorReportingService } from './errors/noop-error-reporting-service'; import { NoopLoggerService } from './logging/noop-logger-service'; import { PinoLoggerService } from './logging/pino-logger-service'; +import { NoopNotificationService } from './notifications/gotify-notification-service'; import { config, getMaskedConfig } from '../config'; /** @@ -71,9 +72,7 @@ export function getAppServices(): AppServices { // Create logger first to log initialization const logger = - typeof window === 'undefined' - ? new PinoLoggerService('server') - : new NoopLoggerService(); + typeof window === 'undefined' ? new PinoLoggerService('server') : new NoopLoggerService(); // Log initialization if (typeof window === 'undefined') { @@ -121,7 +120,9 @@ export function getAppServices(): AppServices { : new NoopErrorReportingService(); if (sentryEnabled) { - logger.info(`GlitchTip error reporting service initialized (${typeof window === 'undefined' ? 'server' : 'client'})`); + logger.info( + `GlitchTip error reporting service initialized (${typeof window === 'undefined' ? 'server' : 'client'})`, + ); } else { logger.info('Noop error reporting service initialized (error reporting disabled)'); } @@ -138,9 +139,10 @@ export function getAppServices(): AppServices { }); // Create and cache the singleton - singleton = new AppServices(analytics, errors, cache, logger); - + const notifications = new NoopNotificationService(); + singleton = new AppServices(analytics, errors, cache, logger, notifications); + logger.info('All application services initialized successfully'); - + return singleton; } diff --git a/lib/services/errors/error-reporting-service.ts b/lib/services/errors/error-reporting-service.ts index ddab5d90..7aee26cd 100644 --- a/lib/services/errors/error-reporting-service.ts +++ b/lib/services/errors/error-reporting-service.ts @@ -7,10 +7,15 @@ export type ErrorReportingUser = { export type ErrorReportingLevel = 'fatal' | 'error' | 'warning' | 'info' | 'debug' | 'log'; export interface ErrorReportingService { - captureException(error: unknown, context?: Record): string | undefined; - captureMessage(message: string, level?: ErrorReportingLevel): string | undefined; + captureException( + error: unknown, + context?: Record, + ): Promise | string | undefined; + captureMessage( + message: string, + level?: ErrorReportingLevel, + ): Promise | string | undefined; setUser(user: ErrorReportingUser | null): void; setTag(key: string, value: string): void; withScope(fn: () => T, context?: Record): T; } - diff --git a/lib/services/errors/glitchtip-error-reporting-service.ts b/lib/services/errors/glitchtip-error-reporting-service.ts index 9cbcbee5..31a52fce 100644 --- a/lib/services/errors/glitchtip-error-reporting-service.ts +++ b/lib/services/errors/glitchtip-error-reporting-service.ts @@ -4,6 +4,7 @@ import type { ErrorReportingService, ErrorReportingUser, } from './error-reporting-service'; +import type { NotificationService } from '../notifications/notification-service'; type SentryLike = typeof Sentry; @@ -15,12 +16,29 @@ export type GlitchtipErrorReportingServiceOptions = { export class GlitchtipErrorReportingService implements ErrorReportingService { constructor( private readonly options: GlitchtipErrorReportingServiceOptions, - private readonly sentry: SentryLike = Sentry + private readonly notifications?: NotificationService, + private readonly sentry: SentryLike = Sentry, ) {} - captureException(error: unknown, context?: Record) { + async captureException(error: unknown, context?: Record) { if (!this.options.enabled) return undefined; - return this.sentry.captureException(error, context as any) as any; + const result = this.sentry.captureException(error, context as any) as any; + + // Send to Gotify if it's considered critical or if we just want all exceptions there + // For now, let's send all exceptions to Gotify as requested "notify me via gotify about critical error messages" + // We'll treat all captureException calls as potentially critical or at least noteworthy + if (this.notifications) { + const errorMessage = error instanceof Error ? error.message : String(error); + const contextStr = context ? `\nContext: ${JSON.stringify(context, null, 2)}` : ''; + + await this.notifications.notify({ + title: '🔥 Critical Error Captured', + message: `Error: ${errorMessage}${contextStr}`, + priority: 7, + }); + } + + return result; } captureMessage(message: string, level: ErrorReportingLevel = 'error') { diff --git a/lib/services/errors/noop-error-reporting-service.ts b/lib/services/errors/noop-error-reporting-service.ts index 02a1a330..1d7d7988 100644 --- a/lib/services/errors/noop-error-reporting-service.ts +++ b/lib/services/errors/noop-error-reporting-service.ts @@ -1,11 +1,15 @@ -import type { ErrorReportingLevel, ErrorReportingService, ErrorReportingUser } from './error-reporting-service'; +import type { + ErrorReportingLevel, + ErrorReportingService, + ErrorReportingUser, +} from './error-reporting-service'; export class NoopErrorReportingService implements ErrorReportingService { - captureException(_error: unknown, _context?: Record) { + async captureException(_error: unknown, _context?: Record) { return undefined; } - captureMessage(_message: string, _level?: ErrorReportingLevel) { + async captureMessage(_message: string, _level?: ErrorReportingLevel) { return undefined; } diff --git a/lib/services/notifications/gotify-notification-service.ts b/lib/services/notifications/gotify-notification-service.ts new file mode 100644 index 00000000..d18bfa04 --- /dev/null +++ b/lib/services/notifications/gotify-notification-service.ts @@ -0,0 +1,49 @@ +import { NotificationOptions, NotificationService } from './notification-service'; + +export interface GotifyConfig { + url: string; + token: string; + enabled: boolean; +} + +export class GotifyNotificationService implements NotificationService { + constructor(private config: GotifyConfig) {} + + async notify(options: NotificationOptions): Promise { + if (!this.config.enabled) return; + + try { + const { title, message, priority = 4 } = options; + const url = new URL('message', this.config.url); + url.searchParams.set('token', this.config.token); + + const response = await fetch(url.toString(), { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + title, + message, + priority, + }), + }); + + if (!response.ok) { + const errorText = await response.text(); + console.error('Gotify notification failed:', { + status: response.status, + error: errorText, + }); + } + } catch (error) { + console.error('Gotify notification error:', error); + } + } +} + +export class NoopNotificationService implements NotificationService { + async notify(): Promise { + // Do nothing + } +} diff --git a/lib/services/notifications/notification-service.ts b/lib/services/notifications/notification-service.ts new file mode 100644 index 00000000..ac5a52dc --- /dev/null +++ b/lib/services/notifications/notification-service.ts @@ -0,0 +1,9 @@ +export interface NotificationOptions { + title: string; + message: string; + priority?: number; +} + +export interface NotificationService { + notify(options: NotificationOptions): Promise; +}