@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.
- package/README.md +45 -0
- package/package.json +83 -0
- package/src/auth/access_token.ts +122 -0
- package/src/auth/auth.ts +86 -0
- package/src/auth/index.ts +7 -0
- package/src/auth/middleware/authenticate.ts +64 -0
- package/src/auth/middleware/csrf.ts +62 -0
- package/src/auth/middleware/guest.ts +46 -0
- package/src/broadcast/broadcast_manager.ts +411 -0
- package/src/broadcast/client.ts +302 -0
- package/src/broadcast/index.ts +58 -0
- package/src/cache/cache_manager.ts +56 -0
- package/src/cache/cache_store.ts +31 -0
- package/src/cache/helpers.ts +74 -0
- package/src/cache/http_cache.ts +109 -0
- package/src/cache/index.ts +6 -0
- package/src/cache/memory_store.ts +63 -0
- package/src/cli/bootstrap.ts +37 -0
- package/src/cli/commands/generate_api.ts +74 -0
- package/src/cli/commands/generate_key.ts +46 -0
- package/src/cli/commands/generate_models.ts +48 -0
- package/src/cli/commands/migration_compare.ts +152 -0
- package/src/cli/commands/migration_fresh.ts +123 -0
- package/src/cli/commands/migration_generate.ts +79 -0
- package/src/cli/commands/migration_rollback.ts +53 -0
- package/src/cli/commands/migration_run.ts +44 -0
- package/src/cli/commands/queue_flush.ts +35 -0
- package/src/cli/commands/queue_retry.ts +34 -0
- package/src/cli/commands/queue_work.ts +40 -0
- package/src/cli/commands/scheduler_work.ts +45 -0
- package/src/cli/strav.ts +33 -0
- package/src/config/configuration.ts +105 -0
- package/src/config/loaders/base_loader.ts +69 -0
- package/src/config/loaders/env_loader.ts +112 -0
- package/src/config/loaders/typescript_loader.ts +56 -0
- package/src/config/types.ts +8 -0
- package/src/core/application.ts +4 -0
- package/src/core/container.ts +117 -0
- package/src/core/index.ts +3 -0
- package/src/core/inject.ts +39 -0
- package/src/database/database.ts +54 -0
- package/src/database/index.ts +30 -0
- package/src/database/introspector.ts +446 -0
- package/src/database/migration/differ.ts +308 -0
- package/src/database/migration/file_generator.ts +125 -0
- package/src/database/migration/index.ts +18 -0
- package/src/database/migration/runner.ts +133 -0
- package/src/database/migration/sql_generator.ts +378 -0
- package/src/database/migration/tracker.ts +76 -0
- package/src/database/migration/types.ts +189 -0
- package/src/database/query_builder.ts +474 -0
- package/src/encryption/encryption_manager.ts +209 -0
- package/src/encryption/helpers.ts +158 -0
- package/src/encryption/index.ts +3 -0
- package/src/encryption/types.ts +6 -0
- package/src/events/emitter.ts +101 -0
- package/src/events/index.ts +2 -0
- package/src/exceptions/errors.ts +75 -0
- package/src/exceptions/exception_handler.ts +126 -0
- package/src/exceptions/helpers.ts +25 -0
- package/src/exceptions/http_exception.ts +129 -0
- package/src/exceptions/index.ts +23 -0
- package/src/exceptions/strav_error.ts +11 -0
- package/src/generators/api_generator.ts +972 -0
- package/src/generators/config.ts +87 -0
- package/src/generators/doc_generator.ts +974 -0
- package/src/generators/index.ts +11 -0
- package/src/generators/model_generator.ts +586 -0
- package/src/generators/route_generator.ts +188 -0
- package/src/generators/test_generator.ts +1666 -0
- package/src/helpers/crypto.ts +4 -0
- package/src/helpers/env.ts +50 -0
- package/src/helpers/identity.ts +12 -0
- package/src/helpers/index.ts +4 -0
- package/src/helpers/strings.ts +67 -0
- package/src/http/context.ts +215 -0
- package/src/http/cookie.ts +59 -0
- package/src/http/cors.ts +163 -0
- package/src/http/index.ts +16 -0
- package/src/http/middleware.ts +39 -0
- package/src/http/rate_limit.ts +173 -0
- package/src/http/router.ts +556 -0
- package/src/http/server.ts +79 -0
- package/src/i18n/defaults/en/validation.json +20 -0
- package/src/i18n/helpers.ts +72 -0
- package/src/i18n/i18n_manager.ts +155 -0
- package/src/i18n/index.ts +4 -0
- package/src/i18n/middleware.ts +90 -0
- package/src/i18n/translator.ts +96 -0
- package/src/i18n/types.ts +17 -0
- package/src/logger/index.ts +6 -0
- package/src/logger/logger.ts +100 -0
- package/src/logger/request_logger.ts +19 -0
- package/src/logger/sinks/console_sink.ts +24 -0
- package/src/logger/sinks/file_sink.ts +24 -0
- package/src/logger/sinks/sink.ts +36 -0
- package/src/mail/css_inliner.ts +79 -0
- package/src/mail/helpers.ts +212 -0
- package/src/mail/index.ts +19 -0
- package/src/mail/mail_manager.ts +92 -0
- package/src/mail/transports/log_transport.ts +69 -0
- package/src/mail/transports/resend_transport.ts +59 -0
- package/src/mail/transports/sendgrid_transport.ts +77 -0
- package/src/mail/transports/smtp_transport.ts +48 -0
- package/src/mail/types.ts +80 -0
- package/src/notification/base_notification.ts +67 -0
- package/src/notification/channels/database_channel.ts +30 -0
- package/src/notification/channels/discord_channel.ts +43 -0
- package/src/notification/channels/email_channel.ts +37 -0
- package/src/notification/channels/webhook_channel.ts +45 -0
- package/src/notification/helpers.ts +214 -0
- package/src/notification/index.ts +20 -0
- package/src/notification/notification_manager.ts +126 -0
- package/src/notification/types.ts +122 -0
- package/src/orm/base_model.ts +351 -0
- package/src/orm/decorators.ts +127 -0
- package/src/orm/index.ts +4 -0
- package/src/policy/authorize.ts +44 -0
- package/src/policy/index.ts +3 -0
- package/src/policy/policy_result.ts +13 -0
- package/src/queue/index.ts +11 -0
- package/src/queue/queue.ts +338 -0
- package/src/queue/worker.ts +197 -0
- package/src/scheduler/cron.ts +140 -0
- package/src/scheduler/index.ts +7 -0
- package/src/scheduler/runner.ts +116 -0
- package/src/scheduler/schedule.ts +183 -0
- package/src/scheduler/scheduler.ts +47 -0
- package/src/schema/database_representation.ts +122 -0
- package/src/schema/define_association.ts +60 -0
- package/src/schema/define_schema.ts +46 -0
- package/src/schema/field_builder.ts +155 -0
- package/src/schema/field_definition.ts +66 -0
- package/src/schema/index.ts +21 -0
- package/src/schema/naming.ts +19 -0
- package/src/schema/postgres.ts +109 -0
- package/src/schema/registry.ts +157 -0
- package/src/schema/representation_builder.ts +479 -0
- package/src/schema/type_builder.ts +107 -0
- package/src/schema/types.ts +35 -0
- package/src/session/index.ts +4 -0
- package/src/session/middleware.ts +46 -0
- package/src/session/session.ts +308 -0
- package/src/session/session_manager.ts +81 -0
- package/src/storage/index.ts +13 -0
- package/src/storage/local_driver.ts +46 -0
- package/src/storage/s3_driver.ts +51 -0
- package/src/storage/storage.ts +43 -0
- package/src/storage/storage_manager.ts +59 -0
- package/src/storage/types.ts +42 -0
- package/src/storage/upload.ts +91 -0
- package/src/validation/index.ts +18 -0
- package/src/validation/rules.ts +170 -0
- package/src/validation/validate.ts +41 -0
- package/src/view/cache.ts +47 -0
- package/src/view/client/islands.ts +50 -0
- package/src/view/compiler.ts +185 -0
- package/src/view/engine.ts +139 -0
- package/src/view/escape.ts +14 -0
- package/src/view/index.ts +13 -0
- package/src/view/islands/island_builder.ts +161 -0
- package/src/view/islands/vue_plugin.ts +140 -0
- package/src/view/middleware/static.ts +35 -0
- package/src/view/tokenizer.ts +172 -0
- 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
|
+
}
|