create-forgeon 0.3.23 → 0.3.24
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 +232 -0
- package/src/modules/dependencies.test.mjs +16 -2
- package/src/modules/executor.mjs +4 -0
- package/src/modules/executor.test.mjs +5 -2
- package/src/modules/registry.mjs +18 -2
- 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 +20 -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 +2 -6
- 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 +58 -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 +24 -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 +64 -0
- package/templates/module-presets/communications/packages/communications/src/forgeon-communications.module.ts +64 -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,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,64 @@
|
|
|
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
|
+
} as const;
|
|
10
|
+
|
|
11
|
+
@Injectable()
|
|
12
|
+
export class GmailSmtpEmailProvider implements EmailProvider {
|
|
13
|
+
private readonly logger = new Logger(GmailSmtpEmailProvider.name);
|
|
14
|
+
private transporter: Transporter | null = null;
|
|
15
|
+
|
|
16
|
+
constructor(private readonly configService: CommunicationsConfigService) {}
|
|
17
|
+
|
|
18
|
+
get providerId(): string {
|
|
19
|
+
return this.configService.emailProvider;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async send(input: EmailProviderSendInput): Promise<EmailProviderSendResult> {
|
|
23
|
+
if (!this.configService.emailProviderReady) {
|
|
24
|
+
throw new ServiceUnavailableException({
|
|
25
|
+
message: 'Email provider is not configured',
|
|
26
|
+
details: {
|
|
27
|
+
code: EMAIL_ERROR_CODES.providerNotConfigured,
|
|
28
|
+
provider: this.providerId,
|
|
29
|
+
},
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const response = await this.getTransporter().sendMail({
|
|
34
|
+
from: this.configService.emailFrom,
|
|
35
|
+
to: input.to,
|
|
36
|
+
subject: input.subject,
|
|
37
|
+
html: input.html,
|
|
38
|
+
replyTo: input.replyTo ?? this.configService.emailReplyTo ?? undefined,
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
this.logger.log(`email.sent provider=${this.providerId} to=${input.to} messageId=${response.messageId ?? 'n/a'}`);
|
|
42
|
+
|
|
43
|
+
return {
|
|
44
|
+
status: 'sent',
|
|
45
|
+
messageId: response.messageId ?? null,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
private getTransporter(): Transporter {
|
|
50
|
+
if (!this.transporter) {
|
|
51
|
+
this.transporter = nodemailer.createTransport({
|
|
52
|
+
host: this.configService.emailSmtpHost,
|
|
53
|
+
port: this.configService.emailSmtpPort,
|
|
54
|
+
secure: this.configService.emailSmtpSecure,
|
|
55
|
+
auth: {
|
|
56
|
+
user: this.configService.emailSmtpUser,
|
|
57
|
+
pass: this.configService.emailSmtpPass,
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return this.transporter;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { DynamicModule, 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
|
+
@Module({})
|
|
24
|
+
export class ForgeonCommunicationsModule {
|
|
25
|
+
static register(options: ForgeonCommunicationsModuleOptions = {}): DynamicModule {
|
|
26
|
+
return {
|
|
27
|
+
module: ForgeonCommunicationsModule,
|
|
28
|
+
imports: [CommunicationsConfigModule, ...(options.imports ?? [])],
|
|
29
|
+
controllers: [CommunicationsProbeController],
|
|
30
|
+
providers: [
|
|
31
|
+
TemplateLoaderService,
|
|
32
|
+
TemplateRendererService,
|
|
33
|
+
CommunicationsService,
|
|
34
|
+
EmailChannelService,
|
|
35
|
+
SmsChannelService,
|
|
36
|
+
PushChannelService,
|
|
37
|
+
GmailSmtpEmailProvider,
|
|
38
|
+
StubSmsProvider,
|
|
39
|
+
StubPushProvider,
|
|
40
|
+
{
|
|
41
|
+
provide: COMMUNICATIONS_EMAIL_PROVIDER,
|
|
42
|
+
useExisting: GmailSmtpEmailProvider,
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
provide: COMMUNICATIONS_SMS_PROVIDER,
|
|
46
|
+
useExisting: StubSmsProvider,
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
provide: COMMUNICATIONS_PUSH_PROVIDER,
|
|
50
|
+
useExisting: StubPushProvider,
|
|
51
|
+
},
|
|
52
|
+
],
|
|
53
|
+
exports: [
|
|
54
|
+
CommunicationsConfigModule,
|
|
55
|
+
TemplateLoaderService,
|
|
56
|
+
TemplateRendererService,
|
|
57
|
+
CommunicationsService,
|
|
58
|
+
COMMUNICATIONS_EMAIL_PROVIDER,
|
|
59
|
+
COMMUNICATIONS_SMS_PROVIDER,
|
|
60
|
+
COMMUNICATIONS_PUSH_PROVIDER,
|
|
61
|
+
],
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { BadRequestException, Inject, Injectable, Logger } from '@nestjs/common';
|
|
2
|
+
import { COMMUNICATIONS_SMS_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 { SmsProvider } from './sms-provider.port';
|
|
7
|
+
|
|
8
|
+
const SMS_ERROR_CODES = {
|
|
9
|
+
missingRecipient: 'COMMUNICATIONS_SMS_RECIPIENT_REQUIRED',
|
|
10
|
+
} as const;
|
|
11
|
+
|
|
12
|
+
@Injectable()
|
|
13
|
+
export class SmsChannelService {
|
|
14
|
+
private readonly logger = new Logger(SmsChannelService.name);
|
|
15
|
+
|
|
16
|
+
constructor(
|
|
17
|
+
private readonly templateLoader: TemplateLoaderService,
|
|
18
|
+
private readonly templateRenderer: TemplateRendererService,
|
|
19
|
+
@Inject(COMMUNICATIONS_SMS_PROVIDER)
|
|
20
|
+
private readonly smsProvider: SmsProvider,
|
|
21
|
+
) {}
|
|
22
|
+
|
|
23
|
+
async send(input: CommunicationMessageInput): Promise<CommunicationResult> {
|
|
24
|
+
const recipient = input.recipient.phone?.trim();
|
|
25
|
+
if (!recipient) {
|
|
26
|
+
throw new BadRequestException({
|
|
27
|
+
message: 'SMS channel requires a phone recipient',
|
|
28
|
+
details: {
|
|
29
|
+
code: SMS_ERROR_CODES.missingRecipient,
|
|
30
|
+
channel: 'sms',
|
|
31
|
+
},
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const template = await this.templateLoader.loadChannelTemplate('sms', input.kind, input.locale);
|
|
36
|
+
const text = this.templateRenderer.render(template, {
|
|
37
|
+
...(input.payload ?? {}),
|
|
38
|
+
...(input.metadata ?? {}),
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
const response = await this.smsProvider.send({ phone: recipient, text });
|
|
42
|
+
this.logger.warn(`communications.sms kind=${input.kind} provider=${this.smsProvider.providerId} phone=${recipient}`);
|
|
43
|
+
|
|
44
|
+
return {
|
|
45
|
+
kind: input.kind,
|
|
46
|
+
channel: 'sms',
|
|
47
|
+
provider: this.smsProvider.providerId,
|
|
48
|
+
status: response.status,
|
|
49
|
+
messageId: response.messageId,
|
|
50
|
+
recipient,
|
|
51
|
+
metadata: {
|
|
52
|
+
locale: input.locale ?? null,
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
}
|
package/templates/module-presets/communications/packages/communications/src/sms/sms-provider.port.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export interface SmsProviderSendInput {
|
|
2
|
+
phone: string;
|
|
3
|
+
text: string;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export interface SmsProviderSendResult {
|
|
7
|
+
status: 'stub' | 'sent';
|
|
8
|
+
messageId: string | null;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface SmsProvider {
|
|
12
|
+
readonly providerId: string;
|
|
13
|
+
send(input: SmsProviderSendInput): Promise<SmsProviderSendResult>;
|
|
14
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import * as fs from 'node:fs';
|
|
2
|
+
import * as path from 'node:path';
|
|
3
|
+
import { promises as fsPromises } from 'node:fs';
|
|
4
|
+
import { BadRequestException, Injectable, InternalServerErrorException } from '@nestjs/common';
|
|
5
|
+
import type { CommunicationChannel } from './communications.types';
|
|
6
|
+
import { CommunicationsConfigService } from './communications-config.service';
|
|
7
|
+
|
|
8
|
+
type TemplateVariant = 'body' | 'subject';
|
|
9
|
+
|
|
10
|
+
const CHANNEL_EXTENSIONS: Record<CommunicationChannel, string> = {
|
|
11
|
+
email: '.html',
|
|
12
|
+
sms: '.txt',
|
|
13
|
+
push: '.json',
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const SUBJECT_EXTENSION = '.subject.txt';
|
|
17
|
+
const TEMPLATE_ERROR_CODES = {
|
|
18
|
+
invalidKey: 'COMMUNICATIONS_TEMPLATE_INVALID_KEY',
|
|
19
|
+
missing: 'COMMUNICATIONS_TEMPLATE_MISSING',
|
|
20
|
+
} as const;
|
|
21
|
+
|
|
22
|
+
@Injectable()
|
|
23
|
+
export class TemplateLoaderService {
|
|
24
|
+
constructor(private readonly configService: CommunicationsConfigService) {}
|
|
25
|
+
|
|
26
|
+
async loadChannelTemplate(
|
|
27
|
+
channel: CommunicationChannel,
|
|
28
|
+
kind: string,
|
|
29
|
+
locale?: string,
|
|
30
|
+
): Promise<string> {
|
|
31
|
+
const relativePath = this.resolveTemplateRelativePath(channel, kind, 'body', locale);
|
|
32
|
+
return this.readTemplate(relativePath, channel, kind);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async loadOptionalEmailSubjectTemplate(kind: string, locale?: string): Promise<string | null> {
|
|
36
|
+
const relativePath = this.resolveTemplateRelativePath('email', kind, 'subject', locale);
|
|
37
|
+
const absolutePath = path.join(this.configService.templatesRoot, relativePath);
|
|
38
|
+
if (!fs.existsSync(absolutePath)) {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return fsPromises.readFile(absolutePath, 'utf8');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
private resolveTemplateRelativePath(
|
|
46
|
+
channel: CommunicationChannel,
|
|
47
|
+
kind: string,
|
|
48
|
+
variant: TemplateVariant,
|
|
49
|
+
locale?: string,
|
|
50
|
+
): string {
|
|
51
|
+
const safeKind = this.sanitizeSegment(kind, 'kind');
|
|
52
|
+
const safeLocale = locale ? this.sanitizeSegment(locale, 'locale') : null;
|
|
53
|
+
const extension = variant === 'subject' ? SUBJECT_EXTENSION : CHANNEL_EXTENSIONS[channel];
|
|
54
|
+
|
|
55
|
+
if (safeLocale) {
|
|
56
|
+
const localizedPath = path.join(channel, `${safeKind}.${safeLocale}${extension}`);
|
|
57
|
+
const absoluteLocalizedPath = path.join(this.configService.templatesRoot, localizedPath);
|
|
58
|
+
if (fs.existsSync(absoluteLocalizedPath)) {
|
|
59
|
+
return localizedPath;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return path.join(channel, `${safeKind}${extension}`);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
private async readTemplate(relativePath: string, channel: CommunicationChannel, kind: string): Promise<string> {
|
|
67
|
+
const absolutePath = path.join(this.configService.templatesRoot, relativePath);
|
|
68
|
+
if (!fs.existsSync(absolutePath)) {
|
|
69
|
+
throw new InternalServerErrorException({
|
|
70
|
+
message: 'Communication template was not found',
|
|
71
|
+
details: {
|
|
72
|
+
code: TEMPLATE_ERROR_CODES.missing,
|
|
73
|
+
channel,
|
|
74
|
+
kind,
|
|
75
|
+
relativePath,
|
|
76
|
+
},
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return fsPromises.readFile(absolutePath, 'utf8');
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
private sanitizeSegment(value: string, field: 'kind' | 'locale'): string {
|
|
84
|
+
const normalized = value.trim();
|
|
85
|
+
if (!/^[a-z0-9._-]+$/i.test(normalized)) {
|
|
86
|
+
throw new BadRequestException({
|
|
87
|
+
message: `Communication ${field} contains unsupported characters`,
|
|
88
|
+
details: {
|
|
89
|
+
code: TEMPLATE_ERROR_CODES.invalidKey,
|
|
90
|
+
field,
|
|
91
|
+
value,
|
|
92
|
+
},
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return normalized;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { Injectable } from '@nestjs/common';
|
|
2
|
+
import type { CommunicationPayloadValue } from './communications.types';
|
|
3
|
+
|
|
4
|
+
@Injectable()
|
|
5
|
+
export class TemplateRendererService {
|
|
6
|
+
render(template: string, values: Record<string, CommunicationPayloadValue> = {}): string {
|
|
7
|
+
let output = template;
|
|
8
|
+
|
|
9
|
+
for (const [rawKey, rawValue] of Object.entries(values)) {
|
|
10
|
+
const replacement = this.stringifyValue(rawValue);
|
|
11
|
+
const normalizedKeys = new Set([rawKey, rawKey.toUpperCase()]);
|
|
12
|
+
|
|
13
|
+
for (const key of normalizedKeys) {
|
|
14
|
+
output = output.split(`$${key}$`).join(replacement);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
return output;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
private stringifyValue(value: CommunicationPayloadValue): string {
|
|
22
|
+
if (value instanceof Date) {
|
|
23
|
+
return value.toISOString();
|
|
24
|
+
}
|
|
25
|
+
if (value === null || value === undefined) {
|
|
26
|
+
return '';
|
|
27
|
+
}
|
|
28
|
+
return String(value);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
<html>
|
|
2
|
+
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #111827;">
|
|
3
|
+
<h1>Communications Probe</h1>
|
|
4
|
+
<p>This is a test message from the Forgeon communications module.</p>
|
|
5
|
+
<p>Recipient: <strong>$EMAIL$</strong></p>
|
|
6
|
+
<p>Probe id: <strong>$PROBE_ID$</strong></p>
|
|
7
|
+
<p>Generated at: <strong>$DATE$</strong></p>
|
|
8
|
+
</body>
|
|
9
|
+
</html>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
Communications probe
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
<html>
|
|
2
|
+
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #111827;">
|
|
3
|
+
<h1>Email Verification</h1>
|
|
4
|
+
<p>Hello $NAME$,</p>
|
|
5
|
+
<p>Your verification code is <strong>$CODE$</strong>.</p>
|
|
6
|
+
<p>If you did not request this, you can safely ignore this message.</p>
|
|
7
|
+
</body>
|
|
8
|
+
</html>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
Verify your email address
|
package/templates/module-presets/communications/resources/communications/email/password_reset.html
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
<html>
|
|
2
|
+
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #111827;">
|
|
3
|
+
<h1>Password Reset</h1>
|
|
4
|
+
<p>Hello $NAME$,</p>
|
|
5
|
+
<p>Use this reset token to continue: <strong>$TOKEN$</strong>.</p>
|
|
6
|
+
<p>If you did not request a password reset, please ignore this email.</p>
|
|
7
|
+
</body>
|
|
8
|
+
</html>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
Reset your password
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
Welcome to Forgeon
|
package/templates/module-presets/communications/resources/communications/sms/phone_verification.txt
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
Phone verification code: $CODE$
|