@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stamhoofd/backend",
3
- "version": "2.124.1",
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.124.1",
61
- "@stamhoofd/backend-i18n": "2.124.1",
62
- "@stamhoofd/backend-middleware": "2.124.1",
63
- "@stamhoofd/crons": "2.124.1",
64
- "@stamhoofd/email": "2.124.1",
65
- "@stamhoofd/excel-writer": "2.124.1",
66
- "@stamhoofd/logging": "2.124.1",
67
- "@stamhoofd/models": "2.124.1",
68
- "@stamhoofd/object-differ": "2.124.1",
69
- "@stamhoofd/queues": "2.124.1",
70
- "@stamhoofd/sql": "2.124.1",
71
- "@stamhoofd/structures": "2.124.1",
72
- "@stamhoofd/types": "2.124.1",
73
- "@stamhoofd/utility": "2.124.1",
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.124.1",
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": "d520495d0c1a5f376b10a60ebbceec63b2228bd5"
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, EmailRecipientFilterType, EmailRecipientSubfilter, EmailTemplateType, ReceivableBalanceType } from '@stamhoofd/structures';
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
 
@@ -12,3 +12,4 @@ import './members-fees.js';
12
12
  import './stripe-invoices.js';
13
13
  import './transfer-fees.js';
14
14
  import './drip-emails.js';
15
+ import './update-organization-future-events.js';
@@ -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, EmailRecipientFilterType, PaginatedResponse, Replacement } from '@stamhoofd/structures';
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 { EmailRecipientFilterType, PaginatedResponse, mergeFilters } from '@stamhoofd/structures';
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 { EmailRecipientFilterType, mergeFilters, PaginatedResponse } from '@stamhoofd/structures';
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, Order, Organization, Payment, User, Webshop } from '@stamhoofd/models';
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, EmailRecipientFilterType, PaginatedResponse, PaymentMethod } from '@stamhoofd/structures';
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, EmailRecipientFilterType, PaginatedResponse, receivableBalanceObjectContactInMemoryFilterCompilers, ReceivableBalanceType, Replacement } from '@stamhoofd/structures';
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 { EmailRecipientFilterType, PaginatedResponse, mergeFilters } from '@stamhoofd/structures';
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 buildData(requestQuery: LimitedFilteredRequest): Promise<PaginatedResponse<OrganizationStruct[], LimitedFilteredRequest>> {
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(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 10 per week
48
- limit: 10,
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 === organization.id)) {
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, EmailRecipientFilterType, EmailRecipientSubfilter, EmailStatus, Email as EmailStruct, OrganizationEmail, Parent, PermissionLevel, Permissions, PermissionsResourceType, ResourcePermissions, UserPermissions, Version } from '@stamhoofd/structures';
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 { Organization, User } from '@stamhoofd/models';
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() - 17);
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 + (applicationFee ?? 0);
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
+ });