create-forgeon 0.3.22 → 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.
Files changed (64) hide show
  1. package/README.md +14 -12
  2. package/package.json +1 -1
  3. package/src/modules/accounts.mjs +3 -1
  4. package/src/modules/communications.mjs +232 -0
  5. package/src/modules/dependencies.test.mjs +16 -2
  6. package/src/modules/executor.mjs +4 -0
  7. package/src/modules/executor.test.mjs +60 -31
  8. package/src/modules/files-access.mjs +25 -1
  9. package/src/modules/registry.mjs +18 -2
  10. package/src/modules/shared/probes.mjs +3 -1
  11. package/src/run-add-module.test.mjs +4 -0
  12. package/templates/base/apps/web/src/App.tsx +75 -11
  13. package/templates/base/apps/web/src/probes.ts +11 -1
  14. package/templates/base/apps/web/src/styles.css +25 -0
  15. package/templates/module-fragments/accounts/20_scope.md +3 -2
  16. package/templates/module-fragments/accounts/90_status_implemented.md +2 -2
  17. package/templates/module-fragments/accounts/90_status_planned.md +1 -1
  18. package/templates/module-fragments/communications/00_title.md +1 -0
  19. package/templates/module-fragments/communications/10_overview.md +6 -0
  20. package/templates/module-fragments/communications/20_scope.md +20 -0
  21. package/templates/module-fragments/communications/90_status_implemented.md +8 -0
  22. package/templates/module-presets/accounts/packages/accounts-api/package.json +1 -0
  23. package/templates/module-presets/accounts/packages/accounts-api/src/auth-core.service.ts +60 -20
  24. package/templates/module-presets/accounts/packages/accounts-api/src/forgeon-accounts.module.ts +2 -6
  25. package/templates/module-presets/accounts/packages/accounts-api/src/index.ts +1 -1
  26. package/templates/module-presets/communications/packages/communications/package.json +23 -0
  27. package/templates/module-presets/communications/packages/communications/src/communications-config.loader.ts +58 -0
  28. package/templates/module-presets/communications/packages/communications/src/communications-config.module.ts +11 -0
  29. package/templates/module-presets/communications/packages/communications/src/communications-config.service.ts +60 -0
  30. package/templates/module-presets/communications/packages/communications/src/communications-env.schema.ts +24 -0
  31. package/templates/module-presets/communications/packages/communications/src/communications.constants.ts +3 -0
  32. package/templates/module-presets/communications/packages/communications/src/communications.probe.controller.ts +18 -0
  33. package/templates/module-presets/communications/packages/communications/src/communications.service.ts +104 -0
  34. package/templates/module-presets/communications/packages/communications/src/communications.types.ts +55 -0
  35. package/templates/module-presets/communications/packages/communications/src/dto/send-communications-probe.dto.ts +6 -0
  36. package/templates/module-presets/communications/packages/communications/src/email/email-channel.service.ts +90 -0
  37. package/templates/module-presets/communications/packages/communications/src/email/email-provider.port.ts +16 -0
  38. package/templates/module-presets/communications/packages/communications/src/email/providers/gmail-smtp-email.provider.ts +64 -0
  39. package/templates/module-presets/communications/packages/communications/src/forgeon-communications.module.ts +64 -0
  40. package/templates/module-presets/communications/packages/communications/src/index.ts +21 -0
  41. package/templates/module-presets/communications/packages/communications/src/push/providers/stub-push.provider.ts +16 -0
  42. package/templates/module-presets/communications/packages/communications/src/push/push-channel.service.ts +56 -0
  43. package/templates/module-presets/communications/packages/communications/src/push/push-provider.port.ts +14 -0
  44. package/templates/module-presets/communications/packages/communications/src/sms/providers/stub-sms.provider.ts +16 -0
  45. package/templates/module-presets/communications/packages/communications/src/sms/sms-channel.service.ts +56 -0
  46. package/templates/module-presets/communications/packages/communications/src/sms/sms-provider.port.ts +14 -0
  47. package/templates/module-presets/communications/packages/communications/src/template-loader.service.ts +98 -0
  48. package/templates/module-presets/communications/packages/communications/src/template-renderer.service.ts +30 -0
  49. package/templates/module-presets/communications/packages/communications/tsconfig.json +9 -0
  50. package/templates/module-presets/communications/resources/communications/email/communications_probe.html +9 -0
  51. package/templates/module-presets/communications/resources/communications/email/communications_probe.subject.txt +1 -0
  52. package/templates/module-presets/communications/resources/communications/email/email_verification_code.html +8 -0
  53. package/templates/module-presets/communications/resources/communications/email/email_verification_code.subject.txt +1 -0
  54. package/templates/module-presets/communications/resources/communications/email/password_reset.html +8 -0
  55. package/templates/module-presets/communications/resources/communications/email/password_reset.subject.txt +1 -0
  56. package/templates/module-presets/communications/resources/communications/email/welcome_email.html +8 -0
  57. package/templates/module-presets/communications/resources/communications/email/welcome_email.subject.txt +1 -0
  58. package/templates/module-presets/communications/resources/communications/push/login_alert.json +4 -0
  59. package/templates/module-presets/communications/resources/communications/sms/phone_verification.txt +1 -0
  60. package/templates/module-presets/files-quotas/packages/files-quotas/package.json +21 -20
  61. package/templates/module-presets/files-quotas/packages/files-quotas/src/files-quotas.service.ts +118 -118
  62. package/templates/module-presets/files-quotas/packages/files-quotas/src/forgeon-files-quotas.module.ts +21 -19
  63. package/templates/module-presets/i18n/apps/web/src/App.tsx +72 -8
  64. package/templates/module-presets/accounts/packages/accounts-api/src/accounts-email.port.ts +0 -13
@@ -1,5 +1,5 @@
1
1
  import { useState } from 'react';
2
- import { probeDefinitions, type ProbeDefinition, type ProbeResult } from './probes';
2
+ import { probeDefinitions, type ProbeDefinition, type ProbeInputDefinition, type ProbeResult } from './probes';
3
3
  import './styles.css';
4
4
 
5
5
  type ProbeState = {
@@ -8,34 +8,83 @@ type ProbeState = {
8
8
  loading: boolean;
9
9
  };
10
10
 
11
+ type ProbeInputState = Record<string, string>;
12
+
11
13
  const emptyProbeState: ProbeState = {
12
14
  result: null,
13
15
  error: null,
14
16
  loading: false,
15
17
  };
16
18
 
19
+ function resolveBodyTemplate(value: unknown, inputs: ProbeInputState): unknown {
20
+ if (typeof value === 'string') {
21
+ const match = value.match(/^\$INPUT\.([a-zA-Z0-9_-]+)\$$/);
22
+ if (match) {
23
+ return inputs[match[1]] ?? '';
24
+ }
25
+ return value;
26
+ }
27
+
28
+ if (Array.isArray(value)) {
29
+ return value.map((item) => resolveBodyTemplate(item, inputs));
30
+ }
31
+
32
+ if (value && typeof value === 'object') {
33
+ return Object.fromEntries(
34
+ Object.entries(value).map(([key, nestedValue]) => [key, resolveBodyTemplate(nestedValue, inputs)]),
35
+ );
36
+ }
37
+
38
+ return value;
39
+ }
40
+
17
41
  export default function App() {
18
42
  const [probeState, setProbeState] = useState<Record<string, ProbeState>>({});
43
+ const [probeInputs, setProbeInputs] = useState<Record<string, ProbeInputState>>({});
44
+
45
+ const getProbeInputValue = (probeId: string, input: ProbeInputDefinition): string => {
46
+ return probeInputs[probeId]?.[input.id] ?? input.defaultValue ?? '';
47
+ };
48
+
49
+ const updateProbeInput = (probeId: string, inputId: string, value: string) => {
50
+ setProbeInputs((current) => ({
51
+ ...current,
52
+ [probeId]: {
53
+ ...(current[probeId] ?? {}),
54
+ [inputId]: value,
55
+ },
56
+ }));
57
+ };
19
58
 
20
59
  const requestProbe = async (probe: ProbeDefinition): Promise<ProbeResult> => {
21
- const response = await fetch(`/api${probe.path}`, {
22
- ...(probe.request ?? {}),
60
+ const method = probe.request?.method ?? 'GET';
61
+ const headers: Record<string, string> = {
62
+ ...(probe.request?.headers ?? {}),
63
+ };
64
+
65
+ const requestInit: RequestInit = {
66
+ method,
23
67
  cache: 'no-store',
24
- headers: {
25
- ...(probe.request?.headers ?? {}),
26
- },
27
- });
28
- let body: unknown = null;
68
+ headers,
69
+ };
70
+
71
+ if (method !== 'GET' && probe.request?.body !== undefined) {
72
+ headers['Content-Type'] = 'application/json';
73
+ requestInit.body = JSON.stringify(resolveBodyTemplate(probe.request.body, probeInputs[probe.id] ?? {}));
74
+ }
75
+
76
+ const response = await fetch(`/api${probe.path}`, requestInit);
77
+ let responseBody: unknown = null;
29
78
 
30
79
  try {
31
- body = await response.json();
80
+ responseBody = await response.json();
32
81
  } catch {
33
- body = { message: 'Non-JSON response' };
82
+ responseBody = { message: 'Non-JSON response' };
34
83
  }
35
84
 
36
85
  return {
37
86
  statusCode: response.status,
38
- body,
87
+ body: responseBody,
39
88
  };
40
89
  };
41
90
 
@@ -87,6 +136,21 @@ export default function App() {
87
136
  {current.loading ? 'Running...' : probe.buttonLabel}
88
137
  </button>
89
138
  </div>
139
+ {probe.inputs?.length ? (
140
+ <div className="probe-inputs">
141
+ {probe.inputs.map((input) => (
142
+ <label key={`${probe.id}-${input.id}`} className="probe-input">
143
+ <span>{input.label}</span>
144
+ <input
145
+ type={input.type ?? 'text'}
146
+ value={getProbeInputValue(probe.id, input)}
147
+ placeholder={input.placeholder}
148
+ onChange={(event) => updateProbeInput(probe.id, input.id, event.target.value)}
149
+ />
150
+ </label>
151
+ ))}
152
+ </div>
153
+ ) : null}
90
154
  <div className="probe-output">
91
155
  <h3>{probe.resultTitle}</h3>
92
156
  {current.error ? <p className="error">{current.error}</p> : null}
@@ -1,11 +1,20 @@
1
- export type ProbeResult = {
1
+ export type ProbeResult = {
2
2
  statusCode: number;
3
3
  body: unknown;
4
4
  };
5
5
 
6
+ export type ProbeInputDefinition = {
7
+ id: string;
8
+ label: string;
9
+ type?: 'text' | 'email';
10
+ placeholder?: string;
11
+ defaultValue?: string;
12
+ };
13
+
6
14
  export type ProbeRequest = {
7
15
  method?: 'GET' | 'POST';
8
16
  headers?: Record<string, string>;
17
+ body?: unknown;
9
18
  };
10
19
 
11
20
  export type ProbeDefinition = {
@@ -16,6 +25,7 @@ export type ProbeDefinition = {
16
25
  resultTitle: string;
17
26
  path: string;
18
27
  request?: ProbeRequest;
28
+ inputs?: ProbeInputDefinition[];
19
29
  };
20
30
 
21
31
  const baseProbeDefinitions: ProbeDefinition[] = [
@@ -104,3 +104,28 @@ select {
104
104
  align-items: start;
105
105
  }
106
106
  }
107
+
108
+ .probe-inputs {
109
+ display: grid;
110
+ gap: 0.75rem;
111
+ }
112
+
113
+ .probe-input {
114
+ display: grid;
115
+ gap: 0.35rem;
116
+ margin: 0;
117
+ }
118
+
119
+ .probe-input span {
120
+ font-size: 0.9rem;
121
+ font-weight: 600;
122
+ }
123
+
124
+ .probe-input input {
125
+ width: 100%;
126
+ box-sizing: border-box;
127
+ padding: 0.65rem 0.75rem;
128
+ border-radius: 0.6rem;
129
+ border: 1px solid #cbd5e1;
130
+ background: #ffffff;
131
+ }
@@ -5,10 +5,11 @@ Implemented scope:
5
5
  1. Public installer surface:
6
6
  - single umbrella add-module: `accounts`
7
7
  - requires `db-adapter`
8
+ - requires `communications-runtime`
8
9
  2. Internal runtime split:
9
10
  - `@forgeon/accounts-contracts`
10
11
  - `@forgeon/accounts-api`
11
- - users core, auth core, auth-jwt, auth-password, email stub port/adapter
12
+ - users core, auth core, auth-jwt, auth-password
12
13
  3. API runtime:
13
14
  - `POST /api/auth/register`
14
15
  - `POST /api/auth/login`
@@ -16,7 +17,7 @@ Implemented scope:
16
17
  - `POST /api/auth/logout`
17
18
  - `GET /api/auth/me`
18
19
  - `POST /api/auth/change-password`
19
- - stub endpoints for verify-email and password reset
20
+ - stub endpoints for verify-email and password reset confirmation
20
21
  4. Users surface:
21
22
  - owner-scoped routes under `/api/users/:id`, `/api/users/:id/profile`, `/api/users/:id/settings`
22
23
  - `/users/me` is resolved through the same owner-scoped route surface
@@ -3,6 +3,6 @@
3
3
  Status: implemented.
4
4
 
5
5
  Notes:
6
- - `accounts` is a hard consumer of the `db-adapter` capability.
6
+ - `accounts` is a hard consumer of the `db-adapter` and `communications-runtime` capabilities.
7
7
  - The base accounts schema does not store RBAC roles or permissions.
8
- - Email verification and password reset are routed through an internal email stub boundary until the public `emails` module is implemented.
8
+ - Registration and password-reset request flows send best-effort communication intents through `CommunicationsService`.
@@ -4,4 +4,4 @@ Status: planned.
4
4
 
5
5
  Notes:
6
6
  - public provider add-modules and DB-backed RBAC authz storage are intentionally deferred
7
- - the future `emails` module is expected to plug into the existing `AccountsEmailPort`
7
+ - verification and password-reset token lifecycle storage remains a follow-up iteration
@@ -0,0 +1 @@
1
+ # {{MODULE_LABEL}}
@@ -0,0 +1,6 @@
1
+ ## Overview
2
+
3
+ - Id: `{{MODULE_ID}}`
4
+ - Category: `{{MODULE_CATEGORY}}`
5
+ - Status: {{MODULE_STATUS}}
6
+ - Description: {{MODULE_DESCRIPTION}}
@@ -0,0 +1,20 @@
1
+ ## Scope
2
+
3
+ Implemented scope:
4
+
5
+ 1. Public installer surface:
6
+ - single add-module: `communications`
7
+ 2. Runtime package:
8
+ - `@forgeon/communications`
9
+ 3. Core behavior:
10
+ - `CommunicationsService` orchestration
11
+ - file-based template loading from `resources/communications/*`
12
+ - simple `$PLACEHOLDER$` rendering
13
+ 4. Channel support:
14
+ - email channel with Gmail SMTP transport configuration
15
+ - sms stub channel
16
+ - push stub channel
17
+ 5. Module checks:
18
+ - `GET /api/health/communications`
19
+ - `POST /api/health/communications`
20
+ - default web probe with email input + test send
@@ -0,0 +1,8 @@
1
+ ## Current State
2
+
3
+ Status: implemented.
4
+
5
+ Notes:
6
+ - `communications` is the canonical communication boundary for domain modules.
7
+ - Provider selection is module-owned config, not a runtime request field.
8
+ - Scheduling, queueing, retries, and persistent delivery history are intentionally deferred.
@@ -9,6 +9,7 @@
9
9
  },
10
10
  "dependencies": {
11
11
  "@forgeon/accounts-contracts": "workspace:*",
12
+ "@forgeon/communications": "workspace:*",
12
13
  "@forgeon/db-prisma": "workspace:*",
13
14
  "@nestjs/common": "^11.0.1",
14
15
  "@nestjs/config": "^4.0.2",
@@ -2,16 +2,16 @@ import crypto from 'node:crypto';
2
2
  import {
3
3
  BadRequestException,
4
4
  ConflictException,
5
- Inject,
6
5
  Injectable,
6
+ Logger,
7
7
  UnauthorizedException,
8
8
  } from '@nestjs/common';
9
+ import { CommunicationsService } from '@forgeon/communications';
9
10
  import type {
10
11
  AuthSessionResponse,
11
12
  RegisterRequest,
12
13
  UserRecordDto,
13
14
  } from '@forgeon/accounts-contracts';
14
- import { ACCOUNTS_EMAIL_PORT, type AccountsEmailPort } from './accounts-email.port';
15
15
  import { AuthJwtService } from './auth-jwt.service';
16
16
  import { AuthPasswordService } from './auth-password.service';
17
17
  import { AuthStore } from './auth.store';
@@ -29,10 +29,11 @@ const AUTH_ERROR_CODES = {
29
29
 
30
30
  @Injectable()
31
31
  export class AuthCoreService {
32
+ private readonly logger = new Logger(AuthCoreService.name);
33
+
32
34
  constructor(
33
35
  private readonly authStore: AuthStore,
34
- @Inject(ACCOUNTS_EMAIL_PORT)
35
- private readonly emailPort: AccountsEmailPort,
36
+ private readonly communicationsService: CommunicationsService,
36
37
  private readonly authJwtService: AuthJwtService,
37
38
  private readonly authPasswordService: AuthPasswordService,
38
39
  private readonly usersService: UsersService,
@@ -66,20 +67,39 @@ export class AuthCoreService {
66
67
  },
67
68
  });
68
69
 
70
+ const accountDto = toUserRecordDto(account);
69
71
  const verificationToken = this.createStubToken('verify', account.id);
70
72
  await Promise.all([
71
- this.emailPort.sendVerificationEmail({
72
- email,
73
- token: verificationToken,
74
- userId: account.id,
73
+ this.sendCommunicationSafely({
74
+ kind: 'email_verification_code',
75
+ channels: ['email'],
76
+ recipient: { email },
77
+ payload: {
78
+ NAME: accountDto.profile?.name ?? 'there',
79
+ CODE: verificationToken,
80
+ },
81
+ locale: accountDto.settings?.locale ?? undefined,
82
+ metadata: {
83
+ USER_ID: account.id,
84
+ SOURCE: 'accounts.register',
85
+ },
75
86
  }),
76
- this.emailPort.sendWelcomeEmail({
77
- email,
78
- userId: account.id,
87
+ this.sendCommunicationSafely({
88
+ kind: 'welcome_email',
89
+ channels: ['email'],
90
+ recipient: { email },
91
+ payload: {
92
+ NAME: accountDto.profile?.name ?? 'there',
93
+ },
94
+ locale: accountDto.settings?.locale ?? undefined,
95
+ metadata: {
96
+ USER_ID: account.id,
97
+ SOURCE: 'accounts.register',
98
+ },
79
99
  }),
80
100
  ]);
81
101
 
82
- return this.issueSession(toUserRecordDto(account));
102
+ return this.issueSession(accountDto);
83
103
  }
84
104
 
85
105
  async loginWithPassword(emailInput: string, password: string): Promise<AuthSessionResponse> {
@@ -167,16 +187,26 @@ export class AuthCoreService {
167
187
  const email = emailInput.trim().toLowerCase();
168
188
  const account = await this.authStore.findPasswordAccountByEmail(email);
169
189
  if (account) {
170
- await this.emailPort.sendPasswordResetEmail({
171
- email,
172
- token: this.createStubToken('reset', account.id),
173
- userId: account.id,
190
+ const user = toUserRecordDto(account);
191
+ await this.sendCommunicationSafely({
192
+ kind: 'password_reset',
193
+ channels: ['email'],
194
+ recipient: { email },
195
+ payload: {
196
+ NAME: user.profile?.name ?? 'there',
197
+ TOKEN: this.createStubToken('reset', account.id),
198
+ },
199
+ locale: user.settings?.locale ?? undefined,
200
+ metadata: {
201
+ USER_ID: account.id,
202
+ SOURCE: 'accounts.password-reset',
203
+ },
174
204
  });
175
205
  }
176
206
 
177
207
  return {
178
208
  status: 'accepted',
179
- delivery: 'stub',
209
+ delivery: 'communications',
180
210
  };
181
211
  }
182
212
 
@@ -188,7 +218,7 @@ export class AuthCoreService {
188
218
  return {
189
219
  status: 'accepted',
190
220
  delivery: 'stub',
191
- nextAction: 'emails-module',
221
+ nextAction: 'accounts-token-flow',
192
222
  passwordLength: newPassword.length,
193
223
  };
194
224
  }
@@ -201,7 +231,7 @@ export class AuthCoreService {
201
231
  return {
202
232
  status: 'accepted',
203
233
  delivery: 'stub',
204
- nextAction: 'emails-module',
234
+ nextAction: 'accounts-token-flow',
205
235
  };
206
236
  }
207
237
 
@@ -215,7 +245,7 @@ export class AuthCoreService {
215
245
  status: 'ok',
216
246
  feature: 'accounts',
217
247
  storage: 'db-prisma',
218
- emailDelivery: 'stub',
248
+ emailDelivery: 'communications',
219
249
  selfServiceRoutes: [
220
250
  '/api/users/:id',
221
251
  '/api/users/:id/profile',
@@ -258,6 +288,16 @@ export class AuthCoreService {
258
288
  };
259
289
  }
260
290
 
291
+ private async sendCommunicationSafely(input: Parameters<CommunicationsService['send']>[0]): Promise<void> {
292
+ try {
293
+ await this.communicationsService.send(input);
294
+ } catch (error) {
295
+ this.logger.warn(
296
+ `accounts.communication_failed kind=${input.kind} channel=${input.channels.join(',')} reason=${error instanceof Error ? error.message : 'unknown'}`,
297
+ );
298
+ }
299
+ }
300
+
261
301
  private assertAccountActive(user: { status: string; deletedAt: Date | null }) {
262
302
  if (user.deletedAt || user.status !== 'active') {
263
303
  throw new UnauthorizedException({
@@ -6,12 +6,12 @@ import {
6
6
  } from '@nestjs/common';
7
7
  import { JwtModule } from '@nestjs/jwt';
8
8
  import { PassportModule } from '@nestjs/passport';
9
+ import { ForgeonCommunicationsModule } from '@forgeon/communications';
9
10
  import { DbPrismaModule } from '@forgeon/db-prisma';
10
11
  import {
11
12
  ACCOUNTS_AUTHZ_CLAIMS_RESOLVER,
12
13
  NoopAccountsAuthzClaimsResolver,
13
14
  } from './accounts-rbac.port';
14
- import { ACCOUNTS_EMAIL_PORT, StubAccountsEmailAdapter } from './accounts-email.port';
15
15
  import { AuthConfigModule } from './auth-config.module';
16
16
  import { AuthController } from './auth.controller';
17
17
  import { AuthCoreService } from './auth-core.service';
@@ -41,6 +41,7 @@ export class ForgeonAccountsModule {
41
41
  imports: [
42
42
  AuthConfigModule,
43
43
  DbPrismaModule,
44
+ ForgeonCommunicationsModule.register(),
44
45
  PassportModule.register({ defaultStrategy: 'jwt' }),
45
46
  JwtModule.register({}),
46
47
  ...(options.imports ?? []),
@@ -51,10 +52,6 @@ export class ForgeonAccountsModule {
51
52
  provide: USERS_MODULE_OPTIONS,
52
53
  useValue: UsersModule.register(options.users ?? {}),
53
54
  },
54
- {
55
- provide: ACCOUNTS_EMAIL_PORT,
56
- useClass: StubAccountsEmailAdapter,
57
- },
58
55
  {
59
56
  provide: ACCOUNTS_AUTHZ_CLAIMS_RESOLVER,
60
57
  useClass: NoopAccountsAuthzClaimsResolver,
@@ -80,7 +77,6 @@ export class ForgeonAccountsModule {
80
77
  UsersService,
81
78
  JwtAuthGuard,
82
79
  OwnerAccessGuard,
83
- ACCOUNTS_EMAIL_PORT,
84
80
  ACCOUNTS_AUTHZ_CLAIMS_RESOLVER,
85
81
  ],
86
82
  };
@@ -1,4 +1,3 @@
1
- export * from './accounts-email.port';
2
1
  export * from './accounts-rbac.port';
3
2
  export * from './auth-config.loader';
4
3
  export * from './auth-config.module';
@@ -21,3 +20,4 @@ export * from './users.controller';
21
20
  export * from './users.service';
22
21
  export * from './users.store';
23
22
  export * from './users.types';
23
+
@@ -0,0 +1,23 @@
1
+ {
2
+ "name": "@forgeon/communications",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "main": "dist/index.js",
6
+ "types": "dist/index.d.ts",
7
+ "scripts": {
8
+ "build": "tsc -p tsconfig.json"
9
+ },
10
+ "dependencies": {
11
+ "@nestjs/common": "^11.0.1",
12
+ "@nestjs/config": "^4.0.2",
13
+ "class-transformer": "^0.5.1",
14
+ "class-validator": "^0.14.1",
15
+ "nodemailer": "^6.10.0",
16
+ "zod": "^3.23.8"
17
+ },
18
+ "devDependencies": {
19
+ "@types/node": "^22.10.7",
20
+ "@types/nodemailer": "^6.4.17",
21
+ "typescript": "^5.7.3"
22
+ }
23
+ }
@@ -0,0 +1,58 @@
1
+ import * as path from 'node:path';
2
+ import { registerAs } from '@nestjs/config';
3
+ import { parseCommunicationsEnv } from './communications-env.schema';
4
+
5
+ export const COMMUNICATIONS_CONFIG_NAMESPACE = 'communications';
6
+
7
+ export type CommunicationsConfigValues = {
8
+ templatesRoot: string;
9
+ email: {
10
+ provider: 'gmail-smtp';
11
+ from: string;
12
+ replyTo: string | null;
13
+ subjectPrefix: string | null;
14
+ smtp: {
15
+ host: string;
16
+ port: number;
17
+ secure: boolean;
18
+ user: string;
19
+ pass: string;
20
+ };
21
+ };
22
+ sms: {
23
+ provider: 'stub';
24
+ };
25
+ push: {
26
+ provider: 'stub';
27
+ };
28
+ };
29
+
30
+ export const communicationsConfig = registerAs(
31
+ COMMUNICATIONS_CONFIG_NAMESPACE,
32
+ (): CommunicationsConfigValues => {
33
+ const env = parseCommunicationsEnv(process.env as unknown as Record<string, unknown>);
34
+
35
+ return {
36
+ templatesRoot: path.resolve(process.cwd(), env.COMMUNICATIONS_TEMPLATES_ROOT),
37
+ email: {
38
+ provider: env.COMMUNICATIONS_EMAIL_PROVIDER,
39
+ from: env.COMMUNICATIONS_EMAIL_FROM,
40
+ replyTo: env.COMMUNICATIONS_EMAIL_REPLY_TO || null,
41
+ subjectPrefix: env.COMMUNICATIONS_EMAIL_SUBJECT_PREFIX || null,
42
+ smtp: {
43
+ host: env.COMMUNICATIONS_EMAIL_SMTP_HOST,
44
+ port: env.COMMUNICATIONS_EMAIL_SMTP_PORT,
45
+ secure: env.COMMUNICATIONS_EMAIL_SMTP_SECURE,
46
+ user: env.COMMUNICATIONS_EMAIL_SMTP_USER,
47
+ pass: env.COMMUNICATIONS_EMAIL_SMTP_PASS,
48
+ },
49
+ },
50
+ sms: {
51
+ provider: env.COMMUNICATIONS_SMS_PROVIDER,
52
+ },
53
+ push: {
54
+ provider: env.COMMUNICATIONS_PUSH_PROVIDER,
55
+ },
56
+ };
57
+ },
58
+ );
@@ -0,0 +1,11 @@
1
+ import { Module } from '@nestjs/common';
2
+ import { ConfigModule } from '@nestjs/config';
3
+ import { communicationsConfig } from './communications-config.loader';
4
+ import { CommunicationsConfigService } from './communications-config.service';
5
+
6
+ @Module({
7
+ imports: [ConfigModule.forFeature(communicationsConfig)],
8
+ providers: [CommunicationsConfigService],
9
+ exports: [ConfigModule, CommunicationsConfigService],
10
+ })
11
+ export class CommunicationsConfigModule {}
@@ -0,0 +1,60 @@
1
+ import { Injectable } from '@nestjs/common';
2
+ import { ConfigService } from '@nestjs/config';
3
+ import { COMMUNICATIONS_CONFIG_NAMESPACE } from './communications-config.loader';
4
+
5
+ @Injectable()
6
+ export class CommunicationsConfigService {
7
+ constructor(private readonly configService: ConfigService) {}
8
+
9
+ get templatesRoot(): string {
10
+ return this.configService.getOrThrow<string>(`${COMMUNICATIONS_CONFIG_NAMESPACE}.templatesRoot`);
11
+ }
12
+
13
+ get emailProvider(): 'gmail-smtp' {
14
+ return this.configService.getOrThrow<'gmail-smtp'>(`${COMMUNICATIONS_CONFIG_NAMESPACE}.email.provider`);
15
+ }
16
+
17
+ get emailFrom(): string {
18
+ return this.configService.getOrThrow<string>(`${COMMUNICATIONS_CONFIG_NAMESPACE}.email.from`);
19
+ }
20
+
21
+ get emailReplyTo(): string | null {
22
+ return this.configService.get<string | null>(`${COMMUNICATIONS_CONFIG_NAMESPACE}.email.replyTo`) ?? null;
23
+ }
24
+
25
+ get emailSubjectPrefix(): string | null {
26
+ return this.configService.get<string | null>(`${COMMUNICATIONS_CONFIG_NAMESPACE}.email.subjectPrefix`) ?? null;
27
+ }
28
+
29
+ get emailSmtpHost(): string {
30
+ return this.configService.getOrThrow<string>(`${COMMUNICATIONS_CONFIG_NAMESPACE}.email.smtp.host`);
31
+ }
32
+
33
+ get emailSmtpPort(): number {
34
+ return this.configService.getOrThrow<number>(`${COMMUNICATIONS_CONFIG_NAMESPACE}.email.smtp.port`);
35
+ }
36
+
37
+ get emailSmtpSecure(): boolean {
38
+ return this.configService.getOrThrow<boolean>(`${COMMUNICATIONS_CONFIG_NAMESPACE}.email.smtp.secure`);
39
+ }
40
+
41
+ get emailSmtpUser(): string {
42
+ return this.configService.getOrThrow<string>(`${COMMUNICATIONS_CONFIG_NAMESPACE}.email.smtp.user`);
43
+ }
44
+
45
+ get emailSmtpPass(): string {
46
+ return this.configService.getOrThrow<string>(`${COMMUNICATIONS_CONFIG_NAMESPACE}.email.smtp.pass`);
47
+ }
48
+
49
+ get smsProvider(): 'stub' {
50
+ return this.configService.getOrThrow<'stub'>(`${COMMUNICATIONS_CONFIG_NAMESPACE}.sms.provider`);
51
+ }
52
+
53
+ get pushProvider(): 'stub' {
54
+ return this.configService.getOrThrow<'stub'>(`${COMMUNICATIONS_CONFIG_NAMESPACE}.push.provider`);
55
+ }
56
+
57
+ get emailProviderReady(): boolean {
58
+ return this.emailSmtpUser.trim().length > 0 && this.emailSmtpPass.trim().length > 0;
59
+ }
60
+ }
@@ -0,0 +1,24 @@
1
+ import { z } from 'zod';
2
+
3
+ export const communicationsEnvSchema = z
4
+ .object({
5
+ COMMUNICATIONS_TEMPLATES_ROOT: z.string().trim().min(1).default('resources/communications'),
6
+ COMMUNICATIONS_EMAIL_PROVIDER: z.enum(['gmail-smtp']).default('gmail-smtp'),
7
+ COMMUNICATIONS_EMAIL_FROM: z.string().trim().min(1).default('Forgeon <no-reply@example.com>'),
8
+ COMMUNICATIONS_EMAIL_REPLY_TO: z.string().trim().default(''),
9
+ COMMUNICATIONS_EMAIL_SUBJECT_PREFIX: z.string().trim().default('[Forgeon]'),
10
+ COMMUNICATIONS_EMAIL_SMTP_HOST: z.string().trim().min(1).default('smtp.gmail.com'),
11
+ COMMUNICATIONS_EMAIL_SMTP_PORT: z.coerce.number().int().min(1).max(65535).default(587),
12
+ COMMUNICATIONS_EMAIL_SMTP_SECURE: z.coerce.boolean().default(false),
13
+ COMMUNICATIONS_EMAIL_SMTP_USER: z.string().trim().default(''),
14
+ COMMUNICATIONS_EMAIL_SMTP_PASS: z.string().trim().default(''),
15
+ COMMUNICATIONS_SMS_PROVIDER: z.enum(['stub']).default('stub'),
16
+ COMMUNICATIONS_PUSH_PROVIDER: z.enum(['stub']).default('stub')
17
+ })
18
+ .passthrough();
19
+
20
+ export type CommunicationsEnv = z.infer<typeof communicationsEnvSchema>;
21
+
22
+ export function parseCommunicationsEnv(input: Record<string, unknown>): CommunicationsEnv {
23
+ return communicationsEnvSchema.parse(input);
24
+ }
@@ -0,0 +1,3 @@
1
+ export const COMMUNICATIONS_EMAIL_PROVIDER = 'FORGEON_COMMUNICATIONS_EMAIL_PROVIDER';
2
+ export const COMMUNICATIONS_SMS_PROVIDER = 'FORGEON_COMMUNICATIONS_SMS_PROVIDER';
3
+ export const COMMUNICATIONS_PUSH_PROVIDER = 'FORGEON_COMMUNICATIONS_PUSH_PROVIDER';