@stamhoofd/backend 2.124.2 → 2.125.1

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.
Files changed (26) hide show
  1. package/package.json +17 -17
  2. package/src/boot.ts +1 -0
  3. package/src/crons/balance-emails.ts +2 -1
  4. package/src/crons/index.ts +1 -0
  5. package/src/crons/update-organization-future-events.ts +42 -0
  6. package/src/email-recipient-loaders/documents.ts +2 -1
  7. package/src/email-recipient-loaders/members.ts +2 -1
  8. package/src/email-recipient-loaders/orders.ts +2 -1
  9. package/src/email-recipient-loaders/organizations.ts +78 -0
  10. package/src/email-recipient-loaders/payments.ts +3 -2
  11. package/src/email-recipient-loaders/receivable-balances.ts +2 -1
  12. package/src/email-recipient-loaders/registrations.ts +2 -1
  13. package/src/endpoints/admin/organizations/GetOrganizationsEndpoint.ts +12 -2
  14. package/src/endpoints/global/email/ManageEmailAddressEndpoint.ts +12 -3
  15. package/src/endpoints/global/email/PatchEmailEndpoint.test.ts +2 -1
  16. package/src/endpoints/global/events/PatchEventsEndpoint.test.ts +64 -2
  17. package/src/endpoints/global/events/PatchEventsEndpoint.ts +14 -0
  18. package/src/endpoints/organization/dashboard/receivable-balances/GetReceivableBalanceEndpoint.ts +8 -9
  19. package/src/endpoints/organization/dashboard/registration-periods/PatchOrganizationRegistrationPeriodsEndpoint.ts +27 -48
  20. package/src/endpoints/organization/dashboard/webshops/PatchWebshopOrdersEndpoint.ts +1 -0
  21. package/src/endpoints/organization/webshops/PlaceOrderEndpoint.test.ts +57 -2
  22. package/src/helpers/AdminPermissionChecker.ts +17 -6
  23. package/src/helpers/AuthenticatedStructures.ts +1 -3
  24. package/src/seeds/1782728609-update-organization-future-events.ts +11 -0
  25. package/src/seeds/1782737437-fill-order-payment-customer.ts +59 -0
  26. package/src/sql-filters/payments.ts +6 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stamhoofd/backend",
3
- "version": "2.124.2",
3
+ "version": "2.125.1",
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.2",
61
- "@stamhoofd/backend-i18n": "2.124.2",
62
- "@stamhoofd/backend-middleware": "2.124.2",
63
- "@stamhoofd/crons": "2.124.2",
64
- "@stamhoofd/email": "2.124.2",
65
- "@stamhoofd/excel-writer": "2.124.2",
66
- "@stamhoofd/logging": "2.124.2",
67
- "@stamhoofd/models": "2.124.2",
68
- "@stamhoofd/object-differ": "2.124.2",
69
- "@stamhoofd/queues": "2.124.2",
70
- "@stamhoofd/sql": "2.124.2",
71
- "@stamhoofd/structures": "2.124.2",
72
- "@stamhoofd/types": "2.124.2",
73
- "@stamhoofd/utility": "2.124.2",
60
+ "@stamhoofd/backend-env": "2.125.1",
61
+ "@stamhoofd/backend-i18n": "2.125.1",
62
+ "@stamhoofd/backend-middleware": "2.125.1",
63
+ "@stamhoofd/crons": "2.125.1",
64
+ "@stamhoofd/email": "2.125.1",
65
+ "@stamhoofd/excel-writer": "2.125.1",
66
+ "@stamhoofd/logging": "2.125.1",
67
+ "@stamhoofd/models": "2.125.1",
68
+ "@stamhoofd/object-differ": "2.125.1",
69
+ "@stamhoofd/queues": "2.125.1",
70
+ "@stamhoofd/sql": "2.125.1",
71
+ "@stamhoofd/structures": "2.125.1",
72
+ "@stamhoofd/types": "2.125.1",
73
+ "@stamhoofd/utility": "2.125.1",
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.2",
94
+ "@stamhoofd/test-utils": "2.125.1",
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": "e45e42edd908f088a0f56b5174ef2a8fbae7a446"
110
+ "gitHead": "7a5fada26c2b7e4d0cd47cd5f2ee95adceae8391"
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,
@@ -2,6 +2,7 @@ import type { DecodedRequest, Request } from '@simonbackx/simple-endpoints';
2
2
  import { Endpoint, Response } from '@simonbackx/simple-endpoints';
3
3
  import { DetailedReceivableBalance, PaymentStatus, PermissionLevel, ReceivableBalanceType } from '@stamhoofd/structures';
4
4
 
5
+ import type { MemberWithUsersAndRegistrations } from '@stamhoofd/models';
5
6
  import { BalanceItem, BalanceItemPayment, CachedBalance, Member, MemberUser, Payment, Registration } from '@stamhoofd/models';
6
7
  import { Context } from '../../../../helpers/Context.js';
7
8
  import { AuthenticatedStructures } from '../../../../helpers/AuthenticatedStructures.js';
@@ -36,20 +37,18 @@ export class GetReceivableBalanceEndpoint extends Endpoint<Params, Query, Body,
36
37
  await Context.authenticate();
37
38
 
38
39
  if (!await Context.auth.canManageFinances(organization.id)) {
39
- // Group-level financial access is sufficient for member and registration balance views
40
- if (request.params.type === ReceivableBalanceType.member) {
41
- const member = await Member.getByIdWithUsersAndRegistrations(request.params.id);
42
- if (!member || !await Context.auth.hasFinancialMemberAccess(member, PermissionLevel.Read, organization.id)) {
43
- throw Context.auth.error();
44
- }
45
- } else if (request.params.type === ReceivableBalanceType.registration) {
40
+ let member: MemberWithUsersAndRegistrations | null = null;
41
+ if (request.params.type === ReceivableBalanceType.registration) {
46
42
  const registration = await Registration.select().where('id', request.params.id).first(false);
47
43
  if (!registration) throw Context.auth.error();
48
- const member = await Member.getByIdWithUsersAndRegistrations(registration.memberId);
44
+ member = await Member.getByIdWithUsersAndRegistrations(registration.memberId);
45
+ } else if (request.params.type === ReceivableBalanceType.member) {
46
+ member = await Member.getByIdWithUsersAndRegistrations(request.params.id);
49
47
  if (!member || !await Context.auth.hasFinancialMemberAccess(member, PermissionLevel.Read, organization.id)) {
50
48
  throw Context.auth.error();
51
49
  }
52
- } else {
50
+ }
51
+ if (!member || !await Context.auth.hasFinancialMemberAccess(member, PermissionLevel.Read, organization.id)) {
53
52
  throw Context.auth.error();
54
53
  }
55
54
  }
@@ -565,29 +565,16 @@ export class PatchOrganizationRegistrationPeriodsEndpoint extends Endpoint<Param
565
565
  if (patch.waitingList === null) {
566
566
  // delete
567
567
  if (model.waitingListId) {
568
- // for now don't delete, as waiting lists can be shared between multiple groups
569
- // await PatchOrganizationRegistrationPeriodsEndpoint.deleteGroup(model.waitingListId)
570
568
  model.waitingListId = null;
571
569
  }
572
570
  } else if (patch.waitingList.isPatch()) {
573
- if (!model.waitingListId) {
574
- throw new SimpleError({
575
- code: 'invalid_field',
576
- field: 'waitingList',
577
- message: 'Cannot patch waiting list before it is created',
578
- });
579
- }
580
- patch.waitingList.id = model.waitingListId;
581
- patch.waitingList.type = GroupType.WaitingList;
582
- await throwIfUpdateWaitingListPeriodWithMultipleGroups(patch.waitingList.id);
583
- await PatchOrganizationRegistrationPeriodsEndpoint.patchGroup(patch.waitingList, period, {
584
- allowPatchWaitingListPeriod: shouldUpdatePeriodIds,
585
- isPatchingEvent,
571
+ throw new SimpleError({
572
+ code: 'invalid_field',
573
+ field: 'waitingList',
574
+ message: 'Cannot patch waitingList',
586
575
  });
587
576
  } else {
588
577
  if (model.waitingListId) {
589
- // for now don't delete, as waiting lists can be shared between multiple groups
590
- // await PatchOrganizationRegistrationPeriodsEndpoint.deleteGroup(model.waitingListId)
591
578
  model.waitingListId = null;
592
579
  }
593
580
  patch.waitingList.type = GroupType.WaitingList;
@@ -613,29 +600,13 @@ export class PatchOrganizationRegistrationPeriodsEndpoint extends Endpoint<Param
613
600
 
614
601
  model.waitingListId = existing.id;
615
602
  } else {
616
- const requiredPeriod = period ?? await RegistrationPeriod.getByID(model.periodId);
617
-
618
- if (!requiredPeriod) {
619
- throw new Error('Unexpected missing period when creating waiting list');
620
- }
621
-
622
- if (STAMHOOFD.userMode === 'organization' && requiredPeriod.organizationId !== model.organizationId) {
623
- throw new SimpleError({
624
- code: 'invalid_period',
625
- message: 'Period has different organization id',
626
- statusCode: 400,
627
- });
628
- }
629
-
630
- const group = await PatchOrganizationRegistrationPeriodsEndpoint.createGroup(
631
- patch.waitingList,
632
- model.organizationId,
633
- requiredPeriod,
634
- {
635
- allowedIds: [patch.waitingList.id],
636
- },
637
- );
638
- model.waitingListId = group.id;
603
+ throw new SimpleError({
604
+ code: 'invalid_waiting_list',
605
+ field: 'waitingList',
606
+ message: 'Waiting list not found',
607
+ human: $t('%ZZb'),
608
+ statusCode: 404,
609
+ });
639
610
  }
640
611
  }
641
612
  } else if (shouldUpdatePeriodIds && model.waitingListId && period) {
@@ -761,16 +732,24 @@ export class PatchOrganizationRegistrationPeriodsEndpoint extends Endpoint<Param
761
732
  });
762
733
  }
763
734
 
735
+ if (existing.periodId !== model.periodId) {
736
+ throw new SimpleError({
737
+ code: 'invalid_field',
738
+ field: 'waitingList',
739
+ message: 'Waiting list group is already used in another period',
740
+ human: $t(`%F9`),
741
+ });
742
+ }
743
+
764
744
  model.waitingListId = existing.id;
765
745
  } else {
766
- struct.waitingList.type = GroupType.WaitingList;
767
- const group = await PatchOrganizationRegistrationPeriodsEndpoint.createGroup(
768
- struct.waitingList,
769
- model.organizationId,
770
- period,
771
- { allowedIds: [struct.waitingList.id] },
772
- );
773
- model.waitingListId = group.id;
746
+ throw new SimpleError({
747
+ code: 'invalid_waiting_list',
748
+ field: 'waitingList',
749
+ message: 'Waiting list not found',
750
+ human: $t('%ZZb'),
751
+ statusCode: 404,
752
+ });
774
753
  }
775
754
  }
776
755
 
@@ -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
  });
@@ -702,12 +702,6 @@ export class AdminPermissionChecker {
702
702
 
703
703
  if (this.user.permissions) {
704
704
  // We grant permission for a whole payment when the user has at least permission for a part of that payment.
705
- for (const registration of registrations) {
706
- if (await this.canAccessRegistration(registration, permissionLevel)) {
707
- return true;
708
- }
709
- }
710
-
711
705
  const webshopCache: Map<string, Webshop> = new Map();
712
706
 
713
707
  for (const order of orders) {
@@ -720,6 +714,23 @@ export class AdminPermissionChecker {
720
714
  }
721
715
  }
722
716
  }
717
+
718
+ for (const registration of registrations) {
719
+ if (await this.canAccessRegistration(registration, permissionLevel)) {
720
+ return true;
721
+ }
722
+ }
723
+
724
+ const seenMemberIds = new Set<string>();
725
+
726
+ for (const balanceItem of balanceItems) {
727
+ if (!balanceItem.memberId || seenMemberIds.has(balanceItem.memberId)) continue;
728
+ seenMemberIds.add(balanceItem.memberId);
729
+ const member = await Member.getByIdWithUsersAndRegistrations(balanceItem.memberId);
730
+ if (member && await this.hasFinancialMemberAccess(member, permissionLevel)) {
731
+ return true;
732
+ }
733
+ }
723
734
  }
724
735
 
725
736
  if (permissionLevel === PermissionLevel.Read) {
@@ -32,10 +32,8 @@ export class AuthenticatedStructures {
32
32
  const { balanceItemPayments, balanceItems } = await Payment.loadBalanceItems(payments);
33
33
 
34
34
  if (checkPermissions) {
35
- const { registrations, orders } = await Payment.loadBalanceItemRelations(balanceItems);
36
-
37
35
  // Note: permission checking is moved here for performacne to avoid loading the data multiple times
38
- if (!(await Context.optionalAuth?.canAccessBalanceItems(balanceItems, PermissionLevel.Read, { registrations, orders }))) {
36
+ if (!(await Context.optionalAuth?.canAccessBalanceItems(balanceItems, PermissionLevel.Read))) {
39
37
  throw new SimpleError({
40
38
  code: 'permission_denied',
41
39
  message: 'Permission denied',
@@ -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
+ });
@@ -1,7 +1,8 @@
1
1
  import { Payment } from '@stamhoofd/models';
2
2
  import type { SQLFilterDefinitions } from '@stamhoofd/sql';
3
- import { baseSQLFilterCompilers, createColumnFilter, createExistsFilter, SQL, SQLCast, SQLConcat, SQLJsonUnquote, SQLScalar, SQLValueType } from '@stamhoofd/sql';
3
+ import { baseSQLFilterCompilers, createColumnFilter, createExistsFilter, createJoinedRelationFilter, SQL, SQLCast, SQLConcat, SQLJsonUnquote, SQLScalar, SQLValueType } from '@stamhoofd/sql';
4
4
  import { balanceItemPaymentsCompilers } from './balance-item-payments.js';
5
+ import { organizationFilterCompilers } from './organizations.js';
5
6
 
6
7
  /**
7
8
  * Defines how to filter payments in the database from StamhoofdFilter objects
@@ -38,6 +39,10 @@ export const paymentFilterCompilers: SQLFilterDefinitions = {
38
39
  type: SQLValueType.String,
39
40
  nullable: true,
40
41
  }),
42
+ payingOrganization: createJoinedRelationFilter(
43
+ SQL.join('organizations').where(SQL.column('organizations', 'id'), SQL.column(Payment.table, 'payingOrganizationId')),
44
+ organizationFilterCompilers,
45
+ ),
41
46
  invoiceId: createColumnFilter({
42
47
  expression: SQL.column('invoiceId'),
43
48
  type: SQLValueType.String,