@stamhoofd/backend 2.90.1 → 2.90.3

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.90.1",
3
+ "version": "2.90.3",
4
4
  "main": "./dist/index.js",
5
5
  "exports": {
6
6
  ".": {
@@ -45,14 +45,14 @@
45
45
  "@simonbackx/simple-encoding": "2.22.0",
46
46
  "@simonbackx/simple-endpoints": "1.20.1",
47
47
  "@simonbackx/simple-logging": "^1.0.1",
48
- "@stamhoofd/backend-i18n": "2.90.1",
49
- "@stamhoofd/backend-middleware": "2.90.1",
50
- "@stamhoofd/email": "2.90.1",
51
- "@stamhoofd/models": "2.90.1",
52
- "@stamhoofd/queues": "2.90.1",
53
- "@stamhoofd/sql": "2.90.1",
54
- "@stamhoofd/structures": "2.90.1",
55
- "@stamhoofd/utility": "2.90.1",
48
+ "@stamhoofd/backend-i18n": "2.90.3",
49
+ "@stamhoofd/backend-middleware": "2.90.3",
50
+ "@stamhoofd/email": "2.90.3",
51
+ "@stamhoofd/models": "2.90.3",
52
+ "@stamhoofd/queues": "2.90.3",
53
+ "@stamhoofd/sql": "2.90.3",
54
+ "@stamhoofd/structures": "2.90.3",
55
+ "@stamhoofd/utility": "2.90.3",
56
56
  "archiver": "^7.0.1",
57
57
  "axios": "^1.8.2",
58
58
  "cookie": "^0.7.0",
@@ -70,5 +70,5 @@
70
70
  "publishConfig": {
71
71
  "access": "public"
72
72
  },
73
- "gitHead": "9a720678a4d4699e555f38c9d97779da5818d712"
73
+ "gitHead": "81248f4f4d578fb67e29c48c55ddb1a1beb12313"
74
74
  }
@@ -1,8 +1,8 @@
1
1
  import { DecodedRequest, Endpoint, Request, Response } from '@simonbackx/simple-endpoints';
2
- import { UitpasService } from '../../../services/uitpas/UitpasService';
2
+ import { UitpasService } from '../../../../services/uitpas/UitpasService';
3
3
  import { UitpasOrganizersResponse } from '@stamhoofd/structures';
4
4
  import { AutoEncoder, Decoder, field, StringDecoder } from '@simonbackx/simple-encoding';
5
- import { Context } from '../../../helpers/Context';
5
+ import { Context } from '../../../../helpers/Context';
6
6
 
7
7
  type Params = Record<string, never>;
8
8
  class Query extends AutoEncoder {
@@ -0,0 +1,51 @@
1
+ import { DecodedRequest, Endpoint, Request, Response } from '@simonbackx/simple-endpoints';
2
+ import { UitpasService } from '../../../../services/uitpas/UitpasService';
3
+ import { AutoEncoder, Decoder, field, StringDecoder } from '@simonbackx/simple-encoding';
4
+ import { Context } from '../../../../helpers/Context';
5
+ import { UitpasEventsResponse } from '@stamhoofd/structures';
6
+ import { SimpleError } from '@simonbackx/simple-errors';
7
+
8
+ type Params = Record<string, never>;
9
+ class Query extends AutoEncoder {
10
+ @field({ decoder: StringDecoder })
11
+ text: string;
12
+ }
13
+ type Body = undefined;
14
+ type ResponseBody = UitpasEventsResponse;
15
+
16
+ export class SearchUitpasEventsEndpoint extends Endpoint<Params, Query, Body, ResponseBody> {
17
+ queryDecoder = Query as Decoder<Query>;
18
+
19
+ protected doesMatch(request: Request): [true, Params] | [false] {
20
+ if (request.method !== 'GET') {
21
+ return [false];
22
+ }
23
+
24
+ const params = Endpoint.parseParameters(request.url, '/organization/search-uitpas-events', {});
25
+
26
+ if (params) {
27
+ return [true, params as Params];
28
+ }
29
+ return [false];
30
+ }
31
+
32
+ async handle(request: DecodedRequest<Params, Query, Body>) {
33
+ const organization = await Context.setOrganizationScope();
34
+ await Context.authenticate();
35
+
36
+ if (!await Context.auth.hasFullAccess(organization.id)) {
37
+ throw Context.auth.error();
38
+ }
39
+
40
+ if (!organization.meta.uitpasOrganizerId) {
41
+ throw new SimpleError({
42
+ code: 'no_uitpas_organizer_id',
43
+ message: `No UiTPAS organizer ID set for organization`,
44
+ human: $t(`Deze organisatie heeft nog geen UiTPAS-organisatie ingesteld. Stel dit in via de algemene instellingen.`),
45
+ });
46
+ }
47
+ const uitpasOrganizersResponse = await UitpasService.searchUitpasEvents(organization.id, organization.meta.uitpasOrganizerId, request.query.text);
48
+
49
+ return new Response(uitpasOrganizersResponse);
50
+ }
51
+ }
@@ -173,7 +173,7 @@ export class PlaceOrderEndpoint extends Endpoint<Params, Query, Body, ResponseBo
173
173
  if (item.product.uitpasEvent) {
174
174
  const basePrice = item.product.prices.find(p => p.id === item.productPrice.uitpasBaseProductPriceId)?.price ?? 0;
175
175
  const reducedPrices = await UitpasService.getSocialTariffForUitpasNumbers(organization.id, uitpasNumbersOnly, basePrice, item.product.uitpasEvent.url);
176
- const expectedReducedPrices = item.uitpasNumbers.map(p => p.price);
176
+ const expectedReducedPrices = item.uitpasNumbers;
177
177
  if (reducedPrices.length < expectedReducedPrices.length) {
178
178
  // should not happen
179
179
  throw new SimpleError({
@@ -184,14 +184,15 @@ export class PlaceOrderEndpoint extends Endpoint<Params, Query, Body, ResponseBo
184
184
  });
185
185
  }
186
186
  for (let i = 0; i < expectedReducedPrices.length; i++) {
187
- if (reducedPrices[i] !== expectedReducedPrices[i]) {
187
+ if (reducedPrices[i].price !== expectedReducedPrices[i].price) {
188
188
  throw new SimpleError({
189
189
  code: 'uitpas_social_tariff_price_mismatch',
190
190
  message: 'UiTPAS social tariff have a different price',
191
- human: $t('Het kansentarief voor deze UiTPAS is {correctPrice} in plaats van {orderPrice}.', { correctPrice: Formatter.price(reducedPrices[i]), orderPrice: Formatter.price(expectedReducedPrices[i]) }),
191
+ human: $t('Het kansentarief voor deze UiTPAS is {correctPrice} in plaats van {orderPrice}.', { correctPrice: Formatter.price(reducedPrices[i].price), orderPrice: Formatter.price(expectedReducedPrices[i].price) }),
192
192
  field: 'uitpasNumbers.' + i.toString(),
193
193
  });
194
194
  }
195
+ item.uitpasNumbers[i].uitpasTariffId = reducedPrices[i].uitpasTariffId;
195
196
  }
196
197
  }
197
198
  else {
@@ -52,7 +52,7 @@ export class RetrieveUitpasSocialTariffPricesEndpoint extends Endpoint<Params, Q
52
52
  const organization = await Context.setOrganizationScope({ willAuthenticate: false });
53
53
  const reducedPrices = await UitpasService.getSocialTariffForUitpasNumbers(organization.id, request.body.uitpasNumbers, request.body.basePrice, request.body.uitpasEventUrl); // Throws if invalid
54
54
  const uitpasPriceCheckResponse = UitpasPriceCheckResponse.create({
55
- prices: reducedPrices,
55
+ prices: reducedPrices.map(price => price.price), // ignore tariff id's here
56
56
  });
57
57
  return new Response(uitpasPriceCheckResponse);
58
58
  }
@@ -203,10 +203,10 @@ export class UitpasTokenRepository {
203
203
  });
204
204
  }
205
205
 
206
- static async getClientIdFor(organisationId: string | null): Promise<string> {
207
- const repo = UitpasTokenRepository.getRepoFromMemory(organisationId);
206
+ static async getClientIdFor(organizationId: string | null): Promise<string> {
207
+ const repo = UitpasTokenRepository.getRepoFromMemory(organizationId);
208
208
  if (!repo) {
209
- const model = await UitpasClientCredential.select().where('organizationId', organisationId).first(false);
209
+ const model = await UitpasClientCredential.select().where('organizationId', organizationId).first(false);
210
210
  if (!model) {
211
211
  return ''; // no client ID and secret configured
212
212
  }
@@ -1,6 +1,6 @@
1
1
  import { Model } from '@simonbackx/simple-database';
2
2
  import { Order, WebshopUitpasNumber } from '@stamhoofd/models';
3
- import { OrderStatus, UitpasClientCredentialsStatus, UitpasOrganizersResponse } from '@stamhoofd/structures';
3
+ import { 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';
@@ -8,88 +8,225 @@ import { checkPermissionsFor } from './checkPermissionsFor';
8
8
  import { checkUitpasNumbers } from './checkUitpasNumbers';
9
9
  import { getSocialTariffForEvent } from './getSocialTariffForEvent';
10
10
  import { getSocialTariffForUitpasNumbers } from './getSocialTariffForUitpasNumbers';
11
+ import { searchUitpasEvents } from './searchUitpasEvents';
12
+ import { RegisterTicketSaleRequest, RegisterTicketSaleResponse, registerTicketSales } from './registerTicketSales';
13
+ import { cancelTicketSales } from './cancelTicketSales';
14
+ import { SimpleError } from '@simonbackx/simple-errors';
15
+ import { QueueHandler } from '@stamhoofd/queues';
16
+
17
+ type UitpasTicketSale = {
18
+ basePrice: number;
19
+ uitpasNumber: string;
20
+ basePriceLabel: string;
21
+ reducedPrice: number;
22
+ uitpasEventUrl: string | null;
23
+ uitpasTariffId: string | null;
24
+ productId: string;
25
+ };
26
+
27
+ type InsertUitpasNumber = {
28
+ ticketSaleId: string | null;
29
+ reducedPriceUitpas: number | null;
30
+ registeredAt: Date | null;
31
+ webshopId: string;
32
+ orderId: string;
33
+ productId: string;
34
+ uitpasNumber: string;
35
+ basePrice: number;
36
+ reducedPrice: number;
37
+ basePriceLabel: string;
38
+ uitpasTariffId: string | null; // null for non-official flow
39
+ uitpasEventUrl: string | null; // null for non-official flow
40
+ };
11
41
 
12
42
  function shouldReserveUitpasNumbers(status: OrderStatus): boolean {
13
43
  return status !== OrderStatus.Canceled && status !== OrderStatus.Deleted;
14
44
  }
15
45
 
16
- function mapUitpasNumbersToProducts(order: Order): Map<string, string[]> {
17
- const items = order.data.cart.items;
18
- const productIdToUitpasNumbers: Map<string, string[]> = new Map();
19
- for (const item of items) {
20
- const a = productIdToUitpasNumbers.get(item.product.id);
21
- if (a) {
22
- a.push(...item.uitpasNumbers.map(p => p.uitpasNumber));
23
- }
24
- else {
25
- productIdToUitpasNumbers.set(item.product.id, [...item.uitpasNumbers.map(p => p.uitpasNumber)]); // make a copy
46
+ function areThereUitpasChanges(oldTicketSales: UitpasTicketSale[], newTicketSales: UitpasTicketSale[]): boolean {
47
+ if (oldTicketSales.length !== newTicketSales.length) {
48
+ return true;
49
+ }
50
+ for (const oldTicketSale of oldTicketSales) {
51
+ const newTicketSale = newTicketSales.find(
52
+ ts =>
53
+ ts.uitpasNumber === oldTicketSale.uitpasNumber
54
+ && ts.basePrice === oldTicketSale.basePrice
55
+ && ts.basePriceLabel === oldTicketSale.basePriceLabel
56
+ && ts.reducedPrice === oldTicketSale.reducedPrice
57
+ && ts.uitpasTariffId === oldTicketSale.uitpasTariffId
58
+ && ts.uitpasEventUrl === oldTicketSale.uitpasEventUrl
59
+ && ts.productId === oldTicketSale.productId,
60
+ );
61
+ if (!newTicketSale) {
62
+ return true;
26
63
  }
27
64
  }
28
- return productIdToUitpasNumbers;
65
+ return false;
29
66
  }
30
67
 
31
- function areUitpasNumbersChanged(oldOrder: Order, newOrder: Order): boolean {
32
- const oldMap = mapUitpasNumbersToProducts(oldOrder);
33
- const newMap = mapUitpasNumbersToProducts(newOrder);
34
- if (oldMap.size !== newMap.size) {
35
- return true;
68
+ function getUitpasTicketSales(order: Order): UitpasTicketSale[] {
69
+ const ticketSales: UitpasTicketSale[] = [];
70
+ if (!shouldReserveUitpasNumbers(order.status)) {
71
+ return ticketSales;
36
72
  }
37
- for (const [productId, uitpasNumbers] of oldMap.entries()) {
38
- const newUitpasNumbers = newMap.get(productId);
39
- if (!newUitpasNumbers) {
40
- return true;
41
- }
42
- if (newUitpasNumbers.length !== uitpasNumbers.length) {
43
- return true;
44
- }
45
- for (const uitpasNumber of uitpasNumbers) {
46
- if (!newUitpasNumbers.includes(uitpasNumber)) {
47
- return true;
73
+ for (const item of order.data.cart.items) {
74
+ if (item.uitpasNumbers.length > 0) {
75
+ const baseProductPrice = item.product.prices.filter(price => price.id === item.productPrice.uitpasBaseProductPriceId)[0];
76
+ if (!baseProductPrice) {
77
+ throw new SimpleError({
78
+ code: 'missing_uitpas_base_product_price',
79
+ message: `Missing UiTPAS base product price`,
80
+ human: $t(`Er is een fout opgetreden bij het registreren van de UiTPAS ticket verkoop. Probeer het later opnieuw.`),
81
+ });
82
+ }
83
+ const label = makeBaseProductPriceLabel(item.product, baseProductPrice);
84
+ for (const uitpasNumber of item.uitpasNumbers) {
85
+ ticketSales.push({
86
+ productId: item.product.id,
87
+ uitpasNumber: uitpasNumber.uitpasNumber,
88
+ basePrice: baseProductPrice.price,
89
+ reducedPrice: uitpasNumber.price,
90
+ basePriceLabel: label,
91
+ uitpasEventUrl: item.product.uitpasEvent?.url || null,
92
+ uitpasTariffId: uitpasNumber.uitpasTariffId || null,
93
+ });
48
94
  }
49
95
  }
50
96
  }
51
- return false;
97
+ return ticketSales;
98
+ }
99
+
100
+ function makeBaseProductPriceLabel(product: Product, productPrice: ProductPrice): string {
101
+ if (product.name && productPrice.name) {
102
+ return product.name + ' - ' + productPrice.name;
103
+ }
104
+ if (productPrice.name) {
105
+ return productPrice.name;
106
+ }
107
+ return product.name;
52
108
  }
53
109
 
54
110
  export class UitpasService {
55
111
  static listening = false;
56
112
 
57
- static async updateUitpasNumbers(order: Order) {
58
- await this.deleteUitpasNumbers(order);
59
- await this.createUitpasNumbers(order);
113
+ static async getWebshopUitpasNumberFromDb(order: Order): Promise<WebshopUitpasNumber[]> {
114
+ return await WebshopUitpasNumber.select().where('webshopId', order.webshopId).andWhere('orderId', order.id).fetch();
60
115
  }
61
116
 
62
- static async createUitpasNumbers(order: Order) {
63
- const mappedUitpasNumbers = mapUitpasNumbersToProducts(order); // productId -> Set of uitpas numbers
117
+ static async createUitpasNumbers(toBeInserted: InsertUitpasNumber[]) {
118
+ if (toBeInserted.length === 0) {
119
+ return;
120
+ }
64
121
  // add to DB
65
122
  const insert = WebshopUitpasNumber.insert();
66
123
  insert.columns(
67
124
  'id',
125
+ 'ticketSaleId',
126
+ 'reducedPriceUitpas',
127
+ 'registeredAt',
68
128
  'webshopId',
69
129
  'orderId',
70
130
  'productId',
71
131
  'uitpasNumber',
132
+ 'basePrice',
133
+ 'reducedPrice',
134
+ 'basePriceLabel',
135
+ 'uitpasTariffId',
136
+ 'uitpasEventUrl',
72
137
  );
73
- const rows = [...mappedUitpasNumbers].flatMap(([productId, uitpasNumbers]) => {
74
- return uitpasNumbers.map(uitpasNumber => [
138
+ const rows = toBeInserted.map((insert) => {
139
+ return [
75
140
  uuidv4(),
76
- order.webshopId,
77
- order.id,
78
- productId,
79
- uitpasNumber,
80
- ]);
141
+ insert.ticketSaleId,
142
+ insert.reducedPriceUitpas,
143
+ insert.registeredAt,
144
+ insert.webshopId,
145
+ insert.orderId,
146
+ insert.productId,
147
+ insert.uitpasNumber,
148
+ insert.basePrice,
149
+ insert.reducedPrice,
150
+ insert.basePriceLabel,
151
+ insert.uitpasTariffId,
152
+ insert.uitpasEventUrl,
153
+ ];
81
154
  });
82
- if (rows.length === 0) {
83
- // No uitpas numbers to insert, skipping
84
- return;
85
- }
155
+
86
156
  insert.values(...rows);
87
157
  await insert.insert();
88
158
  }
89
159
 
90
- static async deleteUitpasNumbers(order: Order) {
91
- await WebshopUitpasNumber.delete().where('webshopId', order.webshopId)
92
- .andWhere('orderId', order.id);
160
+ static async updateTicketSales(order: Order, isNewOrder: boolean, ticketSalesHint?: UitpasTicketSale[]): Promise<void> {
161
+ const ticketSales = ticketSalesHint ?? getUitpasTicketSales(order);
162
+
163
+ // queue on order, so no race conditions if the same order is updated multiple times in short time period
164
+ return await QueueHandler.schedule('uitpas-order-' + order.id, async () => {
165
+ const registered = isNewOrder ? [] : await this.getWebshopUitpasNumberFromDb(order);
166
+
167
+ const unchangedRegistered: WebshopUitpasNumber[] = [];
168
+ const toBeRegistered: UitpasTicketSale[] = [];
169
+
170
+ for (const ticketSale of ticketSales) {
171
+ const i = registered.findIndex(request => request.uitpasNumber === ticketSale.uitpasNumber && request.basePrice === ticketSale.basePrice && request.basePriceLabel === ticketSale.basePriceLabel);
172
+ if (i !== -1) {
173
+ unchangedRegistered.push(registered[i]);
174
+ registered.splice(i, 1);
175
+ continue; // already registered, so skip
176
+ }
177
+ toBeRegistered.push(ticketSale);
178
+ }
179
+
180
+ const toBeCanceled = registered;
181
+
182
+ // Only register/cancel tickets if official flow is/was used
183
+ const toBeCanceledUitpasIds = toBeCanceled.filter(c => c.uitpasEventUrl && c.uitpasTariffId && c.ticketSaleId).map(c => c.ticketSaleId!);
184
+ const toBeRegisteredUitpasRequests: RegisterTicketSaleRequest[] = toBeRegistered.filter(c => c.uitpasEventUrl && c.uitpasTariffId) as RegisterTicketSaleRequest[];
185
+ const noUitpasTariffId = toBeRegistered.filter(c => c.uitpasEventUrl && !c.uitpasTariffId);
186
+ if (noUitpasTariffId.length > 0) {
187
+ console.warn('Some UiTPAS do not have an uitpasTariffId, although an UiTPAS event is linked (official flow)', noUitpasTariffId);
188
+ }
189
+
190
+ let canceledUitpasId: string[] = [];
191
+ let newlyRegistered: Map<RegisterTicketSaleRequest, RegisterTicketSaleResponse> = new Map();
192
+ if (toBeRegisteredUitpasRequests.length !== 0 || toBeCanceledUitpasIds.length !== 0) {
193
+ const access_token = await UitpasTokenRepository.getAccessTokenFor(order.organizationId);
194
+ canceledUitpasId = await cancelTicketSales(access_token, toBeCanceledUitpasIds);
195
+ if (canceledUitpasId.length !== toBeCanceledUitpasIds.length) {
196
+ console.error('Failed to cancel some UiTPAS ticket sales, successfully canceled:', canceledUitpasId, 'but tried to cancel:', toBeCanceledUitpasIds);
197
+ }
198
+ try {
199
+ newlyRegistered = await registerTicketSales(access_token, toBeRegisteredUitpasRequests);
200
+ }
201
+ catch (e) {
202
+ console.error('Failed to register UiTPAS ticket sales', e);
203
+ }
204
+ }
205
+
206
+ const effectiveDeletes = toBeCanceled.filter(c => c.ticketSaleId === null || canceledUitpasId.find(id => id === c.ticketSaleId));
207
+ if (effectiveDeletes.length !== 0) {
208
+ await WebshopUitpasNumber.delete().where('id', effectiveDeletes.map(c => c.id));
209
+ }
210
+
211
+ const inserts = toBeRegistered.map((c) => {
212
+ const response = newlyRegistered.get(c as RegisterTicketSaleRequest);
213
+ return {
214
+ ticketSaleId: response?.ticketSaleId ?? null,
215
+ reducedPriceUitpas: response?.reducedPriceUitpas ?? null,
216
+ registeredAt: response?.registeredAt ?? null,
217
+ webshopId: order.webshopId,
218
+ orderId: order.id,
219
+ productId: c.productId,
220
+ uitpasNumber: c.uitpasNumber,
221
+ basePrice: c.basePrice,
222
+ reducedPrice: c.reducedPrice,
223
+ basePriceLabel: c.basePriceLabel,
224
+ uitpasTariffId: c.uitpasTariffId, // null for non-official flow
225
+ uitpasEventUrl: c.uitpasEventUrl, // null for non-official flow
226
+ };
227
+ });
228
+ await this.createUitpasNumbers(inserts);
229
+ });
93
230
  }
94
231
 
95
232
  static listen() {
@@ -100,9 +237,11 @@ export class UitpasService {
100
237
  Model.modelEventBus.addListener(this, async (event) => {
101
238
  try {
102
239
  if (event.model instanceof Order) {
103
- // event.type ==='deteled' -> not needed as foreign key will delete the order
240
+ // if (event.type === 'deleted') {
241
+ // delete from db is not not needed as foreign key will delete the order
242
+ // we do not cancel the ticket sales
104
243
  if (event.type === 'created' && shouldReserveUitpasNumbers(event.model.status)) {
105
- await this.createUitpasNumbers(event.model);
244
+ await this.updateTicketSales(event.model, true);
106
245
  return;
107
246
  }
108
247
  if (event.type === 'updated') {
@@ -111,18 +250,16 @@ export class UitpasService {
111
250
  const statusAfter = event.changedFields.status as OrderStatus;
112
251
  const shouldReserveAfter = shouldReserveUitpasNumbers(statusAfter);
113
252
  if (shouldReserveUitpasNumbers(statusBefore) !== shouldReserveAfter) {
114
- if (shouldReserveAfter) {
115
- await this.createUitpasNumbers(event.model);
116
- return;
117
- }
118
- await this.deleteUitpasNumbers(event.model);
253
+ await this.updateTicketSales(event.model, shouldReserveAfter);
119
254
  return;
120
255
  }
121
256
  }
122
257
  if (event.changedFields.data) {
123
- const oldOrder = event.getOldModel() as Order;
124
- if (areUitpasNumbersChanged(oldOrder, event.model)) {
125
- await this.updateUitpasNumbers(event.model);
258
+ const oldTicketSales = getUitpasTicketSales(event.getOldModel() as Order);
259
+ const newTicketSales = getUitpasTicketSales(event.model);
260
+ if (areThereUitpasChanges(oldTicketSales, newTicketSales)) {
261
+ await this.updateTicketSales(event.model, false, newTicketSales);
262
+ return;
126
263
  }
127
264
  }
128
265
  }
@@ -134,24 +271,22 @@ export class UitpasService {
134
271
  });
135
272
  }
136
273
 
137
- static async getSocialTariffForUitpasNumbers(organisationId: string, uitpasNumbers: string[], basePrice: number, uitpasEventUrl: string) {
274
+ static async getSocialTariffForUitpasNumbers(organizationId: string, uitpasNumbers: string[], basePrice: number, uitpasEventUrl: string) {
138
275
  // https://docs.publiq.be/docs/uitpas/uitpas-api/reference/operations/list-tariffs
139
- const access_token = await UitpasTokenRepository.getAccessTokenFor(organisationId);
276
+ const access_token = await UitpasTokenRepository.getAccessTokenFor(organizationId);
140
277
  return await getSocialTariffForUitpasNumbers(access_token, uitpasNumbers, basePrice, uitpasEventUrl);
141
278
  }
142
279
 
143
- static async getSocialTariffForEvent(organisationId: string, basePrice: number, uitpasEventUrl: string) {
280
+ static async getSocialTariffForEvent(organizationId: string, basePrice: number, uitpasEventUrl: string) {
144
281
  // https://docs.publiq.be/docs/uitpas/uitpas-api/reference/operations/get-a-tariff-static
145
- const access_token = await UitpasTokenRepository.getAccessTokenFor(organisationId);
282
+ const access_token = await UitpasTokenRepository.getAccessTokenFor(organizationId);
146
283
  return await getSocialTariffForEvent(access_token, basePrice, uitpasEventUrl);
147
284
  }
148
285
 
149
- static async registerTicketSales() {
150
- // https://docs.publiq.be/docs/uitpas/uitpas-api/reference/operations/create-a-ticket-sale
151
- }
152
-
153
- static async cancelTicketSale() {
286
+ static async cancelTicketSales(organisationId: string, ticketSaleIds: string[]) {
154
287
  // https://docs.publiq.be/docs/uitpas/uitpas-api/reference/operations/delete-a-ticket-sale
288
+ const access_token = await UitpasTokenRepository.getAccessTokenFor(organisationId);
289
+ return await cancelTicketSales(access_token, ticketSaleIds);
155
290
  }
156
291
 
157
292
  static async getTicketSales() {
@@ -162,9 +297,11 @@ export class UitpasService {
162
297
  // https://api-test.uitpas.be/checkins
163
298
  }
164
299
 
165
- static searchUitpasEvents(organizationId: string, uitpasOrganizerId: string, textQuery?: string) {
300
+ static async searchUitpasEvents(organizationId: string, uitpasOrganizerId: string, textQuery?: string) {
166
301
  // input = client id of organization (never platform0 & uitpasOrganizerId
167
302
  // https://docs.publiq.be/docs/uitpas/events/searching#searching-for-uitpas-events-of-one-specific-organizer
303
+ const clientId = await UitpasTokenRepository.getClientIdFor(organizationId);
304
+ return searchUitpasEvents(clientId, uitpasOrganizerId, textQuery);
168
305
  }
169
306
 
170
307
  static async searchUitpasOrganizers(name: string): Promise<UitpasOrganizersResponse> {
@@ -184,7 +321,7 @@ export class UitpasService {
184
321
 
185
322
  /**
186
323
  * Returns the client ID if it is configured for the organization, otherwise an empty string. Empty strings means no client ID and secret configured.
187
- * @param organisationId
324
+ * @param organizationId
188
325
  * @returns clientId or empty string if not configured
189
326
  */
190
327
  static async getClientIdFor(organizationId: string | null): Promise<string> {
@@ -0,0 +1,41 @@
1
+ import { SimpleError } from '@simonbackx/simple-errors';
2
+
3
+ async function cancelTicketSale(access_token: string, ticketSaleId: string) {
4
+ // https://docs.publiq.be/docs/uitpas/uitpas-api/reference/operations/delete-a-ticket-sale
5
+ const url = 'https://api-test.uitpas.be/ticket-sales/' + ticketSaleId;
6
+ const myHeaders = new Headers();
7
+ myHeaders.append('Authorization', 'Bearer ' + access_token);
8
+ myHeaders.append('Accept', 'application/json');
9
+ const requestOptions = {
10
+ method: 'DELETE',
11
+ headers: myHeaders,
12
+ };
13
+ const response = await fetch(url, requestOptions).catch(() => {
14
+ // Handle network errors
15
+ throw new SimpleError({
16
+ code: 'uitpas_unreachable_registering_ticket_sales',
17
+ message: `Network issue when registering UiTPAS ticket sales`,
18
+ human: $t(
19
+ `We konden UiTPAS niet bereiken om de ticket verkoop te registreren bij UiTPAS. Probeer het later opnieuw.`,
20
+ ),
21
+ });
22
+ });
23
+ if (!response.ok) {
24
+ throw new SimpleError({
25
+ code: 'unsuccessful_response_registering_ticket_sales',
26
+ message: `Unsuccessful response when registering UiTPAS ticket sales`,
27
+ human: $t(`Er is een fout opgetreden bij het verbinden met UiTPAS. Probeer het later opnieuw.`),
28
+ });
29
+ }
30
+ return ticketSaleId;
31
+ }
32
+
33
+ /**
34
+ * Will never throw an error, but will return array of successfully canceled ticket sale ids
35
+ */
36
+ export async function cancelTicketSales(access_token: string, ticketSaleIds: string[]) {
37
+ // https://docs.publiq.be/docs/uitpas/uitpas-api/reference/operations/delete-a-ticket-sale
38
+ const promises = ticketSaleIds.map(ticketSaleId => cancelTicketSale(access_token, ticketSaleId));
39
+ const results = await Promise.allSettled(promises);
40
+ return results.filter(result => result.status === 'fulfilled').map(result => result.value);
41
+ }
@@ -1,7 +1,9 @@
1
1
  import { isSimpleError, isSimpleErrors, SimpleError, SimpleErrors } from '@simonbackx/simple-errors';
2
+ import { UitpasNumberAndPrice } from '@stamhoofd/structures';
2
3
 
3
4
  type SocialTariffReponse = {
4
5
  available: Array<{
6
+ id: string;
5
7
  price: number;
6
8
  remaining: number;
7
9
  // other properties ignored
@@ -25,7 +27,7 @@ function assertsIsSocialTariffResponse(json: unknown): asserts json is SocialTar
25
27
  || !('available' in json)
26
28
  || !Array.isArray(json.available)
27
29
  || !json.available.every(
28
- (item: unknown) => typeof item === 'object' && item !== null && 'price' in item && typeof item.price === 'number' && 'remaining' in item && typeof item.remaining === 'number',
30
+ (item: unknown) => typeof item === 'object' && item !== null && 'id' in item && typeof item.id === 'string' && 'price' in item && typeof item.price === 'number' && 'remaining' in item && typeof item.remaining === 'number',
29
31
  )
30
32
  ) {
31
33
  console.error('Invalid response when getting UiTPAS social tariff:', json);
@@ -152,16 +154,20 @@ async function getSocialTariffForUitpasNumber(access_token: string, uitpasNumber
152
154
  });
153
155
  }
154
156
  console.log('Social tariff for UiTPAS number', uitpasNumber, 'with event id', uitpasEventUrl, 'is', json.available[0].price, 'euros');
155
- return Math.round((json.available[0].price) * 100);
157
+ return UitpasNumberAndPrice.create({
158
+ uitpasNumber,
159
+ price: Math.round((json.available[0].price) * 100),
160
+ uitpasTariffId: json.available[0].id,
161
+ });
156
162
  }
157
163
 
158
164
  export async function getSocialTariffForUitpasNumbers(access_token: string, uitpasNumbers: string[], basePrice: number, uitpasEventUrl: string) {
159
165
  const simpleErrors = new SimpleErrors();
160
- const reducedPrices = new Array<number>(uitpasNumbers.length);
166
+ const reducedPrices = new Array<UitpasNumberAndPrice>(uitpasNumbers.length);
161
167
  for (let i = 0; i < uitpasNumbers.length; i++) {
162
168
  const uitpasNumber = uitpasNumbers[i];
163
169
  try {
164
- reducedPrices[i] = await getSocialTariffForUitpasNumber(access_token, uitpasNumber, basePrice, uitpasEventUrl); // Throws if invalid
170
+ reducedPrices[i] = await getSocialTariffForUitpasNumber(access_token, uitpasNumber, basePrice, uitpasEventUrl);
165
171
  }
166
172
  catch (e) {
167
173
  if (isSimpleError(e) || isSimpleErrors(e)) {
@@ -0,0 +1,139 @@
1
+ import { SimpleError } from '@simonbackx/simple-errors';
2
+
3
+ export type RegisterTicketSaleRequest = {
4
+ basePrice: number;
5
+ uitpasEventUrl: string;
6
+ basePriceLabel: string;
7
+ uitpasNumber: string;
8
+ uitpasTariffId: string;
9
+ };
10
+
11
+ export type RegisterTicketSaleResponse = {
12
+ ticketSaleId: string;
13
+ reducedPriceUitpas: number;
14
+ registeredAt: Date;
15
+ };
16
+
17
+ type SuccessResponse = {
18
+ id: string;
19
+ tariff: {
20
+ price: number;
21
+ id: string;
22
+ };
23
+ uitpasNumber: string;
24
+ eventId: string;
25
+ };
26
+
27
+ function assertisSuccessResponse(json: unknown): asserts json is SuccessResponse {
28
+ if (
29
+ typeof json !== 'object'
30
+ || json === null
31
+ || !('tariff' in json)
32
+ || typeof json.tariff !== 'object'
33
+ || json.tariff === null
34
+ || !('price' in json.tariff)
35
+ || typeof json.tariff.price !== 'number'
36
+ || !('id' in json.tariff)
37
+ || typeof json.tariff.id !== 'string'
38
+ || !('id' in json)
39
+ || typeof json.id !== 'string'
40
+ || !('uitpasNumber' in json)
41
+ || typeof json.uitpasNumber !== 'string'
42
+ || !('eventId' in json)
43
+ || typeof json.eventId !== 'string'
44
+ ) {
45
+ console.error('Invalid register ticket sale response', json);
46
+ throw new SimpleError({
47
+ code: 'invalid_register_ticket_sale_response',
48
+ message: `Invalid register ticket sale response`,
49
+ human: $t(`Er is een fout opgetreden bij het registreren van de UiTPAS-ticketverkoop.`),
50
+ });
51
+ }
52
+ }
53
+
54
+ /**
55
+ * @returns Map of request (from parameters) -> response
56
+ */
57
+
58
+ export async function registerTicketSales(access_token: string, registerTicketSaleRequests: RegisterTicketSaleRequest[]): Promise<Map<RegisterTicketSaleRequest, RegisterTicketSaleResponse>> {
59
+ // https://docs.publiq.be/docs/uitpas/uitpas-api/reference/operations/create-a-ticket-sale
60
+
61
+ console.error('Registering ticket sales', registerTicketSaleRequests);
62
+ if (registerTicketSaleRequests.length === 0) {
63
+ return new Map();
64
+ };
65
+ const url = 'https://api-test.uitpas.be/ticket-sales';
66
+ const body = registerTicketSaleRequests.map((ticketSale) => {
67
+ const eventId = ticketSale.uitpasEventUrl.split('/').pop();
68
+ if (!eventId) {
69
+ throw new SimpleError({
70
+ code: 'invalid_uitpas_event_url',
71
+ message: `Invalid UiTPAS event URL: ${ticketSale.uitpasEventUrl}`,
72
+ human: $t(`De opgegeven UiTPAS-evenement URL is ongeldig.`),
73
+ });
74
+ }
75
+
76
+ return {
77
+ uitpasNumber: ticketSale.uitpasNumber,
78
+ eventId,
79
+ regularPrice: (ticketSale.basePrice / 100).toFixed(2), // Convert from cents to euros
80
+ regularPriceLabel: ticketSale.basePriceLabel,
81
+ tariff: {
82
+ id: ticketSale.uitpasTariffId,
83
+ },
84
+ };
85
+ });
86
+ const myHeaders = new Headers();
87
+ myHeaders.append('Authorization', 'Bearer ' + access_token);
88
+ myHeaders.append('Accept', 'application/json');
89
+ myHeaders.append('Content-Type', 'application/json');
90
+ const requestOptions = {
91
+ method: 'POST',
92
+ headers: myHeaders,
93
+ body: JSON.stringify(body),
94
+ };
95
+ const response = await fetch(url, requestOptions).catch(() => {
96
+ // Handle network errors
97
+ throw new SimpleError({
98
+ code: 'uitpas_unreachable_registering_ticket_sales',
99
+ message: `Network issue when registering UiTPAS ticket sales`,
100
+ human: $t(
101
+ `We konden UiTPAS niet bereiken om de ticket verkoop te registreren bij UiTPAS. Probeer het later opnieuw.`,
102
+ ),
103
+ });
104
+ });
105
+ if (!response.ok) {
106
+ const json: unknown = await response.json().catch(() => { /* ignore */ });
107
+ console.error('Unsuccessful response when registering ticket sales', json);
108
+ throw new SimpleError({
109
+ code: 'unsuccessful_response_registering_ticket_sales',
110
+ message: `Unsuccessful response when registering UiTPAS ticket sales`,
111
+ human: $t(`Er is een fout opgetreden bij het verbinden met UiTPAS. Probeer het later opnieuw.`),
112
+ });
113
+ }
114
+ const json = await response.json().catch(() => {});
115
+ if (!json || !Array.isArray(json)) {
116
+ console.error('Invalid response when registering ticket sales', json);
117
+ throw new SimpleError({
118
+ code: 'invalid_response_registering_ticket_sales',
119
+ message: `Invalid response when registering ticket sales`,
120
+ human: $t(`Er is een fout opgetreden bij het registreren van de UiTPAS-ticketverkoop.`),
121
+ });
122
+ }
123
+ const now = new Date();
124
+ const results: Map<RegisterTicketSaleRequest, RegisterTicketSaleResponse> = new Map();
125
+ for (const ticketSale of json) {
126
+ assertisSuccessResponse(ticketSale);
127
+ const request = registerTicketSaleRequests.find(r => r.uitpasNumber === ticketSale.uitpasNumber && r.uitpasEventUrl.endsWith(`/${ticketSale.eventId}`) && r.uitpasTariffId === ticketSale.tariff.id);
128
+ if (!request) {
129
+ console.error('Could not find request for ticket sale', ticketSale);
130
+ continue;
131
+ }
132
+ results.set(request, {
133
+ ticketSaleId: ticketSale.id,
134
+ reducedPriceUitpas: Math.round(ticketSale.tariff.price * 100),
135
+ registeredAt: now,
136
+ });
137
+ }
138
+ return results;
139
+ }
@@ -0,0 +1,141 @@
1
+ import { UitpasEventResponse, UitpasEventsResponse } from '@stamhoofd/structures';
2
+ import { SimpleError } from '@simonbackx/simple-errors';
3
+
4
+ type EventsResponse = {
5
+ totalItems: number;
6
+ itemsPerPage: number;
7
+ member: Array<{
8
+ '@id': string;
9
+ 'name': {
10
+ nl: string;
11
+ };
12
+ 'location': {
13
+ name: object;
14
+ };
15
+ 'startDate'?: string;
16
+ 'endDate'?: string;
17
+ }>;
18
+ };
19
+
20
+ function assertIsEventsResponse(json: unknown): asserts json is EventsResponse {
21
+ if (
22
+ typeof json !== 'object'
23
+ || json === null
24
+ || !('totalItems' in json)
25
+ || typeof json.totalItems !== 'number'
26
+ || !('itemsPerPage' in json)
27
+ || typeof json.itemsPerPage !== 'number'
28
+ || !('member' in json)
29
+ || !Array.isArray(json.member)
30
+ || json.member.some(member => (
31
+ typeof member !== 'object'
32
+ || member === null
33
+ || !('@id' in member)
34
+ || typeof member['@id'] !== 'string'
35
+ || !('name' in member)
36
+ || typeof member.name !== 'object'
37
+ || member.name === null
38
+ || !('nl' in member.name)
39
+ || typeof member.name.nl !== 'string'
40
+ || !('location' in member)
41
+ || typeof member.location !== 'object'
42
+ || member.location === null
43
+ || !('name' in member.location)
44
+ || typeof member.location.name !== 'object'
45
+ || member.location.name === null
46
+ ))
47
+ ) {
48
+ console.error('Invalid events response', json);
49
+ throw new SimpleError({
50
+ code: 'invalid_events_response',
51
+ message: `Invalid events response`,
52
+ human: $t(`Er is een fout opgetreden bij het ophalen van de UiTPAS-evenementen.`),
53
+ });
54
+ }
55
+ }
56
+
57
+ export async function searchUitpasEvents(clientId: string, uitpasOrganizerId: string, textQuery?: string): Promise<UitpasEventsResponse> {
58
+ // uses no credentials (only client id of the organization)
59
+ // https://docs.publiq.be/docs/uitpas/events/searching
60
+ if (!clientId) {
61
+ throw new SimpleError({
62
+ code: 'no_client_id_for_uitpas_events',
63
+ message: `No client ID configured for Uitpas events`,
64
+ human: $t(`Er is geen UiTPAS client ID geconfigureerd voor deze organisatie.`),
65
+ });
66
+ }
67
+ const baseUrl = 'https://search-test.uitdatabank.be/events';
68
+ const params = new URLSearchParams();
69
+ params.append('clientId', clientId);
70
+ params.append('organizerId', uitpasOrganizerId);
71
+ params.append('embed', 'true');
72
+ params.append('uitpas', 'true');
73
+ params.append('start', '0');
74
+ params.append('limit', '200');
75
+ if (textQuery) {
76
+ params.append('text', textQuery);
77
+ }
78
+ const url = `${baseUrl}?${params.toString()}`;
79
+ const myHeaders = new Headers();
80
+ myHeaders.append('Accept', 'application/json');
81
+ const requestOptions = {
82
+ method: 'GET',
83
+ headers: myHeaders,
84
+ };
85
+ const response = await fetch(url, requestOptions).catch(() => {
86
+ // Handle network errors
87
+ throw new SimpleError({
88
+ code: 'uitpas_unreachable_searching_events',
89
+ message: `Network issue when searching for UiTPAS events`,
90
+ human: $t(
91
+ `We konden UiTPAS niet bereiken om UiTPAS-evenementen op te zoeken. Probeer het later opnieuw.`,
92
+ ),
93
+ });
94
+ });
95
+ if (!response.ok) {
96
+ console.error('Unsuccessful response when searching for UiTPAS events', response);
97
+ throw new SimpleError({
98
+ code: 'unsuccessful_response_searching_uitpas_events',
99
+ message: `Unsuccessful response when searching for UiTPAS events`,
100
+ human: $t(`Er is een fout opgetreden bij het verbinden met UiTPAS. Probeer het later opnieuw.`),
101
+ });
102
+ }
103
+ const json = await response.json().catch(() => {
104
+ // Handle JSON parsing errors
105
+ throw new SimpleError({
106
+ code: 'invalid_json_searching_uitpas_events',
107
+ message: `Invalid json when searching for UiTPAS events`,
108
+ human: $t(
109
+ `Er is een fout opgetreden bij het communiceren met UiTPAS. Probeer het later opnieuw.`,
110
+ ),
111
+ });
112
+ });
113
+
114
+ assertIsEventsResponse(json);
115
+ const eventsResponse = new UitpasEventsResponse();
116
+ eventsResponse.totalItems = json.totalItems;
117
+ eventsResponse.itemsPerPage = json.itemsPerPage;
118
+ eventsResponse.member = json.member.map((member) => {
119
+ const event = new UitpasEventResponse();
120
+ event.url = member['@id'];
121
+ event.name = member.name.nl;
122
+ const locationName = member.location.name as Record<string, string>;
123
+ const entrs = Object.entries(locationName);
124
+ const hasNl = entrs.find(([key]) => key === 'nl');
125
+ if (hasNl) {
126
+ event.location = locationName.nl;
127
+ }
128
+ else {
129
+ const lang = entrs[0];
130
+ event.location = lang ? lang[1] : '';
131
+ }
132
+ if (member.startDate) {
133
+ event.startDate = new Date(member.startDate);
134
+ }
135
+ if (member.endDate) {
136
+ event.endDate = new Date(member.endDate);
137
+ }
138
+ return event;
139
+ });
140
+ return eventsResponse;
141
+ }