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.
- 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 +60 -31
- package/src/modules/files-access.mjs +25 -1
- 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/files-quotas/packages/files-quotas/package.json +21 -20
- package/templates/module-presets/files-quotas/packages/files-quotas/src/files-quotas.service.ts +118 -118
- package/templates/module-presets/files-quotas/packages/files-quotas/src/forgeon-files-quotas.module.ts +21 -19
- 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
|
@@ -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
|
|
22
|
-
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
80
|
+
responseBody = await response.json();
|
|
32
81
|
} catch {
|
|
33
|
-
|
|
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
|
-
|
|
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
|
|
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`
|
|
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
|
-
-
|
|
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
|
-
-
|
|
7
|
+
- verification and password-reset token lifecycle storage remains a follow-up iteration
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# {{MODULE_LABEL}}
|
|
@@ -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.
|
|
@@ -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
|
-
|
|
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.
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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.
|
|
77
|
-
|
|
78
|
-
|
|
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(
|
|
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
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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: '
|
|
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: '
|
|
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: '
|
|
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: '
|
|
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({
|
package/templates/module-presets/accounts/packages/accounts-api/src/forgeon-accounts.module.ts
CHANGED
|
@@ -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
|
+
}
|