@stamhoofd/backend 2.91.0 → 2.92.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/audit-logs/EmailLogger.ts +4 -4
- package/src/crons/endFunctionsOfUsersWithoutRegistration.ts +6 -0
- package/src/endpoints/global/email/CreateEmailEndpoint.ts +29 -5
- package/src/endpoints/global/email/GetAdminEmailsEndpoint.ts +207 -0
- package/src/endpoints/global/email/GetEmailEndpoint.ts +5 -1
- package/src/endpoints/global/email/PatchEmailEndpoint.test.ts +404 -8
- package/src/endpoints/global/email/PatchEmailEndpoint.ts +67 -22
- package/src/endpoints/organization/dashboard/webshops/DeleteWebshopEndpoint.ts +10 -1
- package/src/endpoints/organization/dashboard/webshops/PatchWebshopOrdersEndpoint.ts +8 -1
- package/src/endpoints/organization/webshops/PlaceOrderEndpoint.ts +2 -67
- package/src/helpers/AdminPermissionChecker.ts +81 -5
- package/src/seeds/1752848560-groups-registration-periods.ts +768 -0
- package/src/seeds/1755181288-remove-duplicate-members.ts +145 -0
- package/src/seeds/1755532883-update-email-sender-ids.ts +47 -0
- package/src/services/uitpas/UitpasService.ts +71 -2
- package/src/services/uitpas/checkUitpasNumbers.ts +1 -0
- package/src/sql-filters/emails.ts +65 -0
- package/src/sql-sorters/emails.ts +47 -0
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { Migration, SQLResultNamespacedRow } from '@simonbackx/simple-database';
|
|
2
|
+
import { Member, mergeTwoMembers } from '@stamhoofd/models';
|
|
3
|
+
import { SQL, SQLSelect } from '@stamhoofd/sql';
|
|
4
|
+
import { PatchOrganizationMembersEndpoint } from '../endpoints/global/members/PatchOrganizationMembersEndpoint';
|
|
5
|
+
|
|
6
|
+
type MergeType = {
|
|
7
|
+
a: {
|
|
8
|
+
id: string;
|
|
9
|
+
firstName: string;
|
|
10
|
+
lastName: string;
|
|
11
|
+
createdAt: Date;
|
|
12
|
+
};
|
|
13
|
+
b: {
|
|
14
|
+
id: string;
|
|
15
|
+
createdAt: Date;
|
|
16
|
+
};
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export default new Migration(async () => {
|
|
20
|
+
if (STAMHOOFD.environment === 'test') {
|
|
21
|
+
console.log('skipped in tests');
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const q = new SQLSelect(
|
|
26
|
+
(row: SQLResultNamespacedRow): MergeType => {
|
|
27
|
+
return {
|
|
28
|
+
a: {
|
|
29
|
+
id: row['a'].id as string,
|
|
30
|
+
firstName: row['a'].firstName as string,
|
|
31
|
+
lastName: row['a'].lastName as string,
|
|
32
|
+
createdAt: row['a'].createdAt as Date,
|
|
33
|
+
},
|
|
34
|
+
b: {
|
|
35
|
+
id: row['b'].id as string,
|
|
36
|
+
createdAt: row['b'].createdAt as Date,
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
},
|
|
40
|
+
SQL.wildcard('a'),
|
|
41
|
+
SQL.wildcard('b'),
|
|
42
|
+
)
|
|
43
|
+
.from(Member.table, 'a')
|
|
44
|
+
.join(
|
|
45
|
+
SQL.join(Member.table, 'b')
|
|
46
|
+
.where(
|
|
47
|
+
SQL.column('b', 'id'),
|
|
48
|
+
'!=',
|
|
49
|
+
SQL.column('a', 'id'),
|
|
50
|
+
)
|
|
51
|
+
.andWhere(
|
|
52
|
+
SQL.column('b', 'firstName'),
|
|
53
|
+
SQL.column('a', 'firstName'),
|
|
54
|
+
)
|
|
55
|
+
.andWhere(
|
|
56
|
+
SQL.column('b', 'lastName'),
|
|
57
|
+
SQL.column('a', 'lastName'),
|
|
58
|
+
)
|
|
59
|
+
.andWhere(
|
|
60
|
+
SQL.column('b', 'birthDay'),
|
|
61
|
+
SQL.column('a', 'birthDay'),
|
|
62
|
+
),
|
|
63
|
+
)
|
|
64
|
+
.where(
|
|
65
|
+
SQL.where(
|
|
66
|
+
SQL.column('a', 'createdAt'),
|
|
67
|
+
'<',
|
|
68
|
+
SQL.column('b', 'createdAt'),
|
|
69
|
+
).or(
|
|
70
|
+
SQL.where(
|
|
71
|
+
SQL.column('b', 'createdAt'),
|
|
72
|
+
SQL.column('a', 'createdAt'),
|
|
73
|
+
).and(
|
|
74
|
+
SQL.column('a', 'id'),
|
|
75
|
+
'<',
|
|
76
|
+
SQL.column('b', 'id'),
|
|
77
|
+
),
|
|
78
|
+
),
|
|
79
|
+
)
|
|
80
|
+
.orderBy(
|
|
81
|
+
SQL.column('a', 'createdAt'),
|
|
82
|
+
'ASC',
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
if (STAMHOOFD.userMode === 'organization') {
|
|
86
|
+
q.where(
|
|
87
|
+
SQL.column('b', 'organizationId'),
|
|
88
|
+
SQL.column('organizationId'),
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const duplicates = await q.fetch();
|
|
93
|
+
|
|
94
|
+
const deletedSet = new Set<string>();
|
|
95
|
+
const mergedIntoSet = new Set<string>();
|
|
96
|
+
|
|
97
|
+
for (const duplicate of duplicates) {
|
|
98
|
+
if (mergedIntoSet.has(duplicate.b.id)) {
|
|
99
|
+
console.log('Found chained duplicate in wrong order', duplicate.a.id, 'and', duplicate.b.id);
|
|
100
|
+
continue; // Already merged this one
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (deletedSet.has(duplicate.b.id)) {
|
|
104
|
+
continue; // Already deleted this one
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (deletedSet.has(duplicate.a.id)) {
|
|
108
|
+
console.log('Skipping duplicate', duplicate.a.id, 'because it was already deleted');
|
|
109
|
+
continue; // Already deleted this one
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
console.log(
|
|
113
|
+
'Found duplicate member',
|
|
114
|
+
duplicate.a.id,
|
|
115
|
+
duplicate.a.createdAt,
|
|
116
|
+
'and',
|
|
117
|
+
duplicate.b.id,
|
|
118
|
+
duplicate.b.createdAt,
|
|
119
|
+
'with name',
|
|
120
|
+
duplicate.a.firstName, duplicate.a.lastName);
|
|
121
|
+
deletedSet.add(duplicate.b.id);
|
|
122
|
+
mergedIntoSet.add(duplicate.a.id);
|
|
123
|
+
|
|
124
|
+
// Run the merge
|
|
125
|
+
const [memberA] = await Member.getBlobByIds(duplicate.a.id);
|
|
126
|
+
const [memberB] = await Member.getBlobByIds(duplicate.b.id);
|
|
127
|
+
|
|
128
|
+
if (memberA.details.name !== memberB.details.name) {
|
|
129
|
+
console.warn('Member names do not match', memberA.details.name, 'and', memberB.details.name);
|
|
130
|
+
continue; // Names do not match, cannot merge
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (memberA.details.birthDayFormatted === null || memberA.details.birthDayFormatted !== memberB.details.birthDayFormatted) {
|
|
134
|
+
console.warn('Member birthday do not match', memberA.details.birthDayFormatted, 'and', memberB.details.birthDayFormatted);
|
|
135
|
+
continue; // Names do not match, cannot merge
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (!PatchOrganizationMembersEndpoint.shouldCheckIfMemberIsDuplicate(memberA)) {
|
|
139
|
+
console.log('Skipping merge because not eligible for duplicate check');
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
await mergeTwoMembers(memberA, memberB);
|
|
144
|
+
}
|
|
145
|
+
});
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { Migration } from '@simonbackx/simple-database';
|
|
2
|
+
import { Email, Organization, Platform } from '@stamhoofd/models';
|
|
3
|
+
|
|
4
|
+
export default new Migration(async () => {
|
|
5
|
+
if (STAMHOOFD.environment === 'test') {
|
|
6
|
+
console.log('skipped in tests');
|
|
7
|
+
return;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
console.log('Start setting senderId of emails.');
|
|
11
|
+
|
|
12
|
+
const batchSize = 100;
|
|
13
|
+
let count = 0;
|
|
14
|
+
const platform = await Platform.getShared();
|
|
15
|
+
|
|
16
|
+
for await (const email of Email.select()
|
|
17
|
+
.where('senderId', null).limit(batchSize).all()) {
|
|
18
|
+
if (!email.fromAddress) {
|
|
19
|
+
continue;
|
|
20
|
+
}
|
|
21
|
+
const organization = email.organizationId ? await Organization.getByID(email.organizationId) : null;
|
|
22
|
+
if (!organization) {
|
|
23
|
+
const sender = platform.privateConfig.emails.find(s => s.email === email.fromAddress);
|
|
24
|
+
if (sender) {
|
|
25
|
+
email.senderId = sender.id;
|
|
26
|
+
await email.save();
|
|
27
|
+
count += 1;
|
|
28
|
+
}
|
|
29
|
+
else {
|
|
30
|
+
console.warn(`No sender found for email ${email.fromAddress} in platform config`);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
else {
|
|
34
|
+
const sender = organization.privateMeta.emails.find(s => s.email === email.fromAddress);
|
|
35
|
+
if (sender) {
|
|
36
|
+
email.senderId = sender.id;
|
|
37
|
+
await email.save();
|
|
38
|
+
count += 1;
|
|
39
|
+
}
|
|
40
|
+
else {
|
|
41
|
+
console.warn(`No sender found for email ${email.fromAddress} in organization ${organization.id}`);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
console.log('Finished saving ' + count + ' emails.');
|
|
47
|
+
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Model } from '@simonbackx/simple-database';
|
|
2
2
|
import { Order, WebshopUitpasNumber } from '@stamhoofd/models';
|
|
3
|
-
import { OrderStatus, Product, ProductPrice, UitpasClientCredentialsStatus, UitpasOrganizersResponse } from '@stamhoofd/structures';
|
|
3
|
+
import { Cart, OrderStatus, Product, ProductPrice, UitpasClientCredentialsStatus, UitpasOrganizersResponse } from '@stamhoofd/structures';
|
|
4
4
|
import { v4 as uuidv4 } from 'uuid';
|
|
5
5
|
import { UitpasTokenRepository } from '../../helpers/UitpasTokenRepository';
|
|
6
6
|
import { searchUitpasOrganizers } from './searchUitpasOrganizers';
|
|
@@ -13,6 +13,7 @@ import { RegisterTicketSaleRequest, RegisterTicketSaleResponse, registerTicketSa
|
|
|
13
13
|
import { cancelTicketSales } from './cancelTicketSales';
|
|
14
14
|
import { SimpleError } from '@simonbackx/simple-errors';
|
|
15
15
|
import { QueueHandler } from '@stamhoofd/queues';
|
|
16
|
+
import { Formatter } from '@stamhoofd/utility';
|
|
16
17
|
|
|
17
18
|
type UitpasTicketSale = {
|
|
18
19
|
basePrice: number;
|
|
@@ -39,7 +40,7 @@ type InsertUitpasNumber = {
|
|
|
39
40
|
uitpasEventUrl: string | null; // null for non-official flow
|
|
40
41
|
};
|
|
41
42
|
|
|
42
|
-
function shouldReserveUitpasNumbers(status: OrderStatus): boolean {
|
|
43
|
+
export function shouldReserveUitpasNumbers(status: OrderStatus): boolean {
|
|
43
44
|
return status !== OrderStatus.Canceled && status !== OrderStatus.Deleted;
|
|
44
45
|
}
|
|
45
46
|
|
|
@@ -356,4 +357,72 @@ export class UitpasService {
|
|
|
356
357
|
// Clear the uitpas client credentials for the organization
|
|
357
358
|
await UitpasTokenRepository.clearClientCredentialsFor(organizationId);
|
|
358
359
|
}
|
|
360
|
+
|
|
361
|
+
static async areThereRegisteredTicketSales(webshopId: string): Promise<boolean> {
|
|
362
|
+
return await WebshopUitpasNumber.areThereRegisteredTicketSales(webshopId);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
static async validateCart(organizationId: string, webshopId: string, cart: Cart, exisitingOrderId?: string): Promise<Cart> {
|
|
366
|
+
let access_token_org: string | null = null;
|
|
367
|
+
let access_token_platform: string | null = null;
|
|
368
|
+
for (const item of cart.items) {
|
|
369
|
+
if (item.uitpasNumbers.length === 0) {
|
|
370
|
+
continue;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// verify the UiTPAS numbers are not already used for this product
|
|
374
|
+
const hasBeenUsed = await WebshopUitpasNumber.areUitpasNumbersUsed(webshopId, item.product.id, item.uitpasNumbers.map(p => p.uitpasNumber), item.product.uitpasEvent?.url, exisitingOrderId);
|
|
375
|
+
if (hasBeenUsed) {
|
|
376
|
+
throw new SimpleError({
|
|
377
|
+
code: 'uitpas_number_already_used',
|
|
378
|
+
message: 'One or more uitpas numbers are already used',
|
|
379
|
+
human: $t('f3daff19-a227-4e45-b19a-c770bd7a6687'),
|
|
380
|
+
field: 'uitpasNumbers',
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
if (item.product.uitpasEvent) {
|
|
385
|
+
// official flow
|
|
386
|
+
const basePrice = item.product.prices.filter(price => price.id === item.productPrice.uitpasBaseProductPriceId)[0]?.price;
|
|
387
|
+
if (!basePrice) {
|
|
388
|
+
throw new SimpleError({
|
|
389
|
+
code: 'missing_uitpas_base_product_price',
|
|
390
|
+
message: `Missing UiTPAS base product price`,
|
|
391
|
+
human: $t(`3d08a166-11a7-4429-8ff7-84458bbe3e9a`),
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
access_token_org = access_token_org ?? await UitpasTokenRepository.getAccessTokenFor(organizationId);
|
|
396
|
+
const verified = await getSocialTariffForUitpasNumbers(access_token_org, item.uitpasNumbers.map(p => p.uitpasNumber), basePrice, item.product.uitpasEvent.url);
|
|
397
|
+
if (verified.length < item.uitpasNumbers.length) {
|
|
398
|
+
throw new SimpleError({
|
|
399
|
+
code: 'uitpas_social_tariff_price_mismatch',
|
|
400
|
+
message: 'UiTPAS wrong number of prices returned',
|
|
401
|
+
human: $t('83c472b8-4bc5-4282-bbc9-1c6a2d382171'),
|
|
402
|
+
field: 'uitpasNumbers',
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
for (let i = 0; i < verified.length; i++) {
|
|
406
|
+
if (item.uitpasNumbers[i].uitpasTariffId !== verified[i].uitpasTariffId) {
|
|
407
|
+
// silently update
|
|
408
|
+
item.uitpasNumbers[i].uitpasTariffId = verified[i].uitpasTariffId;
|
|
409
|
+
}
|
|
410
|
+
if (item.uitpasNumbers[i].price !== verified[i].price) {
|
|
411
|
+
throw new SimpleError({
|
|
412
|
+
code: 'uitpas_social_tariff_price_mismatch',
|
|
413
|
+
message: 'UiTPAS social tariff have a different price',
|
|
414
|
+
human: $t('9a0ad099-99e3-4341-beac-f14feb3fb9d1', { correctPrice: Formatter.price(verified[i].price), orderPrice: Formatter.price(item.uitpasNumbers[i].price) }),
|
|
415
|
+
field: 'uitpasNumbers.' + i.toString(),
|
|
416
|
+
});
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
else {
|
|
421
|
+
// non-official flow
|
|
422
|
+
access_token_platform = access_token_platform ?? await UitpasTokenRepository.getAccessTokenFor();
|
|
423
|
+
await checkUitpasNumbers(access_token_platform, item.uitpasNumbers.map(p => p.uitpasNumber));
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
return cart;
|
|
427
|
+
}
|
|
359
428
|
};
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { baseSQLFilterCompilers, createColumnFilter, SQL, SQLFilterDefinitions, SQLValueType } from '@stamhoofd/sql';
|
|
2
|
+
|
|
3
|
+
export const emailFilterCompilers: SQLFilterDefinitions = {
|
|
4
|
+
...baseSQLFilterCompilers,
|
|
5
|
+
id: createColumnFilter({
|
|
6
|
+
expression: SQL.column('id'),
|
|
7
|
+
type: SQLValueType.String,
|
|
8
|
+
nullable: false,
|
|
9
|
+
}),
|
|
10
|
+
organizationId: createColumnFilter({
|
|
11
|
+
expression: SQL.column('organizationId'),
|
|
12
|
+
type: SQLValueType.String,
|
|
13
|
+
nullable: true,
|
|
14
|
+
}),
|
|
15
|
+
userId: createColumnFilter({
|
|
16
|
+
expression: SQL.column('userId'),
|
|
17
|
+
type: SQLValueType.String,
|
|
18
|
+
nullable: true,
|
|
19
|
+
}),
|
|
20
|
+
emailType: createColumnFilter({
|
|
21
|
+
expression: SQL.column('emailType'),
|
|
22
|
+
type: SQLValueType.String,
|
|
23
|
+
nullable: true,
|
|
24
|
+
}),
|
|
25
|
+
subject: createColumnFilter({
|
|
26
|
+
expression: SQL.column('subject'),
|
|
27
|
+
type: SQLValueType.String,
|
|
28
|
+
nullable: true,
|
|
29
|
+
}),
|
|
30
|
+
fromAddress: createColumnFilter({
|
|
31
|
+
expression: SQL.column('fromAddress'),
|
|
32
|
+
type: SQLValueType.String,
|
|
33
|
+
nullable: true,
|
|
34
|
+
}),
|
|
35
|
+
text: createColumnFilter({
|
|
36
|
+
expression: SQL.column('text'),
|
|
37
|
+
type: SQLValueType.String,
|
|
38
|
+
nullable: true,
|
|
39
|
+
}),
|
|
40
|
+
status: createColumnFilter({
|
|
41
|
+
expression: SQL.column('status'),
|
|
42
|
+
type: SQLValueType.String,
|
|
43
|
+
nullable: false,
|
|
44
|
+
}),
|
|
45
|
+
recipientStatus: createColumnFilter({
|
|
46
|
+
expression: SQL.column('recipientStatus'),
|
|
47
|
+
type: SQLValueType.String,
|
|
48
|
+
nullable: false,
|
|
49
|
+
}),
|
|
50
|
+
recipientCount: createColumnFilter({
|
|
51
|
+
expression: SQL.column('recipientCount'),
|
|
52
|
+
type: SQLValueType.Number,
|
|
53
|
+
nullable: true,
|
|
54
|
+
}),
|
|
55
|
+
createdAt: createColumnFilter({
|
|
56
|
+
expression: SQL.column('createdAt'),
|
|
57
|
+
type: SQLValueType.Datetime,
|
|
58
|
+
nullable: true,
|
|
59
|
+
}),
|
|
60
|
+
sentAt: createColumnFilter({
|
|
61
|
+
expression: SQL.column('sentAt'),
|
|
62
|
+
type: SQLValueType.Datetime,
|
|
63
|
+
nullable: true,
|
|
64
|
+
}),
|
|
65
|
+
};
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { Email } from '@stamhoofd/models';
|
|
2
|
+
import { SQL, SQLOrderBy, SQLOrderByDirection, SQLSortDefinitions } from '@stamhoofd/sql';
|
|
3
|
+
import { Formatter } from '@stamhoofd/utility';
|
|
4
|
+
|
|
5
|
+
export const emailSorters: SQLSortDefinitions<Email> = {
|
|
6
|
+
// WARNING! TEST NEW SORTERS THOROUGHLY!
|
|
7
|
+
// Try to avoid creating sorters on fields that er not 1:1 with the database, that often causes pagination issues if not thought through
|
|
8
|
+
// An example: sorting on 'name' is not a good idea, because it is a concatenation of two fields.
|
|
9
|
+
// You might be tempted to use ORDER BY firstName, lastName, but that will not work as expected and it needs to be ORDER BY CONCAT(firstName, ' ', lastName)
|
|
10
|
+
// Why? Because ORDER BY firstName, lastName produces a different order dan ORDER BY CONCAT(firstName, ' ', lastName) if there are multiple people with spaces in the first name
|
|
11
|
+
// And that again causes issues with pagination because the next query will append a filter of name > 'John Doe' - causing duplicate and/or skipped results
|
|
12
|
+
// What if you need mapping? simply map the sorters in the frontend: name -> firstname, lastname, age -> birthDay, etc.
|
|
13
|
+
|
|
14
|
+
id: {
|
|
15
|
+
getValue(a) {
|
|
16
|
+
return a.id;
|
|
17
|
+
},
|
|
18
|
+
toSQL: (direction: SQLOrderByDirection): SQLOrderBy => {
|
|
19
|
+
return new SQLOrderBy({
|
|
20
|
+
column: SQL.column('id'),
|
|
21
|
+
direction,
|
|
22
|
+
});
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
createdAt: {
|
|
26
|
+
getValue(a) {
|
|
27
|
+
return Formatter.dateTimeIso(a.createdAt, 'UTC');
|
|
28
|
+
},
|
|
29
|
+
toSQL: (direction: SQLOrderByDirection): SQLOrderBy => {
|
|
30
|
+
return new SQLOrderBy({
|
|
31
|
+
column: SQL.column('createdAt'),
|
|
32
|
+
direction,
|
|
33
|
+
});
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
sentAt: {
|
|
37
|
+
getValue(a) {
|
|
38
|
+
return a.sentAt ? Formatter.dateTimeIso(a.sentAt, 'UTC') : null;
|
|
39
|
+
},
|
|
40
|
+
toSQL: (direction: SQLOrderByDirection): SQLOrderBy => {
|
|
41
|
+
return new SQLOrderBy({
|
|
42
|
+
column: SQL.column('sentAt'),
|
|
43
|
+
direction,
|
|
44
|
+
});
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
};
|