@stamhoofd/backend 2.91.0 → 2.93.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.
Files changed (36) hide show
  1. package/package.json +10 -10
  2. package/src/audit-logs/EmailLogger.ts +6 -6
  3. package/src/crons/amazon-ses.ts +100 -4
  4. package/src/crons/balance-emails.ts +1 -1
  5. package/src/crons/endFunctionsOfUsersWithoutRegistration.ts +6 -0
  6. package/src/email-recipient-loaders/receivable-balances.ts +3 -1
  7. package/src/endpoints/global/email/CreateEmailEndpoint.ts +37 -7
  8. package/src/endpoints/global/email/GetAdminEmailsEndpoint.ts +205 -0
  9. package/src/endpoints/global/email/GetEmailEndpoint.ts +5 -1
  10. package/src/endpoints/global/email/PatchEmailEndpoint.test.ts +404 -8
  11. package/src/endpoints/global/email/PatchEmailEndpoint.ts +81 -26
  12. package/src/endpoints/global/email-recipients/GetEmailRecipientsCountEndpoint.ts +47 -0
  13. package/src/endpoints/global/email-recipients/GetEmailRecipientsEndpoint.test.ts +225 -0
  14. package/src/endpoints/global/email-recipients/GetEmailRecipientsEndpoint.ts +164 -0
  15. package/src/endpoints/global/email-recipients/helpers/validateEmailRecipientFilter.ts +64 -0
  16. package/src/endpoints/global/members/PatchOrganizationMembersEndpoint.ts +5 -1
  17. package/src/endpoints/global/registration-periods/GetRegistrationPeriodsEndpoint.ts +19 -1
  18. package/src/endpoints/organization/dashboard/webshops/DeleteWebshopEndpoint.ts +10 -1
  19. package/src/endpoints/organization/dashboard/webshops/PatchWebshopOrdersEndpoint.ts +8 -1
  20. package/src/endpoints/organization/webshops/PlaceOrderEndpoint.ts +2 -67
  21. package/src/helpers/AdminPermissionChecker.ts +81 -5
  22. package/src/helpers/EmailResumer.ts +2 -2
  23. package/src/seeds/1752848560-groups-registration-periods.ts +768 -0
  24. package/src/seeds/1755532883-update-email-sender-ids.ts +47 -0
  25. package/src/seeds/1755790070-fill-email-recipient-errors.ts +96 -0
  26. package/src/seeds/1755876819-remove-duplicate-members.ts +145 -0
  27. package/src/seeds/1756115432-remove-old-drafts.ts +16 -0
  28. package/src/seeds/1756115433-fill-email-recipient-organization-id.ts +30 -0
  29. package/src/services/uitpas/UitpasService.ts +71 -2
  30. package/src/services/uitpas/checkUitpasNumbers.ts +1 -0
  31. package/src/sql-filters/email-recipients.ts +59 -0
  32. package/src/sql-filters/emails.ts +95 -0
  33. package/src/sql-filters/members.ts +42 -1
  34. package/src/sql-filters/registration-periods.ts +5 -0
  35. package/src/sql-sorters/email-recipients.ts +69 -0
  36. package/src/sql-sorters/emails.ts +47 -0
@@ -0,0 +1,225 @@
1
+ import { STExpect, TestUtils } from '@stamhoofd/test-utils';
2
+ import { GetEmailRecipientsEndpoint } from './GetEmailRecipientsEndpoint';
3
+ import { AccessRight, EmailStatus, LimitedFilteredRequest, OrganizationEmail, PermissionLevel, Permissions, PermissionsResourceType, ResourcePermissions } from '@stamhoofd/structures';
4
+ import { Email, EmailRecipient, Organization, OrganizationFactory, RegistrationPeriod, RegistrationPeriodFactory, Token, User, UserFactory } from '@stamhoofd/models';
5
+ import { Request } from '@simonbackx/simple-endpoints';
6
+ import { testServer } from '../../../../tests/helpers/TestServer';
7
+
8
+ const baseUrl = `/email-recipients`;
9
+
10
+ describe('Endpoint.GetEmailRecipients', () => {
11
+ const endpoint = new GetEmailRecipientsEndpoint();
12
+ let period: RegistrationPeriod;
13
+ let organization: Organization;
14
+ let token: Token;
15
+ let user: User;
16
+ let sender: OrganizationEmail;
17
+ let sender2: OrganizationEmail;
18
+
19
+ let token2: Token;
20
+ let user2: User;
21
+
22
+ beforeAll(async () => {
23
+ TestUtils.setPermanentEnvironment('userMode', 'platform');
24
+ period = await new RegistrationPeriodFactory({
25
+ startDate: new Date(2023, 0, 1),
26
+ endDate: new Date(2023, 11, 31),
27
+ }).create();
28
+
29
+ organization = await new OrganizationFactory({ period })
30
+ .create();
31
+
32
+ sender = OrganizationEmail.create({
33
+ email: 'groepsleiding@voorbeeld.com',
34
+ name: 'Groepsleiding',
35
+ });
36
+ sender2 = OrganizationEmail.create({
37
+ email: 'kapoenen@voorbeeld.com',
38
+ name: 'Kapoenen',
39
+ });
40
+
41
+ organization.privateMeta.emails.push(sender);
42
+ organization.privateMeta.emails.push(sender2);
43
+ await organization.save();
44
+
45
+ user = await new UserFactory({
46
+ organization,
47
+ permissions: Permissions.create({
48
+ level: PermissionLevel.None,
49
+ resources: new Map([
50
+ [PermissionsResourceType.Senders, new Map([['', ResourcePermissions.create({
51
+ resourceName: sender.name!,
52
+ level: PermissionLevel.Read,
53
+ })]])],
54
+ ]),
55
+ }),
56
+ })
57
+ .create();
58
+
59
+ token = await Token.createToken(user);
60
+
61
+ user2 = await new UserFactory({
62
+ organization,
63
+ permissions: Permissions.create({
64
+ level: PermissionLevel.None,
65
+ resources: new Map([
66
+ [PermissionsResourceType.Senders, new Map([[sender2.id, ResourcePermissions.create({
67
+ resourceName: sender.name!,
68
+ level: PermissionLevel.Read,
69
+ })]])],
70
+ ]),
71
+ }),
72
+ })
73
+ .create();
74
+
75
+ token2 = await Token.createToken(user2);
76
+ });
77
+
78
+ test('It can request all email recipients if read permission for all senders', async () => {
79
+ const email = new Email();
80
+ email.subject = 'test subject';
81
+ email.status = EmailStatus.Draft;
82
+ email.text = 'test email';
83
+ email.html = `<p style="margin: 0; padding: 0; line-height: 1.4;">test email</p>`;
84
+ email.json = {};
85
+ email.organizationId = organization.id;
86
+ email.senderId = sender.id;
87
+ await email.save();
88
+
89
+ const emailRecipient = new EmailRecipient();
90
+ emailRecipient.email = 'jan.janssens@geenemail.com';
91
+ emailRecipient.firstName = 'Jan';
92
+ emailRecipient.lastName = 'Janssens';
93
+ emailRecipient.emailId = email.id;
94
+ emailRecipient.organizationId = organization.id;
95
+ await emailRecipient.save();
96
+
97
+ const request = Request.get({
98
+ path: baseUrl,
99
+ host: organization.getApiHost(),
100
+ query: new LimitedFilteredRequest({
101
+ filter: {},
102
+ limit: 10,
103
+ }),
104
+ headers: {
105
+ authorization: 'Bearer ' + token.accessToken,
106
+ },
107
+ });
108
+ const result = await testServer.test(endpoint, request);
109
+ expect(result.body.results).toHaveLength(1);
110
+ expect(result.body.results[0]).toMatchObject({
111
+ id: emailRecipient.id,
112
+ });
113
+ });
114
+
115
+ test('It can not request all email recipients if not read permission for all senders', async () => {
116
+ const email = new Email();
117
+ email.subject = 'test subject';
118
+ email.status = EmailStatus.Draft;
119
+ email.text = 'test email';
120
+ email.html = `<p style="margin: 0; padding: 0; line-height: 1.4;">test email</p>`;
121
+ email.json = {};
122
+ email.organizationId = organization.id;
123
+ email.senderId = sender2.id;
124
+ await email.save();
125
+
126
+ const emailRecipient = new EmailRecipient();
127
+ emailRecipient.email = 'jan.janssens@geenemail.com';
128
+ emailRecipient.firstName = 'Jan';
129
+ emailRecipient.lastName = 'Janssens';
130
+ emailRecipient.emailId = email.id;
131
+ emailRecipient.organizationId = organization.id;
132
+ await emailRecipient.save();
133
+
134
+ const request = Request.get({
135
+ path: baseUrl,
136
+ host: organization.getApiHost(),
137
+ query: new LimitedFilteredRequest({
138
+ filter: {},
139
+ limit: 10,
140
+ }),
141
+ headers: {
142
+ authorization: 'Bearer ' + token2.accessToken,
143
+ },
144
+ });
145
+ await expect(testServer.test(endpoint, request))
146
+ .rejects
147
+ .toThrow(STExpect.errorWithCode('permission_denied'));
148
+ });
149
+
150
+ test('It request all email recipients of a single email if read permission for that sender', async () => {
151
+ const email = new Email();
152
+ email.subject = 'test subject';
153
+ email.status = EmailStatus.Draft;
154
+ email.text = 'test email';
155
+ email.html = `<p style="margin: 0; padding: 0; line-height: 1.4;">test email</p>`;
156
+ email.json = {};
157
+ email.organizationId = organization.id;
158
+ email.senderId = sender2.id;
159
+ await email.save();
160
+
161
+ const emailRecipient = new EmailRecipient();
162
+ emailRecipient.email = 'jan.janssens@geenemail.com';
163
+ emailRecipient.firstName = 'Jan';
164
+ emailRecipient.lastName = 'Janssens';
165
+ emailRecipient.emailId = email.id;
166
+ emailRecipient.organizationId = organization.id;
167
+ await emailRecipient.save();
168
+
169
+ const request = Request.get({
170
+ path: baseUrl,
171
+ host: organization.getApiHost(),
172
+ query: new LimitedFilteredRequest({
173
+ filter: {
174
+ emailId: email.id,
175
+ },
176
+ limit: 10,
177
+ }),
178
+ headers: {
179
+ authorization: 'Bearer ' + token2.accessToken,
180
+ },
181
+ });
182
+ const result = await testServer.test(endpoint, request);
183
+ expect(result.body.results).toHaveLength(1);
184
+ expect(result.body.results[0]).toMatchObject({
185
+ id: emailRecipient.id,
186
+ });
187
+ });
188
+
189
+ test('It cannot request all email recipients of a single email if read permission for another sender', async () => {
190
+ const email = new Email();
191
+ email.subject = 'test subject';
192
+ email.status = EmailStatus.Draft;
193
+ email.text = 'test email';
194
+ email.html = `<p style="margin: 0; padding: 0; line-height: 1.4;">test email</p>`;
195
+ email.json = {};
196
+ email.organizationId = organization.id;
197
+ email.senderId = sender.id;
198
+ await email.save();
199
+
200
+ const emailRecipient = new EmailRecipient();
201
+ emailRecipient.email = 'jan.janssens@geenemail.com';
202
+ emailRecipient.firstName = 'Jan';
203
+ emailRecipient.lastName = 'Janssens';
204
+ emailRecipient.emailId = email.id;
205
+ emailRecipient.organizationId = organization.id;
206
+ await emailRecipient.save();
207
+
208
+ const request = Request.get({
209
+ path: baseUrl,
210
+ host: organization.getApiHost(),
211
+ query: new LimitedFilteredRequest({
212
+ filter: {
213
+ emailId: email.id,
214
+ },
215
+ limit: 10,
216
+ }),
217
+ headers: {
218
+ authorization: 'Bearer ' + token2.accessToken,
219
+ },
220
+ });
221
+ await expect(testServer.test(endpoint, request))
222
+ .rejects
223
+ .toThrow(STExpect.errorWithCode('permission_denied'));
224
+ });
225
+ });
@@ -0,0 +1,164 @@
1
+ import { DecodedRequest, Endpoint, Request, Response } from '@simonbackx/simple-endpoints';
2
+ import { assertSort, CountFilteredRequest, EmailRecipient as EmailRecipientStruct, getSortFilter, LimitedFilteredRequest, PaginatedResponse, PermissionLevel, StamhoofdFilter } from '@stamhoofd/structures';
3
+
4
+ import { Decoder } from '@simonbackx/simple-encoding';
5
+ import { SimpleError } from '@simonbackx/simple-errors';
6
+ import { EmailRecipient } from '@stamhoofd/models';
7
+ import { applySQLSorter, compileToSQLFilter, SQLFilterDefinitions, SQLSortDefinitions } from '@stamhoofd/sql';
8
+ import { Context } from '../../../helpers/Context';
9
+ import { emailRecipientsFilterCompilers } from '../../../sql-filters/email-recipients';
10
+ import { emailRecipientSorters } from '../../../sql-sorters/email-recipients';
11
+ import { validateEmailRecipientFilter } from './helpers/validateEmailRecipientFilter';
12
+
13
+ type Params = Record<string, never>;
14
+ type Query = LimitedFilteredRequest;
15
+ type Body = undefined;
16
+ type ResponseBody = PaginatedResponse<EmailRecipientStruct[], LimitedFilteredRequest>;
17
+
18
+ const filterCompilers: SQLFilterDefinitions = emailRecipientsFilterCompilers;
19
+ const sorters: SQLSortDefinitions<EmailRecipient> = emailRecipientSorters;
20
+
21
+ export class GetEmailRecipientsEndpoint extends Endpoint<Params, Query, Body, ResponseBody> {
22
+ queryDecoder = LimitedFilteredRequest as Decoder<LimitedFilteredRequest>;
23
+
24
+ protected doesMatch(request: Request): [true, Params] | [false] {
25
+ if (request.method !== 'GET') {
26
+ return [false];
27
+ }
28
+
29
+ const params = Endpoint.parseParameters(request.url, '/email-recipients', {});
30
+
31
+ if (params) {
32
+ return [true, params as Params];
33
+ }
34
+ return [false];
35
+ }
36
+
37
+ static async buildQuery(q: CountFilteredRequest | LimitedFilteredRequest) {
38
+ const organization = Context.organization;
39
+ let scopeFilter: StamhoofdFilter | undefined = undefined;
40
+
41
+ if (organization) {
42
+ scopeFilter = {
43
+ organizationId: organization.id,
44
+ };
45
+ }
46
+ const canReadAllEmails = await Context.auth.canReadAllEmails(organization ?? null);
47
+
48
+ if (!canReadAllEmails) {
49
+ // Check if scope is correctly limited to a single email, otherwise throw an error.
50
+ if (!await validateEmailRecipientFilter({ filter: q.filter, permissionLevel: PermissionLevel.Read })) {
51
+ throw Context.auth.error({
52
+ message: 'You do not have sufficient permissions to view all email recipients',
53
+ human: $t(`Je hebt niet voldoende toegangsrechten om alle email ontvangers te bekijken. Filter op één specifieke e-mail.`),
54
+ });
55
+ }
56
+ }
57
+
58
+ const query = EmailRecipient.select();
59
+
60
+ if (scopeFilter) {
61
+ query.where(await compileToSQLFilter(scopeFilter, filterCompilers));
62
+ }
63
+
64
+ if (q.filter) {
65
+ query.where(await compileToSQLFilter(q.filter, filterCompilers));
66
+ }
67
+
68
+ if (q.search) {
69
+ let searchFilter: StamhoofdFilter | null = null;
70
+
71
+ searchFilter = {
72
+ $or: [
73
+ {
74
+ email: {
75
+ $contains: q.search,
76
+ },
77
+ },
78
+ {
79
+ name: {
80
+ $contains: q.search,
81
+ },
82
+ },
83
+ ],
84
+ };
85
+
86
+ if (searchFilter) {
87
+ query.where(await compileToSQLFilter(searchFilter, filterCompilers));
88
+ }
89
+ }
90
+
91
+ if (q instanceof LimitedFilteredRequest) {
92
+ if (q.pageFilter) {
93
+ query.where(await compileToSQLFilter(q.pageFilter, filterCompilers));
94
+ }
95
+
96
+ q.sort = assertSort(q.sort, [{ key: 'id' }]);
97
+ applySQLSorter(query, q.sort, sorters);
98
+ query.limit(q.limit);
99
+ }
100
+
101
+ return query;
102
+ }
103
+
104
+ static async buildData(requestQuery: LimitedFilteredRequest) {
105
+ const query = await GetEmailRecipientsEndpoint.buildQuery(requestQuery);
106
+ const recipients = await query.fetch();
107
+
108
+ let next: LimitedFilteredRequest | undefined;
109
+
110
+ if (recipients.length >= requestQuery.limit) {
111
+ const lastObject = recipients[recipients.length - 1];
112
+ const nextFilter = getSortFilter(lastObject, sorters, requestQuery.sort);
113
+
114
+ next = new LimitedFilteredRequest({
115
+ filter: requestQuery.filter,
116
+ pageFilter: nextFilter,
117
+ sort: requestQuery.sort,
118
+ limit: requestQuery.limit,
119
+ search: requestQuery.search,
120
+ });
121
+
122
+ if (JSON.stringify(nextFilter) === JSON.stringify(requestQuery.pageFilter)) {
123
+ console.error('Found infinite loading loop for', requestQuery);
124
+ next = undefined;
125
+ }
126
+ }
127
+
128
+ return new PaginatedResponse<EmailRecipientStruct[], LimitedFilteredRequest>({
129
+ results: recipients.map(r => r.getStructure()),
130
+ next,
131
+ });
132
+ }
133
+
134
+ async handle(request: DecodedRequest<Params, Query, Body>) {
135
+ const organization = await Context.setOptionalOrganizationScope();
136
+ await Context.authenticate();
137
+
138
+ if (!await Context.auth.canReadEmails(organization)) {
139
+ throw Context.auth.error();
140
+ }
141
+
142
+ const maxLimit = Context.auth.hasSomePlatformAccess() ? 1000 : 100;
143
+
144
+ if (request.query.limit > maxLimit) {
145
+ throw new SimpleError({
146
+ code: 'invalid_field',
147
+ field: 'limit',
148
+ message: 'Limit can not be more than ' + maxLimit,
149
+ });
150
+ }
151
+
152
+ if (request.query.limit < 1) {
153
+ throw new SimpleError({
154
+ code: 'invalid_field',
155
+ field: 'limit',
156
+ message: 'Limit can not be less than 1',
157
+ });
158
+ }
159
+
160
+ return new Response(
161
+ await GetEmailRecipientsEndpoint.buildData(request.query),
162
+ );
163
+ }
164
+ }
@@ -0,0 +1,64 @@
1
+ import { SimpleError } from '@simonbackx/simple-errors';
2
+ import { Email } from '@stamhoofd/models';
3
+ import { FilterWrapperMarker, PermissionLevel, StamhoofdFilter, unwrapFilter, WrapperFilter } from '@stamhoofd/structures';
4
+ import { Context } from '../../../../helpers/Context';
5
+
6
+ export async function validateEmailRecipientFilter({ filter, permissionLevel }: { filter: StamhoofdFilter; permissionLevel: PermissionLevel }) {
7
+ // Require presence of a filter
8
+ const requiredFilter: WrapperFilter = {
9
+ emailId: FilterWrapperMarker,
10
+ };
11
+
12
+ const unwrapped = unwrapFilter(filter, requiredFilter);
13
+ if (!unwrapped.match) {
14
+ return false;
15
+ }
16
+
17
+ const emailIds = typeof unwrapped.markerValue === 'string'
18
+ ? [unwrapped.markerValue]
19
+ : unwrapFilter(unwrapped.markerValue as StamhoofdFilter, {
20
+ $in: FilterWrapperMarker,
21
+ })?.markerValue;
22
+
23
+ if (!Array.isArray(emailIds)) {
24
+ throw new SimpleError({
25
+ code: 'invalid_field',
26
+ field: 'filter',
27
+ message: 'You must filter on an email id of the email recipients you are trying to access',
28
+ human: $t(`Je hebt niet voldoende toegangsrechten om alle email ontvangers te bekijken.`),
29
+ });
30
+ }
31
+
32
+ if (emailIds.length === 0) {
33
+ throw new SimpleError({
34
+ code: 'invalid_field',
35
+ field: 'filter',
36
+ message: 'Filtering on an empty list of email ids is not supported',
37
+ });
38
+ }
39
+
40
+ for (const emailId of emailIds) {
41
+ if (typeof emailId !== 'string') {
42
+ throw new SimpleError({
43
+ code: 'invalid_field',
44
+ field: 'filter',
45
+ message: 'Invalid email ID in filter',
46
+ });
47
+ }
48
+ }
49
+
50
+ const emails = await Email.getByIDs(...emailIds as string[]);
51
+
52
+ console.log('Fetching recipients for emails', emails.map(g => g.subject));
53
+
54
+ for (const email of emails) {
55
+ if (!await Context.auth.canAccessEmail(email, permissionLevel)) {
56
+ throw Context.auth.error({
57
+ message: 'You do not have access to this email',
58
+ human: $t(`Je hebt geen toegangsrechten tot de ontvangers van deze email`),
59
+ });
60
+ }
61
+ }
62
+
63
+ return true;
64
+ }
@@ -966,7 +966,11 @@ export class PatchOrganizationMembersEndpoint extends Endpoint<Params, Query, Bo
966
966
  }
967
967
 
968
968
  static shouldCheckIfMemberIsDuplicate(put: Member): boolean {
969
- if (put.details.firstName.length <= 3 && put.details.lastName.length <= 3) {
969
+ if (put.details.firstName === '???') {
970
+ return false;
971
+ }
972
+
973
+ if (put.details.name.length <= 3) {
970
974
  return false;
971
975
  }
972
976
 
@@ -5,6 +5,7 @@ import { Decoder } from '@simonbackx/simple-encoding';
5
5
  import { SimpleError } from '@simonbackx/simple-errors';
6
6
  import { RegistrationPeriod } from '@stamhoofd/models';
7
7
  import { applySQLSorter, compileToSQLFilter, SQLFilterDefinitions, SQLSortDefinitions } from '@stamhoofd/sql';
8
+ import { Context } from '../../../helpers/Context';
8
9
  import { registrationPeriodFilterCompilers } from '../../../sql-filters/registration-periods';
9
10
  import { registrationPeriodSorters } from '../../../sql-sorters/registration-periods';
10
11
 
@@ -33,9 +34,24 @@ export class GetRegistrationPeriodsEndpoint extends Endpoint<Params, Query, Body
33
34
  }
34
35
 
35
36
  static async buildQuery(q: CountFilteredRequest | LimitedFilteredRequest) {
36
- const scopeFilter: StamhoofdFilter | undefined = undefined;
37
+ let scopeFilter: StamhoofdFilter | undefined = undefined;
37
38
  const query = RegistrationPeriod.select();
38
39
 
40
+ if (STAMHOOFD.userMode === 'organization') {
41
+ const organization = Context.organization;
42
+
43
+ if (!organization) {
44
+ throw new SimpleError({
45
+ code: 'no_organization',
46
+ message: 'Organization is undefined on Context',
47
+ });
48
+ }
49
+
50
+ scopeFilter = {
51
+ organizationId: organization.id,
52
+ };
53
+ }
54
+
39
55
  if (scopeFilter) {
40
56
  query.where(await compileToSQLFilter(scopeFilter, filterCompilers));
41
57
  }
@@ -127,6 +143,8 @@ export class GetRegistrationPeriodsEndpoint extends Endpoint<Params, Query, Body
127
143
  });
128
144
  }
129
145
 
146
+ await Context.setUserOrganizationScope();
147
+
130
148
  return new Response(
131
149
  await GetRegistrationPeriodsEndpoint.buildData(request.query),
132
150
  );
@@ -1,9 +1,10 @@
1
1
  import { DecodedRequest, Endpoint, Request, Response } from '@simonbackx/simple-endpoints';
2
- import { SimpleError } from '@simonbackx/simple-errors';
3
2
  import { BalanceItem, Order, Webshop } from '@stamhoofd/models';
4
3
  import { PermissionLevel } from '@stamhoofd/structures';
5
4
 
6
5
  import { Context } from '../../../../helpers/Context';
6
+ import { UitpasService } from '../../../../services/uitpas/UitpasService';
7
+ import { SimpleError } from '@simonbackx/simple-errors';
7
8
 
8
9
  type Params = { id: string };
9
10
  type Query = undefined;
@@ -42,6 +43,14 @@ export class DeleteWebshopEndpoint extends Endpoint<Params, Query, Body, Respons
42
43
  throw Context.auth.notFoundOrNoAccess();
43
44
  }
44
45
 
46
+ if (await UitpasService.areThereRegisteredTicketSales(webshop.id)) {
47
+ throw new SimpleError({
48
+ code: 'webshop_has_registered_ticket_sales',
49
+ message: `Webshop ${webshop.id} has registered ticket sales`,
50
+ human: $t(`0b3d6ea1-a70b-428c-9ba4-cc0c327ed415`),
51
+ });
52
+ }
53
+
45
54
  const orders = await Order.where({ webshopId: webshop.id });
46
55
  await BalanceItem.deleteForDeletedOrders(orders.map(o => o.id));
47
56
  await webshop.delete();
@@ -7,6 +7,7 @@ import { AuditLogSource, BalanceItemRelation, BalanceItemRelationType, BalanceIt
7
7
 
8
8
  import { Context } from '../../../../helpers/Context';
9
9
  import { AuditLogService } from '../../../../services/AuditLogService';
10
+ import { shouldReserveUitpasNumbers, UitpasService } from '../../../../services/uitpas/UitpasService';
10
11
 
11
12
  type Params = { id: string };
12
13
  type Query = undefined;
@@ -132,6 +133,7 @@ export class PatchWebshopOrdersEndpoint extends Endpoint<Params, Query, Body, Re
132
133
 
133
134
  // TODO: validate before updating stock
134
135
  order.data.validate(webshopGetter.struct, organization.meta, request.i18n, true);
136
+ order.data.cart = await UitpasService.validateCart(organization.id, webshop.id, order.data.cart);
135
137
 
136
138
  try {
137
139
  await order.updateStock(null, true);
@@ -230,6 +232,8 @@ export class PatchWebshopOrdersEndpoint extends Endpoint<Params, Query, Body, Re
230
232
  const previousToPay = model.totalToPay;
231
233
  const previousStatus = model.status;
232
234
 
235
+ const shouldReserveBefore = shouldReserveUitpasNumbers(model.status);
236
+
233
237
  model.status = patch.status ?? model.status;
234
238
 
235
239
  // For now, we don't invalidate tickets, because they will get invalidated at scan time (the order status is checked)
@@ -240,13 +244,16 @@ export class PatchWebshopOrdersEndpoint extends Endpoint<Params, Query, Body, Re
240
244
  const previousData = model.data.clone();
241
245
  if (patch.data) {
242
246
  model.data.patchOrPut(patch.data);
243
-
244
247
  if (model.status !== OrderStatus.Deleted) {
245
248
  // Make sure all data is up to date and validated (= possible corrections happen here too)
246
249
  model.data.validate(webshopGetter.struct, organization.meta, request.i18n, true);
247
250
  }
248
251
  }
249
252
 
253
+ if ((patch.data || !shouldReserveBefore) && shouldReserveUitpasNumbers(model.status)) {
254
+ model.data.cart = await UitpasService.validateCart(organization.id, webshop.id, model.data.cart, model.id);
255
+ }
256
+
250
257
  if (model.status === OrderStatus.Deleted) {
251
258
  model.data.removePersonalData();
252
259
 
@@ -3,7 +3,7 @@ import { Decoder } from '@simonbackx/simple-encoding';
3
3
  import { DecodedRequest, Endpoint, Request, Response } from '@simonbackx/simple-endpoints';
4
4
  import { SimpleError } from '@simonbackx/simple-errors';
5
5
  import { Email } from '@stamhoofd/email';
6
- import { BalanceItem, BalanceItemPayment, MolliePayment, MollieToken, Order, PayconiqPayment, Payment, RateLimiter, Webshop, WebshopDiscountCode, WebshopUitpasNumber } from '@stamhoofd/models';
6
+ import { BalanceItem, BalanceItemPayment, MolliePayment, MollieToken, Order, PayconiqPayment, Payment, RateLimiter, Webshop, WebshopDiscountCode } from '@stamhoofd/models';
7
7
  import { QueueHandler } from '@stamhoofd/queues';
8
8
  import { AuditLogSource, BalanceItemRelation, BalanceItemRelationType, BalanceItemStatus, BalanceItemType, OrderData, OrderResponse, Order as OrderStruct, PaymentCustomer, PaymentMethod, PaymentMethodHelper, PaymentProvider, PaymentStatus, Payment as PaymentStruct, TranslatedString, Version, WebshopAuthType, Webshop as WebshopStruct } from '@stamhoofd/structures';
9
9
  import { Formatter } from '@stamhoofd/utility';
@@ -133,72 +133,7 @@ export class PlaceOrderEndpoint extends Endpoint<Params, Query, Body, ResponseBo
133
133
  request.body.validate(webshopStruct, organization.meta, request.i18n, false, Context.user?.getStructure());
134
134
  request.body.update(webshopStruct);
135
135
 
136
- // UiTPAS numbers validation
137
- const articlesWithUitpasSocialTariff = request.body.cart.items.filter(item => item.productPrice.uitpasBaseProductPriceId !== null);
138
- for (const item of articlesWithUitpasSocialTariff) {
139
- const uitpasNumbersOnly = item.uitpasNumbers.map(p => p.uitpasNumber);
140
-
141
- // verify the amount of UiTPAS numbers
142
- if (uitpasNumbersOnly.length !== item.amount) {
143
- throw new SimpleError({
144
- code: 'amount_of_uitpas_numbers_mismatch',
145
- message: 'The number of UiTPAS numbers and items with UiTPAS social tariff does not match',
146
- human: $t('6140c642-69b2-43d6-80ba-2af4915c5837'),
147
- field: 'cart.items.uitpasNumbers',
148
- });
149
- }
150
-
151
- // verify the UiTPAS numbers are unique (within the order)
152
- if (uitpasNumbersOnly.length !== Formatter.uniqueArray(uitpasNumbersOnly).length) {
153
- throw new SimpleError({
154
- code: 'duplicate_uitpas_numbers',
155
- message: 'Duplicate uitpas numbers used',
156
- human: $t('d9ec27f3-dafa-41e8-bcfb-9da564a4a675'),
157
- field: 'cart.items.uitpasNumbers',
158
- });
159
- }
160
-
161
- // verify the UiTPAS numbers are not already used for this product
162
- const hasBeenUsed = await WebshopUitpasNumber.areUitpasNumbersUsed(webshop.id, item.product.id, uitpasNumbersOnly);
163
- if (hasBeenUsed) {
164
- throw new SimpleError({
165
- code: 'uitpas_number_already_used',
166
- message: 'One or more uitpas numbers are already used',
167
- human: $t('1ef059c2-e758-4cfa-bc2b-16a581029450'),
168
- field: 'cart.items.uitpasNumbers',
169
- });
170
- }
171
-
172
- // verify the UiTPAS numbers are valid for social tariff (static check + API call to UiTPAS)
173
- if (item.product.uitpasEvent) {
174
- const basePrice = item.product.prices.find(p => p.id === item.productPrice.uitpasBaseProductPriceId)?.price ?? 0;
175
- const reducedPrices = await UitpasService.getSocialTariffForUitpasNumbers(organization.id, uitpasNumbersOnly, basePrice, item.product.uitpasEvent.url);
176
- const expectedReducedPrices = item.uitpasNumbers;
177
- if (reducedPrices.length < expectedReducedPrices.length) {
178
- // should not happen
179
- throw new SimpleError({
180
- code: 'uitpas_social_tariff_price_mismatch',
181
- message: 'UiTPAS wrong number of prices retruned',
182
- human: $t('2d1983fa-2224-422f-9ea0-fdae77cb4914'),
183
- field: 'cart.items.uitpasNumbers',
184
- });
185
- }
186
- for (let i = 0; i < expectedReducedPrices.length; i++) {
187
- if (reducedPrices[i].price !== expectedReducedPrices[i].price) {
188
- throw new SimpleError({
189
- code: 'uitpas_social_tariff_price_mismatch',
190
- message: 'UiTPAS social tariff have a different price',
191
- human: $t('2f4b9572-4b9c-42e0-91f1-b0984624d225', { correctPrice: Formatter.price(reducedPrices[i].price), orderPrice: Formatter.price(expectedReducedPrices[i].price) }),
192
- field: 'uitpasNumbers.' + i.toString(),
193
- });
194
- }
195
- item.uitpasNumbers[i].uitpasTariffId = reducedPrices[i].uitpasTariffId;
196
- }
197
- }
198
- else {
199
- await UitpasService.checkUitpasNumbers(uitpasNumbersOnly); // Throws if invalid
200
- }
201
- }
136
+ request.body.cart = await UitpasService.validateCart(organization.id, webshop.id, request.body.cart);
202
137
 
203
138
  const order = new Order().setRelation(Order.webshop, webshop);
204
139
  order.data = request.body; // TODO: validate