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
|
@@ -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$
|
|
@@ -1,20 +1,21 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@forgeon/files-quotas",
|
|
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
|
-
"@forgeon/
|
|
12
|
-
"@
|
|
13
|
-
"@nestjs/
|
|
14
|
-
"
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
"
|
|
19
|
-
|
|
20
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "@forgeon/files-quotas",
|
|
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
|
+
"@forgeon/db-prisma": "workspace:*",
|
|
12
|
+
"@forgeon/files": "workspace:*",
|
|
13
|
+
"@nestjs/common": "^11.0.1",
|
|
14
|
+
"@nestjs/config": "^4.0.0",
|
|
15
|
+
"zod": "^3.24.2"
|
|
16
|
+
},
|
|
17
|
+
"devDependencies": {
|
|
18
|
+
"@types/node": "^22.10.7",
|
|
19
|
+
"typescript": "^5.7.3"
|
|
20
|
+
}
|
|
21
|
+
}
|
package/templates/module-presets/files-quotas/packages/files-quotas/src/files-quotas.service.ts
CHANGED
|
@@ -1,118 +1,118 @@
|
|
|
1
|
-
import { ConflictException, Injectable } from '@nestjs/common';
|
|
2
|
-
import {
|
|
3
|
-
import { FilesQuotasConfigService } from './files-quotas-config.service';
|
|
4
|
-
import type { FilesQuotaCheckInput, FilesQuotaCheckResult } from './files-quotas.types';
|
|
5
|
-
|
|
6
|
-
@Injectable()
|
|
7
|
-
export class FilesQuotasService {
|
|
8
|
-
constructor(
|
|
9
|
-
private readonly
|
|
10
|
-
private readonly configService: FilesQuotasConfigService,
|
|
11
|
-
) {}
|
|
12
|
-
|
|
13
|
-
get enabled(): boolean {
|
|
14
|
-
return this.configService.enabled;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
get limits() {
|
|
18
|
-
return {
|
|
19
|
-
maxFilesPerOwner: this.configService.maxFilesPerOwner,
|
|
20
|
-
maxBytesPerOwner: this.configService.maxBytesPerOwner,
|
|
21
|
-
};
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
async evaluateUpload(input: FilesQuotaCheckInput): Promise<FilesQuotaCheckResult> {
|
|
25
|
-
const limits = this.limits;
|
|
26
|
-
const emptyUsage = {
|
|
27
|
-
filesCount: 0,
|
|
28
|
-
totalBytes: 0,
|
|
29
|
-
};
|
|
30
|
-
const baseResult = {
|
|
31
|
-
limits,
|
|
32
|
-
current: emptyUsage,
|
|
33
|
-
next: {
|
|
34
|
-
filesCount: emptyUsage.filesCount + 1,
|
|
35
|
-
totalBytes: emptyUsage.totalBytes + input.fileSize,
|
|
36
|
-
},
|
|
37
|
-
};
|
|
38
|
-
|
|
39
|
-
if (!this.enabled) {
|
|
40
|
-
return {
|
|
41
|
-
allowed: true,
|
|
42
|
-
reason: 'disabled',
|
|
43
|
-
...baseResult,
|
|
44
|
-
};
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
if (!input.ownerId) {
|
|
48
|
-
return {
|
|
49
|
-
allowed: true,
|
|
50
|
-
reason: 'owner-missing',
|
|
51
|
-
...baseResult,
|
|
52
|
-
};
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
const usage = await this.
|
|
56
|
-
const next = {
|
|
57
|
-
filesCount: usage.filesCount + 1,
|
|
58
|
-
totalBytes: usage.totalBytes + input.fileSize,
|
|
59
|
-
};
|
|
60
|
-
|
|
61
|
-
if (next.filesCount > limits.maxFilesPerOwner) {
|
|
62
|
-
return {
|
|
63
|
-
allowed: false,
|
|
64
|
-
reason: 'max-files',
|
|
65
|
-
limits,
|
|
66
|
-
current: usage,
|
|
67
|
-
next,
|
|
68
|
-
};
|
|
69
|
-
}
|
|
70
|
-
if (next.totalBytes > limits.maxBytesPerOwner) {
|
|
71
|
-
return {
|
|
72
|
-
allowed: false,
|
|
73
|
-
reason: 'max-bytes',
|
|
74
|
-
limits,
|
|
75
|
-
current: usage,
|
|
76
|
-
next,
|
|
77
|
-
};
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
return {
|
|
81
|
-
allowed: true,
|
|
82
|
-
reason: 'ok',
|
|
83
|
-
limits,
|
|
84
|
-
current: usage,
|
|
85
|
-
next,
|
|
86
|
-
};
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
async assertUploadAllowed(input: FilesQuotaCheckInput): Promise<void> {
|
|
90
|
-
const result = await this.evaluateUpload(input);
|
|
91
|
-
if (result.allowed) {
|
|
92
|
-
return;
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
throw new ConflictException({
|
|
96
|
-
message: 'File quota exceeded for owner',
|
|
97
|
-
details: {
|
|
98
|
-
reason: result.reason,
|
|
99
|
-
limits: result.limits,
|
|
100
|
-
current: result.current,
|
|
101
|
-
next: result.next,
|
|
102
|
-
},
|
|
103
|
-
});
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
async getProbeStatus(input: FilesQuotaCheckInput): Promise<{
|
|
107
|
-
status: 'ok';
|
|
108
|
-
feature: 'files-quotas';
|
|
109
|
-
result: FilesQuotaCheckResult;
|
|
110
|
-
}> {
|
|
111
|
-
const result = await this.evaluateUpload(input);
|
|
112
|
-
return {
|
|
113
|
-
status: 'ok',
|
|
114
|
-
feature: 'files-quotas',
|
|
115
|
-
result,
|
|
116
|
-
};
|
|
117
|
-
}
|
|
118
|
-
}
|
|
1
|
+
import { ConflictException, Injectable } from '@nestjs/common';
|
|
2
|
+
import { FilesStore } from '@forgeon/files';
|
|
3
|
+
import { FilesQuotasConfigService } from './files-quotas-config.service';
|
|
4
|
+
import type { FilesQuotaCheckInput, FilesQuotaCheckResult } from './files-quotas.types';
|
|
5
|
+
|
|
6
|
+
@Injectable()
|
|
7
|
+
export class FilesQuotasService {
|
|
8
|
+
constructor(
|
|
9
|
+
private readonly filesStore: FilesStore,
|
|
10
|
+
private readonly configService: FilesQuotasConfigService,
|
|
11
|
+
) {}
|
|
12
|
+
|
|
13
|
+
get enabled(): boolean {
|
|
14
|
+
return this.configService.enabled;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
get limits() {
|
|
18
|
+
return {
|
|
19
|
+
maxFilesPerOwner: this.configService.maxFilesPerOwner,
|
|
20
|
+
maxBytesPerOwner: this.configService.maxBytesPerOwner,
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async evaluateUpload(input: FilesQuotaCheckInput): Promise<FilesQuotaCheckResult> {
|
|
25
|
+
const limits = this.limits;
|
|
26
|
+
const emptyUsage = {
|
|
27
|
+
filesCount: 0,
|
|
28
|
+
totalBytes: 0,
|
|
29
|
+
};
|
|
30
|
+
const baseResult = {
|
|
31
|
+
limits,
|
|
32
|
+
current: emptyUsage,
|
|
33
|
+
next: {
|
|
34
|
+
filesCount: emptyUsage.filesCount + 1,
|
|
35
|
+
totalBytes: emptyUsage.totalBytes + input.fileSize,
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
if (!this.enabled) {
|
|
40
|
+
return {
|
|
41
|
+
allowed: true,
|
|
42
|
+
reason: 'disabled',
|
|
43
|
+
...baseResult,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (!input.ownerId) {
|
|
48
|
+
return {
|
|
49
|
+
allowed: true,
|
|
50
|
+
reason: 'owner-missing',
|
|
51
|
+
...baseResult,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const usage = await this.filesStore.countOwnerUsage(input.ownerType, input.ownerId);
|
|
56
|
+
const next = {
|
|
57
|
+
filesCount: usage.filesCount + 1,
|
|
58
|
+
totalBytes: usage.totalBytes + input.fileSize,
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
if (next.filesCount > limits.maxFilesPerOwner) {
|
|
62
|
+
return {
|
|
63
|
+
allowed: false,
|
|
64
|
+
reason: 'max-files',
|
|
65
|
+
limits,
|
|
66
|
+
current: usage,
|
|
67
|
+
next,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
if (next.totalBytes > limits.maxBytesPerOwner) {
|
|
71
|
+
return {
|
|
72
|
+
allowed: false,
|
|
73
|
+
reason: 'max-bytes',
|
|
74
|
+
limits,
|
|
75
|
+
current: usage,
|
|
76
|
+
next,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return {
|
|
81
|
+
allowed: true,
|
|
82
|
+
reason: 'ok',
|
|
83
|
+
limits,
|
|
84
|
+
current: usage,
|
|
85
|
+
next,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async assertUploadAllowed(input: FilesQuotaCheckInput): Promise<void> {
|
|
90
|
+
const result = await this.evaluateUpload(input);
|
|
91
|
+
if (result.allowed) {
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
throw new ConflictException({
|
|
96
|
+
message: 'File quota exceeded for owner',
|
|
97
|
+
details: {
|
|
98
|
+
reason: result.reason,
|
|
99
|
+
limits: result.limits,
|
|
100
|
+
current: result.current,
|
|
101
|
+
next: result.next,
|
|
102
|
+
},
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async getProbeStatus(input: FilesQuotaCheckInput): Promise<{
|
|
107
|
+
status: 'ok';
|
|
108
|
+
feature: 'files-quotas';
|
|
109
|
+
result: FilesQuotaCheckResult;
|
|
110
|
+
}> {
|
|
111
|
+
const result = await this.evaluateUpload(input);
|
|
112
|
+
return {
|
|
113
|
+
status: 'ok',
|
|
114
|
+
feature: 'files-quotas',
|
|
115
|
+
result,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
}
|
|
@@ -1,19 +1,21 @@
|
|
|
1
|
-
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
1
|
+
import { Module } from '@nestjs/common';
|
|
2
|
+
import { DbPrismaModule } from '@forgeon/db-prisma';
|
|
3
|
+
import { FilesStore } from '@forgeon/files';
|
|
4
|
+
import { FilesQuotasConfigModule } from './files-quotas-config.module';
|
|
5
|
+
import { FilesQuotasService } from './files-quotas.service';
|
|
6
|
+
|
|
7
|
+
const FORGEON_FILES_UPLOAD_QUOTA_SERVICE = 'FORGEON_FILES_UPLOAD_QUOTA_SERVICE';
|
|
8
|
+
|
|
9
|
+
@Module({
|
|
10
|
+
imports: [DbPrismaModule, FilesQuotasConfigModule],
|
|
11
|
+
providers: [
|
|
12
|
+
FilesStore,
|
|
13
|
+
FilesQuotasService,
|
|
14
|
+
{
|
|
15
|
+
provide: FORGEON_FILES_UPLOAD_QUOTA_SERVICE,
|
|
16
|
+
useExisting: FilesQuotasService,
|
|
17
|
+
},
|
|
18
|
+
],
|
|
19
|
+
exports: [FilesQuotasConfigModule, FilesQuotasService, FORGEON_FILES_UPLOAD_QUOTA_SERVICE],
|
|
20
|
+
})
|
|
21
|
+
export class ForgeonFilesQuotasModule {}
|
|
@@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next';
|
|
|
3
3
|
import i18n from './i18n';
|
|
4
4
|
import * as i18nWeb from '@forgeon/i18n-web';
|
|
5
5
|
import type { I18nLocale } from '@forgeon/i18n-web';
|
|
6
|
-
import { probeDefinitions, type ProbeDefinition, type ProbeResult } from './probes';
|
|
6
|
+
import { probeDefinitions, type ProbeDefinition, type ProbeInputDefinition, type ProbeResult } from './probes';
|
|
7
7
|
import './styles.css';
|
|
8
8
|
|
|
9
9
|
type ProbeState = {
|
|
@@ -12,17 +12,42 @@ type ProbeState = {
|
|
|
12
12
|
loading: boolean;
|
|
13
13
|
};
|
|
14
14
|
|
|
15
|
+
type ProbeInputState = Record<string, string>;
|
|
16
|
+
|
|
15
17
|
const emptyProbeState: ProbeState = {
|
|
16
18
|
result: null,
|
|
17
19
|
error: null,
|
|
18
20
|
loading: false,
|
|
19
21
|
};
|
|
20
22
|
|
|
23
|
+
function resolveBodyTemplate(value: unknown, inputs: ProbeInputState): unknown {
|
|
24
|
+
if (typeof value === 'string') {
|
|
25
|
+
const match = value.match(/^\$INPUT\.([a-zA-Z0-9_-]+)\$$/);
|
|
26
|
+
if (match) {
|
|
27
|
+
return inputs[match[1]] ?? '';
|
|
28
|
+
}
|
|
29
|
+
return value;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (Array.isArray(value)) {
|
|
33
|
+
return value.map((item) => resolveBodyTemplate(item, inputs));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (value && typeof value === 'object') {
|
|
37
|
+
return Object.fromEntries(
|
|
38
|
+
Object.entries(value).map(([key, nestedValue]) => [key, resolveBodyTemplate(nestedValue, inputs)]),
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return value;
|
|
43
|
+
}
|
|
44
|
+
|
|
21
45
|
export default function App() {
|
|
22
46
|
const { t } = useTranslation(['ui']);
|
|
23
47
|
const { I18N_LOCALES, getInitialLocale, persistLocale, toLangQuery } = i18nWeb;
|
|
24
48
|
const [locale, setLocale] = useState<I18nLocale>(getInitialLocale);
|
|
25
49
|
const [probeState, setProbeState] = useState<Record<string, ProbeState>>({});
|
|
50
|
+
const [probeInputs, setProbeInputs] = useState<Record<string, ProbeInputState>>({});
|
|
26
51
|
|
|
27
52
|
const changeLocale = (nextLocale: I18nLocale) => {
|
|
28
53
|
setLocale(nextLocale);
|
|
@@ -30,15 +55,39 @@ export default function App() {
|
|
|
30
55
|
void i18n.changeLanguage(nextLocale);
|
|
31
56
|
};
|
|
32
57
|
|
|
58
|
+
const getProbeInputValue = (probeId: string, input: ProbeInputDefinition): string => {
|
|
59
|
+
return probeInputs[probeId]?.[input.id] ?? input.defaultValue ?? '';
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const updateProbeInput = (probeId: string, inputId: string, value: string) => {
|
|
63
|
+
setProbeInputs((current) => ({
|
|
64
|
+
...current,
|
|
65
|
+
[probeId]: {
|
|
66
|
+
...(current[probeId] ?? {}),
|
|
67
|
+
[inputId]: value,
|
|
68
|
+
},
|
|
69
|
+
}));
|
|
70
|
+
};
|
|
71
|
+
|
|
33
72
|
const requestProbe = async (probe: ProbeDefinition): Promise<ProbeResult> => {
|
|
34
|
-
const
|
|
35
|
-
|
|
73
|
+
const method = probe.request?.method ?? 'GET';
|
|
74
|
+
const headers: Record<string, string> = {
|
|
75
|
+
...(probe.request?.headers ?? {}),
|
|
76
|
+
'Accept-Language': locale,
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const requestInit: RequestInit = {
|
|
80
|
+
method,
|
|
36
81
|
cache: 'no-store',
|
|
37
|
-
headers
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
82
|
+
headers,
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
if (method !== 'GET' && probe.request?.body !== undefined) {
|
|
86
|
+
headers['Content-Type'] = 'application/json';
|
|
87
|
+
requestInit.body = JSON.stringify(resolveBodyTemplate(probe.request.body, probeInputs[probe.id] ?? {}));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const response = await fetch(`/api${probe.path}${toLangQuery(locale)}`, requestInit);
|
|
42
91
|
|
|
43
92
|
let body: unknown = null;
|
|
44
93
|
try {
|
|
@@ -113,6 +162,21 @@ export default function App() {
|
|
|
113
162
|
{current.loading ? 'Running...' : probe.buttonLabel}
|
|
114
163
|
</button>
|
|
115
164
|
</div>
|
|
165
|
+
{probe.inputs?.length ? (
|
|
166
|
+
<div className="probe-inputs">
|
|
167
|
+
{probe.inputs.map((input) => (
|
|
168
|
+
<label key={`${probe.id}-${input.id}`} className="probe-input">
|
|
169
|
+
<span>{input.label}</span>
|
|
170
|
+
<input
|
|
171
|
+
type={input.type ?? 'text'}
|
|
172
|
+
value={getProbeInputValue(probe.id, input)}
|
|
173
|
+
placeholder={input.placeholder}
|
|
174
|
+
onChange={(event) => updateProbeInput(probe.id, input.id, event.target.value)}
|
|
175
|
+
/>
|
|
176
|
+
</label>
|
|
177
|
+
))}
|
|
178
|
+
</div>
|
|
179
|
+
) : null}
|
|
116
180
|
<div className="probe-output">
|
|
117
181
|
<h3>{probe.resultTitle}</h3>
|
|
118
182
|
{current.error ? <p className="error">{current.error}</p> : null}
|
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
export const ACCOUNTS_EMAIL_PORT = 'FORGEON_ACCOUNTS_EMAIL_PORT';
|
|
2
|
-
|
|
3
|
-
export interface AccountsEmailPort {
|
|
4
|
-
sendVerificationEmail(input: { email: string; token: string; userId: string }): Promise<void>;
|
|
5
|
-
sendPasswordResetEmail(input: { email: string; token: string; userId: string }): Promise<void>;
|
|
6
|
-
sendWelcomeEmail(input: { email: string; userId: string }): Promise<void>;
|
|
7
|
-
}
|
|
8
|
-
|
|
9
|
-
export class StubAccountsEmailAdapter implements AccountsEmailPort {
|
|
10
|
-
async sendVerificationEmail(): Promise<void> {}
|
|
11
|
-
async sendPasswordResetEmail(): Promise<void> {}
|
|
12
|
-
async sendWelcomeEmail(): Promise<void> {}
|
|
13
|
-
}
|