@stamhoofd/backend 2.94.0 → 2.95.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 +10 -10
- package/src/endpoints/global/email/GetAdminEmailsEndpoint.ts +25 -36
- package/src/endpoints/global/email/GetEmailEndpoint.ts +2 -2
- package/src/endpoints/global/email/GetUserEmailsEndpoint.ts +163 -0
- package/src/endpoints/global/email-recipients/GetEmailRecipientsEndpoint.ts +13 -2
- package/src/endpoints/global/registration/GetUserDetailedPayableBalanceEndpoint.ts +1 -1
- package/src/endpoints/organization/dashboard/registration-periods/PatchOrganizationRegistrationPeriodsEndpoint.ts +25 -11
- package/src/seeds/1752848561-groups-registration-periods.ts +715 -0
- package/src/sql-filters/email-recipients.ts +25 -0
- package/src/sql-filters/emails.ts +35 -2
- package/src/seeds/1752848560-groups-registration-periods.ts +0 -768
|
@@ -0,0 +1,715 @@
|
|
|
1
|
+
import { Migration } from '@simonbackx/simple-database';
|
|
2
|
+
import { Group, Organization, OrganizationRegistrationPeriod, Registration, RegistrationPeriod } from '@stamhoofd/models';
|
|
3
|
+
import { CycleInformation, GroupCategory, GroupCategorySettings, GroupPrivateSettings, GroupSettings, GroupStatus, GroupType, RegistrationPeriodSettings, TranslatedString } from '@stamhoofd/structures';
|
|
4
|
+
|
|
5
|
+
export default new Migration(async () => {
|
|
6
|
+
if (STAMHOOFD.environment === 'test') {
|
|
7
|
+
console.log('skipped in tests');
|
|
8
|
+
return;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
if (STAMHOOFD.platformName.toLowerCase() !== 'stamhoofd') {
|
|
12
|
+
console.log('skipped for platform (only runs for Stamhoofd): ' + STAMHOOFD.platformName);
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const dryRun = false;
|
|
17
|
+
await start(dryRun);
|
|
18
|
+
|
|
19
|
+
if (dryRun) {
|
|
20
|
+
throw new Error('Migration did not finish because of dryRun');
|
|
21
|
+
}
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
const cycleIfMigrated = -99;
|
|
25
|
+
|
|
26
|
+
async function start(dryRun: boolean) {
|
|
27
|
+
for await (const organization of Organization.select().all()) {
|
|
28
|
+
const groups: Group[] = await Group.select().where('organizationId', organization.id).fetch();
|
|
29
|
+
|
|
30
|
+
if (groups.length === 0) {
|
|
31
|
+
await createDefaultRegistrationPeriod(organization, dryRun);
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (groups.some(g => g.cycle === cycleIfMigrated)) {
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
console.log('Organization: ' + organization.name);
|
|
40
|
+
|
|
41
|
+
// sort groups by start date
|
|
42
|
+
groups.sort((a, b) => a.settings.startDate.getTime() - b.settings.startDate.getTime());
|
|
43
|
+
|
|
44
|
+
const bestCurrentPeriodSpan = await calculateBestCurrentPeriodSpan(groups);
|
|
45
|
+
|
|
46
|
+
const allGroups: Group[] = await migrateGroups({ groups, organization, periodSpan: bestCurrentPeriodSpan }, dryRun);
|
|
47
|
+
|
|
48
|
+
// cleanup
|
|
49
|
+
for (const group of allGroups) {
|
|
50
|
+
await cleanupGroup(group, dryRun);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function cleanupGroup(group: Group, dryRun: boolean) {
|
|
56
|
+
group.settings.cycleSettings = new Map();
|
|
57
|
+
group.cycle = cycleIfMigrated;
|
|
58
|
+
if (group.status === GroupStatus.Archived) {
|
|
59
|
+
group.status = GroupStatus.Closed;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (!dryRun) {
|
|
63
|
+
await group.updateOccupancy();
|
|
64
|
+
await group.save();
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function migrateGroups({ groups, organization, periodSpan }: { groups: Group[]; organization: Organization; periodSpan: { startDate: Date; endDate: Date } }, dryRun: boolean) {
|
|
69
|
+
// #region create periods
|
|
70
|
+
const previousYearStartDate = new Date(periodSpan.startDate);
|
|
71
|
+
previousYearStartDate.setFullYear(previousYearStartDate.getFullYear() - 1);
|
|
72
|
+
|
|
73
|
+
const archivePeriod = await createRegistrationPeriod({
|
|
74
|
+
// todo: what should be the start date?
|
|
75
|
+
startDate: previousYearStartDate,
|
|
76
|
+
endDate: new Date(periodSpan.startDate.getTime() - 1),
|
|
77
|
+
locked: true,
|
|
78
|
+
organization,
|
|
79
|
+
previousPeriodId: undefined,
|
|
80
|
+
}, dryRun);
|
|
81
|
+
|
|
82
|
+
const archiveOrganizationPeriod = await createOrganizationRegistrationPeriod({
|
|
83
|
+
organization,
|
|
84
|
+
period: archivePeriod,
|
|
85
|
+
}, dryRun);
|
|
86
|
+
|
|
87
|
+
const currentPeriod = await createRegistrationPeriod({
|
|
88
|
+
startDate: periodSpan.startDate,
|
|
89
|
+
endDate: periodSpan.endDate,
|
|
90
|
+
locked: false,
|
|
91
|
+
previousPeriodId: archivePeriod.id,
|
|
92
|
+
organization,
|
|
93
|
+
}, dryRun);
|
|
94
|
+
|
|
95
|
+
organization.periodId = currentPeriod.id;
|
|
96
|
+
|
|
97
|
+
if (!dryRun) {
|
|
98
|
+
await organization.save();
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const currentOrganizationRegistrationPeriod = await createOrganizationRegistrationPeriod({
|
|
102
|
+
organization,
|
|
103
|
+
period: currentPeriod,
|
|
104
|
+
}, dryRun);
|
|
105
|
+
// #endregion
|
|
106
|
+
|
|
107
|
+
// (to add to categories later)
|
|
108
|
+
const groupMap = new Map<
|
|
109
|
+
// original group id
|
|
110
|
+
string,
|
|
111
|
+
// groups in previous cycles
|
|
112
|
+
Group[]>();
|
|
113
|
+
|
|
114
|
+
for (const originalGroup of groups) {
|
|
115
|
+
// archived groups should be migrated to the archive period
|
|
116
|
+
const currentGroupPeriod = originalGroup.status === GroupStatus.Archived ? archivePeriod : currentPeriod;
|
|
117
|
+
const currentCycle = originalGroup.cycle;
|
|
118
|
+
const originalGroupId: string = originalGroup.id;
|
|
119
|
+
originalGroup.periodId = currentGroupPeriod.id;
|
|
120
|
+
|
|
121
|
+
// first migrate registrations for the current cycle
|
|
122
|
+
await migrateRegistrations({ organization, period: currentGroupPeriod, originalGroup, newGroup: originalGroup, cycle: currentCycle }, dryRun);
|
|
123
|
+
|
|
124
|
+
const cycleSettingEntries = [...originalGroup.settings.cycleSettings.entries()].filter(([cycle]) => {
|
|
125
|
+
return cycle !== currentCycle;
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
// most recent cycle first (highest cycle number)
|
|
129
|
+
cycleSettingEntries.sort((a, b) => b[0] - a[0]);
|
|
130
|
+
|
|
131
|
+
// create groups for the previous cycles and migrate registrations
|
|
132
|
+
for (let previousPeriodIndex = 0; previousPeriodIndex < cycleSettingEntries.length; previousPeriodIndex++) {
|
|
133
|
+
const [cycle, cycleInformation] = cycleSettingEntries[previousPeriodIndex];
|
|
134
|
+
const newGroup = createPreviousGroup({ originalGroup, period: archivePeriod, cycleInformation, index: previousPeriodIndex });
|
|
135
|
+
|
|
136
|
+
const registrationCount = await Registration.select()
|
|
137
|
+
.where('groupId', originalGroup.id)
|
|
138
|
+
.andWhere('cycle', cycle)
|
|
139
|
+
.count();
|
|
140
|
+
|
|
141
|
+
// only create group if there are registrations
|
|
142
|
+
if (registrationCount > 0) {
|
|
143
|
+
if (!dryRun) {
|
|
144
|
+
await newGroup.save();
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
await migrateRegistrations({ organization, period: archivePeriod, originalGroup, newGroup, cycle }, dryRun);
|
|
148
|
+
|
|
149
|
+
const allPreviousGroups = groupMap.get(originalGroupId);
|
|
150
|
+
if (allPreviousGroups) {
|
|
151
|
+
allPreviousGroups.push(newGroup);
|
|
152
|
+
}
|
|
153
|
+
else {
|
|
154
|
+
groupMap.set(originalGroupId, [newGroup]);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// #region create categories for current period
|
|
161
|
+
const nonArchivedGroupIds = [...new Set(groups.filter(g => g.status !== GroupStatus.Archived).map(g => g.id))];
|
|
162
|
+
const newCategoriesData = organization.meta.categories.map((c: GroupCategory) => {
|
|
163
|
+
const category = GroupCategory.create({
|
|
164
|
+
settings: GroupCategorySettings.create({
|
|
165
|
+
...c.settings,
|
|
166
|
+
}),
|
|
167
|
+
groupIds: [...new Set(c.groupIds.filter(id => nonArchivedGroupIds.includes(id)))],
|
|
168
|
+
});
|
|
169
|
+
return {
|
|
170
|
+
category,
|
|
171
|
+
originalCategory: c,
|
|
172
|
+
};
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
newCategoriesData.forEach((c) => {
|
|
176
|
+
const newCategoryIds = [...new Set(c.originalCategory.categoryIds.flatMap((id) => {
|
|
177
|
+
const result = newCategoriesData.find(c => c.originalCategory.id === id);
|
|
178
|
+
if (result) {
|
|
179
|
+
return [result.category.id];
|
|
180
|
+
}
|
|
181
|
+
return [];
|
|
182
|
+
}))];
|
|
183
|
+
c.category.categoryIds = newCategoryIds;
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
const originalRootCategoryId = organization.meta.rootCategoryId;
|
|
187
|
+
const rootCategoryData = newCategoriesData.find(c => c.originalCategory.id === originalRootCategoryId);
|
|
188
|
+
if (!rootCategoryData) {
|
|
189
|
+
throw new Error('No root category found');
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
currentOrganizationRegistrationPeriod.settings.rootCategoryId = rootCategoryData.category.id;
|
|
193
|
+
|
|
194
|
+
const currentPeriodCategories = newCategoriesData.map(c => c.category);
|
|
195
|
+
currentOrganizationRegistrationPeriod.settings.categories = currentPeriodCategories;
|
|
196
|
+
|
|
197
|
+
if (currentOrganizationRegistrationPeriod.settings.categories.length === 0) {
|
|
198
|
+
throw new Error('No categories found');
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (!dryRun) {
|
|
202
|
+
await currentOrganizationRegistrationPeriod.save();
|
|
203
|
+
}
|
|
204
|
+
// #endregion
|
|
205
|
+
|
|
206
|
+
// #region archived category
|
|
207
|
+
const archivedGroupIds = [...new Set(groups.filter(g => g.status === GroupStatus.Archived).map(g => g.id))];
|
|
208
|
+
const hasArchivedGroups = archivedGroupIds.length > 0;
|
|
209
|
+
|
|
210
|
+
const archiveSubCategories = archivedGroupIds.map((originalGroupId) => {
|
|
211
|
+
const originalGroup = groups.find(g => g.id === originalGroupId)!;
|
|
212
|
+
const childGroups = groupMap.get(originalGroupId) ?? [];
|
|
213
|
+
const groupIds = [originalGroup, ...childGroups].map(g => g.id);
|
|
214
|
+
|
|
215
|
+
return GroupCategory.create({
|
|
216
|
+
settings: GroupCategorySettings.create({
|
|
217
|
+
name: originalGroup!.settings.name.toString(),
|
|
218
|
+
public: false,
|
|
219
|
+
maximumRegistrations: null,
|
|
220
|
+
}),
|
|
221
|
+
groupIds,
|
|
222
|
+
});
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
const archiveCategory = GroupCategory.create({
|
|
226
|
+
settings: GroupCategorySettings.create({
|
|
227
|
+
name: 'Archief',
|
|
228
|
+
description: 'Gearchiveerde groepen',
|
|
229
|
+
public: false,
|
|
230
|
+
}),
|
|
231
|
+
categoryIds: archiveSubCategories.map(c => c.id),
|
|
232
|
+
});
|
|
233
|
+
// #endregion
|
|
234
|
+
|
|
235
|
+
// #region create categories for previous periods
|
|
236
|
+
const allPreviousPeriodCategoriesData: { category: GroupCategory; originalCategory: GroupCategory | null }[] = currentPeriodCategories.flatMap((originalCategory: GroupCategory) => {
|
|
237
|
+
// important: do not set category ids on cloned category yet (because the new category ids are unknown still)
|
|
238
|
+
const clonedCategory = GroupCategory.create({
|
|
239
|
+
settings: GroupCategorySettings.create({
|
|
240
|
+
...originalCategory.settings,
|
|
241
|
+
}),
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
if (originalCategory.groupIds.length) {
|
|
245
|
+
// create category for each original group
|
|
246
|
+
const newCategories = originalCategory.groupIds.flatMap((originalGroupId: string) => {
|
|
247
|
+
const childGroups = groupMap.get(originalGroupId);
|
|
248
|
+
|
|
249
|
+
// if(originalCategory)
|
|
250
|
+
if (childGroups && childGroups.length) {
|
|
251
|
+
const originalGroup = groups.find(g => g.id === originalGroupId)!;
|
|
252
|
+
const groupIds = childGroups.map(g => g.id);
|
|
253
|
+
const newCategory = GroupCategory.create({
|
|
254
|
+
settings: GroupCategorySettings.create({
|
|
255
|
+
name: originalGroup.settings.name.toString(),
|
|
256
|
+
public: false,
|
|
257
|
+
maximumRegistrations: null,
|
|
258
|
+
}),
|
|
259
|
+
groupIds,
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
return [newCategory];
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
return [];
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
clonedCategory.groupIds = [];
|
|
269
|
+
// set category ids because the ids are correct
|
|
270
|
+
clonedCategory.categoryIds = newCategories.map(c => c.id);
|
|
271
|
+
|
|
272
|
+
return [{ category: clonedCategory, originalCategory }, ...newCategories.map(c => ({ category: c, originalCategory: null }))];
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// important: do not set category ids on cloned category yet (because the new category ids are unknown still)
|
|
276
|
+
return [{ category: clonedCategory, originalCategory }];
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
// update the category ids
|
|
280
|
+
const allPreviousPeriodCategories = allPreviousPeriodCategoriesData.map((c) => {
|
|
281
|
+
if (!c.originalCategory) {
|
|
282
|
+
return c.category;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const category = c.category;
|
|
286
|
+
|
|
287
|
+
// update category ids, if the category ids are not empty this means the ids are correct already
|
|
288
|
+
if (category.categoryIds.length === 0) {
|
|
289
|
+
category.categoryIds = c.originalCategory.categoryIds.flatMap((originalCategoryId) => {
|
|
290
|
+
const newCategoryData = allPreviousPeriodCategoriesData.find(c => c.originalCategory && c.originalCategory.id === originalCategoryId);
|
|
291
|
+
if (newCategoryData) {
|
|
292
|
+
return [newCategoryData.category.id];
|
|
293
|
+
}
|
|
294
|
+
return [];
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
return category;
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
const previousRootCategoryId = currentOrganizationRegistrationPeriod.settings.rootCategoryId;
|
|
302
|
+
const previousRootCategoryData = allPreviousPeriodCategoriesData.find(c => c.originalCategory && c.originalCategory.id === previousRootCategoryId);
|
|
303
|
+
if (!previousRootCategoryData) {
|
|
304
|
+
throw new Error(`No root category found for archive period (${previousRootCategoryId})`);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// add archive category to root category
|
|
308
|
+
if (hasArchivedGroups) {
|
|
309
|
+
previousRootCategoryData.category.categoryIds.push(archiveCategory.id);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
archiveOrganizationPeriod.settings.rootCategoryId = previousRootCategoryData.category.id;
|
|
313
|
+
archiveOrganizationPeriod.settings.categories = hasArchivedGroups ? [...allPreviousPeriodCategories, archiveCategory, ...archiveSubCategories] : allPreviousPeriodCategories;
|
|
314
|
+
|
|
315
|
+
if (archiveOrganizationPeriod.settings.categories.length === 0) {
|
|
316
|
+
throw new Error('No categories found for archive period');
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
if (!dryRun) {
|
|
320
|
+
await archiveOrganizationPeriod.save();
|
|
321
|
+
}
|
|
322
|
+
// #endregion
|
|
323
|
+
|
|
324
|
+
const result: Group[] = [...groups, ...[...groupMap.values()].flat()];
|
|
325
|
+
return result;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
async function migrateRegistrations({ organization, period, originalGroup, newGroup, cycle }: { organization: Organization; period: RegistrationPeriod; originalGroup: Group; newGroup: Group; cycle: number }, dryRun: boolean) {
|
|
329
|
+
// what for waiting lists of archive groups (previous cycles)?
|
|
330
|
+
let waitingList: Group | null = null;
|
|
331
|
+
|
|
332
|
+
const getOrCreateWaitingList = async () => {
|
|
333
|
+
if (newGroup.waitingListId) {
|
|
334
|
+
if (waitingList !== null) {
|
|
335
|
+
return waitingList;
|
|
336
|
+
}
|
|
337
|
+
const fetchedWaitingList = await Group.getByID(newGroup.waitingListId);
|
|
338
|
+
|
|
339
|
+
if (!fetchedWaitingList) {
|
|
340
|
+
throw new Error(`Waiting list not found: (waitingListId: ${newGroup.waitingListId}, groupId: ${newGroup.id})`);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const newWaitingList = new Group();
|
|
345
|
+
newWaitingList.cycle = cycleIfMigrated;
|
|
346
|
+
newWaitingList.type = GroupType.WaitingList;
|
|
347
|
+
newWaitingList.organizationId = organization.id;
|
|
348
|
+
newWaitingList.periodId = period.id;
|
|
349
|
+
newWaitingList.settings = GroupSettings.create({
|
|
350
|
+
name: TranslatedString.create($t(`c1f1d9d0-3fa1-4633-8e14-8c4fc98b4f0f`) + ' ' + newGroup.settings.name.toString()),
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
if (!dryRun) {
|
|
354
|
+
await newWaitingList.save();
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
waitingList = newWaitingList;
|
|
358
|
+
return newWaitingList;
|
|
359
|
+
};
|
|
360
|
+
|
|
361
|
+
const registrations = await Registration.select()
|
|
362
|
+
.where('groupId', originalGroup.id)
|
|
363
|
+
.andWhere('cycle', cycle)
|
|
364
|
+
.fetch();
|
|
365
|
+
|
|
366
|
+
for (const registration of registrations) {
|
|
367
|
+
if (registration.waitingList) {
|
|
368
|
+
const waitingList = await getOrCreateWaitingList();
|
|
369
|
+
if (newGroup.waitingListId !== waitingList.id) {
|
|
370
|
+
newGroup.waitingListId = waitingList.id;
|
|
371
|
+
|
|
372
|
+
if (!dryRun) {
|
|
373
|
+
await newGroup.save();
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
registration.groupId = waitingList.id;
|
|
378
|
+
}
|
|
379
|
+
else {
|
|
380
|
+
registration.groupId = newGroup.id;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
registration.periodId = period.id;
|
|
384
|
+
registration.cycle = cycle;
|
|
385
|
+
|
|
386
|
+
if (!dryRun) {
|
|
387
|
+
await registration.save();
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// #region period spans
|
|
393
|
+
type PeriodSpan = {
|
|
394
|
+
startMonth: number;
|
|
395
|
+
span: number;
|
|
396
|
+
};
|
|
397
|
+
|
|
398
|
+
function getPeriodSpans() {
|
|
399
|
+
const results: PeriodSpan[] = [];
|
|
400
|
+
const monthSpans: number[] = [12];
|
|
401
|
+
|
|
402
|
+
for (const monthSpan of monthSpans) {
|
|
403
|
+
for (let i = 0; i < monthSpan; i++) {
|
|
404
|
+
results.push({ startMonth: i, span: monthSpan });
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
return results;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
const periodSpans = getPeriodSpans();
|
|
412
|
+
|
|
413
|
+
const defaultYear = 2025;
|
|
414
|
+
const defaultPeriodSpan = periodSpans.find(periodSpan => periodSpan.span === 12 && periodSpan.startMonth === 8)!;
|
|
415
|
+
|
|
416
|
+
function findBestPeriodMatch(cyclePeriod: { startDate: Date; endDate: Date }): { year: number; periodSpan: PeriodSpan } {
|
|
417
|
+
// best match = most days overlap and least days missing
|
|
418
|
+
const startYear = cyclePeriod.startDate.getFullYear();
|
|
419
|
+
const endYear = cyclePeriod.endDate.getFullYear();
|
|
420
|
+
|
|
421
|
+
const yearsToLoop = new Set([startYear - 1, startYear, endYear, endYear + 1]);
|
|
422
|
+
const cycleInfo = {
|
|
423
|
+
startDate: cyclePeriod.startDate,
|
|
424
|
+
endDate: cyclePeriod.endDate,
|
|
425
|
+
};
|
|
426
|
+
|
|
427
|
+
let bestMatch: {
|
|
428
|
+
year: number;
|
|
429
|
+
periodSpan: PeriodSpan;
|
|
430
|
+
daysMissing: number;
|
|
431
|
+
daysOverlapping: number;
|
|
432
|
+
} | null = null;
|
|
433
|
+
|
|
434
|
+
for (const periodSpan of periodSpans) {
|
|
435
|
+
const repeat = 12 / periodSpan.span;
|
|
436
|
+
|
|
437
|
+
for (let i = 0; i < repeat; i++) {
|
|
438
|
+
const startMonth = i * periodSpan.span + periodSpan.startMonth;
|
|
439
|
+
|
|
440
|
+
for (const year of yearsToLoop) {
|
|
441
|
+
const { daysMissing, daysOverlapping } = getDaysMissingAndOverlap(
|
|
442
|
+
cycleInfo,
|
|
443
|
+
{
|
|
444
|
+
startDate: new Date(year, startMonth, 1),
|
|
445
|
+
endDate: new Date(new Date(year, startMonth + periodSpan.span, 1).getTime() - 1),
|
|
446
|
+
},
|
|
447
|
+
);
|
|
448
|
+
|
|
449
|
+
if (bestMatch === null) {
|
|
450
|
+
bestMatch = {
|
|
451
|
+
year,
|
|
452
|
+
periodSpan,
|
|
453
|
+
daysMissing,
|
|
454
|
+
daysOverlapping,
|
|
455
|
+
};
|
|
456
|
+
continue;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
if (daysOverlapping < 0) {
|
|
460
|
+
continue;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
if (daysOverlapping > bestMatch.daysOverlapping) {
|
|
464
|
+
bestMatch = {
|
|
465
|
+
year,
|
|
466
|
+
periodSpan,
|
|
467
|
+
daysMissing,
|
|
468
|
+
daysOverlapping,
|
|
469
|
+
};
|
|
470
|
+
continue;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
if (daysOverlapping === bestMatch.daysOverlapping && daysMissing < bestMatch.daysMissing) {
|
|
474
|
+
bestMatch = {
|
|
475
|
+
year,
|
|
476
|
+
periodSpan,
|
|
477
|
+
daysMissing,
|
|
478
|
+
daysOverlapping,
|
|
479
|
+
};
|
|
480
|
+
continue;
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
if (bestMatch) {
|
|
487
|
+
return { year: bestMatch.year, periodSpan: bestMatch.periodSpan };
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
return { year: defaultYear, periodSpan: periodSpans[0] };
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/**
|
|
494
|
+
* returns the number of days that the periods overlap and the number of days that are missing from the longest period
|
|
495
|
+
* @param period
|
|
496
|
+
* @param targetPeriod
|
|
497
|
+
*/
|
|
498
|
+
export function getDaysMissingAndOverlap(period1: { startDate: Date; endDate: Date }, period2: { startDate: Date; endDate: Date }): { daysMissing: number; daysOverlapping: number; longestPeriodInDays: number } {
|
|
499
|
+
const totalDays1 = differenceInDays(period1.startDate, period1.endDate);
|
|
500
|
+
const totalDays2 = differenceInDays(period2.startDate, period2.endDate);
|
|
501
|
+
|
|
502
|
+
const targetPeriod = totalDays1 > totalDays2 ? period1 : period2;
|
|
503
|
+
const otherPeriod = totalDays1 > totalDays2 ? period2 : period1;
|
|
504
|
+
|
|
505
|
+
const startOverlap = targetPeriod.startDate.getTime() > otherPeriod.startDate.getTime() ? targetPeriod.startDate : otherPeriod.startDate;
|
|
506
|
+
const endOverlap = targetPeriod.endDate.getTime() < otherPeriod.endDate.getTime() ? targetPeriod.endDate : otherPeriod.endDate;
|
|
507
|
+
|
|
508
|
+
const daysOverlapping = startOverlap.getTime() > endOverlap.getTime() ? 0 : differenceInDays(startOverlap, endOverlap);
|
|
509
|
+
|
|
510
|
+
const daysMissing = totalDays1 > totalDays2 ? totalDays1 - daysOverlapping : totalDays2 - daysOverlapping;
|
|
511
|
+
|
|
512
|
+
return {
|
|
513
|
+
daysMissing,
|
|
514
|
+
daysOverlapping,
|
|
515
|
+
longestPeriodInDays: totalDays1 > totalDays2 ? totalDays1 : totalDays2,
|
|
516
|
+
};
|
|
517
|
+
}
|
|
518
|
+
// #endregion
|
|
519
|
+
|
|
520
|
+
async function calculateBestCurrentPeriodSpan(groups: Group[]): Promise<{ startDate: Date; endDate: Date }> {
|
|
521
|
+
// #region separate short and long groups
|
|
522
|
+
const shortGroups: Group[] = [];
|
|
523
|
+
const longGroups: Group[] = [];
|
|
524
|
+
|
|
525
|
+
for (const group of groups) {
|
|
526
|
+
const { startDate, endDate } = group.settings as { startDate: Date; endDate: Date };
|
|
527
|
+
if (startDate && endDate && differenceInDays(startDate, endDate) < 150) {
|
|
528
|
+
shortGroups.push(group);
|
|
529
|
+
}
|
|
530
|
+
else {
|
|
531
|
+
longGroups.push(group);
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
// #endregion
|
|
535
|
+
|
|
536
|
+
// #region search the best period span
|
|
537
|
+
const periodSpanCounts = new Map<PeriodSpan, { year: number; group: Group }[]>();
|
|
538
|
+
let topPeriodSpan: PeriodSpan = defaultPeriodSpan;
|
|
539
|
+
let topCount: number = 0;
|
|
540
|
+
|
|
541
|
+
/**
|
|
542
|
+
* Use the long groups to calculate the best period span.
|
|
543
|
+
* If there are no long groups, use the short groups.
|
|
544
|
+
* If there are no short groups, use the default period span.
|
|
545
|
+
*/
|
|
546
|
+
for (const group of (longGroups.length ? longGroups : shortGroups)) {
|
|
547
|
+
const { startDate, endDate } = group.settings as { startDate: Date; endDate: Date };
|
|
548
|
+
|
|
549
|
+
// month start, month end, span
|
|
550
|
+
const { periodSpan: bestPeriod, year } = findBestPeriodMatch({
|
|
551
|
+
startDate,
|
|
552
|
+
endDate });
|
|
553
|
+
|
|
554
|
+
const groups = periodSpanCounts.get(bestPeriod) ?? [];
|
|
555
|
+
// also keep track of the year to find the best year later
|
|
556
|
+
groups.push({ group, year });
|
|
557
|
+
|
|
558
|
+
if (groups.length > topCount) {
|
|
559
|
+
topCount = groups.length;
|
|
560
|
+
topPeriodSpan = bestPeriod;
|
|
561
|
+
}
|
|
562
|
+
periodSpanCounts.set(bestPeriod, groups);
|
|
563
|
+
}
|
|
564
|
+
// #endregion
|
|
565
|
+
|
|
566
|
+
// #region find the best year
|
|
567
|
+
let topYear = defaultYear;
|
|
568
|
+
const groupsInTopPeriodSpan = periodSpanCounts.get(topPeriodSpan) ?? [];
|
|
569
|
+
|
|
570
|
+
// calculate the number of groups in each year
|
|
571
|
+
const yearMap = new Map<number, Group[]>();
|
|
572
|
+
for (const { group, year } of groupsInTopPeriodSpan) {
|
|
573
|
+
const groups = yearMap.get(year) ?? [];
|
|
574
|
+
groups.push(group);
|
|
575
|
+
yearMap.set(year, groups);
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
// get the year with the most groups
|
|
579
|
+
let topYearCount = 0;
|
|
580
|
+
for (const [year, groups] of yearMap.entries()) {
|
|
581
|
+
if (groups.length > topYearCount) {
|
|
582
|
+
topYearCount = groups.length;
|
|
583
|
+
topYear = year;
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
// #endregion
|
|
587
|
+
|
|
588
|
+
const startDate = new Date(topYear, topPeriodSpan.startMonth, 1);
|
|
589
|
+
const endDate = new Date((new Date(topYear, topPeriodSpan.startMonth + topPeriodSpan.span)).getTime() - 1);
|
|
590
|
+
|
|
591
|
+
return { startDate, endDate };
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
// #region helpers
|
|
595
|
+
function differenceInDays(date1: Date, date2: Date) {
|
|
596
|
+
const diffTime = Math.abs(date2.getTime() - date1.getTime());
|
|
597
|
+
return timeToDays(diffTime);
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
function timeToDays(time: number): number {
|
|
601
|
+
return Math.ceil(time / (1000 * 60 * 60 * 24));
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
// #endregion
|
|
605
|
+
|
|
606
|
+
// #region factories
|
|
607
|
+
async function createDefaultRegistrationPeriod(organization: Organization, dryRun: boolean) {
|
|
608
|
+
// first check if already has a registration period for the organization
|
|
609
|
+
const registrationPeriod = await RegistrationPeriod.getByID(organization.periodId); ;
|
|
610
|
+
if (registrationPeriod && registrationPeriod.organizationId === organization.id) {
|
|
611
|
+
return;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
// create new registration period, every organization should have a registration period with an organization id
|
|
615
|
+
const period = await createRegistrationPeriod({
|
|
616
|
+
organization,
|
|
617
|
+
startDate: new Date(2025, 0, 1, 0, 0, 0, 0),
|
|
618
|
+
endDate: new Date(2025, 11, 31, 59, 59, 59, 999),
|
|
619
|
+
}, dryRun);
|
|
620
|
+
|
|
621
|
+
organization.periodId = period.id;
|
|
622
|
+
|
|
623
|
+
if (!dryRun) {
|
|
624
|
+
await organization.save();
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
async function createOrganizationRegistrationPeriod(options: {
|
|
629
|
+
organization: Organization;
|
|
630
|
+
period: RegistrationPeriod;
|
|
631
|
+
}, dryRun: boolean) {
|
|
632
|
+
const organizationRegistrationPeriod = new OrganizationRegistrationPeriod();
|
|
633
|
+
|
|
634
|
+
organizationRegistrationPeriod.organizationId = options.organization.id;
|
|
635
|
+
organizationRegistrationPeriod.periodId = options.period.id;
|
|
636
|
+
|
|
637
|
+
if (!dryRun) {
|
|
638
|
+
await organizationRegistrationPeriod.save();
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
return organizationRegistrationPeriod;
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
async function createRegistrationPeriod(options: {
|
|
645
|
+
startDate?: Date;
|
|
646
|
+
endDate?: Date;
|
|
647
|
+
previousPeriodId?: string;
|
|
648
|
+
locked?: boolean;
|
|
649
|
+
organization?: Organization;
|
|
650
|
+
}, dryRun: boolean) {
|
|
651
|
+
const period = new RegistrationPeriod();
|
|
652
|
+
|
|
653
|
+
period.organizationId = options.organization ? options.organization.id : null;
|
|
654
|
+
period.startDate = options.startDate ?? new Date(2024, 0, 1, 0, 0, 0, 0);
|
|
655
|
+
period.endDate = options.endDate ?? new Date(2024, 11, 31, 59, 59, 59, 999);
|
|
656
|
+
if (options.previousPeriodId) {
|
|
657
|
+
period.previousPeriodId = options.previousPeriodId;
|
|
658
|
+
}
|
|
659
|
+
period.settings = RegistrationPeriodSettings.create({});
|
|
660
|
+
period.locked = options.locked ?? false;
|
|
661
|
+
|
|
662
|
+
if (!dryRun) {
|
|
663
|
+
await period.save();
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
return period;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
function createPreviousGroup({ originalGroup, period, cycleInformation, index }: { originalGroup: Group; period: RegistrationPeriod; cycleInformation: CycleInformation; index: number }) {
|
|
670
|
+
const newGroup = new Group();
|
|
671
|
+
newGroup.organizationId = originalGroup.organizationId;
|
|
672
|
+
newGroup.periodId = period.id;
|
|
673
|
+
newGroup.status = originalGroup.status;
|
|
674
|
+
newGroup.createdAt = originalGroup.createdAt;
|
|
675
|
+
newGroup.deletedAt = originalGroup.deletedAt;
|
|
676
|
+
|
|
677
|
+
const originalPrivateSettings: GroupPrivateSettings = originalGroup.privateSettings;
|
|
678
|
+
|
|
679
|
+
// todo: should group ids in permissions get updated?
|
|
680
|
+
newGroup.privateSettings = GroupPrivateSettings.create({
|
|
681
|
+
...originalPrivateSettings,
|
|
682
|
+
});
|
|
683
|
+
|
|
684
|
+
newGroup.cycle = cycleIfMigrated;
|
|
685
|
+
|
|
686
|
+
const originalSettings: GroupSettings = originalGroup.settings;
|
|
687
|
+
|
|
688
|
+
const periodStartDate: Date = period.startDate;
|
|
689
|
+
const periodEndDate: Date = period.endDate;
|
|
690
|
+
|
|
691
|
+
// todo: how to choose start and end dates?
|
|
692
|
+
let startDate = new Date(periodStartDate);
|
|
693
|
+
let endDate = new Date(periodEndDate);
|
|
694
|
+
|
|
695
|
+
if (cycleInformation.startDate && cycleInformation.endDate) {
|
|
696
|
+
startDate = new Date(cycleInformation.startDate);
|
|
697
|
+
endDate = new Date(cycleInformation.endDate);
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
const isPlural = index > 0;
|
|
701
|
+
const extraName = isPlural ? `${index + 1} periodes geleden` : `${index + 1} periode geleden`;
|
|
702
|
+
|
|
703
|
+
newGroup.settings = GroupSettings
|
|
704
|
+
.create({
|
|
705
|
+
...originalSettings,
|
|
706
|
+
cycleSettings: new Map(),
|
|
707
|
+
name: new TranslatedString(`${originalSettings.name.toString()} (${extraName})`),
|
|
708
|
+
startDate,
|
|
709
|
+
endDate,
|
|
710
|
+
});
|
|
711
|
+
newGroup.type = originalGroup.type;
|
|
712
|
+
|
|
713
|
+
return newGroup;
|
|
714
|
+
}
|
|
715
|
+
// #endregion
|