@stamhoofd/backend 2.122.8 → 2.123.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stamhoofd/backend",
3
- "version": "2.122.8",
3
+ "version": "2.123.0",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "exports": {
@@ -57,20 +57,20 @@
57
57
  "@simonbackx/simple-endpoints": "1.21.1",
58
58
  "@simonbackx/simple-errors": "1.5.0",
59
59
  "@simonbackx/simple-logging": "1.0.1",
60
- "@stamhoofd/backend-env": "2.122.8",
61
- "@stamhoofd/backend-i18n": "2.122.8",
62
- "@stamhoofd/backend-middleware": "2.122.8",
63
- "@stamhoofd/crons": "2.122.8",
64
- "@stamhoofd/email": "2.122.8",
65
- "@stamhoofd/excel-writer": "2.122.8",
66
- "@stamhoofd/logging": "2.122.8",
67
- "@stamhoofd/models": "2.122.8",
68
- "@stamhoofd/object-differ": "2.122.8",
69
- "@stamhoofd/queues": "2.122.8",
70
- "@stamhoofd/sql": "2.122.8",
71
- "@stamhoofd/structures": "2.122.8",
72
- "@stamhoofd/types": "2.122.8",
73
- "@stamhoofd/utility": "2.122.8",
60
+ "@stamhoofd/backend-env": "2.123.0",
61
+ "@stamhoofd/backend-i18n": "2.123.0",
62
+ "@stamhoofd/backend-middleware": "2.123.0",
63
+ "@stamhoofd/crons": "2.123.0",
64
+ "@stamhoofd/email": "2.123.0",
65
+ "@stamhoofd/excel-writer": "2.123.0",
66
+ "@stamhoofd/logging": "2.123.0",
67
+ "@stamhoofd/models": "2.123.0",
68
+ "@stamhoofd/object-differ": "2.123.0",
69
+ "@stamhoofd/queues": "2.123.0",
70
+ "@stamhoofd/sql": "2.123.0",
71
+ "@stamhoofd/structures": "2.123.0",
72
+ "@stamhoofd/types": "2.123.0",
73
+ "@stamhoofd/utility": "2.123.0",
74
74
  "archiver": "7.0.1",
75
75
  "axios": "1.16.0",
76
76
  "base-x": "3.0.11",
@@ -91,7 +91,7 @@
91
91
  "stripe": "16.12.0"
92
92
  },
93
93
  "devDependencies": {
94
- "@stamhoofd/test-utils": "2.122.8",
94
+ "@stamhoofd/test-utils": "2.123.0",
95
95
  "@types/cookie": "0.6.0",
96
96
  "@types/luxon": "3.7.1",
97
97
  "@types/mailparser": "3.4.6",
@@ -107,5 +107,5 @@
107
107
  "publishConfig": {
108
108
  "access": "public"
109
109
  },
110
- "gitHead": "6326798a67de66af62b5cdebcb81eacd48afdb29"
110
+ "gitHead": "5030664a2a57bf8626611b181e7c12d86a0293f6"
111
111
  }
@@ -3,6 +3,7 @@ import { OrganizationFactory, Token, UserFactory } from '@stamhoofd/models';
3
3
 
4
4
  import { testServer } from '../../../tests/helpers/TestServer.js';
5
5
  import { GetUserEndpoint } from './GetUserEndpoint.js';
6
+ import { STExpect } from '@stamhoofd/test-utils';
6
7
 
7
8
  describe('Endpoint.GetUser', () => {
8
9
  // Test endpoint
@@ -48,4 +49,46 @@ describe('Endpoint.GetUser', () => {
48
49
 
49
50
  await expect(testServer.test(endpoint, r)).rejects.toThrow(/expired/i);
50
51
  });
52
+
53
+ test('Can request details of an API user by token without knowing the organization scope', async () => {
54
+ const organization = await new OrganizationFactory({}).create();
55
+ const user = await new UserFactory({ organization, apiUser: true }).create();
56
+ const token = await Token.createToken(user);
57
+
58
+ const r = Request.buildJson('GET', '/v1/user');
59
+ r.headers.authorization = 'Bearer ' + token.accessToken;
60
+
61
+ const response = await testServer.test(endpoint, r);
62
+ expect(response.body).toBeDefined();
63
+ expect(response.body.id).toEqual(user.id);
64
+ expect(response.body.organizationId).toEqual(organization.id);
65
+ });
66
+
67
+ test('Cannot request details of an normal user by token without knowing the organization scope', async () => {
68
+ const organization = await new OrganizationFactory({}).create();
69
+ const user = await new UserFactory({ organization }).create();
70
+ const token = await Token.createToken(user);
71
+
72
+ const r = Request.buildJson('GET', '/v1/user');
73
+ r.headers.authorization = 'Bearer ' + token.accessToken;
74
+
75
+ await expect(testServer.test(endpoint, r)).rejects.toThrow(STExpect.simpleError({
76
+ code: 'invalid_access_token'
77
+ }));
78
+ });
79
+
80
+ test('Cannot request details of an API user by token with wrong organization scope', async () => {
81
+ const wrongOrganization = await new OrganizationFactory({}).create();
82
+ const organization = await new OrganizationFactory({}).create();
83
+ const user = await new UserFactory({ organization, apiUser: true }).create();
84
+ const token = await Token.createToken(user);
85
+
86
+ const r = Request.buildJson('GET', '/v1/user', wrongOrganization.getApiHost());
87
+ r.headers.authorization = 'Bearer ' + token.accessToken;
88
+
89
+ await expect(testServer.test(endpoint, r)).rejects.toThrow(STExpect.simpleError({
90
+ code: 'invalid_access_token'
91
+ }));
92
+ });
93
+
51
94
  });
@@ -1,7 +1,6 @@
1
1
  import type { DecodedRequest, Request } from '@simonbackx/simple-endpoints';
2
2
  import { Endpoint, Response } from '@simonbackx/simple-endpoints';
3
3
  import type { UserWithMembers } from '@stamhoofd/structures';
4
-
5
4
  import { AuthenticatedStructures } from '../../helpers/AuthenticatedStructures.js';
6
5
  import { Context } from '../../helpers/Context.js';
7
6
 
@@ -26,7 +25,7 @@ export class GetUserEndpoint extends Endpoint<Params, Query, Body, ResponseBody>
26
25
 
27
26
  async handle(request: DecodedRequest<Params, Query, Body>) {
28
27
  await Context.setOptionalOrganizationScope();
29
- const { user } = await Context.authenticate({ allowWithoutAccount: true });
28
+ const { user } = await Context.authenticate({ allowWithoutAccount: true, allowUnscoped: true });
30
29
 
31
30
  return new Response(
32
31
  await AuthenticatedStructures.userWithMembers(user),
@@ -455,7 +455,8 @@ export class PatchOrganizationRegistrationPeriodsEndpoint extends Endpoint<Param
455
455
  }
456
456
 
457
457
  if (shouldUpdatePeriodIds) {
458
- if (model.settings.hasBundleDiscounts) {
458
+ const oldOrganizationPeriod = await OrganizationRegistrationPeriod.select().where('periodId', model.periodId).where('organizationId', model.organizationId).first(false);
459
+ if (oldOrganizationPeriod && model.settings.hasBundleDiscounts(oldOrganizationPeriod)) {
459
460
  throw new SimpleError({
460
461
  code: 'change_period_not_allowed',
461
462
  message: 'This group cannot be moved to another period because it has bundle discounts.',
@@ -0,0 +1,201 @@
1
+ import { BalanceItem, BalanceItemPaymentDetailed, BalanceItemRelation, BalanceItemRelationType, BalanceItemType, Cart, CartItem, CartItemPrice, OrderData, PaymentGeneral, PaymentMethod, PaymentStatus, Product, ProductPrice, TranslatedString } from '@stamhoofd/structures';
2
+ import { expandPaymentBalanceItemPayments } from './payments.js';
3
+
4
+ function createOrderData(options: {
5
+ percentageDiscount?: number;
6
+ fixedDiscount?: number;
7
+ } = {}) {
8
+ const productPrice = ProductPrice.create({
9
+ id: 'large',
10
+ name: 'Groot',
11
+ price: 3500,
12
+ });
13
+ const otherProductPrice = ProductPrice.create({
14
+ id: 'small',
15
+ name: 'Klein',
16
+ price: 2500,
17
+ });
18
+ const product = Product.create({
19
+ id: 'coffee',
20
+ name: 'Koffie',
21
+ prices: [productPrice, otherProductPrice],
22
+ });
23
+ const cartItem = CartItem.create({
24
+ id: 'cart-item-1',
25
+ product,
26
+ productPrice,
27
+ amount: 2,
28
+ unitPrice: 3500,
29
+ calculatedPrices: [
30
+ CartItemPrice.create({ price: 3500 }),
31
+ CartItemPrice.create({ price: 3500 }),
32
+ ],
33
+ });
34
+
35
+ return OrderData.create({
36
+ cart: Cart.create({
37
+ items: [cartItem],
38
+ }),
39
+ administrationFee: 500,
40
+ percentageDiscount: options.percentageDiscount ?? 0,
41
+ fixedDiscount: options.fixedDiscount ?? 0,
42
+ });
43
+ }
44
+
45
+ function createPayment(price: number): PaymentGeneral {
46
+ return PaymentGeneral.create({
47
+ id: 'payment-1',
48
+ method: PaymentMethod.Transfer,
49
+ status: PaymentStatus.Succeeded,
50
+ price,
51
+ balanceItemPayments: [
52
+ BalanceItemPaymentDetailed.create({
53
+ id: 'balance-item-payment-1',
54
+ price,
55
+ balanceItem: BalanceItem.create({
56
+ id: 'balance-item-1',
57
+ type: BalanceItemType.Order,
58
+ orderId: 'order-1',
59
+ description: 'Bestelling #1',
60
+ amount: 1,
61
+ unitPrice: price,
62
+ relations: new Map([
63
+ [BalanceItemRelationType.Webshop, BalanceItemRelation.create({
64
+ id: 'webshop-1',
65
+ name: new TranslatedString('Clubshop'),
66
+ })],
67
+ ]),
68
+ }),
69
+ }),
70
+ ],
71
+ });
72
+ }
73
+
74
+ function expectRowsToMatchReplacedPayment(rows: BalanceItemPaymentDetailed[], payment: PaymentGeneral) {
75
+ expect(rows.reduce((sum, row) => sum + row.price, 0)).toBe(payment.balanceItemPayments[0].price);
76
+ }
77
+
78
+ describe('payments excel loader', () => {
79
+ describe('expandPaymentBalanceItemPayments', () => {
80
+ it('splits a full order payment into order item rows and fees', () => {
81
+ const orderData = createOrderData();
82
+ const payment = createPayment(orderData.totalPrice);
83
+
84
+ const rows = expandPaymentBalanceItemPayments(payment, new Map([
85
+ ['order-1', { id: 'order-1', number: 123, data: orderData }],
86
+ ]));
87
+
88
+ expect(rows).toHaveLength(2);
89
+ expect(rows[0].customTitle).toBe('Koffie');
90
+ expect(rows[0].balanceItem.description).toBe('Groot');
91
+ expect(rows[0].amount).toBe(2);
92
+ expect(rows[0].unitPrice).toBe(3500);
93
+ expect(rows[0].price).toBe(7000);
94
+ expect(rows[1].customTitle).toBe('Administratiekosten');
95
+ expect(rows[1].balanceItem.description).toBe('Administratiekosten');
96
+ expect(rows[1].amount).toBe(1);
97
+ expect(rows[1].price).toBe(500);
98
+ expectRowsToMatchReplacedPayment(rows, payment);
99
+ });
100
+
101
+ it('adds order discount rows when splitting a full order payment', () => {
102
+ const orderData = createOrderData({
103
+ percentageDiscount: 1000,
104
+ fixedDiscount: 300,
105
+ });
106
+ const payment = createPayment(orderData.totalPrice);
107
+
108
+ const rows = expandPaymentBalanceItemPayments(payment, new Map([
109
+ ['order-1', { id: 'order-1', number: 123, data: orderData }],
110
+ ]));
111
+
112
+ expect(rows).toHaveLength(4);
113
+ expect(rows.map(row => row.customTitle)).toEqual([
114
+ 'Koffie',
115
+ 'Korting (10%)',
116
+ 'Korting',
117
+ 'Administratiekosten',
118
+ ]);
119
+ expect(rows.map(row => row.balanceItem.description)).toEqual([
120
+ 'Groot',
121
+ 'Korting (10%)',
122
+ 'Vaste korting',
123
+ 'Administratiekosten',
124
+ ]);
125
+ expect(rows.map(row => row.price)).toEqual([
126
+ 7000,
127
+ -700,
128
+ -300,
129
+ 500,
130
+ ]);
131
+ expectRowsToMatchReplacedPayment(rows, payment);
132
+ });
133
+
134
+ it('caps order discount rows to the cart subtotal', () => {
135
+ const orderData = createOrderData({
136
+ percentageDiscount: 10000,
137
+ fixedDiscount: 3000,
138
+ });
139
+ const payment = createPayment(orderData.totalPrice);
140
+
141
+ const rows = expandPaymentBalanceItemPayments(payment, new Map([
142
+ ['order-1', { id: 'order-1', number: 123, data: orderData }],
143
+ ]));
144
+
145
+ expect(rows).toHaveLength(3);
146
+ expect(rows.map(row => row.customTitle)).toEqual([
147
+ 'Koffie',
148
+ 'Korting (100%)',
149
+ 'Administratiekosten',
150
+ ]);
151
+ expect(rows.map(row => row.balanceItem.description)).toEqual([
152
+ 'Groot',
153
+ 'Korting (100%)',
154
+ 'Administratiekosten',
155
+ ]);
156
+ expect(rows.map(row => row.price)).toEqual([
157
+ 7000,
158
+ -7000,
159
+ 500,
160
+ ]);
161
+ expectRowsToMatchReplacedPayment(rows, payment);
162
+ });
163
+
164
+ it('keeps partial order payments and refunds as single rows', () => {
165
+ const orderData = createOrderData();
166
+ const orderMap = new Map([
167
+ ['order-1', { id: 'order-1', number: 123, data: orderData }],
168
+ ]);
169
+
170
+ const changedRows = expandPaymentBalanceItemPayments(createPayment(1000), orderMap);
171
+ expect(changedRows).toHaveLength(1);
172
+ expect(changedRows[0].balanceItem.description).toBe('Gedeeltelijke betaling/terugbetaling voor bestelling #123');
173
+ expect(changedRows[0].amount).toBe(1);
174
+ expect(changedRows[0].price).toBe(1000);
175
+
176
+ const refundRows = expandPaymentBalanceItemPayments(createPayment(-1000), orderMap);
177
+ expect(refundRows).toHaveLength(1);
178
+ expect(refundRows[0].balanceItem.description).toBe('Gedeeltelijke betaling/terugbetaling voor bestelling #123');
179
+ expect(refundRows[0].amount).toBe(1);
180
+ expect(refundRows[0].price).toBe(-1000);
181
+ });
182
+
183
+ it('only splits the same full order once per export page', () => {
184
+ const orderData = createOrderData();
185
+ const orderMap = new Map([
186
+ ['order-1', { id: 'order-1', number: 123, data: orderData }],
187
+ ]);
188
+ const addedOrderIds = new Set<string>();
189
+
190
+ const firstRows = expandPaymentBalanceItemPayments(createPayment(orderData.totalPrice), orderMap, addedOrderIds);
191
+ const secondRows = expandPaymentBalanceItemPayments(createPayment(orderData.totalPrice), orderMap, addedOrderIds);
192
+
193
+ expect(firstRows).toHaveLength(2);
194
+ expect(secondRows).toHaveLength(1);
195
+ expect(secondRows[0].balanceItem.description).toBe('Gedeeltelijke betaling/terugbetaling voor bestelling #123');
196
+ expect(secondRows[0].price).toBe(orderData.totalPrice);
197
+ expectRowsToMatchReplacedPayment(firstRows, createPayment(orderData.totalPrice));
198
+ expectRowsToMatchReplacedPayment(secondRows, createPayment(orderData.totalPrice));
199
+ });
200
+ });
201
+ });
@@ -1,22 +1,34 @@
1
1
  import { field } from '@simonbackx/simple-encoding';
2
2
  import type { XlsxTransformerColumn, XlsxTransformerConcreteColumn } from '@stamhoofd/excel-writer';
3
3
  import { XlsxBuiltInNumberFormat } from '@stamhoofd/excel-writer';
4
- import { StripeAccount } from '@stamhoofd/models';
5
- import type { BalanceItemPaymentDetailed } from '@stamhoofd/structures';
6
- import { BalanceItemRelationType, ExcelExportType, getBalanceItemRelationTypeName, getBalanceItemTypeName, PaginatedResponse, PaymentGeneral, PaymentMethodHelper, PaymentStatusHelper, StripeAccount as StripeAccountStruct } from '@stamhoofd/structures';
4
+ import { Order, StripeAccount } from '@stamhoofd/models';
5
+ import type { OrderData } from '@stamhoofd/structures';
6
+ import { BalanceItem, BalanceItemPaymentDetailed, BalanceItemRelationType, BalanceItemType, ExcelExportType, getBalanceItemRelationTypeName, getBalanceItemTypeName, PaginatedResponse, PaymentGeneral, PaymentMethodHelper, PaymentStatusHelper, StripeAccount as StripeAccountStruct } from '@stamhoofd/structures';
7
7
  import { Formatter } from '@stamhoofd/utility';
8
8
  import { ExportToExcelEndpoint } from '../endpoints/global/files/ExportToExcelEndpoint.js';
9
9
  import { GetPaymentsEndpoint } from '../endpoints/organization/dashboard/payments/GetPaymentsEndpoint.js';
10
10
  import { XlsxTransformerColumnHelper } from '../helpers/XlsxTransformerColumnHelper.js';
11
11
 
12
12
  type PaymentWithItem = {
13
- payment: PaymentGeneral;
14
- balanceItemPayment: BalanceItemPaymentDetailed;
13
+ payment: PaymentGeneralWithStripeAccount;
14
+ balanceItemPayment: PaymentExportBalanceItemPayment;
15
+ };
16
+
17
+ type PaymentExportOrder = {
18
+ id: string;
19
+ number: number | null;
20
+ data: OrderData;
21
+ };
22
+
23
+ export type PaymentExportBalanceItemPayment = BalanceItemPaymentDetailed & {
24
+ customTitle: string | null;
15
25
  };
16
26
 
17
27
  export class PaymentGeneralWithStripeAccount extends PaymentGeneral {
18
28
  @field({ decoder: StripeAccountStruct, nullable: true })
19
29
  stripeAccount: StripeAccountStruct | null = null;
30
+
31
+ expandedBalanceItemPayments: PaymentExportBalanceItemPayment[] = [];
20
32
  }
21
33
 
22
34
  ExportToExcelEndpoint.loaders.set(ExcelExportType.Payments, {
@@ -31,11 +43,21 @@ ExportToExcelEndpoint.loaders.set(ExcelExportType.Payments, {
31
43
  accounts = (await StripeAccount.getByIDs(...stripeAccountIds)).map(s => StripeAccountStruct.create(s));
32
44
  }
33
45
 
46
+ const orderIds = Formatter.uniqueArray(
47
+ data.results.flatMap(payment => payment.balanceItemPayments.flatMap((item) => {
48
+ return item.balanceItem.orderId ? [item.balanceItem.orderId] : [];
49
+ })),
50
+ );
51
+ const orders = orderIds.length > 0 ? await Order.getByIDs(...orderIds) : [];
52
+ const orderMap = new Map<string, PaymentExportOrder>(orders.map(order => [order.id, order]));
53
+ const addedOrderIds = new Set<string>();
54
+
34
55
  return new PaginatedResponse({
35
56
  ...data,
36
57
  results: data.results.map((p) => {
37
58
  const payment = PaymentGeneralWithStripeAccount.create(p);
38
59
  payment.stripeAccount = p.stripeAccountId ? (accounts.find(a => a.id === p.stripeAccountId) ?? null) : null;
60
+ payment.expandedBalanceItemPayments = expandPaymentBalanceItemPayments(payment, orderMap, addedOrderIds);
39
61
  return payment;
40
62
  }),
41
63
  });
@@ -56,7 +78,7 @@ ExportToExcelEndpoint.loaders.set(ExcelExportType.Payments, {
56
78
  {
57
79
  id: 'balanceItemPayments',
58
80
  name: $t(`%Ly`),
59
- transform: (data: PaymentGeneral): PaymentWithItem[] => data.balanceItemPayments.map(p => ({
81
+ transform: (data: PaymentGeneralWithStripeAccount): PaymentWithItem[] => data.expandedBalanceItemPayments.map(p => ({
60
82
  payment: data,
61
83
  balanceItemPayment: p,
62
84
  })),
@@ -100,6 +122,177 @@ ExportToExcelEndpoint.loaders.set(ExcelExportType.Payments, {
100
122
  ],
101
123
  });
102
124
 
125
+ export function expandPaymentBalanceItemPayments(
126
+ payment: PaymentGeneral,
127
+ orderMap: Map<string, PaymentExportOrder>,
128
+ addedOrderIds = new Set<string>(),
129
+ ): PaymentExportBalanceItemPayment[] {
130
+ return payment.balanceItemPayments.flatMap((item) => {
131
+ const orderId = item.balanceItem.orderId;
132
+ if (!orderId) {
133
+ return [createExportBalanceItemPayment(item, null)];
134
+ }
135
+
136
+ const order = orderMap.get(orderId);
137
+ if (!order) {
138
+ return [createExportBalanceItemPayment(item, null)];
139
+ }
140
+
141
+ if (!addedOrderIds.has(order.id) && item.price === order.data.totalPrice) {
142
+ addedOrderIds.add(order.id);
143
+ return createOrderItemPaymentRows(item, order);
144
+ }
145
+
146
+ return [
147
+ createSyntheticBalanceItemPayment({
148
+ source: item,
149
+ description: getPartialOrderPaymentDescription(order),
150
+ amount: 1,
151
+ price: item.price,
152
+ }),
153
+ ];
154
+ });
155
+ }
156
+
157
+ function createOrderItemPaymentRows(
158
+ item: BalanceItemPaymentDetailed,
159
+ order: PaymentExportOrder,
160
+ ): PaymentExportBalanceItemPayment[] {
161
+ const rows: PaymentExportBalanceItemPayment[] = [];
162
+
163
+ for (const orderItem of order.data.cart.items) {
164
+ rows.push(
165
+ createSyntheticBalanceItemPayment({
166
+ source: item,
167
+ customTitle: orderItem.product.name,
168
+ description: orderItem.description,
169
+ amount: orderItem.amount,
170
+ price: orderItem.getPrice(),
171
+ }),
172
+ );
173
+ }
174
+
175
+ addOrderDiscountRows(rows, item, order);
176
+
177
+ if (order.data.deliveryPrice) {
178
+ rows.push(
179
+ createSyntheticBalanceItemPayment({
180
+ source: item,
181
+ customTitle: $t('Leveringskosten'),
182
+ description: $t('Leveringskosten'),
183
+ amount: 1,
184
+ price: order.data.deliveryPrice,
185
+ }),
186
+ );
187
+ }
188
+
189
+ if (order.data.administrationFee) {
190
+ rows.push(
191
+ createSyntheticBalanceItemPayment({
192
+ source: item,
193
+ customTitle: $t('Administratiekosten'),
194
+ description: $t('Administratiekosten'),
195
+ amount: 1,
196
+ price: order.data.administrationFee,
197
+ }),
198
+ );
199
+ }
200
+
201
+ const difference = item.price - rows.reduce((sum, row) => sum + row.price, 0);
202
+ if (difference !== 0) {
203
+ rows.push(
204
+ createSyntheticBalanceItemPayment({
205
+ source: item,
206
+ customTitle: $t('Correctie bestelling'),
207
+ description: $t('Correctie bestelling'),
208
+ amount: 1,
209
+ price: difference,
210
+ }),
211
+ );
212
+ }
213
+
214
+ return rows;
215
+ }
216
+
217
+ function addOrderDiscountRows(
218
+ rows: PaymentExportBalanceItemPayment[],
219
+ item: BalanceItemPaymentDetailed,
220
+ order: PaymentExportOrder,
221
+ ) {
222
+ let remainingDiscountablePrice = order.data.cart.price;
223
+
224
+ const appliedPercentageDiscount = Math.min(order.data.appliedPercentageDiscount, remainingDiscountablePrice);
225
+ if (appliedPercentageDiscount > 0) {
226
+ rows.push(
227
+ createSyntheticBalanceItemPayment({
228
+ source: item,
229
+ customTitle: $t('Korting ({percentage})', { percentage: Formatter.percentage(order.data.percentageDiscount) }),
230
+ description: $t('Korting ({percentage})', { percentage: Formatter.percentage(order.data.percentageDiscount) }),
231
+ amount: 1,
232
+ price: -appliedPercentageDiscount,
233
+ }),
234
+ );
235
+ remainingDiscountablePrice -= appliedPercentageDiscount;
236
+ }
237
+
238
+ const fixedDiscount = Math.min(order.data.fixedDiscount, remainingDiscountablePrice);
239
+ if (fixedDiscount > 0) {
240
+ rows.push(
241
+ createSyntheticBalanceItemPayment({
242
+ source: item,
243
+ customTitle: $t('Korting'),
244
+ description: $t('Vaste korting'),
245
+ amount: 1,
246
+ price: -fixedDiscount,
247
+ }),
248
+ );
249
+ }
250
+ }
251
+
252
+ function getPartialOrderPaymentDescription(order: PaymentExportOrder): string {
253
+ if (order.number !== null) {
254
+ return $t('Gedeeltelijke betaling/terugbetaling voor bestelling #{number}', { number: order.number.toString() });
255
+ }
256
+
257
+ return $t('Gedeeltelijke betaling/terugbetaling voor bestelling');
258
+ }
259
+
260
+ function createSyntheticBalanceItemPayment({
261
+ source,
262
+ customTitle = null,
263
+ description,
264
+ amount,
265
+ price,
266
+ }: {
267
+ source: BalanceItemPaymentDetailed;
268
+ customTitle?: string | null;
269
+ description: string;
270
+ amount: number;
271
+ price: number;
272
+ }): PaymentExportBalanceItemPayment {
273
+ return createExportBalanceItemPayment(BalanceItemPaymentDetailed.create({
274
+ ...source,
275
+ price,
276
+ balanceItem: BalanceItem.create({
277
+ ...source.balanceItem,
278
+ type: BalanceItemType.Order,
279
+ relations: new Map(source.balanceItem.relations),
280
+ description,
281
+ amount,
282
+ unitPrice: amount === 0 ? 0 : price / amount,
283
+ }),
284
+ }), customTitle);
285
+ }
286
+
287
+ function createExportBalanceItemPayment(
288
+ item: BalanceItemPaymentDetailed,
289
+ customTitle: string | null,
290
+ ): PaymentExportBalanceItemPayment {
291
+ return Object.assign(item, {
292
+ customTitle,
293
+ });
294
+ }
295
+
103
296
  function getBalanceItemColumns(): XlsxTransformerColumn<PaymentWithItem>[] {
104
297
  return [
105
298
  {
@@ -149,12 +342,25 @@ function getBalanceItemColumns(): XlsxTransformerColumn<PaymentWithItem>[] {
149
342
  };
150
343
  },
151
344
  },
345
+ {
346
+ id: 'balanceItem.title',
347
+ name: $t('Titel'),
348
+ width: 40,
349
+ getValue: (object: PaymentWithItem) => ({
350
+ value: object.balanceItemPayment.customTitle || object.balanceItemPayment.balanceItem.itemTitle,
351
+ }),
352
+ },
152
353
  {
153
354
  id: 'balanceItem.description',
154
355
  name: $t(`%6o`),
155
- width: 40,
356
+ width: 50,
156
357
  getValue: (object: PaymentWithItem) => ({
157
- value: object.balanceItemPayment.balanceItem.description,
358
+ value: object.balanceItemPayment.balanceItem.itemDescription || object.balanceItemPayment.balanceItem.description,
359
+ style: {
360
+ alignment: {
361
+ wrapText: true,
362
+ },
363
+ },
158
364
  }),
159
365
  },
160
366
  {
@@ -209,6 +209,14 @@ export class AdminPermissionChecker {
209
209
  return this.user.permissions.organizationPermissions.get(typeof organizationOrId === 'string' ? organizationOrId : organizationOrId.id) ?? null;
210
210
  }
211
211
 
212
+ getUnloadedPlatformPermissions() {
213
+ if (!this.user.permissions) {
214
+ return null;
215
+ }
216
+
217
+ return this.user.permissions.globalPermissions;
218
+ }
219
+
212
220
  async canAccessPrivateOrganizationData(organization: Organization) {
213
221
  if (!this.checkScope(organization.id)) {
214
222
  return false;
@@ -232,7 +240,7 @@ export class AdminPermissionChecker {
232
240
  return false;
233
241
  }
234
242
 
235
- if (!await this.hasSomeUnloadedAccess(organization)) {
243
+ if (!await this.hasSomeUnloadedAccess(organization) && !this.hasSomeUnloadedPlatformAccess()) {
236
244
  return false;
237
245
  }
238
246
  return true;
@@ -1130,6 +1138,11 @@ export class AdminPermissionChecker {
1130
1138
  return !!unloadedPermissions && !unloadedPermissions.isEmpty;
1131
1139
  }
1132
1140
 
1141
+ hasSomeUnloadedPlatformAccess(): boolean {
1142
+ const unloadedPermissions = this.getUnloadedPlatformPermissions();
1143
+ return !!unloadedPermissions && !unloadedPermissions.isEmpty;
1144
+ }
1145
+
1133
1146
  async canManageAdmins(organizationId: string) {
1134
1147
  return !this.user.isApiUser && (await this.hasFullAccess(organizationId));
1135
1148
  }
@@ -223,7 +223,7 @@ export class ContextInstance {
223
223
  }
224
224
  }
225
225
 
226
- async authenticate({ allowWithoutAccount = false }: { allowWithoutAccount?: boolean } = {}): Promise<{ user: User; token: Token }> {
226
+ async authenticate({ allowWithoutAccount = false, allowUnscoped = false }: { allowWithoutAccount?: boolean; allowUnscoped?: boolean } = {}): Promise<{ user: User; token: Token }> {
227
227
  let header = this.request.headers.authorization;
228
228
 
229
229
  if (!header && this.request.method === 'POST') {
@@ -255,7 +255,7 @@ export class ContextInstance {
255
255
 
256
256
  const token = await Token.getByAccessToken(accessToken, true);
257
257
 
258
- if (!token || (this.organization && token.user.organizationId !== null && token.user.organizationId !== this.organization.id) || (!this.organization && token.user.organizationId)) {
258
+ if (!token || (this.organization && token.user.organizationId !== null && token.user.organizationId !== this.organization.id) || (!this.organization && token.user.organizationId && (!allowUnscoped || !token.user.isApiUser))) {
259
259
  if (token?.user) {
260
260
  console.log(
261
261
  'Failed auth: ' + token?.user.email + ' (' + token?.user.id + ')',
@@ -269,6 +269,11 @@ export class ContextInstance {
269
269
  });
270
270
  }
271
271
 
272
+ if (!this.organization && token.user.organizationId && allowUnscoped) {
273
+ // Automatically scope full request to the user's scope
274
+ await this.setManualOrganizationScope(await Organization.getByID(token.user.organizationId, true));
275
+ }
276
+
272
277
  if (token.isAccessTokenExpired()) {
273
278
  if (token?.user) {
274
279
  console.log(
@@ -116,9 +116,13 @@ export class FileCache {
116
116
  });
117
117
  }
118
118
 
119
- const date = new Date(year, month - 1, day, 0, 0, 0);
120
- const now = new Date();
121
- now.setHours(0, 0, 0, 0);
119
+ const date = Formatter.luxon().set({
120
+ day,
121
+ month,
122
+ year,
123
+ }).startOf('day').toJSDate();
124
+
125
+ const now = Formatter.luxon().startOf('day').toJSDate();
122
126
 
123
127
  const diff = now.getTime() - date.getTime();
124
128
 
package/src/migrate.ts CHANGED
@@ -8,10 +8,10 @@ Column.setJSONVersion(Version);
8
8
  process.env.TZ = 'UTC';
9
9
 
10
10
  // Polyfill require.resolve, since import.meta.resolve is not supported by vitest
11
- import { createRequire } from 'node:module';
12
- import { GlobalHelper } from './helpers/GlobalHelper.js';
13
11
  import { I18n } from '@stamhoofd/backend-i18n/I18n';
14
12
  import { QueryableModel } from '@stamhoofd/sql';
13
+ import { createRequire } from 'node:module';
14
+ import { GlobalHelper } from './helpers/GlobalHelper.js';
15
15
  const require = createRequire(import.meta.url);
16
16
 
17
17
  const emailPath = require.resolve('@stamhoofd/email');
@@ -50,34 +50,37 @@ const start = async () => {
50
50
  database: null,
51
51
  });
52
52
 
53
- await globalDatabase.statement(query);
53
+ try {
54
+ await globalDatabase.statement(query);
54
55
 
55
- // External migrations
56
- if (!await Migration.runAll(path.dirname(modelsPath) + '/migrations')) {
57
- throw new Error('Migrations failed');
58
- }
59
- if (!await Migration.runAll(path.dirname(emailPath) + '/../migrations')) {
60
- throw new Error('Email migrations failed');
61
- }
56
+ // External migrations
57
+ if (!await Migration.runAll(path.dirname(modelsPath) + '/migrations')) {
58
+ throw new Error('Migrations failed');
59
+ }
60
+ if (!await Migration.runAll(path.dirname(emailPath) + '/../migrations')) {
61
+ throw new Error('Email migrations failed');
62
+ }
62
63
 
63
- // Internal
64
- await I18n.load();
65
- GlobalHelper.loadGlobalTranslateFunction();
64
+ // Internal
65
+ await I18n.load();
66
+ GlobalHelper.loadGlobalTranslateFunction();
66
67
 
67
- if (!await Migration.runAll(import.meta.dirname + '/migrations')) {
68
- throw new Error('Internal migrations failed');
69
- }
68
+ if (!await Migration.runAll(import.meta.dirname + '/migrations')) {
69
+ throw new Error('Internal migrations failed');
70
+ }
70
71
 
71
- if (killSignalReceived) {
72
- console.error(chalk.red('Killing process due to received signal during migration'));
73
- process.exit(1);
72
+ if (killSignalReceived) {
73
+ console.error(chalk.red('Killing process due to received signal during migration'));
74
+ process.exit(1);
75
+ }
76
+
77
+ // Reload database to prevent connection state leakage
78
+ await Database.reload({});
79
+ } finally {
80
+ await globalDatabase.end();
81
+ process.off('SIGTERM', handler);
82
+ process.off('SIGINT', handler);
74
83
  }
75
-
76
- // Reload database to prevent connection state leakage
77
- await Database.reload({});
78
-
79
- process.off('SIGTERM', handler);
80
- process.off('SIGINT', handler);
81
84
  };
82
85
 
83
86
  export async function run() {
@@ -0,0 +1,237 @@
1
+ import { Migration } from '@simonbackx/simple-database';
2
+ import { Group, Organization, OrganizationRegistrationPeriod, V1GroupMigrationData } from '@stamhoofd/models';
3
+ import { GroupCategory, GroupCategorySettings, GroupType } from '@stamhoofd/structures';
4
+ import { SeedTools } from '../helpers/SeedTools.js';
5
+
6
+ export default new Migration(async () => {
7
+ if (STAMHOOFD.environment === 'test') {
8
+ console.log('skipped in tests');
9
+ return;
10
+ }
11
+
12
+ if (STAMHOOFD.platformName.toLowerCase() !== 'stamhoofd') {
13
+ console.log('skipped for platform (only runs for Stamhoofd): ' + STAMHOOFD.platformName);
14
+ return;
15
+ }
16
+
17
+ const dryRun = false;
18
+ await start(dryRun);
19
+
20
+ if (dryRun) {
21
+ throw new Error('Migration did not finish because of dryRun');
22
+ }
23
+ });
24
+
25
+ // only check organizations that were checked in the 'fix' migration
26
+ const relevantOrganizations = new Set(['f2543d6b-523e-4b03-848b-838200b1ff41', '11a478dd-e8cf-4160-80dd-8b48d20a6d39', '6d1c32cf-2ef0-4738-bacb-d553488f97b9', '3e6f5cb9-e130-4dda-92c9-26682fa09509', '57815733-5750-492f-beb1-1efd6229803a', '7677e32d-763a-4033-bc74-30d13f27556d', '7ac75a0f-f365-4ddc-836c-66a3267f24f0', '9dced404-a92d-44c4-b301-ecd29b6ac4e0', '97928f8a-b8d1-44cc-918f-09d0ae896c3b', 'f0edb665-1ce0-442a-88e7-5a62ea78c5fc', '842ada22-82f3-4cef-a1da-ae5da20b208d', '33744473-9ad9-4d3e-a9fc-31cd1b6a2753', 'ed6e74f1-51a0-4e04-8195-e722b015eeb5', '10827cc5-5f29-46cf-b26e-5fd11a9342cc', '5f4d4a26-43c3-4f80-86ad-64d07d5a46a7', '74e2bcbf-0ff9-484b-988a-e11d147122c0', 'c69512bc-ea0c-427a-ab90-08c3dcf1c856', '16742d64-0b31-4ce7-9c1e-6194882045b9', '95d59803-c50e-4bc1-adc9-90ade32b7579', 'eb6ccffa-41c5-45df-b4e4-1eb5749bc0fe', '1a534005-2784-400c-b8fe-bde09c901259', '27e06924-49a6-468c-a16e-b5735ace1894', '7a86f3db-08d8-4752-a77d-eb85f2167942', 'f562c735-7bf4-4e2e-8c0f-6584e8e96a1d', 'e2afe517-cd35-4d9e-a561-125ad184a2ff', 'fd934e40-734c-442d-b338-c3ae288dd55d']);
27
+
28
+ // only check groups that were checked in the 'fix' migration
29
+ const relevantGroups = new Set([
30
+ '020a313c-0e97-4068-84ba-783d91e768a4', '02420f62-1a07-4e2a-ae92-13c35320fb27', '0b6d8422-857f-46e4-a632-a4af322c9193', '12ba896a-e064-4697-a5df-69664c9bd8cb', '134e33f9-3560-4eca-977b-aaef01af5ed4', '1a9bb59a-c85a-4338-94c6-f82061f1ee0e', '1bf5519d-4645-4c51-8ae5-7a202bb74e9e', '1c86c401-c32b-4c6f-a2b5-fb15649efa74', '1ddc6fde-f512-43ac-8e15-2496601e900e', '20cab6ae-fb30-4de8-948e-60caf0979ff7', '22194f9d-fba2-4497-b7b0-de8ba6e884ff', '2384f112-d50d-4fcf-b6f2-45db73a8944c', '26693e8a-b73d-4f28-a41c-d281ba91fb3e', '287ff7b8-3319-40fb-ad25-26b139db3272', '2e473bf7-36f9-4276-8770-032db776d154', '3282e30c-a3db-4f00-b827-646fcc532c43', '3490cfd8-4c0b-487f-8f0d-c96c62950160', '3d68762b-8133-42e0-a642-7649864b09b3', '4a7d205b-e996-4841-b21c-1336642e670d', '52b0285c-4935-4577-bc7b-30d0b8804fc9', '579263b6-ceb1-4be0-a31e-9a4a904f0cae', '5870423f-6faf-492e-accb-4bd110365328', '60e24adb-5bb3-48de-9008-5b80c905a01a', '6535d9b4-f2ad-4f7e-b7e2-9e50ddf5eff6', '6a508234-08aa-4b0b-9647-7aeb9d80fa5a', '722f3bdf-d9df-4a78-a4c1-783c9af3d21b', '754bb111-caad-49b3-8dde-5bd0f3ad9104', '76aef892-19b5-4deb-9276-0c6b19b652fd', '78fa525b-e346-4b78-9e25-f9b1b5134b77', '7989ea9d-354d-4eff-9350-8eb8a6c4732b', '7aba3792-0138-4302-8550-2ccb456a37ad', '7c480cd5-8db3-4c03-8f0b-a88556df0234', '8113c2a2-e788-4f11-815f-4b019d7ca966', '82958c24-bc12-45f2-9d0d-3c8b902df052', '88c28670-cf97-42e2-ae9e-d836d349c2e6', '88d87c1a-749f-453c-a60b-e54a7001a9f0', '8983c1d5-e8aa-4628-a251-17220336fad5', '89ced76d-8c98-4f83-bffa-8b467289972d', '8abed111-cda2-4bb8-8d98-21291a85fb4f', '8bb9b1c6-2d40-4a69-bbd5-63ce152b1a91', '8f98e452-e174-4946-96ec-888cc3498f56', '8fdffd17-84fa-41f7-8b74-744578d4b9d9', '92ae627b-8aa4-4197-885e-603d4a1be8aa', '9852662c-7e9f-4c26-a6d3-6335b49b9b3b', 'a06ddcb5-dc9c-4bec-a0f8-40fd4ab20016', 'a25bbeae-86b9-4162-8570-d121845f26e9', 'a690c59b-72eb-40bb-b181-d9dc904f9ec1', 'a9b0a795-3b7e-40bb-b8ec-65280d149dba', 'ab9ddbb9-21bc-4a8e-be7f-036554b70db4', 'bea51786-4d47-4bcb-bf79-fc636f047cbb', 'c806a74d-f0ae-4473-86bd-facb92085eb1', 'c9064135-322e-4848-9de0-61011d067697', 'cb7e738f-bcb5-4bc5-9962-2608ea9c4094', 'cbc21e01-bb7e-4f1d-94c8-d4aac3b7247d', 'cf766627-3e32-45a1-8d62-865b6cc1d4ed', 'd0cf99ce-24e9-4912-9dc8-660b4ff57dcc', 'd2a536af-f23d-4c3e-8275-dc2e5116ab60', 'd715451c-6292-4d58-83f8-ffce115fca21', 'd76b57b8-10c4-43e4-94d1-bb66d1053bb4', 'dcffb511-131b-401c-a4b5-13810596bc42', 'df010c46-fc79-401c-a4a4-1fddb478acff', 'dfaf11b8-f835-4952-99e1-2b0279931b05', 'e0b8e823-4213-4919-b1ed-70395ad15df2', 'e9470495-097c-47b5-8e63-cd4f7370fa2d', 'e9fe89f8-780b-417f-a4ee-b48e0b0bb8ad', 'eda36826-ee68-414d-a773-cb41e1a1e2d6', 'edaa90fa-869b-4f3c-8b90-4ea41fe9a364', 'f163d78a-9539-43a3-aeb7-443559599342', 'f24e2ca9-3036-4839-9ff4-91bd4ecb5c91', 'f665642c-93e7-4886-bd6e-f94252d54e77', 'f7e5436c-855d-4bf9-b2de-94e9d0fd3052', 'f822b26a-715a-4154-b2a2-b11e38373fad', 'fe2e4ec5-cee4-4648-ae87-2f8dee7f42c8', 'ff3a7e68-feb7-4aca-983d-a8f8315851e7',
31
+ ]);
32
+
33
+ async function start(dryRun: boolean) {
34
+ await SeedTools.loop({
35
+ query: Organization.select(),
36
+ batchSize: 50,
37
+ useTransactionPerBatch: true,
38
+ action: async (organization: Organization) => {
39
+ if (!relevantOrganizations.has(organization.id)) {
40
+ return;
41
+ }
42
+
43
+ await createMissingGroupCategories(organization, dryRun);
44
+ },
45
+ });
46
+ }
47
+
48
+ async function createMissingGroupCategories(organization: Organization, dryRun: boolean) {
49
+ const organizationPeriods = await OrganizationRegistrationPeriod.select()
50
+ .where('organizationId', organization.id)
51
+ // exclude current period
52
+ .andWhereNot('periodId', organization.periodId)
53
+ .fetch();
54
+
55
+ if (organizationPeriods.length > 1) {
56
+ console.error('found multiple periods for organization: ' + organization.id);
57
+ }
58
+
59
+ for (const organizationPeriod of organizationPeriods) {
60
+ const groups: Group[] = await Group.select()
61
+ .where('organizationId', organization.id)
62
+ // only for period being looped
63
+ .andWhere('periodId', organizationPeriod.periodId)
64
+ .andWhere('type', GroupType.Membership)
65
+ .andWhere('deletedAt', null)
66
+ .fetch();
67
+
68
+ if (groups.length === 0) {
69
+ continue;
70
+ }
71
+
72
+ const allCategories = organizationPeriod.settings.categories;
73
+
74
+ for (const group of groups) {
75
+ const category = allCategories.find(c => c.groupIds.includes(group.id));
76
+
77
+ // only create missing category if group is not in any category yet
78
+ if (!category) {
79
+ const oldGroupId = await getOldGroupId(group);
80
+ if (!relevantGroups.has(oldGroupId)) {
81
+ throw new Error('not expected');
82
+ }
83
+
84
+ const oldGroup = await Group.getByID(oldGroupId);
85
+ if (!oldGroup) {
86
+ throw new Error('Old group not found: ' + oldGroupId);
87
+ }
88
+
89
+ const { path, organizationPeriod: currentOrganizationPeriod } = await findPath(oldGroup);
90
+
91
+ const allArchivedCategories = organizationPeriod.settings.categories;
92
+
93
+ const archivedRoot = organizationPeriod.settings.rootCategory;
94
+ if (!archivedRoot) {
95
+ throw new Error('archived root category not found');
96
+ }
97
+ let thisLayer = getChildCategories(archivedRoot, allArchivedCategories);
98
+ const equalArchivedPath: GroupCategory[] = [organizationPeriod.settings.rootCategory!];
99
+
100
+ for (const currentPeriodCategory of path) {
101
+ if (currentPeriodCategory === currentOrganizationPeriod.settings.rootCategory) {
102
+ continue;
103
+ }
104
+
105
+ // only exact match for last category is needed
106
+ const exactMatch = currentPeriodCategory !== path[path.length - 1];
107
+ const equalArchivedCategories = thisLayer.filter(archivedCategory => isEqualCategory(currentPeriodCategory, archivedCategory, exactMatch));
108
+ if (equalArchivedCategories.length > 1) {
109
+ throw new Error(`Found multiple equal categories (${currentPeriodCategory.settings.name}): ` + equalArchivedCategories.map(c => c.settings.name).join(', '));
110
+ }
111
+
112
+ if (equalArchivedCategories.length === 0) {
113
+ break;
114
+ }
115
+
116
+ const equalArchivedCategory = equalArchivedCategories[0];
117
+ thisLayer = getChildCategories(equalArchivedCategory, allArchivedCategories);
118
+ equalArchivedPath.push(equalArchivedCategory);
119
+ }
120
+
121
+ // logging
122
+ console.log('organization: ', organization.name);
123
+ console.log('group: ', group.settings.name.toString());
124
+ logPath(path, 'current');
125
+ logPath(equalArchivedPath, 'archived');
126
+
127
+ if (path.length === equalArchivedPath.length) {
128
+ // complete match -> add group to last category
129
+ const lastCategory = equalArchivedPath[equalArchivedPath.length - 1];
130
+
131
+ if (lastCategory.groupIds.length) {
132
+ throw new Error('not expected last category has group ids');
133
+ }
134
+
135
+ // create new category
136
+ const newCategory = createCategoryForOriginalGroup(oldGroup, [group.id]);
137
+ lastCategory.categoryIds.push(newCategory.id);
138
+ organizationPeriod.settings.categories.push(newCategory);
139
+
140
+ if (!dryRun) {
141
+ await organizationPeriod.save();
142
+ }
143
+ } else {
144
+ // should not happen
145
+ console.error('no complete match');
146
+ }
147
+ }
148
+ }
149
+ }
150
+ }
151
+
152
+ function getChildCategories(category: GroupCategory, allCategories: GroupCategory[]) {
153
+ const results: GroupCategory[] = [];
154
+
155
+ for (const childId of category.categoryIds) {
156
+ const child = allCategories.find(c => c.id === childId);
157
+ if (!child) {
158
+ throw new Error('child category not found');
159
+ }
160
+ results.push(child);
161
+ }
162
+
163
+ return results;
164
+ }
165
+
166
+ async function getOldGroupId(group: Group) {
167
+ const data = await V1GroupMigrationData.select().where('newGroupId', group.id).first(true);
168
+ return data.oldGroupId;
169
+ }
170
+
171
+ /**
172
+ * Find the path from the old group to the root category.
173
+ */
174
+ async function findPath(oldGroup: Group): Promise<{ path: GroupCategory[]; organizationPeriod: OrganizationRegistrationPeriod }> {
175
+ /**
176
+ * Returns all the parent categories of the group starting with the root category (= start) or null if not found.
177
+ */
178
+ function getPathToGroup(groupId: string, start: GroupCategory, allCategories: GroupCategory[]): GroupCategory[] | null {
179
+ if (start.groupIds.includes(groupId)) {
180
+ return [start];
181
+ }
182
+
183
+ for (const childCategoryId of start.categoryIds) {
184
+ const childCategory = allCategories.find(c => c.id === childCategoryId);
185
+ if (childCategory) {
186
+ const childPath = getPathToGroup(groupId, childCategory, allCategories);
187
+ if (childPath !== null) {
188
+ return [start, ...childPath];
189
+ }
190
+ }
191
+ }
192
+
193
+ return null;
194
+ }
195
+
196
+ const organizationPeriod = await OrganizationRegistrationPeriod.select()
197
+ .where('organizationId', oldGroup.organizationId)
198
+ .where('periodId', oldGroup.periodId)
199
+ .first(true);
200
+
201
+ const root = organizationPeriod.settings.rootCategory;
202
+ if (!root) {
203
+ throw new Error('root category not found');
204
+ }
205
+
206
+ const path = getPathToGroup(oldGroup.id, root, organizationPeriod.settings.categories);
207
+ if (path === null) {
208
+ throw new Error('path not found');
209
+ }
210
+
211
+ return {
212
+ organizationPeriod,
213
+ path,
214
+ };
215
+ }
216
+
217
+ function logPath(path: GroupCategory[], prefix: string): void {
218
+ console.log(`${prefix} path: ${path.map(c => c.settings.name).join(' > ')}`);
219
+ }
220
+
221
+ function isEqualCategory(currentPeriodCategory: GroupCategory, archivedPeriodCategory: GroupCategory, exactMatch: boolean) {
222
+ if (exactMatch) {
223
+ return archivedPeriodCategory.settings.name === currentPeriodCategory.settings.name;
224
+ }
225
+ return archivedPeriodCategory.settings.name.startsWith(currentPeriodCategory.settings.name);
226
+ }
227
+
228
+ function createCategoryForOriginalGroup(originalGroup: Group, groupIds: string[]) {
229
+ return GroupCategory.create({
230
+ settings: GroupCategorySettings.create({
231
+ name: originalGroup.settings.name.toString(),
232
+ public: false,
233
+ maximumRegistrations: null,
234
+ }),
235
+ groupIds,
236
+ });
237
+ }
@@ -1,181 +0,0 @@
1
- import type { RegistrationPeriod } from '@stamhoofd/models';
2
- import { DocumentTemplate, DocumentTemplateFactory, GroupFactory, OrganizationFactory, RegistrationPeriodFactory } from '@stamhoofd/models';
3
- import { migrateDocumentYears } from './1750090029-document-update-year.js';
4
- import { Formatter } from '@stamhoofd/utility';
5
-
6
- describe('migration.document-update-year', () => {
7
- describe('should use most frequent year of groups', () => {
8
- test('groups with date in period', async () => {
9
- const organization = await new OrganizationFactory({
10
- }).create();
11
-
12
- const period1 = await new RegistrationPeriodFactory({
13
- startDate: Formatter.luxon().set({ day: 1, month: 1, year: 2021 }).toJSDate(),
14
- endDate: Formatter.luxon().set({ day: 31, month: 12, year: 2021 }).toJSDate(),
15
- organization,
16
- }).create();
17
-
18
- organization.periodId = period1.id;
19
- await organization.save();
20
-
21
- const period2 = await new RegistrationPeriodFactory({
22
- startDate: Formatter.luxon().set({ day: 1, month: 1, year: 2019 }).toJSDate(),
23
- endDate: Formatter.luxon().set({ day: 31, month: 12, year: 2019 }).toJSDate(),
24
- organization,
25
- }).create();
26
-
27
- const createGroup = async (startDate: Date, endDate: Date, period: RegistrationPeriod) => {
28
- const group = await new GroupFactory({ organization, period }).create();
29
- group.settings.startDate = startDate;
30
- group.settings.endDate = endDate;
31
- await group.save();
32
- return group;
33
- };
34
-
35
- // groups
36
- const group1 = await createGroup(Formatter.luxon().set({ day: 1, month: 1, year: 2021 }).toJSDate(), Formatter.luxon().set({ day: 31, month: 12, year: 2021 }).toJSDate(), period1);
37
- const group2 = await createGroup(Formatter.luxon().set({ day: 1, month: 1, year: 2021 }).toJSDate(), Formatter.luxon().set({ day: 31, month: 12, year: 2021 }).toJSDate(), period1);
38
- const group3 = await createGroup(Formatter.luxon().set({ day: 1, month: 1, year: 2019 }).toJSDate(), Formatter.luxon().set({ day: 31, month: 12, year: 2019 }).toJSDate(), period2);
39
-
40
- // document created in 2021
41
- const document1 = await new DocumentTemplateFactory({
42
- groups: [group1, group2, group3],
43
- year: 0,
44
- }).create();
45
-
46
- document1.createdAt = Formatter.luxon().set({ day: 1, month: 1, year: 2022 }).toJSDate();
47
- await document1.save();
48
-
49
- // document created in 2020
50
- const document2 = await new DocumentTemplateFactory({
51
- groups: [group1, group2, group3],
52
- year: 0,
53
- }).create();
54
-
55
- document2.createdAt = Formatter.luxon().set({ day: 1, month: 1, year: 2021 }).toJSDate();
56
- await document2.save();
57
-
58
- // document created in 2019
59
- const document3 = await new DocumentTemplateFactory({
60
- groups: [group1, group2, group3],
61
- year: 0,
62
- }).create();
63
-
64
- document3.createdAt = Formatter.luxon().set({ day: 1, month: 1, year: 2020 }).toJSDate();
65
- await document3.save();
66
-
67
- // act
68
- await migrateDocumentYears();
69
-
70
- // assert
71
- const updatedDocument1 = await DocumentTemplate.getByID(document1.id);
72
- const updatedDocument2 = await DocumentTemplate.getByID(document2.id);
73
- const updatedDocument3 = await DocumentTemplate.getByID(document3.id);
74
-
75
- // take most frequent year and prefer date of document creation
76
- expect(updatedDocument1?.year).toBe(2021);
77
- expect(updatedDocument2?.year).toBe(2021);
78
- expect(updatedDocument3?.year).toBe(2020); // maxed at creation year
79
- });
80
-
81
- test('groups overlapping period', async () => {
82
- const organization = await new OrganizationFactory({
83
- }).create();
84
-
85
- const period1 = await new RegistrationPeriodFactory({
86
- startDate: Formatter.luxon().set({ day: 1, month: 1, year: 2021 }).toJSDate(),
87
- endDate: Formatter.luxon().set({ day: 31, month: 12, year: 2021 }).toJSDate(),
88
- organization,
89
- }).create();
90
-
91
- organization.periodId = period1.id;
92
- await organization.save();
93
-
94
- const period2 = await new RegistrationPeriodFactory({
95
- startDate: Formatter.luxon().set({ day: 1, month: 1, year: 2020 }).toJSDate(),
96
- endDate: Formatter.luxon().set({ day: 31, month: 12, year: 2020 }).toJSDate(),
97
- organization,
98
- }).create();
99
-
100
- const period3 = await new RegistrationPeriodFactory({
101
- startDate: Formatter.luxon().set({ day: 1, month: 1, year: 2019 }).toJSDate(),
102
- endDate: Formatter.luxon().set({ day: 31, month: 12, year: 2019 }).toJSDate(),
103
- organization,
104
- }).create();
105
-
106
- const createGroup = async (startDate: Date, endDate: Date, period: RegistrationPeriod) => {
107
- const group = await new GroupFactory({ organization, period }).create();
108
- group.settings.startDate = startDate;
109
- group.settings.endDate = endDate;
110
- await group.save();
111
- return group;
112
- };
113
-
114
- // groups
115
- const group1 = await createGroup(Formatter.luxon().set({ day: 1, month: 7, year: 2020 }).toJSDate(), Formatter.luxon().set({ day: 30, month: 6, year: 2021 }).toJSDate(), period1);
116
- const group2 = await createGroup(Formatter.luxon().set({ day: 5, month: 8, year: 2020 }).toJSDate(), Formatter.luxon().set({ day: 3, month: 5, year: 2021 }).toJSDate(), period1);
117
- const group3 = await createGroup(Formatter.luxon().set({ day: 1, month: 6, year: 2018 }).toJSDate(), Formatter.luxon().set({ day: 14, month: 11, year: 2019 }).toJSDate(), period3);
118
-
119
- // document created in 2021
120
- const document1 = await new DocumentTemplateFactory({
121
- groups: [group1, group2, group3],
122
- year: 0,
123
- }).create();
124
-
125
- document1.createdAt = Formatter.luxon().set({ day: 1, month: 1, year: 2022 }).toJSDate();
126
- await document1.save();
127
-
128
- // document created in 2020
129
- const document2 = await new DocumentTemplateFactory({
130
- groups: [group1, group2, group3],
131
- year: 0,
132
- }).create();
133
-
134
- document2.createdAt = Formatter.luxon().set({ day: 1, month: 1, year: 2021 }).toJSDate();
135
- await document2.save();
136
-
137
- // document created in 2019
138
- const document3 = await new DocumentTemplateFactory({
139
- groups: [group1, group2, group3],
140
- year: 0,
141
- }).create();
142
-
143
- document3.createdAt = Formatter.luxon().set({ day: 1, month: 1, year: 2020 }).toJSDate();
144
- await document3.save();
145
-
146
- // act
147
- await migrateDocumentYears();
148
-
149
- // assert
150
- const updatedDocument1 = await DocumentTemplate.getByID(document1.id);
151
- const updatedDocument2 = await DocumentTemplate.getByID(document2.id);
152
- const updatedDocument3 = await DocumentTemplate.getByID(document3.id);
153
-
154
- // take most frequent year and prefer date of document creation
155
- expect(updatedDocument1?.year).toBe(2020);
156
- // should take 2020 because document was created in 2020
157
- expect(updatedDocument2?.year).toBe(2020);
158
- expect(updatedDocument3?.year).toBe(2020);
159
- });
160
- });
161
-
162
- test('no groups should use year before creation', async () => {
163
- // create document
164
- const document = await new DocumentTemplateFactory({
165
- groups: [],
166
- year: 0,
167
- }).create();
168
-
169
- document.createdAt = Formatter.luxon().set({ day: 1, month: 1, year: 2025 }).toJSDate();
170
- await document.save();
171
-
172
- // act
173
- await migrateDocumentYears();
174
-
175
- // assert
176
- const updatedDocument = await DocumentTemplate.getByID(document.id);
177
-
178
- // year should be year of creation
179
- expect(updatedDocument?.year).toBe(2024);
180
- });
181
- });