@stamhoofd/backend 2.49.1 → 2.50.2

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/index.ts CHANGED
@@ -10,7 +10,7 @@ import { loadLogger } from '@stamhoofd/logging';
10
10
  import { Version } from '@stamhoofd/structures';
11
11
  import { sleep } from '@stamhoofd/utility';
12
12
 
13
- import { areCronsRunning, crons, stopCronScheduling } from './src/crons';
13
+ import { stopCrons, startCrons, waitForCrons } from '@stamhoofd/crons';
14
14
  import { resumeEmails } from './src/helpers/EmailResumer';
15
15
  import { ContextMiddleware } from './src/middleware/ContextMiddleware';
16
16
 
@@ -115,8 +115,7 @@ const start = async () => {
115
115
  routerServer.server.keepAliveTimeout = 1;
116
116
  }
117
117
 
118
- stopCronScheduling();
119
- clearInterval(cronInterval);
118
+ stopCrons();
120
119
 
121
120
  if (STAMHOOFD.environment === 'development') {
122
121
  setTimeout(() => {
@@ -134,16 +133,7 @@ const start = async () => {
134
133
  console.error(err);
135
134
  }
136
135
 
137
- try {
138
- while (areCronsRunning()) {
139
- console.log('Crons are still running. Waiting 2 seconds...');
140
- await sleep(2000);
141
- }
142
- }
143
- catch (err) {
144
- console.error('Failed to wait for crons to finish:');
145
- console.error(err);
146
- }
136
+ await waitForCrons();
147
137
 
148
138
  try {
149
139
  while (Email.currentQueue.length > 0) {
@@ -185,10 +175,9 @@ const start = async () => {
185
175
  });
186
176
  });
187
177
 
188
- const cronInterval = setInterval(() => {
189
- crons().catch(console.error);
190
- }, 5 * 60 * 1000);
191
- crons().catch(console.error);
178
+ // Register crons
179
+ await import('./src/crons');
180
+ startCrons();
192
181
  seeds().catch(console.error);
193
182
  };
194
183
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stamhoofd/backend",
3
- "version": "2.49.1",
3
+ "version": "2.50.2",
4
4
  "main": "./dist/index.js",
5
5
  "exports": {
6
6
  ".": {
@@ -33,17 +33,17 @@
33
33
  "dependencies": {
34
34
  "@mollie/api-client": "3.7.0",
35
35
  "@simonbackx/simple-database": "1.25.0",
36
- "@simonbackx/simple-encoding": "2.16.1",
36
+ "@simonbackx/simple-encoding": "2.16.5",
37
37
  "@simonbackx/simple-endpoints": "1.14.0",
38
38
  "@simonbackx/simple-logging": "^1.0.1",
39
- "@stamhoofd/backend-i18n": "2.49.1",
40
- "@stamhoofd/backend-middleware": "2.49.1",
41
- "@stamhoofd/email": "2.49.1",
42
- "@stamhoofd/models": "2.49.1",
43
- "@stamhoofd/queues": "2.49.1",
44
- "@stamhoofd/sql": "2.49.1",
45
- "@stamhoofd/structures": "2.49.1",
46
- "@stamhoofd/utility": "2.49.1",
39
+ "@stamhoofd/backend-i18n": "2.50.2",
40
+ "@stamhoofd/backend-middleware": "2.50.2",
41
+ "@stamhoofd/email": "2.50.2",
42
+ "@stamhoofd/models": "2.50.2",
43
+ "@stamhoofd/queues": "2.50.2",
44
+ "@stamhoofd/sql": "2.50.2",
45
+ "@stamhoofd/structures": "2.50.2",
46
+ "@stamhoofd/utility": "2.50.2",
47
47
  "archiver": "^7.0.1",
48
48
  "aws-sdk": "^2.885.0",
49
49
  "axios": "1.6.8",
@@ -60,5 +60,8 @@
60
60
  "postmark": "^4.0.5",
61
61
  "stripe": "^16.6.0"
62
62
  },
63
- "gitHead": "d99dc987933ceb59c4f5fcb0e4af869b4007826a"
63
+ "publishConfig": {
64
+ "access": "public"
65
+ },
66
+ "gitHead": "ffbc162ba4d0def7714d88b255b72a5f8991b4c3"
64
67
  }
package/src/crons.ts CHANGED
@@ -1,13 +1,13 @@
1
1
  /* eslint-disable @typescript-eslint/no-unsafe-argument */
2
2
  import { Database } from '@simonbackx/simple-database';
3
- import { logger, StyledText } from '@simonbackx/simple-logging';
4
3
  import { Email, EmailAddress } from '@stamhoofd/email';
5
4
  import { Group, Organization, Payment, Registration, STPackage, Webshop } from '@stamhoofd/models';
6
5
  import { PaymentMethod, PaymentProvider, PaymentStatus } from '@stamhoofd/structures';
7
- import { Formatter, sleep } from '@stamhoofd/utility';
6
+ import { Formatter } from '@stamhoofd/utility';
8
7
  import AWS from 'aws-sdk';
9
8
  import { DateTime } from 'luxon';
10
9
 
10
+ import { registerCron } from '@stamhoofd/crons';
11
11
  import { clearExcelCache } from './crons/clearExcelCache';
12
12
  import { endFunctionsOfUsersWithoutRegistration } from './crons/endFunctionsOfUsersWithoutRegistration';
13
13
  import { ExchangePaymentEndpoint } from './endpoints/organization/shared/ExchangePaymentEndpoint';
@@ -654,163 +654,16 @@ async function checkDrips() {
654
654
  lastDripId = organizations[organizations.length - 1].id;
655
655
  }
656
656
 
657
- type CronJobDefinition = {
658
- name: string;
659
- method: () => Promise<void>;
660
- running: boolean;
661
- };
662
-
663
- const registeredCronJobs: CronJobDefinition[] = [];
664
-
665
- registeredCronJobs.push({
666
- name: 'checkSettlements',
667
- method: checkSettlements,
668
- running: false,
669
- });
670
-
671
- registeredCronJobs.push({
672
- name: 'checkFailedBuckarooPayments',
673
- method: checkFailedBuckarooPayments,
674
- running: false,
675
- });
676
-
677
- registeredCronJobs.push({
678
- name: 'checkExpirationEmails',
679
- method: checkExpirationEmails,
680
- running: false,
681
- });
682
-
683
- registeredCronJobs.push({
684
- name: 'checkPostmarkBounces',
685
- method: checkPostmarkBounces,
686
- running: false,
687
- });
688
-
689
- registeredCronJobs.push({
690
- name: 'checkReservedUntil',
691
- method: checkReservedUntil,
692
- running: false,
693
- });
694
-
695
- registeredCronJobs.push({
696
- name: 'checkComplaints',
697
- method: checkComplaints,
698
- running: false,
699
- });
700
-
701
- registeredCronJobs.push({
702
- name: 'checkReplies',
703
- method: checkReplies,
704
- running: false,
705
- });
706
-
707
- registeredCronJobs.push({
708
- name: 'checkBounces',
709
- method: checkBounces,
710
- running: false,
711
- });
712
-
713
- registeredCronJobs.push({
714
- name: 'checkDNS',
715
- method: checkDNS,
716
- running: false,
717
- });
718
-
719
- registeredCronJobs.push({
720
- name: 'checkWebshopDNS',
721
- method: checkWebshopDNS,
722
- running: false,
723
- });
724
-
725
- registeredCronJobs.push({
726
- name: 'checkPayments',
727
- method: checkPayments,
728
- running: false,
729
- });
730
-
731
- registeredCronJobs.push({
732
- name: 'checkDrips',
733
- method: checkDrips,
734
- running: false,
735
- });
736
-
737
- registeredCronJobs.push({
738
- name: 'clearExcelCache',
739
- method: clearExcelCache,
740
- running: false,
741
- });
742
-
743
- registeredCronJobs.push({
744
- name: 'endFunctionsOfUsersWithoutRegistration',
745
- method: endFunctionsOfUsersWithoutRegistration,
746
- running: false,
747
- });
748
-
749
- async function run(name: string, handler: () => Promise<void>) {
750
- try {
751
- await logger.setContext({
752
- prefixes: [
753
- new StyledText(`[${name}] `).addClass('crons', 'tag'),
754
- ],
755
- tags: ['crons'],
756
- }, async () => {
757
- try {
758
- await handler();
759
- }
760
- catch (e) {
761
- console.error(new StyledText(e).addClass('error'));
762
- }
763
- });
764
- }
765
- catch (e) {
766
- console.error(new StyledText(e).addClass('error'));
767
- }
768
- }
769
-
770
- let stopCrons = false;
771
- export function stopCronScheduling() {
772
- stopCrons = true;
773
- }
774
-
775
- let schedulingJobs = false;
776
- export function areCronsRunning(): boolean {
777
- if (schedulingJobs && !stopCrons) {
778
- return true;
779
- }
780
-
781
- for (const job of registeredCronJobs) {
782
- if (job.running) {
783
- return true;
784
- }
785
- }
786
- return false;
787
- }
788
-
789
- export const crons = async () => {
790
- if (STAMHOOFD.CRONS_DISABLED) {
791
- console.log('Crons are disabled. Make sure to enable them in the environment variables.');
792
- return;
793
- }
794
-
795
- schedulingJobs = true;
796
- for (const job of registeredCronJobs) {
797
- if (stopCrons) {
798
- break;
799
- }
800
- if (job.running) {
801
- continue;
802
- }
803
- job.running = true;
804
- run(job.name, job.method).finally(() => {
805
- job.running = false;
806
- }).catch((e) => {
807
- console.error(e);
808
- });
809
-
810
- // Prevent starting too many jobs at once
811
- if (STAMHOOFD.environment !== 'development') {
812
- await sleep(10 * 1000);
813
- }
814
- }
815
- schedulingJobs = false;
816
- };
657
+ registerCron('checkSettlements', checkSettlements);
658
+ registerCron('checkExpirationEmails', checkExpirationEmails);
659
+ registerCron('checkPostmarkBounces', checkPostmarkBounces);
660
+ registerCron('checkReservedUntil', checkReservedUntil);
661
+ registerCron('checkComplaints', checkComplaints);
662
+ registerCron('checkReplies', checkReplies);
663
+ registerCron('checkBounces', checkBounces);
664
+ registerCron('checkDNS', checkDNS);
665
+ registerCron('checkWebshopDNS', checkWebshopDNS);
666
+ registerCron('checkPayments', checkPayments);
667
+ registerCron('checkDrips', checkDrips);
668
+ registerCron('clearExcelCache', clearExcelCache);
669
+ registerCron('endFunctionsOfUsersWithoutRegistration', endFunctionsOfUsersWithoutRegistration);
@@ -40,7 +40,6 @@ export class PatchOrganizationsEndpoint extends Endpoint<Params, Query, Body, Re
40
40
  }
41
41
 
42
42
  const result: Organization[] = [];
43
- const platform = await Platform.getShared();
44
43
 
45
44
  for (const id of request.body.getDeletes()) {
46
45
  if (!Context.auth.hasPlatformFullAccess()) {
@@ -119,6 +118,9 @@ export class PatchOrganizationsEndpoint extends Endpoint<Params, Query, Body, Re
119
118
  organization.meta = put.meta;
120
119
  organization.address = put.address;
121
120
 
121
+ const periodId = (await Platform.getShared()).periodId;
122
+ organization.periodId = periodId;
123
+
122
124
  if (put.privateMeta) {
123
125
  organization.privateMeta = put.privateMeta;
124
126
  }
@@ -137,7 +139,7 @@ export class PatchOrganizationsEndpoint extends Endpoint<Params, Query, Body, Re
137
139
 
138
140
  const organizationPeriod = new OrganizationRegistrationPeriod();
139
141
  organizationPeriod.organizationId = organization.id;
140
- organizationPeriod.periodId = (await Platform.getShared()).periodId;
142
+ organizationPeriod.periodId = periodId;
141
143
  await organizationPeriod.save();
142
144
 
143
145
  result.push(organization);
@@ -80,16 +80,6 @@ export class CreateOrganizationEndpoint extends Endpoint<Params, Query, Body, Re
80
80
  organization.id = request.body.organization.id;
81
81
  organization.name = request.body.organization.name;
82
82
 
83
- // Delay save until after organization is saved, but do validations before the organization is saved
84
- // let registerCodeModels: Model[] = []
85
- // let delayEmails: EmailInterfaceBase[] = []
86
-
87
- // if (request.body.registerCode) {
88
- // const applied = await RegisterCode.applyRegisterCode(organization, request.body.registerCode)
89
- // registerCodeModels = applied.models
90
- // delayEmails = applied.emails
91
- // }
92
-
93
83
  organization.uri = uri;
94
84
  organization.meta = request.body.organization.meta;
95
85
  organization.address = request.body.organization.address;
@@ -2,6 +2,7 @@ import { AnyDecoder, AutoEncoder, Decoder, field, StringDecoder } from '@simonba
2
2
  import { DecodedRequest, Endpoint, Request, Response } from '@simonbackx/simple-endpoints';
3
3
  import { SimpleError } from '@simonbackx/simple-errors';
4
4
  import { Organization, StripeAccount, StripeCheckoutSession, StripePaymentIntent } from '@stamhoofd/models';
5
+ import { isDebouncedError, QueueHandler } from '@stamhoofd/queues';
5
6
 
6
7
  import { StripeHelper } from '../../../helpers/StripeHelper';
7
8
  import { ExchangePaymentEndpoint } from '../../organization/shared/ExchangePaymentEndpoint';
@@ -114,13 +115,7 @@ export class StripeWebookEndpoint extends Endpoint<Params, Query, Body, Response
114
115
  const checkoutId = request.body.data.object.id;
115
116
  const [model] = await StripeCheckoutSession.where({ stripeSessionId: checkoutId }, { limit: 1 });
116
117
  if (model && model.organizationId) {
117
- const organization = await Organization.getByID(model.organizationId);
118
- if (organization) {
119
- await ExchangePaymentEndpoint.pollStatus(model.paymentId, organization);
120
- }
121
- else {
122
- console.warn('Could not find organization with id', model.organizationId);
123
- }
118
+ await this.checkDebounced(model.paymentId, model.organizationId);
124
119
  }
125
120
  else {
126
121
  console.warn('Could not find stripe checkout session with id', checkoutId);
@@ -157,17 +152,35 @@ export class StripeWebookEndpoint extends Endpoint<Params, Query, Body, Response
157
152
  return new Response(undefined);
158
153
  }
159
154
 
155
+ async checkDebounced(paymentId: string, organizationId: string) {
156
+ // Stripe often sends a couple of webhooks for the same payment in short succession.
157
+ // Make sure we only do one check in a certain amount of time to prevent hammering the stripe API + system
158
+ try {
159
+ await QueueHandler.debounce('stripe-webhook/payment-' + paymentId, async () => {
160
+ const organization = await Organization.getByID(organizationId);
161
+ if (organization) {
162
+ await ExchangePaymentEndpoint.pollStatus(paymentId, organization);
163
+ }
164
+ else {
165
+ console.warn('Could not find organization with id', organizationId);
166
+ }
167
+ }, 1000);
168
+ }
169
+ catch (e) {
170
+ if (isDebouncedError(e)) {
171
+ // Okay, we are debounced (new request came in)
172
+ console.log('Debounced', paymentId);
173
+ return;
174
+ }
175
+ throw e;
176
+ }
177
+ }
178
+
160
179
  async updateIntent(intentId: string) {
161
180
  console.log('[Webooks] Updating intent', intentId);
162
181
  const [model] = await StripePaymentIntent.where({ stripeIntentId: intentId }, { limit: 1 });
163
182
  if (model && model.organizationId) {
164
- const organization = await Organization.getByID(model.organizationId);
165
- if (organization) {
166
- await ExchangePaymentEndpoint.pollStatus(model.paymentId, organization);
167
- }
168
- else {
169
- console.warn('Could not find organization with id', model.organizationId);
170
- }
183
+ await this.checkDebounced(model.paymentId, model.organizationId);
171
184
  }
172
185
  else {
173
186
  console.warn('Could not find stripe payment intent with id', intentId);
@@ -0,0 +1,49 @@
1
+ import { Decoder } from '@simonbackx/simple-encoding';
2
+ import { DecodedRequest, Endpoint, Request, Response } from '@simonbackx/simple-endpoints';
3
+ import { CountFilteredRequest, CountResponse } from '@stamhoofd/structures';
4
+
5
+ import { Context } from '../../../../helpers/Context';
6
+ import { GetDocumentsEndpoint } from './GetDocumentsEndpoint';
7
+
8
+ type Params = Record<string, never>;
9
+ type Query = CountFilteredRequest;
10
+ type Body = undefined;
11
+ type ResponseBody = CountResponse;
12
+
13
+ export class GetDocumentsCountEndpoint extends Endpoint<Params, Query, Body, ResponseBody> {
14
+ queryDecoder = CountFilteredRequest as Decoder<CountFilteredRequest>;
15
+
16
+ protected doesMatch(request: Request): [true, Params] | [false] {
17
+ if (request.method !== 'GET') {
18
+ return [false];
19
+ }
20
+
21
+ const params = Endpoint.parseParameters(request.url, '/organization/documents/count', {});
22
+
23
+ if (params) {
24
+ return [true, params as Params];
25
+ }
26
+ return [false];
27
+ }
28
+
29
+ async handle(request: DecodedRequest<Params, Query, Body>) {
30
+ const organization = await Context.setOrganizationScope();
31
+ await Context.authenticate();
32
+
33
+ // Fast throw first (more in depth checking for patches later)
34
+ if (!await Context.auth.hasSomeAccess(organization.id)) {
35
+ throw Context.auth.error();
36
+ }
37
+
38
+ const query = GetDocumentsEndpoint.buildQuery(request.query);
39
+
40
+ const count = await query
41
+ .count();
42
+
43
+ return new Response(
44
+ CountResponse.create({
45
+ count,
46
+ }),
47
+ );
48
+ }
49
+ }
@@ -1,26 +1,36 @@
1
1
  import { DecodedRequest, Endpoint, Request, Response } from '@simonbackx/simple-endpoints';
2
- import { SimpleError } from '@simonbackx/simple-errors';
3
- import { Document, DocumentTemplate } from '@stamhoofd/models';
4
- import { Document as DocumentStruct } from '@stamhoofd/structures';
2
+ import { Document } from '@stamhoofd/models';
3
+ import { assertSort, CountFilteredRequest, Document as DocumentStruct, getSortFilter, LimitedFilteredRequest, PaginatedResponse } from '@stamhoofd/structures';
5
4
 
5
+ import { Decoder } from '@simonbackx/simple-encoding';
6
+ import { compileToSQLFilter, compileToSQLSorter, SQL, SQLFilterDefinitions, SQLSortDefinitions } from '@stamhoofd/sql';
7
+ import { AuthenticatedStructures } from '../../../../helpers/AuthenticatedStructures';
6
8
  import { Context } from '../../../../helpers/Context';
9
+ import { LimitedFilteredRequestHelper } from '../../../../helpers/LimitedFilteredRequestHelper';
10
+ import { documentFilterCompilers } from '../../../../sql-filters/documents';
11
+ import { documentSorters } from '../../../../sql-sorters/documents';
7
12
 
8
- type Params = { id: string };
9
- type Query = undefined;
13
+ type Params = Record<string, never>;
14
+ type Query = LimitedFilteredRequest;
10
15
  type Body = undefined;
11
- type ResponseBody = DocumentStruct[];
16
+ type ResponseBody = PaginatedResponse<DocumentStruct[], LimitedFilteredRequest>;
17
+
18
+ const filterCompilers: SQLFilterDefinitions = documentFilterCompilers;
19
+ const sorters: SQLSortDefinitions<Document> = documentSorters;
12
20
 
13
21
  /**
14
22
  * One endpoint to create, patch and delete groups. Usefull because on organization setup, we need to create multiple groups at once. Also, sometimes we need to link values and update multiple groups at once
15
23
  */
16
24
 
17
25
  export class GetDocumentsEndpoint extends Endpoint<Params, Query, Body, ResponseBody> {
26
+ queryDecoder = LimitedFilteredRequest as Decoder<LimitedFilteredRequest>;
27
+
18
28
  protected doesMatch(request: Request): [true, Params] | [false] {
19
29
  if (request.method !== 'GET') {
20
30
  return [false];
21
31
  }
22
32
 
23
- const params = Endpoint.parseParameters(request.url, '/organization/document-templates/@id/documents', { id: String });
33
+ const params = Endpoint.parseParameters(request.url, '/organization/documents', {});
24
34
 
25
35
  if (params) {
26
36
  return [true, params as Params];
@@ -28,6 +38,76 @@ export class GetDocumentsEndpoint extends Endpoint<Params, Query, Body, Response
28
38
  return [false];
29
39
  }
30
40
 
41
+ static buildQuery(q: CountFilteredRequest | LimitedFilteredRequest) {
42
+ const organization = Context.organization!;
43
+
44
+ const documentTable: string = Document.table;
45
+
46
+ const query = SQL
47
+ .select(SQL.wildcard(documentTable))
48
+ .from(SQL.table(documentTable))
49
+ .where(compileToSQLFilter({
50
+ organizationId: organization.id,
51
+ }, filterCompilers));
52
+
53
+ if (q.filter) {
54
+ query.where(compileToSQLFilter(q.filter, filterCompilers));
55
+ }
56
+
57
+ if (q.search) {
58
+ // todo
59
+ // const searchFilter: StamhoofdFilter | null = getOrderSearchFilter(q.search, parsePhoneNumber);
60
+
61
+ // if (searchFilter) {
62
+ // query.where(compileToSQLFilter(searchFilter, filterCompilers));
63
+ // }
64
+ }
65
+
66
+ if (q instanceof LimitedFilteredRequest) {
67
+ if (q.pageFilter) {
68
+ query.where(compileToSQLFilter(q.pageFilter, filterCompilers));
69
+ }
70
+
71
+ q.sort = assertSort(q.sort, [{ key: 'id' }]);
72
+ query.orderBy(compileToSQLSorter(q.sort, sorters));
73
+ query.limit(q.limit);
74
+ }
75
+
76
+ return query;
77
+ }
78
+
79
+ static async buildData(requestQuery: LimitedFilteredRequest) {
80
+ const query = this.buildQuery(requestQuery);
81
+ const data = await query.fetch();
82
+
83
+ const documents: Document[] = Document.fromRows(data, Document.table);
84
+
85
+ let next: LimitedFilteredRequest | undefined;
86
+
87
+ if (documents.length >= requestQuery.limit) {
88
+ const lastObject = documents[documents.length - 1];
89
+ const nextFilter = getSortFilter(lastObject, sorters, requestQuery.sort);
90
+
91
+ next = new LimitedFilteredRequest({
92
+ filter: requestQuery.filter,
93
+ pageFilter: nextFilter,
94
+ sort: requestQuery.sort,
95
+ limit: requestQuery.limit,
96
+ search: requestQuery.search,
97
+ });
98
+
99
+ if (JSON.stringify(nextFilter) === JSON.stringify(requestQuery.pageFilter)) {
100
+ console.error('Found infinite loading loop for', requestQuery);
101
+ next = undefined;
102
+ }
103
+ }
104
+
105
+ return new PaginatedResponse<DocumentStruct[], LimitedFilteredRequest>({
106
+ results: await AuthenticatedStructures.documents(documents),
107
+ next,
108
+ });
109
+ }
110
+
31
111
  async handle(request: DecodedRequest<Params, Query, Body>) {
32
112
  const organization = await Context.setOrganizationScope();
33
113
  await Context.authenticate();
@@ -36,15 +116,13 @@ export class GetDocumentsEndpoint extends Endpoint<Params, Query, Body, Response
36
116
  throw Context.auth.error();
37
117
  }
38
118
 
39
- const template = await DocumentTemplate.getByID(request.params.id);
40
- if (!template || !await Context.auth.canAccessDocumentTemplate(template)) {
41
- throw Context.auth.notFoundOrNoAccess('Onbekend document');
42
- }
43
-
44
- const documents = await Document.where({ templateId: template.id });
119
+ LimitedFilteredRequestHelper.throwIfInvalidLimit({
120
+ request: request.query,
121
+ maxLimit: Context.auth.hasSomePlatformAccess() ? 1000 : 100,
122
+ });
45
123
 
46
124
  return new Response(
47
- documents.map(t => t.getStructure()),
125
+ await GetDocumentsEndpoint.buildData(request.query),
48
126
  );
49
127
  }
50
128
  }
@@ -3,6 +3,7 @@ import { DecodedRequest, Endpoint, Request, Response } from '@simonbackx/simple-
3
3
  import { EmailTemplate } from '@stamhoofd/models';
4
4
  import { EmailTemplate as EmailTemplateStruct, EmailTemplateType } from '@stamhoofd/structures';
5
5
 
6
+ import { SimpleError } from '@simonbackx/simple-errors';
6
7
  import { StringArrayDecoder } from '../../../../decoders/StringArrayDecoder';
7
8
  import { StringNullableDecoder } from '../../../../decoders/StringNullableDecoder';
8
9
  import { Context } from '../../../../helpers/Context';
@@ -54,6 +55,13 @@ export class GetEmailTemplatesEndpoint extends Endpoint<Params, Query, Body, Res
54
55
  }
55
56
  }
56
57
 
58
+ if (request.query.types?.length === 0) {
59
+ throw new SimpleError({
60
+ code: 'empty_types',
61
+ message: 'Types cannot be empty',
62
+ });
63
+ }
64
+
57
65
  const types = (request.query.types ?? [...Object.values(EmailTemplateType)]).filter((type) => {
58
66
  if (!organization) {
59
67
  return EmailTemplateStruct.allowPlatformLevel(type);
@@ -61,14 +69,29 @@ export class GetEmailTemplatesEndpoint extends Endpoint<Params, Query, Body, Res
61
69
  return EmailTemplateStruct.allowOrganizationLevel(type);
62
70
  });
63
71
 
72
+ if (types.length === 0) {
73
+ throw new SimpleError({
74
+ code: 'empty_types',
75
+ message: 'Types after filtering allowed types is empty',
76
+ });
77
+ }
78
+
64
79
  const templates = organization
65
80
  ? (
66
- await EmailTemplate.where({ organizationId: organization.id, webshopId: request.query.webshopId ?? null, groupId: request.query.groupIds ? { sign: 'IN', value: request.query.groupIds } : null, type: { sign: 'IN', value: types } })
81
+ await EmailTemplate.where({
82
+ organizationId: organization.id,
83
+ webshopId: request.query.webshopId ?? null,
84
+ groupId: request.query.groupIds ? { sign: 'IN', value: request.query.groupIds } : null,
85
+ type: { sign: 'IN', value: types } })
67
86
  )
68
87
  : (
69
88
  // Required for event emails when logged in as the platform admin
70
89
  (request.query.webshopId || request.query.groupIds)
71
- ? await EmailTemplate.where({ webshopId: request.query.webshopId ?? null, groupId: request.query.groupIds ? { sign: 'IN', value: request.query.groupIds } : null, type: { sign: 'IN', value: types } })
90
+ ? await EmailTemplate.where({
91
+ webshopId: request.query.webshopId ?? null,
92
+ groupId: request.query.groupIds ? { sign: 'IN', value: request.query.groupIds } : null,
93
+ type: { sign: 'IN', value: types },
94
+ })
72
95
  : []
73
96
  );
74
97
 
@@ -46,6 +46,9 @@ export class GetWebshopOrdersEndpoint extends Endpoint<Params, Query, Body, Resp
46
46
  .from(SQL.table(ordersTable))
47
47
  .where(compileToSQLFilter({
48
48
  organizationId: organization.id,
49
+ number: {
50
+ $neq: null,
51
+ },
49
52
  }, filterCompilers));
50
53
 
51
54
  if (q.filter) {
@@ -0,0 +1,49 @@
1
+ import { Decoder } from '@simonbackx/simple-encoding';
2
+ import { DecodedRequest, Endpoint, Request, Response } from '@simonbackx/simple-endpoints';
3
+ import { CountFilteredRequest, CountResponse } from '@stamhoofd/structures';
4
+ import { GetWebshopTicketsEndpoint } from './GetWebshopTicketsEndpoint';
5
+
6
+ import { Context } from '../../../../helpers/Context';
7
+
8
+ type Params = Record<string, never>;
9
+ type Query = CountFilteredRequest;
10
+ type Body = undefined;
11
+ type ResponseBody = CountResponse;
12
+
13
+ export class GetWebshopTicketsCountEndpoint extends Endpoint<Params, Query, Body, ResponseBody> {
14
+ queryDecoder = CountFilteredRequest as Decoder<CountFilteredRequest>;
15
+
16
+ protected doesMatch(request: Request): [true, Params] | [false] {
17
+ if (request.method !== 'GET') {
18
+ return [false];
19
+ }
20
+
21
+ const params = Endpoint.parseParameters(request.url, '/webshop/tickets/private/count', {});
22
+
23
+ if (params) {
24
+ return [true, params as Params];
25
+ }
26
+ return [false];
27
+ }
28
+
29
+ async handle(request: DecodedRequest<Params, Query, Body>): Promise<Response<ResponseBody>> {
30
+ const organization = await Context.setOrganizationScope();
31
+ await Context.authenticate();
32
+
33
+ // Fast throw first (more in depth checking for patches later)
34
+ if (!await Context.auth.hasSomeAccess(organization.id)) {
35
+ throw Context.auth.error();
36
+ }
37
+
38
+ const query = GetWebshopTicketsEndpoint.buildQuery(request.query);
39
+
40
+ const count = await query
41
+ .count();
42
+
43
+ return new Response(
44
+ CountResponse.create({
45
+ count,
46
+ }),
47
+ );
48
+ }
49
+ }
@@ -1,24 +1,32 @@
1
1
  import { Decoder } from '@simonbackx/simple-encoding';
2
2
  import { DecodedRequest, Endpoint, Request, Response } from '@simonbackx/simple-endpoints';
3
- import { Ticket, Webshop } from '@stamhoofd/models';
4
- import { PaginatedResponse, PermissionLevel, TicketPrivate, WebshopTicketsQuery } from '@stamhoofd/structures';
3
+ import { Ticket } from '@stamhoofd/models';
4
+ import { assertSort, CountFilteredRequest, getSortFilter, LimitedFilteredRequest, PaginatedResponse, TicketPrivate } from '@stamhoofd/structures';
5
5
 
6
+ import { compileToSQLFilter, compileToSQLSorter, SQL, SQLFilterDefinitions, SQLSortDefinitions } from '@stamhoofd/sql';
7
+ import { AuthenticatedStructures } from '../../../../helpers/AuthenticatedStructures';
6
8
  import { Context } from '../../../../helpers/Context';
9
+ import { LimitedFilteredRequestHelper } from '../../../../helpers/LimitedFilteredRequestHelper';
10
+ import { ticketFilterCompilers } from '../../../../sql-filters/tickets';
11
+ import { ticketSorters } from '../../../../sql-sorters/tickets';
7
12
 
8
- type Params = { id: string };
9
- type Query = WebshopTicketsQuery;
13
+ type Params = Record<string, never>;
14
+ type Query = LimitedFilteredRequest;
10
15
  type Body = undefined;
11
- type ResponseBody = PaginatedResponse<TicketPrivate[], Query>;
16
+ type ResponseBody = PaginatedResponse<TicketPrivate[], LimitedFilteredRequest>;
17
+
18
+ const filterCompilers: SQLFilterDefinitions = ticketFilterCompilers;
19
+ const sorters: SQLSortDefinitions<Ticket> = ticketSorters;
12
20
 
13
21
  export class GetWebshopTicketsEndpoint extends Endpoint<Params, Query, Body, ResponseBody> {
14
- queryDecoder = WebshopTicketsQuery as Decoder<WebshopTicketsQuery>;
22
+ queryDecoder = LimitedFilteredRequest as Decoder<LimitedFilteredRequest>;
15
23
 
16
24
  protected doesMatch(request: Request): [true, Params] | [false] {
17
25
  if (request.method !== 'GET') {
18
26
  return [false];
19
27
  }
20
28
 
21
- const params = Endpoint.parseParameters(request.url, '/webshop/@id/tickets/private', { id: String });
29
+ const params = Endpoint.parseParameters(request.url, '/webshop/tickets/private', {});
22
30
 
23
31
  if (params) {
24
32
  return [true, params as Params];
@@ -26,45 +34,87 @@ export class GetWebshopTicketsEndpoint extends Endpoint<Params, Query, Body, Res
26
34
  return [false];
27
35
  }
28
36
 
29
- async handle(_: DecodedRequest<Params, Query, Body>): Promise<Response<ResponseBody>> {
30
- await Promise.resolve();
31
- throw new Error('Not implemented');
32
- /* const organization = await Context.setOrganizationScope();
33
- await Context.authenticate()
37
+ static buildQuery(q: CountFilteredRequest | LimitedFilteredRequest) {
38
+ const organization = Context.organization!;
34
39
 
35
- // Fast throw first (more in depth checking for patches later)
36
- if (!await Context.auth.hasSomeAccess(organization.id)) {
37
- throw Context.auth.error()
40
+ const ticketsTable: string = Ticket.table;
41
+
42
+ const query = SQL
43
+ .select(SQL.wildcard(ticketsTable))
44
+ .from(SQL.table(ticketsTable))
45
+ .where(compileToSQLFilter({
46
+ organizationId: organization.id,
47
+ }, filterCompilers));
48
+
49
+ if (q.filter) {
50
+ query.where(compileToSQLFilter(q.filter, filterCompilers));
38
51
  }
39
52
 
40
- const webshop = await Webshop.getByID(request.params.id)
41
- if (!webshop || !await Context.auth.canAccessWebshopTickets(webshop, PermissionLevel.Read)) {
42
- throw Context.auth.notFoundOrNoAccess("Je hebt geen toegang tot de tickets van deze webshop")
53
+ // currently no search supported, probably not needed?
54
+ // if (q.search) {
55
+ // }
56
+
57
+ if (q instanceof LimitedFilteredRequest) {
58
+ if (q.pageFilter) {
59
+ query.where(compileToSQLFilter(q.pageFilter, filterCompilers));
60
+ }
61
+
62
+ q.sort = assertSort(q.sort, [{ key: 'id' }]);
63
+ query.orderBy(compileToSQLSorter(q.sort, sorters));
64
+ query.limit(q.limit);
43
65
  }
44
66
 
45
- let tickets: Ticket[] | undefined = undefined
46
- const limit = 150
67
+ return query;
68
+ }
69
+
70
+ static async buildData(requestQuery: LimitedFilteredRequest) {
71
+ const query = this.buildQuery(requestQuery);
72
+ const data = await query.fetch();
73
+
74
+ const tickets: Ticket[] = Ticket.fromRows(data, Ticket.table);
47
75
 
48
- if (request.query.updatedSince !== undefined) {
49
- if (request.query.lastId !== undefined) {
50
- tickets = await Ticket.select("WHERE webshopId = ? AND (updatedAt > ? OR (updatedAt = ? AND id > ?)) ORDER BY updatedAt, id LIMIT ?", [webshop.id, request.query.updatedSince, request.query.updatedSince, request.query.lastId, limit])
51
- } else {
52
- tickets = await Ticket.select("WHERE webshopId = ? AND updatedAt >= ? ORDER BY updatedAt, id LIMIT ?", [webshop.id, request.query.updatedSince, limit])
76
+ let next: LimitedFilteredRequest | undefined;
77
+
78
+ if (tickets.length >= requestQuery.limit) {
79
+ const lastObject = tickets[tickets.length - 1];
80
+ const nextFilter = getSortFilter(lastObject, sorters, requestQuery.sort);
81
+
82
+ next = new LimitedFilteredRequest({
83
+ filter: requestQuery.filter,
84
+ pageFilter: nextFilter,
85
+ sort: requestQuery.sort,
86
+ limit: requestQuery.limit,
87
+ search: requestQuery.search,
88
+ });
89
+
90
+ if (JSON.stringify(nextFilter) === JSON.stringify(requestQuery.pageFilter)) {
91
+ console.error('Found infinite loading loop for', requestQuery);
92
+ next = undefined;
53
93
  }
54
- } else {
55
- tickets = await Ticket.select("WHERE webshopId = ? ORDER BY updatedAt, id LIMIT ?", [webshop.id, limit])
56
94
  }
57
95
 
58
- const supportsDeletedTickets = request.request.getVersion() >= 229
96
+ return new PaginatedResponse<TicketPrivate[], LimitedFilteredRequest>({
97
+ results: await AuthenticatedStructures.tickets(tickets),
98
+ next,
99
+ });
100
+ }
101
+
102
+ async handle(request: DecodedRequest<Params, Query, Body>): Promise<Response<ResponseBody>> {
103
+ const organization = await Context.setOrganizationScope();
104
+ await Context.authenticate();
105
+
106
+ // Fast throw first (more in depth checking for patches later)
107
+ if (!await Context.auth.hasSomeAccess(organization.id)) {
108
+ throw Context.auth.error();
109
+ }
110
+
111
+ LimitedFilteredRequestHelper.throwIfInvalidLimit({
112
+ request: request.query,
113
+ maxLimit: Context.auth.hasSomePlatformAccess() ? 1000 : 100,
114
+ });
59
115
 
60
116
  return new Response(
61
- new PaginatedResponse({
62
- results: tickets.map(ticket => TicketPrivate.create(ticket)).filter(ticket => supportsDeletedTickets || !ticket.deletedAt),
63
- next: tickets.length >= limit ? WebshopTicketsQuery.create({
64
- updatedSince: tickets[tickets.length - 1].updatedAt ?? undefined,
65
- lastId: tickets[tickets.length - 1].id ?? undefined
66
- }) : undefined
67
- })
68
- ); */
117
+ await GetWebshopTicketsEndpoint.buildData(request.query),
118
+ );
69
119
  }
70
120
  }
@@ -2,7 +2,7 @@ import { createMollieClient, PaymentStatus as MolliePaymentStatus } from '@molli
2
2
  import { AutoEncoder, BooleanDecoder, Decoder, field } from '@simonbackx/simple-encoding';
3
3
  import { DecodedRequest, Endpoint, Request, Response } from '@simonbackx/simple-endpoints';
4
4
  import { SimpleError } from '@simonbackx/simple-errors';
5
- import { BalanceItem, BalanceItemPayment, MolliePayment, MollieToken, Organization, PayconiqPayment, Payment } from '@stamhoofd/models';
5
+ import { BalanceItem, BalanceItemPayment, MolliePayment, MollieToken, Order, Organization, PayconiqPayment, Payment } from '@stamhoofd/models';
6
6
  import { QueueHandler } from '@stamhoofd/queues';
7
7
  import { PaymentGeneral, PaymentMethod, PaymentProvider, PaymentStatus } from '@stamhoofd/structures';
8
8
 
@@ -48,7 +48,7 @@ export class ExchangePaymentEndpoint extends Endpoint<Params, Query, Body, Respo
48
48
  async handle(request: DecodedRequest<Params, Query, Body>) {
49
49
  const organization = await Context.setOptionalOrganizationScope();
50
50
  if (!request.query.exchange) {
51
- await Context.authenticate();
51
+ await Context.optionalAuthenticate();
52
52
  }
53
53
 
54
54
  // Not method on payment because circular references (not supprted in ts)
@@ -64,8 +64,22 @@ export class ExchangePaymentEndpoint extends Endpoint<Params, Query, Body, Respo
64
64
  return new Response(undefined);
65
65
  }
66
66
 
67
+ // #region skip check permissions if order and created less than hour ago
68
+ let checkPermissions = true;
69
+ const hourAgo = new Date();
70
+ hourAgo.setHours(-1);
71
+
72
+ if (payment.createdAt > hourAgo) {
73
+ const orders = await Order.where({ paymentId: payment.id }, { limit: 1 });
74
+ const isOrder = orders[0] !== undefined;
75
+ if (isOrder) {
76
+ checkPermissions = false;
77
+ }
78
+ }
79
+ // #endregion
80
+
67
81
  return new Response(
68
- await AuthenticatedStructures.paymentGeneral(payment, true),
82
+ await AuthenticatedStructures.paymentGeneral(payment, checkPermissions),
69
83
  );
70
84
  }
71
85
 
@@ -154,7 +168,6 @@ export class ExchangePaymentEndpoint extends Endpoint<Params, Query, Body, Respo
154
168
  */
155
169
  static async pollStatus(paymentId: string, org: Organization | null, cancel = false): Promise<Payment | undefined> {
156
170
  // Prevent polling the same payment multiple times at the same time: create a queue to prevent races
157
- QueueHandler.cancel('payments/' + paymentId); // Prevent creating more than one queue item for the same payment
158
171
  return await QueueHandler.schedule('payments/' + paymentId, async () => {
159
172
  // Get a new copy of the payment (is required to prevent concurreny bugs)
160
173
  const payment = await Payment.getByID(paymentId);
@@ -1,6 +1,6 @@
1
1
  import { AutoEncoderPatchType, PatchMap } from '@simonbackx/simple-encoding';
2
2
  import { SimpleError } from '@simonbackx/simple-errors';
3
- import { BalanceItem, CachedBalance, Document, DocumentTemplate, EmailTemplate, Event, Group, Member, MemberPlatformMembership, MemberWithRegistrations, Order, Organization, OrganizationRegistrationPeriod, Payment, Registration, User, Webshop } from '@stamhoofd/models';
3
+ import { BalanceItem, CachedBalance, Document, EmailTemplate, Event, Group, Member, MemberPlatformMembership, MemberWithRegistrations, Order, Organization, OrganizationRegistrationPeriod, Payment, Registration, User, Webshop } from '@stamhoofd/models';
4
4
  import { AccessRight, EventPermissionChecker, FinancialSupportSettings, GroupCategory, GroupStatus, MemberWithRegistrationsBlob, PermissionLevel, PermissionsResourceType, Platform as PlatformStruct, RecordCategory } from '@stamhoofd/structures';
5
5
  import { Formatter } from '@stamhoofd/utility';
6
6
  import { addTemporaryMemberAccess, hasTemporaryMemberAccess } from './TemporaryMemberAccess';
@@ -305,7 +305,7 @@ export class AdminPermissionChecker {
305
305
  return false;
306
306
  }
307
307
 
308
- async canAccessWebshop(webshop: Webshop, permissionLevel: PermissionLevel = PermissionLevel.Read) {
308
+ async canAccessWebshop(webshop: { id: string; organizationId: string }, permissionLevel: PermissionLevel = PermissionLevel.Read) {
309
309
  const organizationPermissions = await this.getOrganizationPermissions(webshop.organizationId);
310
310
 
311
311
  if (!organizationPermissions) {
@@ -323,7 +323,7 @@ export class AdminPermissionChecker {
323
323
  return false;
324
324
  }
325
325
 
326
- async canAccessWebshopTickets(webshop: Webshop, permissionLevel: PermissionLevel = PermissionLevel.Read) {
326
+ async canAccessWebshopTickets(webshop: { id: string; organizationId: string }, permissionLevel: PermissionLevel = PermissionLevel.Read) {
327
327
  if (!this.checkScope(webshop.organizationId)) {
328
328
  return false;
329
329
  }
@@ -345,7 +345,7 @@ export class AdminPermissionChecker {
345
345
  return false;
346
346
  }
347
347
 
348
- async canAccessOrder(webshop: Webshop, permissionLevel: PermissionLevel = PermissionLevel.Read) {
348
+ async canAccessOrder(webshop: { id: string; organizationId: string }, permissionLevel: PermissionLevel = PermissionLevel.Read) {
349
349
  return await this.canAccessWebshop(webshop, permissionLevel);
350
350
  }
351
351
 
@@ -477,7 +477,7 @@ export class AdminPermissionChecker {
477
477
  return false;
478
478
  }
479
479
 
480
- async canAccessDocumentTemplate(documentTemplate: DocumentTemplate, _: PermissionLevel = PermissionLevel.Read) {
480
+ async canAccessDocumentTemplate(documentTemplate: { organizationId: string }, _: PermissionLevel = PermissionLevel.Read) {
481
481
  if (!this.checkScope(documentTemplate.organizationId)) {
482
482
  return false;
483
483
  }
@@ -1,6 +1,6 @@
1
1
  import { SimpleError } from '@simonbackx/simple-errors';
2
- import { CachedBalance, Event, Group, Member, MemberPlatformMembership, MemberResponsibilityRecord, MemberWithRegistrations, Order, Organization, OrganizationRegistrationPeriod, Payment, RegistrationPeriod, User, Webshop } from '@stamhoofd/models';
3
- import { AccessRight, Event as EventStruct, Group as GroupStruct, MemberPlatformMembership as MemberPlatformMembershipStruct, MemberWithRegistrationsBlob, MembersBlob, OrganizationRegistrationPeriod as OrganizationRegistrationPeriodStruct, Organization as OrganizationStruct, PaymentGeneral, PermissionLevel, PrivateOrder, PrivateWebshop, ReceivableBalanceObject, ReceivableBalanceObjectContact, ReceivableBalance as ReceivableBalanceStruct, ReceivableBalanceType, UserWithMembers, WebshopPreview, Webshop as WebshopStruct } from '@stamhoofd/structures';
2
+ import { BalanceItem, CachedBalance, Document, Event, Group, Member, MemberPlatformMembership, MemberResponsibilityRecord, MemberWithRegistrations, Order, Organization, OrganizationRegistrationPeriod, Payment, RegistrationPeriod, Ticket, User, Webshop } from '@stamhoofd/models';
3
+ import { AccessRight, Document as DocumentStruct, Event as EventStruct, Group as GroupStruct, MemberPlatformMembership as MemberPlatformMembershipStruct, MemberWithRegistrationsBlob, MembersBlob, OrganizationRegistrationPeriod as OrganizationRegistrationPeriodStruct, Organization as OrganizationStruct, PaymentGeneral, PermissionLevel, PrivateOrder, PrivateWebshop, ReceivableBalanceObject, ReceivableBalanceObjectContact, ReceivableBalance as ReceivableBalanceStruct, ReceivableBalanceType, TicketPrivate, UserWithMembers, WebshopPreview, Webshop as WebshopStruct } from '@stamhoofd/structures';
4
4
 
5
5
  import { Formatter } from '@stamhoofd/utility';
6
6
  import { Context } from './Context';
@@ -428,20 +428,63 @@ export class AuthenticatedStructures {
428
428
  }
429
429
 
430
430
  static async orders(orders: Order[]): Promise<PrivateOrder[]> {
431
- // Load groups
432
- // const groupIds = orders.map(e => e.groupId).filter(id => id !== null);
433
- // const groups = groupIds.length > 0 ? await Group.getByIDs(...groupIds) : [];
434
- // const groupStructs = await this.groups(groups);
435
-
436
431
  const result: PrivateOrder[] = [];
432
+ const webshopIds = new Set(orders.map(o => o.webshopId));
433
+
434
+ for (const webshopId of webshopIds) {
435
+ const organizationId = orders.find(o => o.webshopId === webshopId)!.organizationId;
436
+
437
+ const canAccess = await Context.auth.canAccessOrder({
438
+ id: webshopId,
439
+ organizationId,
440
+ }, PermissionLevel.Read);
441
+
442
+ if (!canAccess) {
443
+ throw new SimpleError({
444
+ code: 'permission_denied',
445
+ message: 'Permission denied',
446
+ human: 'Je hebt geen toegang tot de orders van deze webshop',
447
+ });
448
+ }
449
+ }
437
450
 
438
451
  for (const order of orders) {
439
- // const group = groupStructs.find(g => g.id == event.groupId) ?? null;
452
+ const balanceItems = await BalanceItem.where({ orderId: order.id });
440
453
 
441
454
  const struct = PrivateOrder.create({
442
455
  ...order,
443
- // todo!!!!!
444
- balanceItems: [],
456
+ balanceItems: await BalanceItem.getStructureWithPrivatePayments(balanceItems),
457
+ });
458
+
459
+ result.push(struct);
460
+ }
461
+
462
+ return result;
463
+ }
464
+
465
+ static async documents(documents: Document[]): Promise<DocumentStruct[]> {
466
+ const result: DocumentStruct[] = [];
467
+ const templateIds = new Set(documents.map(d => d.templateId));
468
+
469
+ for (const templateId of templateIds) {
470
+ const organizationId = documents.find(d => d.templateId === templateId)!.organizationId;
471
+
472
+ const canAccess = await Context.auth.canAccessDocumentTemplate({
473
+ organizationId,
474
+ }, PermissionLevel.Read);
475
+
476
+ if (!canAccess) {
477
+ throw new SimpleError({
478
+ code: 'permission_denied',
479
+ message: 'Permission denied',
480
+ human: 'Je hebt geen toegang tot de documenten van deze template',
481
+ });
482
+ }
483
+ }
484
+
485
+ for (const document of documents) {
486
+ const struct = DocumentStruct.create({
487
+ ...document,
445
488
  });
446
489
 
447
490
  result.push(struct);
@@ -450,6 +493,37 @@ export class AuthenticatedStructures {
450
493
  return result;
451
494
  }
452
495
 
496
+ // todo?
497
+ static async tickets(tickets: Ticket[]): Promise<TicketPrivate[]> {
498
+ const result: TicketPrivate[] = [];
499
+ const webshopIds = new Set(tickets.map(t => t.webshopId));
500
+
501
+ for (const webshopId of webshopIds) {
502
+ const organizationId = tickets.find(t => t.webshopId === webshopId)!.organizationId;
503
+
504
+ const canAccess = await Context.auth.canAccessWebshopTickets({
505
+ id: webshopId,
506
+ organizationId,
507
+ }, PermissionLevel.Read);
508
+
509
+ if (!canAccess) {
510
+ throw new SimpleError({
511
+ code: 'permission_denied',
512
+ message: 'Permission denied',
513
+ human: 'Je hebt geen toegang tot de tickets van deze webshop',
514
+ });
515
+ }
516
+ }
517
+
518
+ for (const ticket of tickets) {
519
+ const struct = TicketPrivate.create({ ...ticket });
520
+
521
+ result.push(struct);
522
+ }
523
+
524
+ return result;
525
+ }
526
+
453
527
  static async receivableBalance(balance: CachedBalance): Promise<ReceivableBalanceStruct> {
454
528
  return (await this.receivableBalances([balance]))[0];
455
529
  }
@@ -358,6 +358,11 @@ export class StripeHelper {
358
358
  customer_creation: 'if_required',
359
359
  metadata: fullMetadata,
360
360
  expires_at: Math.floor(Date.now() / 1000) + 30 * 60, // Expire in 30 minutes
361
+ payment_method_options: {
362
+ card: {
363
+ request_three_d_secure: 'challenge', // Force usage of string customer authentication for card payments
364
+ },
365
+ },
361
366
  });
362
367
  console.log('Stripe session', session);
363
368
 
@@ -0,0 +1,17 @@
1
+ import { baseSQLFilterCompilers, createSQLColumnFilterCompiler, createSQLExpressionFilterCompiler, SQL, SQLFilterDefinitions, SQLValueType } from '@stamhoofd/sql';
2
+
3
+ export const documentFilterCompilers: SQLFilterDefinitions = {
4
+ ...baseSQLFilterCompilers,
5
+ id: createSQLColumnFilterCompiler('id'),
6
+ description: createSQLExpressionFilterCompiler(
7
+ SQL.jsonValue(SQL.column('data'), '$.value.description'),
8
+ { isJSONValue: true, type: SQLValueType.JSONString },
9
+ ),
10
+ organizationId: createSQLColumnFilterCompiler('organizationId'),
11
+ templateId: createSQLColumnFilterCompiler('templateId'),
12
+ memberId: createSQLColumnFilterCompiler('memberId'),
13
+ updatedAt: createSQLColumnFilterCompiler('updatedAt'),
14
+ createdAt: createSQLColumnFilterCompiler('createdAt'),
15
+ number: createSQLColumnFilterCompiler('number'),
16
+ status: createSQLColumnFilterCompiler('status'),
17
+ };
@@ -0,0 +1,10 @@
1
+ import { baseSQLFilterCompilers, createSQLColumnFilterCompiler, SQLFilterDefinitions } from '@stamhoofd/sql';
2
+
3
+ export const ticketFilterCompilers: SQLFilterDefinitions = {
4
+ ...baseSQLFilterCompilers,
5
+ organizationId: createSQLColumnFilterCompiler('organizationId'),
6
+ updatedAt: createSQLColumnFilterCompiler('updatedAt'),
7
+ webshopId: createSQLColumnFilterCompiler('webshopId'),
8
+ id: createSQLColumnFilterCompiler('id'),
9
+ createdAt: createSQLColumnFilterCompiler('createdAt'),
10
+ };
@@ -0,0 +1,79 @@
1
+ import { Document } from '@stamhoofd/models';
2
+ import { SQL, SQLOrderBy, SQLOrderByDirection, SQLSortDefinitions } from '@stamhoofd/sql';
3
+ import { Formatter } from '@stamhoofd/utility';
4
+
5
+ export const documentSorters: SQLSortDefinitions<Document> = {
6
+ // WARNING! TEST NEW SORTERS THOROUGHLY!
7
+ // Try to avoid creating sorters on fields that er not 1:1 with the database, that often causes pagination issues if not thought through
8
+ // An example: sorting on 'name' is not a good idea, because it is a concatenation of two fields.
9
+ // You might be tempted to use ORDER BY firstName, lastName, but that will not work as expected and it needs to be ORDER BY CONCAT(firstName, ' ', lastName)
10
+ // Why? Because ORDER BY firstName, lastName produces a different order dan ORDER BY CONCAT(firstName, ' ', lastName) if there are multiple people with spaces in the first name
11
+ // And that again causes issues with pagination because the next query will append a filter of name > 'John Doe' - causing duplicate and/or skipped results
12
+ // What if you need mapping? simply map the sorters in the frontend: name -> firstname, lastname, age -> birthDay, etc.
13
+ id: {
14
+ getValue(a) {
15
+ return a.id;
16
+ },
17
+ toSQL: (direction: SQLOrderByDirection): SQLOrderBy => {
18
+ return new SQLOrderBy({
19
+ column: SQL.column('id'),
20
+ direction,
21
+ });
22
+ },
23
+ },
24
+ description: {
25
+ getValue(a) {
26
+ return a.data.description;
27
+ },
28
+ toSQL: (direction: SQLOrderByDirection): SQLOrderBy => {
29
+ return new SQLOrderBy({
30
+ column: SQL.jsonValue(SQL.column('data'), '$.value.description'),
31
+ direction,
32
+ });
33
+ },
34
+ },
35
+ createdAt: {
36
+ getValue(a) {
37
+ return Formatter.dateTimeIso(a.createdAt);
38
+ },
39
+ toSQL: (direction: SQLOrderByDirection): SQLOrderBy => {
40
+ return new SQLOrderBy({
41
+ column: SQL.column('createdAt'),
42
+ direction,
43
+ });
44
+ },
45
+ },
46
+ updatedAt: {
47
+ getValue(a) {
48
+ return Formatter.dateTimeIso(a.updatedAt);
49
+ },
50
+ toSQL: (direction: SQLOrderByDirection): SQLOrderBy => {
51
+ return new SQLOrderBy({
52
+ column: SQL.column('updatedAt'),
53
+ direction,
54
+ });
55
+ },
56
+ },
57
+ number: {
58
+ getValue(a) {
59
+ return a.number;
60
+ },
61
+ toSQL: (direction: SQLOrderByDirection): SQLOrderBy => {
62
+ return new SQLOrderBy({
63
+ column: SQL.column('number'),
64
+ direction,
65
+ });
66
+ },
67
+ },
68
+ status: {
69
+ getValue(a) {
70
+ return a.status;
71
+ },
72
+ toSQL: (direction: SQLOrderByDirection): SQLOrderBy => {
73
+ return new SQLOrderBy({
74
+ column: SQL.column('status'),
75
+ direction,
76
+ });
77
+ },
78
+ },
79
+ };
@@ -21,6 +21,17 @@ export const orderSorters: SQLSortDefinitions<Order> = {
21
21
  });
22
22
  },
23
23
  },
24
+ updatedAt: {
25
+ getValue(a) {
26
+ return Formatter.dateTimeIso(a.updatedAt);
27
+ },
28
+ toSQL: (direction: SQLOrderByDirection): SQLOrderBy => {
29
+ return new SQLOrderBy({
30
+ column: SQL.column('updatedAt'),
31
+ direction,
32
+ });
33
+ },
34
+ },
24
35
  id: {
25
36
  getValue(a) {
26
37
  return a.id;
@@ -0,0 +1,46 @@
1
+ import { Ticket } from '@stamhoofd/models';
2
+ import { SQL, SQLOrderBy, SQLOrderByDirection, SQLSortDefinitions } from '@stamhoofd/sql';
3
+ import { Formatter } from '@stamhoofd/utility';
4
+
5
+ export const ticketSorters: SQLSortDefinitions<Ticket> = {
6
+ // WARNING! TEST NEW SORTERS THOROUGHLY!
7
+ // Try to avoid creating sorters on fields that er not 1:1 with the database, that often causes pagination issues if not thought through
8
+ // An example: sorting on 'name' is not a good idea, because it is a concatenation of two fields.
9
+ // You might be tempted to use ORDER BY firstName, lastName, but that will not work as expected and it needs to be ORDER BY CONCAT(firstName, ' ', lastName)
10
+ // Why? Because ORDER BY firstName, lastName produces a different order dan ORDER BY CONCAT(firstName, ' ', lastName) if there are multiple people with spaces in the first name
11
+ // And that again causes issues with pagination because the next query will append a filter of name > 'John Doe' - causing duplicate and/or skipped results
12
+ // What if you need mapping? simply map the sorters in the frontend: name -> firstname, lastname, age -> birthDay, etc.
13
+ createdAt: {
14
+ getValue(a) {
15
+ return Formatter.dateTimeIso(a.createdAt);
16
+ },
17
+ toSQL: (direction: SQLOrderByDirection): SQLOrderBy => {
18
+ return new SQLOrderBy({
19
+ column: SQL.column('createdAt'),
20
+ direction,
21
+ });
22
+ },
23
+ },
24
+ updatedAt: {
25
+ getValue(a) {
26
+ return Formatter.dateTimeIso(a.updatedAt);
27
+ },
28
+ toSQL: (direction: SQLOrderByDirection): SQLOrderBy => {
29
+ return new SQLOrderBy({
30
+ column: SQL.column('updatedAt'),
31
+ direction,
32
+ });
33
+ },
34
+ },
35
+ id: {
36
+ getValue(a) {
37
+ return a.id;
38
+ },
39
+ toSQL: (direction: SQLOrderByDirection): SQLOrderBy => {
40
+ return new SQLOrderBy({
41
+ column: SQL.column('id'),
42
+ direction,
43
+ });
44
+ },
45
+ },
46
+ };