@stamhoofd/backend 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (150) hide show
  1. package/.env.template.json +63 -0
  2. package/.eslintrc.js +61 -0
  3. package/README.md +40 -0
  4. package/index.ts +172 -0
  5. package/jest.config.js +11 -0
  6. package/migrations.ts +33 -0
  7. package/package.json +48 -0
  8. package/src/crons.ts +845 -0
  9. package/src/endpoints/admin/organizations/GetOrganizationsCountEndpoint.ts +42 -0
  10. package/src/endpoints/admin/organizations/GetOrganizationsEndpoint.ts +320 -0
  11. package/src/endpoints/admin/organizations/PatchOrganizationsEndpoint.ts +171 -0
  12. package/src/endpoints/auth/CreateAdminEndpoint.ts +137 -0
  13. package/src/endpoints/auth/CreateTokenEndpoint.test.ts +68 -0
  14. package/src/endpoints/auth/CreateTokenEndpoint.ts +200 -0
  15. package/src/endpoints/auth/DeleteTokenEndpoint.ts +31 -0
  16. package/src/endpoints/auth/ForgotPasswordEndpoint.ts +70 -0
  17. package/src/endpoints/auth/GetUserEndpoint.test.ts +64 -0
  18. package/src/endpoints/auth/GetUserEndpoint.ts +57 -0
  19. package/src/endpoints/auth/PatchApiUserEndpoint.ts +90 -0
  20. package/src/endpoints/auth/PatchUserEndpoint.ts +122 -0
  21. package/src/endpoints/auth/PollEmailVerificationEndpoint.ts +37 -0
  22. package/src/endpoints/auth/RetryEmailVerificationEndpoint.ts +41 -0
  23. package/src/endpoints/auth/SignupEndpoint.ts +107 -0
  24. package/src/endpoints/auth/VerifyEmailEndpoint.ts +89 -0
  25. package/src/endpoints/global/addresses/SearchRegionsEndpoint.ts +95 -0
  26. package/src/endpoints/global/addresses/ValidateAddressEndpoint.ts +31 -0
  27. package/src/endpoints/global/caddy/CheckDomainCertEndpoint.ts +101 -0
  28. package/src/endpoints/global/email/GetEmailAddressEndpoint.ts +53 -0
  29. package/src/endpoints/global/email/ManageEmailAddressEndpoint.ts +57 -0
  30. package/src/endpoints/global/files/UploadFile.ts +147 -0
  31. package/src/endpoints/global/files/UploadImage.ts +119 -0
  32. package/src/endpoints/global/members/GetMemberFamilyEndpoint.ts +76 -0
  33. package/src/endpoints/global/members/GetMembersCountEndpoint.ts +43 -0
  34. package/src/endpoints/global/members/GetMembersEndpoint.ts +429 -0
  35. package/src/endpoints/global/members/PatchOrganizationMembersEndpoint.ts +734 -0
  36. package/src/endpoints/global/organizations/CheckRegisterCodeEndpoint.ts +45 -0
  37. package/src/endpoints/global/organizations/CreateOrganizationEndpoint.test.ts +105 -0
  38. package/src/endpoints/global/organizations/CreateOrganizationEndpoint.ts +146 -0
  39. package/src/endpoints/global/organizations/GetOrganizationFromDomainEndpoint.test.ts +52 -0
  40. package/src/endpoints/global/organizations/GetOrganizationFromDomainEndpoint.ts +80 -0
  41. package/src/endpoints/global/organizations/GetOrganizationFromUriEndpoint.ts +49 -0
  42. package/src/endpoints/global/organizations/SearchOrganizationEndpoint.test.ts +58 -0
  43. package/src/endpoints/global/organizations/SearchOrganizationEndpoint.ts +62 -0
  44. package/src/endpoints/global/payments/ExchangeSTPaymentEndpoint.ts +153 -0
  45. package/src/endpoints/global/payments/StripeWebhookEndpoint.ts +134 -0
  46. package/src/endpoints/global/platform/GetPlatformAdminsEndpoint.ts +44 -0
  47. package/src/endpoints/global/platform/GetPlatformEnpoint.ts +39 -0
  48. package/src/endpoints/global/platform/PatchPlatformEnpoint.ts +63 -0
  49. package/src/endpoints/global/registration/GetPaymentRegistrations.ts +68 -0
  50. package/src/endpoints/global/registration/GetUserBalanceEndpoint.ts +39 -0
  51. package/src/endpoints/global/registration/GetUserDocumentsEndpoint.ts +80 -0
  52. package/src/endpoints/global/registration/GetUserMembersEndpoint.ts +41 -0
  53. package/src/endpoints/global/registration/PatchUserMembersEndpoint.ts +134 -0
  54. package/src/endpoints/global/registration/RegisterMembersEndpoint.ts +521 -0
  55. package/src/endpoints/global/registration-periods/GetRegistrationPeriodsEndpoint.ts +37 -0
  56. package/src/endpoints/global/registration-periods/PatchRegistrationPeriodsEndpoint.ts +115 -0
  57. package/src/endpoints/global/webshops/GetWebshopFromDomainEndpoint.ts +187 -0
  58. package/src/endpoints/organization/dashboard/billing/ActivatePackagesEndpoint.ts +424 -0
  59. package/src/endpoints/organization/dashboard/billing/DeactivatePackageEndpoint.ts +67 -0
  60. package/src/endpoints/organization/dashboard/billing/GetBillingStatusEndpoint.ts +39 -0
  61. package/src/endpoints/organization/dashboard/documents/GetDocumentTemplateXML.ts +57 -0
  62. package/src/endpoints/organization/dashboard/documents/GetDocumentTemplatesEndpoint.ts +50 -0
  63. package/src/endpoints/organization/dashboard/documents/GetDocumentsEndpoint.ts +50 -0
  64. package/src/endpoints/organization/dashboard/documents/PatchDocumentEndpoint.ts +129 -0
  65. package/src/endpoints/organization/dashboard/documents/PatchDocumentTemplateEndpoint.ts +114 -0
  66. package/src/endpoints/organization/dashboard/email/CheckEmailBouncesEndpoint.ts +50 -0
  67. package/src/endpoints/organization/dashboard/email/EmailEndpoint.ts +234 -0
  68. package/src/endpoints/organization/dashboard/email-templates/GetEmailTemplatesEndpoint.ts +62 -0
  69. package/src/endpoints/organization/dashboard/email-templates/PatchEmailTemplatesEndpoint.ts +85 -0
  70. package/src/endpoints/organization/dashboard/mollie/CheckMollieEndpoint.ts +80 -0
  71. package/src/endpoints/organization/dashboard/mollie/ConnectMollieEndpoint.ts +54 -0
  72. package/src/endpoints/organization/dashboard/mollie/DisconnectMollieEndpoint.ts +49 -0
  73. package/src/endpoints/organization/dashboard/mollie/GetMollieDashboardEndpoint.ts +63 -0
  74. package/src/endpoints/organization/dashboard/nolt/CreateNoltTokenEndpoint.ts +61 -0
  75. package/src/endpoints/organization/dashboard/organization/ApplyRegisterCodeEndpoint.test.ts +64 -0
  76. package/src/endpoints/organization/dashboard/organization/ApplyRegisterCodeEndpoint.ts +84 -0
  77. package/src/endpoints/organization/dashboard/organization/GetOrganizationArchivedGroups.ts +43 -0
  78. package/src/endpoints/organization/dashboard/organization/GetOrganizationDeletedGroups.ts +42 -0
  79. package/src/endpoints/organization/dashboard/organization/GetOrganizationSSOEndpoint.ts +43 -0
  80. package/src/endpoints/organization/dashboard/organization/GetRegisterCodeEndpoint.ts +65 -0
  81. package/src/endpoints/organization/dashboard/organization/PatchOrganizationEndpoint.test.ts +281 -0
  82. package/src/endpoints/organization/dashboard/organization/PatchOrganizationEndpoint.ts +338 -0
  83. package/src/endpoints/organization/dashboard/organization/SetOrganizationDomainEndpoint.ts +196 -0
  84. package/src/endpoints/organization/dashboard/organization/SetOrganizationSSOEndpoint.ts +50 -0
  85. package/src/endpoints/organization/dashboard/payments/GetMemberBalanceEndpoint.ts +48 -0
  86. package/src/endpoints/organization/dashboard/payments/GetPaymentsEndpoint.ts +207 -0
  87. package/src/endpoints/organization/dashboard/payments/PatchBalanceItemsEndpoint.ts +202 -0
  88. package/src/endpoints/organization/dashboard/payments/PatchPaymentsEndpoint.ts +233 -0
  89. package/src/endpoints/organization/dashboard/registration-periods/GetOrganizationRegistrationPeriodsEndpoint.ts +66 -0
  90. package/src/endpoints/organization/dashboard/registration-periods/PatchOrganizationRegistrationPeriodsEndpoint.ts +210 -0
  91. package/src/endpoints/organization/dashboard/stripe/ConnectStripeEndpoint.ts +93 -0
  92. package/src/endpoints/organization/dashboard/stripe/DeleteStripeAccountEndpoint.ts +59 -0
  93. package/src/endpoints/organization/dashboard/stripe/GetStripeAccountLinkEndpoint.ts +78 -0
  94. package/src/endpoints/organization/dashboard/stripe/GetStripeAccountsEndpoint.ts +40 -0
  95. package/src/endpoints/organization/dashboard/stripe/GetStripeLoginLinkEndpoint.ts +69 -0
  96. package/src/endpoints/organization/dashboard/stripe/UpdateStripeAccountEndpoint.ts +52 -0
  97. package/src/endpoints/organization/dashboard/users/CreateApiUserEndpoint.ts +73 -0
  98. package/src/endpoints/organization/dashboard/users/DeleteUserEndpoint.ts +60 -0
  99. package/src/endpoints/organization/dashboard/users/GetApiUsersEndpoint.ts +47 -0
  100. package/src/endpoints/organization/dashboard/users/GetOrganizationAdminsEndpoint.ts +41 -0
  101. package/src/endpoints/organization/dashboard/webshops/CreateWebshopEndpoint.ts +217 -0
  102. package/src/endpoints/organization/dashboard/webshops/DeleteWebshopEndpoint.ts +51 -0
  103. package/src/endpoints/organization/dashboard/webshops/GetDiscountCodesEndpoint.ts +47 -0
  104. package/src/endpoints/organization/dashboard/webshops/GetWebshopOrdersEndpoint.ts +83 -0
  105. package/src/endpoints/organization/dashboard/webshops/GetWebshopTicketsEndpoint.ts +68 -0
  106. package/src/endpoints/organization/dashboard/webshops/GetWebshopUriAvailabilityEndpoint.ts +69 -0
  107. package/src/endpoints/organization/dashboard/webshops/PatchDiscountCodesEndpoint.ts +125 -0
  108. package/src/endpoints/organization/dashboard/webshops/PatchWebshopEndpoint.ts +204 -0
  109. package/src/endpoints/organization/dashboard/webshops/PatchWebshopOrdersEndpoint.ts +278 -0
  110. package/src/endpoints/organization/dashboard/webshops/PatchWebshopTicketsEndpoint.ts +80 -0
  111. package/src/endpoints/organization/dashboard/webshops/VerifyWebshopDomainEndpoint.ts +60 -0
  112. package/src/endpoints/organization/shared/ExchangePaymentEndpoint.ts +379 -0
  113. package/src/endpoints/organization/shared/GetDocumentHtml.ts +54 -0
  114. package/src/endpoints/organization/shared/GetPaymentEndpoint.ts +45 -0
  115. package/src/endpoints/organization/shared/auth/GetOrganizationEndpoint.test.ts +78 -0
  116. package/src/endpoints/organization/shared/auth/GetOrganizationEndpoint.ts +34 -0
  117. package/src/endpoints/organization/shared/auth/OpenIDConnectCallbackEndpoint.ts +44 -0
  118. package/src/endpoints/organization/shared/auth/OpenIDConnectStartEndpoint.ts +82 -0
  119. package/src/endpoints/organization/webshops/CheckWebshopDiscountCodesEndpoint.ts +59 -0
  120. package/src/endpoints/organization/webshops/GetOrderByPaymentEndpoint.ts +51 -0
  121. package/src/endpoints/organization/webshops/GetOrderEndpoint.ts +40 -0
  122. package/src/endpoints/organization/webshops/GetTicketsEndpoint.ts +124 -0
  123. package/src/endpoints/organization/webshops/GetWebshopEndpoint.test.ts +130 -0
  124. package/src/endpoints/organization/webshops/GetWebshopEndpoint.ts +50 -0
  125. package/src/endpoints/organization/webshops/PlaceOrderEndpoint.test.ts +450 -0
  126. package/src/endpoints/organization/webshops/PlaceOrderEndpoint.ts +335 -0
  127. package/src/helpers/AddressValidator.test.ts +40 -0
  128. package/src/helpers/AddressValidator.ts +256 -0
  129. package/src/helpers/AdminPermissionChecker.ts +1031 -0
  130. package/src/helpers/AuthenticatedStructures.ts +158 -0
  131. package/src/helpers/BuckarooHelper.ts +279 -0
  132. package/src/helpers/CheckSettlements.ts +215 -0
  133. package/src/helpers/Context.ts +202 -0
  134. package/src/helpers/CookieHelper.ts +45 -0
  135. package/src/helpers/ForwardHandler.test.ts +216 -0
  136. package/src/helpers/ForwardHandler.ts +140 -0
  137. package/src/helpers/OpenIDConnectHelper.ts +284 -0
  138. package/src/helpers/StripeHelper.ts +293 -0
  139. package/src/helpers/StripePayoutChecker.ts +188 -0
  140. package/src/middleware/ContextMiddleware.ts +16 -0
  141. package/src/migrations/1646578856-validate-addresses.ts +60 -0
  142. package/src/seeds/0000000000-example.ts +13 -0
  143. package/src/seeds/1715028563-user-permissions.ts +52 -0
  144. package/tests/e2e/stock.test.ts +2120 -0
  145. package/tests/e2e/tickets.test.ts +926 -0
  146. package/tests/helpers/StripeMocker.ts +362 -0
  147. package/tests/helpers/TestServer.ts +21 -0
  148. package/tests/jest.global.setup.ts +29 -0
  149. package/tests/jest.setup.ts +59 -0
  150. package/tsconfig.json +42 -0
@@ -0,0 +1,69 @@
1
+ import { AutoEncoder, Decoder, field, StringDecoder } from '@simonbackx/simple-encoding';
2
+ import { DecodedRequest, Endpoint, Request, Response } from "@simonbackx/simple-endpoints";
3
+ import { Webshop } from '@stamhoofd/models';
4
+ import { PermissionLevel, WebshopUriAvailabilityResponse } from "@stamhoofd/structures";
5
+
6
+ import { Context } from '../../../../helpers/Context';
7
+
8
+ type Params = { id: string };
9
+ type Body = undefined;
10
+ class Query extends AutoEncoder {
11
+ @field({ decoder: StringDecoder })
12
+ uri: string;
13
+ }
14
+ type ResponseBody = WebshopUriAvailabilityResponse;
15
+
16
+ /**
17
+ * 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
18
+ */
19
+
20
+ export class GetWebshopUriAvailabilityEndpoint extends Endpoint<Params, Query, Body, ResponseBody> {
21
+ queryDecoder = Query as Decoder<Query>
22
+
23
+ protected doesMatch(request: Request): [true, Params] | [false] {
24
+ if (request.method != "GET") {
25
+ return [false];
26
+ }
27
+
28
+ const params = Endpoint.parseParameters(request.url, "/webshop/@id/check-uri", { id: String });
29
+
30
+ if (params) {
31
+ return [true, params as Params];
32
+ }
33
+ return [false];
34
+ }
35
+
36
+ async handle(request: DecodedRequest<Params, Query, Body>) {
37
+ const organization = await Context.setOrganizationScope();
38
+ await Context.authenticate()
39
+
40
+ // Fast throw first (more in depth checking for patches later)
41
+ if (!await Context.auth.hasSomeAccess(organization.id)) {
42
+ throw Context.auth.error()
43
+ }
44
+
45
+ const webshop = await Webshop.getByID(request.params.id)
46
+ if (!webshop || !await Context.auth.canAccessWebshop(webshop, PermissionLevel.Full)) {
47
+ throw Context.auth.notFoundOrNoAccess()
48
+ }
49
+
50
+ const q = await Webshop.where({
51
+ uri: request.query.uri,
52
+ id: {
53
+ sign: "!=",
54
+ value: request.params.id
55
+ }
56
+ }, {
57
+ limit: 1,
58
+ select: "id"
59
+ })
60
+
61
+ const available = q.length == 0
62
+
63
+ return new Response(
64
+ WebshopUriAvailabilityResponse.create({
65
+ available
66
+ })
67
+ );
68
+ }
69
+ }
@@ -0,0 +1,125 @@
1
+ import { AutoEncoderPatchType, Decoder, PatchableArrayAutoEncoder, PatchableArrayDecoder, patchObject, StringDecoder } from "@simonbackx/simple-encoding";
2
+ import { DecodedRequest, Endpoint, Request, Response } from "@simonbackx/simple-endpoints";
3
+ import { SimpleError } from '@simonbackx/simple-errors';
4
+ import { Webshop, WebshopDiscountCode } from '@stamhoofd/models';
5
+ import { QueueHandler } from "@stamhoofd/queues";
6
+ import { DiscountCode, PermissionLevel } from "@stamhoofd/structures";
7
+
8
+ import { Context } from "../../../../helpers/Context";
9
+
10
+ type Params = { id: string };
11
+ type Query = undefined
12
+ type Body = PatchableArrayAutoEncoder<DiscountCode>
13
+ type ResponseBody = DiscountCode[]
14
+
15
+ export class PatchWebshopDiscountCodesEndpoint extends Endpoint<Params, Query, Body, ResponseBody> {
16
+ bodyDecoder = new PatchableArrayDecoder(DiscountCode as Decoder<DiscountCode>, DiscountCode.patchType() as Decoder<AutoEncoderPatchType<DiscountCode>>, StringDecoder)
17
+
18
+ protected doesMatch(request: Request): [true, Params] | [false] {
19
+ if (request.method != "PATCH") {
20
+ return [false];
21
+ }
22
+
23
+ const params = Endpoint.parseParameters(request.url, "/webshop/@id/discount-codes", { id: String });
24
+
25
+ if (params) {
26
+ return [true, params as Params];
27
+ }
28
+ return [false];
29
+ }
30
+
31
+ async handle(request: DecodedRequest<Params, Query, Body>) {
32
+ const organization = await Context.setOrganizationScope();
33
+ await Context.authenticate()
34
+
35
+ // Fast throw first (more in depth checking for patches later)
36
+ if (!await Context.auth.hasSomeAccess(organization.id)) {
37
+ throw Context.auth.error()
38
+ }
39
+
40
+ const webshop = await Webshop.getByID(request.params.id)
41
+ if (!webshop || !await Context.auth.canAccessWebshop(webshop, PermissionLevel.Full)) {
42
+ throw Context.auth.notFoundOrNoAccess()
43
+ }
44
+
45
+ const discountCodes: WebshopDiscountCode[] = []
46
+
47
+ // Updating discoutn codes should happen in the stock queue (because they are also edited when placing orders)
48
+ await QueueHandler.schedule("webshop-stock/"+request.params.id, async () => {
49
+ // TODO: handle order creation here
50
+ for (const put of request.body.getPuts()) {
51
+ const struct = put.put
52
+ const model = new WebshopDiscountCode()
53
+ model.code = struct.code;
54
+ model.description = struct.description
55
+ model.webshopId = webshop.id
56
+ model.organizationId = webshop.organizationId
57
+ model.discounts = struct.discounts
58
+ model.maximumUsage = struct.maximumUsage
59
+
60
+ try {
61
+ await model.save()
62
+ } catch (e) {
63
+ // Duplicate key probably
64
+ if (e.code && e.code == "ER_DUP_ENTRY") {
65
+ throw new SimpleError({
66
+ code: 'used_code',
67
+ message: 'Discount code already in use',
68
+ human: 'Er bestaat al een kortingscode met de code ' + struct.code+', een code moet uniek zijn.'
69
+ })
70
+ }
71
+ throw e;
72
+ }
73
+
74
+ discountCodes.push(model)
75
+ }
76
+
77
+ for (const patch of request.body.getPatches()) {
78
+ const model = await WebshopDiscountCode.getByID(patch.id)
79
+ if (!model || model.webshopId !== webshop.id) {
80
+ throw new SimpleError({
81
+ code: "not_found",
82
+ message: "Discount code with id "+patch.id+" does not exist"
83
+ })
84
+ }
85
+
86
+ model.code = patchObject(model.code, patch.code)
87
+ model.description = patchObject(model.description, patch.description)
88
+ model.discounts = patchObject(model.discounts, patch.discounts)
89
+ model.maximumUsage = patchObject(model.maximumUsage, patch.maximumUsage)
90
+
91
+ try {
92
+ await model.save()
93
+ } catch (e) {
94
+ // Duplicate key probably
95
+ if (e.code && e.code == "ER_DUP_ENTRY") {
96
+ throw new SimpleError({
97
+ code: 'used_code',
98
+ message: 'Discount code already in use',
99
+ human: 'Er bestaat al een kortingscode met de code ' + model.code+', een code moet uniek zijn.'
100
+ })
101
+ }
102
+ throw e;
103
+ }
104
+
105
+ discountCodes.push(model)
106
+ }
107
+
108
+ for (const id of request.body.getDeletes()) {
109
+ const model = await WebshopDiscountCode.getByID(id)
110
+ if (!model || model.webshopId !== webshop.id) {
111
+ throw new SimpleError({
112
+ code: "not_found",
113
+ message: "Discount code with id "+id+" does not exist"
114
+ })
115
+ }
116
+
117
+ await model.delete()
118
+ }
119
+ });
120
+
121
+ return new Response(
122
+ discountCodes.map(d => d.getStructure())
123
+ );
124
+ }
125
+ }
@@ -0,0 +1,204 @@
1
+ import { AutoEncoderPatchType, Decoder } from '@simonbackx/simple-encoding';
2
+ import { DecodedRequest, Endpoint, Request, Response } from "@simonbackx/simple-endpoints";
3
+ import { SimpleError } from '@simonbackx/simple-errors';
4
+ import { Token, Webshop } from '@stamhoofd/models';
5
+ import { QueueHandler } from '@stamhoofd/queues';
6
+ import { PermissionLevel, PrivateWebshop, WebshopPrivateMetaData } from "@stamhoofd/structures";
7
+ import { Formatter } from '@stamhoofd/utility';
8
+
9
+ import { Context } from '../../../../helpers/Context';
10
+
11
+ type Params = { id: string };
12
+ type Query = undefined;
13
+ type Body = AutoEncoderPatchType<PrivateWebshop>;
14
+ type ResponseBody = PrivateWebshop;
15
+
16
+ /**
17
+ * 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
18
+ */
19
+
20
+ export class PatchWebshopEndpoint extends Endpoint<Params, Query, Body, ResponseBody> {
21
+ bodyDecoder = PrivateWebshop.patchType() as Decoder<AutoEncoderPatchType<PrivateWebshop>>
22
+
23
+ protected doesMatch(request: Request): [true, Params] | [false] {
24
+ if (request.method != "PATCH") {
25
+ return [false];
26
+ }
27
+
28
+ const params = Endpoint.parseParameters(request.url, "/webshop/@id", { id: String });
29
+
30
+ if (params) {
31
+ return [true, params as Params];
32
+ }
33
+ return [false];
34
+ }
35
+
36
+ async handle(request: DecodedRequest<Params, Query, Body>) {
37
+ const organization = await Context.setOrganizationScope();
38
+ await Context.authenticate()
39
+
40
+ // Fast throw first (more in depth checking for patches later)
41
+ if (!await Context.auth.hasSomeAccess(organization.id)) {
42
+ throw Context.auth.error()
43
+ }
44
+
45
+ // Halt all order placement and validation + pause stock updates
46
+ return await QueueHandler.schedule("webshop-stock/"+request.params.id, async () => {
47
+ const webshop = await Webshop.getByID(request.params.id)
48
+ if (!webshop || !await Context.auth.canAccessWebshop(webshop, PermissionLevel.Full)) {
49
+ throw Context.auth.notFoundOrNoAccess()
50
+ }
51
+
52
+ // Do all updates
53
+ if (request.body.meta) {
54
+ request.body.meta.domainActive = undefined
55
+ webshop.meta.patchOrPut(request.body.meta)
56
+ }
57
+
58
+ if (request.body.privateMeta) {
59
+ // Prevent editing internal fields
60
+ (request.body.privateMeta as any).dnsRecords = undefined
61
+ webshop.privateMeta.patchOrPut(request.body.privateMeta)
62
+ }
63
+
64
+ if (request.body.products) {
65
+ webshop.products = request.body.products.applyTo(webshop.products)
66
+ }
67
+
68
+ if (request.body.categories) {
69
+ webshop.categories = request.body.categories.applyTo(webshop.categories)
70
+ }
71
+
72
+ if (request.body.domain !== undefined) {
73
+ if (request.body.domain !== null) {
74
+ const cleaned = request.body.domain.toLowerCase().replace(/[^a-zA-Z0-9-.]/g, '');
75
+
76
+ if (cleaned != webshop.domain) {
77
+ webshop.domain = cleaned
78
+ webshop.meta.domainActive = false
79
+ webshop.privateMeta.dnsRecords = WebshopPrivateMetaData.buildDNSRecords(cleaned)
80
+
81
+ // Check if this is a known domain
82
+ const knownWebshops = await Webshop.getByDomainOnly(cleaned)
83
+
84
+ if (knownWebshops.length > 0) {
85
+ const active = !!knownWebshops.find(k => k.meta.domainActive)
86
+
87
+ if (active) {
88
+ const sameOrg = knownWebshops.find(w => w.organizationId === organization.id)
89
+ const otherOrg = knownWebshops.find(w => w.organizationId !== organization.id)
90
+ if (otherOrg && !sameOrg) {
91
+ throw new SimpleError({
92
+ code: "domain_already_used",
93
+ message: "This domain is already used by another organization",
94
+ human: "Deze domeinnaam is al in gebruik door een andere vereniging. Neem contact op met Stamhoofd als je denkt dat je toch toegang zou moeten krijgen.",
95
+ statusCode: 400
96
+ })
97
+ }
98
+
99
+ // Automatically update the dns records already.
100
+ // This domain was already used, so no risk of making DNS-caches dirty
101
+ console.log("Automatically updating dns records for", cleaned, "during patch")
102
+ await webshop.updateDNSRecords()
103
+ }
104
+ }
105
+
106
+ if (cleaned.length < 4 || !cleaned.includes(".")) {
107
+ throw new SimpleError({
108
+ code: "invalid_field",
109
+ message: "Invalid domain",
110
+ human: "Ongeldige domeinnaam",
111
+ field: "customUrl"
112
+ })
113
+ }
114
+ }
115
+ } else {
116
+ webshop.domain = null
117
+ webshop.privateMeta.dnsRecords = []
118
+ webshop.meta.domainActive = false
119
+ }
120
+ }
121
+
122
+ if (request.body.domainUri !== undefined) {
123
+ if (webshop.domain !== null) {
124
+ webshop.domainUri = request.body.domainUri ?? ""
125
+
126
+ if (webshop.domainUri != Formatter.slug(webshop.domainUri)) {
127
+ throw new SimpleError({
128
+ code: "invalid_field",
129
+ message: "domainUri contains invalid characters",
130
+ human: "Een link mag geen spaties, hoofdletters of speciale tekens bevatten",
131
+ field: "customUrl"
132
+ })
133
+ }
134
+
135
+ // Check exists
136
+ const existing = await Webshop.getByDomain(webshop.domain, webshop.domainUri);
137
+ if (existing !== undefined) {
138
+ throw new SimpleError({
139
+ code: "invalid_domain",
140
+ message: "This domain is already in use",
141
+ human: "Deze link is al in gebruik door een andere webshop: " + existing.meta.name+". Verwijder of pas daar de link eerst aan als je die wilt hergebruiken."
142
+ })
143
+ }
144
+ } else {
145
+ webshop.domainUri = null
146
+ }
147
+ }
148
+
149
+ if (request.body.legacyUri !== undefined) {
150
+ // Support editing the legacy uri (e.g. delete it, or for older clients)
151
+ webshop.legacyUri = request.body.legacyUri
152
+ }
153
+
154
+ if (request.body.uri !== undefined) {
155
+ // Validate
156
+ if (request.body.uri.length == 0) {
157
+ throw new SimpleError({
158
+ code: "invalid_field",
159
+ message: "Uri cannot be empty",
160
+ human: "De link mag niet leeg zijn",
161
+ field: "uri"
162
+ })
163
+ }
164
+
165
+ if (request.body.uri != Formatter.slug(request.body.uri)) {
166
+ throw new SimpleError({
167
+ code: "invalid_field",
168
+ message: "Uri contains invalid characters",
169
+ human: "Een link mag geen spaties, hoofdletters of speciale tekens bevatten",
170
+ field: "uri"
171
+ })
172
+ }
173
+
174
+ webshop.uri = request.body.uri
175
+ }
176
+
177
+ // Verify if we still have full access
178
+ if (!await Context.auth.canAccessWebshop(webshop, PermissionLevel.Full)) {
179
+ throw new SimpleError({
180
+ code: "missing_permissions",
181
+ message: "You cannot restrict your own permissions",
182
+ human: "Je kan je eigen volledige toegang tot deze webshop niet verwijderen (algemeen > toegangsbeheer). Vraag aan een hoofdbeheerder om jouw toegang te verwijderen."
183
+ })
184
+ }
185
+
186
+ try {
187
+ await webshop.save()
188
+ } catch (e) {
189
+ // Duplicate key probably
190
+ if (e.code && e.code == "ER_DUP_ENTRY") {
191
+ throw new SimpleError({
192
+ code: "invalid_field",
193
+ message: "Uri already in use",
194
+ human: "De link die je hebt gekozen is al in gebruik. Kies een andere.",
195
+ field: "uri"
196
+ })
197
+ }
198
+ throw e;
199
+ }
200
+
201
+ return new Response(PrivateWebshop.create(webshop));
202
+ })
203
+ }
204
+ }
@@ -0,0 +1,278 @@
1
+ import { ArrayDecoder, AutoEncoderPatchType, Data, Decoder, PatchableArray, PatchableArrayAutoEncoder, PatchableArrayDecoder, StringDecoder } from '@simonbackx/simple-encoding';
2
+ import { DecodedRequest, Endpoint, Request, Response } from "@simonbackx/simple-endpoints";
3
+ import { SimpleError } from "@simonbackx/simple-errors";
4
+ import { BalanceItem, BalanceItemPayment, Order, Payment, Token, Webshop } from '@stamhoofd/models';
5
+ import { QueueHandler } from '@stamhoofd/queues';
6
+ import { BalanceItemStatus, OrderStatus, PaymentMethod, PaymentStatus, PermissionLevel, PrivateOrder, PrivatePayment,Webshop as WebshopStruct } from "@stamhoofd/structures";
7
+
8
+ import { Context } from '../../../../helpers/Context';
9
+
10
+ type Params = { id: string };
11
+ type Query = undefined;
12
+ type Body = AutoEncoderPatchType<PrivateOrder>[] | PatchableArrayAutoEncoder<PrivateOrder>
13
+ type ResponseBody = PrivateOrder[]
14
+
15
+ class VersionSpecificDecoder<A, B> implements Decoder<A | B> {
16
+ oldDecoder: Decoder<A>;
17
+ version: number;
18
+ newerDecoder: Decoder<B>;
19
+
20
+ constructor(oldDecoder: Decoder<A>, version: number, newerDecoder: Decoder<B>) {
21
+ this.oldDecoder = oldDecoder;
22
+ this.version = version;
23
+ this.newerDecoder = newerDecoder;
24
+ }
25
+
26
+ decode(data: Data): A | B {
27
+ // Set the version of the decoding context of "data"
28
+ const v = data.context.version
29
+
30
+ if (v >= this.version) {
31
+ return this.newerDecoder.decode(data);
32
+ }
33
+
34
+ return this.oldDecoder.decode(data);
35
+ }
36
+ }
37
+
38
+ export class PatchWebshopOrdersEndpoint extends Endpoint<Params, Query, Body, ResponseBody> {
39
+ bodyDecoder = new VersionSpecificDecoder(
40
+ // Before version 159, accept an array of patches
41
+ new ArrayDecoder(PrivateOrder.patchType() as Decoder<AutoEncoderPatchType<PrivateOrder>>),
42
+ 159,
43
+ // After or at version 159, accept a patchable array
44
+ new PatchableArrayDecoder(PrivateOrder as Decoder<PrivateOrder>, PrivateOrder.patchType() as Decoder<AutoEncoderPatchType<PrivateOrder>>, StringDecoder)
45
+ );
46
+
47
+ protected doesMatch(request: Request): [true, Params] | [false] {
48
+ if (request.method != "PATCH") {
49
+ return [false];
50
+ }
51
+
52
+ const params = Endpoint.parseParameters(request.url, "/webshop/@id/orders", { id: String });
53
+
54
+ if (params) {
55
+ return [true, params as Params];
56
+ }
57
+ return [false];
58
+ }
59
+
60
+ async handle(request: DecodedRequest<Params, Query, Body>) {
61
+ const organization = await Context.setOrganizationScope();
62
+ await Context.authenticate()
63
+
64
+ // Fast throw first (more in depth checking for patches later)
65
+ if (!await Context.auth.hasSomeAccess(organization.id)) {
66
+ throw Context.auth.error()
67
+ }
68
+
69
+ let body: PatchableArrayAutoEncoder<PrivateOrder> = new PatchableArray()
70
+
71
+ // Migrate old syntax
72
+ if (Array.isArray(request.body)) {
73
+ for (const p of request.body) {
74
+ body.addPatch(p);
75
+ }
76
+ } else {
77
+ body = request.body
78
+ }
79
+
80
+ if (body.changes.length == 0) {
81
+ return new Response([]);
82
+ }
83
+
84
+ // Need to happen in the queue because we are updating the webshop stock
85
+ const orders = await QueueHandler.schedule("webshop-stock/"+request.params.id, async () => {
86
+ const webshop = await Webshop.getByID(request.params.id)
87
+ if (!webshop || !await Context.auth.canAccessWebshop(webshop, PermissionLevel.Write)) {
88
+ throw Context.auth.notFoundOrNoAccess()
89
+ }
90
+
91
+ const orders = body.getPatches().length > 0 ? await Order.where({
92
+ webshopId: webshop.id,
93
+ id: {
94
+ sign: "IN",
95
+ value: body.getPatches().map(o => o.id)
96
+ }
97
+ }) : []
98
+
99
+ // We use a getter because we need to have an up to date webshop struct
100
+ // otherwise we won't validate orders on the latest webshop with the latest stock information
101
+ const webshopGetter = {
102
+ get struct() {
103
+ return WebshopStruct.create(webshop);
104
+ }
105
+ }
106
+
107
+ // TODO: handle order creation here
108
+ for (const put of body.getPuts()) {
109
+ const struct = put.put
110
+ const model = new Order()
111
+ model.webshopId = webshop.id
112
+ model.organizationId = webshop.organizationId
113
+ model.status = struct.status
114
+ model.data = struct.data
115
+
116
+ // For now, we don't invalidate tickets, because they will get invalidated at scan time (the order status is checked)
117
+ // This allows you to revalidate a ticket without needing to generate a new one (e.g. when accidentally canceling an order)
118
+ // -> the user doesn't need to download the ticket again
119
+ // + added benefit: we can inform the user that the ticket was canceled, instead of throwing an 'invalid ticket' error
120
+
121
+ if (model.status === OrderStatus.Deleted) {
122
+ model.data.removePersonalData()
123
+ }
124
+
125
+ const order = model.setRelation(Order.webshop, webshop.setRelation(Webshop.organization, organization))
126
+
127
+ // TODO: validate before updating stock
128
+ order.data.validate(webshopGetter.struct, organization.meta, request.i18n, true);
129
+
130
+ try {
131
+ await order.updateStock()
132
+ const totalPrice = order.data.totalPrice
133
+
134
+ if (totalPrice == 0) {
135
+ // Force unknown payment method
136
+ order.data.paymentMethod = PaymentMethod.Unknown
137
+
138
+ // Mark this order as paid
139
+ await order.markPaid(null, organization, webshop)
140
+ await order.save()
141
+ } else {
142
+ const payment = new Payment()
143
+ payment.organizationId = organization.id
144
+ payment.method = struct.data.paymentMethod
145
+ payment.status = PaymentStatus.Created
146
+ payment.price = totalPrice
147
+ payment.paidAt = null
148
+
149
+ // Determine the payment provider (always null because no online payments here)
150
+ payment.provider = null
151
+
152
+ await payment.save()
153
+
154
+ order.paymentId = payment.id
155
+ order.setRelation(Order.payment, payment)
156
+
157
+ // Create balance item
158
+ const balanceItem = new BalanceItem();
159
+ balanceItem.orderId = order.id;
160
+ balanceItem.price = totalPrice
161
+ balanceItem.description = webshop.meta.name
162
+ balanceItem.pricePaid = 0
163
+ balanceItem.organizationId = organization.id;
164
+ balanceItem.status = BalanceItemStatus.Pending;
165
+ await balanceItem.save();
166
+
167
+ // Create one balance item payment to pay it in one payment
168
+ const balanceItemPayment = new BalanceItemPayment()
169
+ balanceItemPayment.balanceItemId = balanceItem.id;
170
+ balanceItemPayment.paymentId = payment.id;
171
+ balanceItemPayment.organizationId = organization.id;
172
+ balanceItemPayment.price = balanceItem.price;
173
+ await balanceItemPayment.save();
174
+
175
+ if (payment.method == PaymentMethod.Transfer) {
176
+ await order.markValid(payment, [])
177
+ await payment.save()
178
+ await order.save()
179
+ } else if (payment.method == PaymentMethod.PointOfSale) {
180
+ // Not really paid, but needed to create the tickets if needed
181
+ await order.markPaid(payment, organization, webshop)
182
+ await payment.save()
183
+ await order.save()
184
+ } else {
185
+ throw new Error("Unsupported payment method")
186
+ }
187
+
188
+ balanceItem.description = order.generateBalanceDescription(webshop)
189
+ await balanceItem.save()
190
+ }
191
+ } catch (e) {
192
+ await order.deleteOrderBecauseOfCreationError()
193
+ throw e;
194
+ }
195
+
196
+ orders.push(order)
197
+ }
198
+
199
+ for (const patch of body.getPatches()) {
200
+ const model = orders.find(p => p.id == patch.id)
201
+ if (!model) {
202
+ throw new SimpleError({
203
+ code: "not_found",
204
+ message: "Order with id "+patch.id+" does not exist"
205
+ })
206
+ }
207
+ const previousToPay = model.totalToPay;
208
+ const previousStatus = model.status
209
+
210
+ model.status = patch.status ?? model.status
211
+
212
+ // For now, we don't invalidate tickets, because they will get invalidated at scan time (the order status is checked)
213
+ // This allows you to revalidate a ticket without needing to generate a new one (e.g. when accidentally canceling an order)
214
+ // -> the user doesn't need to download the ticket again
215
+ // + added benefit: we can inform the user that the ticket was canceled, instead of throwing an 'invalid ticket' error
216
+
217
+ const previousData = model.data.clone()
218
+ if (patch.data) {
219
+ model.data.patchOrPut(patch.data)
220
+
221
+ if (model.status !== OrderStatus.Deleted) {
222
+ // Make sure all data is up to date and validated (= possible corrections happen here too)
223
+ model.data.validate(webshopGetter.struct, organization.meta, request.i18n, true);
224
+ }
225
+ }
226
+
227
+ if (model.status === OrderStatus.Deleted) {
228
+ model.data.removePersonalData()
229
+ }
230
+
231
+ if (model.status === OrderStatus.Deleted || model.status === OrderStatus.Canceled) {
232
+ model.markUpdated()
233
+ // Cancel payment if still pending
234
+ await BalanceItem.deleteForDeletedOrders([model.id])
235
+ } else {
236
+ if (previousStatus === OrderStatus.Canceled || previousStatus === OrderStatus.Deleted) {
237
+ model.markUpdated()
238
+ // Undo deletion
239
+ await BalanceItem.undoForDeletedOrders([model.id])
240
+ }
241
+ }
242
+
243
+ // Update balance item prices for this order if price has changed
244
+ if (previousToPay !== model.totalToPay) {
245
+ const items = await BalanceItem.where({ orderId: model.id })
246
+ if (items.length === 1) {
247
+ model.markUpdated()
248
+ items[0].price = model.totalToPay
249
+ items[0].description = model.generateBalanceDescription(webshop)
250
+ items[0].updateStatus();
251
+ await items[0].save()
252
+ } else if (items.length === 0 && model.totalToPay > 0) {
253
+ model.markUpdated()
254
+ const balanceItem = new BalanceItem();
255
+ balanceItem.orderId = model.id;
256
+ balanceItem.price = model.totalToPay
257
+ balanceItem.description = model.generateBalanceDescription(webshop)
258
+ balanceItem.pricePaid = 0
259
+ balanceItem.organizationId = organization.id;
260
+ balanceItem.status = BalanceItemStatus.Pending;
261
+ await balanceItem.save();
262
+ }
263
+ }
264
+
265
+ await model.save()
266
+ await model.setRelation(Order.webshop, webshop).updateStock(previousData)
267
+ await model.setRelation(Order.webshop, webshop).updateTickets()
268
+ }
269
+
270
+ const mapped = orders.map(order => order.setRelation(Order.webshop, webshop))
271
+ return mapped
272
+ })
273
+
274
+ return new Response(
275
+ await Order.getPrivateStructures(orders)
276
+ );
277
+ }
278
+ }