@stamhoofd/backend 2.123.1 → 2.124.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.123.1",
3
+ "version": "2.124.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.123.1",
61
- "@stamhoofd/backend-i18n": "2.123.1",
62
- "@stamhoofd/backend-middleware": "2.123.1",
63
- "@stamhoofd/crons": "2.123.1",
64
- "@stamhoofd/email": "2.123.1",
65
- "@stamhoofd/excel-writer": "2.123.1",
66
- "@stamhoofd/logging": "2.123.1",
67
- "@stamhoofd/models": "2.123.1",
68
- "@stamhoofd/object-differ": "2.123.1",
69
- "@stamhoofd/queues": "2.123.1",
70
- "@stamhoofd/sql": "2.123.1",
71
- "@stamhoofd/structures": "2.123.1",
72
- "@stamhoofd/types": "2.123.1",
73
- "@stamhoofd/utility": "2.123.1",
60
+ "@stamhoofd/backend-env": "2.124.0",
61
+ "@stamhoofd/backend-i18n": "2.124.0",
62
+ "@stamhoofd/backend-middleware": "2.124.0",
63
+ "@stamhoofd/crons": "2.124.0",
64
+ "@stamhoofd/email": "2.124.0",
65
+ "@stamhoofd/excel-writer": "2.124.0",
66
+ "@stamhoofd/logging": "2.124.0",
67
+ "@stamhoofd/models": "2.124.0",
68
+ "@stamhoofd/object-differ": "2.124.0",
69
+ "@stamhoofd/queues": "2.124.0",
70
+ "@stamhoofd/sql": "2.124.0",
71
+ "@stamhoofd/structures": "2.124.0",
72
+ "@stamhoofd/types": "2.124.0",
73
+ "@stamhoofd/utility": "2.124.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.123.1",
94
+ "@stamhoofd/test-utils": "2.124.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": "4e2300b489aa1df8eba6d6223042b8e881a5fef2"
110
+ "gitHead": "7783583e6555a28cce7c7ad26cb223a4623ca005"
111
111
  }
@@ -0,0 +1,107 @@
1
+ import { Request } from '@simonbackx/simple-endpoints';
2
+ import type { Organization, Token } from '@stamhoofd/models';
3
+ import { OrganizationFactory, UserFactory } from '@stamhoofd/models';
4
+ import { LimitedFilteredRequest, PermissionLevel, Permissions } from '@stamhoofd/structures';
5
+ import { TestUtils } from '@stamhoofd/test-utils';
6
+ import { testServer } from '../../../../tests/helpers/TestServer.js';
7
+ import { initPlatformAdmin } from '../../../../tests/init/index.js';
8
+ import { initMembershipOrganization } from '../../../../tests/init/initMembershipOrganization.js';
9
+ import { GetOrganizationsEndpoint } from './GetOrganizationsEndpoint.js';
10
+
11
+ const baseUrl = `/admin/organizations`;
12
+ const endpoint = new GetOrganizationsEndpoint();
13
+
14
+ describe('Endpoint.GetOrganizationsEndpoint', () => {
15
+ beforeEach(async () => {
16
+ TestUtils.setEnvironment('userMode', 'organization');
17
+ });
18
+
19
+ beforeAll(async () => {
20
+ await initMembershipOrganization();
21
+ });
22
+
23
+ const search = async (searchTerm: string, token: Token): Promise<string[]> => {
24
+ const request = Request.get({
25
+ path: baseUrl,
26
+ // Platform admins are not scoped to an organization, so no host is required
27
+ host: '',
28
+ query: new LimitedFilteredRequest({
29
+ search: searchTerm,
30
+ limit: 100,
31
+ }),
32
+ headers: {
33
+ authorization: 'Bearer ' + token.accessToken,
34
+ },
35
+ });
36
+ const response = await testServer.test(endpoint, request);
37
+ return response.body.results.map(o => o.id);
38
+ };
39
+
40
+ // Creates an admin (user with permissions) for the given organization
41
+ const createAdmin = async (organization: Organization, data: { firstName?: string; lastName?: string; email?: string }) => {
42
+ return await new UserFactory({
43
+ organization,
44
+ permissions: Permissions.create({ level: PermissionLevel.Full }),
45
+ ...data,
46
+ }).create();
47
+ };
48
+
49
+ test('Searches organizations by name', async () => {
50
+ const { adminToken } = await initPlatformAdmin();
51
+ const organization = await new OrganizationFactory({ name: 'Bombilcious Tennis Club' }).create();
52
+
53
+ expect(await search('Bombilcious Tennis Club', adminToken)).toContain(organization.id);
54
+ });
55
+
56
+ test('Searches organizations by uri', async () => {
57
+ const { adminToken } = await initPlatformAdmin();
58
+ const organization = await new OrganizationFactory({ }).create();
59
+
60
+ expect(await search(organization.uri, adminToken)).toContain(organization.id);
61
+ });
62
+
63
+ test('Does not search organizations by admin name', async () => {
64
+ const { adminToken } = await initPlatformAdmin();
65
+ const organization = await new OrganizationFactory({ name: 'Halfnamed Club' }).create();
66
+ await createAdmin(organization, { firstName: 'Zwervinkel' });
67
+
68
+ expect(await search('Zwervinkel', adminToken)).not.toContain(organization.id);
69
+ });
70
+
71
+ test('Does not match users without permissions (non-admins)', async () => {
72
+ const { adminToken } = await initPlatformAdmin();
73
+ const organization = await new OrganizationFactory({ name: 'Memberonly Club' }).create();
74
+
75
+ // A regular user (no permissions) linked to the organization
76
+ await new UserFactory({ organization, firstName: 'Solitary', lastName: 'Pangolin' }).create();
77
+
78
+ expect(await search('Solitary Pangolin', adminToken)).not.toContain(organization.id);
79
+ });
80
+
81
+ test('Searches admins by email', async () => {
82
+ const { adminToken } = await initPlatformAdmin();
83
+ const organization = await new OrganizationFactory({ name: 'Mailable Club' }).create();
84
+ await createAdmin(organization, { email: 'findme@admin-search-test.com' });
85
+
86
+ expect(await search('findme@admin-search-test.com', adminToken)).toContain(organization.id);
87
+ });
88
+
89
+ test('Does search the organization name when the search contains an @', async () => {
90
+ const { adminToken } = await initPlatformAdmin();
91
+ // The organization name itself contains an @, but there is no admin with a matching email
92
+ const organization = await new OrganizationFactory({ name: 'weird@orgname-test.com' }).create();
93
+
94
+ expect(await search('weird@orgname-test.com', adminToken)).toContain(organization.id);
95
+ });
96
+
97
+ test('Does not match admins of other organizations by email', async () => {
98
+ const { adminToken } = await initPlatformAdmin();
99
+ const organization = await new OrganizationFactory({ name: 'Owner Club' }).create();
100
+ const otherOrganization = await new OrganizationFactory({ name: 'Bystander Club' }).create();
101
+ await createAdmin(organization, { email: 'unique-owner@admin-search-test.com' });
102
+
103
+ const results = await search('unique-owner@admin-search-test.com', adminToken);
104
+ expect(results).toContain(organization.id);
105
+ expect(results).not.toContain(otherOrganization.id);
106
+ });
107
+ });
@@ -72,25 +72,47 @@ export class GetOrganizationsEndpoint extends Endpoint<Params, Query, Body, Resp
72
72
  }
73
73
 
74
74
  if (q.search) {
75
- let searchFilter: StamhoofdFilter | null = null;
76
-
77
- // todo: auto detect e-mailaddresses and search on admins
78
- searchFilter = {
79
- $or: [
80
- {
81
- name: {
82
- $contains: q.search,
75
+ let searchFilter: StamhoofdFilter;
76
+
77
+ if (q.search.includes('@')) {
78
+ // Automatically search in the email addresses of admins instead of the organization name
79
+ searchFilter = {
80
+ $or: [
81
+ {
82
+ name: {
83
+ $contains: q.search,
84
+ },
83
85
  },
84
- },
85
- {
86
- uri: q.search,
87
- },
88
- ],
89
- };
90
-
91
- if (searchFilter) {
92
- query.where(await compileToSQLFilter(searchFilter, filterCompilers));
86
+ {
87
+ uri: q.search,
88
+ },
89
+ {
90
+ admins: {
91
+ $elemMatch: {
92
+ email: {
93
+ $contains: q.search,
94
+ },
95
+ },
96
+ },
97
+ },
98
+ ],
99
+ };
100
+ } else {
101
+ searchFilter = {
102
+ $or: [
103
+ {
104
+ name: {
105
+ $contains: q.search,
106
+ },
107
+ },
108
+ {
109
+ uri: q.search,
110
+ },
111
+ ],
112
+ };
93
113
  }
114
+
115
+ query.where(await compileToSQLFilter(searchFilter, filterCompilers));
94
116
  }
95
117
 
96
118
  if (q instanceof LimitedFilteredRequest) {
@@ -146,7 +146,7 @@ export class PatchWebshopEndpoint extends Endpoint<Params, Query, Body, Response
146
146
  throw new SimpleError({
147
147
  code: 'invalid_field',
148
148
  message: 'domainUri is reserved for internal webshop routes',
149
- human: $t("%1a4"),
149
+ human: $t('%1a4'),
150
150
  field: 'customUrl',
151
151
  });
152
152
  }
@@ -194,7 +194,7 @@ export class PatchWebshopEndpoint extends Endpoint<Params, Query, Body, Response
194
194
  throw new SimpleError({
195
195
  code: 'invalid_field',
196
196
  message: 'Uri is reserved for internal webshop routes',
197
- human: $t("%1a4"),
197
+ human: $t('%1a4'),
198
198
  field: 'uri',
199
199
  });
200
200
  }
@@ -302,6 +302,12 @@ const sheet: XlsxTransformerSheet<PlatformMember, PlatformMember> = {
302
302
  }),
303
303
  },
304
304
 
305
+ // Group categories
306
+ XlsxTransformerColumnHelper.createGroupCategoryColumns<PlatformMember>({
307
+ matchId: 'groupCategory',
308
+ getMember: member => member,
309
+ }),
310
+
305
311
  // Registration records
306
312
  {
307
313
  match(id) {
@@ -178,8 +178,8 @@ function createOrderItemPaymentRows(
178
178
  rows.push(
179
179
  createSyntheticBalanceItemPayment({
180
180
  source: item,
181
- customTitle: $t('Leveringskosten'),
182
- description: $t('Leveringskosten'),
181
+ customTitle: $t('%1eU'),
182
+ description: $t('%1eU'),
183
183
  amount: 1,
184
184
  price: order.data.deliveryPrice,
185
185
  }),
@@ -190,8 +190,8 @@ function createOrderItemPaymentRows(
190
190
  rows.push(
191
191
  createSyntheticBalanceItemPayment({
192
192
  source: item,
193
- customTitle: $t('Administratiekosten'),
194
- description: $t('Administratiekosten'),
193
+ customTitle: $t('%xK'),
194
+ description: $t('%xK'),
195
195
  amount: 1,
196
196
  price: order.data.administrationFee,
197
197
  }),
@@ -203,8 +203,8 @@ function createOrderItemPaymentRows(
203
203
  rows.push(
204
204
  createSyntheticBalanceItemPayment({
205
205
  source: item,
206
- customTitle: $t('Correctie bestelling'),
207
- description: $t('Correctie bestelling'),
206
+ customTitle: $t('%1eE'),
207
+ description: $t('%1eE'),
208
208
  amount: 1,
209
209
  price: difference,
210
210
  }),
@@ -226,8 +226,8 @@ function addOrderDiscountRows(
226
226
  rows.push(
227
227
  createSyntheticBalanceItemPayment({
228
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) }),
229
+ customTitle: $t('%1ei', { percentage: Formatter.percentage(order.data.percentageDiscount) }),
230
+ description: $t('%1ei', { percentage: Formatter.percentage(order.data.percentageDiscount) }),
231
231
  amount: 1,
232
232
  price: -appliedPercentageDiscount,
233
233
  }),
@@ -240,8 +240,8 @@ function addOrderDiscountRows(
240
240
  rows.push(
241
241
  createSyntheticBalanceItemPayment({
242
242
  source: item,
243
- customTitle: $t('Korting'),
244
- description: $t('Vaste korting'),
243
+ customTitle: $t('%176'),
244
+ description: $t('%1eM'),
245
245
  amount: 1,
246
246
  price: -fixedDiscount,
247
247
  }),
@@ -251,10 +251,10 @@ function addOrderDiscountRows(
251
251
 
252
252
  function getPartialOrderPaymentDescription(order: PaymentExportOrder): string {
253
253
  if (order.number !== null) {
254
- return $t('Gedeeltelijke betaling/terugbetaling voor bestelling #{number}', { number: order.number.toString() });
254
+ return $t('%1eO', { number: order.number.toString() });
255
255
  }
256
256
 
257
- return $t('Gedeeltelijke betaling/terugbetaling voor bestelling');
257
+ return $t('%1eH');
258
258
  }
259
259
 
260
260
  function createSyntheticBalanceItemPayment({
@@ -344,7 +344,7 @@ function getBalanceItemColumns(): XlsxTransformerColumn<PaymentWithItem>[] {
344
344
  },
345
345
  {
346
346
  id: 'balanceItem.title',
347
- name: $t('Titel'),
347
+ name: $t('%vC'),
348
348
  width: 40,
349
349
  getValue: (object: PaymentWithItem) => ({
350
350
  value: object.balanceItemPayment.customTitle || object.balanceItemPayment.balanceItem.itemTitle,
@@ -261,6 +261,11 @@ const sheet: XlsxTransformerSheet<PlatformMember, PlatformRegistration> = {
261
261
  ];
262
262
  },
263
263
  },
264
+ // Group categories
265
+ XlsxTransformerColumnHelper.createGroupCategoryColumns<PlatformRegistration>({
266
+ matchId: 'groupCategory',
267
+ getMember: registration => registration.member,
268
+ }),
264
269
  // recordAnswers
265
270
  {
266
271
  match(id) {
@@ -0,0 +1,169 @@
1
+ import type { XlsxTransformerConcreteColumn } from '@stamhoofd/excel-writer';
2
+ import type { PlatformMember } from '@stamhoofd/structures';
3
+ import {
4
+ Group,
5
+ GroupCategory,
6
+ GroupCategorySettings,
7
+ GroupSettings,
8
+ MemberDetails,
9
+ MembersBlob,
10
+ MemberWithRegistrationsBlob,
11
+ Organization,
12
+ OrganizationRegistrationPeriod,
13
+ OrganizationRegistrationPeriodSettings,
14
+ Platform,
15
+ PlatformFamily,
16
+ Registration,
17
+ RegistrationPeriod,
18
+ TranslatedString,
19
+ } from '@stamhoofd/structures';
20
+ import { XlsxTransformerColumnHelper } from './XlsxTransformerColumnHelper.js';
21
+
22
+ describe('XlsxTransformerColumnHelper', () => {
23
+ describe('createGroupCategoryColumns', () => {
24
+ function createGroup(organization: Organization, periodId: string, name: string) {
25
+ return Group.create({
26
+ organizationId: organization.id,
27
+ periodId,
28
+ settings: GroupSettings.create({
29
+ name: new TranslatedString(name),
30
+ }),
31
+ });
32
+ }
33
+
34
+ function setup() {
35
+ const period = RegistrationPeriod.create({});
36
+
37
+ // Build the organization with two categories, each holding a few groups
38
+ const organization = Organization.create({});
39
+
40
+ const kapoenen = createGroup(organization, period.id, 'Kapoenen');
41
+ const wouters = createGroup(organization, period.id, 'Wouters');
42
+ const jonggivers = createGroup(organization, period.id, 'Jonggivers');
43
+ const groepsleiding = createGroup(organization, period.id, 'Groepsleiding');
44
+ const takleiding = createGroup(organization, period.id, 'Takleiding');
45
+
46
+ const ageGroupsCategory = GroupCategory.create({
47
+ settings: GroupCategorySettings.create({ name: 'Leeftijdsgroepen' }),
48
+ groupIds: [kapoenen.id, wouters.id, jonggivers.id],
49
+ });
50
+ const leadershipCategory = GroupCategory.create({
51
+ settings: GroupCategorySettings.create({ name: 'Leiding' }),
52
+ groupIds: [groepsleiding.id, takleiding.id],
53
+ });
54
+ const root = GroupCategory.create({
55
+ id: 'root',
56
+ categoryIds: [ageGroupsCategory.id, leadershipCategory.id],
57
+ });
58
+
59
+ organization.period = OrganizationRegistrationPeriod.create({
60
+ period,
61
+ groups: [kapoenen, wouters, jonggivers, groepsleiding, takleiding],
62
+ settings: OrganizationRegistrationPeriodSettings.create({
63
+ categories: [root, ageGroupsCategory, leadershipCategory],
64
+ rootCategoryId: root.id,
65
+ }),
66
+ });
67
+
68
+ return { organization, period, ageGroupsCategory, leadershipCategory, groups: { kapoenen, wouters, jonggivers, groepsleiding, takleiding } };
69
+ }
70
+
71
+ function createMember(organization: Organization, registeredGroups: Group[]) {
72
+ const blob = MembersBlob.create({
73
+ organizations: [organization],
74
+ members: [
75
+ MemberWithRegistrationsBlob.create({
76
+ details: MemberDetails.create({ firstName: 'John', lastName: 'Doe' }),
77
+ registrations: registeredGroups.map(group => Registration.create({
78
+ organizationId: organization.id,
79
+ group,
80
+ groupPrice: group.settings.prices[0],
81
+ registeredAt: new Date(),
82
+ })),
83
+ }),
84
+ ],
85
+ });
86
+
87
+ const family = PlatformFamily.create(blob, { platform: Platform.create({}), contextOrganization: organization });
88
+ return family.members[0];
89
+ }
90
+
91
+ function getColumn(categoryId: string): XlsxTransformerConcreteColumn<PlatformMember> {
92
+ const column = XlsxTransformerColumnHelper.createGroupCategoryColumns<PlatformMember>({
93
+ matchId: 'groupCategory',
94
+ getMember: member => member,
95
+ });
96
+
97
+ if (!('match' in column)) {
98
+ throw new Error('Expected a match column');
99
+ }
100
+
101
+ const matched = column.match(`groupCategory.${categoryId}`);
102
+ expect(matched).toBeDefined();
103
+ expect(matched).toHaveLength(1);
104
+ return matched![0];
105
+ }
106
+
107
+ it('lists the groups a member is registered for within a category', () => {
108
+ const { organization, ageGroupsCategory, leadershipCategory, groups } = setup();
109
+ const member = createMember(organization, [groups.kapoenen, groups.jonggivers, groups.groepsleiding]);
110
+
111
+ expect(getColumn(ageGroupsCategory.id).getValue(member).value).toBe('Jonggivers, Kapoenen');
112
+ expect(getColumn(leadershipCategory.id).getValue(member).value).toBe('Groepsleiding');
113
+ });
114
+
115
+ it('returns an empty value when the member has no registrations in the category', () => {
116
+ const { organization, leadershipCategory, groups } = setup();
117
+ const member = createMember(organization, [groups.kapoenen]);
118
+
119
+ expect(getColumn(leadershipCategory.id).getValue(member).value).toBe('');
120
+ });
121
+
122
+ it('does not match ids without a category id', () => {
123
+ const column = XlsxTransformerColumnHelper.createGroupCategoryColumns<PlatformMember>({
124
+ matchId: 'groupCategory',
125
+ getMember: member => member,
126
+ });
127
+
128
+ if (!('match' in column)) {
129
+ throw new Error('Expected a match column');
130
+ }
131
+
132
+ expect(column.match('groupCategory')).toBeUndefined();
133
+ expect(column.match('groupCategory.')).toBeUndefined();
134
+ expect(column.match('somethingElse.123')).toBeUndefined();
135
+ });
136
+
137
+ it('resolves groups in nested subcategories', () => {
138
+ const period = RegistrationPeriod.create({});
139
+ const organization = Organization.create({});
140
+
141
+ const kapoenen = createGroup(organization, period.id, 'Kapoenen');
142
+ const welpen = createGroup(organization, period.id, 'Welpen');
143
+
144
+ const subCategory = GroupCategory.create({
145
+ settings: GroupCategorySettings.create({ name: 'Jongste tak' }),
146
+ groupIds: [welpen.id],
147
+ });
148
+ const ageGroupsCategory = GroupCategory.create({
149
+ settings: GroupCategorySettings.create({ name: 'Leeftijdsgroepen' }),
150
+ groupIds: [kapoenen.id],
151
+ categoryIds: [subCategory.id],
152
+ });
153
+ const root = GroupCategory.create({ id: 'root', categoryIds: [ageGroupsCategory.id] });
154
+
155
+ organization.period = OrganizationRegistrationPeriod.create({
156
+ period,
157
+ groups: [kapoenen, welpen],
158
+ settings: OrganizationRegistrationPeriodSettings.create({
159
+ categories: [root, ageGroupsCategory, subCategory],
160
+ rootCategoryId: root.id,
161
+ }),
162
+ });
163
+
164
+ const member = createMember(organization, [kapoenen, welpen]);
165
+
166
+ expect(getColumn(ageGroupsCategory.id).getValue(member).value).toBe('Kapoenen, Welpen');
167
+ });
168
+ });
169
+ });
@@ -1,7 +1,8 @@
1
1
  import type { XlsxTransformerColumn, XlsxTransformerConcreteColumn } from '@stamhoofd/excel-writer';
2
2
  import { isXlsxTransformerConcreteColumn } from '@stamhoofd/excel-writer';
3
- import type { Address, Parent, PlatformMember, RecordAnswer, RecordSettings } from '@stamhoofd/structures';
3
+ import type { Address, GroupCategory, Parent, PlatformMember, RecordAnswer, RecordSettings } from '@stamhoofd/structures';
4
4
  import { CountryHelper, ParentTypeHelper, RecordCategory, RecordType } from '@stamhoofd/structures';
5
+ import { Formatter } from '@stamhoofd/utility';
5
6
 
6
7
  export class XlsxTransformerColumnHelper {
7
8
  static formatBoolean(value: boolean | undefined | null): string {
@@ -242,6 +243,77 @@ export class XlsxTransformerColumnHelper {
242
243
  };
243
244
  }
244
245
 
246
+ /**
247
+ * Creates a dynamic column for a group category. The matched id is `${matchId}.${categoryId}`, where categoryId
248
+ * refers to a category in the current period of the organization the member is registered at. The value is a
249
+ * comma separated list of all the groups the member is registered for in the current period that belong to that
250
+ * category (including its subcategories).
251
+ */
252
+ static createGroupCategoryColumns<T>({ matchId, getMember }: { matchId: string; getMember: (object: T) => PlatformMember }): XlsxTransformerColumn<T> {
253
+ return {
254
+ match(id) {
255
+ if (!id.startsWith(matchId + '.')) {
256
+ return;
257
+ }
258
+
259
+ const categoryId = id.substring(matchId.length + 1);
260
+ if (!categoryId) {
261
+ return;
262
+ }
263
+
264
+ return [
265
+ {
266
+ id: `${matchId}.${categoryId}`,
267
+ name: $t(`%M2`),
268
+ width: 40,
269
+ getValue: (object: T) => {
270
+ const member = getMember(object);
271
+
272
+ for (const organization of member.organizations) {
273
+ const categories = organization.period.settings.categories;
274
+ if (!categories.find(c => c.id === categoryId)) {
275
+ continue;
276
+ }
277
+
278
+ const groupIds = XlsxTransformerColumnHelper.collectGroupIdsForCategory(categoryId, categories);
279
+ const registrations = member.filterRegistrations({ groupIds, organizationId: organization.id });
280
+ const names = Formatter.uniqueArray(registrations.map(r => r.group.settings.name.toString())).sort();
281
+
282
+ return {
283
+ value: names.join(', '),
284
+ };
285
+ }
286
+
287
+ return {
288
+ value: '',
289
+ };
290
+ },
291
+ },
292
+ ];
293
+ },
294
+ };
295
+ }
296
+
297
+ /**
298
+ * Recursively collects all group ids that belong to a category and its subcategories.
299
+ */
300
+ private static collectGroupIdsForCategory(categoryId: string, categories: GroupCategory[], seen = new Set<string>()): string[] {
301
+ if (seen.has(categoryId)) {
302
+ return [];
303
+ }
304
+ seen.add(categoryId);
305
+
306
+ const category = categories.find(c => c.id === categoryId);
307
+ if (!category) {
308
+ return [];
309
+ }
310
+
311
+ return [
312
+ ...category.groupIds,
313
+ ...category.categoryIds.flatMap(id => XlsxTransformerColumnHelper.collectGroupIdsForCategory(id, categories, seen)),
314
+ ];
315
+ }
316
+
245
317
  /**
246
318
  * Makes it possible to reuse an XlsxTransformerColumn, for example member columns exist, PlatformRegistration has
247
319
  * a property member, so we can reuse the member columns for PlatformRegistration.
@@ -157,7 +157,7 @@ function handlePermissionRoleForGroup(organization: Organization, group: Group,
157
157
  return;
158
158
  }
159
159
 
160
- specificGroupPermissions.merge(convertedRessourcePermissions);
160
+ specificGroupPermissions.add(convertedRessourcePermissions);
161
161
  return;
162
162
  }
163
163
 
@@ -209,7 +209,7 @@ function handlePermissionRoleForGroupCategory(organization: Organization, oldCat
209
209
  return;
210
210
  }
211
211
 
212
- specificGroupCategoryPermissions.merge(convertedRessourcePermissions);
212
+ specificGroupCategoryPermissions.add(convertedRessourcePermissions);
213
213
  return;
214
214
  }
215
215
 
@@ -261,7 +261,7 @@ function handlePermissionRoleForWebshop(organization: Organization, webshop: Web
261
261
  return;
262
262
  }
263
263
 
264
- specificWebshopPermissions.merge(convertedRessourcePermissions);
264
+ specificWebshopPermissions.add(convertedRessourcePermissions);
265
265
  return;
266
266
  }
267
267
 
@@ -1,5 +1,5 @@
1
1
  import type { SQLFilterDefinitions } from '@stamhoofd/sql';
2
- import { baseSQLFilterCompilers, createColumnFilter, SQL, SQLValueType } from '@stamhoofd/sql';
2
+ import { baseSQLFilterCompilers, createColumnFilter, createExistsFilter, SQL, SQLValueType } from '@stamhoofd/sql';
3
3
 
4
4
  export const balanceItemPaymentsCompilers: SQLFilterDefinitions = {
5
5
  ...baseSQLFilterCompilers,
@@ -30,5 +30,42 @@ export const balanceItemPaymentsCompilers: SQLFilterDefinitions = {
30
30
  type: SQLValueType.String,
31
31
  nullable: true,
32
32
  }),
33
+ type: createColumnFilter({
34
+ expression: SQL.column('balance_items', 'type'),
35
+ type: SQLValueType.String,
36
+ nullable: false,
37
+ }),
38
+ registration: createExistsFilter(
39
+ SQL.select()
40
+ .from(SQL.table('registrations'))
41
+ .where(
42
+ SQL.column('registrations', 'id'),
43
+ SQL.column('balance_items', 'registrationId'),
44
+ ),
45
+ {
46
+ ...baseSQLFilterCompilers,
47
+ groupId: createColumnFilter({
48
+ expression: SQL.column('registrations', 'groupId'),
49
+ type: SQLValueType.String,
50
+ nullable: false,
51
+ }),
52
+ },
53
+ ),
54
+ order: createExistsFilter(
55
+ SQL.select()
56
+ .from(SQL.table('webshop_orders'))
57
+ .where(
58
+ SQL.column('webshop_orders', 'id'),
59
+ SQL.column('balance_items', 'orderId'),
60
+ ),
61
+ {
62
+ ...baseSQLFilterCompilers,
63
+ webshopId: createColumnFilter({
64
+ expression: SQL.column('webshop_orders', 'webshopId'),
65
+ type: SQLValueType.String,
66
+ nullable: false,
67
+ }),
68
+ },
69
+ ),
33
70
  },
34
71
  };
@@ -188,6 +188,42 @@ export const organizationFilterCompilers: SQLFilterDefinitions = {
188
188
  }),
189
189
  },
190
190
  ),
191
+ admins: createExistsFilter(
192
+ SQL.select()
193
+ .from(SQL.table('users'))
194
+ .where(
195
+ SQL.column('users', 'organizationId'),
196
+ SQL.column('organizations', 'id'),
197
+ )
198
+ .where(SQL.column('users', 'permissions'), SQLWhereSign.NotEqual, new SQLNull()),
199
+ {
200
+ ...baseSQLFilterCompilers,
201
+ name: createColumnFilter({
202
+ expression: new SQLConcat(
203
+ SQL.coalesce(SQL.column('users', 'firstName'), new SQLScalar('')),
204
+ new SQLScalar(' '),
205
+ SQL.coalesce(SQL.column('users', 'lastName'), new SQLScalar('')),
206
+ ),
207
+ type: SQLValueType.String,
208
+ nullable: false,
209
+ }),
210
+ firstName: createColumnFilter({
211
+ expression: SQL.column('users', 'firstName'),
212
+ type: SQLValueType.String,
213
+ nullable: true,
214
+ }),
215
+ lastName: createColumnFilter({
216
+ expression: SQL.column('users', 'lastName'),
217
+ type: SQLValueType.String,
218
+ nullable: true,
219
+ }),
220
+ email: createColumnFilter({
221
+ expression: SQL.column('users', 'email'),
222
+ type: SQLValueType.String,
223
+ nullable: false,
224
+ }),
225
+ },
226
+ ),
191
227
  companies: createExistsFilter(
192
228
  /**
193
229
  * There is a bug in MySQL 8 that is fixed in 9.3
@@ -1,237 +0,0 @@
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
- }