adonia 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 +64 -0
- package/build/adonis_peers.d.ts +14 -0
- package/build/adonis_peers.js +56 -0
- package/build/cli.d.ts +2 -0
- package/build/cli.js +111 -0
- package/build/commands/add.d.ts +18 -0
- package/build/commands/add.js +246 -0
- package/build/commands/check.d.ts +4 -0
- package/build/commands/check.js +159 -0
- package/build/commands/diff.d.ts +2 -0
- package/build/commands/diff.js +74 -0
- package/build/commands/init.d.ts +6 -0
- package/build/commands/init.js +124 -0
- package/build/commands/list.d.ts +2 -0
- package/build/commands/list.js +34 -0
- package/build/config_merge.d.ts +10 -0
- package/build/config_merge.js +71 -0
- package/build/constants_merge.d.ts +4 -0
- package/build/constants_merge.js +85 -0
- package/build/copy.d.ts +8 -0
- package/build/copy.js +33 -0
- package/build/host.d.ts +15 -0
- package/build/host.js +85 -0
- package/build/registry/index.d.ts +26 -0
- package/build/registry/index.js +116 -0
- package/build/registry/plan.d.ts +53 -0
- package/build/registry/plan.js +159 -0
- package/build/schema.d.ts +136 -0
- package/build/schema.js +58 -0
- package/build/types.d.ts +21 -0
- package/build/types.js +1 -0
- package/build/wire.d.ts +16 -0
- package/build/wire.js +162 -0
- package/package.json +51 -0
- package/registry/README.md +86 -0
- package/registry/account/contracts/avatar_storage.ts +7 -0
- package/registry/account/contracts/index.ts +7 -0
- package/registry/account/contracts/types.ts +23 -0
- package/registry/account/events.ts +14 -0
- package/registry/account/module.json +31 -0
- package/registry/account/options.ts +11 -0
- package/registry/account/service.ts +155 -0
- package/registry/account/stubs/adapters/account.ts +21 -0
- package/registry/account/stubs/adapters/drive_avatar_storage.ts +15 -0
- package/registry/account/stubs/controllers/account_controller.ts +190 -0
- package/registry/account/stubs/routes/account.ts +53 -0
- package/registry/account/stubs/validators/account.ts +37 -0
- package/registry/account/tests/account_service.spec.ts +132 -0
- package/registry/account/tests/fakes/memory_avatar_storage.ts +12 -0
- package/registry/api/envelope.ts +66 -0
- package/registry/api/exception.ts +38 -0
- package/registry/api/exception_handler.ts +54 -0
- package/registry/api/module.json +27 -0
- package/registry/api/provider.ts +129 -0
- package/registry/api/service.ts +88 -0
- package/registry/api/tests/emit_safe.spec.ts +107 -0
- package/registry/api/tests/respond.spec.ts +40 -0
- package/registry/auth/contracts/index.ts +18 -0
- package/registry/auth/contracts/password_reset_store.ts +21 -0
- package/registry/auth/contracts/session_manager.ts +19 -0
- package/registry/auth/contracts/types.ts +32 -0
- package/registry/auth/events.ts +21 -0
- package/registry/auth/module.json +45 -0
- package/registry/auth/options.ts +23 -0
- package/registry/auth/service.ts +150 -0
- package/registry/auth/stubs/adapters/auth.ts +21 -0
- package/registry/auth/stubs/adapters/lucid_access_token_session.ts +33 -0
- package/registry/auth/stubs/adapters/lucid_password_reset_store.ts +68 -0
- package/registry/auth/stubs/adapters/lucid_user_store.ts +86 -0
- package/registry/auth/stubs/adapters/map_user.ts +19 -0
- package/registry/auth/stubs/controllers/auth_controller.ts +126 -0
- package/registry/auth/stubs/migrations/add_pending_email_to_users_table.ts +17 -0
- package/registry/auth/stubs/migrations/create_password_resets_table.ts +27 -0
- package/registry/auth/stubs/migrations/create_users_table.ts +31 -0
- package/registry/auth/stubs/models/password_reset.ts +29 -0
- package/registry/auth/stubs/models/user.ts +99 -0
- package/registry/auth/stubs/providers/vine_provider.ts +43 -0
- package/registry/auth/stubs/routes/auth.ts +36 -0
- package/registry/auth/stubs/start/limiter.ts +17 -0
- package/registry/auth/stubs/validators/auth.ts +48 -0
- package/registry/auth/stubs/validators/rules/exists.ts +21 -0
- package/registry/auth/stubs/validators/rules/unique.ts +21 -0
- package/registry/auth/stubs/validators/rules/valid_password.ts +20 -0
- package/registry/auth/stubs/validators/rules/valid_token.ts +27 -0
- package/registry/auth/stubs/validators/vine.d.ts +10 -0
- package/registry/auth/tests/auth_service.spec.ts +145 -0
- package/registry/auth/tests/auth_update_password.spec.ts +99 -0
- package/registry/auth/tests/fakes/index.ts +3 -0
- package/registry/auth/tests/fakes/memory_password_reset_store.ts +71 -0
- package/registry/auth/tests/fakes/memory_session_manager.ts +23 -0
- package/registry/auth/tests/fakes/memory_user_store.ts +89 -0
- package/registry/constants/exceptions.ts +64 -0
- package/registry/constants/index.ts +12 -0
- package/registry/constants/responseCodes.ts +87 -0
- package/registry/contracts/index.ts +5 -0
- package/registry/contracts/user.ts +78 -0
- package/registry/creem/contracts/creem_client.ts +34 -0
- package/registry/creem/contracts/index.ts +8 -0
- package/registry/creem/contracts/types.ts +30 -0
- package/registry/creem/events.ts +9 -0
- package/registry/creem/module.json +28 -0
- package/registry/creem/service.ts +87 -0
- package/registry/creem/stubs/adapters/creem.ts +23 -0
- package/registry/creem/stubs/adapters/creem_io_client.ts +70 -0
- package/registry/creem/tests/fakes/fake_creem_client.ts +68 -0
- package/registry/notification/contracts/app_notification_store.ts +8 -0
- package/registry/notification/contracts/index.ts +9 -0
- package/registry/notification/contracts/mail_transport.ts +7 -0
- package/registry/notification/contracts/types.ts +32 -0
- package/registry/notification/module.json +23 -0
- package/registry/notification/options.ts +11 -0
- package/registry/notification/service.ts +103 -0
- package/registry/notification/stubs/adapters/adonis_mail_transport.ts +15 -0
- package/registry/notification/stubs/adapters/lucid_app_notification_store.ts +21 -0
- package/registry/notification/stubs/adapters/notification.ts +36 -0
- package/registry/notification/stubs/migrations/create_notifications_table.ts +23 -0
- package/registry/notification/stubs/models/app_notification.ts +45 -0
- package/registry/notification/tests/fakes/memory_app_notification_store.ts +19 -0
- package/registry/notification/tests/fakes/memory_mail_transport.ts +16 -0
- package/registry/notification/tests/notification_service.spec.ts +63 -0
- package/registry/registry.json +4 -0
- package/registry/subscription/contracts/index.ts +9 -0
- package/registry/subscription/contracts/subscription_store.ts +15 -0
- package/registry/subscription/contracts/types.ts +39 -0
- package/registry/subscription/events.ts +13 -0
- package/registry/subscription/module.json +31 -0
- package/registry/subscription/options.ts +14 -0
- package/registry/subscription/service.ts +140 -0
- package/registry/subscription/stubs/adapters/lucid_subscription_store.ts +69 -0
- package/registry/subscription/stubs/adapters/subscription.ts +21 -0
- package/registry/subscription/stubs/migrations/create_subscriptions_table.ts +26 -0
- package/registry/subscription/stubs/migrations/subscriptions_user_creem_unique.ts +19 -0
- package/registry/subscription/stubs/models/subscription.ts +45 -0
- package/registry/subscription/tests/billing_service.spec.ts +189 -0
- package/registry/subscription/tests/fakes/memory_subscription_store.ts +63 -0
- package/registry/types/api.ts +41 -0
- package/registry/types/constants.ts +7 -0
- package/registry/types/index.ts +6 -0
- package/registry/types/models.ts +32 -0
- package/schema/adonia.schema.json +39 -0
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
CreemClient,
|
|
3
|
+
CreemInvoice,
|
|
4
|
+
CreemWebhookHandlers,
|
|
5
|
+
} from '#modules/creem/contracts/index'
|
|
6
|
+
|
|
7
|
+
type CheckoutInput = {
|
|
8
|
+
successUrl: string
|
|
9
|
+
productId: string
|
|
10
|
+
customerEmail: string
|
|
11
|
+
userId: number
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Fake CreemClient. handleWebhookEvents invokes a named handler with canned data.
|
|
16
|
+
*/
|
|
17
|
+
export class FakeCreemClient implements CreemClient {
|
|
18
|
+
checkouts: CheckoutInput[] = []
|
|
19
|
+
portalCalls: string[] = []
|
|
20
|
+
listCalls: Array<{ customerId: string; limit: number }> = []
|
|
21
|
+
checkoutUrl: string | null = 'https://pay.creem.io/checkout/1'
|
|
22
|
+
portalUrl = 'https://portal.creem.io/1'
|
|
23
|
+
invoices: CreemInvoice[] = []
|
|
24
|
+
|
|
25
|
+
/** When set, handleWebhookEvents calls this handler name with handlerPayload. */
|
|
26
|
+
nextHandler: { name: string; payload: any } | null = null
|
|
27
|
+
|
|
28
|
+
async createCheckout(input: CheckoutInput) {
|
|
29
|
+
this.checkouts.push(input)
|
|
30
|
+
return { checkoutUrl: this.checkoutUrl }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async createPortalLink(customerId: string) {
|
|
34
|
+
this.portalCalls.push(customerId)
|
|
35
|
+
return { portalUrl: this.portalUrl }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async listTransactions(customerId: string, limit: number) {
|
|
39
|
+
this.listCalls.push({ customerId, limit })
|
|
40
|
+
return { items: this.invoices }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async handleWebhookEvents(
|
|
44
|
+
_rawBody: string,
|
|
45
|
+
_signature: string,
|
|
46
|
+
handlers: CreemWebhookHandlers
|
|
47
|
+
) {
|
|
48
|
+
if (!this.nextHandler) return
|
|
49
|
+
const fn = handlers[this.nextHandler.name]
|
|
50
|
+
if (fn) {
|
|
51
|
+
await fn(this.nextHandler.payload)
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function makeNormalizedSub(overrides: Record<string, any> = {}) {
|
|
57
|
+
return {
|
|
58
|
+
id: 'sub_123',
|
|
59
|
+
status: 'active',
|
|
60
|
+
metadata: { userId: '1' },
|
|
61
|
+
customer: { id: 'cust_123' },
|
|
62
|
+
product: { id: 'prod_123', name: 'Pro' },
|
|
63
|
+
currentPeriodStartDate: '2024-01-01T00:00:00.000Z',
|
|
64
|
+
currentPeriodEndDate: '2024-02-01T00:00:00.000Z',
|
|
65
|
+
canceledAt: null,
|
|
66
|
+
...overrides,
|
|
67
|
+
}
|
|
68
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export type {
|
|
2
|
+
NotificationChannel,
|
|
3
|
+
AppNotificationData,
|
|
4
|
+
NotificationDefinition,
|
|
5
|
+
NotificationRegistry,
|
|
6
|
+
} from './types.ts'
|
|
7
|
+
|
|
8
|
+
export type { MailTransport } from './mail_transport.ts'
|
|
9
|
+
export type { AppNotificationStore } from './app_notification_store.ts'
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Notification channel + registry types.
|
|
3
|
+
* Concrete notification types and mail classes stay host-owned.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export type NotificationChannel = 'email' | 'app'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Payload written to the in-app notifications store.
|
|
10
|
+
*/
|
|
11
|
+
export type AppNotificationData = {
|
|
12
|
+
userId: number
|
|
13
|
+
type: string
|
|
14
|
+
title: string
|
|
15
|
+
body: string
|
|
16
|
+
data?: Record<string, any>
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Per-type handlers. Email returns an opaque message for MailTransport;
|
|
21
|
+
* app returns the row shape for AppNotificationStore.
|
|
22
|
+
*/
|
|
23
|
+
export type NotificationDefinition = {
|
|
24
|
+
email?: (payload: Record<string, any>) => unknown
|
|
25
|
+
app?: (payload: Record<string, any>) => AppNotificationData
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Host-supplied map of notification type → channel handlers.
|
|
30
|
+
* Keys are free-form strings (e.g. 'verify_email', 'welcome').
|
|
31
|
+
*/
|
|
32
|
+
export type NotificationRegistry = Record<string, NotificationDefinition>
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "notification",
|
|
3
|
+
"description": "Multi-channel notification dispatcher with host-supplied registry",
|
|
4
|
+
"files": [
|
|
5
|
+
"service.ts",
|
|
6
|
+
"options.ts",
|
|
7
|
+
"contracts/**",
|
|
8
|
+
"module.json"
|
|
9
|
+
],
|
|
10
|
+
"stubs": [
|
|
11
|
+
"stubs/models/app_notification.ts",
|
|
12
|
+
"stubs/migrations/*.ts",
|
|
13
|
+
"stubs/adapters/**/*"
|
|
14
|
+
],
|
|
15
|
+
"dependencies": {},
|
|
16
|
+
"registryDependencies": ["api"],
|
|
17
|
+
"peerModels": ["#models/app_notification"],
|
|
18
|
+
"configKeys": [],
|
|
19
|
+
"env": [],
|
|
20
|
+
"events": [],
|
|
21
|
+
"hostFacingExceptions": [],
|
|
22
|
+
"hostTests": []
|
|
23
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { NotificationChannel } from '#modules/notification/contracts/index'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Constructor options for NotificationService.
|
|
5
|
+
*/
|
|
6
|
+
export type NotificationServiceOptions = {
|
|
7
|
+
/** Called when a channel has no handler for the given type. Default: no-op. */
|
|
8
|
+
onWarn?: (message: string) => void
|
|
9
|
+
/** Called when a channel handler throws. Default: no-op. */
|
|
10
|
+
onError?: (channel: NotificationChannel, type: string, error: unknown) => void
|
|
11
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* NotificationService
|
|
3
|
+
*
|
|
4
|
+
* Channel-agnostic dispatcher. Resolves handlers from a host-supplied registry
|
|
5
|
+
* and fans out to email / in-app transports.
|
|
6
|
+
*
|
|
7
|
+
* Infrastructure (not a domain service) — intentionally does not extend
|
|
8
|
+
* ApiService. Does not own notification types, mail templates, or preferences.
|
|
9
|
+
*/
|
|
10
|
+
import type {
|
|
11
|
+
AppNotificationStore,
|
|
12
|
+
MailTransport,
|
|
13
|
+
NotificationChannel,
|
|
14
|
+
NotificationRegistry,
|
|
15
|
+
} from '#modules/notification/contracts/index'
|
|
16
|
+
import type { NotificationServiceOptions } from '#modules/notification/options'
|
|
17
|
+
|
|
18
|
+
export type ChannelSendStatus = 'sent' | 'skipped' | 'failed'
|
|
19
|
+
export type { NotificationServiceOptions } from '#modules/notification/options'
|
|
20
|
+
|
|
21
|
+
export class NotificationService<R extends NotificationRegistry = NotificationRegistry> {
|
|
22
|
+
private readonly onWarn: (message: string) => void
|
|
23
|
+
private readonly onError: (
|
|
24
|
+
channel: NotificationChannel,
|
|
25
|
+
type: string,
|
|
26
|
+
error: unknown
|
|
27
|
+
) => void
|
|
28
|
+
|
|
29
|
+
constructor(
|
|
30
|
+
private registry: R,
|
|
31
|
+
private mail: MailTransport,
|
|
32
|
+
private appNotifications: AppNotificationStore,
|
|
33
|
+
options: NotificationServiceOptions = {}
|
|
34
|
+
) {
|
|
35
|
+
this.onWarn = options.onWarn ?? (() => {})
|
|
36
|
+
this.onError = options.onError ?? (() => {})
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Dispatch to the requested channels in parallel.
|
|
41
|
+
* Channel failures are isolated and reported via onError + the return map.
|
|
42
|
+
*/
|
|
43
|
+
async send<K extends keyof R & string>(
|
|
44
|
+
type: K,
|
|
45
|
+
channels: NotificationChannel[],
|
|
46
|
+
payload: Record<string, any>
|
|
47
|
+
): Promise<Record<NotificationChannel, ChannelSendStatus>> {
|
|
48
|
+
const status: Record<NotificationChannel, ChannelSendStatus> = {
|
|
49
|
+
email: 'skipped',
|
|
50
|
+
app: 'skipped',
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
await Promise.all(
|
|
54
|
+
[...new Set(channels)].map(async (channel) => {
|
|
55
|
+
try {
|
|
56
|
+
status[channel] = await this.dispatch(channel, type, payload)
|
|
57
|
+
} catch (error) {
|
|
58
|
+
this.onError(channel, type, error)
|
|
59
|
+
status[channel] = 'failed'
|
|
60
|
+
}
|
|
61
|
+
})
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
return status
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
private async dispatch(
|
|
68
|
+
channel: NotificationChannel,
|
|
69
|
+
type: string,
|
|
70
|
+
payload: Record<string, any>
|
|
71
|
+
): Promise<'sent' | 'skipped'> {
|
|
72
|
+
if (channel === 'email') {
|
|
73
|
+
return this.sendEmail(type, payload)
|
|
74
|
+
}
|
|
75
|
+
return this.sendApp(type, payload)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
private async sendEmail(
|
|
79
|
+
type: string,
|
|
80
|
+
payload: Record<string, any>
|
|
81
|
+
): Promise<'sent' | 'skipped'> {
|
|
82
|
+
const definition = this.registry[type]
|
|
83
|
+
if (!definition?.email) {
|
|
84
|
+
this.onWarn(`No email handler for notification type: ${type}`)
|
|
85
|
+
return 'skipped'
|
|
86
|
+
}
|
|
87
|
+
await this.mail.sendLater(definition.email(payload))
|
|
88
|
+
return 'sent'
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
private async sendApp(
|
|
92
|
+
type: string,
|
|
93
|
+
payload: Record<string, any>
|
|
94
|
+
): Promise<'sent' | 'skipped'> {
|
|
95
|
+
const definition = this.registry[type]
|
|
96
|
+
if (!definition?.app) {
|
|
97
|
+
this.onWarn(`No app handler for notification type: ${type}`)
|
|
98
|
+
return 'skipped'
|
|
99
|
+
}
|
|
100
|
+
await this.appNotifications.create(definition.app(payload))
|
|
101
|
+
return 'sent'
|
|
102
|
+
}
|
|
103
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Adonis Mail transport. Copied to app/adapters/adonis_mail_transport.ts.
|
|
3
|
+
* Requires `@adonisjs/mail` (`node ace add @adonisjs/mail`).
|
|
4
|
+
*
|
|
5
|
+
* Registry email builders must return whatever `mail.sendLater` accepts
|
|
6
|
+
* (a BaseMail instance or a message callback).
|
|
7
|
+
*/
|
|
8
|
+
import mail from '@adonisjs/mail/services/main'
|
|
9
|
+
import type { MailTransport } from '#modules/notification/contracts/index'
|
|
10
|
+
|
|
11
|
+
export class AdonisMailTransport implements MailTransport {
|
|
12
|
+
async sendLater(message: unknown): Promise<void> {
|
|
13
|
+
await mail.sendLater(message as Parameters<typeof mail.sendLater>[0])
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lucid AppNotificationStore. Copied to app/adapters/lucid_app_notification_store.ts.
|
|
3
|
+
* Expects a model at #models/app_notification with userId, type, title, body, data.
|
|
4
|
+
*/
|
|
5
|
+
import AppNotificationModel from '#models/app_notification'
|
|
6
|
+
import type {
|
|
7
|
+
AppNotificationData,
|
|
8
|
+
AppNotificationStore,
|
|
9
|
+
} from '#modules/notification/contracts/index'
|
|
10
|
+
|
|
11
|
+
export class LucidAppNotificationStore implements AppNotificationStore {
|
|
12
|
+
async create(data: AppNotificationData): Promise<void> {
|
|
13
|
+
await AppNotificationModel.create({
|
|
14
|
+
userId: data.userId,
|
|
15
|
+
type: data.type,
|
|
16
|
+
title: data.title,
|
|
17
|
+
body: data.body,
|
|
18
|
+
data: data.data ?? null,
|
|
19
|
+
})
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host factory for notification. Copied to app/adapters/notification.ts.
|
|
3
|
+
* Import as `#adapters/notification`.
|
|
4
|
+
*
|
|
5
|
+
* Defaults to Adonis Mail + Lucid in-app store. Requires
|
|
6
|
+
* `node ace add @adonisjs/mail` (and Lucid for the app channel).
|
|
7
|
+
*/
|
|
8
|
+
import { NotificationService } from '#modules/notification/service'
|
|
9
|
+
import type { NotificationServiceOptions } from '#modules/notification/options'
|
|
10
|
+
import { AdonisMailTransport } from './adonis_mail_transport.ts'
|
|
11
|
+
import { LucidAppNotificationStore } from './lucid_app_notification_store.ts'
|
|
12
|
+
import type {
|
|
13
|
+
AppNotificationStore,
|
|
14
|
+
MailTransport,
|
|
15
|
+
NotificationRegistry,
|
|
16
|
+
} from '#modules/notification/contracts/index'
|
|
17
|
+
|
|
18
|
+
export { AdonisMailTransport } from './adonis_mail_transport.ts'
|
|
19
|
+
export { LucidAppNotificationStore } from './lucid_app_notification_store.ts'
|
|
20
|
+
|
|
21
|
+
export type CreateNotificationServiceOptions = NotificationServiceOptions & {
|
|
22
|
+
mail?: MailTransport
|
|
23
|
+
appNotifications?: AppNotificationStore
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function createNotificationService<R extends NotificationRegistry>(
|
|
27
|
+
registry: R,
|
|
28
|
+
options: CreateNotificationServiceOptions = {}
|
|
29
|
+
) {
|
|
30
|
+
return new NotificationService(
|
|
31
|
+
registry,
|
|
32
|
+
options.mail ?? new AdonisMailTransport(),
|
|
33
|
+
options.appNotifications ?? new LucidAppNotificationStore(),
|
|
34
|
+
{ onWarn: options.onWarn, onError: options.onError }
|
|
35
|
+
)
|
|
36
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { BaseSchema } from '@adonisjs/lucid/schema'
|
|
2
|
+
|
|
3
|
+
export default class extends BaseSchema {
|
|
4
|
+
protected tableName = 'notifications'
|
|
5
|
+
|
|
6
|
+
async up() {
|
|
7
|
+
this.schema.createTable(this.tableName, (table) => {
|
|
8
|
+
table.string('id').primary().unique().notNullable()
|
|
9
|
+
table.integer('user_id').references('id').inTable('users').onDelete('CASCADE').notNullable()
|
|
10
|
+
table.string('type').notNullable()
|
|
11
|
+
table.string('title').notNullable()
|
|
12
|
+
table.text('body').notNullable()
|
|
13
|
+
table.json('data').nullable()
|
|
14
|
+
table.timestamp('read_at').nullable()
|
|
15
|
+
table.timestamp('created_at')
|
|
16
|
+
table.timestamp('updated_at')
|
|
17
|
+
})
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async down() {
|
|
21
|
+
this.schema.dropTable(this.tableName)
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { DateTime } from 'luxon'
|
|
2
|
+
import { BaseModel, beforeCreate, belongsTo, column } from '@adonisjs/lucid/orm'
|
|
3
|
+
import type { BelongsTo } from '@adonisjs/lucid/types/relations'
|
|
4
|
+
import { randomUUID } from 'node:crypto'
|
|
5
|
+
import User from '#models/user'
|
|
6
|
+
|
|
7
|
+
export default class AppNotification extends BaseModel {
|
|
8
|
+
static selfAssignPrimaryKey = true
|
|
9
|
+
static table = 'notifications'
|
|
10
|
+
|
|
11
|
+
@column({ isPrimary: true })
|
|
12
|
+
declare id: string
|
|
13
|
+
|
|
14
|
+
@column()
|
|
15
|
+
declare userId: number
|
|
16
|
+
|
|
17
|
+
@column()
|
|
18
|
+
declare type: string
|
|
19
|
+
|
|
20
|
+
@column()
|
|
21
|
+
declare title: string
|
|
22
|
+
|
|
23
|
+
@column()
|
|
24
|
+
declare body: string
|
|
25
|
+
|
|
26
|
+
@column()
|
|
27
|
+
declare data: Record<string, any> | null
|
|
28
|
+
|
|
29
|
+
@column.dateTime()
|
|
30
|
+
declare readAt: DateTime | null
|
|
31
|
+
|
|
32
|
+
@column.dateTime({ autoCreate: true })
|
|
33
|
+
declare createdAt: DateTime
|
|
34
|
+
|
|
35
|
+
@column.dateTime({ autoCreate: true, autoUpdate: true })
|
|
36
|
+
declare updatedAt: DateTime
|
|
37
|
+
|
|
38
|
+
@beforeCreate()
|
|
39
|
+
static assignUuid(model: AppNotification) {
|
|
40
|
+
model.id = randomUUID()
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
@belongsTo(() => User)
|
|
44
|
+
declare user: BelongsTo<typeof User>
|
|
45
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AppNotificationData,
|
|
3
|
+
AppNotificationStore,
|
|
4
|
+
} from '#modules/notification/contracts/index'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* In-memory AppNotificationStore.
|
|
8
|
+
*/
|
|
9
|
+
export class MemoryAppNotificationStore implements AppNotificationStore {
|
|
10
|
+
rows: AppNotificationData[] = []
|
|
11
|
+
throwOnCreate = false
|
|
12
|
+
|
|
13
|
+
async create(data: AppNotificationData): Promise<void> {
|
|
14
|
+
if (this.throwOnCreate) {
|
|
15
|
+
throw new Error('app notification failure')
|
|
16
|
+
}
|
|
17
|
+
this.rows.push({ ...data })
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { MailTransport } from '#modules/notification/contracts/index'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* In-memory MailTransport. Optionally throws to simulate mailer outages.
|
|
5
|
+
*/
|
|
6
|
+
export class MemoryMailTransport implements MailTransport {
|
|
7
|
+
messages: unknown[] = []
|
|
8
|
+
throwOnSend = false
|
|
9
|
+
|
|
10
|
+
async sendLater(message: unknown): Promise<void> {
|
|
11
|
+
if (this.throwOnSend) {
|
|
12
|
+
throw new Error('SMTP failure')
|
|
13
|
+
}
|
|
14
|
+
this.messages.push(message)
|
|
15
|
+
}
|
|
16
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { test } from '@japa/runner'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { NotificationService } from '#modules/notification/service'
|
|
4
|
+
import { MemoryMailTransport } from '#modules/notification/tests/fakes/memory_mail_transport'
|
|
5
|
+
import { MemoryAppNotificationStore } from '#modules/notification/tests/fakes/memory_app_notification_store'
|
|
6
|
+
import type { NotificationRegistry } from '#modules/notification/contracts/index'
|
|
7
|
+
|
|
8
|
+
const registry = {
|
|
9
|
+
welcome: {
|
|
10
|
+
email: (p: Record<string, any>) => ({ to: p.email, template: 'welcome' }),
|
|
11
|
+
},
|
|
12
|
+
new_submission: {
|
|
13
|
+
email: (p: Record<string, any>) => ({ to: p.email }),
|
|
14
|
+
app: (p: Record<string, any>) => ({
|
|
15
|
+
userId: p.userId,
|
|
16
|
+
type: 'new_submission',
|
|
17
|
+
title: 'New',
|
|
18
|
+
body: 'Body',
|
|
19
|
+
}),
|
|
20
|
+
},
|
|
21
|
+
} as const satisfies NotificationRegistry
|
|
22
|
+
|
|
23
|
+
test.group('NotificationService', () => {
|
|
24
|
+
test('send typo is a compile-time error when using a typed registry', async () => {
|
|
25
|
+
const mail = new MemoryMailTransport()
|
|
26
|
+
const app = new MemoryAppNotificationStore()
|
|
27
|
+
const service = new NotificationService(registry, mail, app)
|
|
28
|
+
|
|
29
|
+
// @ts-expect-error typo is not a registry key
|
|
30
|
+
await service.send('typo', ['email'], {})
|
|
31
|
+
|
|
32
|
+
const status = await service.send('welcome', ['email'], { email: 'a@test.com' })
|
|
33
|
+
assert.equal(status.email, 'sent')
|
|
34
|
+
assert.equal(status.app, 'skipped')
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
test('throwing MailTransport calls onError and returns email failed', async () => {
|
|
38
|
+
const mail = new MemoryMailTransport()
|
|
39
|
+
mail.throwOnSend = true
|
|
40
|
+
const errors: Array<{ channel: string; type: string }> = []
|
|
41
|
+
const service = new NotificationService(registry, mail, new MemoryAppNotificationStore(), {
|
|
42
|
+
onError: (channel, type) => errors.push({ channel, type }),
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
const status = await service.send('welcome', ['email'], { email: 'a@test.com' })
|
|
46
|
+
assert.equal(status.email, 'failed')
|
|
47
|
+
assert.deepEqual(errors, [{ channel: 'email', type: 'welcome' }])
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
test('dispatches to both channels', async () => {
|
|
51
|
+
const mail = new MemoryMailTransport()
|
|
52
|
+
const app = new MemoryAppNotificationStore()
|
|
53
|
+
const service = new NotificationService(registry, mail, app)
|
|
54
|
+
const status = await service.send('new_submission', ['email', 'app'], {
|
|
55
|
+
email: 'a@test.com',
|
|
56
|
+
userId: 1,
|
|
57
|
+
})
|
|
58
|
+
assert.equal(status.email, 'sent')
|
|
59
|
+
assert.equal(status.app, 'sent')
|
|
60
|
+
assert.equal(mail.messages.length, 1)
|
|
61
|
+
assert.equal(app.rows.length, 1)
|
|
62
|
+
})
|
|
63
|
+
})
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { Subscription, SubscriptionSnapshot } from './types.ts'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Host persistence for subscriptions. Lucid / Drizzle / etc. stay in adapters.
|
|
5
|
+
*/
|
|
6
|
+
export interface SubscriptionStore {
|
|
7
|
+
/**
|
|
8
|
+
* Current subscription for the user: the row with the latest currentPeriodEnd.
|
|
9
|
+
*/
|
|
10
|
+
findCurrentByUserId(userId: number): Promise<Subscription | null>
|
|
11
|
+
|
|
12
|
+
findActiveByUserId(userId: number, activeStatuses: string[]): Promise<Subscription | null>
|
|
13
|
+
|
|
14
|
+
upsert(snapshot: SubscriptionSnapshot): Promise<void>
|
|
15
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Portable subscription DTO + webhook snapshot.
|
|
3
|
+
* Host adapters map persistence models ↔ these shapes.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export type Subscription = {
|
|
7
|
+
id: string | number
|
|
8
|
+
userId: number
|
|
9
|
+
creemCustomerId: string
|
|
10
|
+
creemSubscriptionId: string | null
|
|
11
|
+
creemProductId: string
|
|
12
|
+
/** Product display name as stored from Creem (informational). */
|
|
13
|
+
plan: string
|
|
14
|
+
status: string
|
|
15
|
+
currentPeriodStart: Date | null
|
|
16
|
+
currentPeriodEnd: Date | null
|
|
17
|
+
canceledAt: Date | null
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Normalized Creem subscription payload used for upsert.
|
|
22
|
+
* Mapped from webhook context inside the service — store never sees creem_io types.
|
|
23
|
+
*/
|
|
24
|
+
export type SubscriptionSnapshot = {
|
|
25
|
+
id: string
|
|
26
|
+
userId: number
|
|
27
|
+
customerId: string
|
|
28
|
+
productId: string
|
|
29
|
+
productName: string
|
|
30
|
+
status: string
|
|
31
|
+
currentPeriodStart: Date
|
|
32
|
+
currentPeriodEnd: Date
|
|
33
|
+
canceledAt: Date | null
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export type SubscriptionConfig = {
|
|
37
|
+
/** Statuses treated as entitled. Defaults from options.ts. */
|
|
38
|
+
activeStatuses?: string[]
|
|
39
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Subscription module events. Import this file so the EventsList augmentation loads.
|
|
3
|
+
*/
|
|
4
|
+
declare module '@adonisjs/core/types' {
|
|
5
|
+
interface EventsList {
|
|
6
|
+
'Subscription:Activated': { userId: number }
|
|
7
|
+
'Subscription:Revoked': { userId: number }
|
|
8
|
+
'Subscription:Canceled': { userId: number }
|
|
9
|
+
'Subscription:ScheduledCancel': { userId: number }
|
|
10
|
+
'Subscription:Updated': { userId: number }
|
|
11
|
+
'Subscription:RefundCreated': { refundId: string }
|
|
12
|
+
}
|
|
13
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "subscription",
|
|
3
|
+
"description": "Subscription state synced from Creem webhooks",
|
|
4
|
+
"files": [
|
|
5
|
+
"service.ts",
|
|
6
|
+
"events.ts",
|
|
7
|
+
"options.ts",
|
|
8
|
+
"contracts/**",
|
|
9
|
+
"module.json"
|
|
10
|
+
],
|
|
11
|
+
"stubs": [
|
|
12
|
+
"stubs/models/subscription.ts",
|
|
13
|
+
"stubs/migrations/*.ts",
|
|
14
|
+
"stubs/adapters/**/*"
|
|
15
|
+
],
|
|
16
|
+
"dependencies": {},
|
|
17
|
+
"registryDependencies": ["api", "creem"],
|
|
18
|
+
"peerModels": ["#models/subscription"],
|
|
19
|
+
"configKeys": ["modules.subscription"],
|
|
20
|
+
"env": [],
|
|
21
|
+
"events": [
|
|
22
|
+
"Subscription:Activated",
|
|
23
|
+
"Subscription:Revoked",
|
|
24
|
+
"Subscription:Canceled",
|
|
25
|
+
"Subscription:ScheduledCancel",
|
|
26
|
+
"Subscription:Updated",
|
|
27
|
+
"Subscription:RefundCreated"
|
|
28
|
+
],
|
|
29
|
+
"hostFacingExceptions": [],
|
|
30
|
+
"hostTests": ["tests/integration/lucid_adapters.spec.ts"]
|
|
31
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host-editable defaults for SubscriptionService.
|
|
3
|
+
*/
|
|
4
|
+
export type SubscriptionOptions = {
|
|
5
|
+
/** Statuses treated as entitled. */
|
|
6
|
+
activeStatuses: string[]
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export const defaultSubscriptionOptions: SubscriptionOptions = {
|
|
10
|
+
activeStatuses: ['active', 'trialing', 'scheduled_cancel'],
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** @deprecated Prefer defaultSubscriptionOptions.activeStatuses */
|
|
14
|
+
export const DEFAULT_ACTIVE_STATUSES = defaultSubscriptionOptions.activeStatuses
|