@stamhoofd/backend 2.124.1 → 2.125.0
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 +17 -17
- package/src/boot.ts +1 -0
- package/src/crons/balance-emails.ts +2 -1
- package/src/crons/index.ts +1 -0
- package/src/crons/update-organization-future-events.ts +42 -0
- package/src/email-recipient-loaders/documents.ts +2 -1
- package/src/email-recipient-loaders/members.ts +2 -1
- package/src/email-recipient-loaders/orders.ts +2 -1
- package/src/email-recipient-loaders/organizations.ts +78 -0
- package/src/email-recipient-loaders/payments.ts +3 -2
- package/src/email-recipient-loaders/receivable-balances.ts +2 -1
- package/src/email-recipient-loaders/registrations.ts +2 -1
- package/src/endpoints/admin/organizations/GetOrganizationsEndpoint.ts +12 -2
- package/src/endpoints/global/email/ManageEmailAddressEndpoint.ts +12 -3
- package/src/endpoints/global/email/PatchEmailEndpoint.test.ts +2 -1
- package/src/endpoints/global/events/PatchEventsEndpoint.test.ts +64 -2
- package/src/endpoints/global/events/PatchEventsEndpoint.ts +14 -0
- package/src/endpoints/organization/dashboard/webshops/PatchWebshopOrdersEndpoint.ts +1 -0
- package/src/endpoints/organization/webshops/PlaceOrderEndpoint.test.ts +57 -2
- package/src/helpers/StripePayoutChecker.ts +2 -2
- package/src/seeds/1782728609-update-organization-future-events.ts +11 -0
- package/src/seeds/1782737437-fill-order-payment-customer.ts +59 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stamhoofd/backend",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.125.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"exports": {
|
|
@@ -57,20 +57,20 @@
|
|
|
57
57
|
"@simonbackx/simple-endpoints": "1.21.1",
|
|
58
58
|
"@simonbackx/simple-errors": "1.5.0",
|
|
59
59
|
"@simonbackx/simple-logging": "1.0.1",
|
|
60
|
-
"@stamhoofd/backend-env": "2.
|
|
61
|
-
"@stamhoofd/backend-i18n": "2.
|
|
62
|
-
"@stamhoofd/backend-middleware": "2.
|
|
63
|
-
"@stamhoofd/crons": "2.
|
|
64
|
-
"@stamhoofd/email": "2.
|
|
65
|
-
"@stamhoofd/excel-writer": "2.
|
|
66
|
-
"@stamhoofd/logging": "2.
|
|
67
|
-
"@stamhoofd/models": "2.
|
|
68
|
-
"@stamhoofd/object-differ": "2.
|
|
69
|
-
"@stamhoofd/queues": "2.
|
|
70
|
-
"@stamhoofd/sql": "2.
|
|
71
|
-
"@stamhoofd/structures": "2.
|
|
72
|
-
"@stamhoofd/types": "2.
|
|
73
|
-
"@stamhoofd/utility": "2.
|
|
60
|
+
"@stamhoofd/backend-env": "2.125.0",
|
|
61
|
+
"@stamhoofd/backend-i18n": "2.125.0",
|
|
62
|
+
"@stamhoofd/backend-middleware": "2.125.0",
|
|
63
|
+
"@stamhoofd/crons": "2.125.0",
|
|
64
|
+
"@stamhoofd/email": "2.125.0",
|
|
65
|
+
"@stamhoofd/excel-writer": "2.125.0",
|
|
66
|
+
"@stamhoofd/logging": "2.125.0",
|
|
67
|
+
"@stamhoofd/models": "2.125.0",
|
|
68
|
+
"@stamhoofd/object-differ": "2.125.0",
|
|
69
|
+
"@stamhoofd/queues": "2.125.0",
|
|
70
|
+
"@stamhoofd/sql": "2.125.0",
|
|
71
|
+
"@stamhoofd/structures": "2.125.0",
|
|
72
|
+
"@stamhoofd/types": "2.125.0",
|
|
73
|
+
"@stamhoofd/utility": "2.125.0",
|
|
74
74
|
"archiver": "7.0.1",
|
|
75
75
|
"axios": "1.16.0",
|
|
76
76
|
"base-x": "3.0.11",
|
|
@@ -91,7 +91,7 @@
|
|
|
91
91
|
"stripe": "16.12.0"
|
|
92
92
|
},
|
|
93
93
|
"devDependencies": {
|
|
94
|
-
"@stamhoofd/test-utils": "2.
|
|
94
|
+
"@stamhoofd/test-utils": "2.125.0",
|
|
95
95
|
"@types/cookie": "0.6.0",
|
|
96
96
|
"@types/luxon": "3.7.1",
|
|
97
97
|
"@types/mailparser": "3.4.6",
|
|
@@ -107,5 +107,5 @@
|
|
|
107
107
|
"publishConfig": {
|
|
108
108
|
"access": "public"
|
|
109
109
|
},
|
|
110
|
-
"gitHead": "
|
|
110
|
+
"gitHead": "8fc26e2a47fee0ff97992a55f80a921880f33077"
|
|
111
111
|
}
|
package/src/boot.ts
CHANGED
|
@@ -165,6 +165,7 @@ export const boot = async (options: { killProcess: boolean }) => {
|
|
|
165
165
|
await import('./excel-loaders/registrations.js');
|
|
166
166
|
await import('./email-recipient-loaders/documents.js');
|
|
167
167
|
await import ('./email-recipient-loaders/payments.js');
|
|
168
|
+
await import ('./email-recipient-loaders/organizations.js');
|
|
168
169
|
|
|
169
170
|
productionLog('Opening port...');
|
|
170
171
|
routerServer.listen(STAMHOOFD.PORT ?? 9090);
|
|
@@ -3,8 +3,9 @@ import { CachedBalance, Email, EmailRecipient, Organization, User } from '@stamh
|
|
|
3
3
|
import type { IterableSQLSelect } from '@stamhoofd/sql';
|
|
4
4
|
import { readDynamicSQLExpression, SQL } from '@stamhoofd/sql';
|
|
5
5
|
import type { OrganizationEmail, StamhoofdFilter } from '@stamhoofd/structures';
|
|
6
|
-
import { EmailRecipientFilter,
|
|
6
|
+
import { EmailRecipientFilter, EmailRecipientSubfilter, EmailTemplateType, ReceivableBalanceType } from '@stamhoofd/structures';
|
|
7
7
|
import { ContextInstance } from '../helpers/Context.js';
|
|
8
|
+
import { EmailRecipientFilterType } from '@stamhoofd/structures/email/EmailRecipientFilterType.js';
|
|
8
9
|
|
|
9
10
|
registerCron('balanceEmails', balanceEmails);
|
|
10
11
|
|
package/src/crons/index.ts
CHANGED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Recalculate the hasFutureEvents flag for all organizations.
|
|
3
|
+
*
|
|
4
|
+
* The flag can go stale over time: an event's end date passes without anyone
|
|
5
|
+
* touching the organization, so it keeps reporting future events it no longer
|
|
6
|
+
* has (or vice versa). Recomputing it nightly keeps it accurate.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { registerCron } from '@stamhoofd/crons';
|
|
10
|
+
import { Organization } from '@stamhoofd/models';
|
|
11
|
+
|
|
12
|
+
let lastRunDate: number | null = null;
|
|
13
|
+
|
|
14
|
+
registerCron('updateOrganizationFutureEvents', updateOrganizationFutureEvents);
|
|
15
|
+
|
|
16
|
+
function shouldRun() {
|
|
17
|
+
const now = new Date();
|
|
18
|
+
|
|
19
|
+
if (now.getDate() === lastRunDate) {
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const hour = now.getHours();
|
|
24
|
+
|
|
25
|
+
// between 3 and 5 AM - except in development
|
|
26
|
+
if ((hour < 3 || hour >= 5) && STAMHOOFD.environment !== 'development') {
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return true;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function updateOrganizationFutureEvents() {
|
|
34
|
+
if (!shouldRun()) {
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
lastRunDate = new Date().getDate();
|
|
39
|
+
|
|
40
|
+
console.log('Updating hasFutureEvents for all organizations...');
|
|
41
|
+
await Organization.updateFutureEventsForOrganizations('all');
|
|
42
|
+
}
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { Email, Member } from '@stamhoofd/models';
|
|
2
2
|
import { SQL } from '@stamhoofd/sql';
|
|
3
3
|
import type { LimitedFilteredRequest } from '@stamhoofd/structures';
|
|
4
|
-
import { EmailRecipient,
|
|
4
|
+
import { EmailRecipient, PaginatedResponse, Replacement } from '@stamhoofd/structures';
|
|
5
5
|
import { GetDocumentsEndpoint } from '../endpoints/organization/dashboard/documents/GetDocumentsEndpoint.js';
|
|
6
|
+
import { EmailRecipientFilterType } from '@stamhoofd/structures/email/EmailRecipientFilterType.js';
|
|
6
7
|
|
|
7
8
|
async function fetch(query: LimitedFilteredRequest) {
|
|
8
9
|
const result = await GetDocumentsEndpoint.buildData(query);
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { Email } from '@stamhoofd/models';
|
|
2
2
|
import { SQL } from '@stamhoofd/sql';
|
|
3
3
|
import type { EmailRecipient, LimitedFilteredRequest, MembersBlob } from '@stamhoofd/structures';
|
|
4
|
-
import {
|
|
4
|
+
import { PaginatedResponse, mergeFilters } from '@stamhoofd/structures';
|
|
5
5
|
import { GetMembersEndpoint } from '../endpoints/global/members/GetMembersEndpoint.js';
|
|
6
|
+
import { EmailRecipientFilterType } from '@stamhoofd/structures/email/EmailRecipientFilterType.js';
|
|
6
7
|
|
|
7
8
|
async function getRecipients(result: PaginatedResponse<MembersBlob, LimitedFilteredRequest>, type: 'member' | 'parents' | 'unverified') {
|
|
8
9
|
const recipients: EmailRecipient[] = [];
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { Email, Webshop } from '@stamhoofd/models';
|
|
2
2
|
import type { EmailRecipient, LimitedFilteredRequest, WebshopPreview } from '@stamhoofd/structures';
|
|
3
|
-
import {
|
|
3
|
+
import { mergeFilters, PaginatedResponse } from '@stamhoofd/structures';
|
|
4
4
|
import { GetWebshopOrdersEndpoint } from '../endpoints/organization/dashboard/webshops/GetWebshopOrdersEndpoint.js';
|
|
5
5
|
import { AuthenticatedStructures } from '../helpers/AuthenticatedStructures.js';
|
|
6
6
|
import { Context } from '../helpers/Context.js';
|
|
7
|
+
import { EmailRecipientFilterType } from '@stamhoofd/structures/email/EmailRecipientFilterType.js';
|
|
7
8
|
|
|
8
9
|
Email.recipientLoaders.set(EmailRecipientFilterType.Orders, {
|
|
9
10
|
fetch: async (query: LimitedFilteredRequest) => {
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import type { Organization, User } from '@stamhoofd/models';
|
|
2
|
+
import { Platform } from '@stamhoofd/models';
|
|
3
|
+
import { Email } from '@stamhoofd/models';
|
|
4
|
+
import type { InMemoryFilterDefinitions, LimitedFilteredRequest, StamhoofdFilter, Platform as PlatformStruct } from '@stamhoofd/structures';
|
|
5
|
+
import { baseInMemoryFilterCompilers, compileToInMemoryFilter, createInMemoryFilterCompiler, EmailRecipient, Replacement } from '@stamhoofd/structures';
|
|
6
|
+
import { PaginatedResponse } from '@stamhoofd/structures';
|
|
7
|
+
import { EmailRecipientFilterType } from '@stamhoofd/structures/email/EmailRecipientFilterType.js';
|
|
8
|
+
import { GetOrganizationsEndpoint } from '../endpoints/admin/organizations/GetOrganizationsEndpoint.js';
|
|
9
|
+
|
|
10
|
+
function userToFilterableAdmin(user: User, platform: PlatformStruct, organization: Organization) {
|
|
11
|
+
return {
|
|
12
|
+
id: user.id,
|
|
13
|
+
permissions: user.permissions?.forOrganization(organization, platform),
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
const filterableAdminFilterCompilers: InMemoryFilterDefinitions = {
|
|
17
|
+
...baseInMemoryFilterCompilers,
|
|
18
|
+
permissions: createInMemoryFilterCompiler(['permissions'], {
|
|
19
|
+
...baseInMemoryFilterCompilers,
|
|
20
|
+
level: createInMemoryFilterCompiler('level'),
|
|
21
|
+
accessRights: createInMemoryFilterCompiler('accessRights'),
|
|
22
|
+
}),
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
Email.recipientLoaders.set(EmailRecipientFilterType.Organizations, {
|
|
26
|
+
fetch: fetchRecipients,
|
|
27
|
+
|
|
28
|
+
count: async (query: LimitedFilteredRequest, subfilter: StamhoofdFilter | null) => {
|
|
29
|
+
const q = await GetOrganizationsEndpoint.buildQuery(query);
|
|
30
|
+
const base = await q.count();
|
|
31
|
+
|
|
32
|
+
if (base < 100) {
|
|
33
|
+
// Do full scan
|
|
34
|
+
query.limit = 100;
|
|
35
|
+
const result = await fetchRecipients(query, subfilter);
|
|
36
|
+
return result.results.length;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return base;
|
|
40
|
+
},
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
async function fetchRecipients(query: LimitedFilteredRequest, subfilter: StamhoofdFilter | null) {
|
|
44
|
+
const result = await GetOrganizationsEndpoint.buildModels(query);
|
|
45
|
+
const compiledFilter = compileToInMemoryFilter(subfilter, filterableAdminFilterCompilers);
|
|
46
|
+
const platform = await Platform.getSharedStruct();
|
|
47
|
+
|
|
48
|
+
// Map recipients to admins
|
|
49
|
+
const recipients: EmailRecipient[] = [];
|
|
50
|
+
for (const organization of result.results) {
|
|
51
|
+
// todo: filter admins
|
|
52
|
+
const users = await organization.getAdmins();
|
|
53
|
+
const filteredUsers = users.filter((user) => {
|
|
54
|
+
const filterable = userToFilterableAdmin(user, platform, organization);
|
|
55
|
+
return compiledFilter(filterable);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
recipients.push(
|
|
59
|
+
...organization.adminsToRecipients(filteredUsers).map((r) => {
|
|
60
|
+
return EmailRecipient.create({
|
|
61
|
+
...r,
|
|
62
|
+
replacements: [
|
|
63
|
+
...r.replacements,
|
|
64
|
+
Replacement.create({
|
|
65
|
+
token: 'organizationName',
|
|
66
|
+
value: organization.name,
|
|
67
|
+
}),
|
|
68
|
+
],
|
|
69
|
+
});
|
|
70
|
+
}),
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return new PaginatedResponse({
|
|
75
|
+
results: recipients,
|
|
76
|
+
next: result.next,
|
|
77
|
+
});
|
|
78
|
+
}
|
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
import type { RecipientLoader } from '@stamhoofd/models';
|
|
2
|
-
import { BalanceItem, BalanceItemPayment, Email, Member, MemberResponsibilityRecord,
|
|
2
|
+
import { BalanceItem, BalanceItemPayment, Email, Member, MemberResponsibilityRecord, Organization, Payment, User } from '@stamhoofd/models';
|
|
3
3
|
import { compileToSQLFilter, SQL } from '@stamhoofd/sql';
|
|
4
4
|
import type { LimitedFilteredRequest, PaymentGeneral, StamhoofdFilter } from '@stamhoofd/structures';
|
|
5
|
-
import { CountFilteredRequest, EmailRecipient,
|
|
5
|
+
import { CountFilteredRequest, EmailRecipient, PaginatedResponse, PaymentMethod } from '@stamhoofd/structures';
|
|
6
6
|
import { Formatter } from '@stamhoofd/utility';
|
|
7
7
|
import { GetPaymentsEndpoint } from '../endpoints/organization/dashboard/payments/GetPaymentsEndpoint.js';
|
|
8
8
|
import { memberResponsibilityRecordFilterCompilers } from '../sql-filters/member-responsibility-records.js';
|
|
9
9
|
import type { ReplacementsOptions } from '../email-replacements/getEmailReplacementsForPayment.js';
|
|
10
10
|
import { buildReplacementOptions, getEmailReplacementsForPayment } from '../email-replacements/getEmailReplacementsForPayment.js';
|
|
11
|
+
import { EmailRecipientFilterType } from '@stamhoofd/structures/email/EmailRecipientFilterType.js';
|
|
11
12
|
|
|
12
13
|
type BeforeFetchAllResult = {
|
|
13
14
|
doesIncludePaymentWithoutOrders: boolean;
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { CachedBalance, Email } from '@stamhoofd/models';
|
|
2
2
|
import type { LimitedFilteredRequest, StamhoofdFilter } from '@stamhoofd/structures';
|
|
3
|
-
import { BalanceItem as BalanceItemStruct, compileToInMemoryFilter, EmailRecipient,
|
|
3
|
+
import { BalanceItem as BalanceItemStruct, compileToInMemoryFilter, EmailRecipient, PaginatedResponse, receivableBalanceObjectContactInMemoryFilterCompilers, ReceivableBalanceType, Replacement } from '@stamhoofd/structures';
|
|
4
4
|
import { Formatter } from '@stamhoofd/utility';
|
|
5
5
|
import { GetReceivableBalancesEndpoint } from '../endpoints/organization/dashboard/receivable-balances/GetReceivableBalancesEndpoint.js';
|
|
6
|
+
import { EmailRecipientFilterType } from '@stamhoofd/structures/email/EmailRecipientFilterType.js';
|
|
6
7
|
|
|
7
8
|
async function fetch(query: LimitedFilteredRequest, subfilter: StamhoofdFilter | null) {
|
|
8
9
|
const result = await GetReceivableBalancesEndpoint.buildData(query);
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { Email, Member } from '@stamhoofd/models';
|
|
2
2
|
import { SQL } from '@stamhoofd/sql';
|
|
3
3
|
import type { EmailRecipient, LimitedFilteredRequest, RegistrationsBlob } from '@stamhoofd/structures';
|
|
4
|
-
import {
|
|
4
|
+
import { PaginatedResponse, mergeFilters } from '@stamhoofd/structures';
|
|
5
5
|
import { GetRegistrationsEndpoint } from '../endpoints/global/registration/GetRegistrationsEndpoint.js';
|
|
6
6
|
import { memberJoin } from '../sql-filters/registrations.js';
|
|
7
|
+
import { EmailRecipientFilterType } from '@stamhoofd/structures/email/EmailRecipientFilterType.js';
|
|
7
8
|
|
|
8
9
|
async function getRecipients(result: PaginatedResponse<RegistrationsBlob, LimitedFilteredRequest>, type: 'member' | 'parents' | 'unverified') {
|
|
9
10
|
const recipients: EmailRecipient[] = [];
|
|
@@ -5,6 +5,7 @@ import { SimpleError } from '@simonbackx/simple-errors';
|
|
|
5
5
|
import { Organization } from '@stamhoofd/models';
|
|
6
6
|
import { SQL, applySQLSorter, compileToSQLFilter } from '@stamhoofd/sql';
|
|
7
7
|
import type { CountFilteredRequest, Organization as OrganizationStruct, StamhoofdFilter } from '@stamhoofd/structures';
|
|
8
|
+
import { UnencodeablePaginatedResponse } from '@stamhoofd/structures';
|
|
8
9
|
import { LimitedFilteredRequest, PaginatedResponse, PermissionLevel, assertSort, getSortFilter } from '@stamhoofd/structures';
|
|
9
10
|
|
|
10
11
|
import type { SQLResultNamespacedRow } from '@simonbackx/simple-database';
|
|
@@ -130,7 +131,7 @@ export class GetOrganizationsEndpoint extends Endpoint<Params, Query, Body, Resp
|
|
|
130
131
|
return query;
|
|
131
132
|
}
|
|
132
133
|
|
|
133
|
-
static async
|
|
134
|
+
static async buildModels(requestQuery: LimitedFilteredRequest): Promise<UnencodeablePaginatedResponse<Organization[], LimitedFilteredRequest>> {
|
|
134
135
|
const maxLimit = Context.auth.hasSomePlatformAccess() ? 1000 : 100;
|
|
135
136
|
|
|
136
137
|
if (requestQuery.limit > maxLimit) {
|
|
@@ -187,8 +188,17 @@ export class GetOrganizationsEndpoint extends Endpoint<Params, Query, Body, Resp
|
|
|
187
188
|
}
|
|
188
189
|
}
|
|
189
190
|
|
|
191
|
+
return new UnencodeablePaginatedResponse<Organization[], LimitedFilteredRequest>({
|
|
192
|
+
results: organizations,
|
|
193
|
+
next,
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
static async buildData(requestQuery: LimitedFilteredRequest): Promise<PaginatedResponse<OrganizationStruct[], LimitedFilteredRequest>> {
|
|
198
|
+
const { results, next } = await this.buildModels(requestQuery);
|
|
199
|
+
|
|
190
200
|
return new PaginatedResponse<OrganizationStruct[], LimitedFilteredRequest>({
|
|
191
|
-
results: await AuthenticatedStructures.organizations(
|
|
201
|
+
results: await AuthenticatedStructures.organizations(results),
|
|
192
202
|
next,
|
|
193
203
|
});
|
|
194
204
|
}
|
|
@@ -44,10 +44,15 @@ class Body extends AutoEncoder {
|
|
|
44
44
|
export const unblockLimiter = new RateLimiter({
|
|
45
45
|
limits: [
|
|
46
46
|
{
|
|
47
|
-
// Max
|
|
48
|
-
limit:
|
|
47
|
+
// Max 25 per week
|
|
48
|
+
limit: 25,
|
|
49
49
|
duration: 24 * 60 * 1000 * 60 * 7,
|
|
50
50
|
},
|
|
51
|
+
{
|
|
52
|
+
// Max 50 per month
|
|
53
|
+
limit: 50,
|
|
54
|
+
duration: 24 * 60 * 1000 * 60 * 30,
|
|
55
|
+
},
|
|
51
56
|
],
|
|
52
57
|
});
|
|
53
58
|
|
|
@@ -129,12 +134,16 @@ export class ManageEmailAddressEndpoint extends Endpoint<Params, Query, Body, Re
|
|
|
129
134
|
for (const email of emails) {
|
|
130
135
|
const wasBlocked = email.unsubscribedAll || email.unsubscribedMarketing || email.hardBounce || email.markedAsSpam;
|
|
131
136
|
|
|
132
|
-
if (email.organizationId === null || (organization && email.organizationId
|
|
137
|
+
if (email.organizationId === null || (organization && email.organizationId !== organization.id)) {
|
|
133
138
|
if (Context.auth.hasPlatformFullAccess()) {
|
|
134
139
|
// Only allowed as platform admins
|
|
140
|
+
// This is important, otherwise an individual organization can resubscribe to platform emails
|
|
135
141
|
email.unsubscribedAll = request.body.unsubscribedAll ?? email.unsubscribedAll;
|
|
136
142
|
email.unsubscribedMarketing = request.body.unsubscribedMarketing ?? email.unsubscribedMarketing;
|
|
137
143
|
}
|
|
144
|
+
} else {
|
|
145
|
+
email.unsubscribedAll = request.body.unsubscribedAll ?? email.unsubscribedAll;
|
|
146
|
+
// unsubscribed marketing not allowed for now
|
|
138
147
|
}
|
|
139
148
|
|
|
140
149
|
email.hardBounce = request.body.hardBounce ?? email.hardBounce;
|
|
@@ -2,10 +2,11 @@ import type { AutoEncoderPatchType } from '@simonbackx/simple-encoding';
|
|
|
2
2
|
import { Request } from '@simonbackx/simple-endpoints';
|
|
3
3
|
import type { Organization, RegistrationPeriod, User } from '@stamhoofd/models';
|
|
4
4
|
import { Email, EmailRecipient, GroupFactory, MemberFactory, OrganizationFactory, RegistrationFactory, RegistrationPeriodFactory, Token, UserFactory } from '@stamhoofd/models';
|
|
5
|
-
import { AccessRight, EmailRecipientFilter,
|
|
5
|
+
import { AccessRight, EmailRecipientFilter, EmailRecipientSubfilter, EmailStatus, Email as EmailStruct, OrganizationEmail, Parent, PermissionLevel, Permissions, PermissionsResourceType, ResourcePermissions, UserPermissions, Version } from '@stamhoofd/structures';
|
|
6
6
|
import { STExpect, TestUtils } from '@stamhoofd/test-utils';
|
|
7
7
|
import { testServer } from '../../../../tests/helpers/TestServer.js';
|
|
8
8
|
import { PatchEmailEndpoint } from './PatchEmailEndpoint.js';
|
|
9
|
+
import { EmailRecipientFilterType } from '@stamhoofd/structures/email/EmailRecipientFilterType.js';
|
|
9
10
|
|
|
10
11
|
// Import recipient loaders to initialize them
|
|
11
12
|
import { Formatter } from '@stamhoofd/utility';
|
|
@@ -1,8 +1,9 @@
|
|
|
1
|
+
import { Database } from '@simonbackx/simple-database';
|
|
1
2
|
import { PatchableArray } from '@simonbackx/simple-encoding';
|
|
2
3
|
import type { Endpoint } from '@simonbackx/simple-endpoints';
|
|
3
4
|
import { Request } from '@simonbackx/simple-endpoints';
|
|
4
|
-
import type {
|
|
5
|
-
import { EventFactory, OrganizationFactory, OrganizationRegistrationPeriodFactory, PlatformEventTypeFactory, RegistrationPeriodFactory, Token, UserFactory } from '@stamhoofd/models';
|
|
5
|
+
import type { User } from '@stamhoofd/models';
|
|
6
|
+
import { EventFactory, Organization, OrganizationFactory, OrganizationRegistrationPeriodFactory, PlatformEventTypeFactory, RegistrationPeriodFactory, Token, UserFactory } from '@stamhoofd/models';
|
|
6
7
|
import { AccessRight, Event, Group, GroupSettings, GroupType, OrganizationEventType, PermissionLevel, Permissions, PermissionsResourceType, ResourcePermissions, TranslatedString } from '@stamhoofd/structures';
|
|
7
8
|
import { STExpect, TestUtils } from '@stamhoofd/test-utils';
|
|
8
9
|
import { testServer } from '../../../../tests/helpers/TestServer.js';
|
|
@@ -90,6 +91,67 @@ describe('Endpoint.PatchEventsEndpoint', () => {
|
|
|
90
91
|
});
|
|
91
92
|
});
|
|
92
93
|
|
|
94
|
+
describe('hasFutureEvents recomputation', () => {
|
|
95
|
+
// A global event (organizationId === null) counts towards every organization,
|
|
96
|
+
// so make sure leftover events from other tests don't influence these assertions.
|
|
97
|
+
beforeEach(async () => {
|
|
98
|
+
await Database.delete('DELETE FROM `events`');
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test('Creating a future event sets hasFutureEvents to true on the organization', async () => {
|
|
102
|
+
const organization = await new OrganizationFactory({}).create();
|
|
103
|
+
organization.hasFutureEvents = false;
|
|
104
|
+
await organization.save();
|
|
105
|
+
|
|
106
|
+
const user = await new UserFactory({
|
|
107
|
+
organization,
|
|
108
|
+
permissions: minimumUserPermissions,
|
|
109
|
+
}).create();
|
|
110
|
+
|
|
111
|
+
const body: Body = new PatchableArray();
|
|
112
|
+
body.addPut(Event.create({
|
|
113
|
+
organizationId: organization.id,
|
|
114
|
+
typeId: (await new PlatformEventTypeFactory({}).create()).id,
|
|
115
|
+
name: 'test event',
|
|
116
|
+
startDate: new Date(Date.now() + 5 * 24 * 60 * 60 * 1000),
|
|
117
|
+
endDate: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
|
|
118
|
+
}));
|
|
119
|
+
|
|
120
|
+
const result = await TestRequest.patch({ body, user, organization });
|
|
121
|
+
expect(result.status).toBe(200);
|
|
122
|
+
|
|
123
|
+
const updated = await Organization.getByID(organization.id, true);
|
|
124
|
+
expect(updated.hasFutureEvents).toBe(true);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test('Creating an event that already ended does not set hasFutureEvents to true', async () => {
|
|
128
|
+
const organization = await new OrganizationFactory({}).create();
|
|
129
|
+
organization.hasFutureEvents = false;
|
|
130
|
+
await organization.save();
|
|
131
|
+
|
|
132
|
+
const user = await new UserFactory({
|
|
133
|
+
organization,
|
|
134
|
+
permissions: minimumUserPermissions,
|
|
135
|
+
}).create();
|
|
136
|
+
|
|
137
|
+
const body: Body = new PatchableArray();
|
|
138
|
+
// Ended well before the future-events cutoff (~2 months ago), so it should not count.
|
|
139
|
+
body.addPut(Event.create({
|
|
140
|
+
organizationId: organization.id,
|
|
141
|
+
typeId: (await new PlatformEventTypeFactory({}).create()).id,
|
|
142
|
+
name: 'test event',
|
|
143
|
+
startDate: new Date(Date.now() - 100 * 24 * 60 * 60 * 1000),
|
|
144
|
+
endDate: new Date(Date.now() - 90 * 24 * 60 * 60 * 1000),
|
|
145
|
+
}));
|
|
146
|
+
|
|
147
|
+
const result = await TestRequest.patch({ body, user, organization });
|
|
148
|
+
expect(result.status).toBe(200);
|
|
149
|
+
|
|
150
|
+
const updated = await Organization.getByID(organization.id, true);
|
|
151
|
+
expect(updated.hasFutureEvents).toBe(false);
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
|
|
93
155
|
test('A normal user with write access cannot create a global event', async () => {
|
|
94
156
|
const organization = await new OrganizationFactory({}).create();
|
|
95
157
|
const user = await new UserFactory({
|
|
@@ -149,6 +149,14 @@ export class PatchEventsEndpoint extends Endpoint<Params, Query, Body, ResponseB
|
|
|
149
149
|
await event.save();
|
|
150
150
|
}
|
|
151
151
|
|
|
152
|
+
// Creating an event can only add a future event, so it can only flip
|
|
153
|
+
// hasFutureEvents from false to true. Only recompute when it is still false.
|
|
154
|
+
// Best effort only
|
|
155
|
+
if (eventOrganization && !eventOrganization.hasFutureEvents) {
|
|
156
|
+
await eventOrganization.updateFutureEvents();
|
|
157
|
+
await eventOrganization.save();
|
|
158
|
+
}
|
|
159
|
+
|
|
152
160
|
events.push(event);
|
|
153
161
|
}
|
|
154
162
|
|
|
@@ -327,6 +335,12 @@ export class PatchEventsEndpoint extends Endpoint<Params, Query, Body, ResponseB
|
|
|
327
335
|
await event.delete();
|
|
328
336
|
}
|
|
329
337
|
|
|
338
|
+
if (request.body.getDeletes().length && organization && organization.hasFutureEvents) {
|
|
339
|
+
// Best effort only
|
|
340
|
+
await organization.updateFutureEvents();
|
|
341
|
+
await organization.save();
|
|
342
|
+
}
|
|
343
|
+
|
|
330
344
|
const structures = await AuthenticatedStructures.events(events);
|
|
331
345
|
return new Response(
|
|
332
346
|
structures,
|
|
@@ -158,6 +158,7 @@ export class PatchWebshopOrdersEndpoint extends Endpoint<Params, Query, Body, Re
|
|
|
158
158
|
PaymentService.roundPayment(payment);
|
|
159
159
|
payment.paidAt = null;
|
|
160
160
|
payment.adminUserId = user.id;
|
|
161
|
+
payment.customer = order.data.customer.toPaymentCustomer();
|
|
161
162
|
|
|
162
163
|
// Determine the payment provider (always null because no online payments here)
|
|
163
164
|
payment.provider = null;
|
|
@@ -1,12 +1,15 @@
|
|
|
1
|
+
import type { PatchableArrayAutoEncoder } from '@simonbackx/simple-encoding';
|
|
2
|
+
import { PatchableArray } from '@simonbackx/simple-encoding';
|
|
1
3
|
import type { Response } from '@simonbackx/simple-endpoints';
|
|
2
4
|
import { Request } from '@simonbackx/simple-endpoints';
|
|
3
5
|
import type { Organization, StripeAccount, User } from '@stamhoofd/models';
|
|
4
|
-
import { OrganizationFactory, Token, UserFactory, Webshop, WebshopFactory } from '@stamhoofd/models';
|
|
6
|
+
import { Order, OrganizationFactory, Payment, Token, UserFactory, Webshop, WebshopFactory } from '@stamhoofd/models';
|
|
5
7
|
import type { OrderResponse } from '@stamhoofd/structures';
|
|
6
|
-
import { Address, Cart, CartItem, CartItemOption, Customer, Option, OptionMenu, OrderData, PaymentConfiguration, PaymentMethod, PermissionLevel, Permissions, PrivatePaymentConfiguration, Product, ProductPrice, ProductType, SeatingPlan, SeatingPlanRow, SeatingPlanSeat, SeatingPlanSection, TransferSettings, WebshopAuthType, WebshopDeliveryMethod, WebshopMetaData, WebshopOnSiteMethod, WebshopPrivateMetaData, WebshopTakeoutMethod, WebshopTimeSlot } from '@stamhoofd/structures';
|
|
8
|
+
import { Address, Cart, CartItem, CartItemOption, Customer, Option, OptionMenu, OrderData, PaymentConfiguration, PaymentMethod, PermissionLevel, Permissions, PrivateOrder, PrivatePaymentConfiguration, Product, ProductPrice, ProductType, SeatingPlan, SeatingPlanRow, SeatingPlanSeat, SeatingPlanSection, TransferSettings, WebshopAuthType, WebshopDeliveryMethod, WebshopMetaData, WebshopOnSiteMethod, WebshopPrivateMetaData, WebshopTakeoutMethod, WebshopTimeSlot } from '@stamhoofd/structures';
|
|
7
9
|
import { STExpect } from '@stamhoofd/test-utils';
|
|
8
10
|
import { Country } from '@stamhoofd/types/Country';
|
|
9
11
|
import sinon from 'sinon';
|
|
12
|
+
import { v4 as uuidv4 } from 'uuid';
|
|
10
13
|
|
|
11
14
|
import { StripeMocker } from '../../../../tests/helpers/StripeMocker.js';
|
|
12
15
|
import { testServer } from '../../../../tests/helpers/TestServer.js';
|
|
@@ -518,4 +521,56 @@ describe('Endpoint.PlaceOrderEndpoint', () => {
|
|
|
518
521
|
}
|
|
519
522
|
});
|
|
520
523
|
});
|
|
524
|
+
|
|
525
|
+
describe('Manually created orders', () => {
|
|
526
|
+
async function createManualOrder(paymentMethod: PaymentMethod): Promise<PrivateOrder> {
|
|
527
|
+
const orderData = OrderData.create({
|
|
528
|
+
paymentMethod,
|
|
529
|
+
checkoutMethod: takeoutMethod,
|
|
530
|
+
timeSlot: slot1,
|
|
531
|
+
cart: Cart.create({
|
|
532
|
+
items: [
|
|
533
|
+
CartItem.create({
|
|
534
|
+
product,
|
|
535
|
+
productPrice: productPrice1,
|
|
536
|
+
amount: 2,
|
|
537
|
+
}),
|
|
538
|
+
],
|
|
539
|
+
}),
|
|
540
|
+
customer,
|
|
541
|
+
});
|
|
542
|
+
|
|
543
|
+
const patchArray: PatchableArrayAutoEncoder<PrivateOrder> = new PatchableArray();
|
|
544
|
+
patchArray.addPut(PrivateOrder.create({
|
|
545
|
+
id: uuidv4(),
|
|
546
|
+
data: orderData,
|
|
547
|
+
webshopId: webshop.id,
|
|
548
|
+
}));
|
|
549
|
+
|
|
550
|
+
const r = Request.buildJson('PATCH', `/webshop/${webshop.id}/orders`, organization.getApiHost(), patchArray);
|
|
551
|
+
r.headers.authorization = 'Bearer ' + token.accessToken;
|
|
552
|
+
|
|
553
|
+
const response = await testServer.test(patchWebshopOrdersEndpoint, r);
|
|
554
|
+
expect(response.body).toHaveLength(1);
|
|
555
|
+
return response.body[0];
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
test.each([PaymentMethod.Transfer, PaymentMethod.PointOfSale])('Stores the payment customer for a manually added %s order', async (paymentMethod) => {
|
|
559
|
+
const order = await createManualOrder(paymentMethod);
|
|
560
|
+
|
|
561
|
+
// The persisted payment should contain the billing details of the customer
|
|
562
|
+
const orderModel = (await Order.getByID(order.id))!;
|
|
563
|
+
expect(orderModel.paymentId).not.toBeNull();
|
|
564
|
+
|
|
565
|
+
const payment = (await Payment.getByID(orderModel.paymentId!))!;
|
|
566
|
+
expect(payment.customer).not.toBeNull();
|
|
567
|
+
expect(payment.customer!.firstName).toEqual(customer.firstName);
|
|
568
|
+
expect(payment.customer!.lastName).toEqual(customer.lastName);
|
|
569
|
+
expect(payment.customer!.email).toEqual(customer.email);
|
|
570
|
+
expect(payment.customer!.phone).toEqual(customer.phone);
|
|
571
|
+
|
|
572
|
+
// The payment customer should match the order's customer billing details
|
|
573
|
+
expect(payment.customer!.equals(orderModel.data.customer.toPaymentCustomer())).toBe(true);
|
|
574
|
+
});
|
|
575
|
+
});
|
|
521
576
|
});
|
|
@@ -35,7 +35,7 @@ export class StripePayoutChecker {
|
|
|
35
35
|
async checkSettlements(checkAll = false) {
|
|
36
36
|
// Check last 2 weeks + 3 day margin, unless we check them all
|
|
37
37
|
const d = new Date();
|
|
38
|
-
d.setDate(d.getDate() -
|
|
38
|
+
d.setDate(d.getDate() - 20);
|
|
39
39
|
|
|
40
40
|
if (checkAll) {
|
|
41
41
|
d.setFullYear(2022, 11, 1);
|
|
@@ -156,7 +156,7 @@ export class StripePayoutChecker {
|
|
|
156
156
|
|
|
157
157
|
const applicationFee = balanceItem.source.application_fee_amount;
|
|
158
158
|
const otherFees = balanceItem.fee;
|
|
159
|
-
const totalFees = otherFees
|
|
159
|
+
const totalFees = Math.max(otherFees, (applicationFee ?? 0));
|
|
160
160
|
|
|
161
161
|
// Cool, we can store this in the database now.
|
|
162
162
|
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { Migration } from '@simonbackx/simple-database';
|
|
2
|
+
import { Organization } from '@stamhoofd/models';
|
|
3
|
+
|
|
4
|
+
export default new Migration(async () => {
|
|
5
|
+
if (STAMHOOFD.environment === 'test') {
|
|
6
|
+
console.log('skipped in tests');
|
|
7
|
+
return;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
await Organization.updateFutureEventsForOrganizations('all');
|
|
11
|
+
});
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { Migration } from '@simonbackx/simple-database';
|
|
2
|
+
import { Order, Payment } from '@stamhoofd/models';
|
|
3
|
+
import { Formatter } from '@stamhoofd/utility';
|
|
4
|
+
import { SeedTools } from '../helpers/SeedTools.js';
|
|
5
|
+
|
|
6
|
+
export default new Migration(async () => {
|
|
7
|
+
if (STAMHOOFD.environment === 'test') {
|
|
8
|
+
// The customer is filled in directly when the payment is created (see PatchWebshopOrdersEndpoint),
|
|
9
|
+
// which is covered by PlaceOrderEndpoint.test.ts.
|
|
10
|
+
console.log('skipped in tests');
|
|
11
|
+
return;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// Note: unlike 1780915001-fill-payment-customer, there is no platformName guard here.
|
|
15
|
+
// Manually added webshop orders never stored a payment customer on any platform, so every
|
|
16
|
+
// platform needs this fix.
|
|
17
|
+
|
|
18
|
+
console.log('Start filling in payment customers for orders.');
|
|
19
|
+
|
|
20
|
+
let filled = 0;
|
|
21
|
+
|
|
22
|
+
const result = await SeedTools.loopBatched({
|
|
23
|
+
query: Payment.select().where('customer', null),
|
|
24
|
+
batchSize: 1000,
|
|
25
|
+
batchAction: async (payments: Payment[]) => {
|
|
26
|
+
// Load the balance items linked to the payments so we can find their orders in bulk.
|
|
27
|
+
const { balanceItemPayments, balanceItems } = await Payment.loadBalanceItems(payments);
|
|
28
|
+
|
|
29
|
+
const orderIds = Formatter.uniqueArray(balanceItems.map(b => b.orderId).filter((id): id is string => id !== null));
|
|
30
|
+
const orders = orderIds.length ? await Order.getByIDs(...orderIds) : [];
|
|
31
|
+
|
|
32
|
+
for (const payment of payments) {
|
|
33
|
+
const paymentBalanceItemIds = balanceItemPayments.filter(bip => bip.paymentId === payment.id).map(bip => bip.balanceItemId);
|
|
34
|
+
const linkedOrderIds = Formatter.uniqueArray(
|
|
35
|
+
balanceItems
|
|
36
|
+
.filter(b => paymentBalanceItemIds.includes(b.id))
|
|
37
|
+
.map(b => b.orderId)
|
|
38
|
+
.filter((id): id is string => id !== null),
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
// Only fill in the customer based on a linked order with usable billing details.
|
|
42
|
+
for (const orderId of linkedOrderIds) {
|
|
43
|
+
const order = orders.find(o => o.id === orderId);
|
|
44
|
+
if (order && (order.data.customer.email || order.data.customer.name)) {
|
|
45
|
+
payment.customer = order.data.customer.toPaymentCustomer();
|
|
46
|
+
await payment.save({
|
|
47
|
+
skipMarkSaved: true,
|
|
48
|
+
skipSendEvents: true,
|
|
49
|
+
});
|
|
50
|
+
filled++;
|
|
51
|
+
break;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
console.log(`Finished filling in order payment customers: set ${filled} of ${result.total} payments.`);
|
|
59
|
+
});
|