@global-torque/invest-core 0.2.2
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/CHANGELOG.md +14 -0
- package/LICENSE +21 -0
- package/NOTICE.md +11 -0
- package/README.md +76 -0
- package/SECURITY.md +9 -0
- package/SUPPORT.md +6 -0
- package/dist/node/app/config.js +173 -0
- package/dist/node/helpers/text.js +37 -0
- package/dist/node/markdown/tableWrap.js +23 -0
- package/package.json +113 -0
- package/src/accreditation/status.ts +100 -0
- package/src/analytics/__tests__/analyticsBody.test.ts +50 -0
- package/src/analytics/analyticsBody.ts +270 -0
- package/src/app/config.test.ts +79 -0
- package/src/app/config.ts +329 -0
- package/src/decimal/__tests__/canonicalDecimal.test.ts +58 -0
- package/src/decimal/canonicalDecimal.ts +154 -0
- package/src/evm/__tests__/walletInfo.test.ts +488 -0
- package/src/evm/walletInfo.ts +625 -0
- package/src/filer/__tests__/documentFormatter.test.ts +208 -0
- package/src/filer/__tests__/publicImage.test.ts +42 -0
- package/src/filer/documentFormatter.ts +195 -0
- package/src/filer/publicImage.ts +120 -0
- package/src/form-validation/__tests__/general.test.ts +78 -0
- package/src/form-validation/__tests__/investment.test.ts +100 -0
- package/src/form-validation/ajv.ts +109 -0
- package/src/form-validation/constants.ts +22 -0
- package/src/form-validation/general.ts +114 -0
- package/src/form-validation/index.ts +5 -0
- package/src/form-validation/investment.ts +65 -0
- package/src/form-validation/rules.ts +35 -0
- package/src/formatting/__tests__/buildInfo.test.ts +19 -0
- package/src/formatting/__tests__/dateTime.test.ts +24 -0
- package/src/formatting/__tests__/display.test.ts +30 -0
- package/src/formatting/buildInfo.ts +31 -0
- package/src/formatting/dateTime.ts +43 -0
- package/src/formatting/display.ts +24 -0
- package/src/helpers/arrays.ts +11 -0
- package/src/helpers/currency.ts +19 -0
- package/src/helpers/formatters/formatToDate.ts +47 -0
- package/src/helpers/formatters/formatToNumber.ts +39 -0
- package/src/helpers/formatters/formatToPhone.ts +13 -0
- package/src/helpers/general.ts +164 -0
- package/src/helpers/model.ts +87 -0
- package/src/helpers/numberFormatter.ts +4 -0
- package/src/helpers/text.ts +51 -0
- package/src/index.ts +22 -0
- package/src/investment/__tests__/status.test.ts +59 -0
- package/src/investment/rawAmount.test.ts +23 -0
- package/src/investment/rawAmount.ts +81 -0
- package/src/investment/status.ts +56 -0
- package/src/kyc/__tests__/kycAlert.formatter.test.ts +47 -0
- package/src/kyc/__tests__/kycAlert.test.ts +47 -0
- package/src/kyc/__tests__/thirdPartyScreen.test.ts +16 -0
- package/src/kyc/kycAlert.ts +47 -0
- package/src/kyc/status.ts +109 -0
- package/src/kyc/thirdPartyScreen.ts +28 -0
- package/src/markdown/tableWrap.ts +29 -0
- package/src/notifications/shareFields.ts +15 -0
- package/src/offer/__tests__/metrics.test.ts +67 -0
- package/src/offer/formatter.ts +559 -0
- package/src/offer/metrics.ts +83 -0
- package/src/onboarding/__tests__/intents.test.ts +83 -0
- package/src/onboarding/intents.ts +128 -0
- package/src/profiles/__tests__/formatting.test.ts +24 -0
- package/src/profiles/avatarInitial.ts +5 -0
- package/src/profiles/formatting.ts +38 -0
- package/src/repository/__tests__/formatterCache.test.ts +45 -0
- package/src/repository/formatterCache.ts +56 -0
- package/src/wallet/__tests__/auth.test.ts +102 -0
- package/src/wallet/__tests__/operationPresentation.test.ts +94 -0
- package/src/wallet/__tests__/setupError.test.ts +32 -0
- package/src/wallet/auth.ts +491 -0
- package/src/wallet/operationPresentation.ts +106 -0
- package/src/wallet/setupError.ts +50 -0
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import type { FilerObjectTree } from '@global-torque/domain-types/filerTypes';
|
|
3
|
+
import { FilerFormatter } from '../documentFormatter.ts';
|
|
4
|
+
|
|
5
|
+
describe('FilerFormatter', () => {
|
|
6
|
+
it('recursively flattens root/deep documents, excludes media, and uses Other for root files', () => {
|
|
7
|
+
const tree: FilerObjectTree = {
|
|
8
|
+
entities: {
|
|
9
|
+
root: { id: 1, filename: 'root.pdf', mime: 'application/pdf', created_at: 'invalid' },
|
|
10
|
+
agreements: {
|
|
11
|
+
name: 'investment_agreements',
|
|
12
|
+
entities: {
|
|
13
|
+
nested: { name: 'Nested', entities: {
|
|
14
|
+
deep: { id: '2', filename: 'deep.pdf', mime: 'application/pdf', created_at: '2026-01-02T00:00:00Z' },
|
|
15
|
+
} },
|
|
16
|
+
media: { name: 'media', entities: {
|
|
17
|
+
nestedImage: { id: 4, filename: 'nested-image.png', mime: 'image/png' },
|
|
18
|
+
} },
|
|
19
|
+
},
|
|
20
|
+
},
|
|
21
|
+
media: { name: 'media', entities: {
|
|
22
|
+
image: { id: 3, filename: 'image.png', mime: 'image/png' },
|
|
23
|
+
} },
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
const rows = FilerFormatter.getFormattedInvestmentDocuments(
|
|
27
|
+
[{ access: 'private', tree }],
|
|
28
|
+
'https://files.example.test',
|
|
29
|
+
);
|
|
30
|
+
expect(rows.map((row) => row.name)).toEqual(['deep.pdf', 'root.pdf']);
|
|
31
|
+
expect(rows.find((row) => row.id === 1)).toMatchObject({
|
|
32
|
+
category: 'other',
|
|
33
|
+
date: '—',
|
|
34
|
+
dateTimestamp: null,
|
|
35
|
+
});
|
|
36
|
+
expect(rows.every((row) => row.url === undefined)).toBe(true);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('uses created_at before updated_at, keeps invalid dates last, and prefers public duplicate ids', () => {
|
|
40
|
+
const privateTree: FilerObjectTree = { entities: {
|
|
41
|
+
one: { id: 1, filename: 'private.pdf', mime: 'application/pdf', created_at: '2026-02-01T00:00:00Z' },
|
|
42
|
+
two: { id: 2, filename: 'older.pdf', mime: 'application/pdf', created_at: '2025-01-01T00:00:00Z', updated_at: '2026-06-01T00:00:00Z' },
|
|
43
|
+
three: { id: 3, filename: 'invalid.pdf', mime: 'application/pdf', created_at: 'invalid' },
|
|
44
|
+
} };
|
|
45
|
+
const publicTree: FilerObjectTree = { entities: {
|
|
46
|
+
one: { id: 1, filename: 'public.pdf', mime: 'application/pdf', created_at: '2026-02-01T00:00:00Z' },
|
|
47
|
+
} };
|
|
48
|
+
const rows = FilerFormatter.getFormattedInvestmentDocuments([
|
|
49
|
+
{ access: 'private', tree: privateTree },
|
|
50
|
+
{ access: 'public', tree: publicTree },
|
|
51
|
+
], 'https://files.example.test');
|
|
52
|
+
|
|
53
|
+
expect(rows.map((row) => row.name)).toEqual(['public.pdf', 'older.pdf', 'invalid.pdf']);
|
|
54
|
+
expect(rows[0]).toMatchObject({ access: 'public', key: 'public:1' });
|
|
55
|
+
expect(rows[1].dateTimestamp).toBe(Date.parse('2025-01-01T00:00:00Z'));
|
|
56
|
+
expect(rows[2].dateTimestamp).toBeNull();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('keeps identical filenames with different ids', () => {
|
|
60
|
+
const tree: FilerObjectTree = { entities: {
|
|
61
|
+
one: { id: 1, filename: 'same.pdf', mime: 'application/pdf' },
|
|
62
|
+
two: { id: 2, filename: 'same.pdf', mime: 'application/pdf' },
|
|
63
|
+
} };
|
|
64
|
+
expect(FilerFormatter.getFormattedInvestmentDocuments(
|
|
65
|
+
[{ access: 'private', tree }],
|
|
66
|
+
'https://files.example.test',
|
|
67
|
+
)).toHaveLength(2);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('keeps minimal leaf nodes, prefers filename over metadata name, and does not mask invalid creation dates', () => {
|
|
71
|
+
const tree: FilerObjectTree = { entities: {
|
|
72
|
+
minimal: { id: 10, name: 'Minimal document' },
|
|
73
|
+
named: {
|
|
74
|
+
id: 11,
|
|
75
|
+
name: 'Metadata label',
|
|
76
|
+
filename: 'actual-file.pdf',
|
|
77
|
+
created_at: 'invalid',
|
|
78
|
+
updated_at: '2026-07-22T00:00:00Z',
|
|
79
|
+
},
|
|
80
|
+
} };
|
|
81
|
+
|
|
82
|
+
const rows = FilerFormatter.getFormattedInvestmentDocuments(
|
|
83
|
+
[{ access: 'private', tree }],
|
|
84
|
+
'https://files.example.test',
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
expect(rows.map(row => row.name)).toEqual(['Minimal document', 'actual-file.pdf']);
|
|
88
|
+
expect(rows.find(row => row.id === 11)).toMatchObject({
|
|
89
|
+
date: '—',
|
|
90
|
+
dateTimestamp: null,
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('keeps empty folders out of document rows and exposes them as folder tabs', () => {
|
|
95
|
+
const tree: FilerObjectTree = { entities: {
|
|
96
|
+
tax: {
|
|
97
|
+
id: 20,
|
|
98
|
+
type: 'folder',
|
|
99
|
+
name: 'Tax documents',
|
|
100
|
+
created_at: '2026-08-13T00:00:00Z',
|
|
101
|
+
},
|
|
102
|
+
agreements: {
|
|
103
|
+
id: 21,
|
|
104
|
+
type: 'folder',
|
|
105
|
+
name: 'investment_agreements',
|
|
106
|
+
entities: {
|
|
107
|
+
agreement: {
|
|
108
|
+
id: 22,
|
|
109
|
+
type: 'file',
|
|
110
|
+
filename: 'agreement.pdf',
|
|
111
|
+
mime: 'application/pdf',
|
|
112
|
+
},
|
|
113
|
+
},
|
|
114
|
+
},
|
|
115
|
+
rootDocument: {
|
|
116
|
+
id: 23,
|
|
117
|
+
type: 'file',
|
|
118
|
+
filename: 'root.pdf',
|
|
119
|
+
mime: 'application/pdf',
|
|
120
|
+
},
|
|
121
|
+
} };
|
|
122
|
+
const sources = [{ access: 'private' as const, tree }];
|
|
123
|
+
|
|
124
|
+
const rows = FilerFormatter.getFormattedInvestmentDocuments(
|
|
125
|
+
sources,
|
|
126
|
+
'https://files.example.test',
|
|
127
|
+
);
|
|
128
|
+
|
|
129
|
+
expect(rows.map(row => row.name)).toEqual(['agreement.pdf', 'root.pdf']);
|
|
130
|
+
expect(rows.some(row => row.id === 20)).toBe(false);
|
|
131
|
+
expect(FilerFormatter.getFolderedInvestmentDocuments(sources)).toEqual([
|
|
132
|
+
'Tax documents',
|
|
133
|
+
'Investment Agreements',
|
|
134
|
+
'Other',
|
|
135
|
+
]);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('keeps an original document but excludes its generated thumbnail descendants', () => {
|
|
139
|
+
const tree: FilerObjectTree = { entities: {
|
|
140
|
+
other: {
|
|
141
|
+
name: 'other',
|
|
142
|
+
entities: {
|
|
143
|
+
'95430aa4bf08da89aad368bc91f70bc1272a90c0': {
|
|
144
|
+
id: 698167,
|
|
145
|
+
filename: '95430aa4bf08da89aad368bc91f70bc1272a90c0',
|
|
146
|
+
original_filename: 'Gemini_Generated_Image.png',
|
|
147
|
+
mime: 'image/png',
|
|
148
|
+
type: 'file',
|
|
149
|
+
entities: {
|
|
150
|
+
big: {
|
|
151
|
+
name: 'big',
|
|
152
|
+
entities: {
|
|
153
|
+
thumbnail: {
|
|
154
|
+
id: 698172,
|
|
155
|
+
original_filename: 'Gemini_Generated_Image.png',
|
|
156
|
+
mime: 'image/avif',
|
|
157
|
+
type: 'file_thumbnail',
|
|
158
|
+
meta_data: { size: 'big' },
|
|
159
|
+
},
|
|
160
|
+
},
|
|
161
|
+
},
|
|
162
|
+
medium: {
|
|
163
|
+
name: 'medium',
|
|
164
|
+
entities: {
|
|
165
|
+
thumbnail: {
|
|
166
|
+
id: 698170,
|
|
167
|
+
original_filename: 'Gemini_Generated_Image.png',
|
|
168
|
+
mime: 'image/avif',
|
|
169
|
+
type: 'file_thumbnail',
|
|
170
|
+
meta_data: { size: 'medium' },
|
|
171
|
+
},
|
|
172
|
+
},
|
|
173
|
+
},
|
|
174
|
+
small: {
|
|
175
|
+
name: 'small',
|
|
176
|
+
entities: {
|
|
177
|
+
thumbnail: {
|
|
178
|
+
id: 698168,
|
|
179
|
+
original_filename: 'Gemini_Generated_Image.png',
|
|
180
|
+
mime: 'image/avif',
|
|
181
|
+
type: 'file_thumbnail',
|
|
182
|
+
meta_data: { size: 'small' },
|
|
183
|
+
},
|
|
184
|
+
},
|
|
185
|
+
},
|
|
186
|
+
},
|
|
187
|
+
},
|
|
188
|
+
},
|
|
189
|
+
},
|
|
190
|
+
} };
|
|
191
|
+
|
|
192
|
+
const rows = FilerFormatter.getFormattedInvestmentDocuments(
|
|
193
|
+
[{ access: 'public', tree }],
|
|
194
|
+
'https://files.example.test',
|
|
195
|
+
);
|
|
196
|
+
|
|
197
|
+
expect(rows).toHaveLength(1);
|
|
198
|
+
expect(rows[0]).toMatchObject({
|
|
199
|
+
id: 698167,
|
|
200
|
+
name: 'Gemini_Generated_Image.png',
|
|
201
|
+
category: 'other',
|
|
202
|
+
typeFormatted: 'Other',
|
|
203
|
+
});
|
|
204
|
+
expect(FilerFormatter.getFolderedInvestmentDocuments([
|
|
205
|
+
{ access: 'public', tree },
|
|
206
|
+
])).toEqual(['Other']);
|
|
207
|
+
});
|
|
208
|
+
});
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import {
|
|
2
|
+
describe,
|
|
3
|
+
expect,
|
|
4
|
+
it,
|
|
5
|
+
} from 'vitest';
|
|
6
|
+
import {
|
|
7
|
+
buildPublicFilerImageSource,
|
|
8
|
+
buildPublicFilerImageSrcset,
|
|
9
|
+
buildPublicFilerImageUrl,
|
|
10
|
+
getPublicFilerWidthDescriptor,
|
|
11
|
+
} from '../publicImage';
|
|
12
|
+
|
|
13
|
+
describe('publicImage', () => {
|
|
14
|
+
it('builds public filer image URLs with injected config', () => {
|
|
15
|
+
expect(buildPublicFilerImageUrl(42, 'medium', {
|
|
16
|
+
filerUrl: 'https://filer.example.com',
|
|
17
|
+
})).toBe('https://filer.example.com/public/files/42?size=medium');
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('passes direct image URLs through without filer config', () => {
|
|
21
|
+
expect(buildPublicFilerImageUrl('https://cdn.example.com/image.png')).toBe('https://cdn.example.com/image.png');
|
|
22
|
+
expect(buildPublicFilerImageUrl('/fallback.svg')).toBe('/fallback.svg');
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('builds width descriptors and srcsets', () => {
|
|
26
|
+
expect(getPublicFilerWidthDescriptor('484x290')).toBe(323);
|
|
27
|
+
expect(buildPublicFilerImageSrcset(42, {
|
|
28
|
+
filerUrl: 'https://filer.example.com',
|
|
29
|
+
})).toBe([
|
|
30
|
+
'https://filer.example.com/public/files/42?size=small 207w',
|
|
31
|
+
'https://filer.example.com/public/files/42?size=medium 323w',
|
|
32
|
+
'https://filer.example.com/public/files/42?size=big 683w',
|
|
33
|
+
].join(', '));
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('falls back when filer config or image id is unavailable', () => {
|
|
37
|
+
expect(buildPublicFilerImageUrl(42)).toBeUndefined();
|
|
38
|
+
expect(buildPublicFilerImageSource(undefined, {
|
|
39
|
+
fallbackSrc: '/fallback.svg',
|
|
40
|
+
})).toBe('/fallback.svg');
|
|
41
|
+
});
|
|
42
|
+
});
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
FilerAccess,
|
|
3
|
+
FilerId,
|
|
4
|
+
FilerObjectTree,
|
|
5
|
+
IFilerItem,
|
|
6
|
+
} from '@global-torque/domain-types/filerTypes';
|
|
7
|
+
|
|
8
|
+
export interface FilerDocumentSource {
|
|
9
|
+
access: FilerAccess;
|
|
10
|
+
tree?: FilerObjectTree | null;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface IFilerItemFormatted extends IFilerItem {
|
|
14
|
+
id: FilerId;
|
|
15
|
+
key: string;
|
|
16
|
+
access: FilerAccess;
|
|
17
|
+
actionUrl: string;
|
|
18
|
+
category: string;
|
|
19
|
+
date: string;
|
|
20
|
+
dateTimestamp: number | null;
|
|
21
|
+
isNew: boolean;
|
|
22
|
+
name: string;
|
|
23
|
+
tagColor?: string;
|
|
24
|
+
typeFormatted: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const MEDIA_CATEGORY = 'media';
|
|
28
|
+
const OTHER_CATEGORY = 'other';
|
|
29
|
+
const INVESTMENT_AGREEMENTS_CATEGORY = 'investment-agreements';
|
|
30
|
+
const TWO_DAYS_MS = 2 * 24 * 60 * 60 * 1000;
|
|
31
|
+
const DATE_FORMATTER = new Intl.DateTimeFormat('en-US', {
|
|
32
|
+
year: 'numeric',
|
|
33
|
+
month: 'numeric',
|
|
34
|
+
day: 'numeric',
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
const normalizeCategory = (value: unknown): string => {
|
|
38
|
+
if (typeof value !== 'string') return OTHER_CATEGORY;
|
|
39
|
+
const normalized = value.trim().toLowerCase().replace(/_/g, '-');
|
|
40
|
+
return normalized || OTHER_CATEGORY;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const capitalizeCategory = (value: string): string => value
|
|
44
|
+
.split('-')
|
|
45
|
+
.filter(Boolean)
|
|
46
|
+
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
|
47
|
+
.join(' ');
|
|
48
|
+
|
|
49
|
+
const parseDate = (value: unknown): number | null => {
|
|
50
|
+
if (typeof value !== 'string' || !value.trim()) return null;
|
|
51
|
+
const timestamp = Date.parse(value);
|
|
52
|
+
return Number.isFinite(timestamp) ? timestamp : null;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const formatDate = (timestamp: number | null): string => (
|
|
56
|
+
timestamp === null ? '—' : DATE_FORMATTER.format(new Date(timestamp))
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
const isFileNode = (node: IFilerItem): boolean => (
|
|
60
|
+
node.id !== undefined
|
|
61
|
+
&& node.type !== 'folder'
|
|
62
|
+
&& (!node.entities
|
|
63
|
+
|| typeof node.filename === 'string'
|
|
64
|
+
|| typeof node.original_filename === 'string'
|
|
65
|
+
|| typeof node.mime === 'string'
|
|
66
|
+
|| node.type === 'file'
|
|
67
|
+
|| node.type === 'link')
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
const isDerivedThumbnail = (node: IFilerItem): boolean => node.type === 'file_thumbnail';
|
|
71
|
+
|
|
72
|
+
const categoryForNode = (node: IFilerItem, inheritedCategory: string): string => {
|
|
73
|
+
const explicit = node['object-type'] ?? node['object-name'];
|
|
74
|
+
return explicit ? normalizeCategory(explicit) : inheritedCategory;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const collectRootCategories = (nodes: Record<string, IFilerItem>): string[] => (
|
|
78
|
+
Object.entries(nodes).flatMap(([nodeKey, node]) => {
|
|
79
|
+
const nodeCategory = categoryForNode(node, OTHER_CATEGORY);
|
|
80
|
+
const ownCategory = normalizeCategory(
|
|
81
|
+
node['object-type'] ?? node['object-name'] ?? node.name ?? nodeKey,
|
|
82
|
+
);
|
|
83
|
+
if (
|
|
84
|
+
nodeCategory === MEDIA_CATEGORY
|
|
85
|
+
|| ownCategory === MEDIA_CATEGORY
|
|
86
|
+
|| isDerivedThumbnail(node)
|
|
87
|
+
) return [];
|
|
88
|
+
|
|
89
|
+
return [isFileNode(node) ? nodeCategory : ownCategory];
|
|
90
|
+
})
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
const collectFiles = (
|
|
94
|
+
nodes: Record<string, IFilerItem>,
|
|
95
|
+
access: FilerAccess,
|
|
96
|
+
filerBaseUrl: string,
|
|
97
|
+
nowMs: number,
|
|
98
|
+
inheritedCategory = OTHER_CATEGORY,
|
|
99
|
+
): IFilerItemFormatted[] => Object.entries(nodes).flatMap(([nodeKey, node]) => {
|
|
100
|
+
const nodeCategory = categoryForNode(node, inheritedCategory);
|
|
101
|
+
if (nodeCategory === MEDIA_CATEGORY || isDerivedThumbnail(node)) return [];
|
|
102
|
+
|
|
103
|
+
if (isFileNode(node)) {
|
|
104
|
+
const id = node.id!;
|
|
105
|
+
const name = String(node.original_filename ?? node.filename ?? node.name ?? 'Document');
|
|
106
|
+
const hasCreatedAt = typeof node.created_at === 'string' && node.created_at.trim().length > 0;
|
|
107
|
+
const timestamp = hasCreatedAt ? parseDate(node.created_at) : parseDate(node.updated_at);
|
|
108
|
+
const ageMs = timestamp === null ? null : nowMs - timestamp;
|
|
109
|
+
const category = nodeCategory;
|
|
110
|
+
const baseUrl = filerBaseUrl.replace(/\/+$/, '');
|
|
111
|
+
|
|
112
|
+
return [{
|
|
113
|
+
...node,
|
|
114
|
+
id,
|
|
115
|
+
key: `${access}:${String(id)}`,
|
|
116
|
+
access,
|
|
117
|
+
actionUrl: `${baseUrl}/${access === 'public' ? 'public' : 'auth'}/files/${encodeURIComponent(String(id))}`,
|
|
118
|
+
category,
|
|
119
|
+
'object-type': category,
|
|
120
|
+
name,
|
|
121
|
+
date: formatDate(timestamp),
|
|
122
|
+
dateTimestamp: timestamp,
|
|
123
|
+
isNew: ageMs !== null && ageMs >= 0 && ageMs < TWO_DAYS_MS,
|
|
124
|
+
tagColor: FilerFormatter.getTagColorByType(category),
|
|
125
|
+
typeFormatted: capitalizeCategory(category),
|
|
126
|
+
url: undefined,
|
|
127
|
+
}];
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const ownFolderCategory = normalizeCategory(
|
|
131
|
+
node['object-type'] ?? node['object-name'] ?? node.name ?? nodeKey,
|
|
132
|
+
);
|
|
133
|
+
const folderCategory = inheritedCategory === OTHER_CATEGORY
|
|
134
|
+
? ownFolderCategory
|
|
135
|
+
: inheritedCategory;
|
|
136
|
+
if (ownFolderCategory === MEDIA_CATEGORY || folderCategory === MEDIA_CATEGORY) return [];
|
|
137
|
+
|
|
138
|
+
return node.entities
|
|
139
|
+
? collectFiles(node.entities, access, filerBaseUrl, nowMs, folderCategory)
|
|
140
|
+
: [];
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
export class FilerFormatter {
|
|
144
|
+
static getTagColorByType(objectType?: string): string | undefined {
|
|
145
|
+
switch (normalizeCategory(objectType)) {
|
|
146
|
+
case OTHER_CATEGORY:
|
|
147
|
+
return 'is--background-purple-light';
|
|
148
|
+
case INVESTMENT_AGREEMENTS_CATEGORY:
|
|
149
|
+
return 'is--background-secondary-light';
|
|
150
|
+
case 'company':
|
|
151
|
+
return 'is--background-yellow-light';
|
|
152
|
+
case 'agreement':
|
|
153
|
+
return 'is--background-primary-light';
|
|
154
|
+
default:
|
|
155
|
+
return undefined;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
static getFormattedInvestmentDocuments(
|
|
160
|
+
sources: FilerDocumentSource[],
|
|
161
|
+
filerBaseUrl: string,
|
|
162
|
+
nowMs = Date.now(),
|
|
163
|
+
): IFilerItemFormatted[] {
|
|
164
|
+
const byCanonicalId = new Map<string, IFilerItemFormatted>();
|
|
165
|
+
|
|
166
|
+
for (const source of sources) {
|
|
167
|
+
if (!source.tree) continue;
|
|
168
|
+
for (const item of collectFiles(source.tree.entities, source.access, filerBaseUrl, nowMs)) {
|
|
169
|
+
const canonicalId = String(item.id);
|
|
170
|
+
const existing = byCanonicalId.get(canonicalId);
|
|
171
|
+
if (!existing || source.access === 'public') byCanonicalId.set(canonicalId, item);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return [...byCanonicalId.values()].sort((left, right) => {
|
|
176
|
+
const leftFirst = left.category === INVESTMENT_AGREEMENTS_CATEGORY;
|
|
177
|
+
const rightFirst = right.category === INVESTMENT_AGREEMENTS_CATEGORY;
|
|
178
|
+
if (leftFirst !== rightFirst) return leftFirst ? -1 : 1;
|
|
179
|
+
const categoryOrder = left.typeFormatted.localeCompare(right.typeFormatted);
|
|
180
|
+
if (categoryOrder !== 0) return categoryOrder;
|
|
181
|
+
if (left.dateTimestamp === null && right.dateTimestamp !== null) return 1;
|
|
182
|
+
if (left.dateTimestamp !== null && right.dateTimestamp === null) return -1;
|
|
183
|
+
return (right.dateTimestamp ?? 0) - (left.dateTimestamp ?? 0);
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
static getFolderedInvestmentDocuments(sources: FilerDocumentSource[]): string[] {
|
|
188
|
+
const categories = new Set<string>();
|
|
189
|
+
for (const source of sources) {
|
|
190
|
+
if (!source.tree) continue;
|
|
191
|
+
for (const category of collectRootCategories(source.tree.entities)) categories.add(category);
|
|
192
|
+
}
|
|
193
|
+
return [...categories].map(capitalizeCategory);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
export const PUBLIC_FILER_IMAGE_SIZE_ORDER = ['small', 'medium', 'big'] as const;
|
|
2
|
+
export type PublicFilerImageSize = (typeof PUBLIC_FILER_IMAGE_SIZE_ORDER)[number];
|
|
3
|
+
|
|
4
|
+
export const DEFAULT_PUBLIC_FILER_IMAGE_DIMENSIONS: Record<PublicFilerImageSize, string> = {
|
|
5
|
+
small: '311x183',
|
|
6
|
+
medium: '484x290',
|
|
7
|
+
big: '1024x614',
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export const PUBLIC_FILER_IMAGE_WIDTH_DIVISOR = 1.5;
|
|
11
|
+
|
|
12
|
+
export interface BuildPublicFilerImageUrlOptions {
|
|
13
|
+
filerUrl?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface BuildPublicFilerImageSrcsetOptions extends BuildPublicFilerImageUrlOptions {
|
|
17
|
+
dimensions?: Partial<Record<PublicFilerImageSize, string>>;
|
|
18
|
+
preferredSize?: PublicFilerImageSize;
|
|
19
|
+
requestedSizes?: PublicFilerImageSize[];
|
|
20
|
+
widthDivisor?: number;
|
|
21
|
+
fallbackSrc?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const isDirectImageUrl = (value: string) => value.startsWith('http') || value.startsWith('/');
|
|
25
|
+
|
|
26
|
+
const normalizeFilerFileId = (value?: number | string | null) => {
|
|
27
|
+
if (typeof value === 'number') {
|
|
28
|
+
return Number.isFinite(value) && value > 0 ? value : undefined;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (typeof value === 'string') {
|
|
32
|
+
if (isDirectImageUrl(value)) {
|
|
33
|
+
return value;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const parsedValue = Number(value);
|
|
37
|
+
return Number.isFinite(parsedValue) && parsedValue > 0 ? parsedValue : undefined;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return undefined;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export const getPublicFilerWidthDescriptor = (
|
|
44
|
+
dimension?: string,
|
|
45
|
+
widthDivisor: number = PUBLIC_FILER_IMAGE_WIDTH_DIVISOR,
|
|
46
|
+
) => {
|
|
47
|
+
if (!dimension || !Number.isFinite(widthDivisor) || widthDivisor <= 0) {
|
|
48
|
+
return undefined;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const [rawWidth] = dimension.split('x');
|
|
52
|
+
const parsedWidth = Number(rawWidth);
|
|
53
|
+
if (!Number.isFinite(parsedWidth) || parsedWidth <= 0) {
|
|
54
|
+
return undefined;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return Math.max(1, Math.round(parsedWidth / widthDivisor));
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
export const buildPublicFilerImageUrl = (
|
|
61
|
+
fileId?: number | string | null,
|
|
62
|
+
size: PublicFilerImageSize = 'medium',
|
|
63
|
+
{
|
|
64
|
+
filerUrl,
|
|
65
|
+
}: BuildPublicFilerImageUrlOptions = {},
|
|
66
|
+
) => {
|
|
67
|
+
const normalizedFileId = normalizeFilerFileId(fileId);
|
|
68
|
+
|
|
69
|
+
if (!normalizedFileId) {
|
|
70
|
+
return undefined;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (typeof normalizedFileId === 'string') {
|
|
74
|
+
return normalizedFileId;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (!filerUrl) {
|
|
78
|
+
return undefined;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return `${filerUrl}/public/files/${normalizedFileId}?size=${size}`;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
export const buildPublicFilerImageSrcset = (
|
|
85
|
+
fileId?: number | string | null,
|
|
86
|
+
{
|
|
87
|
+
dimensions = DEFAULT_PUBLIC_FILER_IMAGE_DIMENSIONS,
|
|
88
|
+
requestedSizes = [...PUBLIC_FILER_IMAGE_SIZE_ORDER],
|
|
89
|
+
widthDivisor = PUBLIC_FILER_IMAGE_WIDTH_DIVISOR,
|
|
90
|
+
filerUrl,
|
|
91
|
+
}: BuildPublicFilerImageSrcsetOptions = {},
|
|
92
|
+
) => {
|
|
93
|
+
const normalizedFileId = normalizeFilerFileId(fileId);
|
|
94
|
+
if (!normalizedFileId || typeof normalizedFileId === 'string') {
|
|
95
|
+
return '';
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return requestedSizes
|
|
99
|
+
.map((size) => {
|
|
100
|
+
const url = buildPublicFilerImageUrl(normalizedFileId, size, { filerUrl });
|
|
101
|
+
const widthDescriptor = getPublicFilerWidthDescriptor(dimensions[size], widthDivisor);
|
|
102
|
+
|
|
103
|
+
if (!url || !widthDescriptor) {
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return `${url} ${widthDescriptor}w`;
|
|
108
|
+
})
|
|
109
|
+
.filter((entry): entry is string => Boolean(entry))
|
|
110
|
+
.join(', ');
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
export const buildPublicFilerImageSource = (
|
|
114
|
+
fileId?: number | string | null,
|
|
115
|
+
{
|
|
116
|
+
preferredSize = 'medium',
|
|
117
|
+
fallbackSrc,
|
|
118
|
+
filerUrl,
|
|
119
|
+
}: BuildPublicFilerImageSrcsetOptions = {},
|
|
120
|
+
) => buildPublicFilerImageUrl(fileId, preferredSize, { filerUrl }) ?? fallbackSrc;
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import Ajv from 'ajv';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import { filterSchema } from '../general.ts';
|
|
4
|
+
|
|
5
|
+
describe('filterSchema', () => {
|
|
6
|
+
it('projects fields without removing inline requirements or mutating the source schema', () => {
|
|
7
|
+
const schema = {
|
|
8
|
+
type: 'object',
|
|
9
|
+
required: ['legacyRoot'],
|
|
10
|
+
properties: {
|
|
11
|
+
inline: {
|
|
12
|
+
type: 'object',
|
|
13
|
+
required: ['inlineField'],
|
|
14
|
+
properties: { inlineField: { type: 'string' } },
|
|
15
|
+
},
|
|
16
|
+
referenced: { $ref: '#/$defs/Named' },
|
|
17
|
+
items: {
|
|
18
|
+
type: 'array',
|
|
19
|
+
items: {
|
|
20
|
+
type: 'object',
|
|
21
|
+
required: ['itemField'],
|
|
22
|
+
properties: { itemField: { type: 'string' } },
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
choice: { type: 'string', enum: ['selected'] },
|
|
26
|
+
omitted: { type: 'string' },
|
|
27
|
+
},
|
|
28
|
+
allOf: [{
|
|
29
|
+
required: ['choice'],
|
|
30
|
+
properties: { choice: { type: 'string' } },
|
|
31
|
+
}],
|
|
32
|
+
$defs: {
|
|
33
|
+
Named: {
|
|
34
|
+
type: 'object',
|
|
35
|
+
required: ['namedRoot'],
|
|
36
|
+
properties: {
|
|
37
|
+
nested: {
|
|
38
|
+
type: 'object',
|
|
39
|
+
required: ['namedNested'],
|
|
40
|
+
properties: { namedNested: { type: 'string' } },
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
const before = JSON.stringify(schema);
|
|
47
|
+
|
|
48
|
+
const filtered = filterSchema(schema as any, {
|
|
49
|
+
inline: {},
|
|
50
|
+
referenced: {},
|
|
51
|
+
items: [],
|
|
52
|
+
choice: 'selected',
|
|
53
|
+
}) as any;
|
|
54
|
+
|
|
55
|
+
expect(JSON.stringify(schema)).toBe(before);
|
|
56
|
+
expect(filtered.required).toBeUndefined();
|
|
57
|
+
expect(filtered.properties.omitted).toBeUndefined();
|
|
58
|
+
expect(filtered.properties.choice.enum).toBeUndefined();
|
|
59
|
+
expect(filtered.properties.inline.required).toEqual(['inlineField']);
|
|
60
|
+
expect(filtered.properties.items.items.required).toEqual(['itemField']);
|
|
61
|
+
expect(filtered.allOf[0].required).toEqual(['choice']);
|
|
62
|
+
expect(filtered.$defs.Named.required).toBeUndefined();
|
|
63
|
+
expect(filtered.$defs.Named.properties.nested.required).toEqual(['namedNested']);
|
|
64
|
+
|
|
65
|
+
const validate = new Ajv({ allErrors: true, strict: false }).compile(filtered);
|
|
66
|
+
expect(validate({
|
|
67
|
+
inline: {},
|
|
68
|
+
referenced: { nested: {} },
|
|
69
|
+
items: [{}],
|
|
70
|
+
choice: 'selected',
|
|
71
|
+
})).toBe(false);
|
|
72
|
+
expect(validate.errors).toEqual(expect.arrayContaining([
|
|
73
|
+
expect.objectContaining({ instancePath: '/inline', keyword: 'required' }),
|
|
74
|
+
expect.objectContaining({ instancePath: '/referenced/nested', keyword: 'required' }),
|
|
75
|
+
expect.objectContaining({ instancePath: '/items/0', keyword: 'required' }),
|
|
76
|
+
]));
|
|
77
|
+
});
|
|
78
|
+
});
|