@stamhoofd/backend 2.107.3 → 2.109.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 (44) hide show
  1. package/index.ts +2 -2
  2. package/package.json +20 -20
  3. package/src/crons/disable-auto-update-documents.test.ts +164 -0
  4. package/src/crons/disable-auto-update-documents.ts +82 -0
  5. package/src/endpoints/admin/members/ChargeMembersEndpoint.ts +5 -5
  6. package/src/endpoints/admin/registrations/ChargeRegistrationsEndpoint.ts +87 -0
  7. package/src/endpoints/global/members/GetMembersCountEndpoint.ts +2 -2
  8. package/src/endpoints/global/members/GetMembersEndpoint.ts +5 -5
  9. package/src/endpoints/global/registration/GetRegistrationsCountEndpoint.ts +2 -2
  10. package/src/endpoints/global/registration/GetRegistrationsEndpoint.ts +14 -11
  11. package/src/endpoints/global/registration/RegisterMembersEndpoint.ts +9 -8
  12. package/src/endpoints/organization/dashboard/documents/GetDocumentTemplatesCountEndpoint.ts +48 -0
  13. package/src/endpoints/organization/dashboard/documents/GetDocumentTemplatesEndpoint.ts +95 -19
  14. package/src/endpoints/organization/dashboard/documents/PatchDocumentTemplatesEndpoint.test.ts +282 -0
  15. package/src/endpoints/organization/dashboard/documents/{PatchDocumentTemplateEndpoint.ts → PatchDocumentTemplatesEndpoint.ts} +56 -3
  16. package/src/excel-loaders/members.ts +62 -8
  17. package/src/excel-loaders/registrations.ts +180 -9
  18. package/src/helpers/LimitedFilteredRequestHelper.ts +24 -0
  19. package/src/helpers/MemberCharger.ts +16 -4
  20. package/src/helpers/SQLTranslatedString.ts +14 -0
  21. package/src/helpers/TagHelper.test.ts +9 -9
  22. package/src/helpers/fetchToAsyncIterator.ts +1 -1
  23. package/src/helpers/outstandingBalanceJoin.ts +49 -0
  24. package/src/seeds/1765896674-document-update-year.test.ts +179 -0
  25. package/src/seeds/1765896674-document-update-year.ts +75 -0
  26. package/src/seeds/1766150402-document-published-at.test.ts +46 -0
  27. package/src/seeds/1766150402-document-published-at.ts +20 -0
  28. package/src/services/PaymentService.ts +14 -32
  29. package/src/sql-filters/base-registration-filter-compilers.ts +51 -4
  30. package/src/sql-filters/document-templates.ts +45 -0
  31. package/src/sql-filters/documents.ts +1 -1
  32. package/src/sql-filters/events.ts +6 -6
  33. package/src/sql-filters/groups.ts +7 -6
  34. package/src/sql-filters/members.ts +31 -26
  35. package/src/sql-filters/orders.ts +16 -16
  36. package/src/sql-filters/organizations.ts +11 -11
  37. package/src/sql-filters/payments.ts +10 -10
  38. package/src/sql-filters/registrations.ts +14 -6
  39. package/src/sql-sorters/document-templates.ts +79 -0
  40. package/src/sql-sorters/documents.ts +1 -1
  41. package/src/sql-sorters/members.ts +22 -0
  42. package/src/sql-sorters/orders.ts +5 -5
  43. package/src/sql-sorters/organizations.ts +3 -3
  44. package/src/sql-sorters/registrations.ts +186 -15
@@ -1,12 +1,12 @@
1
1
  import { XlsxBuiltInNumberFormat, XlsxTransformerSheet } from '@stamhoofd/excel-writer';
2
2
  import { Platform } from '@stamhoofd/models';
3
- import { ExcelExportType, LimitedFilteredRequest, PlatformMember, PlatformRegistration, Platform as PlatformStruct, UnencodeablePaginatedResponse } from '@stamhoofd/structures';
4
- import { ExportToExcelEndpoint } from '../endpoints/global/files/ExportToExcelEndpoint';
5
- import { GetRegistrationsEndpoint } from '../endpoints/global/registration/GetRegistrationsEndpoint';
6
- import { AuthenticatedStructures } from '../helpers/AuthenticatedStructures';
7
- import { Context } from '../helpers/Context';
8
- import { XlsxTransformerColumnHelper } from '../helpers/XlsxTransformerColumnHelper';
9
- import { baseMemberColumns } from './members';
3
+ import { ExcelExportType, getGroupTypeName, LimitedFilteredRequest, PlatformMember, PlatformRegistration, Platform as PlatformStruct, UnencodeablePaginatedResponse } from '@stamhoofd/structures';
4
+ import { ExportToExcelEndpoint } from '../endpoints/global/files/ExportToExcelEndpoint.js';
5
+ import { GetRegistrationsEndpoint } from '../endpoints/global/registration/GetRegistrationsEndpoint.js';
6
+ import { AuthenticatedStructures } from '../helpers/AuthenticatedStructures.js';
7
+ import { Context } from '../helpers/Context.js';
8
+ import { XlsxTransformerColumnHelper } from '../helpers/XlsxTransformerColumnHelper.js';
9
+ import { baseMemberColumns } from './members.js';
10
10
 
11
11
  // Assign to a typed variable to assure we have correct type checking in place
12
12
  const sheet: XlsxTransformerSheet<PlatformMember, PlatformRegistration> = {
@@ -22,7 +22,7 @@ const sheet: XlsxTransformerSheet<PlatformMember, PlatformRegistration> = {
22
22
  }),
23
23
  },
24
24
  {
25
- id: 'price',
25
+ id: 'priceName',
26
26
  name: $t(`dcc53f25-f0e9-4e3e-9f4f-e8cfa4e88755`),
27
27
  width: 30,
28
28
  getValue: (registration: PlatformRegistration) => {
@@ -31,6 +31,167 @@ const sheet: XlsxTransformerSheet<PlatformMember, PlatformRegistration> = {
31
31
  };
32
32
  },
33
33
  },
34
+ {
35
+ id: 'price',
36
+ name: $t(`dcc53f25-f0e9-4e3e-9f4f-e8cfa4e88755`),
37
+ width: 30,
38
+ getValue: (registration: PlatformRegistration) => {
39
+ return {
40
+ value: registration.balances.reduce((sum, r) => sum + (r.amountOpen + r.amountPaid + r.amountPending), 0) / 1_0000,
41
+ style: {
42
+ numberFormat: {
43
+ id: XlsxBuiltInNumberFormat.Currency2DecimalWithRed,
44
+ },
45
+ },
46
+ };
47
+ },
48
+ },
49
+ {
50
+ id: 'toPay',
51
+ width: 30,
52
+ name: $t(`3a97e6cb-012d-4007-9c54-49d3e5b72909`),
53
+ getValue: (registration: PlatformRegistration) => {
54
+ return {
55
+ value: registration.balances.reduce((sum, r) => sum + (r.amountOpen + r.amountPending), 0) / 1_0000,
56
+ style: {
57
+ numberFormat: {
58
+ id: XlsxBuiltInNumberFormat.Currency2DecimalWithRed,
59
+ },
60
+ },
61
+ };
62
+ },
63
+
64
+ },
65
+ {
66
+ id: 'outstandingBalance',
67
+ name: $t(`beb45452-dee7-4a7f-956c-e6db06aac20f`),
68
+ width: 30,
69
+ getValue: (v) => {
70
+ return {
71
+ value: v.member.member.balances.reduce((sum, r) => sum + (r.amountOpen), 0) / 1_0000,
72
+ style: {
73
+ numberFormat: {
74
+ id: XlsxBuiltInNumberFormat.Currency2DecimalWithRed,
75
+ },
76
+ },
77
+ };
78
+ },
79
+
80
+ },
81
+ {
82
+ id: 'registeredAt',
83
+ name: $t(`8bec8990-4632-40b0-93f3-f27a3f2ddbdb`),
84
+ width: 20,
85
+ getValue: (registration: PlatformRegistration) => ({
86
+ value: registration.registeredAt,
87
+ style: {
88
+ numberFormat: {
89
+ id: XlsxBuiltInNumberFormat.DateSlash,
90
+ },
91
+ },
92
+ }),
93
+ },
94
+ {
95
+ id: 'startDate',
96
+ name: $t(`bbe0af99-b574-4719-a505-ca2285fa86e4`),
97
+ width: 20,
98
+ getValue: (registration: PlatformRegistration) => ({
99
+ value: registration.startDate,
100
+ style: {
101
+ numberFormat: {
102
+ id: XlsxBuiltInNumberFormat.DateSlash,
103
+ },
104
+ },
105
+ }),
106
+ },
107
+ {
108
+ id: 'endDate',
109
+ name: $t(`aef10d71-39c4-4cdb-8252-5fd31781abd8`),
110
+ width: 20,
111
+ getValue: (registration: PlatformRegistration) => ({
112
+ value: registration.endDate,
113
+ style: {
114
+ numberFormat: {
115
+ id: XlsxBuiltInNumberFormat.DateSlash,
116
+ },
117
+ },
118
+ }),
119
+ },
120
+ {
121
+ id: 'createdAt',
122
+ name: $t('63a86cdf-8a76-4e8c-9073-4f0b8970e808'),
123
+ width: 20,
124
+ getValue: (registration: PlatformRegistration) => ({
125
+ value: registration.member.member.createdAt,
126
+ style: {
127
+ numberFormat: {
128
+ id: XlsxBuiltInNumberFormat.DateSlash,
129
+ },
130
+ },
131
+ }),
132
+ },
133
+ {
134
+ id: 'organization',
135
+ name: $t('2f325358-6e2f-418c-9fea-31a14abbc17a'),
136
+ width: 40,
137
+ getValue: (registration: PlatformRegistration) => {
138
+ const organization = registration.member.family.getOrganization(registration.group.organizationId);
139
+ return ({
140
+ value: organization?.name ?? $t('836c2cd3-32a3-43f2-b09c-600170fcd9cb'),
141
+ });
142
+ },
143
+ },
144
+ {
145
+ id: 'uri',
146
+ name: $t('9d283cbb-7ba2-4a16-88ec-ff0c19f39674'),
147
+ width: 40,
148
+ getValue: (registration: PlatformRegistration) => {
149
+ const organization = registration.member.family.getOrganization(registration.group.organizationId);
150
+ return ({
151
+ value: organization?.uri ?? $t('836c2cd3-32a3-43f2-b09c-600170fcd9cb'),
152
+ });
153
+ },
154
+ },
155
+ {
156
+ id: 'groupRegistration',
157
+ name: $t('7289b10e-a284-40ea-bc57-8287c6566a82'),
158
+ width: 40,
159
+ getValue: (registration: PlatformRegistration) => {
160
+ let value: string;
161
+
162
+ if (registration.payingOrganizationId) {
163
+ const organization = registration.member.organizations.find(o => o.id === registration.payingOrganizationId);
164
+ value = organization ? organization.name : $t(`bd1e59c8-3d4c-4097-ab35-0ce7b20d0e50`);
165
+ }
166
+ else {
167
+ value = $t(`b8b730fb-f1a3-4c13-8ec4-0aebe08a1449`);
168
+ }
169
+
170
+ return ({
171
+ value,
172
+ });
173
+ },
174
+ },
175
+ {
176
+ id: 'trialUntil',
177
+ name: $t(`1f2e9d09-717b-4c17-9bbe-dce3f3dcbff0`),
178
+ width: 40,
179
+ getValue: (registration: PlatformRegistration) => {
180
+ let value: Date | null = null;
181
+ if (registration.trialUntil && registration.trialUntil > new Date()) {
182
+ value = new Date(registration.trialUntil.getTime());
183
+ }
184
+
185
+ return {
186
+ value,
187
+ style: {
188
+ numberFormat: {
189
+ id: XlsxBuiltInNumberFormat.DateSlash,
190
+ },
191
+ },
192
+ };
193
+ },
194
+ },
34
195
  // option menu
35
196
  {
36
197
  match(id) {
@@ -71,7 +232,7 @@ const sheet: XlsxTransformerSheet<PlatformMember, PlatformRegistration> = {
71
232
  },
72
233
  }
73
234
  : {},
74
- value: options.length === 1 && returnAmount ? options[0].amount : options.map(option => returnAmount ? option.amount : option).join(', '),
235
+ value: options.map(option => returnAmount ? option.amount : option.option.name).join(', '),
75
236
  };
76
237
  },
77
238
  }];
@@ -178,6 +339,16 @@ const sheet: XlsxTransformerSheet<PlatformMember, PlatformRegistration> = {
178
339
  };
179
340
  },
180
341
  },
342
+ {
343
+ id: 'group.type',
344
+ name: $t('4fda497f-b2d8-43ef-b08c-a3e4e0b472b4'),
345
+ width: 20,
346
+ getValue: (registration: PlatformRegistration) => {
347
+ return {
348
+ value: getGroupTypeName(registration.group.type),
349
+ };
350
+ },
351
+ },
181
352
  ],
182
353
  };
183
354
 
@@ -46,4 +46,28 @@ export class LimitedFilteredRequestHelper {
46
46
 
47
47
  return next;
48
48
  }
49
+
50
+ static fixInfiniteLoadingLoopWithTransform<R, T>({ request, results, sorters, transformer }: { request: LimitedFilteredRequest; results: R[]; sorters: SQLSortDefinitions<T>; transformer: (r: R) => T }): LimitedFilteredRequest | undefined {
51
+ let next: LimitedFilteredRequest | undefined;
52
+
53
+ if (results.length >= request.limit) {
54
+ const lastObject = transformer(results[results.length - 1]);
55
+ const nextFilter = getSortFilter(lastObject, sorters, request.sort);
56
+
57
+ next = new LimitedFilteredRequest({
58
+ filter: request.filter,
59
+ pageFilter: nextFilter,
60
+ sort: request.sort,
61
+ limit: request.limit,
62
+ search: request.search,
63
+ });
64
+
65
+ if (JSON.stringify(nextFilter) === JSON.stringify(request.pageFilter)) {
66
+ console.error('Found infinite loading loop for', request);
67
+ next = undefined;
68
+ }
69
+ }
70
+
71
+ return next;
72
+ }
49
73
  }
@@ -3,17 +3,29 @@ import { BalanceItemType, MemberWithRegistrationsBlob } from '@stamhoofd/structu
3
3
 
4
4
  export class MemberCharger {
5
5
  static async chargeMany({ chargingOrganizationId, membersToCharge, price, amount, description, dueAt, createdAt }: { chargingOrganizationId: string; membersToCharge: MemberWithRegistrationsBlob[]; price: number; amount?: number; description: string; dueAt: Date | null; createdAt: Date | null }) {
6
- const balanceItems = membersToCharge.map(memberBeingCharged => MemberCharger.createBalanceItem({
6
+ await Promise.all(membersToCharge.map(memberToCharge => MemberCharger.charge({
7
7
  price,
8
8
  amount,
9
9
  description,
10
10
  chargingOrganizationId,
11
- memberBeingCharged,
11
+ memberToCharge,
12
12
  dueAt,
13
13
  createdAt,
14
- }));
14
+ })));
15
+ }
16
+
17
+ static async charge({ chargingOrganizationId, memberToCharge, price, amount, description, dueAt, createdAt }: { chargingOrganizationId: string; memberToCharge: MemberWithRegistrationsBlob; price: number; amount?: number; description: string; dueAt: Date | null; createdAt: Date | null }) {
18
+ const balanceItem = MemberCharger.createBalanceItem({
19
+ price,
20
+ amount,
21
+ description,
22
+ chargingOrganizationId,
23
+ memberBeingCharged: memberToCharge,
24
+ dueAt,
25
+ createdAt,
26
+ });
15
27
 
16
- await Promise.all(balanceItems.map(balanceItem => balanceItem.save()));
28
+ await balanceItem.save();
17
29
  }
18
30
 
19
31
  private static createBalanceItem({ price, amount, description, chargingOrganizationId, memberBeingCharged, dueAt, createdAt }: { price: number; amount?: number; description: string; chargingOrganizationId: string; memberBeingCharged: MemberWithRegistrationsBlob; dueAt: Date | null; createdAt: Date | null }): BalanceItem {
@@ -0,0 +1,14 @@
1
+ import { SQLColumnExpression, SQLExpression, SQLExpressionOptions, SQLQuery, SQLTranslatedStringHelper } from '@stamhoofd/sql';
2
+ import { Language } from '@stamhoofd/structures';
3
+
4
+ export class SQLTranslatedString implements SQLExpression {
5
+ private helper: SQLTranslatedStringHelper;
6
+
7
+ constructor(columnExpression: SQLColumnExpression, path: string) {
8
+ this.helper = new SQLTranslatedStringHelper(columnExpression, path, () => Language.English);
9
+ }
10
+
11
+ getSQL(options?: SQLExpressionOptions): SQLQuery {
12
+ return this.helper.getSQL(options);
13
+ }
14
+ }
@@ -1,5 +1,5 @@
1
1
  import { OrganizationTag } from '@stamhoofd/structures';
2
- import { TagHelper } from './TagHelper';
2
+ import { TagHelper } from './TagHelper.js';
3
3
 
4
4
  // todo: move tests for methods of shared package to shared package
5
5
  describe('TagHelper', () => {
@@ -123,13 +123,13 @@ describe('TagHelper', () => {
123
123
 
124
124
  // assert
125
125
  expect(result).toHaveLength(6);
126
- expect(result).toInclude('id5');
127
- expect(result).toInclude('id3');
128
- expect(result).toInclude('id4');
129
- expect(result).toInclude('id6');
130
- expect(result).toInclude('id7');
131
- expect(result).toInclude('id0');
132
- expect(result).not.toInclude('unknownTagId');
126
+ expect(result).toContain('id5');
127
+ expect(result).toContain('id3');
128
+ expect(result).toContain('id4');
129
+ expect(result).toContain('id6');
130
+ expect(result).toContain('id7');
131
+ expect(result).toContain('id0');
132
+ expect(result).not.toContain('unknownTagId');
133
133
  });
134
134
  });
135
135
 
@@ -199,7 +199,7 @@ describe('TagHelper', () => {
199
199
  // assert
200
200
  expect(tag5.childTags).toHaveLength(0);
201
201
  expect(tag7.childTags).toHaveLength(3);
202
- expect(tag7.childTags).not.toInclude('doesNotExist1');
202
+ expect(tag7.childTags).not.toContain('doesNotExist1');
203
203
  });
204
204
 
205
205
  it('should return array of tags in the correct order', () => {
@@ -1,5 +1,5 @@
1
1
  import { IPaginatedResponse, LimitedFilteredRequest } from '@stamhoofd/structures';
2
- import { FileSignService } from '../services/FileSignService';
2
+ import { FileSignService } from '../services/FileSignService.js';
3
3
 
4
4
  export function fetchToAsyncIterator<T>(
5
5
  initialFilter: LimitedFilteredRequest,
@@ -0,0 +1,49 @@
1
+ import { CachedBalance, Registration } from '@stamhoofd/models';
2
+ import { SQL, SQLAlias, SQLCalculation, SQLNamedExpression, SQLPlusSign, SQLSelectAs, SQLSum } from '@stamhoofd/sql';
3
+
4
+ export const memberCachedBalanceForOrganizationJoin = SQL.leftJoin(
5
+ SQL.select('objectId', 'organizationId',
6
+ new SQLSelectAs(
7
+ new SQLSum(
8
+ SQL.column('amountOpen'),
9
+ ),
10
+ new SQLAlias('amountOpen'),
11
+ ),
12
+ )
13
+ .from(CachedBalance.table)
14
+ .where(SQL.column(CachedBalance.table, 'objectType'), 'member')
15
+ .groupBy(SQL.column(CachedBalance.table, 'objectId'), SQL.column(CachedBalance.table, 'organizationId')).as('memberCachedBalance') as SQLNamedExpression, 'memberCachedBalance',
16
+ )
17
+ .where(SQL.column('objectId'), SQL.column(Registration.table, 'memberId'))
18
+ .andWhere(SQL.column('organizationId'), SQL.column(Registration.table, 'organizationId'));
19
+
20
+ export const registrationCachedBalanceJoin = SQL.leftJoin(
21
+ SQL.select('objectId', 'organizationId',
22
+ new SQLSelectAs(
23
+ new SQLSum(
24
+ new SQLCalculation(
25
+ SQL.column('amountOpen'),
26
+ new SQLPlusSign(),
27
+ SQL.column('amountPending'),
28
+ ),
29
+ ),
30
+ new SQLAlias('toPay'),
31
+ ),
32
+ new SQLSelectAs(
33
+ new SQLSum(
34
+ new SQLCalculation(
35
+ SQL.column('amountOpen'),
36
+ new SQLPlusSign(),
37
+ SQL.column('amountPaid'),
38
+ new SQLPlusSign(),
39
+ SQL.column('amountPending'),
40
+ ),
41
+ ),
42
+ new SQLAlias('price'),
43
+ ),
44
+ )
45
+ .from(CachedBalance.table)
46
+ .where(SQL.column(CachedBalance.table, 'objectType'), 'registration')
47
+ .groupBy(SQL.column(CachedBalance.table, 'objectId'), SQL.column(CachedBalance.table, 'organizationId')).as('registrationCachedBalance') as SQLNamedExpression, 'registrationCachedBalance',
48
+ )
49
+ .where(SQL.column('objectId'), SQL.column(Registration.table, 'id'));
@@ -0,0 +1,179 @@
1
+ import { DocumentTemplate, DocumentTemplateFactory, GroupFactory, OrganizationFactory, RegistrationPeriod, RegistrationPeriodFactory } from '@stamhoofd/models';
2
+ import { migrateDocumentYears } from './1765896674-document-update-year.js';
3
+
4
+ describe('migration.document-update-year', () => {
5
+ describe('should use most frequent year of groups', () => {
6
+ test('groups with date in period', async () => {
7
+ const organization = await new OrganizationFactory({
8
+ }).create();
9
+
10
+ const period1 = await new RegistrationPeriodFactory({
11
+ startDate: new Date(2021, 0, 1),
12
+ endDate: new Date(2021, 11, 31),
13
+ organization,
14
+ }).create();
15
+
16
+ organization.periodId = period1.id;
17
+ await organization.save();
18
+
19
+ const period2 = await new RegistrationPeriodFactory({
20
+ startDate: new Date(2019, 0, 1),
21
+ endDate: new Date(2019, 11, 31),
22
+ organization,
23
+ }).create();
24
+
25
+ const createGroup = async (startDate: Date, endDate: Date, period: RegistrationPeriod) => {
26
+ const group = await new GroupFactory({ organization, period }).create();
27
+ group.settings.startDate = startDate;
28
+ group.settings.endDate = endDate;
29
+ await group.save();
30
+ return group;
31
+ };
32
+
33
+ // groups
34
+ const group1 = await createGroup(new Date(2021, 0, 1), new Date(2021, 11, 31), period1);
35
+ const group2 = await createGroup(new Date(2021, 0, 1), new Date(2021, 11, 31), period1);
36
+ const group3 = await createGroup(new Date(2019, 0, 1), new Date(2019, 11, 31), period2);
37
+
38
+ // document created in 2021
39
+ const document1 = await new DocumentTemplateFactory({
40
+ groups: [group1, group2, group3],
41
+ year: 0,
42
+ }).create();
43
+
44
+ document1.createdAt = new Date(2022, 0, 1);
45
+ await document1.save();
46
+
47
+ // document created in 2020
48
+ const document2 = await new DocumentTemplateFactory({
49
+ groups: [group1, group2, group3],
50
+ year: 0,
51
+ }).create();
52
+
53
+ document2.createdAt = new Date(2021, 0, 1);
54
+ await document2.save();
55
+
56
+ // document created in 2019
57
+ const document3 = await new DocumentTemplateFactory({
58
+ groups: [group1, group2, group3],
59
+ year: 0,
60
+ }).create();
61
+
62
+ document3.createdAt = new Date(2020, 0, 1);
63
+ await document3.save();
64
+
65
+ // act
66
+ await migrateDocumentYears();
67
+
68
+ // assert
69
+ const updatedDocument1 = await DocumentTemplate.getByID(document1.id);
70
+ const updatedDocument2 = await DocumentTemplate.getByID(document2.id);
71
+ const updatedDocument3 = await DocumentTemplate.getByID(document3.id);
72
+
73
+ // take most frequent year and prefer date of document creation
74
+ expect(updatedDocument1?.year).toBe(2021);
75
+ expect(updatedDocument2?.year).toBe(2021);
76
+ expect(updatedDocument3?.year).toBe(2021);
77
+ });
78
+
79
+ test('groups overlapping period', async () => {
80
+ const organization = await new OrganizationFactory({
81
+ }).create();
82
+
83
+ const period1 = await new RegistrationPeriodFactory({
84
+ startDate: new Date(2021, 0, 1),
85
+ endDate: new Date(2021, 11, 31),
86
+ organization,
87
+ }).create();
88
+
89
+ organization.periodId = period1.id;
90
+ await organization.save();
91
+
92
+ const period2 = await new RegistrationPeriodFactory({
93
+ startDate: new Date(2020, 0, 1),
94
+ endDate: new Date(2020, 11, 31),
95
+ organization,
96
+ }).create();
97
+
98
+ const period3 = await new RegistrationPeriodFactory({
99
+ startDate: new Date(2019, 0, 1),
100
+ endDate: new Date(2019, 11, 31),
101
+ organization,
102
+ }).create();
103
+
104
+ const createGroup = async (startDate: Date, endDate: Date, period: RegistrationPeriod) => {
105
+ const group = await new GroupFactory({ organization, period }).create();
106
+ group.settings.startDate = startDate;
107
+ group.settings.endDate = endDate;
108
+ await group.save();
109
+ return group;
110
+ };
111
+
112
+ // groups
113
+ const group1 = await createGroup(new Date(2020, 6, 1), new Date(2021, 5, 30), period1);
114
+ const group2 = await createGroup(new Date(2020, 7, 5), new Date(2021, 4, 3), period1);
115
+ const group3 = await createGroup(new Date(2018, 5, 1), new Date(2019, 10, 14), period3);
116
+
117
+ // document created in 2021
118
+ const document1 = await new DocumentTemplateFactory({
119
+ groups: [group1, group2, group3],
120
+ year: 0,
121
+ }).create();
122
+
123
+ document1.createdAt = new Date(2022, 0, 1);
124
+ await document1.save();
125
+
126
+ // document created in 2020
127
+ const document2 = await new DocumentTemplateFactory({
128
+ groups: [group1, group2, group3],
129
+ year: 0,
130
+ }).create();
131
+
132
+ document2.createdAt = new Date(2021, 0, 1);
133
+ await document2.save();
134
+
135
+ // document created in 2019
136
+ const document3 = await new DocumentTemplateFactory({
137
+ groups: [group1, group2, group3],
138
+ year: 0,
139
+ }).create();
140
+
141
+ document3.createdAt = new Date(2020, 0, 1);
142
+ await document3.save();
143
+
144
+ // act
145
+ await migrateDocumentYears();
146
+
147
+ // assert
148
+ const updatedDocument1 = await DocumentTemplate.getByID(document1.id);
149
+ const updatedDocument2 = await DocumentTemplate.getByID(document2.id);
150
+ const updatedDocument3 = await DocumentTemplate.getByID(document3.id);
151
+
152
+ // take most frequent year and prefer date of document creation
153
+ expect(updatedDocument1?.year).toBe(2021);
154
+ // should take 2020 because document was created in 2020
155
+ expect(updatedDocument2?.year).toBe(2020);
156
+ expect(updatedDocument3?.year).toBe(2021);
157
+ });
158
+ });
159
+
160
+ test('no groups should use year before creation', async () => {
161
+ // create document
162
+ const document = await new DocumentTemplateFactory({
163
+ groups: [],
164
+ year: 0,
165
+ }).create();
166
+
167
+ document.createdAt = new Date(2025, 0, 1);
168
+ await document.save();
169
+
170
+ // act
171
+ await migrateDocumentYears();
172
+
173
+ // assert
174
+ const updatedDocument = await DocumentTemplate.getByID(document.id);
175
+
176
+ // year should be year of creation
177
+ expect(updatedDocument?.year).toBe(2024);
178
+ });
179
+ });
@@ -0,0 +1,75 @@
1
+ import { Migration } from '@simonbackx/simple-database';
2
+ import { DocumentTemplate, Group } from '@stamhoofd/models';
3
+ import { SQL } from '@stamhoofd/sql';
4
+
5
+ export async function migrateDocumentYears() {
6
+ let c = 0;
7
+ const totalDocuments = await DocumentTemplate.select().count();
8
+
9
+ for await (const document of DocumentTemplate.select().all()) {
10
+ c++;
11
+ if (c % 1000 === 0) {
12
+ console.log('Processed', c, 'of', totalDocuments);
13
+ }
14
+
15
+ // check
16
+ if (!document.year) {
17
+ // by default use the year before creation
18
+ let year: number = document.createdAt.getFullYear() - 1;
19
+
20
+ const groups = document.privateSettings.groups;
21
+ const groupIds: string[] = [...new Set(groups.map(g => g.group.id))];
22
+
23
+ if (groupIds.length > 0) {
24
+ const query = Group.select().where(SQL.where('id', groupIds));
25
+ const groupModels = await query.fetch();
26
+
27
+ // count the number of groups per year
28
+ const yearMap = new Map<number, number>();
29
+
30
+ for (const group of groupModels) {
31
+ const startYear = group.settings.startDate.getFullYear();
32
+ const endYear = group.settings.endDate.getFullYear();
33
+
34
+ for (let y = startYear; y <= endYear; y++) {
35
+ const count = yearMap.get(y) ?? 0;
36
+ yearMap.set(y, count + 1);
37
+ }
38
+ }
39
+
40
+ // find the year with the highest count
41
+ let topYear = 0;
42
+ let topCount = 0;
43
+ const yearBeforeCreation = document.createdAt.getFullYear() - 1;
44
+
45
+ for (const [year, count] of yearMap) {
46
+ if (count > topCount
47
+ // prefer the year before creation
48
+ || (count === topCount && year === yearBeforeCreation)
49
+ // next prefer the most recent year
50
+ || (topYear !== yearBeforeCreation && year > topYear)
51
+ ) {
52
+ topYear = year;
53
+ topCount = count;
54
+ }
55
+ }
56
+
57
+ // use the year with the highest count
58
+ if (topCount > 0) {
59
+ year = topYear;
60
+ }
61
+ }
62
+
63
+ document.year = year;
64
+ await document.save();
65
+ }
66
+ }
67
+ };
68
+
69
+ export default new Migration(async () => {
70
+ process.stdout.write('\n');
71
+ console.log('Start updating year of document templates.');
72
+ await migrateDocumentYears();
73
+
74
+ return Promise.resolve();
75
+ });