@stamhoofd/backend 2.137.4 → 2.137.5
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/package.json +23 -17
- package/src/crons/drip-emails.ts +2 -1
- package/src/crons.ts +4 -2
- package/src/email-recipient-loaders/payments.ts +3 -2
- package/src/endpoints/auth/CreateAdminEndpoint.ts +3 -2
- package/src/endpoints/auth/CreateTokenEndpoint.ts +2 -1
- package/src/endpoints/auth/ForgotPasswordEndpoint.ts +3 -2
- package/src/endpoints/auth/PatchUserEndpoint.ts +6 -3
- package/src/endpoints/auth/RetryEmailVerificationEndpoint.ts +2 -1
- package/src/endpoints/auth/SignupEndpoint.ts +4 -2
- package/src/endpoints/global/email/CreateEmailEndpoint.ts +2 -1
- package/src/endpoints/global/email/GetAdminEmailsEndpoint.test.ts +1 -1
- package/src/endpoints/global/email/GetAdminEmailsEndpoint.ts +2 -1
- package/src/endpoints/global/email/GetEmailEndpoint.ts +2 -1
- package/src/endpoints/global/email/GetUserEmailsEndpoint.test.ts +1 -1
- package/src/endpoints/global/email/GetUserEmailsEndpoint.ts +2 -1
- package/src/endpoints/global/email/PatchEmailEndpoint.ts +2 -1
- package/src/endpoints/global/files/ExportToExcelEndpoint.ts +9 -5
- package/src/endpoints/global/files/UploadFile.test.ts +206 -0
- package/src/endpoints/global/files/UploadFile.ts +31 -6
- package/src/endpoints/global/files/UploadImage.test.ts +177 -0
- package/src/endpoints/global/files/UploadImage.ts +23 -4
- package/src/endpoints/global/files/upload-security.test.ts +837 -0
- package/src/endpoints/global/members/PatchOrganizationMembersEndpoint.ts +1 -1
- package/src/endpoints/global/organizations/CreateOrganizationEndpoint.ts +2 -1
- package/src/endpoints/organization/dashboard/documents/GetDocumentTemplateXML.ts +2 -1
- package/src/endpoints/organization/dashboard/organization/SetOrganizationDomainEndpoint.ts +3 -2
- package/src/endpoints/organization/dashboard/users/PatchApiUserEndpoint.ts +5 -3
- package/src/endpoints/organization/dashboard/webshops/PatchWebshopOrdersEndpoint.ts +4 -3
- package/src/endpoints/organization/shared/GetDocumentHtml.ts +2 -1
- package/src/endpoints/organization/webshops/PlaceOrderEndpoint.ts +4 -3
- package/src/excel-loaders/balance-items.ts +1 -1
- package/src/excel-loaders/event-notifications.ts +6 -7
- package/src/excel-loaders/members.test.ts +2 -2
- package/src/excel-loaders/members.ts +9 -10
- package/src/excel-loaders/organizations.ts +14 -20
- package/src/excel-loaders/payments.ts +1 -1
- package/src/excel-loaders/platform-memberships.ts +7 -7
- package/src/excel-loaders/platform-sheets.test.ts +113 -0
- package/src/excel-loaders/receivable-balances.ts +1 -1
- package/src/excel-loaders/registrations.ts +9 -9
- package/src/helpers/AdminPermissionChecker.ts +1 -1
- package/src/helpers/AuthenticatedStructures.ts +12 -9
- package/src/helpers/MembershipCharger.ts +3 -2
- package/src/services/BalanceItemService.ts +2 -1
- package/src/services/DocumentRenderService.test.ts +229 -0
- package/src/services/DocumentRenderService.ts +180 -0
- package/src/services/EmailPreviewService.test.ts +300 -0
- package/src/services/EmailPreviewService.ts +239 -0
- package/src/services/FileSignService.test.ts +85 -0
- package/src/services/FileSignService.ts +23 -1
- package/src/services/OrderService.test.ts +308 -0
- package/src/services/OrderService.ts +214 -0
- package/src/services/OrganizationDNSService.test.ts +177 -0
- package/src/services/OrganizationDNSService.ts +282 -0
- package/src/services/OrganizationEmailService.test.ts +57 -0
- package/src/services/OrganizationEmailService.ts +211 -0
- package/src/services/PasswordForgotService.test.ts +99 -0
- package/src/services/PasswordForgotService.ts +38 -0
- package/src/services/PlatformMembershipService.test.ts +180 -0
- package/src/services/PlatformMembershipService.ts +281 -4
- package/src/services/RegistrationService.ts +3 -2
- package/src/services/STPackageService.test.ts +191 -0
- package/src/services/STPackageService.ts +114 -2
- package/src/services/VerificationCodeService.test.ts +71 -0
- package/src/services/VerificationCodeService.ts +100 -0
- package/tests/e2e/documents.test.ts +2 -1
- package/tests/e2e/private-files.test.ts +77 -14
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import { I18n } from '@stamhoofd/backend-i18n';
|
|
2
|
+
import type { Organization } from '@stamhoofd/models';
|
|
3
|
+
import { sendEmailTemplate } from '@stamhoofd/models';
|
|
4
|
+
import { EmailTemplateType, Replacement, STPackageType } from '@stamhoofd/structures';
|
|
5
|
+
import { Country } from '@stamhoofd/types/Country';
|
|
6
|
+
import { Language } from '@stamhoofd/types/Language';
|
|
7
|
+
|
|
8
|
+
export class OrganizationEmailService {
|
|
9
|
+
static async sendEmailTemplate(organization: Organization, data: {
|
|
10
|
+
type: EmailTemplateType;
|
|
11
|
+
personal?: boolean;
|
|
12
|
+
bcc?: boolean;
|
|
13
|
+
}) {
|
|
14
|
+
const recipients = await organization.getAdminRecipients();
|
|
15
|
+
const defaultI18n = new I18n(Language.Dutch, Country.Belgium);
|
|
16
|
+
const i18n = organization.i18n;
|
|
17
|
+
|
|
18
|
+
const replaceAll = [
|
|
19
|
+
{
|
|
20
|
+
from: defaultI18n.localizedDomains.marketing(),
|
|
21
|
+
to: i18n.localizedDomains.marketing(),
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
from: defaultI18n.$t('%2a'),
|
|
25
|
+
to: i18n.$t('%2a'),
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
from: defaultI18n.$t('%19'),
|
|
29
|
+
to: i18n.$t('%19'),
|
|
30
|
+
},
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
await sendEmailTemplate(null, {
|
|
34
|
+
replaceAll,
|
|
35
|
+
recipients,
|
|
36
|
+
template: {
|
|
37
|
+
type: data.type,
|
|
38
|
+
},
|
|
39
|
+
singleBcc: data.bcc ? { email: 'simon@stamhoofd.be' } : undefined,
|
|
40
|
+
type: 'transactional',
|
|
41
|
+
defaultReplacements: [
|
|
42
|
+
Replacement.create({
|
|
43
|
+
token: 'mailDomain',
|
|
44
|
+
value: organization.privateMeta.mailDomain ?? organization.privateMeta.pendingMailDomain ?? '',
|
|
45
|
+
}),
|
|
46
|
+
Replacement.create({
|
|
47
|
+
token: 'organizationName',
|
|
48
|
+
value: organization.name,
|
|
49
|
+
}),
|
|
50
|
+
],
|
|
51
|
+
unsubscribeType: 'marketing',
|
|
52
|
+
fromStamhoofd: true,
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
static async checkDrips(organization: Organization) {
|
|
57
|
+
const days7 = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
|
|
58
|
+
|
|
59
|
+
// Welcome drip
|
|
60
|
+
// Created maximum 7 days ago
|
|
61
|
+
if (organization.createdAt > days7 && !organization.serverMeta.hasEmail(EmailTemplateType.OrganizationDripWelcome)) {
|
|
62
|
+
await this.sendEmailTemplate(organization, {
|
|
63
|
+
type: EmailTemplateType.OrganizationDripWelcome,
|
|
64
|
+
personal: true,
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
organization.serverMeta.addEmail(EmailTemplateType.OrganizationDripWelcome);
|
|
68
|
+
await organization.save();
|
|
69
|
+
|
|
70
|
+
return; // Never send more than 1 drip email on the same day
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Webshop trial checkin
|
|
74
|
+
if (!organization.serverMeta.hasEmail(EmailTemplateType.OrganizationDripWebshopTrialCheckin)) {
|
|
75
|
+
if (organization.meta.packages.isWebshopsTrial) {
|
|
76
|
+
const activeTime = organization.meta.packages.getActiveTime(STPackageType.TrialWebshops);
|
|
77
|
+
if (activeTime !== null && activeTime > 4 * 24 * 60 * 60 * 1000) {
|
|
78
|
+
// 7 days checkin
|
|
79
|
+
await this.sendEmailTemplate(organization, {
|
|
80
|
+
type: EmailTemplateType.OrganizationDripWebshopTrialCheckin,
|
|
81
|
+
personal: true,
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
organization.serverMeta.addEmail(EmailTemplateType.OrganizationDripWebshopTrialCheckin);
|
|
85
|
+
organization.serverMeta.addEmail(EmailTemplateType.OrganizationDripMembersTrialCheckin); // also mark members checkin
|
|
86
|
+
await organization.save();
|
|
87
|
+
|
|
88
|
+
return; // Never send more than 1 drip email on the same day
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Members trial checkin
|
|
94
|
+
if (!organization.serverMeta.hasEmail(EmailTemplateType.OrganizationDripMembersTrialCheckin)) {
|
|
95
|
+
if (organization.meta.packages.isMembersTrial) {
|
|
96
|
+
const activeTime = organization.meta.packages.getActiveTime(STPackageType.TrialMembers);
|
|
97
|
+
if (activeTime !== null && activeTime > 4 * 24 * 60 * 60 * 1000) {
|
|
98
|
+
// 7 days checkin
|
|
99
|
+
await this.sendEmailTemplate(organization, {
|
|
100
|
+
type: EmailTemplateType.OrganizationDripMembersTrialCheckin,
|
|
101
|
+
personal: true,
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
organization.serverMeta.addEmail(EmailTemplateType.OrganizationDripMembersTrialCheckin);
|
|
105
|
+
organization.serverMeta.addEmail(EmailTemplateType.OrganizationDripWebshopTrialCheckin); // Also mark webshop trial checkin
|
|
106
|
+
await organization.save();
|
|
107
|
+
|
|
108
|
+
return; // Never send more than 1 drip email on the same day
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Webshop trial expired after 1 week
|
|
114
|
+
if (!organization.serverMeta.hasEmail(EmailTemplateType.OrganizationDripWebshopTrialExpired)) {
|
|
115
|
+
if (!organization.meta.packages.useWebshops) {
|
|
116
|
+
const deactivatedTime = organization.meta.packages.getDeactivatedTime(STPackageType.TrialWebshops);
|
|
117
|
+
if (deactivatedTime !== null && deactivatedTime < 14 * 24 * 60 * 60 * 1000 && deactivatedTime > 7 * 24 * 60 * 60 * 1000) {
|
|
118
|
+
await this.sendEmailTemplate(organization, {
|
|
119
|
+
type: EmailTemplateType.OrganizationDripWebshopTrialExpired,
|
|
120
|
+
personal: true,
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
organization.serverMeta.addEmail(EmailTemplateType.OrganizationDripWebshopTrialExpired);
|
|
124
|
+
organization.serverMeta.addEmail(EmailTemplateType.OrganizationDripMembersTrialExpired); // also mark members
|
|
125
|
+
await organization.save();
|
|
126
|
+
|
|
127
|
+
return; // Never send more than 1 drip email on the same day
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (!organization.serverMeta.hasEmail(EmailTemplateType.OrganizationDripMembersTrialExpired)) {
|
|
133
|
+
if (!organization.meta.packages.useMembers) {
|
|
134
|
+
const deactivatedTime = organization.meta.packages.getDeactivatedTime(STPackageType.TrialMembers);
|
|
135
|
+
if (deactivatedTime !== null && deactivatedTime < 14 * 24 * 60 * 60 * 1000 && deactivatedTime > 7 * 24 * 60 * 60 * 1000) {
|
|
136
|
+
await this.sendEmailTemplate(organization, {
|
|
137
|
+
type: EmailTemplateType.OrganizationDripMembersTrialExpired,
|
|
138
|
+
personal: true,
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
organization.serverMeta.addEmail(EmailTemplateType.OrganizationDripMembersTrialExpired);
|
|
142
|
+
organization.serverMeta.addEmail(EmailTemplateType.OrganizationDripWebshopTrialExpired); // also mark webshops
|
|
143
|
+
await organization.save();
|
|
144
|
+
|
|
145
|
+
return; // Never send more than 1 drip email on the same day
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// trial expired reminder (after 10 months)
|
|
151
|
+
if (!organization.serverMeta.hasEmail(EmailTemplateType.OrganizationDripTrialExpiredReminder)) {
|
|
152
|
+
if (!organization.meta.packages.isPaid && !organization.meta.packages.wasPaid) {
|
|
153
|
+
const deactivatedTime1 = organization.meta.packages.getDeactivatedTime(STPackageType.TrialWebshops);
|
|
154
|
+
const deactivatedTime2 = organization.meta.packages.getDeactivatedTime(STPackageType.TrialMembers);
|
|
155
|
+
|
|
156
|
+
const deactivatedTime = deactivatedTime1 && deactivatedTime2 ? Math.max(deactivatedTime1, deactivatedTime2) : (deactivatedTime1 ? deactivatedTime1 : deactivatedTime2);
|
|
157
|
+
|
|
158
|
+
if (deactivatedTime !== null && deactivatedTime > 10 * 30 * 24 * 60 * 60 * 1000 && deactivatedTime < 13 * 31 * 24 * 60 * 60 * 1000) {
|
|
159
|
+
await this.sendEmailTemplate(organization, {
|
|
160
|
+
type: EmailTemplateType.OrganizationDripTrialExpiredReminder,
|
|
161
|
+
personal: true,
|
|
162
|
+
bcc: true,
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
organization.serverMeta.addEmail(EmailTemplateType.OrganizationDripTrialExpiredReminder);
|
|
166
|
+
await organization.save();
|
|
167
|
+
|
|
168
|
+
return; // Never send more than 1 drip email on the same day
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (!organization.serverMeta.hasEmail(EmailTemplateType.OrganizationDripWebshopNotRenewed)) {
|
|
174
|
+
if (!organization.meta.packages.useWebshops) {
|
|
175
|
+
const deactivatedTime = organization.meta.packages.getDeactivatedTime(STPackageType.Webshops);
|
|
176
|
+
|
|
177
|
+
if (deactivatedTime !== null && deactivatedTime > 30 * 24 * 60 * 60 * 1000 && deactivatedTime < 30 * 3 * 24 * 60 * 60 * 1000) {
|
|
178
|
+
await this.sendEmailTemplate(organization, {
|
|
179
|
+
type: EmailTemplateType.OrganizationDripWebshopNotRenewed,
|
|
180
|
+
personal: true,
|
|
181
|
+
bcc: true,
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
organization.serverMeta.addEmail(EmailTemplateType.OrganizationDripWebshopNotRenewed);
|
|
185
|
+
await organization.save();
|
|
186
|
+
|
|
187
|
+
return; // Never send more than 1 drip email on the same day
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (!organization.serverMeta.hasEmail(EmailTemplateType.OrganizationDripMembersNotRenewed)) {
|
|
193
|
+
if (!organization.meta.packages.useMembers) {
|
|
194
|
+
const deactivatedTime = organization.meta.packages.getDeactivatedTime(STPackageType.Members);
|
|
195
|
+
|
|
196
|
+
if (deactivatedTime !== null && deactivatedTime > 30 * 24 * 60 * 60 * 1000 && deactivatedTime < 30 * 3 * 24 * 60 * 60 * 1000) {
|
|
197
|
+
await this.sendEmailTemplate(organization, {
|
|
198
|
+
type: EmailTemplateType.OrganizationDripMembersNotRenewed,
|
|
199
|
+
personal: true,
|
|
200
|
+
bcc: true,
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
organization.serverMeta.addEmail(EmailTemplateType.OrganizationDripMembersNotRenewed);
|
|
204
|
+
await organization.save();
|
|
205
|
+
|
|
206
|
+
return; // Never send more than 1 drip email on the same day
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { I18n } from '@stamhoofd/backend-i18n';
|
|
2
|
+
import { Organization, OrganizationFactory, PasswordToken, User, UserFactory } from '@stamhoofd/models';
|
|
3
|
+
import { Address, OrganizationMetaData, PermissionLevel, Permissions } from '@stamhoofd/structures';
|
|
4
|
+
import { TestUtils } from '@stamhoofd/test-utils';
|
|
5
|
+
import { Country } from '@stamhoofd/types/Country';
|
|
6
|
+
import { PasswordForgotService } from './PasswordForgotService.js';
|
|
7
|
+
|
|
8
|
+
const i18n = new I18n(I18n.defaultLanguage, I18n.defaultCountry);
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* An organization that is not saved in the database: enough to build an url with.
|
|
12
|
+
*/
|
|
13
|
+
function buildOrganization(id = 'organization-1') {
|
|
14
|
+
const organization = new Organization();
|
|
15
|
+
organization.id = id;
|
|
16
|
+
organization.name = 'Test organization';
|
|
17
|
+
organization.uri = 'test-organization';
|
|
18
|
+
organization.meta = OrganizationMetaData.create({});
|
|
19
|
+
organization.address = Address.create({
|
|
20
|
+
street: 'Demostraat',
|
|
21
|
+
number: '12',
|
|
22
|
+
city: 'Gent',
|
|
23
|
+
postalCode: '9000',
|
|
24
|
+
country: Country.Belgium,
|
|
25
|
+
});
|
|
26
|
+
return organization;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function buildUser(organizationId: string | null) {
|
|
30
|
+
const user = new User();
|
|
31
|
+
user.id = 'user-1';
|
|
32
|
+
user.organizationId = organizationId;
|
|
33
|
+
user.email = 'user@example.com';
|
|
34
|
+
return user;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function buildToken(user: User, token: string) {
|
|
38
|
+
const passwordToken = new PasswordToken();
|
|
39
|
+
passwordToken.token = token;
|
|
40
|
+
passwordToken.userId = user.id;
|
|
41
|
+
return passwordToken;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
describe('PasswordForgotService', () => {
|
|
45
|
+
beforeEach(() => {
|
|
46
|
+
TestUtils.setEnvironment('userMode', 'organization');
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe('getPasswordRecoveryUrl', () => {
|
|
50
|
+
test('An administrator of the organization gets a dashboard url', async () => {
|
|
51
|
+
const organization = await new OrganizationFactory({}).create();
|
|
52
|
+
const admin = await new UserFactory({
|
|
53
|
+
organization,
|
|
54
|
+
permissions: Permissions.create({ level: PermissionLevel.Full }),
|
|
55
|
+
}).create();
|
|
56
|
+
|
|
57
|
+
const url = new URL(await PasswordForgotService.getPasswordRecoveryUrl(admin, organization, i18n));
|
|
58
|
+
|
|
59
|
+
expect(url.host).toBe(STAMHOOFD.domains.dashboard);
|
|
60
|
+
expect(url.pathname).toMatch(/\/reset-password$/);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('A user without permissions for the organization gets a registration url', async () => {
|
|
64
|
+
const organization = await new OrganizationFactory({}).create();
|
|
65
|
+
const member = await new UserFactory({ organization }).create();
|
|
66
|
+
|
|
67
|
+
const url = new URL(await PasswordForgotService.getPasswordRecoveryUrl(member, organization, i18n));
|
|
68
|
+
|
|
69
|
+
expect(url.host).not.toBe(STAMHOOFD.domains.dashboard);
|
|
70
|
+
expect(url.host.split('.')[0]).toBe(organization.uri);
|
|
71
|
+
expect(url.pathname).toMatch(/\/reset-password$/);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('The url points to the reset-password page with a url encoded token', async () => {
|
|
75
|
+
const organization = buildOrganization();
|
|
76
|
+
const user = buildUser(organization.id);
|
|
77
|
+
|
|
78
|
+
const url = await PasswordForgotService.getPasswordRecoveryUrlForToken(buildToken(user, 'a+b/c=d&e'), organization, i18n, user);
|
|
79
|
+
|
|
80
|
+
expect(url).toMatch(/\/reset-password\?token=a%2Bb%2Fc%3Dd%26e$/);
|
|
81
|
+
expect(new URL(url).searchParams.get('token')).toBe('a+b/c=d&e');
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test('Throws when the user belongs to a different organization', async () => {
|
|
85
|
+
const user = buildUser('a-different-organization');
|
|
86
|
+
|
|
87
|
+
await expect(PasswordForgotService.getPasswordRecoveryUrlForToken(buildToken(user, 'my-token'), buildOrganization(), i18n, user))
|
|
88
|
+
.rejects
|
|
89
|
+
.toThrow('Unexpected mismatch in organization id for PasswordToken');
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test('Allows a user without an organization in every scope', async () => {
|
|
93
|
+
const user = buildUser(null);
|
|
94
|
+
|
|
95
|
+
await expect(PasswordForgotService.getPasswordRecoveryUrlForToken(buildToken(user, 'my-token'), buildOrganization(), i18n, user))
|
|
96
|
+
.toResolve();
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { I18n } from '@stamhoofd/backend-i18n';
|
|
2
|
+
import type { Organization } from '@stamhoofd/models';
|
|
3
|
+
import { PasswordToken, Platform, User } from '@stamhoofd/models';
|
|
4
|
+
import { getAppHost } from '@stamhoofd/structures';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Builds the password recovery links. Which app the link points to depends on whether the user is an
|
|
8
|
+
* administrator of the organization.
|
|
9
|
+
*/
|
|
10
|
+
export class PasswordForgotService {
|
|
11
|
+
/**
|
|
12
|
+
* Create a new password token for the user and build the recovery url for it.
|
|
13
|
+
*/
|
|
14
|
+
static async getPasswordRecoveryUrl(user: User, organization: Organization | null, i18n: I18n, validUntil?: Date) {
|
|
15
|
+
// Send an e-mail to say you already have an account + follow password forgot flow
|
|
16
|
+
const token = await PasswordToken.createToken(user, validUntil);
|
|
17
|
+
return await this.getPasswordRecoveryUrlForToken(token, organization, i18n, user);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Build the recovery url for an already created password token.
|
|
22
|
+
* Pass the user to avoid an extra query when it is already loaded.
|
|
23
|
+
*/
|
|
24
|
+
static async getPasswordRecoveryUrlForToken(token: PasswordToken, organization: Organization | null, i18n: I18n, user?: User) {
|
|
25
|
+
const tokenUser = user ?? await User.getByID(token.userId);
|
|
26
|
+
if (!tokenUser) {
|
|
27
|
+
throw new Error('PasswordToken without a valid user');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (tokenUser.organizationId !== null && ((tokenUser.organizationId ?? null) !== (organization?.id ?? null))) {
|
|
31
|
+
throw new Error('Unexpected mismatch in organization id for PasswordToken');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const hasOrganizationPermissions = organization ? tokenUser.permissions?.forOrganization(organization, await Platform.getSharedStruct())?.isEmpty === false : false;
|
|
35
|
+
const host = 'https://' + getAppHost(hasOrganizationPermissions ? 'dashboard' : 'registration', organization, hasOrganizationPermissions, i18n);
|
|
36
|
+
return host + '/reset-password?token=' + encodeURIComponent(token.token);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { MemberFactory, MemberPlatformMembership, OrganizationFactory, Platform, RegistrationFactory, RegistrationPeriodFactory } from '@stamhoofd/models';
|
|
2
|
+
import { PlatformMembershipType, PlatformMembershipTypeBehaviour, PlatformMembershipTypeConfig, PlatformMembershipTypeConfigPrice, ReduceablePrice } from '@stamhoofd/structures';
|
|
3
|
+
import { TestUtils } from '@stamhoofd/test-utils';
|
|
4
|
+
import { Formatter } from '@stamhoofd/utility';
|
|
5
|
+
import { PlatformMembershipService } from './PlatformMembershipService.js';
|
|
6
|
+
|
|
7
|
+
describe('PlatformMembershipService.calculatePrice', () => {
|
|
8
|
+
const membershipPrice = 25_00;
|
|
9
|
+
|
|
10
|
+
beforeEach(() => {
|
|
11
|
+
TestUtils.setEnvironment('userMode', 'platform');
|
|
12
|
+
vitest.useFakeTimers({ shouldAdvanceTime: true, toFake: ['Date'] }).setSystemTime(new Date(2024, 4, 1, 0, 0, 0, 0));
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
afterEach(() => {
|
|
16
|
+
vitest.useRealTimers();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
async function setupDaysMembership(maximumDays: number) {
|
|
20
|
+
const period = await new RegistrationPeriodFactory({
|
|
21
|
+
startDate: new Date(2024, 0, 1, 0, 0, 0, 0),
|
|
22
|
+
endDate: new Date(2024, 11, 31, 23, 59, 59, 999),
|
|
23
|
+
}).create();
|
|
24
|
+
const organization = await new OrganizationFactory({ period }).create();
|
|
25
|
+
const member = await new MemberFactory({ organization }).create();
|
|
26
|
+
const membershipType = PlatformMembershipType.create({
|
|
27
|
+
name: 'Days membership',
|
|
28
|
+
behaviour: PlatformMembershipTypeBehaviour.Days,
|
|
29
|
+
periods: new Map([
|
|
30
|
+
[period.id, PlatformMembershipTypeConfig.create({
|
|
31
|
+
startDate: period.startDate,
|
|
32
|
+
endDate: period.endDate,
|
|
33
|
+
maximumDays,
|
|
34
|
+
})],
|
|
35
|
+
]),
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
const platform = await Platform.getForEditing();
|
|
39
|
+
platform.periodId = period.id;
|
|
40
|
+
platform.config.membershipTypes = [membershipType];
|
|
41
|
+
await platform.save();
|
|
42
|
+
|
|
43
|
+
const membership = new MemberPlatformMembership();
|
|
44
|
+
membership.memberId = member.id;
|
|
45
|
+
membership.membershipTypeId = membershipType.id;
|
|
46
|
+
membership.organizationId = organization.id;
|
|
47
|
+
membership.periodId = period.id;
|
|
48
|
+
|
|
49
|
+
return { member, membership };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Creates a period membership type with a single configured price, and a membership that is
|
|
54
|
+
* ready to be priced. The period has a previous period, which is required to become eligible
|
|
55
|
+
* for a trial.
|
|
56
|
+
*/
|
|
57
|
+
async function setupPeriodMembership(options: { trialDays?: number } = {}) {
|
|
58
|
+
const previousPeriod = await new RegistrationPeriodFactory({
|
|
59
|
+
startDate: new Date(2023, 0, 1, 0, 0, 0, 0),
|
|
60
|
+
endDate: new Date(2023, 11, 31, 23, 59, 59, 0),
|
|
61
|
+
}).create();
|
|
62
|
+
|
|
63
|
+
const period = await new RegistrationPeriodFactory({
|
|
64
|
+
startDate: new Date(2024, 0, 1, 0, 0, 0, 0),
|
|
65
|
+
endDate: new Date(2024, 11, 31, 23, 59, 59, 0),
|
|
66
|
+
previousPeriodId: previousPeriod.id,
|
|
67
|
+
}).create();
|
|
68
|
+
|
|
69
|
+
const organization = await new OrganizationFactory({ period }).create();
|
|
70
|
+
const member = await new MemberFactory({ organization }).create();
|
|
71
|
+
|
|
72
|
+
const membershipType = PlatformMembershipType.create({
|
|
73
|
+
name: 'Period membership',
|
|
74
|
+
behaviour: PlatformMembershipTypeBehaviour.Period,
|
|
75
|
+
periods: new Map([
|
|
76
|
+
[period.id, PlatformMembershipTypeConfig.create({
|
|
77
|
+
startDate: period.startDate,
|
|
78
|
+
endDate: period.endDate,
|
|
79
|
+
trialDays: options.trialDays ?? 0,
|
|
80
|
+
prices: [
|
|
81
|
+
PlatformMembershipTypeConfigPrice.create({
|
|
82
|
+
prices: new Map([['', ReduceablePrice.create({ price: membershipPrice })]]),
|
|
83
|
+
}),
|
|
84
|
+
],
|
|
85
|
+
})],
|
|
86
|
+
]),
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
const platform = await Platform.getForEditing();
|
|
90
|
+
platform.periodId = period.id;
|
|
91
|
+
platform.config.membershipTypes = [membershipType];
|
|
92
|
+
await platform.save();
|
|
93
|
+
|
|
94
|
+
const membership = new MemberPlatformMembership();
|
|
95
|
+
membership.memberId = member.id;
|
|
96
|
+
membership.membershipTypeId = membershipType.id;
|
|
97
|
+
membership.organizationId = organization.id;
|
|
98
|
+
membership.periodId = period.id;
|
|
99
|
+
membership.startDate = period.startDate;
|
|
100
|
+
membership.endDate = period.endDate;
|
|
101
|
+
|
|
102
|
+
return { member, membership, organization, period };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
test('allows the inclusive maximum number of days', async () => {
|
|
106
|
+
const { member, membership } = await setupDaysMembership(2);
|
|
107
|
+
membership.startDate = new Date(2024, 4, 1, 0, 0, 0, 0);
|
|
108
|
+
membership.endDate = new Date(2024, 4, 2, 0, 0, 0, 0);
|
|
109
|
+
|
|
110
|
+
await expect(PlatformMembershipService.calculatePrice(membership, member)).resolves.toBeUndefined();
|
|
111
|
+
expect(membership.maximumFreeAmount).toBe(2);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test('rejects days memberships that exceed maximum days', async () => {
|
|
115
|
+
const { member, membership } = await setupDaysMembership(2);
|
|
116
|
+
membership.startDate = new Date(2024, 4, 1, 0, 0, 0, 0);
|
|
117
|
+
membership.endDate = new Date(2024, 4, 3, 0, 0, 0, 0);
|
|
118
|
+
|
|
119
|
+
await expect(PlatformMembershipService.calculatePrice(membership, member)).rejects.toMatchObject({
|
|
120
|
+
code: 'invalid_field',
|
|
121
|
+
field: 'endDate',
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test('calculates the configured price of a period membership', async () => {
|
|
126
|
+
const { member, membership } = await setupPeriodMembership();
|
|
127
|
+
|
|
128
|
+
await expect(PlatformMembershipService.calculatePrice(membership, member)).resolves.toBeUndefined();
|
|
129
|
+
|
|
130
|
+
expect(membership.price).toBe(membershipPrice);
|
|
131
|
+
expect(membership.priceWithoutDiscount).toBe(membershipPrice);
|
|
132
|
+
expect(membership.freeAmount).toBe(0);
|
|
133
|
+
expect(membership.maximumFreeAmount).toBe(1);
|
|
134
|
+
expect(membership.trialUntil).toBeNull();
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test('does not recalculate the price of a locked membership', async () => {
|
|
138
|
+
const { member, membership } = await setupPeriodMembership();
|
|
139
|
+
membership.locked = true;
|
|
140
|
+
membership.price = 1_00;
|
|
141
|
+
membership.priceWithoutDiscount = 2_00;
|
|
142
|
+
|
|
143
|
+
await expect(PlatformMembershipService.calculatePrice(membership, member)).resolves.toBeUndefined();
|
|
144
|
+
|
|
145
|
+
expect(membership.price).toBe(1_00);
|
|
146
|
+
expect(membership.priceWithoutDiscount).toBe(2_00);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test('rejects a membership with an unknown membership type', async () => {
|
|
150
|
+
const { member, membership } = await setupPeriodMembership();
|
|
151
|
+
membership.membershipTypeId = 'unknown-membership-type';
|
|
152
|
+
|
|
153
|
+
await expect(PlatformMembershipService.calculatePrice(membership, member)).rejects.toMatchObject({
|
|
154
|
+
code: 'invalid_membership_type',
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
// The price is not silently set to zero
|
|
158
|
+
expect(membership.price).toBe(0);
|
|
159
|
+
expect(membership.priceWithoutDiscount).toBe(0);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test('grants a trial period only when the member has a registration with a trial', async () => {
|
|
163
|
+
const { member, membership, organization } = await setupPeriodMembership({ trialDays: 14 });
|
|
164
|
+
|
|
165
|
+
// Without a registration that had a trial, the member is not eligible for a trial
|
|
166
|
+
await expect(PlatformMembershipService.calculatePrice(membership, member)).resolves.toBeUndefined();
|
|
167
|
+
expect(membership.trialUntil).toBeNull();
|
|
168
|
+
|
|
169
|
+
const registration = await new RegistrationFactory({ member, organization }).create();
|
|
170
|
+
registration.trialUntil = new Date(2024, 4, 20, 12, 0, 0, 0);
|
|
171
|
+
await registration.save();
|
|
172
|
+
|
|
173
|
+
await expect(PlatformMembershipService.calculatePrice(membership, member)).resolves.toBeUndefined();
|
|
174
|
+
|
|
175
|
+
// The trial ends trialDays after the start date of the membership
|
|
176
|
+
expect(membership.trialUntil).not.toBeNull();
|
|
177
|
+
expect(Formatter.dateIso(membership.trialUntil!)).toBe('2024-01-15');
|
|
178
|
+
expect(membership.price).toBe(membershipPrice);
|
|
179
|
+
});
|
|
180
|
+
});
|