@stravigor/core 0.1.0

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.
Files changed (165) hide show
  1. package/README.md +45 -0
  2. package/package.json +83 -0
  3. package/src/auth/access_token.ts +122 -0
  4. package/src/auth/auth.ts +86 -0
  5. package/src/auth/index.ts +7 -0
  6. package/src/auth/middleware/authenticate.ts +64 -0
  7. package/src/auth/middleware/csrf.ts +62 -0
  8. package/src/auth/middleware/guest.ts +46 -0
  9. package/src/broadcast/broadcast_manager.ts +411 -0
  10. package/src/broadcast/client.ts +302 -0
  11. package/src/broadcast/index.ts +58 -0
  12. package/src/cache/cache_manager.ts +56 -0
  13. package/src/cache/cache_store.ts +31 -0
  14. package/src/cache/helpers.ts +74 -0
  15. package/src/cache/http_cache.ts +109 -0
  16. package/src/cache/index.ts +6 -0
  17. package/src/cache/memory_store.ts +63 -0
  18. package/src/cli/bootstrap.ts +37 -0
  19. package/src/cli/commands/generate_api.ts +74 -0
  20. package/src/cli/commands/generate_key.ts +46 -0
  21. package/src/cli/commands/generate_models.ts +48 -0
  22. package/src/cli/commands/migration_compare.ts +152 -0
  23. package/src/cli/commands/migration_fresh.ts +123 -0
  24. package/src/cli/commands/migration_generate.ts +79 -0
  25. package/src/cli/commands/migration_rollback.ts +53 -0
  26. package/src/cli/commands/migration_run.ts +44 -0
  27. package/src/cli/commands/queue_flush.ts +35 -0
  28. package/src/cli/commands/queue_retry.ts +34 -0
  29. package/src/cli/commands/queue_work.ts +40 -0
  30. package/src/cli/commands/scheduler_work.ts +45 -0
  31. package/src/cli/strav.ts +33 -0
  32. package/src/config/configuration.ts +105 -0
  33. package/src/config/loaders/base_loader.ts +69 -0
  34. package/src/config/loaders/env_loader.ts +112 -0
  35. package/src/config/loaders/typescript_loader.ts +56 -0
  36. package/src/config/types.ts +8 -0
  37. package/src/core/application.ts +4 -0
  38. package/src/core/container.ts +117 -0
  39. package/src/core/index.ts +3 -0
  40. package/src/core/inject.ts +39 -0
  41. package/src/database/database.ts +54 -0
  42. package/src/database/index.ts +30 -0
  43. package/src/database/introspector.ts +446 -0
  44. package/src/database/migration/differ.ts +308 -0
  45. package/src/database/migration/file_generator.ts +125 -0
  46. package/src/database/migration/index.ts +18 -0
  47. package/src/database/migration/runner.ts +133 -0
  48. package/src/database/migration/sql_generator.ts +378 -0
  49. package/src/database/migration/tracker.ts +76 -0
  50. package/src/database/migration/types.ts +189 -0
  51. package/src/database/query_builder.ts +474 -0
  52. package/src/encryption/encryption_manager.ts +209 -0
  53. package/src/encryption/helpers.ts +158 -0
  54. package/src/encryption/index.ts +3 -0
  55. package/src/encryption/types.ts +6 -0
  56. package/src/events/emitter.ts +101 -0
  57. package/src/events/index.ts +2 -0
  58. package/src/exceptions/errors.ts +75 -0
  59. package/src/exceptions/exception_handler.ts +126 -0
  60. package/src/exceptions/helpers.ts +25 -0
  61. package/src/exceptions/http_exception.ts +129 -0
  62. package/src/exceptions/index.ts +23 -0
  63. package/src/exceptions/strav_error.ts +11 -0
  64. package/src/generators/api_generator.ts +972 -0
  65. package/src/generators/config.ts +87 -0
  66. package/src/generators/doc_generator.ts +974 -0
  67. package/src/generators/index.ts +11 -0
  68. package/src/generators/model_generator.ts +586 -0
  69. package/src/generators/route_generator.ts +188 -0
  70. package/src/generators/test_generator.ts +1666 -0
  71. package/src/helpers/crypto.ts +4 -0
  72. package/src/helpers/env.ts +50 -0
  73. package/src/helpers/identity.ts +12 -0
  74. package/src/helpers/index.ts +4 -0
  75. package/src/helpers/strings.ts +67 -0
  76. package/src/http/context.ts +215 -0
  77. package/src/http/cookie.ts +59 -0
  78. package/src/http/cors.ts +163 -0
  79. package/src/http/index.ts +16 -0
  80. package/src/http/middleware.ts +39 -0
  81. package/src/http/rate_limit.ts +173 -0
  82. package/src/http/router.ts +556 -0
  83. package/src/http/server.ts +79 -0
  84. package/src/i18n/defaults/en/validation.json +20 -0
  85. package/src/i18n/helpers.ts +72 -0
  86. package/src/i18n/i18n_manager.ts +155 -0
  87. package/src/i18n/index.ts +4 -0
  88. package/src/i18n/middleware.ts +90 -0
  89. package/src/i18n/translator.ts +96 -0
  90. package/src/i18n/types.ts +17 -0
  91. package/src/logger/index.ts +6 -0
  92. package/src/logger/logger.ts +100 -0
  93. package/src/logger/request_logger.ts +19 -0
  94. package/src/logger/sinks/console_sink.ts +24 -0
  95. package/src/logger/sinks/file_sink.ts +24 -0
  96. package/src/logger/sinks/sink.ts +36 -0
  97. package/src/mail/css_inliner.ts +79 -0
  98. package/src/mail/helpers.ts +212 -0
  99. package/src/mail/index.ts +19 -0
  100. package/src/mail/mail_manager.ts +92 -0
  101. package/src/mail/transports/log_transport.ts +69 -0
  102. package/src/mail/transports/resend_transport.ts +59 -0
  103. package/src/mail/transports/sendgrid_transport.ts +77 -0
  104. package/src/mail/transports/smtp_transport.ts +48 -0
  105. package/src/mail/types.ts +80 -0
  106. package/src/notification/base_notification.ts +67 -0
  107. package/src/notification/channels/database_channel.ts +30 -0
  108. package/src/notification/channels/discord_channel.ts +43 -0
  109. package/src/notification/channels/email_channel.ts +37 -0
  110. package/src/notification/channels/webhook_channel.ts +45 -0
  111. package/src/notification/helpers.ts +214 -0
  112. package/src/notification/index.ts +20 -0
  113. package/src/notification/notification_manager.ts +126 -0
  114. package/src/notification/types.ts +122 -0
  115. package/src/orm/base_model.ts +351 -0
  116. package/src/orm/decorators.ts +127 -0
  117. package/src/orm/index.ts +4 -0
  118. package/src/policy/authorize.ts +44 -0
  119. package/src/policy/index.ts +3 -0
  120. package/src/policy/policy_result.ts +13 -0
  121. package/src/queue/index.ts +11 -0
  122. package/src/queue/queue.ts +338 -0
  123. package/src/queue/worker.ts +197 -0
  124. package/src/scheduler/cron.ts +140 -0
  125. package/src/scheduler/index.ts +7 -0
  126. package/src/scheduler/runner.ts +116 -0
  127. package/src/scheduler/schedule.ts +183 -0
  128. package/src/scheduler/scheduler.ts +47 -0
  129. package/src/schema/database_representation.ts +122 -0
  130. package/src/schema/define_association.ts +60 -0
  131. package/src/schema/define_schema.ts +46 -0
  132. package/src/schema/field_builder.ts +155 -0
  133. package/src/schema/field_definition.ts +66 -0
  134. package/src/schema/index.ts +21 -0
  135. package/src/schema/naming.ts +19 -0
  136. package/src/schema/postgres.ts +109 -0
  137. package/src/schema/registry.ts +157 -0
  138. package/src/schema/representation_builder.ts +479 -0
  139. package/src/schema/type_builder.ts +107 -0
  140. package/src/schema/types.ts +35 -0
  141. package/src/session/index.ts +4 -0
  142. package/src/session/middleware.ts +46 -0
  143. package/src/session/session.ts +308 -0
  144. package/src/session/session_manager.ts +81 -0
  145. package/src/storage/index.ts +13 -0
  146. package/src/storage/local_driver.ts +46 -0
  147. package/src/storage/s3_driver.ts +51 -0
  148. package/src/storage/storage.ts +43 -0
  149. package/src/storage/storage_manager.ts +59 -0
  150. package/src/storage/types.ts +42 -0
  151. package/src/storage/upload.ts +91 -0
  152. package/src/validation/index.ts +18 -0
  153. package/src/validation/rules.ts +170 -0
  154. package/src/validation/validate.ts +41 -0
  155. package/src/view/cache.ts +47 -0
  156. package/src/view/client/islands.ts +50 -0
  157. package/src/view/compiler.ts +185 -0
  158. package/src/view/engine.ts +139 -0
  159. package/src/view/escape.ts +14 -0
  160. package/src/view/index.ts +13 -0
  161. package/src/view/islands/island_builder.ts +161 -0
  162. package/src/view/islands/vue_plugin.ts +140 -0
  163. package/src/view/middleware/static.ts +35 -0
  164. package/src/view/tokenizer.ts +172 -0
  165. package/tsconfig.json +4 -0
@@ -0,0 +1,80 @@
1
+ /** Core message structure passed to transports. */
2
+ export interface MailMessage {
3
+ from: string
4
+ to: string | string[]
5
+ cc?: string | string[]
6
+ bcc?: string | string[]
7
+ replyTo?: string
8
+ subject: string
9
+ html?: string
10
+ text?: string
11
+ attachments?: MailAttachment[]
12
+ }
13
+
14
+ export interface MailAttachment {
15
+ filename: string
16
+ content: Buffer | string
17
+ contentType?: string
18
+ /** For CID-referenced inline images. */
19
+ cid?: string
20
+ }
21
+
22
+ export interface MailResult {
23
+ messageId?: string
24
+ accepted?: string[]
25
+ rejected?: string[]
26
+ }
27
+
28
+ /**
29
+ * Pluggable transport backend.
30
+ * Implement this interface for custom mail providers.
31
+ */
32
+ export interface MailTransport {
33
+ send(message: MailMessage): Promise<MailResult>
34
+ }
35
+
36
+ // -- Per-driver configs -------------------------------------------------------
37
+
38
+ export interface SmtpConfig {
39
+ host: string
40
+ port: number
41
+ secure: boolean
42
+ auth?: {
43
+ user: string
44
+ pass: string
45
+ }
46
+ }
47
+
48
+ export interface ResendConfig {
49
+ apiKey: string
50
+ baseUrl?: string
51
+ }
52
+
53
+ export interface SendGridConfig {
54
+ apiKey: string
55
+ baseUrl?: string
56
+ }
57
+
58
+ export interface LogConfig {
59
+ /** Write to 'console' or a file path. */
60
+ output: 'console' | string
61
+ }
62
+
63
+ // -- Top-level mail config ----------------------------------------------------
64
+
65
+ export interface MailConfig {
66
+ /** Default transport name: 'smtp' | 'resend' | 'sendgrid' | 'log' */
67
+ default: string
68
+ /** Default "from" address. */
69
+ from: string
70
+ /** Template prefix for ViewEngine (default: 'emails'). */
71
+ templatePrefix: string
72
+ /** Enable CSS inlining via juice (default: true). */
73
+ inlineCss: boolean
74
+ /** Enable Tailwind CSS compilation before inlining (default: false). */
75
+ tailwind: boolean
76
+ smtp: SmtpConfig
77
+ resend: ResendConfig
78
+ sendgrid: SendGridConfig
79
+ log: LogConfig
80
+ }
@@ -0,0 +1,67 @@
1
+ import type {
2
+ Notifiable,
3
+ MailEnvelope,
4
+ DatabaseEnvelope,
5
+ WebhookEnvelope,
6
+ DiscordEnvelope,
7
+ NotificationPayload,
8
+ } from './types.ts'
9
+
10
+ /**
11
+ * Base class for all notifications.
12
+ *
13
+ * Extend this class and implement `via()` plus at least one `toXxx()` method
14
+ * to define how the notification should be delivered on each channel.
15
+ *
16
+ * @example
17
+ * class TaskAssignedNotification extends BaseNotification {
18
+ * constructor(private task: Task, private assigner: User) { super() }
19
+ *
20
+ * via() { return ['email', 'database'] }
21
+ *
22
+ * toEmail(notifiable: Notifiable): MailEnvelope {
23
+ * return { subject: `Assigned: ${this.task.title}`, template: 'task-assigned', templateData: { ... } }
24
+ * }
25
+ *
26
+ * toDatabase(): DatabaseEnvelope {
27
+ * return { type: 'task.assigned', data: { taskId: this.task.id } }
28
+ * }
29
+ *
30
+ * shouldQueue() { return true }
31
+ * }
32
+ */
33
+ export abstract class BaseNotification {
34
+ /** Which channels this notification should be sent on. */
35
+ abstract via(notifiable: Notifiable): string[]
36
+
37
+ /** Build the email envelope. */
38
+ toEmail?(notifiable: Notifiable): MailEnvelope
39
+ /** Build the database (in-app) envelope. */
40
+ toDatabase?(notifiable: Notifiable): DatabaseEnvelope
41
+ /** Build the webhook envelope. */
42
+ toWebhook?(notifiable: Notifiable): WebhookEnvelope
43
+ /** Build the Discord envelope. */
44
+ toDiscord?(notifiable: Notifiable): DiscordEnvelope
45
+
46
+ /** Whether this notification should be queued for async delivery. */
47
+ shouldQueue(): boolean {
48
+ return false
49
+ }
50
+
51
+ /** Queue options (queue name, delay in ms, max attempts). */
52
+ queueOptions(): { queue?: string; delay?: number; attempts?: number } {
53
+ return {}
54
+ }
55
+
56
+ /** Build a serializable payload containing all channel envelopes. */
57
+ buildPayload(notifiable: Notifiable): NotificationPayload {
58
+ return {
59
+ notificationClass: this.constructor.name,
60
+ channels: this.via(notifiable),
61
+ mail: this.toEmail?.(notifiable),
62
+ database: this.toDatabase?.(notifiable),
63
+ webhook: this.toWebhook?.(notifiable),
64
+ discord: this.toDiscord?.(notifiable),
65
+ }
66
+ }
67
+ }
@@ -0,0 +1,30 @@
1
+ import type { NotificationChannel, Notifiable, NotificationPayload } from '../types.ts'
2
+ import Database from '../../database/database.ts'
3
+
4
+ /**
5
+ * Stores notifications in the `_strav_notifications` table.
6
+ *
7
+ * The database channel enables in-app notification features
8
+ * (unread badges, notification feeds, etc.).
9
+ */
10
+ export class DatabaseChannel implements NotificationChannel {
11
+ readonly name = 'database'
12
+
13
+ async send(notifiable: Notifiable, payload: NotificationPayload): Promise<void> {
14
+ const envelope = payload.database
15
+ if (!envelope) return
16
+
17
+ const sql = Database.raw
18
+
19
+ await sql`
20
+ INSERT INTO "_strav_notifications"
21
+ ("notifiable_type", "notifiable_id", "type", "data")
22
+ VALUES (
23
+ ${notifiable.notifiableType()},
24
+ ${String(notifiable.notifiableId())},
25
+ ${envelope.type},
26
+ ${JSON.stringify(envelope.data)}
27
+ )
28
+ `
29
+ }
30
+ }
@@ -0,0 +1,43 @@
1
+ import { ExternalServiceError } from '../../exceptions/errors.ts'
2
+ import type { NotificationChannel, Notifiable, NotificationPayload, NotificationConfig } from '../types.ts'
3
+
4
+ /**
5
+ * Delivers notifications via Discord webhook.
6
+ *
7
+ * URL resolution order:
8
+ * 1. `DiscordEnvelope.url` (per-notification override)
9
+ * 2. `notifiable.routeNotificationForDiscord()` (per-recipient)
10
+ * 3. Config discord `default` entry
11
+ */
12
+ export class DiscordChannel implements NotificationChannel {
13
+ readonly name = 'discord'
14
+
15
+ constructor(private config: NotificationConfig) {}
16
+
17
+ async send(notifiable: Notifiable, payload: NotificationPayload): Promise<void> {
18
+ const envelope = payload.discord
19
+ if (!envelope) return
20
+
21
+ const url =
22
+ envelope.url ??
23
+ notifiable.routeNotificationForDiscord?.() ??
24
+ this.config.discord?.default ??
25
+ null
26
+
27
+ if (!url) return
28
+
29
+ const body: Record<string, unknown> = {}
30
+ if (envelope.content) body.content = envelope.content
31
+ if (envelope.embeds?.length) body.embeds = envelope.embeds
32
+
33
+ const response = await fetch(url, {
34
+ method: 'POST',
35
+ headers: { 'Content-Type': 'application/json' },
36
+ body: JSON.stringify(body),
37
+ })
38
+
39
+ if (!response.ok) {
40
+ throw new ExternalServiceError('Discord', response.status, await response.text())
41
+ }
42
+ }
43
+ }
@@ -0,0 +1,37 @@
1
+ import type { NotificationChannel, Notifiable, NotificationPayload } from '../types.ts'
2
+ import { PendingMail } from '../../mail/helpers.ts'
3
+
4
+ /**
5
+ * Delivers notifications via the existing Mail module.
6
+ *
7
+ * Reads the email address from `notifiable.routeNotificationForEmail()`
8
+ * and builds a {@link PendingMail} from the {@link MailEnvelope}.
9
+ */
10
+ export class EmailChannel implements NotificationChannel {
11
+ readonly name = 'email'
12
+
13
+ async send(notifiable: Notifiable, payload: NotificationPayload): Promise<void> {
14
+ const envelope = payload.mail
15
+ if (!envelope) return
16
+
17
+ const address = notifiable.routeNotificationForEmail?.()
18
+ if (!address) return
19
+
20
+ const pending = new PendingMail(address).subject(envelope.subject)
21
+
22
+ if (envelope.from) pending.from(envelope.from)
23
+ if (envelope.cc) pending.cc(envelope.cc)
24
+ if (envelope.bcc) pending.bcc(envelope.bcc)
25
+ if (envelope.replyTo) pending.replyTo(envelope.replyTo)
26
+
27
+ if (envelope.template) {
28
+ pending.template(envelope.template, envelope.templateData ?? {})
29
+ } else if (envelope.html) {
30
+ pending.html(envelope.html)
31
+ }
32
+
33
+ if (envelope.text) pending.text(envelope.text)
34
+
35
+ await pending.send()
36
+ }
37
+ }
@@ -0,0 +1,45 @@
1
+ import { ExternalServiceError } from '../../exceptions/errors.ts'
2
+ import type { NotificationChannel, Notifiable, NotificationPayload, NotificationConfig } from '../types.ts'
3
+
4
+ /**
5
+ * Delivers notifications via HTTP POST to a webhook URL.
6
+ *
7
+ * URL resolution order:
8
+ * 1. `WebhookEnvelope.url` (per-notification override)
9
+ * 2. `notifiable.routeNotificationForWebhook()` (per-recipient)
10
+ * 3. Config webhooks `default` entry
11
+ */
12
+ export class WebhookChannel implements NotificationChannel {
13
+ readonly name = 'webhook'
14
+
15
+ constructor(private config: NotificationConfig) {}
16
+
17
+ async send(notifiable: Notifiable, payload: NotificationPayload): Promise<void> {
18
+ const envelope = payload.webhook
19
+ if (!envelope) return
20
+
21
+ const url =
22
+ envelope.url ??
23
+ notifiable.routeNotificationForWebhook?.() ??
24
+ this.config.webhooks?.default?.url ??
25
+ null
26
+
27
+ if (!url) return
28
+
29
+ const headers: Record<string, string> = {
30
+ 'Content-Type': 'application/json',
31
+ ...(this.config.webhooks?.default?.headers),
32
+ ...(envelope.headers),
33
+ }
34
+
35
+ const response = await fetch(url, {
36
+ method: 'POST',
37
+ headers,
38
+ body: JSON.stringify(envelope.payload),
39
+ })
40
+
41
+ if (!response.ok) {
42
+ throw new ExternalServiceError('Webhook', response.status, await response.text())
43
+ }
44
+ }
45
+ }
@@ -0,0 +1,214 @@
1
+ import NotificationManager from './notification_manager.ts'
2
+ import type { BaseNotification } from './base_notification.ts'
3
+ import type { Notifiable, NotificationRecord } from './types.ts'
4
+ import Queue from '../queue/queue.ts'
5
+ import Emitter from '../events/emitter.ts'
6
+ import Database from '../database/database.ts'
7
+
8
+ /**
9
+ * Send a notification to one or more recipients.
10
+ *
11
+ * @example
12
+ * import { notify } from '@stravigor/core/notification'
13
+ *
14
+ * await notify(user, new TaskAssignedNotification(task, assigner))
15
+ * await notify([user1, user2], new InvoicePaidNotification(invoice))
16
+ */
17
+ export async function notify(
18
+ notifiable: Notifiable | Notifiable[],
19
+ notification: BaseNotification,
20
+ ): Promise<void> {
21
+ const recipients = Array.isArray(notifiable) ? notifiable : [notifiable]
22
+
23
+ for (const recipient of recipients) {
24
+ if (notification.shouldQueue()) {
25
+ const payload = notification.buildPayload(recipient)
26
+
27
+ // Pre-resolve routing info so channels can deliver from the queue worker
28
+ const routing: Record<string, string> = {}
29
+ const email = recipient.routeNotificationForEmail?.()
30
+ if (email) routing.email = email
31
+ const webhook = recipient.routeNotificationForWebhook?.()
32
+ if (webhook) routing.webhook = webhook
33
+ const discord = recipient.routeNotificationForDiscord?.()
34
+ if (discord) routing.discord = discord
35
+
36
+ await Queue.push(
37
+ 'strav:send-notification',
38
+ {
39
+ notifiable: { id: recipient.notifiableId(), type: recipient.notifiableType() },
40
+ routing,
41
+ payload,
42
+ },
43
+ {
44
+ queue: notification.queueOptions().queue ?? NotificationManager.config.queue,
45
+ delay: notification.queueOptions().delay,
46
+ attempts: notification.queueOptions().attempts,
47
+ },
48
+ )
49
+ } else {
50
+ await sendNow(recipient, notification)
51
+ }
52
+ }
53
+ }
54
+
55
+ /** Send a notification immediately (bypasses queue). */
56
+ async function sendNow(notifiable: Notifiable, notification: BaseNotification): Promise<void> {
57
+ const payload = notification.buildPayload(notifiable)
58
+
59
+ for (const channelName of payload.channels) {
60
+ const channel = NotificationManager.channel(channelName)
61
+ await channel.send(notifiable, payload)
62
+ }
63
+ }
64
+
65
+ // ---------------------------------------------------------------------------
66
+ // In-app notification query helpers
67
+ // ---------------------------------------------------------------------------
68
+
69
+ /**
70
+ * Notification query helper — convenience API for in-app notification reads.
71
+ *
72
+ * @example
73
+ * import { notifications } from '@stravigor/core/notification'
74
+ *
75
+ * const unread = await notifications.unread('user', userId)
76
+ * await notifications.markAsRead(notificationId)
77
+ * await notifications.markAllAsRead('user', userId)
78
+ */
79
+ export const notifications = {
80
+ /** Get all notifications for a notifiable, newest first. */
81
+ async all(type: string, id: string | number, limit = 50): Promise<NotificationRecord[]> {
82
+ const sql = Database.raw
83
+ const rows = await sql`
84
+ SELECT * FROM "_strav_notifications"
85
+ WHERE "notifiable_type" = ${type} AND "notifiable_id" = ${String(id)}
86
+ ORDER BY "created_at" DESC
87
+ LIMIT ${limit}
88
+ `
89
+ return rows.map(hydrateNotification)
90
+ },
91
+
92
+ /** Get unread notifications for a notifiable. */
93
+ async unread(type: string, id: string | number, limit = 50): Promise<NotificationRecord[]> {
94
+ const sql = Database.raw
95
+ const rows = await sql`
96
+ SELECT * FROM "_strav_notifications"
97
+ WHERE "notifiable_type" = ${type}
98
+ AND "notifiable_id" = ${String(id)}
99
+ AND "read_at" IS NULL
100
+ ORDER BY "created_at" DESC
101
+ LIMIT ${limit}
102
+ `
103
+ return rows.map(hydrateNotification)
104
+ },
105
+
106
+ /** Count unread notifications. */
107
+ async unreadCount(type: string, id: string | number): Promise<number> {
108
+ const sql = Database.raw
109
+ const rows = await sql`
110
+ SELECT COUNT(*)::int AS count FROM "_strav_notifications"
111
+ WHERE "notifiable_type" = ${type}
112
+ AND "notifiable_id" = ${String(id)}
113
+ AND "read_at" IS NULL
114
+ `
115
+ return (rows[0] as Record<string, unknown>).count as number
116
+ },
117
+
118
+ /** Mark a single notification as read. */
119
+ async markAsRead(id: string): Promise<void> {
120
+ const sql = Database.raw
121
+ await sql`
122
+ UPDATE "_strav_notifications"
123
+ SET "read_at" = NOW()
124
+ WHERE "id" = ${id} AND "read_at" IS NULL
125
+ `
126
+ },
127
+
128
+ /** Mark all notifications as read for a notifiable. */
129
+ async markAllAsRead(type: string, id: string | number): Promise<void> {
130
+ const sql = Database.raw
131
+ await sql`
132
+ UPDATE "_strav_notifications"
133
+ SET "read_at" = NOW()
134
+ WHERE "notifiable_type" = ${type}
135
+ AND "notifiable_id" = ${String(id)}
136
+ AND "read_at" IS NULL
137
+ `
138
+ },
139
+
140
+ /** Delete a single notification. */
141
+ async delete(id: string): Promise<void> {
142
+ const sql = Database.raw
143
+ await sql`DELETE FROM "_strav_notifications" WHERE "id" = ${id}`
144
+ },
145
+
146
+ /** Delete all notifications for a notifiable. */
147
+ async deleteAll(type: string, id: string | number): Promise<void> {
148
+ const sql = Database.raw
149
+ await sql`
150
+ DELETE FROM "_strav_notifications"
151
+ WHERE "notifiable_type" = ${type} AND "notifiable_id" = ${String(id)}
152
+ `
153
+ },
154
+
155
+ /**
156
+ * Register the built-in queue handler for async notification delivery.
157
+ * Call this in your app bootstrap after Queue and NotificationManager are configured.
158
+ */
159
+ registerQueueHandler(): void {
160
+ Queue.handle('strav:send-notification', async (job: any) => {
161
+ const { notifiable: ref, routing, payload } = job
162
+
163
+ // Reconstruct a minimal Notifiable proxy with pre-resolved routing
164
+ const notifiable: Notifiable = {
165
+ notifiableId: () => ref.id,
166
+ notifiableType: () => ref.type,
167
+ routeNotificationForEmail: () => routing?.email ?? null,
168
+ routeNotificationForWebhook: () => routing?.webhook ?? null,
169
+ routeNotificationForDiscord: () => routing?.discord ?? null,
170
+ }
171
+
172
+ for (const channelName of payload.channels) {
173
+ const channel = NotificationManager.channel(channelName)
174
+ await channel.send(notifiable, payload)
175
+ }
176
+ })
177
+ },
178
+
179
+ /**
180
+ * Wire event-to-notification mappings to the Emitter.
181
+ * Call this once during bootstrap after registering all bindings via
182
+ * {@link NotificationManager.on}.
183
+ */
184
+ wireEvents(): void {
185
+ for (const [event, bindings] of NotificationManager.eventBindings()) {
186
+ Emitter.on(event, async (eventPayload: any) => {
187
+ for (const binding of bindings) {
188
+ const notification = binding.create(eventPayload)
189
+ const recipients = await binding.recipients(eventPayload)
190
+ await notify(
191
+ Array.isArray(recipients) ? recipients : [recipients],
192
+ notification,
193
+ )
194
+ }
195
+ })
196
+ }
197
+ },
198
+ }
199
+
200
+ // ---------------------------------------------------------------------------
201
+ // Hydration
202
+ // ---------------------------------------------------------------------------
203
+
204
+ function hydrateNotification(row: Record<string, unknown>): NotificationRecord {
205
+ return {
206
+ id: row.id as string,
207
+ notifiableType: row.notifiable_type as string,
208
+ notifiableId: row.notifiable_id as string,
209
+ type: row.type as string,
210
+ data: (typeof row.data === 'string' ? JSON.parse(row.data) : row.data) as Record<string, unknown>,
211
+ readAt: (row.read_at as Date) ?? null,
212
+ createdAt: row.created_at as Date,
213
+ }
214
+ }
@@ -0,0 +1,20 @@
1
+ export { default, default as NotificationManager } from './notification_manager.ts'
2
+ export { BaseNotification } from './base_notification.ts'
3
+ export { notify, notifications } from './helpers.ts'
4
+ export { EmailChannel } from './channels/email_channel.ts'
5
+ export { DatabaseChannel } from './channels/database_channel.ts'
6
+ export { WebhookChannel } from './channels/webhook_channel.ts'
7
+ export { DiscordChannel } from './channels/discord_channel.ts'
8
+ export type {
9
+ NotificationChannel,
10
+ Notifiable,
11
+ NotificationPayload,
12
+ NotificationRecord,
13
+ NotificationConfig,
14
+ MailEnvelope,
15
+ DatabaseEnvelope,
16
+ WebhookEnvelope,
17
+ DiscordEnvelope,
18
+ DiscordEmbed,
19
+ EventNotificationBinding,
20
+ } from './types.ts'
@@ -0,0 +1,126 @@
1
+ import { inject } from '../core/inject.ts'
2
+ import Configuration from '../config/configuration.ts'
3
+ import Database from '../database/database.ts'
4
+ import type { NotificationChannel, NotificationConfig, EventNotificationBinding } from './types.ts'
5
+ import { EmailChannel } from './channels/email_channel.ts'
6
+ import { DatabaseChannel } from './channels/database_channel.ts'
7
+ import { WebhookChannel } from './channels/webhook_channel.ts'
8
+ import { DiscordChannel } from './channels/discord_channel.ts'
9
+ import { ConfigurationError } from '../exceptions/errors.ts'
10
+
11
+ /**
12
+ * Central notification configuration hub.
13
+ *
14
+ * Resolved once via the DI container — reads the notification config,
15
+ * registers built-in channels, and provides an event-to-notification registry.
16
+ *
17
+ * @example
18
+ * app.singleton(NotificationManager)
19
+ * app.resolve(NotificationManager)
20
+ * await NotificationManager.ensureTable()
21
+ *
22
+ * // Register a custom channel
23
+ * NotificationManager.useChannel(new SlackChannel())
24
+ */
25
+ @inject
26
+ export default class NotificationManager {
27
+ private static _db: Database
28
+ private static _config: NotificationConfig
29
+ private static _channels = new Map<string, NotificationChannel>()
30
+ private static _eventMap = new Map<string, EventNotificationBinding[]>()
31
+
32
+ constructor(db: Database, config: Configuration) {
33
+ NotificationManager._db = db
34
+ NotificationManager._config = {
35
+ channels: (config.get('notification.channels', ['database']) as string[]),
36
+ queue: config.get('notification.queue', 'default') as string,
37
+ webhooks: (config.get('notification.webhooks', {}) as Record<string, { url: string; headers?: Record<string, string> }>),
38
+ discord: (config.get('notification.discord', {}) as Record<string, string>),
39
+ }
40
+
41
+ // Register built-in channels
42
+ NotificationManager._channels.set('email', new EmailChannel())
43
+ NotificationManager._channels.set('database', new DatabaseChannel())
44
+ NotificationManager._channels.set('webhook', new WebhookChannel(NotificationManager._config))
45
+ NotificationManager._channels.set('discord', new DiscordChannel(NotificationManager._config))
46
+ }
47
+
48
+ static get db(): Database {
49
+ if (!NotificationManager._db) {
50
+ throw new Error(
51
+ 'NotificationManager not configured. Resolve it through the container first.'
52
+ )
53
+ }
54
+ return NotificationManager._db
55
+ }
56
+
57
+ static get config(): NotificationConfig {
58
+ return NotificationManager._config
59
+ }
60
+
61
+ /** Get a registered channel by name. */
62
+ static channel(name: string): NotificationChannel {
63
+ const ch = NotificationManager._channels.get(name)
64
+ if (!ch) throw new ConfigurationError(`Unknown notification channel: ${name}`)
65
+ return ch
66
+ }
67
+
68
+ /** Register a custom notification channel (or replace a built-in one). */
69
+ static useChannel(channel: NotificationChannel): void {
70
+ NotificationManager._channels.set(channel.name, channel)
71
+ }
72
+
73
+ /** Create the _strav_notifications table if it doesn't exist. */
74
+ static async ensureTable(): Promise<void> {
75
+ const sql = NotificationManager.db.sql
76
+
77
+ await sql`
78
+ CREATE TABLE IF NOT EXISTS "_strav_notifications" (
79
+ "id" UUID PRIMARY KEY DEFAULT gen_random_uuid(),
80
+ "notifiable_type" VARCHAR(255) NOT NULL,
81
+ "notifiable_id" VARCHAR(255) NOT NULL,
82
+ "type" VARCHAR(255) NOT NULL,
83
+ "data" JSONB NOT NULL DEFAULT '{}',
84
+ "read_at" TIMESTAMPTZ,
85
+ "created_at" TIMESTAMPTZ NOT NULL DEFAULT NOW()
86
+ )
87
+ `
88
+
89
+ await sql`
90
+ CREATE INDEX IF NOT EXISTS "idx_strav_notifications_notifiable"
91
+ ON "_strav_notifications" ("notifiable_type", "notifiable_id", "created_at" DESC)
92
+ `
93
+
94
+ await sql`
95
+ CREATE INDEX IF NOT EXISTS "idx_strav_notifications_unread"
96
+ ON "_strav_notifications" ("notifiable_type", "notifiable_id")
97
+ WHERE "read_at" IS NULL
98
+ `
99
+ }
100
+
101
+ /**
102
+ * Register an event-to-notification mapping.
103
+ *
104
+ * @example
105
+ * NotificationManager.on('task.assigned', {
106
+ * create: (payload) => new TaskAssignedNotification(payload.task, payload.assigner),
107
+ * recipients: (payload) => payload.assignee,
108
+ * })
109
+ */
110
+ static on(event: string, binding: EventNotificationBinding): void {
111
+ const existing = NotificationManager._eventMap.get(event) ?? []
112
+ existing.push(binding)
113
+ NotificationManager._eventMap.set(event, existing)
114
+ }
115
+
116
+ /** Expose the event map (used by wireEvents). */
117
+ static eventBindings(): Map<string, EventNotificationBinding[]> {
118
+ return NotificationManager._eventMap
119
+ }
120
+
121
+ /** Clear all state. For testing only. */
122
+ static reset(): void {
123
+ NotificationManager._channels.clear()
124
+ NotificationManager._eventMap.clear()
125
+ }
126
+ }