create-forgeon 0.3.23 → 0.3.25
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 +14 -12
- package/package.json +1 -1
- package/src/modules/accounts.mjs +3 -1
- package/src/modules/communications.mjs +233 -0
- package/src/modules/dependencies.test.mjs +22 -11
- package/src/modules/executor.mjs +4 -0
- package/src/modules/executor.test.mjs +93 -2
- package/src/modules/registry.mjs +24 -8
- package/src/modules/shared/probes.mjs +3 -1
- package/src/run-add-module.test.mjs +4 -0
- package/templates/base/apps/web/src/App.tsx +75 -11
- package/templates/base/apps/web/src/probes.ts +11 -1
- package/templates/base/apps/web/src/styles.css +25 -0
- package/templates/module-fragments/accounts/20_scope.md +3 -2
- package/templates/module-fragments/accounts/90_status_implemented.md +2 -2
- package/templates/module-fragments/accounts/90_status_planned.md +1 -1
- package/templates/module-fragments/communications/00_title.md +1 -0
- package/templates/module-fragments/communications/10_overview.md +6 -0
- package/templates/module-fragments/communications/20_scope.md +23 -0
- package/templates/module-fragments/communications/90_status_implemented.md +8 -0
- package/templates/module-presets/accounts/packages/accounts-api/package.json +1 -0
- package/templates/module-presets/accounts/packages/accounts-api/src/auth-core.service.ts +60 -20
- package/templates/module-presets/accounts/packages/accounts-api/src/forgeon-accounts.module.ts +1 -7
- package/templates/module-presets/accounts/packages/accounts-api/src/index.ts +1 -1
- package/templates/module-presets/communications/packages/communications/package.json +23 -0
- package/templates/module-presets/communications/packages/communications/src/communications-config.loader.ts +59 -0
- package/templates/module-presets/communications/packages/communications/src/communications-config.module.ts +11 -0
- package/templates/module-presets/communications/packages/communications/src/communications-config.service.ts +60 -0
- package/templates/module-presets/communications/packages/communications/src/communications-env.schema.ts +40 -0
- package/templates/module-presets/communications/packages/communications/src/communications.constants.ts +3 -0
- package/templates/module-presets/communications/packages/communications/src/communications.probe.controller.ts +18 -0
- package/templates/module-presets/communications/packages/communications/src/communications.service.ts +104 -0
- package/templates/module-presets/communications/packages/communications/src/communications.types.ts +55 -0
- package/templates/module-presets/communications/packages/communications/src/dto/send-communications-probe.dto.ts +6 -0
- package/templates/module-presets/communications/packages/communications/src/email/email-channel.service.ts +90 -0
- package/templates/module-presets/communications/packages/communications/src/email/email-provider.port.ts +16 -0
- package/templates/module-presets/communications/packages/communications/src/email/providers/gmail-smtp-email.provider.ts +104 -0
- package/templates/module-presets/communications/packages/communications/src/forgeon-communications.module.ts +65 -0
- package/templates/module-presets/communications/packages/communications/src/index.ts +21 -0
- package/templates/module-presets/communications/packages/communications/src/push/providers/stub-push.provider.ts +16 -0
- package/templates/module-presets/communications/packages/communications/src/push/push-channel.service.ts +56 -0
- package/templates/module-presets/communications/packages/communications/src/push/push-provider.port.ts +14 -0
- package/templates/module-presets/communications/packages/communications/src/sms/providers/stub-sms.provider.ts +16 -0
- package/templates/module-presets/communications/packages/communications/src/sms/sms-channel.service.ts +56 -0
- package/templates/module-presets/communications/packages/communications/src/sms/sms-provider.port.ts +14 -0
- package/templates/module-presets/communications/packages/communications/src/template-loader.service.ts +98 -0
- package/templates/module-presets/communications/packages/communications/src/template-renderer.service.ts +30 -0
- package/templates/module-presets/communications/packages/communications/tsconfig.json +9 -0
- package/templates/module-presets/communications/resources/communications/email/communications_probe.html +9 -0
- package/templates/module-presets/communications/resources/communications/email/communications_probe.subject.txt +1 -0
- package/templates/module-presets/communications/resources/communications/email/email_verification_code.html +8 -0
- package/templates/module-presets/communications/resources/communications/email/email_verification_code.subject.txt +1 -0
- package/templates/module-presets/communications/resources/communications/email/password_reset.html +8 -0
- package/templates/module-presets/communications/resources/communications/email/password_reset.subject.txt +1 -0
- package/templates/module-presets/communications/resources/communications/email/welcome_email.html +8 -0
- package/templates/module-presets/communications/resources/communications/email/welcome_email.subject.txt +1 -0
- package/templates/module-presets/communications/resources/communications/push/login_alert.json +4 -0
- package/templates/module-presets/communications/resources/communications/sms/phone_verification.txt +1 -0
- package/templates/module-presets/i18n/apps/web/src/App.tsx +72 -8
- package/templates/module-presets/accounts/packages/accounts-api/src/accounts-email.port.ts +0 -13
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
|
|
3
|
+
function normalizeEnvBoolean(value: unknown): unknown {
|
|
4
|
+
if (typeof value === 'string') {
|
|
5
|
+
const normalized = value.trim().toLowerCase();
|
|
6
|
+
if (['true', '1', 'yes', 'on'].includes(normalized)) {
|
|
7
|
+
return true;
|
|
8
|
+
}
|
|
9
|
+
if (['false', '0', 'no', 'off', ''].includes(normalized)) {
|
|
10
|
+
return false;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
return value;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const envBoolean = z.preprocess(normalizeEnvBoolean, z.boolean());
|
|
18
|
+
|
|
19
|
+
export const communicationsEnvSchema = z
|
|
20
|
+
.object({
|
|
21
|
+
COMMUNICATIONS_TEMPLATES_ROOT: z.string().trim().min(1).default('resources/communications'),
|
|
22
|
+
COMMUNICATIONS_EMAIL_PROVIDER: z.enum(['gmail-smtp']).default('gmail-smtp'),
|
|
23
|
+
COMMUNICATIONS_EMAIL_FROM: z.string().trim().default(''),
|
|
24
|
+
COMMUNICATIONS_EMAIL_REPLY_TO: z.string().trim().default(''),
|
|
25
|
+
COMMUNICATIONS_EMAIL_SUBJECT_PREFIX: z.string().trim().default('[Forgeon]'),
|
|
26
|
+
COMMUNICATIONS_EMAIL_SMTP_HOST: z.string().trim().min(1).default('smtp.gmail.com'),
|
|
27
|
+
COMMUNICATIONS_EMAIL_SMTP_PORT: z.coerce.number().int().min(1).max(65535).default(587),
|
|
28
|
+
COMMUNICATIONS_EMAIL_SMTP_SECURE: envBoolean.default(false),
|
|
29
|
+
COMMUNICATIONS_EMAIL_SMTP_USER: z.string().trim().default(''),
|
|
30
|
+
COMMUNICATIONS_EMAIL_SMTP_PASS: z.string().trim().default(''),
|
|
31
|
+
COMMUNICATIONS_SMS_PROVIDER: z.enum(['stub']).default('stub'),
|
|
32
|
+
COMMUNICATIONS_PUSH_PROVIDER: z.enum(['stub']).default('stub'),
|
|
33
|
+
})
|
|
34
|
+
.passthrough();
|
|
35
|
+
|
|
36
|
+
export type CommunicationsEnv = z.infer<typeof communicationsEnvSchema>;
|
|
37
|
+
|
|
38
|
+
export function parseCommunicationsEnv(input: Record<string, unknown>): CommunicationsEnv {
|
|
39
|
+
return communicationsEnvSchema.parse(input);
|
|
40
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { Body, Controller, Get, Post } from '@nestjs/common';
|
|
2
|
+
import { CommunicationsService } from './communications.service';
|
|
3
|
+
import { SendCommunicationsProbeDto } from './dto/send-communications-probe.dto';
|
|
4
|
+
|
|
5
|
+
@Controller('health')
|
|
6
|
+
export class CommunicationsProbeController {
|
|
7
|
+
constructor(private readonly communicationsService: CommunicationsService) {}
|
|
8
|
+
|
|
9
|
+
@Get('communications')
|
|
10
|
+
getCommunicationsProbe() {
|
|
11
|
+
return this.communicationsService.getProbeStatus();
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
@Post('communications')
|
|
15
|
+
sendCommunicationsProbe(@Body() body: SendCommunicationsProbeDto) {
|
|
16
|
+
return this.communicationsService.sendProbeEmail(body.email);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import * as crypto from 'node:crypto';
|
|
2
|
+
import { BadRequestException, Injectable } from '@nestjs/common';
|
|
3
|
+
import { CommunicationsConfigService } from './communications-config.service';
|
|
4
|
+
import { EmailChannelService } from './email/email-channel.service';
|
|
5
|
+
import { PushChannelService } from './push/push-channel.service';
|
|
6
|
+
import { SmsChannelService } from './sms/sms-channel.service';
|
|
7
|
+
import type {
|
|
8
|
+
CommunicationChannel,
|
|
9
|
+
CommunicationMessageInput,
|
|
10
|
+
CommunicationProbeResult,
|
|
11
|
+
CommunicationResult,
|
|
12
|
+
} from './communications.types';
|
|
13
|
+
|
|
14
|
+
const COMMUNICATIONS_ERROR_CODES = {
|
|
15
|
+
channelsRequired: 'COMMUNICATIONS_CHANNELS_REQUIRED',
|
|
16
|
+
unsupportedChannel: 'COMMUNICATIONS_UNSUPPORTED_CHANNEL',
|
|
17
|
+
} as const;
|
|
18
|
+
|
|
19
|
+
@Injectable()
|
|
20
|
+
export class CommunicationsService {
|
|
21
|
+
constructor(
|
|
22
|
+
private readonly configService: CommunicationsConfigService,
|
|
23
|
+
private readonly emailChannelService: EmailChannelService,
|
|
24
|
+
private readonly smsChannelService: SmsChannelService,
|
|
25
|
+
private readonly pushChannelService: PushChannelService,
|
|
26
|
+
) {}
|
|
27
|
+
|
|
28
|
+
async send(input: CommunicationMessageInput): Promise<CommunicationResult[]> {
|
|
29
|
+
if (!Array.isArray(input.channels) || input.channels.length === 0) {
|
|
30
|
+
throw new BadRequestException({
|
|
31
|
+
message: 'Communication request must include at least one channel',
|
|
32
|
+
details: {
|
|
33
|
+
code: COMMUNICATIONS_ERROR_CODES.channelsRequired,
|
|
34
|
+
},
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return Promise.all(input.channels.map((channel) => this.dispatchChannel(channel, input)));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async sendProbeEmail(email: string): Promise<CommunicationProbeResult> {
|
|
42
|
+
const probeId = crypto.randomUUID();
|
|
43
|
+
const [result] = await this.send({
|
|
44
|
+
kind: 'communications_probe',
|
|
45
|
+
channels: ['email'],
|
|
46
|
+
recipient: { email },
|
|
47
|
+
payload: {
|
|
48
|
+
PROBE_ID: probeId,
|
|
49
|
+
DATE: new Date().toISOString(),
|
|
50
|
+
EMAIL: email,
|
|
51
|
+
},
|
|
52
|
+
metadata: {
|
|
53
|
+
SOURCE: 'communications-health-probe',
|
|
54
|
+
},
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
return {
|
|
58
|
+
probeId,
|
|
59
|
+
recipient: email,
|
|
60
|
+
result,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
getProbeStatus() {
|
|
65
|
+
return {
|
|
66
|
+
status: 'ok',
|
|
67
|
+
feature: 'communications',
|
|
68
|
+
templatesRoot: this.configService.templatesRoot,
|
|
69
|
+
email: {
|
|
70
|
+
provider: this.configService.emailProvider,
|
|
71
|
+
ready: this.configService.emailProviderReady,
|
|
72
|
+
},
|
|
73
|
+
sms: {
|
|
74
|
+
provider: this.configService.smsProvider,
|
|
75
|
+
},
|
|
76
|
+
push: {
|
|
77
|
+
provider: this.configService.pushProvider,
|
|
78
|
+
},
|
|
79
|
+
probeRoutes: {
|
|
80
|
+
inspect: 'GET /api/health/communications',
|
|
81
|
+
send: 'POST /api/health/communications',
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
private dispatchChannel(channel: CommunicationChannel, input: CommunicationMessageInput): Promise<CommunicationResult> {
|
|
87
|
+
switch (channel) {
|
|
88
|
+
case 'email':
|
|
89
|
+
return this.emailChannelService.send(input);
|
|
90
|
+
case 'sms':
|
|
91
|
+
return this.smsChannelService.send(input);
|
|
92
|
+
case 'push':
|
|
93
|
+
return this.pushChannelService.send(input);
|
|
94
|
+
default:
|
|
95
|
+
throw new BadRequestException({
|
|
96
|
+
message: 'Communication channel is not supported',
|
|
97
|
+
details: {
|
|
98
|
+
code: COMMUNICATIONS_ERROR_CODES.unsupportedChannel,
|
|
99
|
+
channel,
|
|
100
|
+
},
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
package/templates/module-presets/communications/packages/communications/src/communications.types.ts
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
export const communicationChannels = ['email', 'sms', 'push'] as const;
|
|
2
|
+
|
|
3
|
+
export type CommunicationChannel = (typeof communicationChannels)[number];
|
|
4
|
+
|
|
5
|
+
export type CommunicationMessageKind =
|
|
6
|
+
| 'email_verification_code'
|
|
7
|
+
| 'password_reset'
|
|
8
|
+
| 'welcome_email'
|
|
9
|
+
| 'login_alert'
|
|
10
|
+
| 'order_status_changed'
|
|
11
|
+
| 'communications_probe'
|
|
12
|
+
| (string & {});
|
|
13
|
+
|
|
14
|
+
export type CommunicationPayloadValue =
|
|
15
|
+
| string
|
|
16
|
+
| number
|
|
17
|
+
| boolean
|
|
18
|
+
| null
|
|
19
|
+
| undefined
|
|
20
|
+
| Date;
|
|
21
|
+
|
|
22
|
+
export interface CommunicationRecipient {
|
|
23
|
+
email?: string;
|
|
24
|
+
phone?: string;
|
|
25
|
+
pushToken?: string;
|
|
26
|
+
userId?: string;
|
|
27
|
+
displayName?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface CommunicationMessageInput {
|
|
31
|
+
kind: CommunicationMessageKind;
|
|
32
|
+
recipient: CommunicationRecipient;
|
|
33
|
+
channels: CommunicationChannel[];
|
|
34
|
+
payload?: Record<string, CommunicationPayloadValue>;
|
|
35
|
+
locale?: string;
|
|
36
|
+
metadata?: Record<string, CommunicationPayloadValue>;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export type CommunicationResultStatus = 'sent' | 'stub' | 'skipped';
|
|
40
|
+
|
|
41
|
+
export interface CommunicationResult {
|
|
42
|
+
kind: string;
|
|
43
|
+
channel: CommunicationChannel;
|
|
44
|
+
provider: string;
|
|
45
|
+
status: CommunicationResultStatus;
|
|
46
|
+
messageId: string | null;
|
|
47
|
+
recipient: string | null;
|
|
48
|
+
metadata?: Record<string, unknown>;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface CommunicationProbeResult {
|
|
52
|
+
probeId: string;
|
|
53
|
+
recipient: string;
|
|
54
|
+
result: CommunicationResult;
|
|
55
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { BadRequestException, Inject, Injectable, Logger } from '@nestjs/common';
|
|
2
|
+
import { COMMUNICATIONS_EMAIL_PROVIDER } from '../communications.constants';
|
|
3
|
+
import { CommunicationsConfigService } from '../communications-config.service';
|
|
4
|
+
import { TemplateLoaderService } from '../template-loader.service';
|
|
5
|
+
import { TemplateRendererService } from '../template-renderer.service';
|
|
6
|
+
import type { CommunicationMessageInput, CommunicationResult } from '../communications.types';
|
|
7
|
+
import type { EmailProvider } from './email-provider.port';
|
|
8
|
+
|
|
9
|
+
const EMAIL_ERROR_CODES = {
|
|
10
|
+
missingRecipient: 'COMMUNICATIONS_EMAIL_RECIPIENT_REQUIRED',
|
|
11
|
+
} as const;
|
|
12
|
+
|
|
13
|
+
@Injectable()
|
|
14
|
+
export class EmailChannelService {
|
|
15
|
+
private readonly logger = new Logger(EmailChannelService.name);
|
|
16
|
+
|
|
17
|
+
constructor(
|
|
18
|
+
private readonly configService: CommunicationsConfigService,
|
|
19
|
+
private readonly templateLoader: TemplateLoaderService,
|
|
20
|
+
private readonly templateRenderer: TemplateRendererService,
|
|
21
|
+
@Inject(COMMUNICATIONS_EMAIL_PROVIDER)
|
|
22
|
+
private readonly emailProvider: EmailProvider,
|
|
23
|
+
) {}
|
|
24
|
+
|
|
25
|
+
async send(input: CommunicationMessageInput): Promise<CommunicationResult> {
|
|
26
|
+
const recipient = input.recipient.email?.trim();
|
|
27
|
+
if (!recipient) {
|
|
28
|
+
throw new BadRequestException({
|
|
29
|
+
message: 'Email channel requires an email recipient',
|
|
30
|
+
details: {
|
|
31
|
+
code: EMAIL_ERROR_CODES.missingRecipient,
|
|
32
|
+
channel: 'email',
|
|
33
|
+
},
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const templateValues = {
|
|
38
|
+
...(input.payload ?? {}),
|
|
39
|
+
...(input.metadata ?? {}),
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const [htmlTemplate, subjectTemplate] = await Promise.all([
|
|
43
|
+
this.templateLoader.loadChannelTemplate('email', input.kind, input.locale),
|
|
44
|
+
this.templateLoader.loadOptionalEmailSubjectTemplate(input.kind, input.locale),
|
|
45
|
+
]);
|
|
46
|
+
|
|
47
|
+
const subject = this.decorateSubject(
|
|
48
|
+
this.templateRenderer.render(subjectTemplate ?? this.humanizeKind(input.kind), templateValues),
|
|
49
|
+
);
|
|
50
|
+
const html = this.templateRenderer.render(htmlTemplate, templateValues);
|
|
51
|
+
|
|
52
|
+
const response = await this.emailProvider.send({
|
|
53
|
+
to: recipient,
|
|
54
|
+
subject,
|
|
55
|
+
html,
|
|
56
|
+
replyTo: this.configService.emailReplyTo,
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
this.logger.log(`communications.email kind=${input.kind} provider=${this.emailProvider.providerId} to=${recipient}`);
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
kind: input.kind,
|
|
63
|
+
channel: 'email',
|
|
64
|
+
provider: this.emailProvider.providerId,
|
|
65
|
+
status: response.status,
|
|
66
|
+
messageId: response.messageId,
|
|
67
|
+
recipient,
|
|
68
|
+
metadata: {
|
|
69
|
+
locale: input.locale ?? null,
|
|
70
|
+
subject,
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
private humanizeKind(kind: string): string {
|
|
76
|
+
return kind
|
|
77
|
+
.split(/[_-]/g)
|
|
78
|
+
.filter(Boolean)
|
|
79
|
+
.map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1))
|
|
80
|
+
.join(' ');
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
private decorateSubject(subject: string): string {
|
|
84
|
+
const prefix = this.configService.emailSubjectPrefix?.trim();
|
|
85
|
+
if (!prefix) {
|
|
86
|
+
return subject;
|
|
87
|
+
}
|
|
88
|
+
return `${prefix} ${subject}`.trim();
|
|
89
|
+
}
|
|
90
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export interface EmailProviderSendInput {
|
|
2
|
+
to: string;
|
|
3
|
+
subject: string;
|
|
4
|
+
html: string;
|
|
5
|
+
replyTo?: string | null;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface EmailProviderSendResult {
|
|
9
|
+
status: 'sent';
|
|
10
|
+
messageId: string | null;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface EmailProvider {
|
|
14
|
+
readonly providerId: string;
|
|
15
|
+
send(input: EmailProviderSendInput): Promise<EmailProviderSendResult>;
|
|
16
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { Injectable, Logger, ServiceUnavailableException } from '@nestjs/common';
|
|
2
|
+
import * as nodemailer from 'nodemailer';
|
|
3
|
+
import type { Transporter } from 'nodemailer';
|
|
4
|
+
import { CommunicationsConfigService } from '../../communications-config.service';
|
|
5
|
+
import type { EmailProvider, EmailProviderSendInput, EmailProviderSendResult } from '../email-provider.port';
|
|
6
|
+
|
|
7
|
+
const EMAIL_ERROR_CODES = {
|
|
8
|
+
providerNotConfigured: 'COMMUNICATIONS_EMAIL_PROVIDER_NOT_CONFIGURED',
|
|
9
|
+
providerSendFailed: 'COMMUNICATIONS_EMAIL_PROVIDER_SEND_FAILED',
|
|
10
|
+
} as const;
|
|
11
|
+
|
|
12
|
+
@Injectable()
|
|
13
|
+
export class GmailSmtpEmailProvider implements EmailProvider {
|
|
14
|
+
private readonly logger = new Logger(GmailSmtpEmailProvider.name);
|
|
15
|
+
private transporter: Transporter | null = null;
|
|
16
|
+
|
|
17
|
+
constructor(private readonly configService: CommunicationsConfigService) {}
|
|
18
|
+
|
|
19
|
+
get providerId(): string {
|
|
20
|
+
return this.configService.emailProvider;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async send(input: EmailProviderSendInput): Promise<EmailProviderSendResult> {
|
|
24
|
+
if (!this.configService.emailProviderReady) {
|
|
25
|
+
throw new ServiceUnavailableException({
|
|
26
|
+
message: 'Email provider is not configured',
|
|
27
|
+
details: {
|
|
28
|
+
code: EMAIL_ERROR_CODES.providerNotConfigured,
|
|
29
|
+
provider: this.providerId,
|
|
30
|
+
},
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
try {
|
|
35
|
+
const response = await this.getTransporter().sendMail({
|
|
36
|
+
from: this.configService.emailFrom,
|
|
37
|
+
to: input.to,
|
|
38
|
+
subject: input.subject,
|
|
39
|
+
html: input.html,
|
|
40
|
+
replyTo: input.replyTo ?? this.configService.emailReplyTo ?? undefined,
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
this.logger.log(`email.sent provider=${this.providerId} to=${input.to} messageId=${response.messageId ?? 'n/a'}`);
|
|
44
|
+
|
|
45
|
+
return {
|
|
46
|
+
status: 'sent',
|
|
47
|
+
messageId: response.messageId ?? null,
|
|
48
|
+
};
|
|
49
|
+
} catch (error) {
|
|
50
|
+
const details = this.extractErrorDetails(error);
|
|
51
|
+
this.logger.error(`email.failed provider=${this.providerId} to=${input.to} details=${JSON.stringify(details)}`);
|
|
52
|
+
throw new ServiceUnavailableException({
|
|
53
|
+
message: 'Email delivery failed',
|
|
54
|
+
details: {
|
|
55
|
+
code: EMAIL_ERROR_CODES.providerSendFailed,
|
|
56
|
+
provider: this.providerId,
|
|
57
|
+
...details,
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
private getTransporter(): Transporter {
|
|
64
|
+
if (!this.transporter) {
|
|
65
|
+
this.transporter = nodemailer.createTransport({
|
|
66
|
+
host: this.configService.emailSmtpHost,
|
|
67
|
+
port: this.configService.emailSmtpPort,
|
|
68
|
+
secure: this.configService.emailSmtpSecure,
|
|
69
|
+
auth: {
|
|
70
|
+
user: this.configService.emailSmtpUser,
|
|
71
|
+
pass: this.configService.emailSmtpPass,
|
|
72
|
+
},
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return this.transporter;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
private extractErrorDetails(error: unknown): Record<string, unknown> {
|
|
80
|
+
if (!error || typeof error !== 'object') {
|
|
81
|
+
return { raw: String(error) };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const candidate = error as {
|
|
85
|
+
code?: unknown;
|
|
86
|
+
command?: unknown;
|
|
87
|
+
response?: unknown;
|
|
88
|
+
responseCode?: unknown;
|
|
89
|
+
errno?: unknown;
|
|
90
|
+
syscall?: unknown;
|
|
91
|
+
message?: unknown;
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
return {
|
|
95
|
+
message: typeof candidate.message === 'string' ? candidate.message : String(error),
|
|
96
|
+
code: candidate.code ?? null,
|
|
97
|
+
command: candidate.command ?? null,
|
|
98
|
+
responseCode: candidate.responseCode ?? null,
|
|
99
|
+
response: candidate.response ?? null,
|
|
100
|
+
errno: candidate.errno ?? null,
|
|
101
|
+
syscall: candidate.syscall ?? null,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { DynamicModule, Global, Module, ModuleMetadata } from '@nestjs/common';
|
|
2
|
+
import {
|
|
3
|
+
COMMUNICATIONS_EMAIL_PROVIDER,
|
|
4
|
+
COMMUNICATIONS_PUSH_PROVIDER,
|
|
5
|
+
COMMUNICATIONS_SMS_PROVIDER,
|
|
6
|
+
} from './communications.constants';
|
|
7
|
+
import { CommunicationsConfigModule } from './communications-config.module';
|
|
8
|
+
import { CommunicationsProbeController } from './communications.probe.controller';
|
|
9
|
+
import { CommunicationsService } from './communications.service';
|
|
10
|
+
import { EmailChannelService } from './email/email-channel.service';
|
|
11
|
+
import { GmailSmtpEmailProvider } from './email/providers/gmail-smtp-email.provider';
|
|
12
|
+
import { PushChannelService } from './push/push-channel.service';
|
|
13
|
+
import { StubPushProvider } from './push/providers/stub-push.provider';
|
|
14
|
+
import { SmsChannelService } from './sms/sms-channel.service';
|
|
15
|
+
import { StubSmsProvider } from './sms/providers/stub-sms.provider';
|
|
16
|
+
import { TemplateLoaderService } from './template-loader.service';
|
|
17
|
+
import { TemplateRendererService } from './template-renderer.service';
|
|
18
|
+
|
|
19
|
+
export interface ForgeonCommunicationsModuleOptions {
|
|
20
|
+
imports?: ModuleMetadata['imports'];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
@Global()
|
|
24
|
+
@Module({})
|
|
25
|
+
export class ForgeonCommunicationsModule {
|
|
26
|
+
static register(options: ForgeonCommunicationsModuleOptions = {}): DynamicModule {
|
|
27
|
+
return {
|
|
28
|
+
module: ForgeonCommunicationsModule,
|
|
29
|
+
imports: [CommunicationsConfigModule, ...(options.imports ?? [])],
|
|
30
|
+
controllers: [CommunicationsProbeController],
|
|
31
|
+
providers: [
|
|
32
|
+
TemplateLoaderService,
|
|
33
|
+
TemplateRendererService,
|
|
34
|
+
CommunicationsService,
|
|
35
|
+
EmailChannelService,
|
|
36
|
+
SmsChannelService,
|
|
37
|
+
PushChannelService,
|
|
38
|
+
GmailSmtpEmailProvider,
|
|
39
|
+
StubSmsProvider,
|
|
40
|
+
StubPushProvider,
|
|
41
|
+
{
|
|
42
|
+
provide: COMMUNICATIONS_EMAIL_PROVIDER,
|
|
43
|
+
useExisting: GmailSmtpEmailProvider,
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
provide: COMMUNICATIONS_SMS_PROVIDER,
|
|
47
|
+
useExisting: StubSmsProvider,
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
provide: COMMUNICATIONS_PUSH_PROVIDER,
|
|
51
|
+
useExisting: StubPushProvider,
|
|
52
|
+
},
|
|
53
|
+
],
|
|
54
|
+
exports: [
|
|
55
|
+
CommunicationsConfigModule,
|
|
56
|
+
TemplateLoaderService,
|
|
57
|
+
TemplateRendererService,
|
|
58
|
+
CommunicationsService,
|
|
59
|
+
COMMUNICATIONS_EMAIL_PROVIDER,
|
|
60
|
+
COMMUNICATIONS_SMS_PROVIDER,
|
|
61
|
+
COMMUNICATIONS_PUSH_PROVIDER,
|
|
62
|
+
],
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export * from './communications-config.loader';
|
|
2
|
+
export * from './communications-config.module';
|
|
3
|
+
export * from './communications-config.service';
|
|
4
|
+
export * from './communications-env.schema';
|
|
5
|
+
export * from './communications.probe.controller';
|
|
6
|
+
export * from './communications.service';
|
|
7
|
+
export * from './communications.types';
|
|
8
|
+
export * from './communications.constants';
|
|
9
|
+
export * from './dto/send-communications-probe.dto';
|
|
10
|
+
export * from './email/email-channel.service';
|
|
11
|
+
export * from './email/email-provider.port';
|
|
12
|
+
export * from './email/providers/gmail-smtp-email.provider';
|
|
13
|
+
export * from './forgeon-communications.module';
|
|
14
|
+
export * from './push/providers/stub-push.provider';
|
|
15
|
+
export * from './push/push-channel.service';
|
|
16
|
+
export * from './push/push-provider.port';
|
|
17
|
+
export * from './sms/providers/stub-sms.provider';
|
|
18
|
+
export * from './sms/sms-channel.service';
|
|
19
|
+
export * from './sms/sms-provider.port';
|
|
20
|
+
export * from './template-loader.service';
|
|
21
|
+
export * from './template-renderer.service';
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { Injectable, Logger } from '@nestjs/common';
|
|
2
|
+
import type { PushProvider, PushProviderSendInput, PushProviderSendResult } from '../push-provider.port';
|
|
3
|
+
|
|
4
|
+
@Injectable()
|
|
5
|
+
export class StubPushProvider implements PushProvider {
|
|
6
|
+
private readonly logger = new Logger(StubPushProvider.name);
|
|
7
|
+
readonly providerId = 'stub';
|
|
8
|
+
|
|
9
|
+
async send(input: PushProviderSendInput): Promise<PushProviderSendResult> {
|
|
10
|
+
this.logger.warn(`communications.push.stub token=${input.pushToken} length=${input.body.length}`);
|
|
11
|
+
return {
|
|
12
|
+
status: 'stub',
|
|
13
|
+
messageId: null,
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { BadRequestException, Inject, Injectable, Logger } from '@nestjs/common';
|
|
2
|
+
import { COMMUNICATIONS_PUSH_PROVIDER } from '../communications.constants';
|
|
3
|
+
import { TemplateLoaderService } from '../template-loader.service';
|
|
4
|
+
import { TemplateRendererService } from '../template-renderer.service';
|
|
5
|
+
import type { CommunicationMessageInput, CommunicationResult } from '../communications.types';
|
|
6
|
+
import type { PushProvider } from './push-provider.port';
|
|
7
|
+
|
|
8
|
+
const PUSH_ERROR_CODES = {
|
|
9
|
+
missingRecipient: 'COMMUNICATIONS_PUSH_RECIPIENT_REQUIRED',
|
|
10
|
+
} as const;
|
|
11
|
+
|
|
12
|
+
@Injectable()
|
|
13
|
+
export class PushChannelService {
|
|
14
|
+
private readonly logger = new Logger(PushChannelService.name);
|
|
15
|
+
|
|
16
|
+
constructor(
|
|
17
|
+
private readonly templateLoader: TemplateLoaderService,
|
|
18
|
+
private readonly templateRenderer: TemplateRendererService,
|
|
19
|
+
@Inject(COMMUNICATIONS_PUSH_PROVIDER)
|
|
20
|
+
private readonly pushProvider: PushProvider,
|
|
21
|
+
) {}
|
|
22
|
+
|
|
23
|
+
async send(input: CommunicationMessageInput): Promise<CommunicationResult> {
|
|
24
|
+
const recipient = input.recipient.pushToken?.trim();
|
|
25
|
+
if (!recipient) {
|
|
26
|
+
throw new BadRequestException({
|
|
27
|
+
message: 'Push channel requires a push token recipient',
|
|
28
|
+
details: {
|
|
29
|
+
code: PUSH_ERROR_CODES.missingRecipient,
|
|
30
|
+
channel: 'push',
|
|
31
|
+
},
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const template = await this.templateLoader.loadChannelTemplate('push', input.kind, input.locale);
|
|
36
|
+
const body = this.templateRenderer.render(template, {
|
|
37
|
+
...(input.payload ?? {}),
|
|
38
|
+
...(input.metadata ?? {}),
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
const response = await this.pushProvider.send({ pushToken: recipient, body });
|
|
42
|
+
this.logger.warn(`communications.push kind=${input.kind} provider=${this.pushProvider.providerId}`);
|
|
43
|
+
|
|
44
|
+
return {
|
|
45
|
+
kind: input.kind,
|
|
46
|
+
channel: 'push',
|
|
47
|
+
provider: this.pushProvider.providerId,
|
|
48
|
+
status: response.status,
|
|
49
|
+
messageId: response.messageId,
|
|
50
|
+
recipient,
|
|
51
|
+
metadata: {
|
|
52
|
+
locale: input.locale ?? null,
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export interface PushProviderSendInput {
|
|
2
|
+
pushToken: string;
|
|
3
|
+
body: string;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export interface PushProviderSendResult {
|
|
7
|
+
status: 'stub' | 'sent';
|
|
8
|
+
messageId: string | null;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface PushProvider {
|
|
12
|
+
readonly providerId: string;
|
|
13
|
+
send(input: PushProviderSendInput): Promise<PushProviderSendResult>;
|
|
14
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { Injectable, Logger } from '@nestjs/common';
|
|
2
|
+
import type { SmsProvider, SmsProviderSendInput, SmsProviderSendResult } from '../sms-provider.port';
|
|
3
|
+
|
|
4
|
+
@Injectable()
|
|
5
|
+
export class StubSmsProvider implements SmsProvider {
|
|
6
|
+
private readonly logger = new Logger(StubSmsProvider.name);
|
|
7
|
+
readonly providerId = 'stub';
|
|
8
|
+
|
|
9
|
+
async send(input: SmsProviderSendInput): Promise<SmsProviderSendResult> {
|
|
10
|
+
this.logger.warn(`communications.sms.stub phone=${input.phone} length=${input.text.length}`);
|
|
11
|
+
return {
|
|
12
|
+
status: 'stub',
|
|
13
|
+
messageId: null,
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
}
|