@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 +6 -17
- package/package.json +14 -11
- package/src/crons.ts +15 -162
- package/src/endpoints/admin/organizations/PatchOrganizationsEndpoint.ts +4 -2
- package/src/endpoints/global/organizations/CreateOrganizationEndpoint.ts +0 -10
- package/src/endpoints/global/payments/StripeWebhookEndpoint.ts +27 -14
- package/src/endpoints/organization/dashboard/documents/GetDocumentsCountEndpoint.ts +49 -0
- package/src/endpoints/organization/dashboard/documents/GetDocumentsEndpoint.ts +92 -14
- package/src/endpoints/organization/dashboard/email-templates/GetEmailTemplatesEndpoint.ts +25 -2
- package/src/endpoints/organization/dashboard/webshops/GetWebshopOrdersEndpoint.ts +3 -0
- package/src/endpoints/organization/dashboard/webshops/GetWebshopTicketsCountEndpoint +49 -0
- package/src/endpoints/organization/dashboard/webshops/GetWebshopTicketsEndpoint.ts +86 -36
- package/src/endpoints/organization/shared/ExchangePaymentEndpoint.ts +17 -4
- package/src/helpers/AdminPermissionChecker.ts +5 -5
- package/src/helpers/AuthenticatedStructures.ts +84 -10
- package/src/helpers/StripeHelper.ts +5 -0
- package/src/sql-filters/documents.ts +17 -0
- package/src/sql-filters/tickets.ts +10 -0
- package/src/sql-sorters/documents.ts +79 -0
- package/src/sql-sorters/orders.ts +11 -0
- package/src/sql-sorters/tickets.ts +46 -0
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
40
|
-
"@stamhoofd/backend-middleware": "2.
|
|
41
|
-
"@stamhoofd/email": "2.
|
|
42
|
-
"@stamhoofd/models": "2.
|
|
43
|
-
"@stamhoofd/queues": "2.
|
|
44
|
-
"@stamhoofd/sql": "2.
|
|
45
|
-
"@stamhoofd/structures": "2.
|
|
46
|
-
"@stamhoofd/utility": "2.
|
|
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
|
-
"
|
|
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
|
|
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
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
3
|
-
import { Document,
|
|
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 =
|
|
9
|
-
type Query =
|
|
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/
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
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({
|
|
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({
|
|
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
|
|
4
|
-
import {
|
|
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 =
|
|
9
|
-
type Query =
|
|
13
|
+
type Params = Record<string, never>;
|
|
14
|
+
type Query = LimitedFilteredRequest;
|
|
10
15
|
type Body = undefined;
|
|
11
|
-
type ResponseBody = PaginatedResponse<TicketPrivate[],
|
|
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 =
|
|
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
|
|
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
|
-
|
|
30
|
-
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
41
|
-
if (
|
|
42
|
-
|
|
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
|
-
|
|
46
|
-
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
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
|
-
|
|
62
|
-
|
|
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.
|
|
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,
|
|
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,
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
-
|
|
452
|
+
const balanceItems = await BalanceItem.where({ orderId: order.id });
|
|
440
453
|
|
|
441
454
|
const struct = PrivateOrder.create({
|
|
442
455
|
...order,
|
|
443
|
-
|
|
444
|
-
|
|
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
|
+
};
|