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.
Files changed (140) hide show
  1. package/README.md +64 -0
  2. package/build/adonis_peers.d.ts +14 -0
  3. package/build/adonis_peers.js +56 -0
  4. package/build/cli.d.ts +2 -0
  5. package/build/cli.js +111 -0
  6. package/build/commands/add.d.ts +18 -0
  7. package/build/commands/add.js +246 -0
  8. package/build/commands/check.d.ts +4 -0
  9. package/build/commands/check.js +159 -0
  10. package/build/commands/diff.d.ts +2 -0
  11. package/build/commands/diff.js +74 -0
  12. package/build/commands/init.d.ts +6 -0
  13. package/build/commands/init.js +124 -0
  14. package/build/commands/list.d.ts +2 -0
  15. package/build/commands/list.js +34 -0
  16. package/build/config_merge.d.ts +10 -0
  17. package/build/config_merge.js +71 -0
  18. package/build/constants_merge.d.ts +4 -0
  19. package/build/constants_merge.js +85 -0
  20. package/build/copy.d.ts +8 -0
  21. package/build/copy.js +33 -0
  22. package/build/host.d.ts +15 -0
  23. package/build/host.js +85 -0
  24. package/build/registry/index.d.ts +26 -0
  25. package/build/registry/index.js +116 -0
  26. package/build/registry/plan.d.ts +53 -0
  27. package/build/registry/plan.js +159 -0
  28. package/build/schema.d.ts +136 -0
  29. package/build/schema.js +58 -0
  30. package/build/types.d.ts +21 -0
  31. package/build/types.js +1 -0
  32. package/build/wire.d.ts +16 -0
  33. package/build/wire.js +162 -0
  34. package/package.json +51 -0
  35. package/registry/README.md +86 -0
  36. package/registry/account/contracts/avatar_storage.ts +7 -0
  37. package/registry/account/contracts/index.ts +7 -0
  38. package/registry/account/contracts/types.ts +23 -0
  39. package/registry/account/events.ts +14 -0
  40. package/registry/account/module.json +31 -0
  41. package/registry/account/options.ts +11 -0
  42. package/registry/account/service.ts +155 -0
  43. package/registry/account/stubs/adapters/account.ts +21 -0
  44. package/registry/account/stubs/adapters/drive_avatar_storage.ts +15 -0
  45. package/registry/account/stubs/controllers/account_controller.ts +190 -0
  46. package/registry/account/stubs/routes/account.ts +53 -0
  47. package/registry/account/stubs/validators/account.ts +37 -0
  48. package/registry/account/tests/account_service.spec.ts +132 -0
  49. package/registry/account/tests/fakes/memory_avatar_storage.ts +12 -0
  50. package/registry/api/envelope.ts +66 -0
  51. package/registry/api/exception.ts +38 -0
  52. package/registry/api/exception_handler.ts +54 -0
  53. package/registry/api/module.json +27 -0
  54. package/registry/api/provider.ts +129 -0
  55. package/registry/api/service.ts +88 -0
  56. package/registry/api/tests/emit_safe.spec.ts +107 -0
  57. package/registry/api/tests/respond.spec.ts +40 -0
  58. package/registry/auth/contracts/index.ts +18 -0
  59. package/registry/auth/contracts/password_reset_store.ts +21 -0
  60. package/registry/auth/contracts/session_manager.ts +19 -0
  61. package/registry/auth/contracts/types.ts +32 -0
  62. package/registry/auth/events.ts +21 -0
  63. package/registry/auth/module.json +45 -0
  64. package/registry/auth/options.ts +23 -0
  65. package/registry/auth/service.ts +150 -0
  66. package/registry/auth/stubs/adapters/auth.ts +21 -0
  67. package/registry/auth/stubs/adapters/lucid_access_token_session.ts +33 -0
  68. package/registry/auth/stubs/adapters/lucid_password_reset_store.ts +68 -0
  69. package/registry/auth/stubs/adapters/lucid_user_store.ts +86 -0
  70. package/registry/auth/stubs/adapters/map_user.ts +19 -0
  71. package/registry/auth/stubs/controllers/auth_controller.ts +126 -0
  72. package/registry/auth/stubs/migrations/add_pending_email_to_users_table.ts +17 -0
  73. package/registry/auth/stubs/migrations/create_password_resets_table.ts +27 -0
  74. package/registry/auth/stubs/migrations/create_users_table.ts +31 -0
  75. package/registry/auth/stubs/models/password_reset.ts +29 -0
  76. package/registry/auth/stubs/models/user.ts +99 -0
  77. package/registry/auth/stubs/providers/vine_provider.ts +43 -0
  78. package/registry/auth/stubs/routes/auth.ts +36 -0
  79. package/registry/auth/stubs/start/limiter.ts +17 -0
  80. package/registry/auth/stubs/validators/auth.ts +48 -0
  81. package/registry/auth/stubs/validators/rules/exists.ts +21 -0
  82. package/registry/auth/stubs/validators/rules/unique.ts +21 -0
  83. package/registry/auth/stubs/validators/rules/valid_password.ts +20 -0
  84. package/registry/auth/stubs/validators/rules/valid_token.ts +27 -0
  85. package/registry/auth/stubs/validators/vine.d.ts +10 -0
  86. package/registry/auth/tests/auth_service.spec.ts +145 -0
  87. package/registry/auth/tests/auth_update_password.spec.ts +99 -0
  88. package/registry/auth/tests/fakes/index.ts +3 -0
  89. package/registry/auth/tests/fakes/memory_password_reset_store.ts +71 -0
  90. package/registry/auth/tests/fakes/memory_session_manager.ts +23 -0
  91. package/registry/auth/tests/fakes/memory_user_store.ts +89 -0
  92. package/registry/constants/exceptions.ts +64 -0
  93. package/registry/constants/index.ts +12 -0
  94. package/registry/constants/responseCodes.ts +87 -0
  95. package/registry/contracts/index.ts +5 -0
  96. package/registry/contracts/user.ts +78 -0
  97. package/registry/creem/contracts/creem_client.ts +34 -0
  98. package/registry/creem/contracts/index.ts +8 -0
  99. package/registry/creem/contracts/types.ts +30 -0
  100. package/registry/creem/events.ts +9 -0
  101. package/registry/creem/module.json +28 -0
  102. package/registry/creem/service.ts +87 -0
  103. package/registry/creem/stubs/adapters/creem.ts +23 -0
  104. package/registry/creem/stubs/adapters/creem_io_client.ts +70 -0
  105. package/registry/creem/tests/fakes/fake_creem_client.ts +68 -0
  106. package/registry/notification/contracts/app_notification_store.ts +8 -0
  107. package/registry/notification/contracts/index.ts +9 -0
  108. package/registry/notification/contracts/mail_transport.ts +7 -0
  109. package/registry/notification/contracts/types.ts +32 -0
  110. package/registry/notification/module.json +23 -0
  111. package/registry/notification/options.ts +11 -0
  112. package/registry/notification/service.ts +103 -0
  113. package/registry/notification/stubs/adapters/adonis_mail_transport.ts +15 -0
  114. package/registry/notification/stubs/adapters/lucid_app_notification_store.ts +21 -0
  115. package/registry/notification/stubs/adapters/notification.ts +36 -0
  116. package/registry/notification/stubs/migrations/create_notifications_table.ts +23 -0
  117. package/registry/notification/stubs/models/app_notification.ts +45 -0
  118. package/registry/notification/tests/fakes/memory_app_notification_store.ts +19 -0
  119. package/registry/notification/tests/fakes/memory_mail_transport.ts +16 -0
  120. package/registry/notification/tests/notification_service.spec.ts +63 -0
  121. package/registry/registry.json +4 -0
  122. package/registry/subscription/contracts/index.ts +9 -0
  123. package/registry/subscription/contracts/subscription_store.ts +15 -0
  124. package/registry/subscription/contracts/types.ts +39 -0
  125. package/registry/subscription/events.ts +13 -0
  126. package/registry/subscription/module.json +31 -0
  127. package/registry/subscription/options.ts +14 -0
  128. package/registry/subscription/service.ts +140 -0
  129. package/registry/subscription/stubs/adapters/lucid_subscription_store.ts +69 -0
  130. package/registry/subscription/stubs/adapters/subscription.ts +21 -0
  131. package/registry/subscription/stubs/migrations/create_subscriptions_table.ts +26 -0
  132. package/registry/subscription/stubs/migrations/subscriptions_user_creem_unique.ts +19 -0
  133. package/registry/subscription/stubs/models/subscription.ts +45 -0
  134. package/registry/subscription/tests/billing_service.spec.ts +189 -0
  135. package/registry/subscription/tests/fakes/memory_subscription_store.ts +63 -0
  136. package/registry/types/api.ts +41 -0
  137. package/registry/types/constants.ts +7 -0
  138. package/registry/types/index.ts +6 -0
  139. package/registry/types/models.ts +32 -0
  140. 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,8 @@
1
+ import type { AppNotificationData } from './types.ts'
2
+
3
+ /**
4
+ * Persist in-app notifications. Host wires Lucid / Drizzle / etc.
5
+ */
6
+ export interface AppNotificationStore {
7
+ create(data: AppNotificationData): Promise<void>
8
+ }
@@ -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,7 @@
1
+ /**
2
+ * Outbound email. Host wires Adonis Mail, Resend, SES, etc.
3
+ * Message type is opaque so this contract stays mailer-agnostic.
4
+ */
5
+ export interface MailTransport {
6
+ sendLater(message: unknown): Promise<void>
7
+ }
@@ -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,4 @@
1
+ {
2
+ "core": ["api", "types", "constants", "contracts"],
3
+ "modules": ["auth", "account", "notification", "creem", "subscription"]
4
+ }
@@ -0,0 +1,9 @@
1
+ export type {
2
+ Subscription,
3
+ SubscriptionSnapshot,
4
+ SubscriptionConfig,
5
+ } from './types.ts'
6
+
7
+ export { DEFAULT_ACTIVE_STATUSES } from '../options.ts'
8
+
9
+ export type { SubscriptionStore } from './subscription_store.ts'
@@ -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