@stamhoofd/backend 2.118.1 → 2.120.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 (362) hide show
  1. package/package.json +32 -22
  2. package/src/audit-logs/ModelLogger.ts +4 -2
  3. package/src/audit-logs/OrderLogger.ts +1 -1
  4. package/src/boot.ts +32 -14
  5. package/src/crons/balance-emails.ts +4 -2
  6. package/src/crons/clearExcelCache.test.ts +8 -8
  7. package/src/crons/update-cached-balances.ts +40 -14
  8. package/src/debug.ts +3 -2
  9. package/src/decoders/StringArrayDecoder.ts +1 -1
  10. package/src/decoders/StringNullableDecoder.ts +1 -1
  11. package/src/email-recipient-loaders/documents.ts +2 -1
  12. package/src/email-recipient-loaders/members.ts +2 -1
  13. package/src/email-recipient-loaders/orders.ts +2 -1
  14. package/src/email-recipient-loaders/payments.ts +6 -3
  15. package/src/email-recipient-loaders/receivable-balances.ts +2 -1
  16. package/src/email-recipient-loaders/registrations.ts +2 -1
  17. package/src/email-replacements/getEmailReplacementsForPayment.ts +8 -7
  18. package/src/endpoints/admin/members/ChargeMembersEndpoint.ts +7 -6
  19. package/src/endpoints/admin/memberships/ChargeMembershipsEndpoint.ts +3 -2
  20. package/src/endpoints/admin/memberships/GetChargeMembershipsSummaryEndpoint.ts +2 -1
  21. package/src/endpoints/admin/organizations/ChargeOrganizationsEndpoint.ts +4 -3
  22. package/src/endpoints/admin/organizations/GetOrganizationsCountEndpoint.ts +3 -2
  23. package/src/endpoints/admin/organizations/GetOrganizationsEndpoint.ts +7 -5
  24. package/src/endpoints/admin/organizations/PatchOrganizationsEndpoint.ts +12 -10
  25. package/src/endpoints/admin/registrations/ChargeRegistrationsEndpoint.ts +4 -3
  26. package/src/endpoints/auth/CreateAdminEndpoint.ts +8 -6
  27. package/src/endpoints/auth/CreateTokenEndpoint.ts +14 -12
  28. package/src/endpoints/auth/DeleteTokenEndpoint.ts +2 -1
  29. package/src/endpoints/auth/DeleteUserEndpoint.ts +2 -1
  30. package/src/endpoints/auth/ForgotPasswordEndpoint.ts +5 -4
  31. package/src/endpoints/auth/GetOtherUserEndpoint.ts +3 -2
  32. package/src/endpoints/auth/GetUserEndpoint.ts +3 -2
  33. package/src/endpoints/auth/OpenIDConnectAuthTokenEndpoint.ts +2 -1
  34. package/src/endpoints/auth/OpenIDConnectCallbackEndpoint.ts +4 -2
  35. package/src/endpoints/auth/OpenIDConnectStartEndpoint.ts +3 -2
  36. package/src/endpoints/auth/PatchUserEndpoint.ts +15 -12
  37. package/src/endpoints/auth/PollEmailVerificationEndpoint.ts +3 -2
  38. package/src/endpoints/auth/RetryEmailVerificationEndpoint.ts +3 -2
  39. package/src/endpoints/auth/SignupEndpoint.ts +5 -4
  40. package/src/endpoints/auth/VerifyEmailEndpoint.ts +6 -5
  41. package/src/endpoints/frontend/FrontendEnvironmentEndpoint.ts +3 -2
  42. package/src/endpoints/global/addresses/SearchRegionsEndpoint.ts +8 -5
  43. package/src/endpoints/global/addresses/ValidateAddressEndpoint.ts +5 -3
  44. package/src/endpoints/global/audit-logs/GetAuditLogsEndpoint.ts +8 -5
  45. package/src/endpoints/global/billing/ActivatePackagesEndpoint.ts +9 -7
  46. package/src/endpoints/global/billing/DeactivatePackageEndpoint.ts +4 -3
  47. package/src/endpoints/global/caddy/CheckDomainCertEndpoint.ts +4 -2
  48. package/src/endpoints/global/email/CreateEmailEndpoint.ts +9 -7
  49. package/src/endpoints/global/email/GetAdminEmailsEndpoint.test.ts +2 -1
  50. package/src/endpoints/global/email/GetAdminEmailsEndpoint.ts +22 -19
  51. package/src/endpoints/global/email/GetEmailAddressEndpoint.ts +6 -4
  52. package/src/endpoints/global/email/GetEmailEndpoint.ts +4 -3
  53. package/src/endpoints/global/email/GetUserEmailsEndpoint.test.ts +3 -2
  54. package/src/endpoints/global/email/GetUserEmailsEndpoint.ts +7 -4
  55. package/src/endpoints/global/email/ManageEmailAddressEndpoint.ts +7 -5
  56. package/src/endpoints/global/email/PatchEmailEndpoint.test.ts +11 -11
  57. package/src/endpoints/global/email/PatchEmailEndpoint.ts +14 -11
  58. package/src/endpoints/global/email-recipients/GetEmailRecipientsCountEndpoint.ts +3 -2
  59. package/src/endpoints/global/email-recipients/GetEmailRecipientsEndpoint.test.ts +2 -1
  60. package/src/endpoints/global/email-recipients/GetEmailRecipientsEndpoint.ts +8 -5
  61. package/src/endpoints/global/email-recipients/RetryEmailRecipientEndpoint.ts +7 -5
  62. package/src/endpoints/global/email-recipients/helpers/validateEmailRecipientFilter.ts +4 -3
  63. package/src/endpoints/global/events/GetEventNotificationsCountEndpoint.ts +3 -2
  64. package/src/endpoints/global/events/GetEventNotificationsEndpoint.ts +9 -6
  65. package/src/endpoints/global/events/GetEventsEndpoint.ts +7 -4
  66. package/src/endpoints/global/events/PatchEventNotificationsEndpoint.test.ts +4 -2
  67. package/src/endpoints/global/events/PatchEventNotificationsEndpoint.ts +17 -15
  68. package/src/endpoints/global/events/PatchEventsEndpoint.test.ts +4 -2
  69. package/src/endpoints/global/events/PatchEventsEndpoint.ts +28 -26
  70. package/src/endpoints/global/files/ExportToExcelEndpoint.ts +8 -5
  71. package/src/endpoints/global/files/GetFileCache.ts +5 -3
  72. package/src/endpoints/global/files/UploadFile.ts +10 -4
  73. package/src/endpoints/global/files/UploadImage.ts +4 -2
  74. package/src/endpoints/global/groups/GetGroupsEndpoint.test.ts +4 -2
  75. package/src/endpoints/global/groups/GetGroupsEndpoint.ts +8 -5
  76. package/src/endpoints/global/members/GetMemberFamilyEndpoint.ts +7 -5
  77. package/src/endpoints/global/members/GetMembersCountEndpoint.ts +3 -2
  78. package/src/endpoints/global/members/GetMembersEndpoint.test.ts +4 -2
  79. package/src/endpoints/global/members/GetMembersEndpoint.ts +10 -8
  80. package/src/endpoints/global/members/PatchOrganizationMembersEndpoint.test.ts +257 -6
  81. package/src/endpoints/global/members/PatchOrganizationMembersEndpoint.ts +162 -107
  82. package/src/endpoints/global/members/helpers/validateGroupFilter.ts +4 -3
  83. package/src/endpoints/global/members/shouldCheckIfMemberIsDuplicate.ts +3 -2
  84. package/src/endpoints/global/organizations/CheckRegisterCodeEndpoint.ts +3 -2
  85. package/src/endpoints/global/organizations/CreateOrganizationEndpoint.test.ts +3 -2
  86. package/src/endpoints/global/organizations/CreateOrganizationEndpoint.ts +8 -7
  87. package/src/endpoints/global/organizations/GetOrganizationFromDomainEndpoint.ts +5 -3
  88. package/src/endpoints/global/organizations/GetOrganizationFromUriEndpoint.ts +5 -3
  89. package/src/endpoints/global/organizations/SearchOrganizationEndpoint.ts +8 -5
  90. package/src/endpoints/global/payments/StripeWebhookEndpoint.ts +4 -2
  91. package/src/endpoints/global/platform/GetPlatformAdminsEndpoint.ts +2 -1
  92. package/src/endpoints/global/platform/GetPlatformEndpoint.ts +3 -2
  93. package/src/endpoints/global/platform/PatchPlatformEnpoint.test.ts +3 -2
  94. package/src/endpoints/global/platform/PatchPlatformEnpoint.ts +7 -4
  95. package/src/endpoints/global/platform-memberships/GetPlatformMembershipsCountEndpoint.ts +47 -0
  96. package/src/endpoints/global/platform-memberships/GetPlatformMembershipsEndpoint.ts +206 -0
  97. package/src/endpoints/global/registration/GetRegistrationsCountEndpoint.ts +3 -2
  98. package/src/endpoints/global/registration/GetRegistrationsEndpoint.test.ts +2 -1
  99. package/src/endpoints/global/registration/GetRegistrationsEndpoint.ts +10 -7
  100. package/src/endpoints/global/registration/GetUserDetailedPayableBalanceEndpoint.ts +4 -2
  101. package/src/endpoints/global/registration/GetUserDocumentsEndpoint.ts +4 -2
  102. package/src/endpoints/global/registration/GetUserMembersEndpoint.ts +3 -2
  103. package/src/endpoints/global/registration/GetUserPayableBalanceEndpoint.ts +2 -1
  104. package/src/endpoints/global/registration/PatchUserMembersEndpoint.test.ts +5 -3
  105. package/src/endpoints/global/registration/PatchUserMembersEndpoint.ts +13 -9
  106. package/src/endpoints/global/registration/RegisterMembersEndpoint.test.ts +50 -49
  107. package/src/endpoints/global/registration/RegisterMembersEndpoint.ts +25 -22
  108. package/src/endpoints/global/registration-periods/GetRegistrationPeriodsEndpoint.ts +8 -5
  109. package/src/endpoints/global/registration-periods/PatchRegistrationPeriodsEndpoint.ts +9 -7
  110. package/src/endpoints/global/sso/GetSSOEndpoint.ts +4 -2
  111. package/src/endpoints/global/sso/SetSSOEndpoint.ts +3 -2
  112. package/src/endpoints/global/webshops/GetWebshopFromDomainEndpoint.ts +4 -2
  113. package/src/endpoints/global/webshops/GetWebshopsCountEndpoint.ts +43 -0
  114. package/src/endpoints/global/webshops/GetWebshopsEndpoint.test.ts +808 -0
  115. package/src/endpoints/global/webshops/GetWebshopsEndpoint.ts +221 -0
  116. package/src/endpoints/organization/dashboard/balance-items/GetBalanceItemEndpoint.ts +4 -3
  117. package/src/endpoints/organization/dashboard/balance-items/GetBalanceItemsCountEndpoint.ts +3 -2
  118. package/src/endpoints/organization/dashboard/balance-items/GetBalanceItemsEndpoint.ts +6 -4
  119. package/src/endpoints/organization/dashboard/balance-items/PatchBalanceItemsEndpoint.ts +15 -13
  120. package/src/endpoints/organization/dashboard/billing/GetOrganizationDetailedPayableBalanceEndpoint.ts +4 -2
  121. package/src/endpoints/organization/dashboard/billing/GetOrganizationPayableBalanceEndpoint.ts +4 -2
  122. package/src/endpoints/organization/dashboard/billing/GetPackagesEndpoint.ts +2 -1
  123. package/src/endpoints/organization/dashboard/documents/GetDocumentTemplateXML.ts +3 -2
  124. package/src/endpoints/organization/dashboard/documents/GetDocumentTemplatesCountEndpoint.ts +3 -2
  125. package/src/endpoints/organization/dashboard/documents/GetDocumentTemplatesEndpoint.ts +7 -4
  126. package/src/endpoints/organization/dashboard/documents/GetDocumentsCountEndpoint.ts +3 -2
  127. package/src/endpoints/organization/dashboard/documents/GetDocumentsEndpoint.ts +7 -4
  128. package/src/endpoints/organization/dashboard/documents/PatchDocumentEndpoint.ts +9 -7
  129. package/src/endpoints/organization/dashboard/documents/PatchDocumentTemplatesEndpoint.test.ts +4 -2
  130. package/src/endpoints/organization/dashboard/documents/PatchDocumentTemplatesEndpoint.ts +7 -5
  131. package/src/endpoints/organization/dashboard/email/CheckEmailBouncesEndpoint.ts +3 -2
  132. package/src/endpoints/organization/dashboard/email-templates/GetEmailTemplatesEndpoint.test.ts +36 -1
  133. package/src/endpoints/organization/dashboard/email-templates/GetEmailTemplatesEndpoint.ts +13 -4
  134. package/src/endpoints/organization/dashboard/email-templates/PatchEmailTemplatesEndpoint.test.ts +4 -2
  135. package/src/endpoints/organization/dashboard/email-templates/PatchEmailTemplatesEndpoint.ts +7 -5
  136. package/src/endpoints/organization/dashboard/invoices/GetInvoicesCountEndpoint.ts +3 -2
  137. package/src/endpoints/organization/dashboard/invoices/GetInvoicesEndpoint.ts +6 -4
  138. package/src/endpoints/organization/dashboard/invoices/PatchInvoicesEndpoint.ts +5 -3
  139. package/src/endpoints/organization/dashboard/mollie/CheckMollieEndpoint.ts +4 -2
  140. package/src/endpoints/organization/dashboard/mollie/ConnectMollieEndpoint.ts +6 -3
  141. package/src/endpoints/organization/dashboard/mollie/DisconnectMollieEndpoint.ts +4 -2
  142. package/src/endpoints/organization/dashboard/mollie/GetMollieDashboardEndpoint.ts +2 -1
  143. package/src/endpoints/organization/dashboard/nolt/CreateNoltTokenEndpoint.ts +2 -1
  144. package/src/endpoints/organization/dashboard/organization/GetOrganizationArchivedGroups.ts +4 -2
  145. package/src/endpoints/organization/dashboard/organization/GetOrganizationDeletedGroups.ts +3 -2
  146. package/src/endpoints/organization/dashboard/organization/GetUitpasClientIdEndpoint.ts +2 -1
  147. package/src/endpoints/organization/dashboard/organization/PatchOrganizationEndpoint.test.ts +5 -3
  148. package/src/endpoints/organization/dashboard/organization/PatchOrganizationEndpoint.ts +22 -19
  149. package/src/endpoints/organization/dashboard/organization/SearchUitpasOrganizersEndpoint.ts +5 -3
  150. package/src/endpoints/organization/dashboard/organization/SetOrganizationDomainEndpoint.ts +11 -9
  151. package/src/endpoints/organization/dashboard/organization/SetUitpasClientCredentialsEndpoint.ts +8 -7
  152. package/src/endpoints/organization/dashboard/payments/GetMemberBalanceEndpoint.ts +2 -1
  153. package/src/endpoints/organization/dashboard/payments/GetPaymentsCountEndpoint.ts +3 -2
  154. package/src/endpoints/organization/dashboard/payments/GetPaymentsEndpoint.ts +7 -5
  155. package/src/endpoints/organization/dashboard/payments/PatchPaymentsEndpoint.ts +18 -16
  156. package/src/endpoints/organization/dashboard/receivable-balances/GetReceivableBalanceEndpoint.ts +2 -1
  157. package/src/endpoints/organization/dashboard/receivable-balances/GetReceivableBalancesCountEndpoint.ts +3 -2
  158. package/src/endpoints/organization/dashboard/receivable-balances/GetReceivableBalancesEndpoint.ts +5 -3
  159. package/src/endpoints/organization/dashboard/registration-periods/GetOrganizationRegistrationPeriodsEndpoint.test.ts +2 -1
  160. package/src/endpoints/organization/dashboard/registration-periods/GetOrganizationRegistrationPeriodsEndpoint.ts +8 -5
  161. package/src/endpoints/organization/dashboard/registration-periods/MoveRegistrationPeriods.test.ts +4 -2
  162. package/src/endpoints/organization/dashboard/registration-periods/PatchOrganizationRegistrationPeriodsEndpoint.test.ts +4 -2
  163. package/src/endpoints/organization/dashboard/registration-periods/PatchOrganizationRegistrationPeriodsEndpoint.ts +32 -29
  164. package/src/endpoints/organization/dashboard/registration-periods/SetupStepReviewEndpoint.ts +6 -3
  165. package/src/endpoints/organization/dashboard/stripe/ConnectStripeEndpoint.ts +3 -2
  166. package/src/endpoints/organization/dashboard/stripe/DeleteStripeAccountEndpoint.ts +3 -2
  167. package/src/endpoints/organization/dashboard/stripe/GetStripeAccountLinkEndpoint.ts +5 -3
  168. package/src/endpoints/organization/dashboard/stripe/GetStripeAccountsEndpoint.ts +2 -1
  169. package/src/endpoints/organization/dashboard/stripe/GetStripeLoginLinkEndpoint.ts +5 -3
  170. package/src/endpoints/organization/dashboard/stripe/UpdateStripeAccountEndpoint.ts +3 -2
  171. package/src/endpoints/organization/dashboard/users/CreateApiUserEndpoint.ts +4 -3
  172. package/src/endpoints/organization/dashboard/users/DeleteUserEndpoint.ts +4 -3
  173. package/src/endpoints/organization/dashboard/users/GetApiUsersEndpoint.ts +3 -2
  174. package/src/endpoints/organization/dashboard/users/GetOrganizationAdminsEndpoint.ts +2 -1
  175. package/src/endpoints/organization/dashboard/users/PatchApiUserEndpoint.ts +6 -4
  176. package/src/endpoints/organization/dashboard/webshops/CreateWebshopEndpoint.ts +7 -6
  177. package/src/endpoints/organization/dashboard/webshops/DeleteWebshopEndpoint.ts +3 -2
  178. package/src/endpoints/organization/dashboard/webshops/GetDiscountCodesEndpoint.ts +4 -2
  179. package/src/endpoints/organization/dashboard/webshops/GetWebshopOrdersCountEndpoint.ts +3 -2
  180. package/src/endpoints/organization/dashboard/webshops/GetWebshopOrdersEndpoint.ts +7 -4
  181. package/src/endpoints/organization/dashboard/webshops/{GetWebshopTicketsCountEndpoint → GetWebshopTicketsCountEndpoint.ts} +7 -7
  182. package/src/endpoints/organization/dashboard/webshops/GetWebshopTicketsEndpoint.ts +9 -6
  183. package/src/endpoints/organization/dashboard/webshops/GetWebshopUriAvailabilityEndpoint.ts +4 -2
  184. package/src/endpoints/organization/dashboard/webshops/PatchDiscountCodesEndpoint.ts +6 -4
  185. package/src/endpoints/organization/dashboard/webshops/PatchWebshopEndpoint.ts +13 -12
  186. package/src/endpoints/organization/dashboard/webshops/PatchWebshopOrdersEndpoint.ts +4 -2
  187. package/src/endpoints/organization/dashboard/webshops/PatchWebshopTicketsEndpoint.ts +5 -3
  188. package/src/endpoints/organization/dashboard/webshops/SearchUitpasEventsEndpoint.ts +6 -4
  189. package/src/endpoints/organization/dashboard/webshops/VerifyWebshopDomainEndpoint.ts +2 -1
  190. package/src/endpoints/organization/shared/ExchangePaymentEndpoint.ts +6 -4
  191. package/src/endpoints/organization/shared/GetDocumentHtml.ts +4 -3
  192. package/src/endpoints/organization/shared/GetPaymentEndpoint.ts +4 -3
  193. package/src/endpoints/organization/shared/GetUitpasNumberDetailsEndpoint.ts +3 -2
  194. package/src/endpoints/organization/shared/auth/GetOrganizationEndpoint.ts +3 -2
  195. package/src/endpoints/organization/webshops/CheckWebshopDiscountCodesEndpoint.ts +4 -3
  196. package/src/endpoints/organization/webshops/GetOrderByPaymentEndpoint.ts +5 -4
  197. package/src/endpoints/organization/webshops/GetOrderEndpoint.ts +4 -3
  198. package/src/endpoints/organization/webshops/GetTicketsEndpoint.ts +7 -5
  199. package/src/endpoints/organization/webshops/GetWebshopEndpoint.ts +4 -3
  200. package/src/endpoints/organization/webshops/PlaceOrderEndpoint.test.ts +7 -3
  201. package/src/endpoints/organization/webshops/PlaceOrderEndpoint.ts +11 -10
  202. package/src/endpoints/organization/webshops/RetrieveUitpasSocialTariffPriceEndpoint.ts +5 -4
  203. package/src/endpoints/system/HealthEndpoint.test.ts +44 -0
  204. package/src/endpoints/system/HealthEndpoint.ts +14 -6
  205. package/src/excel-loaders/balance-items.ts +19 -17
  206. package/src/excel-loaders/event-notifications.ts +15 -13
  207. package/src/excel-loaders/index.ts +1 -0
  208. package/src/excel-loaders/members.ts +45 -43
  209. package/src/excel-loaders/organizations.ts +26 -25
  210. package/src/excel-loaders/payments.ts +44 -42
  211. package/src/excel-loaders/platform-memberships.ts +202 -0
  212. package/src/excel-loaders/receivable-balances.ts +25 -23
  213. package/src/excel-loaders/registrations.ts +30 -28
  214. package/src/helpers/AddressValidator.test.ts +2 -1
  215. package/src/helpers/AddressValidator.ts +13 -10
  216. package/src/helpers/AdminPermissionChecker.ts +193 -95
  217. package/src/helpers/AuthenticatedStructures.ts +13 -11
  218. package/src/helpers/BuckarooHelper.ts +3 -2
  219. package/src/helpers/Context.ts +8 -6
  220. package/src/helpers/CookieHelper.ts +2 -2
  221. package/src/helpers/FileCache.ts +9 -9
  222. package/src/helpers/ForwardHandler.ts +3 -2
  223. package/src/helpers/GlobalHelper.ts +2 -0
  224. package/src/helpers/GroupBuilder.ts +2 -1
  225. package/src/helpers/GroupedThrottledQueue.test.ts +19 -19
  226. package/src/helpers/LimitedFilteredRequestHelper.ts +1 -1
  227. package/src/helpers/MemberCharger.ts +2 -1
  228. package/src/helpers/MemberUserSyncer.ts +6 -3
  229. package/src/helpers/MembershipCharger.ts +2 -2
  230. package/src/helpers/OrganizationCharger.ts +2 -1
  231. package/src/helpers/PeriodHelper.ts +2 -1
  232. package/src/helpers/SQLTranslatedString.ts +3 -2
  233. package/src/helpers/ServiceFeeHelper.ts +1 -1
  234. package/src/helpers/SetupStepUpdater.ts +6 -5
  235. package/src/helpers/StripeHelper.ts +9 -8
  236. package/src/helpers/TagHelper.test.ts +5 -5
  237. package/src/helpers/TagHelper.ts +2 -1
  238. package/src/helpers/TemporaryMemberAccess.ts +2 -1
  239. package/src/helpers/ThrottledQueue.test.ts +20 -20
  240. package/src/helpers/UitpasTokenRepository.ts +7 -7
  241. package/src/helpers/ViesHelper.ts +5 -4
  242. package/src/helpers/XlsxTransformerColumnHelper.ts +21 -19
  243. package/src/helpers/email-html-helpers.ts +13 -12
  244. package/src/helpers/fetchToAsyncIterator.ts +1 -1
  245. package/src/helpers/outstandingBalanceJoin.ts +2 -1
  246. package/src/helpers/updateMemberDetailsUitpasNumber.ts +5 -4
  247. package/src/middleware/ContextMiddleware.ts +1 -1
  248. package/src/migrate.ts +21 -4
  249. package/src/seeds/0000000003-default-email-templates.ts +1 -1
  250. package/src/seeds/0000000004-single-organization.ts +2 -1
  251. package/src/seeds/1752848561-groups-registration-periods.ts +3 -2
  252. package/src/seeds/1754560914-groups-prices.test.ts +2 -1
  253. package/src/seeds/1754560914-groups-prices.ts +2 -1
  254. package/src/seeds/1755790070-fill-email-recipient-errors.ts +6 -6
  255. package/src/seeds/1755876819-remove-duplicate-members.ts +2 -1
  256. package/src/seeds/1765896674-document-update-year.test.ts +2 -1
  257. package/src/seeds/1773754928-force-save-members.ts +15 -0
  258. package/src/services/AuditLogService.ts +3 -2
  259. package/src/services/BalanceItemPaymentService.ts +2 -2
  260. package/src/services/BalanceItemService.ts +2 -1
  261. package/src/services/BootChecksService.test.ts +33 -0
  262. package/src/services/BootChecksService.ts +21 -0
  263. package/src/services/DatabaseCollationService.test.ts +18 -0
  264. package/src/services/DatabaseCollationService.ts +81 -0
  265. package/src/services/DocumentService.ts +1 -1
  266. package/src/services/EventNotificationService.ts +5 -4
  267. package/src/services/FileSignService.ts +2 -2
  268. package/src/services/InvoiceService.ts +3 -3
  269. package/src/services/MemberNumberService.ts +6 -4
  270. package/src/services/MemberRecordStore.ts +28 -19
  271. package/src/services/PaymentReallocationService.test.ts +2 -1
  272. package/src/services/PaymentReallocationService.ts +2 -1
  273. package/src/services/PaymentService.ts +28 -26
  274. package/src/services/RegistrationService.ts +65 -3
  275. package/src/services/SSOService.ts +13 -9
  276. package/src/services/STPackageService.ts +7 -5
  277. package/src/services/StartupHealthService.ts +15 -0
  278. package/src/services/uitpas/PassholderEndpoints.ts +2 -2
  279. package/src/services/uitpas/UitpasService.ts +11 -8
  280. package/src/services/uitpas/cancelTicketSales.ts +1 -1
  281. package/src/services/uitpas/checkPermissionsFor.ts +9 -9
  282. package/src/services/uitpas/getSocialTariffForEvent.ts +4 -4
  283. package/src/services/uitpas/getSocialTariffForUitpasNumbers.ts +5 -5
  284. package/src/services/uitpas/handleUitpasResponse.ts +1 -1
  285. package/src/services/uitpas/registerTicketSales.ts +4 -4
  286. package/src/services/uitpas/searchUitpasEvents.ts +3 -3
  287. package/src/services/uitpas/searchUitpasOrganizers.ts +3 -3
  288. package/src/sql-filters/audit-logs.ts +2 -1
  289. package/src/sql-filters/balance-item-payments.ts +2 -1
  290. package/src/sql-filters/balance-items.ts +2 -1
  291. package/src/sql-filters/base-registration-filter-compilers.ts +6 -4
  292. package/src/sql-filters/document-templates.ts +2 -1
  293. package/src/sql-filters/documents.ts +2 -1
  294. package/src/sql-filters/email-recipients.ts +2 -1
  295. package/src/sql-filters/emails.ts +2 -1
  296. package/src/sql-filters/event-notifications.ts +2 -1
  297. package/src/sql-filters/events.ts +2 -1
  298. package/src/sql-filters/groups.ts +2 -1
  299. package/src/sql-filters/invoiced-balance-items.ts +2 -1
  300. package/src/sql-filters/invoices.ts +2 -1
  301. package/src/sql-filters/member-responsibility-records.ts +2 -1
  302. package/src/sql-filters/members.ts +8 -7
  303. package/src/sql-filters/orders.ts +3 -2
  304. package/src/sql-filters/organization-registration-periods.ts +2 -1
  305. package/src/sql-filters/organizations.ts +2 -1
  306. package/src/sql-filters/payments.ts +2 -1
  307. package/src/sql-filters/platform-memberships.ts +67 -0
  308. package/src/sql-filters/receivable-balances.ts +2 -1
  309. package/src/sql-filters/registration-periods.ts +2 -1
  310. package/src/sql-filters/registrations.ts +2 -1
  311. package/src/sql-filters/tickets.ts +2 -1
  312. package/src/sql-filters/users.ts +2 -1
  313. package/src/sql-filters/webshops.ts +38 -0
  314. package/src/sql-sorters/audit-logs.ts +3 -2
  315. package/src/sql-sorters/balance-items.ts +3 -2
  316. package/src/sql-sorters/document-templates.ts +3 -2
  317. package/src/sql-sorters/documents.ts +3 -2
  318. package/src/sql-sorters/email-recipients.ts +3 -2
  319. package/src/sql-sorters/emails.ts +3 -2
  320. package/src/sql-sorters/event-notifications.ts +3 -2
  321. package/src/sql-sorters/events.ts +3 -2
  322. package/src/sql-sorters/groups.ts +3 -2
  323. package/src/sql-sorters/invoices.ts +3 -2
  324. package/src/sql-sorters/members.ts +3 -2
  325. package/src/sql-sorters/orders.ts +3 -2
  326. package/src/sql-sorters/organization-registration-periods.ts +3 -2
  327. package/src/sql-sorters/organizations.ts +3 -2
  328. package/src/sql-sorters/payments.ts +3 -2
  329. package/src/sql-sorters/platform-memberships.ts +40 -0
  330. package/src/sql-sorters/receivable-balances.ts +3 -2
  331. package/src/sql-sorters/registration-periods.ts +3 -2
  332. package/src/sql-sorters/registrations.ts +3 -2
  333. package/src/sql-sorters/tickets.ts +3 -2
  334. package/src/sql-sorters/webshops.ts +40 -0
  335. package/tests/actions/patchOrganizationMember.ts +5 -4
  336. package/tests/actions/patchPaymentStatus.ts +2 -2
  337. package/tests/actions/patchUserMember.ts +6 -4
  338. package/tests/e2e/api-rate-limits.test.ts +4 -5
  339. package/tests/e2e/bundle-discounts.test.ts +3 -2
  340. package/tests/e2e/charge-members.test.ts +7 -5
  341. package/tests/e2e/documents.test.ts +3 -2
  342. package/tests/e2e/private-files.test.ts +11 -13
  343. package/tests/e2e/register.test.ts +6 -5
  344. package/tests/e2e/stock.test.ts +6 -8
  345. package/tests/e2e/tickets.test.ts +4 -2
  346. package/tests/helpers/StripeMocker.ts +3 -3
  347. package/tests/init/initAdmin.ts +4 -2
  348. package/tests/init/initBundleDiscount.ts +3 -2
  349. package/tests/init/initPayconiq.ts +1 -1
  350. package/tests/init/initPermissionRole.ts +4 -2
  351. package/tests/init/initPlatformRecordCategory.ts +1 -1
  352. package/tests/init/initStripe.ts +1 -1
  353. package/tests/vitest.global.setup.ts +26 -0
  354. package/tests/{jest.setup.ts → vitest.setup.ts} +4 -3
  355. package/tsconfig.build.json +17 -0
  356. package/tsconfig.json +10 -41
  357. package/tsconfig.test.json +17 -0
  358. package/vitest.config.js +13 -0
  359. package/eslint.config.mjs +0 -5
  360. package/jest.config.cjs +0 -27
  361. package/tests/jest.global.setup.ts +0 -33
  362. package/tests/toMatchMap.ts +0 -68
@@ -0,0 +1,808 @@
1
+ import type { Endpoint } from '@simonbackx/simple-endpoints';
2
+ import { Request } from '@simonbackx/simple-endpoints';
3
+ import { Database } from '@simonbackx/simple-database';
4
+ import { OrganizationFactory, OrganizationTagFactory, Token, UserFactory, WebshopFactory } from '@stamhoofd/models';
5
+ import { CountFilteredRequest, LimitedFilteredRequest, PermissionLevel, Permissions, PermissionsResourceType, ResourcePermissions, SortItemDirection, WebshopMetaData, WebshopStatus } from '@stamhoofd/structures';
6
+ import { STExpect, TestUtils } from '@stamhoofd/test-utils';
7
+ import { testServer } from '../../../../tests/helpers/TestServer.js';
8
+ import { GetWebshopsCountEndpoint } from './GetWebshopsCountEndpoint.js';
9
+ import { GetWebshopsEndpoint } from './GetWebshopsEndpoint.js';
10
+
11
+ const baseUrl = '/webshops';
12
+ const endpoint = new GetWebshopsEndpoint();
13
+ const countEndpoint = new GetWebshopsCountEndpoint();
14
+ type EndpointType = typeof endpoint;
15
+ type Body = EndpointType extends Endpoint<any, any, infer B, any> ? B : never;
16
+
17
+ describe('Endpoint.GetWebshopsEndpoint', () => {
18
+ beforeEach(async () => {
19
+ TestUtils.setEnvironment('userMode', 'platform');
20
+ });
21
+
22
+ describe('Organization context', () => {
23
+ test('An org admin with full access can fetch all webshops in their organization', async () => {
24
+ const organization = await new OrganizationFactory({}).create();
25
+
26
+ const user = await new UserFactory({
27
+ organization,
28
+ permissions: Permissions.create({
29
+ level: PermissionLevel.Full,
30
+ }),
31
+ }).create();
32
+
33
+ const token = await Token.createToken(user);
34
+
35
+ const webshop1 = await new WebshopFactory({ organizationId: organization.id, name: 'Webshop A' }).create();
36
+ const webshop2 = await new WebshopFactory({ organizationId: organization.id, name: 'Webshop B' }).create();
37
+
38
+ // Create a webshop in a different organization - should not appear
39
+ const otherOrg = await new OrganizationFactory({}).create();
40
+ await new WebshopFactory({ organizationId: otherOrg.id, name: 'Other Webshop' }).create();
41
+
42
+ const request = Request.get({
43
+ path: baseUrl,
44
+ host: organization.getApiHost(),
45
+ query: new LimitedFilteredRequest({ limit: 100 }),
46
+ headers: { authorization: 'Bearer ' + token.accessToken },
47
+ });
48
+
49
+ const response = await testServer.test(endpoint, request);
50
+ expect(response.status).toBe(200);
51
+ expect(response.body.results).toHaveLength(2);
52
+ expect(response.body.results).toIncludeSameMembers([
53
+ expect.objectContaining({ id: webshop1.id }),
54
+ expect.objectContaining({ id: webshop2.id }),
55
+ ]);
56
+ });
57
+
58
+ test('An org admin with access to a specific webshop only sees that webshop', async () => {
59
+ const organization = await new OrganizationFactory({}).create();
60
+
61
+ const webshop1 = await new WebshopFactory({ organizationId: organization.id, name: 'Accessible Webshop' }).create();
62
+ const webshop2 = await new WebshopFactory({ organizationId: organization.id, name: 'Inaccessible Webshop' }).create();
63
+
64
+ const user = await new UserFactory({
65
+ organization,
66
+ permissions: Permissions.create({
67
+ level: PermissionLevel.None,
68
+ resources: new Map([[
69
+ PermissionsResourceType.Webshops,
70
+ new Map([[
71
+ webshop1.id,
72
+ ResourcePermissions.create({ level: PermissionLevel.Read }),
73
+ ]]),
74
+ ]]),
75
+ }),
76
+ }).create();
77
+
78
+ const token = await Token.createToken(user);
79
+
80
+ const request = Request.get({
81
+ path: baseUrl,
82
+ host: organization.getApiHost(),
83
+ query: new LimitedFilteredRequest({ limit: 100 }),
84
+ headers: { authorization: 'Bearer ' + token.accessToken },
85
+ });
86
+
87
+ const response = await testServer.test(endpoint, request);
88
+ expect(response.status).toBe(200);
89
+ expect(response.body.results).toHaveLength(1);
90
+ expect(response.body.results[0].id).toBe(webshop1.id);
91
+ });
92
+
93
+ test('An org admin with no webshop access gets an empty result', async () => {
94
+ const organization = await new OrganizationFactory({}).create();
95
+ await new WebshopFactory({ organizationId: organization.id }).create();
96
+
97
+ const user = await new UserFactory({
98
+ organization,
99
+ permissions: Permissions.create({
100
+ level: PermissionLevel.None,
101
+ }),
102
+ }).create();
103
+
104
+ const token = await Token.createToken(user);
105
+
106
+ const request = Request.get({
107
+ path: baseUrl,
108
+ host: organization.getApiHost(),
109
+ query: new LimitedFilteredRequest({ limit: 100 }),
110
+ headers: { authorization: 'Bearer ' + token.accessToken },
111
+ });
112
+
113
+ const response = await testServer.test(endpoint, request);
114
+ expect(response.status).toBe(200);
115
+ expect(response.body.results).toHaveLength(0);
116
+ });
117
+
118
+ test('Unauthenticated request is rejected', async () => {
119
+ const organization = await new OrganizationFactory({}).create();
120
+
121
+ const request = Request.get({
122
+ path: baseUrl,
123
+ host: organization.getApiHost(),
124
+ query: new LimitedFilteredRequest({ limit: 10 }),
125
+ });
126
+
127
+ await expect(testServer.test(endpoint, request)).rejects.toThrow(
128
+ STExpect.errorWithCode('not_authenticated'),
129
+ );
130
+ });
131
+ });
132
+
133
+ describe('Platform context', () => {
134
+ test('A platform admin with full access can fetch all webshops across organizations', async () => {
135
+ const org1 = await new OrganizationFactory({}).create();
136
+ const org2 = await new OrganizationFactory({}).create();
137
+
138
+ const webshop1 = await new WebshopFactory({ organizationId: org1.id }).create();
139
+ const webshop2 = await new WebshopFactory({ organizationId: org2.id }).create();
140
+
141
+ const platformAdmin = await new UserFactory({
142
+ globalPermissions: Permissions.create({ level: PermissionLevel.Full }),
143
+ }).create();
144
+
145
+ const token = await Token.createToken(platformAdmin);
146
+
147
+ const request = Request.get({
148
+ path: baseUrl,
149
+ host: 'platform.stamhoofd.app',
150
+ query: new LimitedFilteredRequest({
151
+ filter: {
152
+ id: { $in: [webshop1.id, webshop2.id] },
153
+ },
154
+ limit: 100,
155
+ }),
156
+ headers: { authorization: 'Bearer ' + token.accessToken },
157
+ });
158
+
159
+ const response = await testServer.test(endpoint, request);
160
+ expect(response.status).toBe(200);
161
+ expect(response.body.results).toHaveLength(2);
162
+ expect(response.body.results).toIncludeSameMembers([
163
+ expect.objectContaining({ id: webshop1.id }),
164
+ expect.objectContaining({ id: webshop2.id }),
165
+ ]);
166
+ });
167
+
168
+ test('A platform user with no platform access is rejected', async () => {
169
+ const platformUser = await new UserFactory({
170
+ globalPermissions: Permissions.create({ level: PermissionLevel.None }),
171
+ }).create();
172
+
173
+ const token = await Token.createToken(platformUser);
174
+
175
+ const request = Request.get({
176
+ path: baseUrl,
177
+ host: 'platform.stamhoofd.app',
178
+ query: new LimitedFilteredRequest({ limit: 10 }),
179
+ headers: { authorization: 'Bearer ' + token.accessToken },
180
+ });
181
+
182
+ await expect(testServer.test(endpoint, request)).rejects.toThrow(
183
+ STExpect.errorWithCode('permission_denied'),
184
+ );
185
+ });
186
+
187
+ test('A platform admin with tag-based access only sees webshops of organizations with that tag', async () => {
188
+ const tag = await new OrganizationTagFactory({}).create();
189
+
190
+ // Organization WITH the tag
191
+ const orgWithTag = await new OrganizationFactory({ tags: [tag.id] }).create();
192
+ // Organization WITHOUT the tag
193
+ const orgWithoutTag = await new OrganizationFactory({}).create();
194
+
195
+ const webshopInTaggedOrg = await new WebshopFactory({ organizationId: orgWithTag.id }).create();
196
+ const webshopInUntaggedOrg = await new WebshopFactory({ organizationId: orgWithoutTag.id }).create();
197
+
198
+ // Platform user with read access to organizations with the specific tag
199
+ const platformUser = await new UserFactory({
200
+ globalPermissions: Permissions.create({
201
+ level: PermissionLevel.None,
202
+ resources: new Map([[
203
+ PermissionsResourceType.OrganizationTags,
204
+ new Map([[
205
+ tag.id,
206
+ ResourcePermissions.create({ level: PermissionLevel.Read }),
207
+ ]]),
208
+ ]]),
209
+ }),
210
+ }).create();
211
+
212
+ const token = await Token.createToken(platformUser);
213
+
214
+ const request = Request.get({
215
+ path: baseUrl,
216
+ host: 'platform.stamhoofd.app',
217
+ query: new LimitedFilteredRequest({
218
+ filter: {
219
+ id: { $in: [webshopInTaggedOrg.id, webshopInUntaggedOrg.id] },
220
+ },
221
+ limit: 100,
222
+ }),
223
+ headers: { authorization: 'Bearer ' + token.accessToken },
224
+ });
225
+
226
+ const response = await testServer.test(endpoint, request);
227
+ expect(response.status).toBe(200);
228
+
229
+ // Only the webshop from the tagged organization should be visible
230
+ expect(response.body.results).toHaveLength(1);
231
+ expect(response.body.results[0].id).toBe(webshopInTaggedOrg.id);
232
+ });
233
+
234
+ test('A platform admin with tag-based access to multiple tags sees webshops from all matching organizations', async () => {
235
+ const tag1 = await new OrganizationTagFactory({}).create();
236
+ const tag2 = await new OrganizationTagFactory({}).create();
237
+
238
+ const orgTag1 = await new OrganizationFactory({ tags: [tag1.id] }).create();
239
+ const orgTag2 = await new OrganizationFactory({ tags: [tag2.id] }).create();
240
+ const orgNoTag = await new OrganizationFactory({}).create();
241
+
242
+ const webshop1 = await new WebshopFactory({ organizationId: orgTag1.id }).create();
243
+ const webshop2 = await new WebshopFactory({ organizationId: orgTag2.id }).create();
244
+ const webshopNoTag = await new WebshopFactory({ organizationId: orgNoTag.id }).create();
245
+
246
+ const platformUser = await new UserFactory({
247
+ globalPermissions: Permissions.create({
248
+ level: PermissionLevel.None,
249
+ resources: new Map([[
250
+ PermissionsResourceType.OrganizationTags,
251
+ new Map([
252
+ [tag1.id, ResourcePermissions.create({ level: PermissionLevel.Read })],
253
+ [tag2.id, ResourcePermissions.create({ level: PermissionLevel.Read })],
254
+ ]),
255
+ ]]),
256
+ }),
257
+ }).create();
258
+
259
+ const token = await Token.createToken(platformUser);
260
+
261
+ const request = Request.get({
262
+ path: baseUrl,
263
+ host: 'platform.stamhoofd.app',
264
+ query: new LimitedFilteredRequest({
265
+ filter: {
266
+ id: { $in: [webshop1.id, webshop2.id, webshopNoTag.id] },
267
+ },
268
+ limit: 100,
269
+ }),
270
+ headers: { authorization: 'Bearer ' + token.accessToken },
271
+ });
272
+
273
+ const response = await testServer.test(endpoint, request);
274
+ expect(response.status).toBe(200);
275
+ expect(response.body.results).toHaveLength(2);
276
+ expect(response.body.results).toIncludeSameMembers([
277
+ expect.objectContaining({ id: webshop1.id }),
278
+ expect.objectContaining({ id: webshop2.id }),
279
+ ]);
280
+ });
281
+ });
282
+
283
+ describe('Filtering', () => {
284
+ test('Can filter webshops by id', async () => {
285
+ const organization = await new OrganizationFactory({}).create();
286
+
287
+ const user = await new UserFactory({
288
+ organization,
289
+ permissions: Permissions.create({ level: PermissionLevel.Full }),
290
+ }).create();
291
+
292
+ const token = await Token.createToken(user);
293
+
294
+ const webshop1 = await new WebshopFactory({ organizationId: organization.id }).create();
295
+ const webshop2 = await new WebshopFactory({ organizationId: organization.id }).create();
296
+
297
+ const request = Request.get({
298
+ path: baseUrl,
299
+ host: organization.getApiHost(),
300
+ query: new LimitedFilteredRequest({
301
+ filter: { id: { $eq: webshop1.id } },
302
+ limit: 100,
303
+ }),
304
+ headers: { authorization: 'Bearer ' + token.accessToken },
305
+ });
306
+
307
+ const response = await testServer.test(endpoint, request);
308
+ expect(response.status).toBe(200);
309
+ expect(response.body.results).toHaveLength(1);
310
+ expect(response.body.results[0].id).toBe(webshop1.id);
311
+ });
312
+
313
+ test('Can search webshops by name', async () => {
314
+ const organization = await new OrganizationFactory({}).create();
315
+
316
+ const user = await new UserFactory({
317
+ organization,
318
+ permissions: Permissions.create({ level: PermissionLevel.Full }),
319
+ }).create();
320
+
321
+ const token = await Token.createToken(user);
322
+
323
+ const webshop1 = await new WebshopFactory({ organizationId: organization.id, name: 'Summer Sale' }).create();
324
+ const webshop2 = await new WebshopFactory({ organizationId: organization.id, name: 'Winter Collection' }).create();
325
+
326
+ const request = Request.get({
327
+ path: baseUrl,
328
+ host: organization.getApiHost(),
329
+ query: new LimitedFilteredRequest({
330
+ search: 'Summer',
331
+ limit: 100,
332
+ }),
333
+ headers: { authorization: 'Bearer ' + token.accessToken },
334
+ });
335
+
336
+ const response = await testServer.test(endpoint, request);
337
+ expect(response.status).toBe(200);
338
+ expect(response.body.results).toHaveLength(1);
339
+ expect(response.body.results[0].id).toBe(webshop1.id);
340
+ });
341
+
342
+ test('Can search with status filter when collation_connection differs', async () => {
343
+ await Database.statement('SET collation_connection = \'utf8mb4_general_ci\'');
344
+
345
+ try {
346
+ const organization = await new OrganizationFactory({}).create();
347
+
348
+ const user = await new UserFactory({
349
+ organization,
350
+ permissions: Permissions.create({ level: PermissionLevel.Full }),
351
+ }).create();
352
+
353
+ const token = await Token.createToken(user);
354
+
355
+ const closedWebshop = await new WebshopFactory({
356
+ organizationId: organization.id,
357
+ name: 'Testwebshop 5',
358
+ meta: WebshopMetaData.patch({ status: WebshopStatus.Closed }),
359
+ }).create();
360
+
361
+ await new WebshopFactory({
362
+ organizationId: organization.id,
363
+ name: 'Andere webshop',
364
+ meta: WebshopMetaData.patch({ status: WebshopStatus.Closed }),
365
+ }).create();
366
+
367
+ const request = Request.get({
368
+ path: baseUrl,
369
+ host: organization.getApiHost(),
370
+ query: new LimitedFilteredRequest({
371
+ search: 'Testwebshop 5',
372
+ filter: {
373
+ status: {
374
+ $eq: WebshopStatus.Closed,
375
+ },
376
+ },
377
+ limit: 100,
378
+ }),
379
+ headers: { authorization: 'Bearer ' + token.accessToken },
380
+ });
381
+
382
+ const response = await testServer.test(endpoint, request);
383
+ expect(response.status).toBe(200);
384
+ expect(response.body.results).toHaveLength(1);
385
+ expect(response.body.results[0].id).toBe(closedWebshop.id);
386
+ }
387
+ finally {
388
+ await Database.statement('SET collation_connection = DEFAULT');
389
+ }
390
+ });
391
+ });
392
+
393
+ describe('Sorting and pagination stability', () => {
394
+ test('Sorting by id is stable across pages', async () => {
395
+ const organization = await new OrganizationFactory({}).create();
396
+
397
+ const user = await new UserFactory({
398
+ organization,
399
+ permissions: Permissions.create({ level: PermissionLevel.Full }),
400
+ }).create();
401
+
402
+ const token = await Token.createToken(user);
403
+
404
+ // Create 5 webshops
405
+ const webshops = await Promise.all(
406
+ Array.from({ length: 5 }, (_, i) =>
407
+ new WebshopFactory({ organizationId: organization.id, name: `Webshop ${i}` }).create(),
408
+ ),
409
+ );
410
+
411
+ // Fetch page 1 (limit 2)
412
+ const page1Request = Request.get({
413
+ path: baseUrl,
414
+ host: organization.getApiHost(),
415
+ query: new LimitedFilteredRequest({
416
+ sort: [{ key: 'id', order: SortItemDirection.ASC }],
417
+ limit: 2,
418
+ }),
419
+ headers: { authorization: 'Bearer ' + token.accessToken },
420
+ });
421
+
422
+ const page1 = await testServer.test(endpoint, page1Request);
423
+ expect(page1.body.results).toHaveLength(2);
424
+ expect(page1.body.next).toBeDefined();
425
+
426
+ // Fetch page 2 using the next cursor
427
+ const page2Request = Request.get({
428
+ path: baseUrl,
429
+ host: organization.getApiHost(),
430
+ query: page1.body.next!,
431
+ headers: { authorization: 'Bearer ' + token.accessToken },
432
+ });
433
+
434
+ const page2 = await testServer.test(endpoint, page2Request);
435
+ expect(page2.body.results).toHaveLength(2);
436
+ expect(page2.body.next).toBeDefined();
437
+
438
+ // Fetch page 3
439
+ const page3Request = Request.get({
440
+ path: baseUrl,
441
+ host: organization.getApiHost(),
442
+ query: page2.body.next!,
443
+ headers: { authorization: 'Bearer ' + token.accessToken },
444
+ });
445
+
446
+ const page3 = await testServer.test(endpoint, page3Request);
447
+ expect(page3.body.results).toHaveLength(1);
448
+ expect(page3.body.next).toBeUndefined();
449
+
450
+ // All pages combined should equal all webshops (no duplicates, no missing)
451
+ const allIds = [
452
+ ...page1.body.results.map(w => w.id),
453
+ ...page2.body.results.map(w => w.id),
454
+ ...page3.body.results.map(w => w.id),
455
+ ];
456
+ expect(allIds).toHaveLength(5);
457
+ expect(allIds).toIncludeSameMembers(webshops.map(w => w.id));
458
+
459
+ // Check ordering is stable: each page's ids should be in ascending order
460
+ const allSorted = [...allIds].sort();
461
+ expect(allIds).toEqual(allSorted);
462
+ });
463
+
464
+ test('Sorting by name is stable across pages', async () => {
465
+ const organization = await new OrganizationFactory({}).create();
466
+
467
+ const user = await new UserFactory({
468
+ organization,
469
+ permissions: Permissions.create({ level: PermissionLevel.Full }),
470
+ }).create();
471
+
472
+ const token = await Token.createToken(user);
473
+
474
+ // Create webshops with deterministic names for ordering
475
+ const names = ['Apple Shop', 'Banana Market', 'Cherry Stand', 'Date Store', 'Elderberry Emporium'];
476
+ const webshops = await Promise.all(
477
+ names.map(name => new WebshopFactory({ organizationId: organization.id, name }).create()),
478
+ );
479
+
480
+ // Fetch all in pages of 2 sorted by name ASC
481
+ const allResults: string[] = [];
482
+ let currentQuery: LimitedFilteredRequest | undefined = new LimitedFilteredRequest({
483
+ sort: [{ key: 'name', order: SortItemDirection.ASC }, { key: 'id', order: SortItemDirection.ASC }],
484
+ limit: 2,
485
+ });
486
+
487
+ while (currentQuery) {
488
+ const request = Request.get({
489
+ path: baseUrl,
490
+ host: organization.getApiHost(),
491
+ query: currentQuery,
492
+ headers: { authorization: 'Bearer ' + token.accessToken },
493
+ });
494
+
495
+ const response = await testServer.test(endpoint, request);
496
+ allResults.push(...response.body.results.map((w: { id: string }) => w.id));
497
+ currentQuery = response.body.next;
498
+ }
499
+
500
+ expect(allResults).toHaveLength(5);
501
+ expect(allResults).toIncludeSameMembers(webshops.map(w => w.id));
502
+
503
+ // Verify sorted order matches expected alphabetical order
504
+ const expectedOrder = webshops
505
+ .sort((a, b) => a.meta.name.localeCompare(b.meta.name))
506
+ .map(w => w.id);
507
+ expect(allResults).toEqual(expectedOrder);
508
+ });
509
+ });
510
+
511
+ describe('Count endpoint', () => {
512
+ test('Returns correct count for organization context', async () => {
513
+ const organization = await new OrganizationFactory({}).create();
514
+
515
+ const user = await new UserFactory({
516
+ organization,
517
+ permissions: Permissions.create({ level: PermissionLevel.Full }),
518
+ }).create();
519
+
520
+ const token = await Token.createToken(user);
521
+
522
+ await new WebshopFactory({ organizationId: organization.id }).create();
523
+ await new WebshopFactory({ organizationId: organization.id }).create();
524
+ await new WebshopFactory({ organizationId: organization.id }).create();
525
+
526
+ const request = Request.get({
527
+ path: baseUrl + '/count',
528
+ host: organization.getApiHost(),
529
+ query: new CountFilteredRequest({}),
530
+ headers: { authorization: 'Bearer ' + token.accessToken },
531
+ });
532
+
533
+ const response = await testServer.test(countEndpoint, request);
534
+ expect(response.status).toBe(200);
535
+ // Count includes all webshops in the org (the query doesn't filter by permission)
536
+ expect(response.body.count).toBeGreaterThanOrEqual(3);
537
+ });
538
+
539
+ test('Count matches number of results from list endpoint', async () => {
540
+ const organization = await new OrganizationFactory({}).create();
541
+
542
+ const user = await new UserFactory({
543
+ organization,
544
+ permissions: Permissions.create({ level: PermissionLevel.Full }),
545
+ }).create();
546
+
547
+ const token = await Token.createToken(user);
548
+
549
+ const webshop1 = await new WebshopFactory({ organizationId: organization.id, name: 'Alpha' }).create();
550
+ const webshop2 = await new WebshopFactory({ organizationId: organization.id, name: 'Beta' }).create();
551
+
552
+ const nameFilter = { name: { $contains: 'Alpha' } };
553
+
554
+ // List request
555
+ const listRequest = Request.get({
556
+ path: baseUrl,
557
+ host: organization.getApiHost(),
558
+ query: new LimitedFilteredRequest({ filter: nameFilter, limit: 100 }),
559
+ headers: { authorization: 'Bearer ' + token.accessToken },
560
+ });
561
+
562
+ // Count request
563
+ const countRequest = Request.get({
564
+ path: baseUrl + '/count',
565
+ host: organization.getApiHost(),
566
+ query: new CountFilteredRequest({ filter: nameFilter }),
567
+ headers: { authorization: 'Bearer ' + token.accessToken },
568
+ });
569
+
570
+ const [listResponse, countResponse] = await Promise.all([
571
+ testServer.test(endpoint, listRequest),
572
+ testServer.test(countEndpoint, countRequest),
573
+ ]);
574
+
575
+ expect(listResponse.body.results).toHaveLength(1);
576
+ expect(countResponse.body.count).toBe(1);
577
+ });
578
+ });
579
+
580
+ describe('Pagination with post-query permission filtering', () => {
581
+ test('Paginating through 100 webshops where only 1 is accessible eventually returns the accessible webshop without looping infinitely', async () => {
582
+ const organization = await new OrganizationFactory({}).create();
583
+
584
+ // Create 99 inaccessible webshops and 1 accessible one. Their UUIDs are random,
585
+ // so the accessible webshop can end up anywhere in the sort order (sort key: id).
586
+ // The test verifies that pagination still finds it regardless of position.
587
+ const inaccessibleWebshops = await Promise.all(
588
+ Array.from({ length: 99 }, () =>
589
+ new WebshopFactory({ organizationId: organization.id }).create(),
590
+ ),
591
+ );
592
+
593
+ const accessibleWebshop = await new WebshopFactory({
594
+ organizationId: organization.id,
595
+ name: 'Accessible Webshop',
596
+ }).create();
597
+
598
+ // User only has explicit access to the one accessible webshop.
599
+ const user = await new UserFactory({
600
+ organization,
601
+ permissions: Permissions.create({
602
+ level: PermissionLevel.None,
603
+ resources: new Map([[
604
+ PermissionsResourceType.Webshops,
605
+ new Map([[
606
+ accessibleWebshop.id,
607
+ ResourcePermissions.create({ level: PermissionLevel.Read }),
608
+ ]]),
609
+ ]]),
610
+ }),
611
+ }).create();
612
+
613
+ const token = await Token.createToken(user);
614
+
615
+ // Use a small page size (10) so we exercise multiple pages.
616
+ const pageSize = 10;
617
+ let currentRequest: LimitedFilteredRequest | null = new LimitedFilteredRequest({
618
+ limit: pageSize,
619
+ sort: [],
620
+ });
621
+
622
+ const allResults: string[] = [];
623
+ let pageCount = 0;
624
+ const maxPages = 20; // safety cap — 100 webshops / 10 per page = at most 10 pages
625
+
626
+ while (currentRequest !== null) {
627
+ expect(pageCount).toBeLessThan(maxPages); // guard against infinite loop
628
+
629
+ const request = Request.get({
630
+ path: baseUrl,
631
+ host: organization.getApiHost(),
632
+ query: currentRequest,
633
+ headers: { authorization: 'Bearer ' + token.accessToken },
634
+ });
635
+
636
+ const response = await testServer.test(endpoint, request);
637
+ expect(response.status).toBe(200);
638
+
639
+ for (const result of response.body.results) {
640
+ allResults.push(result.id);
641
+ }
642
+
643
+ currentRequest = response.body.next ?? null;
644
+ pageCount++;
645
+ }
646
+
647
+ // The accessible webshop must appear exactly once.
648
+ expect(allResults).toContain(accessibleWebshop.id);
649
+ expect(allResults.filter(id => id === accessibleWebshop.id)).toHaveLength(1);
650
+
651
+ // None of the inaccessible webshops should appear.
652
+ const inaccessibleIds = new Set(inaccessibleWebshops.map(w => w.id));
653
+ for (const id of allResults) {
654
+ expect(inaccessibleIds.has(id)).toBe(false);
655
+ }
656
+ });
657
+ });
658
+
659
+ describe('Status filtering', () => {
660
+ // Note: WebshopStatus.Open is the default value and is NOT stored in the JSON.
661
+ // The status column is nullable: null in SQL means 'Open'.
662
+ // To filter Open webshops, use $or: [$eq: 'Open', $eq: null].
663
+
664
+ test('Can filter webshops by closed status', async () => {
665
+ const organization = await new OrganizationFactory({}).create();
666
+ const user = await new UserFactory({
667
+ organization,
668
+ permissions: Permissions.create({ level: PermissionLevel.Full }),
669
+ }).create();
670
+ const token = await Token.createToken(user);
671
+
672
+ await new WebshopFactory({ organizationId: organization.id, name: 'Open Webshop' }).create();
673
+ const closedWebshop = await new WebshopFactory({
674
+ organizationId: organization.id,
675
+ name: 'Closed Webshop',
676
+ meta: WebshopMetaData.patch({ status: WebshopStatus.Closed }),
677
+ }).create();
678
+ await new WebshopFactory({
679
+ organizationId: organization.id,
680
+ name: 'Archived Webshop',
681
+ meta: WebshopMetaData.patch({ status: WebshopStatus.Archived }),
682
+ }).create();
683
+
684
+ const request = Request.get({
685
+ path: baseUrl,
686
+ host: organization.getApiHost(),
687
+ query: new LimitedFilteredRequest({
688
+ filter: { status: { $eq: WebshopStatus.Closed } },
689
+ limit: 100,
690
+ }),
691
+ headers: { authorization: 'Bearer ' + token.accessToken },
692
+ });
693
+
694
+ const response = await testServer.test(endpoint, request);
695
+ expect(response.status).toBe(200);
696
+ expect(response.body.results).toHaveLength(1);
697
+ expect(response.body.results[0].id).toBe(closedWebshop.id);
698
+ });
699
+
700
+ test('Can filter open webshops using $or with null (default value)', async () => {
701
+ const organization = await new OrganizationFactory({}).create();
702
+ const user = await new UserFactory({
703
+ organization,
704
+ permissions: Permissions.create({ level: PermissionLevel.Full }),
705
+ }).create();
706
+ const token = await Token.createToken(user);
707
+
708
+ const openWebshop = await new WebshopFactory({ organizationId: organization.id, name: 'Open Webshop' }).create();
709
+ await new WebshopFactory({
710
+ organizationId: organization.id,
711
+ name: 'Closed Webshop',
712
+ meta: WebshopMetaData.patch({ status: WebshopStatus.Closed }),
713
+ }).create();
714
+ await new WebshopFactory({
715
+ organizationId: organization.id,
716
+ name: 'Archived Webshop',
717
+ meta: WebshopMetaData.patch({ status: WebshopStatus.Archived }),
718
+ }).create();
719
+
720
+ // Open status is not stored in JSON (default value = null in DB).
721
+ // Use $or to match both the explicit 'Open' value and null (= default Open).
722
+ const request = Request.get({
723
+ path: baseUrl,
724
+ host: organization.getApiHost(),
725
+ query: new LimitedFilteredRequest({
726
+ filter: { $or: [{ status: { $eq: WebshopStatus.Open } }, { status: { $eq: null } }] },
727
+ limit: 100,
728
+ }),
729
+ headers: { authorization: 'Bearer ' + token.accessToken },
730
+ });
731
+
732
+ const response = await testServer.test(endpoint, request);
733
+ expect(response.status).toBe(200);
734
+ expect(response.body.results).toHaveLength(1);
735
+ expect(response.body.results[0].id).toBe(openWebshop.id);
736
+ });
737
+
738
+ test('Can filter webshops by archived status', async () => {
739
+ const organization = await new OrganizationFactory({}).create();
740
+ const user = await new UserFactory({
741
+ organization,
742
+ permissions: Permissions.create({ level: PermissionLevel.Full }),
743
+ }).create();
744
+ const token = await Token.createToken(user);
745
+
746
+ await new WebshopFactory({ organizationId: organization.id, name: 'Open Webshop' }).create();
747
+ await new WebshopFactory({
748
+ organizationId: organization.id,
749
+ name: 'Closed Webshop',
750
+ meta: WebshopMetaData.patch({ status: WebshopStatus.Closed }),
751
+ }).create();
752
+ const archivedWebshop = await new WebshopFactory({
753
+ organizationId: organization.id,
754
+ name: 'Archived Webshop',
755
+ meta: WebshopMetaData.patch({ status: WebshopStatus.Archived }),
756
+ }).create();
757
+
758
+ const request = Request.get({
759
+ path: baseUrl,
760
+ host: organization.getApiHost(),
761
+ query: new LimitedFilteredRequest({
762
+ filter: { status: { $eq: WebshopStatus.Archived } },
763
+ limit: 100,
764
+ }),
765
+ headers: { authorization: 'Bearer ' + token.accessToken },
766
+ });
767
+
768
+ const response = await testServer.test(endpoint, request);
769
+ expect(response.status).toBe(200);
770
+ expect(response.body.results).toHaveLength(1);
771
+ expect(response.body.results[0].id).toBe(archivedWebshop.id);
772
+ });
773
+ });
774
+
775
+ describe('Organization name filtering', () => {
776
+ test('Can filter webshops by organization name in platform context', async () => {
777
+ const orgA = await new OrganizationFactory({ name: 'Groep Blauw' }).create();
778
+ const orgB = await new OrganizationFactory({ name: 'Groep Rood' }).create();
779
+
780
+ const webshopA = await new WebshopFactory({ organizationId: orgA.id, name: 'Webshop A' }).create();
781
+ await new WebshopFactory({ organizationId: orgB.id, name: 'Webshop B' }).create();
782
+
783
+ const platformUser = await new UserFactory({
784
+ globalPermissions: Permissions.create({ level: PermissionLevel.Full }),
785
+ }).create();
786
+ const token = await Token.createToken(platformUser);
787
+
788
+ const request = Request.get({
789
+ path: baseUrl,
790
+ host: 'platform.stamhoofd.app',
791
+ query: new LimitedFilteredRequest({
792
+ filter: {
793
+ organization: {
794
+ name: { $contains: 'Blauw' },
795
+ },
796
+ },
797
+ limit: 100,
798
+ }),
799
+ headers: { authorization: 'Bearer ' + token.accessToken },
800
+ });
801
+
802
+ const response = await testServer.test(endpoint, request);
803
+ expect(response.status).toBe(200);
804
+ expect(response.body.results).toHaveLength(1);
805
+ expect(response.body.results[0].id).toBe(webshopA.id);
806
+ });
807
+ });
808
+ });