@stamhoofd/backend 2.23.0 → 2.25.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.
@@ -12,6 +12,11 @@
12
12
  "BE": "www.be.stamhoofd",
13
13
  "NL": "www.nl.stamhoofd"
14
14
  },
15
+ "documentation": {
16
+ "": "www.be.stamhoofd/docs",
17
+ "BE": "www.be.stamhoofd/docs",
18
+ "NL": "www.nl.stamhoofd/docs"
19
+ },
15
20
  "webshop": {
16
21
  "": "shop.be.stamhoofd",
17
22
  "BE": "shop.be.stamhoofd",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stamhoofd/backend",
3
- "version": "2.23.0",
3
+ "version": "2.25.0",
4
4
  "main": "./dist/index.js",
5
5
  "exports": {
6
6
  ".": {
@@ -36,14 +36,14 @@
36
36
  "@simonbackx/simple-encoding": "2.15.0",
37
37
  "@simonbackx/simple-endpoints": "1.14.0",
38
38
  "@simonbackx/simple-logging": "^1.0.1",
39
- "@stamhoofd/backend-i18n": "2.23.0",
40
- "@stamhoofd/backend-middleware": "2.23.0",
41
- "@stamhoofd/email": "2.23.0",
42
- "@stamhoofd/models": "2.23.0",
43
- "@stamhoofd/queues": "2.23.0",
44
- "@stamhoofd/sql": "2.23.0",
45
- "@stamhoofd/structures": "2.23.0",
46
- "@stamhoofd/utility": "2.23.0",
39
+ "@stamhoofd/backend-i18n": "2.25.0",
40
+ "@stamhoofd/backend-middleware": "2.25.0",
41
+ "@stamhoofd/email": "2.25.0",
42
+ "@stamhoofd/models": "2.25.0",
43
+ "@stamhoofd/queues": "2.25.0",
44
+ "@stamhoofd/sql": "2.25.0",
45
+ "@stamhoofd/structures": "2.25.0",
46
+ "@stamhoofd/utility": "2.25.0",
47
47
  "archiver": "^7.0.1",
48
48
  "aws-sdk": "^2.885.0",
49
49
  "axios": "1.6.8",
@@ -60,5 +60,5 @@
60
60
  "postmark": "4.0.2",
61
61
  "stripe": "^16.6.0"
62
62
  },
63
- "gitHead": "efcdf16aa2160bcb1fd295d0e5c72751caa3356b"
63
+ "gitHead": "48963030dd9e90bef4ea1c96db2cf6ddb6e1908a"
64
64
  }
package/src/crons.ts CHANGED
@@ -1,9 +1,7 @@
1
1
  import { Database } from '@simonbackx/simple-database';
2
2
  import { logger, StyledText } from "@simonbackx/simple-logging";
3
- import { I18n } from '@stamhoofd/backend-i18n';
4
3
  import { Email, EmailAddress } from '@stamhoofd/email';
5
- import { Group, Organization, Payment, Registration, STPackage, STPendingInvoice, Webshop } from '@stamhoofd/models';
6
- import { QueueHandler } from '@stamhoofd/queues';
4
+ import { Group, Organization, Payment, Registration, STPackage, Webshop } from '@stamhoofd/models';
7
5
  import { PaymentMethod, PaymentProvider, PaymentStatus } from '@stamhoofd/structures';
8
6
  import { Formatter, sleep } from '@stamhoofd/utility';
9
7
  import AWS from 'aws-sdk';
@@ -123,9 +121,7 @@ async function checkWebshopDNS() {
123
121
  console.log("[DNS] Checking webshop DNS...")
124
122
 
125
123
  for (const webshop of webshops) {
126
- if (STAMHOOFD.environment === "production" || true) {
127
- console.log("[DNS] Webshop "+webshop.meta.name+" ("+webshop.id+")"+" ("+webshop.domain+")")
128
- }
124
+ console.log("[DNS] Webshop "+webshop.meta.name+" ("+webshop.id+")"+" ("+webshop.domain+")")
129
125
  await webshop.updateDNSRecords()
130
126
  }
131
127
 
@@ -133,10 +129,10 @@ async function checkWebshopDNS() {
133
129
  }
134
130
 
135
131
  async function checkReplies() {
136
- if (STAMHOOFD.environment !== "production") {
137
- return;
132
+ if (STAMHOOFD.environment !== "production" || !STAMHOOFD.AWS_ACCESS_KEY_ID) {
133
+ return
138
134
  }
139
-
135
+
140
136
  console.log("Checking replies from AWS SQS")
141
137
  const sqs = new AWS.SQS();
142
138
  const messages = await sqs.receiveMessage({ QueueUrl: "https://sqs.eu-west-1.amazonaws.com/118244293157/stamhoofd-email-forwarding", MaxNumberOfMessages: 10 }).promise()
@@ -261,7 +257,7 @@ async function checkPostmarkBounces() {
261
257
  }
262
258
 
263
259
  async function checkBounces() {
264
- if (STAMHOOFD.environment !== "production") {
260
+ if (STAMHOOFD.environment !== "production" || !STAMHOOFD.AWS_ACCESS_KEY_ID) {
265
261
  return
266
262
  }
267
263
 
@@ -343,7 +339,7 @@ async function checkBounces() {
343
339
  }
344
340
 
345
341
  async function checkComplaints() {
346
- if (STAMHOOFD.environment !== "production") {
342
+ if (STAMHOOFD.environment !== "production" || !STAMHOOFD.AWS_ACCESS_KEY_ID) {
347
343
  return
348
344
  }
349
345
 
@@ -396,11 +392,10 @@ async function checkComplaints() {
396
392
  console.error("[AWS COMPLAINTS] Received virus / fraud complaint!")
397
393
  console.error("[AWS COMPLAINTS]", complaint)
398
394
  if (STAMHOOFD.environment === "production") {
399
- Email.sendInternal({
400
- to: "simon@stamhoofd.be",
395
+ Email.sendWebmaster({
401
396
  subject: "Received a "+type+" email notification",
402
397
  text: "We received a "+type+" notification for an e-mail from the organization: "+organization?.name+". Please check and adjust if needed.\n"
403
- }, new I18n("nl", "BE"))
398
+ })
404
399
  }
405
400
  }
406
401
  } else {
@@ -1,13 +1,12 @@
1
1
  import { Decoder } from '@simonbackx/simple-encoding';
2
2
  import { DecodedRequest, Endpoint, Request, Response } from "@simonbackx/simple-endpoints";
3
3
  import { SimpleError } from "@simonbackx/simple-errors";
4
- import { Email } from '@stamhoofd/email';
5
- import { PasswordToken, User } from '@stamhoofd/models';
6
- import { User as UserStruct,UserPermissions, UserWithMembers } from "@stamhoofd/structures";
4
+ import { PasswordToken, sendEmailTemplate, User } from '@stamhoofd/models';
5
+ import { EmailTemplateType, Recipient, Replacement, UserPermissions, User as UserStruct, UserWithMembers } from "@stamhoofd/structures";
7
6
  import { Formatter } from '@stamhoofd/utility';
8
7
 
9
- import { Context } from '../../helpers/Context';
10
8
  import { AuthenticatedStructures } from '../../helpers/AuthenticatedStructures';
9
+ import { Context } from '../../helpers/Context';
11
10
  type Params = Record<string, never>;
12
11
  type Query = undefined;
13
12
  type Body = UserStruct
@@ -95,11 +94,6 @@ export class CreateAdminEndpoint extends Endpoint<Params, Query, Body, ResponseB
95
94
 
96
95
  await admin.save();
97
96
 
98
- const { from, replyTo } = {
99
- from: organization ? organization.getStrongEmail(request.i18n) : Email.getInternalEmailFor(request.i18n),
100
- replyTo: undefined
101
- }
102
-
103
97
  // Create a password token that is valid for 7 days
104
98
  const validUntil = new Date();
105
99
  validUntil.setTime(validUntil.getTime() + 7 * 24 * 3600 * 1000);
@@ -110,28 +104,46 @@ export class CreateAdminEndpoint extends Endpoint<Params, Query, Body, ResponseB
110
104
  const name = organization?.name ?? request.i18n.t("shared.platformName")
111
105
  const what = organization ? `de vereniging ${name} op ${request.i18n.t("shared.platformName")}` : `${request.i18n.t("shared.platformName")}`
112
106
 
113
- if (admin.hasAccount()) {
114
- const url = "https://"+(STAMHOOFD.domains.dashboard ?? "stamhoofd.app")+"/"+request.i18n.locale;
115
-
116
- Email.send({
117
- from,
118
- replyTo,
119
- to: admin.getEmailTo(),
120
- subject: "✉️ Beheerder van "+name,
121
- type: "transactional",
122
- text: (admin.firstName ? "Dag "+admin.firstName : "Hallo") + `, \n\n${user.firstName ?? 'Iemand'} heeft je toegevoegd als beheerder van ${what}. Je kan inloggen met je bestaande account (${admin.email}) door te surfen naar:\n${url}\n\nDaar kan je jouw vereniging zoeken en aanklikken.\n\n----\n\nWeet je jouw wachtwoord niet meer? Dan kan je een nieuw wachtwoord instellen via de onderstaande link:\n`+recoveryUrl+"\n\nDeze link is geldig tot "+dateTime+".\n\nKen je deze vereniging niet? Dan kan je deze e-mail veilig negeren.\n\nMet vriendelijke groeten,\n"+request.i18n.t("shared.platformName")+"\n"
123
- });
124
- } else {
125
- // Send email
126
- Email.send({
127
- from,
128
- replyTo,
129
- to: admin.getEmailTo(),
130
- subject: "✉️ Uitnodiging beheerder van "+name,
131
- type: "transactional",
132
- text: (admin.firstName ? "Dag "+admin.firstName : "Hallo") + `, \n\n${user.firstName ?? 'Iemand'} heeft je uitgenodigd om beheerder te worden van ${what}. Je kan een account aanmaken door op de volgende link te klikken of door deze te kopiëren in de URL-balk van je browser:\n`+recoveryUrl+"\n\nDeze link is geldig tot "+dateTime+".\n\nKen je deze vereniging niet? Dan kan je deze e-mail veilig negeren.\n\nMet vriendelijke groeten,\n"+request.i18n.t("shared.platformName")+"\n"
133
- });
134
- }
107
+ const emailTo = admin.getEmailTo();
108
+ const email: string = typeof emailTo === 'string' ? emailTo : emailTo[0]?.email;
109
+
110
+ await sendEmailTemplate(organization, {
111
+ recipients: [
112
+ Recipient.create({
113
+ email,
114
+ replacements: [
115
+ Replacement.create({
116
+ token: 'greeting',
117
+ value: admin.firstName ? `Dag ${admin.firstName},` : 'Hallo!'
118
+ }),
119
+ Replacement.create({
120
+ token: 'resetUrl',
121
+ value: recoveryUrl
122
+ }),
123
+ Replacement.create({
124
+ token: 'platformOrOrganizationName',
125
+ value: what
126
+ }),
127
+ Replacement.create({
128
+ token: 'inviterName',
129
+ value: user.firstName ?? 'Iemand'
130
+ }),
131
+ Replacement.create({
132
+ token: 'validUntil',
133
+ value: dateTime
134
+ }),
135
+ Replacement.create({
136
+ token: 'email',
137
+ value: admin.email
138
+ })
139
+ ]
140
+ })
141
+ ],
142
+ template: {
143
+ type: admin.hasAccount() ? EmailTemplateType.AdminInvitation : EmailTemplateType.AdminInvitationNewUser
144
+ },
145
+ type: 'transactional'
146
+ });
135
147
 
136
148
  return new Response(
137
149
  await AuthenticatedStructures.userWithMembers(admin)
@@ -60,11 +60,11 @@ export class SignupEndpoint extends Endpoint<Params, Query, Body, ResponseBody>
60
60
  // Send an e-mail to say you already have an account + follow password forgot flow
61
61
  const recoveryUrl = await PasswordToken.getPasswordRecoveryUrl(user, organization, request.i18n)
62
62
  const { from, replyTo } = {
63
- from: (user.permissions || !organization ? Email.getInternalEmailFor(request.i18n) : organization.getStrongEmail(request.i18n)),
63
+ from: (user.permissions || !organization ? Email.getInternalEmailFor(request.i18n) : organization.getDefaultFrom(request.i18n)),
64
64
  replyTo: undefined
65
65
  }
66
66
 
67
- const footer = (!user.permissions && organization ? "\n\n—\n\nOnze ledenadministratie werkt via het Stamhoofd platform, op maat van verenigingen. Probeer het ook via https://"+request.i18n.$t("shared.domains.marketing")+"/ledenadministratie\n\n" : '')
67
+ const footer = (!user.permissions && organization ? "\n\n—\n\nOnze ledenadministratie werkt via het Stamhoofd platform, op maat van verenigingen. Probeer het ook via https://"+request.i18n.localizedDomains.marketing()+"/ledenadministratie\n\n" : '')
68
68
 
69
69
  const name = organization ? organization.name : 'Stamhoofd'
70
70
  // Send email
@@ -107,19 +107,14 @@ export class ExportToExcelEndpoint extends Endpoint<Params, Query, Body, Respons
107
107
  return url;
108
108
  }).catch(async (error) => {
109
109
  if (sendEmail) {
110
- const builder = await getEmailBuilderForTemplate(organization, {
110
+ await sendEmailTemplate(null, {
111
111
  template: {
112
112
  type: EmailTemplateType.ExcelExportFailed
113
113
  },
114
114
  recipients: [
115
115
  user.createRecipient()
116
- ],
117
- from: Email.getInternalEmailFor(Context.i18n)
116
+ ]
118
117
  })
119
-
120
- if (builder) {
121
- Email.schedule(builder)
122
- }
123
118
  }
124
119
  throw error
125
120
  }),
@@ -3,7 +3,7 @@ import { ConvertArrayToPatchableArray, Decoder, PatchableArrayAutoEncoder, Patch
3
3
  import { DecodedRequest, Endpoint, Request, Response } from "@simonbackx/simple-endpoints";
4
4
  import { SimpleError } from "@simonbackx/simple-errors";
5
5
  import { BalanceItem, Document, Group, Member, MemberFactory, MemberPlatformMembership, MemberResponsibilityRecord, MemberWithRegistrations, Organization, Platform, Registration, User } from '@stamhoofd/models';
6
- import { MemberWithRegistrationsBlob, MembersBlob, PermissionLevel } from "@stamhoofd/structures";
6
+ import { GroupType, MemberWithRegistrationsBlob, MembersBlob, PermissionLevel } from "@stamhoofd/structures";
7
7
  import { Formatter } from '@stamhoofd/utility';
8
8
 
9
9
  import { AuthenticatedStructures } from '../../../helpers/AuthenticatedStructures';
@@ -267,6 +267,33 @@ export class PatchOrganizationMembersEndpoint extends Endpoint<Params, Query, Bo
267
267
  })
268
268
  }
269
269
 
270
+ const hasRegistration = member.registrations.some(registration => {
271
+ if (platformResponsibility) {
272
+ if (registration.group.defaultAgeGroupId === null) {
273
+ return false;
274
+ }
275
+ }
276
+
277
+ if (org) {
278
+ if (registration.periodId !== org.periodId) {
279
+ return false;
280
+ }
281
+ } else {
282
+ if (registration.periodId !== platform.periodId) {
283
+ return false;
284
+ }
285
+ }
286
+ return registration.deactivatedAt === null && registration.registeredAt !== null && registration.group.type === GroupType.Membership
287
+ })
288
+
289
+ if (!hasRegistration) {
290
+ throw new SimpleError({
291
+ code: "invalid_field",
292
+ message: "Invalid organization",
293
+ human: "Je kan een functie enkel toekennen aan leden die zijn ingeschreven in het huidige werkjaar",
294
+ })
295
+ }
296
+
270
297
  const model = new MemberResponsibilityRecord()
271
298
  model.memberId = member.id
272
299
  model.responsibilityId = responsibility.id
@@ -6,6 +6,7 @@ import { MemberResponsibility, PlatformConfig, PlatformPremiseType, Platform as
6
6
  import { SimpleError } from "@simonbackx/simple-errors";
7
7
  import { Context } from "../../../helpers/Context";
8
8
  import { SetupStepUpdater } from "../../../helpers/SetupStepsUpdater";
9
+ import { PeriodHelper } from "../../../helpers/PeriodHelper";
9
10
 
10
11
  type Params = Record<string, never>;
11
12
  type Query = undefined;
@@ -73,6 +74,7 @@ export class PatchPlatformEndpoint extends Endpoint<
73
74
  }
74
75
 
75
76
  let shouldUpdateSetupSteps = false;
77
+ let shouldMoveToPeriod: RegistrationPeriod | null = null;
76
78
 
77
79
  if (request.body.config) {
78
80
  if (!Context.auth.hasPlatformFullAccess()) {
@@ -113,6 +115,8 @@ export class PatchPlatformEndpoint extends Endpoint<
113
115
  });
114
116
  }
115
117
  platform.periodId = period.id;
118
+ shouldUpdateSetupSteps = true;
119
+ shouldMoveToPeriod = period;
116
120
  }
117
121
 
118
122
  if (request.body.membershipOrganizationId !== undefined) {
@@ -155,7 +159,10 @@ export class PatchPlatformEndpoint extends Endpoint<
155
159
 
156
160
  await platform.save();
157
161
 
158
- if(shouldUpdateSetupSteps) {
162
+ if (shouldMoveToPeriod) {
163
+ PeriodHelper.moveAllOrganizationsToPeriod(shouldMoveToPeriod).catch(console.error)
164
+ } else if(shouldUpdateSetupSteps) {
165
+ // Do not call this right away when moving to a period, because this needs to happen AFTER moving to the period
159
166
  SetupStepUpdater.updateSetupStepsForAllOrganizationsInCurrentPeriod().catch(console.error);
160
167
  }
161
168
 
@@ -83,11 +83,10 @@ export class RegisterMembersEndpoint extends Endpoint<Params, Query, Body, Respo
83
83
  try {
84
84
  limiter.track(organization.id, 1);
85
85
  } catch (e) {
86
- Email.sendInternal({
87
- to: "hallo@stamhoofd.be",
86
+ Email.sendWebmaster({
88
87
  subject: "[Limiet] Limiet bereikt voor aantal inschrijvingen",
89
88
  text: "Beste, \nDe limiet werd bereikt voor het aantal inschrijvingen per dag. \nVereniging: "+organization.id+" ("+organization.name+")" + "\n\n" + e.message + "\n\nStamhoofd"
90
- }, new I18n("nl", "BE"))
89
+ })
91
90
 
92
91
  throw new SimpleError({
93
92
  code: "too_many_emails_period",
@@ -95,11 +95,10 @@ export class EmailEndpoint extends Endpoint<Params, Query, Body, ResponseBody> {
95
95
  try {
96
96
  limiter.track(organization.id, request.body.recipients.length);
97
97
  } catch (e) {
98
- Email.sendInternal({
99
- to: "hallo@stamhoofd.be",
98
+ Email.sendWebmaster({
100
99
  subject: "[Limiet] Limiet bereikt voor aantal e-mails",
101
100
  text: "Beste, \nDe limiet werd bereikt voor het aantal e-mails per dag. \nVereniging: "+organization.id+" ("+organization.name+")" + "\n\n" + e.message + "\n\nStamhoofd"
102
- }, new I18n("nl", "BE"))
101
+ })
103
102
 
104
103
  throw new SimpleError({
105
104
  code: "too_many_emails_period",
@@ -178,7 +177,7 @@ export class EmailEndpoint extends Endpoint<Params, Query, Body, ResponseBody> {
178
177
  }
179
178
  })
180
179
 
181
- let from = organization.uri+"@stamhoofd.email";
180
+ let from = organization.getDefaultFrom(request.i18n, false, 'broadcast');
182
181
  let replyTo: string | undefined = sender.email;
183
182
 
184
183
  // Can we send from this e-mail or reply-to?
@@ -1,11 +1,11 @@
1
1
  import { DecodedRequest, Endpoint, Request, Response } from "@simonbackx/simple-endpoints";
2
- import { Group as GroupStruct, GroupPrivateSettings, OrganizationRegistrationPeriod as OrganizationRegistrationPeriodStruct, PermissionLevel, PermissionsResourceType, ResourcePermissions, Version, GroupType } from "@stamhoofd/structures";
2
+ import { GroupPrivateSettings, Group as GroupStruct, GroupType, OrganizationRegistrationPeriod as OrganizationRegistrationPeriodStruct, PermissionLevel, PermissionsResourceType, ResourcePermissions, Version } from "@stamhoofd/structures";
3
3
 
4
4
  import { AutoEncoderPatchType, Decoder, PatchableArrayAutoEncoder, PatchableArrayDecoder, StringDecoder } from "@simonbackx/simple-encoding";
5
- import { Context } from "../../../../helpers/Context";
6
- import { Group, Member, OrganizationRegistrationPeriod, Platform, RegistrationPeriod } from "@stamhoofd/models";
7
5
  import { SimpleError } from "@simonbackx/simple-errors";
6
+ import { Group, Member, Organization, OrganizationRegistrationPeriod, Platform, RegistrationPeriod } from "@stamhoofd/models";
8
7
  import { AuthenticatedStructures } from "../../../../helpers/AuthenticatedStructures";
8
+ import { Context } from "../../../../helpers/Context";
9
9
 
10
10
  type Params = Record<string, never>;
11
11
  type Query = undefined;
@@ -46,9 +46,12 @@ export class PatchOrganizationRegistrationPeriodsEndpoint extends Endpoint<Param
46
46
  if (!await Context.auth.hasFullAccess(organization.id)) {
47
47
  throw Context.auth.error()
48
48
  }
49
- const period = await RegistrationPeriod.getByID(put.period.id);
49
+ periods.push(await PatchOrganizationRegistrationPeriodsEndpoint.createOrganizationPeriod(organization, put));
50
+ }
50
51
 
51
- if (!period) {
52
+ for (const patch of request.body.getPatches()) {
53
+ const organizationPeriod = await OrganizationRegistrationPeriod.getByID(patch.id);
54
+ if (!organizationPeriod || organizationPeriod.organizationId !== organization.id) {
52
55
  throw new SimpleError({
53
56
  code: "not_found",
54
57
  message: "Period not found",
@@ -56,38 +59,33 @@ export class PatchOrganizationRegistrationPeriodsEndpoint extends Endpoint<Param
56
59
  })
57
60
  }
58
61
 
59
- const organizationPeriod = new OrganizationRegistrationPeriod();
60
- organizationPeriod.id = put.id;
61
- organizationPeriod.organizationId = organization.id;
62
- organizationPeriod.periodId = put.period.id;
63
- organizationPeriod.settings = put.settings;
64
- await organizationPeriod.save();
62
+ const period = await RegistrationPeriod.getByID(organizationPeriod.periodId);
65
63
 
66
- for (const struct of put.groups) {
67
- await PatchOrganizationRegistrationPeriodsEndpoint.createGroup(struct, organization.id, organizationPeriod.periodId)
64
+ if (!period) {
65
+ throw new SimpleError({
66
+ code: "not_found",
67
+ message: "Period not found",
68
+ statusCode: 404
69
+ })
68
70
  }
69
- const groups = await Group.getAll(organization.id, organizationPeriod.periodId)
70
-
71
- // Delete unreachable categories first
72
- await organizationPeriod.cleanCategories(groups);
73
- await Group.deleteUnreachable(organization.id, organizationPeriod, groups)
74
- periods.push(organizationPeriod);
75
- }
76
71
 
77
- for (const patch of request.body.getPatches()) {
78
- const organizationPeriod = await OrganizationRegistrationPeriod.getByID(patch.id);
79
- if (!organizationPeriod || organizationPeriod.organizationId !== organization.id) {
72
+ if (period.locked) {
80
73
  throw new SimpleError({
81
74
  code: "not_found",
82
75
  message: "Period not found",
76
+ human: 'Je kan geen wijzigingen meer aanbrengen in ' + period.getStructure().name + ' omdat deze is afgesloten',
83
77
  statusCode: 404
84
78
  })
85
79
  }
80
+
86
81
  let deleteUnreachable = false
87
82
  const allowedIds: string[] = []
88
83
 
89
84
  if (await Context.auth.hasFullAccess(organization.id)) {
90
85
  if (patch.settings) {
86
+ if(patch.settings.categories) {
87
+ deleteUnreachable = true;
88
+ }
91
89
  organizationPeriod.settings.patchOrPut(patch.settings);
92
90
  }
93
91
  } else {
@@ -174,6 +172,36 @@ export class PatchOrganizationRegistrationPeriodsEndpoint extends Endpoint<Param
174
172
  })
175
173
  }
176
174
 
175
+ static async createOrganizationPeriod(organization: Organization, struct: OrganizationRegistrationPeriodStruct) {
176
+ const period = await RegistrationPeriod.getByID(struct.period.id);
177
+
178
+ if (!period || period.locked) {
179
+ throw new SimpleError({
180
+ code: "not_found",
181
+ message: "Period not found",
182
+ statusCode: 404
183
+ })
184
+ }
185
+
186
+ const organizationPeriod = new OrganizationRegistrationPeriod();
187
+ organizationPeriod.id = struct.id;
188
+ organizationPeriod.organizationId = organization.id;
189
+ organizationPeriod.periodId = struct.period.id;
190
+ organizationPeriod.settings = struct.settings;
191
+ await organizationPeriod.save();
192
+
193
+ for (const s of struct.groups) {
194
+ await PatchOrganizationRegistrationPeriodsEndpoint.createGroup(s, organization.id, organizationPeriod.periodId)
195
+ }
196
+ const groups = await Group.getAll(organization.id, organizationPeriod.periodId)
197
+
198
+ // Delete unreachable categories first
199
+ await organizationPeriod.cleanCategories(groups);
200
+ await Group.deleteUnreachable(organization.id, organizationPeriod, groups)
201
+
202
+ return organizationPeriod
203
+ }
204
+
177
205
  static async deleteGroup(id: string) {
178
206
  const model = await Group.getByID(id)
179
207
  if (!model || !await Context.auth.canAccessGroup(model, PermissionLevel.Full)) {
@@ -263,6 +291,15 @@ export class PatchOrganizationRegistrationPeriodsEndpoint extends Endpoint<Param
263
291
  })
264
292
  }
265
293
 
294
+ if (existing.periodId !== model.periodId) {
295
+ throw new SimpleError({
296
+ code: 'invalid_field',
297
+ field: 'waitingList',
298
+ message: 'Waiting list group is already used in another period',
299
+ human: 'Een wachtlijst kan momenteel niet gedeeld worden tussen verschillende werkjaren'
300
+ })
301
+ }
302
+
266
303
  model.waitingListId = existing.id
267
304
  } else {
268
305
  const group = await PatchOrganizationRegistrationPeriodsEndpoint.createGroup(
@@ -86,11 +86,10 @@ export class PlaceOrderEndpoint extends Endpoint<Params, Query, Body, ResponseBo
86
86
  try {
87
87
  limiter.track(organization.id, 1);
88
88
  } catch (e) {
89
- Email.sendInternal({
90
- to: "hallo@stamhoofd.be",
89
+ Email.sendWebmaster({
91
90
  subject: "[Limiet] Limiet bereikt voor aantal bestellingen",
92
91
  text: "Beste, \nDe limiet werd bereikt voor het aantal bestellingen per dag. \nVereniging: "+organization.id+" ("+organization.name+")" + "\n\n" + e.message + "\n\nStamhoofd"
93
- }, new I18n("nl", "BE"))
92
+ })
94
93
 
95
94
  throw new SimpleError({
96
95
  code: "too_many_emails_period",
@@ -262,14 +262,43 @@ export class AuthenticatedStructures {
262
262
  }
263
263
  }
264
264
 
265
+ const organizationStructs = await Promise.all([...organizations.values()].filter(o => o.active).map(o => this.organization(o)))
266
+
267
+ // Load missing groups
268
+ const allGroups = new Map<string, GroupStruct>()
269
+ for (const organization of organizationStructs) {
270
+ for (const group of organization.period.groups) {
271
+ allGroups.set(group.id, group)
272
+ }
273
+ }
274
+
265
275
  for (const blob of memberBlobs) {
266
- blob.responsibilities = responsibilities.filter(r => r.memberId == blob.id).map(r => r.getStructure())
276
+ for (const registration of blob.registrations) {
277
+ if (registration.group) {
278
+ allGroups.set(registration.group.id, registration.group)
279
+ }
280
+ }
281
+ }
282
+
283
+ const groupIds = Formatter.uniqueArray(responsibilities.map(r => r.groupId).filter(id => id !== null)).filter(id => !allGroups.has(id))
284
+ const groups = groupIds.length > 0 ? await Group.getByIDs(...groupIds) : []
285
+ const groupStructs = await this.groups(groups)
286
+
287
+ for (const group of groupStructs) {
288
+ allGroups.set(group.id, group)
289
+ }
290
+
291
+ for (const blob of memberBlobs) {
292
+ blob.responsibilities = responsibilities.filter(r => r.memberId == blob.id).map(r => {
293
+ const group = allGroups.get(r.groupId ?? '') ?? null
294
+ return r.getStructure(group)
295
+ })
267
296
  blob.platformMemberships = platformMemberships.filter(r => r.memberId == blob.id).map(r => MemberPlatformMembershipStruct.create(r))
268
297
  }
269
298
 
270
299
  return MembersBlob.create({
271
300
  members: memberBlobs,
272
- organizations: await Promise.all([...organizations.values()].filter(o => o.active).map(o => this.organization(o)))
301
+ organizations: organizationStructs
273
302
  })
274
303
  }
275
304
 
@@ -1,4 +1,4 @@
1
- import { EmailAddress, EmailInterfaceRecipient } from "@stamhoofd/email";
1
+ import { Email, EmailAddress, EmailInterfaceRecipient } from "@stamhoofd/email";
2
2
  import { Organization } from "@stamhoofd/models";
3
3
  import { Formatter } from "@stamhoofd/utility";
4
4
  import { simpleParser } from "mailparser";
@@ -26,32 +26,34 @@ export class ForwardHandler {
26
26
  }
27
27
 
28
28
  // Unsubscribe email?
29
- if (email && email?.startsWith("unsubscribe+") && email.endsWith('@stamhoofd.email')) {
30
- // Get id
31
- const id = email.substring("unsubscribe+".length, email.indexOf('@stamhoofd.email'))
32
- const model = await EmailAddress.getByID(id)
33
-
34
- if (model) {
35
- console.log('[Unsubscribe] Received an unsubscribe request for ' + model.email + ' from ' + from)
36
- if (model.unsubscribedAll) {
37
- // Ignore
38
- return;
39
- }
40
- model.unsubscribedAll = true
41
- await model.save()
42
- } else {
43
- console.error('[Unsubscribe] Received an unsubscribe request for unknown ID ' + id + ' from ' + from)
29
+ for (const domain of Object.values(STAMHOOFD.domains.defaultBroadcastEmail ?? {})) {
30
+ if (email && email?.startsWith("unsubscribe+") && email.endsWith('@' + domain)) {
31
+ // Get id
32
+ const id = email.substring("unsubscribe+".length, email.indexOf('@' + domain))
33
+ const model = await EmailAddress.getByID(id)
34
+
35
+ if (model) {
36
+ console.log('[Unsubscribe] Received an unsubscribe request for ' + model.email + ' from ' + from)
37
+ if (model.unsubscribedAll) {
38
+ // Ignore
39
+ return;
40
+ }
41
+ model.unsubscribedAll = true
42
+ await model.save()
43
+ } else {
44
+ console.error('[Unsubscribe] Received an unsubscribe request for unknown ID ' + id + ' from ' + from)
44
45
 
45
- // Forward
46
- return {
47
- from: "unsubscribe@stamhoofd.be",
48
- to: "hallo@stamhoofd.be",
49
- subject: "E-mail unsubscribe mislukt",
50
- text: "Beste,\n\nEr werd een unsubscribe gemeld op "+email+" die niet kon worden verwerkt. Gelieve dit na te kijken.\n\nStamhoofd"
51
- }
46
+ // Forward
47
+ return {
48
+ from: Email.getWebmasterFromEmail(),
49
+ to: Email.getWebmasterToEmail(),
50
+ subject: "E-mail unsubscribe mislukt",
51
+ text: "Beste,\n\nEr werd een unsubscribe gemeld op "+email+" die niet kon worden verwerkt. Gelieve dit na te kijken.\n\nStamhoofd"
52
+ }
52
53
 
54
+ }
55
+ return;
53
56
  }
54
- return;
55
57
  }
56
58
 
57
59
  if (receipt.spamVerdict.status != "PASS" || receipt.virusVerdict.status != "PASS" || !(receipt.spfVerdict.status == "PASS" || receipt.dkimVerdict.status == "PASS")) {
@@ -60,7 +62,7 @@ export class ForwardHandler {
60
62
  }
61
63
 
62
64
  // Send a new e-mail
63
- let defaultEmail: EmailInterfaceRecipient[]|string = "hallo@stamhoofd.be"
65
+ let defaultEmail: EmailInterfaceRecipient[]|string = Email.getWebmasterToEmail()
64
66
  let organizationEmails: EmailInterfaceRecipient[] = []
65
67
  const extraDescription = "Dit bericht werd verstuurd naar "+email+", en werd automatisch doorgestuurd naar alle beheerders. Stel in Stamhoofd de e-mailadressen in om ervoor te zorgen dat antwoorden naar een specifiek e-mailadres worden verstuurd."
66
68
 
@@ -76,7 +78,7 @@ export class ForwardHandler {
76
78
 
77
79
  // Send back to receiver without including the original message to avoid spam
78
80
  return {
79
- from: email ?? "unknown@stamhoofd.be",
81
+ from: email ?? Email.getWebmasterToEmail(),
80
82
  to: from,
81
83
  subject: "Ongeldig e-mailadres",
82
84
  text: "Beste,\n\nDe vereniging die je probeert te bereiken via "+email+" is helaas niet bereikbaar via dit e-mailadres. Dit e-mailadres wordt enkel gebruikt voor het versturen van automatische e-mails in naam van een vereniging. Probeer de vereniging te contacteren via een ander e-mailadres.\n\nBedankt."
@@ -116,7 +118,7 @@ export class ForwardHandler {
116
118
  }
117
119
 
118
120
  const options = {
119
- from: email ?? "unknown@stamhoofd.be",
121
+ from: email ?? Email.getWebmasterToEmail(),
120
122
  to: defaultEmail,
121
123
  replyTo: parsed.from?.text,
122
124
  subject: parsed.subject ?? "Doorgestuurd bericht",
@@ -137,4 +139,4 @@ export class ForwardHandler {
137
139
 
138
140
  return options
139
141
  }
140
- }
142
+ }
@@ -116,14 +116,14 @@ export class MemberUserSyncerStatic {
116
116
  if (organizationId === null) {
117
117
  const patch = user.permissions.convertPlatformPatch(
118
118
  Permissions.patch({
119
- responsibilities: (responsibilitiesByOrganization.get(organizationId) ?? []).map(r => r.getStructure()) as any
119
+ responsibilities: (responsibilitiesByOrganization.get(organizationId) ?? []).map(r => r.getBaseStructure()) as any
120
120
  })
121
121
  )
122
122
  user.permissions = user.permissions.patch(patch)
123
123
  } else {
124
124
  const patch = user.permissions.convertPatch(
125
125
  Permissions.patch({
126
- responsibilities: (responsibilitiesByOrganization.get(organizationId) ?? []).map(r => r.getStructure()) as any
126
+ responsibilities: (responsibilitiesByOrganization.get(organizationId) ?? []).map(r => r.getBaseStructure()) as any
127
127
  }),
128
128
  organizationId
129
129
  )
@@ -164,7 +164,7 @@ export class MemberUserSyncerStatic {
164
164
  let user = member.users.find(u => u.email.toLocaleLowerCase() === email.toLocaleLowerCase()) ?? await User.getForAuthentication(member.organizationId, email, {allowWithoutAccount: true})
165
165
 
166
166
  if (user) {
167
- console.log("Giving an existing user access to a member: " + user.id + ' - ' + member.id)
167
+ //console.log("Giving an existing user access to a member: " + user.id + ' - ' + member.id)
168
168
  if (!asParent) {
169
169
  if (user.memberId && user.memberId !== member.id) {
170
170
  console.error('Found conflicting user with multiple members', user.id, 'members', user.memberId, 'to', member.id)
@@ -0,0 +1,166 @@
1
+
2
+ import { Member, MemberResponsibilityRecord, Organization, OrganizationRegistrationPeriod, Platform, RegistrationPeriod } from "@stamhoofd/models";
3
+ import { AuthenticatedStructures } from "./AuthenticatedStructures";
4
+ import { PatchOrganizationRegistrationPeriodsEndpoint } from "../endpoints/organization/dashboard/registration-periods/PatchOrganizationRegistrationPeriodsEndpoint";
5
+ import { QueueHandler } from "@stamhoofd/queues";
6
+ import { SetupStepUpdater } from "./SetupStepsUpdater";
7
+ import { PermissionLevel } from "@stamhoofd/structures";
8
+ import { MemberUserSyncer } from "./MemberUserSyncer";
9
+ import { SimpleError } from "@simonbackx/simple-errors";
10
+
11
+ export class PeriodHelper {
12
+ static async moveOrganizationToPeriod(organization: Organization, period: RegistrationPeriod) {
13
+ console.log('moveOrganizationToPeriod', organization.id, period.id)
14
+
15
+ await this.createOrganizationPeriodForPeriod(organization, period)
16
+ organization.periodId = period.id
17
+ await organization.save()
18
+ }
19
+
20
+ static async stopAllResponsibilities() {
21
+ console.log('Stopping all responsibilities')
22
+ const platform = await Platform.getSharedPrivateStruct()
23
+ const keepPlatformResponsibilityIds = platform.config.responsibilities.filter(r => !r.organizationBased).map(r => r.id)
24
+ const keepResponsibilityIds = platform.config.responsibilities.filter(r => !r.organizationBased || r.permissions?.level === PermissionLevel.Full).map(r => r.id)
25
+ const batchSize = 100;
26
+
27
+ let lastId = "";
28
+ let c = 0;
29
+
30
+ while (true) {
31
+ const records = await MemberResponsibilityRecord.where(
32
+ {
33
+ id: { sign: ">", value: lastId },
34
+ endDate: null
35
+ },
36
+ {
37
+ limit: batchSize,
38
+ sort: ["id"]
39
+ }
40
+ );
41
+
42
+ for (const record of records) {
43
+ lastId = record.id;
44
+
45
+ const invalid = keepPlatformResponsibilityIds.includes(record.responsibilityId) && record.organizationId
46
+
47
+ if (!keepResponsibilityIds.includes(record.responsibilityId) || invalid) {
48
+ record.endDate = new Date()
49
+ await record.save()
50
+ c++;
51
+ }
52
+ }
53
+
54
+ if (records.length < batchSize) {
55
+ break;
56
+ }
57
+
58
+ }
59
+
60
+ console.log('Done: stopped all responsibilities: ' + c)
61
+ }
62
+
63
+ static async syncAllMemberUsers() {
64
+ console.log('Syncing all members')
65
+
66
+ let c = 0;
67
+ let lastId: string = '';
68
+
69
+ while(true) {
70
+ const rawMembers = await Member.where({
71
+ id: {
72
+ value: lastId,
73
+ sign: '>'
74
+ }
75
+ }, {limit: 500, sort: ['id']});
76
+
77
+ if (rawMembers.length === 0) {
78
+ break;
79
+ }
80
+
81
+ const membersWithRegistrations = await Member.getBlobByIds(...rawMembers.map(m => m.id));
82
+
83
+ const promises: Promise<any>[] = [];
84
+
85
+ for (const memberWithRegistrations of membersWithRegistrations) {
86
+ promises.push((async () => {
87
+ await MemberUserSyncer.onChangeMember(memberWithRegistrations);
88
+ c++;
89
+
90
+ if (c%10000 === 0) {
91
+ console.log('Synced ' + c + ' members');
92
+ }
93
+ })());
94
+ }
95
+
96
+ await Promise.all(promises);
97
+ lastId = rawMembers[rawMembers.length - 1].id;
98
+ }
99
+
100
+ console.log('Done: synced all members: ' + c)
101
+ }
102
+
103
+ static async createOrganizationPeriodForPeriod(organization: Organization, period: RegistrationPeriod) {
104
+ const oPeriods = await OrganizationRegistrationPeriod.where({ periodId: period.id, organizationId: organization.id }, {limit: 1})
105
+
106
+ if (oPeriods.length) {
107
+ // Already created
108
+ return oPeriods[0]
109
+ }
110
+
111
+ const currentPeriod = await organization.getPeriod()
112
+ if (currentPeriod.periodId === period.id) {
113
+ return currentPeriod
114
+ }
115
+
116
+ const struct = await AuthenticatedStructures.organizationRegistrationPeriod(currentPeriod)
117
+
118
+ const duplicate = struct.duplicate(period.getStructure())
119
+ return await PatchOrganizationRegistrationPeriodsEndpoint.createOrganizationPeriod(organization, duplicate)
120
+ }
121
+
122
+ static async moveAllOrganizationsToPeriod(period: RegistrationPeriod) {
123
+ const tag = "moveAllOrganizationsToPeriod";
124
+ if (QueueHandler.isRunning(tag)) {
125
+ throw new SimpleError({
126
+ code: 'move_period_pending',
127
+ message: 'Er is al een jaarovergang bezig. Wacht tot deze klaar is.'
128
+ })
129
+ }
130
+
131
+ const batchSize = 10;
132
+ await QueueHandler.schedule(tag, async () => {
133
+ let lastId = "";
134
+
135
+ while (true) {
136
+ const organizations = await Organization.where(
137
+ {
138
+ id: { sign: ">", value: lastId },
139
+ },
140
+ {
141
+ limit: batchSize,
142
+ sort: ["id"]
143
+ }
144
+ );
145
+
146
+ for (const organization of organizations) {
147
+ await this.moveOrganizationToPeriod(organization, period);
148
+ lastId = organization.id;
149
+ }
150
+
151
+ if (organizations.length < batchSize) {
152
+ break;
153
+ }
154
+
155
+ }
156
+
157
+ await this.stopAllResponsibilities()
158
+ await this.syncAllMemberUsers()
159
+ });
160
+
161
+ // When done: update setup steps
162
+ await SetupStepUpdater.updateSetupStepsForAllOrganizationsInCurrentPeriod()
163
+ }
164
+
165
+
166
+ }
@@ -1,5 +1,6 @@
1
1
  import {
2
2
  Group,
3
+ Member,
3
4
  MemberResponsibilityRecord,
4
5
  Organization,
5
6
  OrganizationRegistrationPeriod,
@@ -8,11 +9,13 @@ import {
8
9
  import { QueueHandler } from "@stamhoofd/queues";
9
10
  import { SQL, SQLWhereSign } from "@stamhoofd/sql";
10
11
  import {
12
+ GroupType,
11
13
  MemberResponsibility,
12
14
  Platform as PlatformStruct,
13
15
  SetupStepType,
14
16
  SetupSteps
15
17
  } from "@stamhoofd/structures";
18
+ import { Formatter } from "@stamhoofd/utility";
16
19
 
17
20
  type SetupStepOperation = (setupSteps: SetupSteps, organization: Organization, platform: PlatformStruct) => void | Promise<void>;
18
21
 
@@ -148,9 +151,7 @@ export class SetupStepUpdater {
148
151
  ) {
149
152
  const setupSteps = organizationRegistrationPeriod.setupSteps;
150
153
 
151
- // eslint-disable-next-line @typescript-eslint/no-unsafe-argument
152
154
  for (const stepType of Object.values(SetupStepType)) {
153
- console.log(`[STEP TYPE] ${stepType}`);
154
155
  const operation = this.STEP_TYPE_OPERATIONS[stepType];
155
156
  await operation(setupSteps, organization, platform);
156
157
  }
@@ -244,12 +245,21 @@ export class SetupStepUpdater {
244
245
 
245
246
  const responsibilityIds = organizationBasedResponsibilitiesWithRestriction.map(r => r.id);
246
247
 
247
- const records = await MemberResponsibilityRecord.select()
248
+ const allRecords = await MemberResponsibilityRecord.select()
248
249
  .where('responsibilityId', responsibilityIds)
249
250
  .where('organizationId', organization.id)
250
251
  .where(SQL.where('endDate', SQLWhereSign.Greater, now).or('endDate', null))
251
252
  .fetch();
252
253
 
254
+ // Remove invalid responsibilities: members that are not registered in the current period
255
+ const memberIds = Formatter.uniqueArray(allRecords.map(r => r.memberId));
256
+ const members = await Member.getBlobByIds(...memberIds);
257
+ const validMembers = members.filter(m => m.registrations.some(r => r.organizationId === organization.id && r.periodId === organization.periodId && r.group.type === GroupType.Membership && r.deactivatedAt === null && r.registeredAt !== null));
258
+
259
+ const validMembersIds = validMembers.map(m => m.id);
260
+
261
+ const records = allRecords.filter(r => validMembersIds.includes(r.memberId));
262
+
253
263
  let totalSteps = 0;
254
264
  let finishedSteps = 0;
255
265
 
@@ -279,11 +289,11 @@ export class SetupStepUpdater {
279
289
  for(const {responsibility, group} of flatResponsibilities) {
280
290
  const { minimumMembers: min, maximumMembers: max } = responsibility;
281
291
 
282
- if (min === null && max === null) {
292
+ if (min === null) {
283
293
  continue;
284
294
  }
285
295
 
286
- totalSteps++;
296
+ totalSteps += min;
287
297
 
288
298
  const responsibilityId = responsibility.id;
289
299
  let totalRecordsWithThisResponsibility = 0;
@@ -303,14 +313,11 @@ export class SetupStepUpdater {
303
313
  }
304
314
 
305
315
  if (max !== null && totalRecordsWithThisResponsibility > max) {
316
+ // Not added
306
317
  continue;
307
318
  }
308
319
 
309
- if (min !== null && totalRecordsWithThisResponsibility < min) {
310
- continue;
311
- }
312
-
313
- finishedSteps++;
320
+ finishedSteps += Math.min(min, totalRecordsWithThisResponsibility);
314
321
  }
315
322
 
316
323
  setupSteps.update(SetupStepType.Responsibilities, {