diff --git a/admin-ui/default-permissions.config.js b/admin-ui/default-permissions.config.js
index 7721d18935..06d065d7b4 100644
--- a/admin-ui/default-permissions.config.js
+++ b/admin-ui/default-permissions.config.js
@@ -58,6 +58,7 @@ module.exports = () => {
'/verify-email': 'UNRESTRICTED',
'/oauth': 'UNRESTRICTED',
'/external': 'UNRESTRICTED',
+ '/ticketing/gate': 'UNRESTRICTED',
'/orders/[orderId]': 'viewOrders',
'/orders': 'viewOrders',
'/quotations': 'viewQuotations',
@@ -79,6 +80,7 @@ module.exports = () => {
'/verify-email',
'/403',
'/external',
+ '/ticketing/gate',
];
const OnlyPublicPages = [
diff --git a/admin-ui/public/admin-ui-permissions.js b/admin-ui/public/admin-ui-permissions.js
index cd5755fe69..e2ac508122 100644
--- a/admin-ui/public/admin-ui-permissions.js
+++ b/admin-ui/public/admin-ui-permissions.js
@@ -58,6 +58,7 @@ window.AdminUiPermissions = () => {
'/verify-email': 'UNRESTRICTED',
'/oauth': 'UNRESTRICTED',
'/external': 'UNRESTRICTED',
+ '/ticketing/gate': 'UNRESTRICTED',
'/orders/[orderId]': 'viewOrders',
'/orders': 'viewOrders',
'/quotations': 'viewQuotations',
@@ -79,6 +80,7 @@ window.AdminUiPermissions = () => {
'/verify-email',
'/403',
'/external',
+ '/ticketing/gate',
];
const OnlyPublicPages = [
diff --git a/admin-ui/src/gql/types.ts b/admin-ui/src/gql/types.ts
index 461a3b541c..98c3d6266b 100644
--- a/admin-ui/src/gql/types.ts
+++ b/admin-ui/src/gql/types.ts
@@ -1033,12 +1033,28 @@ export type IMutation = {
* Optional worker to identify the worker.
*/
allocateWork?: Maybe;
+ /**
+ * Authenticate gate control by validating a pass code and setting an HttpOnly cookie.
+ * Returns true if the pass code is valid.
+ */
+ authenticateGate: Scalars['Boolean']['output'];
/**
* Toggle Bookmark state on a product as currently logged in user,
* Does not work when multiple bookmarks with different explicit meta configurations exist.
* In those cases please use createBookmark and removeBookmark
*/
bookmark: IBookmark;
+ /**
+ * Cancel all tickets for an event (tokenized product). Invalidates all non-cancelled tokens.
+ * Optionally generates discount codes for affected users.
+ * Returns the number of tickets cancelled.
+ */
+ cancelEvent: Scalars['Int']['output'];
+ /**
+ * Cancel a ticket (token). Sets the cancelled flag on the token metadata.
+ * Optionally generates a discount code for reimbursement.
+ */
+ cancelTicket: IToken;
/** Change the current user's password. Must be logged in. */
changePassword?: Maybe;
/**
@@ -1090,6 +1106,8 @@ export type IMutation = {
createWebAuthnCredentialCreationOptions?: Maybe;
/** Create WebAuthn PublicKeyCredentialRequestrOptions to use for WebAuthn Login Flow */
createWebAuthnCredentialRequestOptions?: Maybe;
+ /** Deauthenticate gate control by clearing the gate pass code cookie. */
+ deauthenticateGate: Scalars['Boolean']['output'];
/** Manually mark a undelivered order as delivered */
deliverOrder: IOrder;
/**
@@ -1255,6 +1273,11 @@ export type IMutation = {
sendEnrollmentEmail?: Maybe;
/** Send an email with a link the user can use verify their email address. */
sendVerificationEmail?: Maybe;
+ /**
+ * Set or remove the scanner pass code for gate control on a tokenized product.
+ * Pass null to remove the pass code.
+ */
+ setEventScannerPassCode: IProduct;
/** Set a new password for a specific user */
setPassword: IUser;
/** Set roles of a user */
@@ -1443,11 +1466,25 @@ export type IMutationAllocateWorkArgs = {
worker?: InputMaybe;
};
+export type IMutationAuthenticateGateArgs = {
+ passCode: Scalars['String']['input'];
+};
+
export type IMutationBookmarkArgs = {
bookmarked?: InputMaybe;
productId: Scalars['ID']['input'];
};
+export type IMutationCancelEventArgs = {
+ generateDiscount?: InputMaybe;
+ productId: Scalars['ID']['input'];
+};
+
+export type IMutationCancelTicketArgs = {
+ generateDiscount?: InputMaybe;
+ tokenId: Scalars['ID']['input'];
+};
+
export type IMutationChangePasswordArgs = {
newPassword: Scalars['String']['input'];
oldPassword: Scalars['String']['input'];
@@ -1863,6 +1900,11 @@ export type IMutationSendVerificationEmailArgs = {
email?: InputMaybe;
};
+export type IMutationSetEventScannerPassCodeArgs = {
+ passCode?: InputMaybe;
+ productId: Scalars['ID']['input'];
+};
+
export type IMutationSetPasswordArgs = {
newPassword: Scalars['String']['input'];
userId: Scalars['ID']['input'];
@@ -2835,6 +2877,11 @@ export type IQuery = {
filtersCount: Scalars['Int']['output'];
/** User impersonating currently logged in user */
impersonator?: Maybe;
+ /**
+ * Validates a scanner pass code for gate access. Pass code is read from the unchained_gate_passcode cookie (set via authenticateGate mutation).
+ * Optionally restricted to a specific product.
+ */
+ isPassCodeValid: Scalars['Boolean']['output'];
/** Get a specific language */
language?: Maybe;
/** Get all languages, by default sorted by creation date (ascending) */
@@ -2892,6 +2939,10 @@ export type IQuery = {
searchProducts: IProductSearchResult;
/** Get shop-global data and the resolved country/language pair */
shopInfo: IShop;
+ /** List all ticket events (tokenized products), by default includes drafts */
+ ticketEvents: Array;
+ /** Returns total number of ticket events (tokenized products) */
+ ticketEventsCount: Scalars['Int']['output'];
/** Get token */
token?: Maybe;
/** Get all tokens */
@@ -3070,6 +3121,10 @@ export type IQueryFiltersCountArgs = {
queryString?: InputMaybe;
};
+export type IQueryIsPassCodeValidArgs = {
+ productId?: InputMaybe;
+};
+
export type IQueryLanguageArgs = {
languageId: Scalars['ID']['input'];
};
@@ -3164,6 +3219,7 @@ export type IQueryProductsArgs = {
slugs?: InputMaybe>;
sort?: InputMaybe>;
tags?: InputMaybe>;
+ type?: InputMaybe;
};
export type IQueryProductsCountArgs = {
@@ -3171,6 +3227,7 @@ export type IQueryProductsCountArgs = {
queryString?: InputMaybe;
slugs?: InputMaybe>;
tags?: InputMaybe>;
+ type?: InputMaybe;
};
export type IQueryQuotationArgs = {
@@ -3204,6 +3261,21 @@ export type IQuerySearchProductsArgs = {
queryString?: InputMaybe;
};
+export type IQueryTicketEventsArgs = {
+ includeDrafts?: InputMaybe;
+ limit?: InputMaybe;
+ offset?: InputMaybe;
+ onlyInvalidateable?: InputMaybe;
+ queryString?: InputMaybe;
+ sort?: InputMaybe>;
+};
+
+export type IQueryTicketEventsCountArgs = {
+ includeDrafts?: InputMaybe;
+ onlyInvalidateable?: InputMaybe;
+ queryString?: InputMaybe;
+};
+
export type IQueryTokenArgs = {
tokenId: Scalars['ID']['input'];
};
@@ -3398,6 +3470,7 @@ export enum IRoleAction {
DownloadFile = 'downloadFile',
EnrollUser = 'enrollUser',
ForgotPassword = 'forgotPassword',
+ GateControl = 'gateControl',
Heartbeat = 'heartbeat',
Impersonate = 'impersonate',
LoginAsGuest = 'loginAsGuest',
@@ -3447,6 +3520,7 @@ export enum IRoleAction {
UploadTempFile = 'uploadTempFile',
UploadUserAvatar = 'uploadUserAvatar',
UseWebAuthn = 'useWebAuthn',
+ ValidatePassCode = 'validatePassCode',
VerifyEmail = 'verifyEmail',
ViewAssortment = 'viewAssortment',
ViewAssortments = 'viewAssortments',
@@ -3655,6 +3729,7 @@ export type IToken = {
ercMetadata?: Maybe;
expiryDate?: Maybe;
invalidatedDate?: Maybe;
+ isCanceled?: Maybe;
isInvalidateable: Scalars['Boolean']['output'];
product: ITokenizedProduct;
quantity: Scalars['Int']['output'];
@@ -3683,12 +3758,14 @@ export type ITokenizedProduct = IProduct & {
contractConfiguration?: Maybe;
contractStandard?: Maybe;
created?: Maybe;
+ isCanceled?: Maybe;
leveledCatalogPrices: Array;
media: Array;
proxies: Array;
published?: Maybe;
reviews: Array;
reviewsCount: Scalars['Int']['output'];
+ scannerPassCode?: Maybe;
sequence: Scalars['Int']['output'];
siblings: Array;
simulatedPrice?: Maybe;
@@ -11744,6 +11821,7 @@ export type ITokenFragment = {
ercMetadata?: any | null;
accessKey: string;
isInvalidateable: boolean;
+ isCanceled?: boolean | null;
};
export type ITokenFragmentVariables = Exact<{ [key: string]: never }>;
@@ -12195,6 +12273,7 @@ export type IExportTokenMutation = {
ercMetadata?: any | null;
accessKey: string;
isInvalidateable: boolean;
+ isCanceled?: boolean | null;
};
};
@@ -12486,6 +12565,9 @@ export type IProductQuery = {
>;
}
| {
+ tokensCount: number;
+ isCanceled?: boolean | null;
+ scannerPassCode?: string | null;
_id: string;
sequence: number;
status: IProductStatus;
@@ -12499,6 +12581,39 @@ export type IProductQuery = {
subtitle?: string | null;
description?: string | null;
} | null;
+ contractConfiguration?: {
+ ercMetadataProperties?: any | null;
+ supply: number;
+ } | null;
+ simulatedStocks?: Array<{ quantity?: number | null }> | null;
+ tokens: Array<{
+ _id: string;
+ tokenSerialNumber?: string | null;
+ isCanceled?: boolean | null;
+ invalidatedDate?: any | null;
+ isInvalidateable: boolean;
+ quantity: number;
+ status: ITokenExportStatus;
+ walletAddress?: string | null;
+ user?: {
+ _id: string;
+ username?: string | null;
+ isGuest: boolean;
+ primaryEmail?: { address: string; verified: boolean } | null;
+ avatar?: { _id: string; url?: string | null } | null;
+ profile?: {
+ displayName?: string | null;
+ address?: {
+ firstName?: string | null;
+ lastName?: string | null;
+ } | null;
+ } | null;
+ lastContact?: {
+ emailAddress?: string | null;
+ telNumber?: string | null;
+ } | null;
+ } | null;
+ }>;
media: Array<{
_id: string;
tags?: Array | null;
@@ -14863,6 +14978,7 @@ export type IUserTokensQuery = {
ercMetadata?: any | null;
accessKey: string;
isInvalidateable: boolean;
+ isCanceled?: boolean | null;
product: {
_id: string;
sequence: number;
@@ -15746,6 +15862,211 @@ export type IVerifyQuotationMutation = {
};
};
+export type ICancelEventMutationVariables = Exact<{
+ productId: Scalars['ID']['input'];
+ generateDiscount?: InputMaybe;
+}>;
+
+export type ICancelEventMutation = { cancelEvent: number };
+
+export type ICancelTicketMutationVariables = Exact<{
+ tokenId: Scalars['ID']['input'];
+ generateDiscount?: InputMaybe;
+}>;
+
+export type ICancelTicketMutation = {
+ cancelTicket: {
+ _id: string;
+ isCanceled?: boolean | null;
+ invalidatedDate?: any | null;
+ isInvalidateable: boolean;
+ tokenSerialNumber?: string | null;
+ };
+};
+
+export type ICheckGateCookieQueryVariables = Exact<{ [key: string]: never }>;
+
+export type ICheckGateCookieQuery = { isPassCodeValid: boolean };
+
+export type ITicketEventsQueryVariables = Exact<{
+ queryString?: InputMaybe;
+ limit?: InputMaybe;
+ offset?: InputMaybe;
+ includeDrafts?: InputMaybe;
+ forceLocale?: InputMaybe;
+}>;
+
+export type ITicketEventsQuery = {
+ ticketEventsCount: number;
+ ticketEvents: Array<
+ | {
+ _id: string;
+ status: IProductStatus;
+ tags?: Array | null;
+ updated?: any | null;
+ published?: any | null;
+ }
+ | {
+ _id: string;
+ status: IProductStatus;
+ tags?: Array | null;
+ updated?: any | null;
+ published?: any | null;
+ }
+ | {
+ _id: string;
+ status: IProductStatus;
+ tags?: Array | null;
+ updated?: any | null;
+ published?: any | null;
+ }
+ | {
+ _id: string;
+ status: IProductStatus;
+ tags?: Array | null;
+ updated?: any | null;
+ published?: any | null;
+ }
+ | {
+ tokensCount: number;
+ isCanceled?: boolean | null;
+ _id: string;
+ status: IProductStatus;
+ tags?: Array | null;
+ updated?: any | null;
+ published?: any | null;
+ texts?: {
+ _id: string;
+ slug?: string | null;
+ title?: string | null;
+ subtitle?: string | null;
+ description?: string | null;
+ } | null;
+ media: Array<{
+ _id: string;
+ file?: { _id: string; url?: string | null; name: string } | null;
+ }>;
+ contractConfiguration?: {
+ ercMetadataProperties?: any | null;
+ supply: number;
+ } | null;
+ simulatedStocks?: Array<{ quantity?: number | null }> | null;
+ }
+ >;
+};
+
+export type IGateEventDetailQueryVariables = Exact<{
+ productId: Scalars['ID']['input'];
+}>;
+
+export type IGateEventDetailQuery = {
+ product?:
+ | { _id: string }
+ | { _id: string }
+ | { _id: string }
+ | { _id: string }
+ | {
+ isCanceled?: boolean | null;
+ _id: string;
+ texts?: {
+ _id: string;
+ title?: string | null;
+ subtitle?: string | null;
+ } | null;
+ contractConfiguration?: {
+ ercMetadataProperties?: any | null;
+ supply: number;
+ } | null;
+ tokens: Array<{
+ _id: string;
+ tokenSerialNumber?: string | null;
+ isCanceled?: boolean | null;
+ invalidatedDate?: any | null;
+ isInvalidateable: boolean;
+ ercMetadata?: any | null;
+ user?: {
+ _id: string;
+ username?: string | null;
+ isGuest: boolean;
+ primaryEmail?: { address: string; verified: boolean } | null;
+ avatar?: { _id: string; url?: string | null } | null;
+ profile?: {
+ displayName?: string | null;
+ address?: {
+ firstName?: string | null;
+ lastName?: string | null;
+ } | null;
+ } | null;
+ lastContact?: {
+ emailAddress?: string | null;
+ telNumber?: string | null;
+ } | null;
+ } | null;
+ }>;
+ }
+ | null;
+};
+
+export type IGateEventsQueryVariables = Exact<{
+ onlyInvalidateable: Scalars['Boolean']['input'];
+}>;
+
+export type IGateEventsQuery = {
+ ticketEvents: Array<
+ | { _id: string; status: IProductStatus }
+ | { _id: string; status: IProductStatus }
+ | { _id: string; status: IProductStatus }
+ | { _id: string; status: IProductStatus }
+ | {
+ isCanceled?: boolean | null;
+ _id: string;
+ status: IProductStatus;
+ texts?: {
+ _id: string;
+ title?: string | null;
+ subtitle?: string | null;
+ } | null;
+ contractConfiguration?: {
+ ercMetadataProperties?: any | null;
+ supply: number;
+ } | null;
+ tokens: Array<{
+ _id: string;
+ tokenSerialNumber?: string | null;
+ isCanceled?: boolean | null;
+ invalidatedDate?: any | null;
+ isInvalidateable: boolean;
+ }>;
+ }
+ >;
+};
+
+export type IAuthenticateGateMutationVariables = Exact<{
+ passCode: Scalars['String']['input'];
+}>;
+
+export type IAuthenticateGateMutation = { authenticateGate: boolean };
+
+export type IDeauthenticateGateMutationVariables = Exact<{
+ [key: string]: never;
+}>;
+
+export type IDeauthenticateGateMutation = { deauthenticateGate: boolean };
+
+export type ISetEventScannerPassCodeMutationVariables = Exact<{
+ productId: Scalars['ID']['input'];
+ passCode?: InputMaybe;
+}>;
+
+export type ISetEventScannerPassCodeMutation = {
+ setEventScannerPassCode:
+ | { _id: string }
+ | { _id: string }
+ | { _id: string }
+ | { _id: string }
+ | { scannerPassCode?: string | null; _id: string };
+};
+
export type IInvalidateTokenMutationVariables = Exact<{
tokenId: Scalars['ID']['input'];
}>;
@@ -15764,6 +16085,7 @@ export type IInvalidateTokenMutation = {
ercMetadata?: any | null;
accessKey: string;
isInvalidateable: boolean;
+ isCanceled?: boolean | null;
};
};
@@ -15786,6 +16108,7 @@ export type ITokenQuery = {
ercMetadata?: any | null;
accessKey: string;
isInvalidateable: boolean;
+ isCanceled?: boolean | null;
product: {
_id: string;
sequence: number;
@@ -15855,6 +16178,7 @@ export type ITokensQuery = {
ercMetadata?: any | null;
accessKey: string;
isInvalidateable: boolean;
+ isCanceled?: boolean | null;
product: {
_id: string;
sequence: number;
diff --git a/admin-ui/src/modules/accounts/components/LogInForm.tsx b/admin-ui/src/modules/accounts/components/LogInForm.tsx
index 7689536e04..4ee01478b9 100644
--- a/admin-ui/src/modules/accounts/components/LogInForm.tsx
+++ b/admin-ui/src/modules/accounts/components/LogInForm.tsx
@@ -302,6 +302,17 @@ const LogInForm = () => {
})}
+
+
+ {intl.formatMessage({
+ id: 'open_gate_control',
+ defaultMessage: 'Open Gate Control',
+ })}
+
+
diff --git a/admin-ui/src/modules/common/components/Layout.tsx b/admin-ui/src/modules/common/components/Layout.tsx
index 107a89734b..0536fcc241 100644
--- a/admin-ui/src/modules/common/components/Layout.tsx
+++ b/admin-ui/src/modules/common/components/Layout.tsx
@@ -15,6 +15,7 @@ import {
CubeIcon,
DocumentTextIcon,
FolderArrowDownIcon,
+ TicketIcon,
} from '@heroicons/react/24/outline';
import Link from 'next/link';
import React, { useState } from 'react';
@@ -180,6 +181,12 @@ const Layout = ({
href: '/tokens',
requiredRole: 'viewTokens',
},
+ isSystemReady && {
+ name: formatMessage({ id: 'ticketing', defaultMessage: 'Ticketing' }),
+ icon: TicketIcon,
+ href: '/ticketing',
+ requiredRole: 'viewTokens',
+ },
{
name: formatMessage({ id: 'system', defaultMessage: 'System settings' }),
icon: Cog8ToothIcon,
diff --git a/admin-ui/src/modules/product/fragments/TokenFragment.ts b/admin-ui/src/modules/product/fragments/TokenFragment.ts
index ce0b9415e1..142262b9af 100644
--- a/admin-ui/src/modules/product/fragments/TokenFragment.ts
+++ b/admin-ui/src/modules/product/fragments/TokenFragment.ts
@@ -14,6 +14,7 @@ const TokenFragment = gql`
ercMetadata
accessKey
isInvalidateable
+ isCanceled
}
`;
diff --git a/admin-ui/src/modules/product/hooks/useProduct.ts b/admin-ui/src/modules/product/hooks/useProduct.ts
index 8932c983be..b723771427 100644
--- a/admin-ui/src/modules/product/hooks/useProduct.ts
+++ b/admin-ui/src/modules/product/hooks/useProduct.ts
@@ -57,6 +57,58 @@ const GetProductQuery = (inlineFragment = '') => gql`
__typename
}
}
+ ... on TokenizedProduct {
+ texts {
+ _id
+ title
+ subtitle
+ description
+ }
+ contractConfiguration {
+ ercMetadataProperties
+ supply
+ }
+ simulatedStocks {
+ quantity
+ }
+ tokensCount
+ isCanceled
+ scannerPassCode
+ tokens {
+ _id
+ tokenSerialNumber
+ isCanceled
+ invalidatedDate
+ isInvalidateable
+ quantity
+ status
+ walletAddress
+ user {
+ _id
+ username
+ isGuest
+ primaryEmail {
+ address
+ verified
+ }
+ avatar {
+ _id
+ url
+ }
+ profile {
+ displayName
+ address {
+ firstName
+ lastName
+ }
+ }
+ lastContact {
+ emailAddress
+ telNumber
+ }
+ }
+ }
+ }
}
}
${ProductDetailFragment}
diff --git a/admin-ui/src/modules/ticketing/components/EventTokenList.tsx b/admin-ui/src/modules/ticketing/components/EventTokenList.tsx
new file mode 100644
index 0000000000..78ced8f7bd
--- /dev/null
+++ b/admin-ui/src/modules/ticketing/components/EventTokenList.tsx
@@ -0,0 +1,71 @@
+import { useIntl } from 'react-intl';
+import Table from '../../common/components/Table';
+import EventTokenListItem from './EventTokenListItem';
+
+const EventTokenList = ({ tokens, onCancelTicket, onInvalidateTicket }) => {
+ const { formatMessage } = useIntl();
+
+ if (!tokens?.length) {
+ return (
+
+ {formatMessage({
+ id: 'no_tickets_issued',
+ defaultMessage: 'No tickets have been issued yet.',
+ })}
+
+ );
+ }
+
+ return (
+
+
+
+ {formatMessage({
+ id: 'ticket_number',
+ defaultMessage: 'Ticket #',
+ })}
+
+
+ {formatMessage({
+ id: 'attendee',
+ defaultMessage: 'Attendee',
+ })}
+
+
+ {formatMessage({
+ id: 'email',
+ defaultMessage: 'E-Mail',
+ })}
+
+
+ {formatMessage({
+ id: 'phone',
+ defaultMessage: 'Phone',
+ })}
+
+
+ {formatMessage({
+ id: 'redeemed',
+ defaultMessage: 'Redeemed',
+ })}
+
+
+ {formatMessage({
+ id: 'actions',
+ defaultMessage: 'Actions',
+ })}
+
+
+ {tokens.map((token) => (
+
+ ))}
+
+ );
+};
+
+export default EventTokenList;
diff --git a/admin-ui/src/modules/ticketing/components/EventTokenListItem.tsx b/admin-ui/src/modules/ticketing/components/EventTokenListItem.tsx
new file mode 100644
index 0000000000..9cec49fd12
--- /dev/null
+++ b/admin-ui/src/modules/ticketing/components/EventTokenListItem.tsx
@@ -0,0 +1,105 @@
+import Link from 'next/link';
+import { useIntl } from 'react-intl';
+import Table from '../../common/components/Table';
+import Badge from '../../common/components/Badge';
+import useFormatDateTime from '../../common/utils/useFormatDateTime';
+import formatUsername from '../../common/utils/formatUsername';
+import MediaAvatar from '../../common/components/MediaAvatar';
+
+const EventTokenListItem = ({ token, onCancelTicket, onInvalidateTicket }) => {
+ const { formatMessage } = useIntl();
+ const { formatDateTime } = useFormatDateTime();
+
+ return (
+
+
+
+ {token.tokenSerialNumber || token._id?.slice(-8)}
+
+
+
+ {token.user && (
+
+
+ {formatUsername(token.user)}
+
+ )}
+
+
+
+ {token.user?.lastContact?.emailAddress ||
+ token.user?.primaryEmail?.address ||
+ '-'}
+
+
+
+
+ {token.user?.lastContact?.telNumber || '-'}
+
+
+
+ {token.invalidatedDate ? (
+
+ ) : (
+ -
+ )}
+
+
+
+ {token.isCanceled ? (
+
+ ) : (
+ <>
+ {!token.invalidatedDate && (
+ onCancelTicket(token._id)}
+ className="inline-flex items-center rounded-md border border-slate-300 dark:border-slate-600 px-3 py-1 text-xs font-medium text-rose-600 dark:text-rose-400 hover:bg-rose-50 dark:hover:bg-rose-900/20"
+ >
+ {formatMessage({
+ id: 'cancel_ticket',
+ defaultMessage: 'Cancel',
+ })}
+
+ )}
+ {token.isInvalidateable && !token.invalidatedDate && (
+ onInvalidateTicket(token._id)}
+ className="inline-flex items-center rounded-md border border-slate-300 dark:border-slate-600 px-3 py-1 text-xs font-medium text-emerald-600 dark:text-emerald-400 hover:bg-emerald-50 dark:hover:bg-emerald-900/20"
+ >
+ {formatMessage({
+ id: 'redeem_ticket',
+ defaultMessage: 'Redeem',
+ })}
+
+ )}
+ >
+ )}
+
+
+
+ );
+};
+
+export default EventTokenListItem;
diff --git a/admin-ui/src/modules/ticketing/components/GateAttendeeList.tsx b/admin-ui/src/modules/ticketing/components/GateAttendeeList.tsx
new file mode 100644
index 0000000000..c0a1f466eb
--- /dev/null
+++ b/admin-ui/src/modules/ticketing/components/GateAttendeeList.tsx
@@ -0,0 +1,180 @@
+import { useCallback } from 'react';
+import { useIntl } from 'react-intl';
+import { toast } from 'react-toastify';
+import Table from '../../common/components/Table';
+import Badge from '../../common/components/Badge';
+import useFormatDateTime from '../../common/utils/useFormatDateTime';
+import formatUsername from '../../common/utils/formatUsername';
+import useInvalidateTicket from '../../token/hooks/useInvalidateTicket';
+
+const GateAttendeeList = ({ event, onRefetch }) => {
+ const { formatMessage } = useIntl();
+ const { formatDateTime } = useFormatDateTime();
+ const { invalidateTicket } = useInvalidateTicket();
+
+ const slot = event?.contractConfiguration?.ercMetadataProperties?.slot;
+ const tokens = event?.tokens || [];
+ const activeTokens = tokens.filter((t) => !t.isCanceled);
+ const redeemedCount = activeTokens.filter((t) => t.invalidatedDate).length;
+
+ const onRedeem = useCallback(async (tokenId: string) => {
+ try {
+ await invalidateTicket({ tokenId });
+ toast.success(
+ formatMessage({
+ id: 'gate_ticket_redeemed',
+ defaultMessage: 'Ticket redeemed successfully',
+ }),
+ );
+ onRefetch?.();
+ } catch (e) {
+ toast.error(
+ formatMessage({
+ id: 'gate_redeem_error',
+ defaultMessage:
+ 'Could not redeem ticket. It may already be redeemed or not yet redeemable.',
+ }),
+ );
+ }
+ }, []);
+
+ return (
+
+
+
+ {slot && (
+
+ {formatDateTime(slot, {
+ dateStyle: 'full',
+ timeStyle: 'short',
+ })}
+
+ )}
+
+
+
+ {redeemedCount}
+
+
/ {activeTokens.length}
+
+ {formatMessage({
+ id: 'gate_redeemed',
+ defaultMessage: 'redeemed',
+ })}
+
+
+
+
+ {!activeTokens.length ? (
+
+ {formatMessage({
+ id: 'gate_no_tickets',
+ defaultMessage: 'No tickets for this event.',
+ })}
+
+ ) : (
+
+
+
+
+ {formatMessage({
+ id: 'ticket_number',
+ defaultMessage: 'Ticket #',
+ })}
+
+
+ {formatMessage({
+ id: 'attendee',
+ defaultMessage: 'Attendee',
+ })}
+
+
+ {formatMessage({
+ id: 'email',
+ defaultMessage: 'E-Mail',
+ })}
+
+
+ {formatMessage({
+ id: 'status',
+ defaultMessage: 'Status',
+ })}
+
+
+ {formatMessage({
+ id: 'actions',
+ defaultMessage: 'Actions',
+ })}
+
+
+ {activeTokens.map((token) => (
+
+
+
+ {token.tokenSerialNumber || token._id?.slice(-8)}
+
+
+
+
+ {token.user ? formatUsername(token.user) : '-'}
+
+
+
+
+ {token.user?.lastContact?.emailAddress ||
+ token.user?.primaryEmail?.address ||
+ '-'}
+
+
+
+ {token.invalidatedDate ? (
+
+ ) : (
+
+ )}
+
+
+ {!token.invalidatedDate && token.isInvalidateable && (
+ onRedeem(token._id)}
+ className="inline-flex items-center rounded-md bg-emerald-600 px-4 py-1.5 text-xs font-medium text-white hover:bg-emerald-700 focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2"
+ >
+ {formatMessage({
+ id: 'gate_redeem',
+ defaultMessage: 'Redeem',
+ })}
+
+ )}
+ {token.invalidatedDate && (
+
+ {formatMessage({
+ id: 'gate_checked_in',
+ defaultMessage: 'Checked in',
+ })}
+
+ )}
+
+
+ ))}
+
+
+ )}
+
+ );
+};
+
+export default GateAttendeeList;
diff --git a/admin-ui/src/modules/ticketing/components/GateControl.tsx b/admin-ui/src/modules/ticketing/components/GateControl.tsx
new file mode 100644
index 0000000000..ae0825a0a0
--- /dev/null
+++ b/admin-ui/src/modules/ticketing/components/GateControl.tsx
@@ -0,0 +1,110 @@
+import { useState } from 'react';
+import { useIntl } from 'react-intl';
+import Loading from '../../common/components/Loading';
+import NoData from '../../common/components/NoData';
+import useGateEvents from '../hooks/useGateEvents';
+import useGateEventDetail from '../hooks/useGateEventDetail';
+import useIsPassCodeValid from '../hooks/useIsPassCodeValid';
+import GateEventList from './GateEventList';
+import GateAttendeeList from './GateAttendeeList';
+
+const GateControl = ({ onLogout, isAdmin = false }) => {
+ const { formatMessage } = useIntl();
+ const { clearPassCode } = useIsPassCodeValid();
+ const { events, loading: eventsLoading } = useGateEvents({
+ onlyInvalidateable: !isAdmin,
+ });
+ const [selectedEventId, setSelectedEventId] = useState(null);
+ const {
+ event: selectedEvent,
+ loading: detailLoading,
+ refetch,
+ } = useGateEventDetail(selectedEventId);
+
+ const selectedEventFromList: any = selectedEventId
+ ? events.find((e: any) => e._id === selectedEventId)
+ : null;
+
+ const title =
+ selectedEventFromList?.texts?.title || (selectedEvent as any)?.texts?.title;
+
+ return (
+
+
+
+ {selectedEventId && (
+
setSelectedEventId(null)}
+ className="inline-flex items-center rounded-md border border-slate-300 dark:border-slate-600 px-2 py-1.5 text-xs font-medium text-slate-600 dark:text-slate-400 hover:bg-slate-50 dark:hover:bg-slate-700"
+ >
+
+
+
+ {formatMessage({ id: 'gate_back', defaultMessage: 'Back' })}
+
+ )}
+
+ {selectedEventId
+ ? title
+ : formatMessage({
+ id: isAdmin ? 'gate_all_events' : 'gate_active_events',
+ defaultMessage: isAdmin ? 'All Events' : 'Active Events',
+ })}
+
+
+ {onLogout && (
+
{
+ await clearPassCode();
+ onLogout();
+ }}
+ className="inline-flex items-center rounded-md border border-slate-300 dark:border-slate-600 px-3 py-1.5 text-xs font-medium text-slate-600 dark:text-slate-400 hover:bg-slate-50 dark:hover:bg-slate-700"
+ >
+ {formatMessage({
+ id: 'gate_deactivate',
+ defaultMessage: 'Deactivate Scanner',
+ })}
+
+ )}
+
+
+ {selectedEventId ? (
+ detailLoading && !selectedEvent ? (
+
+ ) : selectedEvent ? (
+
+ ) : (
+
+ )
+ ) : eventsLoading ? (
+
+ ) : events.length ? (
+
setSelectedEventId(e._id)}
+ />
+ ) : (
+
+ )}
+
+ );
+};
+
+export default GateControl;
diff --git a/admin-ui/src/modules/ticketing/components/GateEventList.tsx b/admin-ui/src/modules/ticketing/components/GateEventList.tsx
new file mode 100644
index 0000000000..d5bb2db989
--- /dev/null
+++ b/admin-ui/src/modules/ticketing/components/GateEventList.tsx
@@ -0,0 +1,93 @@
+import { useIntl } from 'react-intl';
+import useFormatDateTime from '../../common/utils/useFormatDateTime';
+import Badge from '../../common/components/Badge';
+
+const GateEventList = ({ events, onSelectEvent }) => {
+ const { formatMessage } = useIntl();
+ const { formatDateTime } = useFormatDateTime();
+
+ return (
+
+ {events.map((event: any) => {
+ const slot = event?.contractConfiguration?.ercMetadataProperties?.slot;
+ const tokens = event?.tokens || [];
+ const activeTokens = tokens.filter((t) => !t.isCanceled);
+ const redeemedCount = activeTokens.filter(
+ (t) => t.invalidatedDate,
+ ).length;
+ const invalidateableCount = activeTokens.filter(
+ (t) => t.isInvalidateable && !t.invalidatedDate,
+ ).length;
+
+ return (
+
onSelectEvent(event)}
+ className="w-full text-left bg-white dark:bg-slate-800 rounded-lg shadow-sm border border-slate-200 dark:border-slate-700 p-5 hover:border-slate-400 dark:hover:border-slate-500 transition-colors"
+ >
+
+
+
+ {event?.texts?.title}
+
+ {event?.texts?.subtitle && (
+
+ {event.texts.subtitle}
+
+ )}
+ {slot && (
+
+ {formatDateTime(slot, {
+ dateStyle: 'medium',
+ timeStyle: 'short',
+ })}
+
+ )}
+
+
+ {invalidateableCount > 0 && (
+
+ )}
+
+
+ {redeemedCount}
+
+
+ {' '}
+ / {activeTokens.length}
+
+
+ {formatMessage({
+ id: 'gate_redeemed',
+ defaultMessage: 'redeemed',
+ })}
+
+
+
+
+
+
+
+
+ );
+ })}
+
+ );
+};
+
+export default GateEventList;
diff --git a/admin-ui/src/modules/ticketing/components/GatePassCodeForm.tsx b/admin-ui/src/modules/ticketing/components/GatePassCodeForm.tsx
new file mode 100644
index 0000000000..002f393e8b
--- /dev/null
+++ b/admin-ui/src/modules/ticketing/components/GatePassCodeForm.tsx
@@ -0,0 +1,72 @@
+import { useState } from 'react';
+import { useIntl } from 'react-intl';
+import { toast } from 'react-toastify';
+import useIsPassCodeValid from '../hooks/useIsPassCodeValid';
+
+const GatePassCodeForm = ({ onAuthenticated }) => {
+ const { formatMessage } = useIntl();
+ const { validatePassCode, loading } = useIsPassCodeValid();
+ const [passCode, setPassCode] = useState('');
+
+ const handleSubmit = async (e) => {
+ e.preventDefault();
+ if (!passCode.trim()) return;
+
+ const valid = await validatePassCode(passCode.trim());
+ if (valid) {
+ onAuthenticated();
+ } else {
+ toast.error(
+ formatMessage({
+ id: 'gate_invalid_passcode',
+ defaultMessage: 'Invalid pass code. Please try again.',
+ }),
+ );
+ }
+ };
+
+ return (
+
+
+
+ {formatMessage({
+ id: 'gate_enter_passcode',
+ defaultMessage:
+ 'Enter the scanner pass code to activate the gate control.',
+ })}
+
+
+
+
+ );
+};
+
+export default GatePassCodeForm;
diff --git a/admin-ui/src/modules/ticketing/components/TicketEventDetail.tsx b/admin-ui/src/modules/ticketing/components/TicketEventDetail.tsx
new file mode 100644
index 0000000000..34aec13c79
--- /dev/null
+++ b/admin-ui/src/modules/ticketing/components/TicketEventDetail.tsx
@@ -0,0 +1,411 @@
+import Link from 'next/link';
+import { useCallback, useState } from 'react';
+import { useIntl } from 'react-intl';
+import { toast } from 'react-toastify';
+import useModal from '../../modal/hooks/useModal';
+import DangerMessage from '../../modal/components/DangerMessage';
+import useFormatDateTime from '../../common/utils/useFormatDateTime';
+import ImageWithFallback from '../../common/components/ImageWithFallback';
+import defaultNextImageLoader from '../../common/utils/defaultNextImageLoader';
+import Badge from '../../common/components/Badge';
+import EventTokenList from './EventTokenList';
+import useCancelTicket from '../hooks/useCancelTicket';
+import useCancelEvent from '../hooks/useCancelEvent';
+import useInvalidateTicket from '../../token/hooks/useInvalidateTicket';
+import useSetScannerPassCode from '../hooks/useSetScannerPassCode';
+import generateUniqueId from '../../common/utils/getUniqueId';
+
+const TicketEventDetail = ({ product }) => {
+ const { formatMessage } = useIntl();
+ const { formatDateTime } = useFormatDateTime();
+ const { setModal } = useModal();
+ const { cancelTicket } = useCancelTicket();
+ const { cancelEvent } = useCancelEvent();
+ const { invalidateTicket } = useInvalidateTicket();
+ const { setScannerPassCode } = useSetScannerPassCode();
+ const [passCodeInput, setPassCodeInput] = useState('');
+
+ const slot = product?.contractConfiguration?.ercMetadataProperties?.slot;
+ const supply = product?.contractConfiguration?.supply || 0;
+ const remaining =
+ product?.simulatedStocks?.reduce((acc, cur) => acc + cur.quantity, 0) || 0;
+ const sold = supply - remaining;
+
+ const activeTokens = product?.tokens?.filter((t) => !t.isCanceled) || [];
+ const redeemedTokens = activeTokens.filter((t) => t.invalidatedDate);
+
+ const onCancelEvent = useCallback(async () => {
+ let generateDiscount = false;
+ await setModal(
+ setModal('')}
+ message={
+ <>
+ {formatMessage({
+ id: 'cancel_event_confirmation',
+ defaultMessage:
+ 'Are you sure you want to cancel this event? All tickets will be cancelled.',
+ })}
+
+ {
+ generateDiscount = e.target.checked;
+ }}
+ />
+ {formatMessage({
+ id: 'generate_discount_codes',
+ defaultMessage: 'Generate discount codes for affected users',
+ })}
+
+ >
+ }
+ onOkClick={async () => {
+ setModal('');
+ try {
+ await cancelEvent({ productId: product._id, generateDiscount });
+ toast.success(
+ formatMessage({
+ id: 'event_cancelled',
+ defaultMessage: 'Event cancelled successfully',
+ }),
+ );
+ } catch (e) {
+ toast.error(e.message);
+ }
+ }}
+ okText={formatMessage({
+ id: 'cancel_event',
+ defaultMessage: 'Cancel Event',
+ })}
+ />,
+ );
+ }, [product?._id]);
+
+ const onCancelTicket = useCallback(async (tokenId: string) => {
+ let generateDiscount = false;
+ await setModal(
+ setModal('')}
+ message={
+ <>
+ {formatMessage({
+ id: 'cancel_ticket_confirmation',
+ defaultMessage: 'Are you sure you want to cancel this ticket?',
+ })}
+
+ {
+ generateDiscount = e.target.checked;
+ }}
+ />
+ {formatMessage({
+ id: 'generate_discount_code',
+ defaultMessage: 'Generate discount code for the user',
+ })}
+
+ >
+ }
+ onOkClick={async () => {
+ setModal('');
+ try {
+ await cancelTicket({ tokenId, generateDiscount });
+ toast.success(
+ formatMessage({
+ id: 'ticket_cancelled',
+ defaultMessage: 'Ticket cancelled successfully',
+ }),
+ );
+ } catch (e) {
+ toast.error(e.message);
+ }
+ }}
+ okText={formatMessage({
+ id: 'cancel_ticket',
+ defaultMessage: 'Cancel Ticket',
+ })}
+ />,
+ );
+ }, []);
+
+ const onInvalidateTicket = useCallback(async (tokenId: string) => {
+ try {
+ await invalidateTicket({ tokenId });
+ toast.success(
+ formatMessage({
+ id: 'ticket_redeemed',
+ defaultMessage: 'Ticket redeemed successfully',
+ }),
+ );
+ } catch (e) {
+ toast.error(
+ formatMessage({
+ id: 'ticket_redeem_error',
+ defaultMessage:
+ 'Ticket already redeemed or not redeemable at this time',
+ }),
+ );
+ }
+ }, []);
+
+ if (!product) return null;
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ {product?.texts?.title}
+
+ {product?.texts?.subtitle && (
+
+ {product.texts.subtitle}
+
+ )}
+ {product?.texts?.description && (
+
+ {product.texts.description}
+
+ )}
+
+
+
+
+ {formatMessage({
+ id: 'event_date',
+ defaultMessage: 'Event Date',
+ })}
+
+
+ {slot
+ ? formatDateTime(slot, {
+ dateStyle: 'full',
+ timeStyle: 'short',
+ })
+ : '-'}
+
+
+
+
+ {formatMessage({
+ id: 'status',
+ defaultMessage: 'Status',
+ })}
+
+
+
+
+
+
+
+ {formatMessage({
+ id: 'tickets_sold',
+ defaultMessage: 'Tickets Sold',
+ })}
+
+
+ {sold}
+ / {supply}
+
+
+
+
+ {formatMessage({
+ id: 'tickets_redeemed',
+ defaultMessage: 'Tickets Redeemed',
+ })}
+
+
+
+ {redeemedTokens.length}
+
+
+ {' '}
+ / {activeTokens.length}
+
+
+
+
+
+ {product.status === 'ACTIVE' && !product.isCanceled && (
+
+
+ {formatMessage({
+ id: 'cancel_event',
+ defaultMessage: 'Cancel Event',
+ })}
+
+
+ )}
+
+
+
+
+
+
+ {formatMessage({
+ id: 'gate_control_settings',
+ defaultMessage: 'Gate Control',
+ })}
+
+
+ {formatMessage({
+ id: 'gate_control_description',
+ defaultMessage:
+ 'Set a scanner pass code to enable gate control for this event. Share this code with gate operators.',
+ })}
+
+
+
+
+ {formatMessage({
+ id: 'scanner_pass_code',
+ defaultMessage: 'Scanner Pass Code',
+ })}
+
+ setPassCodeInput(e.target.value)}
+ placeholder={
+ product?.scannerPassCode
+ ? formatMessage({
+ id: 'scanner_pass_code_set',
+ defaultMessage:
+ 'Pass code is set (enter new value to change)',
+ })
+ : formatMessage({
+ id: 'scanner_pass_code_placeholder',
+ defaultMessage: 'Enter a pass code for gate operators',
+ })
+ }
+ className="block w-full rounded-md border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-700 px-3 py-2 text-sm text-slate-900 dark:text-slate-100 placeholder-slate-400 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
+ />
+
+
{
+ try {
+ await setScannerPassCode({
+ productId: product._id,
+ passCode: passCodeInput.trim(),
+ });
+ setPassCodeInput('');
+ toast.success(
+ formatMessage({
+ id: 'scanner_pass_code_saved',
+ defaultMessage: 'Scanner pass code updated',
+ }),
+ );
+ } catch (e) {
+ toast.error(e.message);
+ }
+ }}
+ className="inline-flex items-center rounded-md bg-slate-800 px-4 py-2 text-sm font-medium text-white hover:bg-slate-950 disabled:opacity-50 disabled:cursor-not-allowed"
+ >
+ {formatMessage({
+ id: 'save',
+ defaultMessage: 'Save',
+ })}
+
+ {product?.scannerPassCode && (
+
{
+ try {
+ await setScannerPassCode({
+ productId: product._id,
+ passCode: null,
+ });
+ setPassCodeInput('');
+ toast.success(
+ formatMessage({
+ id: 'scanner_pass_code_removed',
+ defaultMessage: 'Scanner pass code removed',
+ }),
+ );
+ } catch (e) {
+ toast.error(e.message);
+ }
+ }}
+ className="inline-flex items-center rounded-md border border-rose-300 dark:border-rose-600 px-4 py-2 text-sm font-medium text-rose-600 dark:text-rose-400 hover:bg-rose-50 dark:hover:bg-rose-900/20"
+ >
+ {formatMessage({
+ id: 'remove',
+ defaultMessage: 'Remove',
+ })}
+
+ )}
+
+ {product?.scannerPassCode && (
+
+ {formatMessage({
+ id: 'scanner_pass_code_active',
+ defaultMessage: 'Gate control is active for this event.',
+ })}
+
+ )}
+
+
+
+
+ {formatMessage(
+ {
+ id: 'attendee_list',
+ defaultMessage: 'Attendees ({count})',
+ },
+ { count: product?.tokens?.length || 0 },
+ )}
+
+
+
+
+ );
+};
+
+export default TicketEventDetail;
diff --git a/admin-ui/src/modules/ticketing/components/TicketEventList.tsx b/admin-ui/src/modules/ticketing/components/TicketEventList.tsx
new file mode 100644
index 0000000000..58047c1adb
--- /dev/null
+++ b/admin-ui/src/modules/ticketing/components/TicketEventList.tsx
@@ -0,0 +1,38 @@
+import { useIntl } from 'react-intl';
+import Table from '../../common/components/Table';
+import TicketEventListItem from './TicketEventListItem';
+
+const TicketEventList = ({ products }) => {
+ const { formatMessage } = useIntl();
+
+ return (
+
+
+
+
+ {formatMessage({ id: 'title', defaultMessage: 'Title' })}
+
+
+ {formatMessage({
+ id: 'event_date',
+ defaultMessage: 'Event Date',
+ })}
+
+
+ {formatMessage({
+ id: 'tickets_sold',
+ defaultMessage: 'Tickets Sold',
+ })}
+
+
+ {formatMessage({ id: 'status', defaultMessage: 'Status' })}
+
+
+ {(products || []).map((product) => (
+
+ ))}
+
+ );
+};
+
+export default TicketEventList;
diff --git a/admin-ui/src/modules/ticketing/components/TicketEventListItem.tsx b/admin-ui/src/modules/ticketing/components/TicketEventListItem.tsx
new file mode 100644
index 0000000000..cc275ecda4
--- /dev/null
+++ b/admin-ui/src/modules/ticketing/components/TicketEventListItem.tsx
@@ -0,0 +1,101 @@
+import Link from 'next/link';
+import Table from '../../common/components/Table';
+import Badge from '../../common/components/Badge';
+import useFormatDateTime from '../../common/utils/useFormatDateTime';
+import ImageWithFallback from '../../common/components/ImageWithFallback';
+import defaultNextImageLoader from '../../common/utils/defaultNextImageLoader';
+import generateUniqueId from '../../common/utils/getUniqueId';
+
+const EVENT_STATUSES = {
+ ACTIVE: 'emerald',
+ DRAFT: 'amber',
+ DELETED: 'rose',
+};
+
+const TicketEventListItem = ({ product }) => {
+ const { formatDateTime } = useFormatDateTime();
+ const slot = product?.contractConfiguration?.ercMetadataProperties?.slot;
+
+ const supply = product?.contractConfiguration?.supply || 0;
+ const remaining =
+ product?.simulatedStocks?.reduce((acc, cur) => acc + cur.quantity, 0) || 0;
+ const sold = supply - remaining;
+ const ticketUrl = `/ticketing?slug=${generateUniqueId(product)}`;
+ return (
+
+
+
+
+
+
+
+
+ {product?.texts?.title || 'Untitled'}
+ {product?.texts?.subtitle && (
+
+ {product.texts.subtitle}
+
+ )}
+
+
+
+
+ {slot
+ ? formatDateTime(slot, {
+ month: 'short',
+ year: 'numeric',
+ day: 'numeric',
+ hour: 'numeric',
+ minute: 'numeric',
+ })
+ : '-'}
+
+
+
+
+
+ {sold}
+
+
/
+
{supply}
+ {supply > 0 && (
+
+ )}
+
+
+
+
+
+
+ );
+};
+
+export default TicketEventListItem;
diff --git a/admin-ui/src/modules/ticketing/hooks/useCancelEvent.ts b/admin-ui/src/modules/ticketing/hooks/useCancelEvent.ts
new file mode 100644
index 0000000000..0f77cb9bf8
--- /dev/null
+++ b/admin-ui/src/modules/ticketing/hooks/useCancelEvent.ts
@@ -0,0 +1,30 @@
+import { gql } from '@apollo/client';
+import { useMutation } from '@apollo/client/react';
+
+const CancelEventMutation = gql`
+ mutation CancelEvent($productId: ID!, $generateDiscount: Boolean) {
+ cancelEvent(productId: $productId, generateDiscount: $generateDiscount)
+ }
+`;
+
+const useCancelEvent = () => {
+ const [cancelEventMutation] = useMutation(CancelEventMutation);
+
+ const cancelEvent = async ({
+ productId,
+ generateDiscount,
+ }: {
+ productId: string;
+ generateDiscount?: boolean;
+ }) => {
+ const result = await cancelEventMutation({
+ variables: { productId, generateDiscount },
+ refetchQueries: ['Product', 'TicketEvents', 'Tokens'],
+ });
+ return result;
+ };
+
+ return { cancelEvent };
+};
+
+export default useCancelEvent;
diff --git a/admin-ui/src/modules/ticketing/hooks/useCancelTicket.ts b/admin-ui/src/modules/ticketing/hooks/useCancelTicket.ts
new file mode 100644
index 0000000000..9f462f061b
--- /dev/null
+++ b/admin-ui/src/modules/ticketing/hooks/useCancelTicket.ts
@@ -0,0 +1,36 @@
+import { gql } from '@apollo/client';
+import { useMutation } from '@apollo/client/react';
+
+const CancelTicketMutation = gql`
+ mutation CancelTicket($tokenId: ID!, $generateDiscount: Boolean) {
+ cancelTicket(tokenId: $tokenId, generateDiscount: $generateDiscount) {
+ _id
+ isCanceled
+ invalidatedDate
+ isInvalidateable
+ tokenSerialNumber
+ }
+ }
+`;
+
+const useCancelTicket = () => {
+ const [cancelTicketMutation] = useMutation(CancelTicketMutation);
+
+ const cancelTicket = async ({
+ tokenId,
+ generateDiscount,
+ }: {
+ tokenId: string;
+ generateDiscount?: boolean;
+ }) => {
+ const result = await cancelTicketMutation({
+ variables: { tokenId, generateDiscount },
+ refetchQueries: ['Product', 'TicketEvents', 'Tokens', 'Token'],
+ });
+ return result;
+ };
+
+ return { cancelTicket };
+};
+
+export default useCancelTicket;
diff --git a/admin-ui/src/modules/ticketing/hooks/useCheckGateCookie.ts b/admin-ui/src/modules/ticketing/hooks/useCheckGateCookie.ts
new file mode 100644
index 0000000000..afeac321f2
--- /dev/null
+++ b/admin-ui/src/modules/ticketing/hooks/useCheckGateCookie.ts
@@ -0,0 +1,32 @@
+import { gql } from '@apollo/client';
+import { useQuery } from '@apollo/client/react';
+import { useCurrentUser } from '../../accounts';
+import {
+ ICheckGateCookieQuery,
+ ICheckGateCookieQueryVariables,
+} from '../../../gql/types';
+
+const CheckGateCookieQuery = gql`
+ query CheckGateCookie {
+ isPassCodeValid
+ }
+`;
+const useCheckGateCookie = () => {
+ const { currentUser } = useCurrentUser();
+ const isAdmin = Boolean(currentUser?._id);
+ const { data, loading, refetch } = useQuery<
+ ICheckGateCookieQuery,
+ ICheckGateCookieQueryVariables
+ >(CheckGateCookieQuery, {
+ fetchPolicy: 'cache-and-network',
+ skip: isAdmin,
+ });
+ const authenticated = isAdmin || data?.isPassCodeValid === true;
+ return {
+ authenticated,
+ loading,
+ refetch,
+ };
+};
+
+export default useCheckGateCookie;
diff --git a/admin-ui/src/modules/ticketing/hooks/useEventProducts.ts b/admin-ui/src/modules/ticketing/hooks/useEventProducts.ts
new file mode 100644
index 0000000000..3740c00881
--- /dev/null
+++ b/admin-ui/src/modules/ticketing/hooks/useEventProducts.ts
@@ -0,0 +1,75 @@
+import { gql } from '@apollo/client';
+import { useQuery } from '@apollo/client/react';
+
+const TicketEventsQuery = gql`
+ query TicketEvents(
+ $queryString: String
+ $limit: Int
+ $offset: Int
+ $includeDrafts: Boolean = true
+ $forceLocale: Locale
+ ) {
+ ticketEvents(
+ queryString: $queryString
+ limit: $limit
+ offset: $offset
+ includeDrafts: $includeDrafts
+ ) {
+ _id
+ status
+ tags
+ updated
+ published
+ ... on TokenizedProduct {
+ texts(forceLocale: $forceLocale) {
+ _id
+ slug
+ title
+ subtitle
+ description
+ }
+ media(limit: 1) {
+ _id
+ file {
+ _id
+ url
+ name
+ }
+ }
+ contractConfiguration {
+ ercMetadataProperties
+ supply
+ }
+ simulatedStocks {
+ quantity
+ }
+ tokensCount
+ isCanceled
+ }
+ }
+ ticketEventsCount(includeDrafts: $includeDrafts, queryString: $queryString)
+ }
+`;
+
+const useEventProducts = ({
+ queryString = null,
+ limit = 50,
+ offset = 0,
+}: {
+ queryString?: string;
+ limit?: number;
+ offset?: number;
+}) => {
+ const { data, loading, error } = useQuery(TicketEventsQuery, {
+ variables: { queryString, limit, offset },
+ });
+
+ return {
+ products: data?.ticketEvents || [],
+ productsCount: data?.ticketEventsCount || 0,
+ loading,
+ error,
+ };
+};
+
+export default useEventProducts;
diff --git a/admin-ui/src/modules/ticketing/hooks/useGateEventDetail.ts b/admin-ui/src/modules/ticketing/hooks/useGateEventDetail.ts
new file mode 100644
index 0000000000..b641dc87f1
--- /dev/null
+++ b/admin-ui/src/modules/ticketing/hooks/useGateEventDetail.ts
@@ -0,0 +1,79 @@
+import { gql } from '@apollo/client';
+import { useQuery } from '@apollo/client/react';
+import {
+ IGateEventDetailQuery,
+ IGateEventDetailQueryVariables,
+} from '../../../gql/types';
+
+const GateEventDetailQuery = gql`
+ query GateEventDetail($productId: ID!) {
+ product(productId: $productId) {
+ _id
+ ... on TokenizedProduct {
+ texts {
+ _id
+ title
+ subtitle
+ }
+ contractConfiguration {
+ ercMetadataProperties
+ supply
+ }
+ isCanceled
+ tokens {
+ _id
+ tokenSerialNumber
+ isCanceled
+ invalidatedDate
+ isInvalidateable
+ ercMetadata
+ user {
+ _id
+ username
+ isGuest
+ primaryEmail {
+ address
+ verified
+ }
+ avatar {
+ _id
+ url
+ }
+ profile {
+ displayName
+ address {
+ firstName
+ lastName
+ }
+ }
+ lastContact {
+ emailAddress
+ telNumber
+ }
+ }
+ }
+ }
+ }
+ }
+`;
+
+const useGateEventDetail = (productId: string | null) => {
+ const { data, loading, error, refetch, previousData } = useQuery<
+ IGateEventDetailQuery,
+ IGateEventDetailQueryVariables
+ >(GateEventDetailQuery, {
+ variables: { productId },
+ skip: !productId,
+ fetchPolicy: 'cache-and-network',
+ pollInterval: 10000,
+ });
+
+ return {
+ event: data?.product || previousData?.product || null,
+ loading,
+ error,
+ refetch,
+ };
+};
+
+export default useGateEventDetail;
diff --git a/admin-ui/src/modules/ticketing/hooks/useGateEvents.ts b/admin-ui/src/modules/ticketing/hooks/useGateEvents.ts
new file mode 100644
index 0000000000..ce5e52f079
--- /dev/null
+++ b/admin-ui/src/modules/ticketing/hooks/useGateEvents.ts
@@ -0,0 +1,66 @@
+import { gql } from '@apollo/client';
+import { useQuery } from '@apollo/client/react';
+import {
+ IGateEventsQuery,
+ IGateEventsQueryVariables,
+} from '../../../gql/types';
+
+const GateEventsQuery = gql`
+ query GateEvents($onlyInvalidateable: Boolean!) {
+ ticketEvents(
+ limit: 100
+ includeDrafts: false
+ onlyInvalidateable: $onlyInvalidateable
+ ) {
+ _id
+ status
+ ... on TokenizedProduct {
+ texts {
+ _id
+ title
+ subtitle
+ }
+ contractConfiguration {
+ ercMetadataProperties
+ supply
+ }
+ isCanceled
+ tokens {
+ _id
+ tokenSerialNumber
+ isCanceled
+ invalidatedDate
+ isInvalidateable
+ }
+ }
+ }
+ }
+`;
+
+const useGateEvents = ({
+ onlyInvalidateable = false,
+}: { onlyInvalidateable?: boolean } = {}) => {
+ const { data, loading, error, refetch, previousData } = useQuery<
+ IGateEventsQuery,
+ IGateEventsQueryVariables
+ >(GateEventsQuery, {
+ variables: { onlyInvalidateable },
+ fetchPolicy: 'cache-and-network',
+ pollInterval: 10000,
+ });
+
+ const events = (
+ data?.ticketEvents ||
+ previousData?.ticketEvents ||
+ []
+ ).filter((p: any) => p?.tokens?.length && !p.isCanceled);
+
+ return {
+ events,
+ loading,
+ error,
+ refetch,
+ };
+};
+
+export default useGateEvents;
diff --git a/admin-ui/src/modules/ticketing/hooks/useIsPassCodeValid.ts b/admin-ui/src/modules/ticketing/hooks/useIsPassCodeValid.ts
new file mode 100644
index 0000000000..d6ea4e66f4
--- /dev/null
+++ b/admin-ui/src/modules/ticketing/hooks/useIsPassCodeValid.ts
@@ -0,0 +1,58 @@
+import { gql } from '@apollo/client';
+import { useMutation } from '@apollo/client/react';
+import {
+ IAuthenticateGateMutation,
+ IAuthenticateGateMutationVariables,
+ IDeauthenticateGateMutation,
+ IDeauthenticateGateMutationVariables,
+} from '../../../gql/types';
+
+const AuthenticateGateMutation = gql`
+ mutation AuthenticateGate($passCode: String!) {
+ authenticateGate(passCode: $passCode)
+ }
+`;
+
+const DeauthenticateGateMutation = gql`
+ mutation DeauthenticateGate {
+ deauthenticateGate
+ }
+`;
+
+const useIsPassCodeValid = () => {
+ const [authenticateGate, { loading: authLoading }] = useMutation<
+ IAuthenticateGateMutation,
+ IAuthenticateGateMutationVariables
+ >(AuthenticateGateMutation);
+ const [deauthenticateGate, { loading: deauthLoading }] = useMutation<
+ IDeauthenticateGateMutation,
+ IDeauthenticateGateMutationVariables
+ >(DeauthenticateGateMutation);
+
+ const validatePassCode = async (passCode: string) => {
+ try {
+ const result = await authenticateGate({
+ variables: { passCode },
+ });
+ return result.data?.authenticateGate || false;
+ } catch {
+ return false;
+ }
+ };
+
+ const clearPassCode = async () => {
+ try {
+ await deauthenticateGate();
+ } catch {
+ // ignore
+ }
+ };
+
+ return {
+ validatePassCode,
+ clearPassCode,
+ loading: authLoading || deauthLoading,
+ };
+};
+
+export default useIsPassCodeValid;
diff --git a/admin-ui/src/modules/ticketing/hooks/useSetScannerPassCode.ts b/admin-ui/src/modules/ticketing/hooks/useSetScannerPassCode.ts
new file mode 100644
index 0000000000..9494306616
--- /dev/null
+++ b/admin-ui/src/modules/ticketing/hooks/useSetScannerPassCode.ts
@@ -0,0 +1,41 @@
+import { gql } from '@apollo/client';
+import { useMutation } from '@apollo/client/react';
+import {
+ ISetEventScannerPassCodeMutation,
+ ISetEventScannerPassCodeMutationVariables,
+} from '../../../gql/types';
+
+const SetEventScannerPassCodeMutation = gql`
+ mutation SetEventScannerPassCode($productId: ID!, $passCode: String) {
+ setEventScannerPassCode(productId: $productId, passCode: $passCode) {
+ _id
+ ... on TokenizedProduct {
+ scannerPassCode
+ }
+ }
+ }
+`;
+
+const useSetScannerPassCode = () => {
+ const [setPassCodeMutation] = useMutation<
+ ISetEventScannerPassCodeMutation,
+ ISetEventScannerPassCodeMutationVariables
+ >(SetEventScannerPassCodeMutation);
+
+ const setScannerPassCode = async ({
+ productId,
+ passCode,
+ }: {
+ productId: string;
+ passCode: string | null;
+ }) => {
+ return setPassCodeMutation({
+ variables: { productId, passCode },
+ refetchQueries: ['Product'],
+ });
+ };
+
+ return { setScannerPassCode };
+};
+
+export default useSetScannerPassCode;
diff --git a/admin-ui/src/modules/ticketing/index.ts b/admin-ui/src/modules/ticketing/index.ts
new file mode 100644
index 0000000000..d672fe521b
--- /dev/null
+++ b/admin-ui/src/modules/ticketing/index.ts
@@ -0,0 +1,6 @@
+export { default as useEventProducts } from './hooks/useEventProducts';
+export { default as useCancelTicket } from './hooks/useCancelTicket';
+export { default as useCancelEvent } from './hooks/useCancelEvent';
+export { default as useIsPassCodeValid } from './hooks/useIsPassCodeValid';
+export { default as useGateEvents } from './hooks/useGateEvents';
+export { default as useSetScannerPassCode } from './hooks/useSetScannerPassCode';
diff --git a/admin-ui/src/modules/token/components/TokenDetail.tsx b/admin-ui/src/modules/token/components/TokenDetail.tsx
index b536de8fbc..b460c44ca0 100644
--- a/admin-ui/src/modules/token/components/TokenDetail.tsx
+++ b/admin-ui/src/modules/token/components/TokenDetail.tsx
@@ -122,6 +122,19 @@ const TokenDetail = ({ token }) => {
+ {token?.isCanceled && (
+
+
+ {formatMessage({
+ id: 'cancelled_status',
+ defaultMessage: 'Cancelled',
+ })}
+ :
+
+
+
+ )}
+
{token?.walletAddress && (
diff --git a/admin-ui/src/modules/token/components/TokenList.tsx b/admin-ui/src/modules/token/components/TokenList.tsx
index 0234239ca2..74aa0ed18c 100644
--- a/admin-ui/src/modules/token/components/TokenList.tsx
+++ b/admin-ui/src/modules/token/components/TokenList.tsx
@@ -33,6 +33,12 @@ const TokenList = ({ tokens }) => {
defaultMessage: 'Invalidated',
})}
+
+ {formatMessage({
+ id: 'token_cancelled',
+ defaultMessage: 'Cancelled',
+ })}
+
{(tokens || []).map((token) => (
diff --git a/admin-ui/src/modules/token/components/TokenListItem.tsx b/admin-ui/src/modules/token/components/TokenListItem.tsx
index 750e9017a3..50119b6c17 100644
--- a/admin-ui/src/modules/token/components/TokenListItem.tsx
+++ b/admin-ui/src/modules/token/components/TokenListItem.tsx
@@ -69,6 +69,11 @@ const TokenListItem = ({ token }) => {
: null}
+
+ {token.isCanceled ? (
+
+ ) : null}
+
);
};
diff --git a/admin-ui/src/pages/ticketing/TicketEventDetailPage.tsx b/admin-ui/src/pages/ticketing/TicketEventDetailPage.tsx
new file mode 100644
index 0000000000..4e398890bd
--- /dev/null
+++ b/admin-ui/src/pages/ticketing/TicketEventDetailPage.tsx
@@ -0,0 +1,33 @@
+import { useIntl } from 'react-intl';
+import BreadCrumbs from '../../modules/common/components/BreadCrumbs';
+import PageHeader from '../../modules/common/components/PageHeader';
+import Loading from '../../modules/common/components/Loading';
+import TicketEventDetail from '../../modules/ticketing/components/TicketEventDetail';
+import useProduct from '../../modules/product/hooks/useProduct';
+
+const TicketEventDetailPage = ({ slug }) => {
+ const { formatMessage } = useIntl();
+ const { product, loading } = useProduct({ slug: slug as string });
+
+ if (loading) return ;
+
+ return (
+ <>
+
+
+
+ >
+ );
+};
+
+export default TicketEventDetailPage;
diff --git a/admin-ui/src/pages/ticketing/gate.tsx b/admin-ui/src/pages/ticketing/gate.tsx
new file mode 100644
index 0000000000..04b1c0cc89
--- /dev/null
+++ b/admin-ui/src/pages/ticketing/gate.tsx
@@ -0,0 +1,56 @@
+import Link from 'next/link';
+import { useIntl } from 'react-intl';
+import { gql } from '@apollo/client';
+import useCurrentUser from '../../modules/accounts/hooks/useCurrentUser';
+import GatePassCodeForm from '../../modules/ticketing/components/GatePassCodeForm';
+import GateControl from '../../modules/ticketing/components/GateControl';
+import useCheckGateCookie from '../../modules/ticketing/hooks/useCheckGateCookie';
+
+const CheckGateCookieQuery = gql`
+ query CheckGateCookie {
+ isPassCodeValid
+ }
+`;
+
+const GateControlPage = () => {
+ const { formatMessage } = useIntl();
+ const { currentUser } = useCurrentUser();
+ const isAdmin = Boolean(currentUser?._id);
+ const { authenticated, loading, refetch } = useCheckGateCookie();
+
+ return (
+
+
+
+ {formatMessage({
+ id: 'gate_control_header',
+ defaultMessage: 'Gate Control',
+ })}
+
+
+
+
+ {formatMessage({
+ id: currentUser?._id ? 'back_to_admin' : 'back_to_login',
+ defaultMessage: currentUser?._id ? 'Back to Admin' : 'Log in',
+ })}
+
+
+ {authenticated ? (
+
refetch() : undefined}
+ isAdmin={isAdmin}
+ />
+ ) : loading ? null : (
+ refetch()} />
+ )}
+
+ );
+};
+
+GateControlPage.getLayout = (page) => page;
+
+export default GateControlPage;
diff --git a/admin-ui/src/pages/ticketing/index.tsx b/admin-ui/src/pages/ticketing/index.tsx
new file mode 100644
index 0000000000..2a0b912755
--- /dev/null
+++ b/admin-ui/src/pages/ticketing/index.tsx
@@ -0,0 +1,109 @@
+import Link from 'next/link';
+import { useIntl } from 'react-intl';
+import { useRouter } from 'next/router';
+
+import BreadCrumbs from '../../modules/common/components/BreadCrumbs';
+import PageHeader from '../../modules/common/components/PageHeader';
+import Loading from '../../modules/common/components/Loading';
+import NoData from '../../modules/common/components/NoData';
+import ListHeader from '../../modules/common/components/ListHeader';
+import SearchWithTags from '../../modules/common/components/SearchWithTags';
+import AnimatedCounter from '../../modules/common/components/AnimatedCounter';
+import TicketEventList from '../../modules/ticketing/components/TicketEventList';
+import useEventProducts from '../../modules/ticketing/hooks/useEventProducts';
+import TicketEventDetailPage from './TicketEventDetailPage';
+
+const TicketingPage = () => {
+ const { formatMessage } = useIntl();
+ const { query, push } = useRouter();
+
+ const { queryString, slug, ...rest } = query;
+
+ const setQueryString = (searchString) => {
+ const { skip, ...withoutSkip } = rest;
+ if (searchString)
+ push({
+ query: {
+ ...withoutSkip,
+ queryString: searchString,
+ },
+ });
+ else
+ push({
+ query: {
+ ...rest,
+ },
+ });
+ };
+
+ const { products, productsCount, loading } = useEventProducts({
+ limit: 0,
+ offset: 0,
+ queryString: queryString as string,
+ });
+
+ if (slug) return ;
+
+ const headerText =
+ productsCount === 1
+ ? formatMessage({
+ id: 'event_header',
+ defaultMessage: '1 Event',
+ })
+ : formatMessage(
+ {
+ id: 'event_count_header',
+ defaultMessage: '{count} Events',
+ },
+ { count: },
+ );
+
+ return (
+ <>
+
+
+
+
+ {formatMessage({
+ id: 'gate_control',
+ defaultMessage: 'Gate Control',
+ })}
+
+
+
+
+
+
+ <>
+ {loading ? : }
+ {!loading && !products?.length && (
+
+ )}
+ >
+
+
+ >
+ );
+};
+
+export default TicketingPage;
diff --git a/admin-ui/src/pages/tokens/TokenDetailPage.tsx b/admin-ui/src/pages/tokens/TokenDetailPage.tsx
index 9bd520bff5..397000eff9 100644
--- a/admin-ui/src/pages/tokens/TokenDetailPage.tsx
+++ b/admin-ui/src/pages/tokens/TokenDetailPage.tsx
@@ -1,4 +1,3 @@
-import { useRouter } from 'next/router';
import { IRoleAction } from '../../gql/types';
import useToken from '../../modules/token/hooks/useToken';
@@ -12,6 +11,7 @@ import HeaderDeleteButton from '../../modules/common/components/HeaderDeleteButt
import DangerMessage from '../../modules/modal/components/DangerMessage';
import useModal from '../../modules/modal/hooks/useModal';
import useInvalidateTicket from '../../modules/token/hooks/useInvalidateTicket';
+import useCancelTicket from '../../modules/ticketing/hooks/useCancelTicket';
import { toast } from 'react-toastify';
import { useCallback } from 'react';
import useAuth from '../../modules/Auth/useAuth';
@@ -20,8 +20,10 @@ const TokenDetailPage = ({ tokenId }) => {
const { formatMessage } = useIntl();
const { setModal } = useModal();
- const { token, loading } = useToken({ tokenId: tokenId as string });
+ const { token: rawToken, loading } = useToken({ tokenId: tokenId as string });
+ const token = rawToken as typeof rawToken & { isCanceled?: boolean };
const { invalidateTicket } = useInvalidateTicket();
+ const { cancelTicket } = useCancelTicket();
const { hasRole } = useAuth();
const onInvalidateToken = useCallback(async () => {
@@ -29,23 +31,72 @@ const TokenDetailPage = ({ tokenId }) => {
setModal('')}
message={formatMessage({
- id: 'delete_paymentProvider_confirmation',
+ id: 'invalidate_token_confirmation',
defaultMessage:
- 'This action might cause inconsistencies with other data that relates to it. Are you sure you want to delete this Payment provider? ',
+ 'Are you sure you want to invalidate this token? This marks it as redeemed.',
})}
onOkClick={async () => {
setModal('');
await invalidateTicket({ tokenId });
toast.success(
formatMessage({
- id: 'payment_provider_deleted',
- defaultMessage: 'Payment provider deleted successfully',
+ id: 'token_invalidated',
+ defaultMessage: 'Token invalidated successfully',
}),
);
}}
okText={formatMessage({
- id: 'delete_payment_provider',
- defaultMessage: 'Delete payment provider',
+ id: 'invalidate_token',
+ defaultMessage: 'Invalidate',
+ })}
+ />,
+ );
+ }, [tokenId]);
+
+ const onCancelToken = useCallback(async () => {
+ let generateDiscount = false;
+ await setModal(
+ setModal('')}
+ message={
+ <>
+ {formatMessage({
+ id: 'cancel_ticket_confirmation',
+ defaultMessage:
+ 'Are you sure you want to cancel this ticket? This action cannot be undone.',
+ })}
+
+ {
+ generateDiscount = e.target.checked;
+ }}
+ />
+ {formatMessage({
+ id: 'generate_discount_code',
+ defaultMessage: 'Generate discount code for the user',
+ })}
+
+ >
+ }
+ onOkClick={async () => {
+ setModal('');
+ try {
+ await cancelTicket({ tokenId, generateDiscount });
+ toast.success(
+ formatMessage({
+ id: 'ticket_cancelled',
+ defaultMessage: 'Ticket cancelled successfully',
+ }),
+ );
+ } catch (e) {
+ toast.error(e.message);
+ }
+ }}
+ okText={formatMessage({
+ id: 'cancel_ticket',
+ defaultMessage: 'Cancel Ticket',
})}
/>,
);
@@ -72,17 +123,29 @@ const TokenDetailPage = ({ tokenId }) => {
)}
- {!token.invalidatedDate &&
- token.isInvalidateable &&
- hasRole(IRoleAction.UpdateToken) ? (
-
- ) : null}
+
+ {!token.isCanceled && hasRole(IRoleAction.UpdateToken) && (
+
+ )}
+ {!token.invalidatedDate &&
+ token.isInvalidateable &&
+ !token.isCanceled &&
+ hasRole(IRoleAction.UpdateToken) ? (
+
+ ) : null}
+
>
diff --git a/package-lock.json b/package-lock.json
index e741409985..26f71a093f 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -21684,6 +21684,7 @@
"@unchainedshop/api": "^4.6.0",
"@unchainedshop/core": "^4.6.0",
"@unchainedshop/core-files": "^4.6.0",
+ "@unchainedshop/core-orders": "^4.6.0",
"@unchainedshop/core-warehousing": "^4.6.0",
"@unchainedshop/core-worker": "^4.6.0",
"@unchainedshop/events": "^4.6.0",
diff --git a/packages/api/src/context.ts b/packages/api/src/context.ts
index cc9d16c1de..384bfc8e76 100644
--- a/packages/api/src/context.ts
+++ b/packages/api/src/context.ts
@@ -37,9 +37,22 @@ export interface AdminUiConfig {
defaultUserTags?: string[];
}
+export interface CookieOptions {
+ domain?: string;
+ path?: string;
+ secure?: boolean;
+ httpOnly?: boolean;
+ sameSite?: 'strict' | 'lax' | 'none' | boolean;
+ maxAge?: number;
+ expires?: Date;
+}
+
export interface UnchainedHTTPServerContext {
setHeader: (key: string, value: string) => void;
getHeader: (key: string) => string;
+ getCookie: (name: string) => string | undefined;
+ setCookie: (name: string, value: string, options: CookieOptions) => void;
+ clearCookie: (name: string, options: CookieOptions) => void;
remoteAddress?: string;
remotePort?: number;
}
@@ -75,6 +88,9 @@ export const createContextResolver =
async ({
getHeader,
setHeader,
+ getCookie,
+ setCookie,
+ clearCookie,
remoteAddress,
remotePort,
userId,
@@ -84,7 +100,15 @@ export const createContextResolver =
login,
logout,
}) => {
- const abstractHttpServerContext = { remoteAddress, remotePort, getHeader, setHeader };
+ const abstractHttpServerContext = {
+ remoteAddress,
+ remotePort,
+ getHeader,
+ setHeader,
+ getCookie,
+ setCookie,
+ clearCookie,
+ };
const loaders = instantiateLoaders(unchainedAPI);
const localeContext = await getLocaleContext(abstractHttpServerContext, unchainedAPI);
diff --git a/packages/api/src/errors.ts b/packages/api/src/errors.ts
index a445630fee..322dba3da6 100644
--- a/packages/api/src/errors.ts
+++ b/packages/api/src/errors.ts
@@ -231,6 +231,11 @@ export const TokenWrongStatusError = createError(
export const TokenNotFoundError = createError('TokenNotFoundError', 'Token not found');
+export const TokenAlreadyRedeemedError = createError(
+ 'TokenAlreadyRedeemedError',
+ 'Cannot cancel a redeemed ticket',
+);
+
export const CyclicAssortmentLinkNotSupportedError = createError(
'CyclicAssortmentLinkNotSupported',
'Cyclic assortment link detected, make sure child assortment is not assigned as a parent on the assortment graph',
@@ -340,3 +345,8 @@ export const DuplicateFilterKeyError = createError(
'DuplicateFilterKeyError',
'Key already registered for another filter',
);
+
+export const TicketingModuleNotFoundError = createError(
+ 'TicketingModuleNotFoundError',
+ 'Ticketing module (passes) is not available, please configure @unchainedshop/ticketing',
+);
diff --git a/packages/api/src/express/index.ts b/packages/api/src/express/index.ts
index 2e0d95aa9f..3c4db9f9aa 100644
--- a/packages/api/src/express/index.ts
+++ b/packages/api/src/express/index.ts
@@ -98,6 +98,9 @@ const createAddContextMiddleware = (authConfig?: AuthConfig, trustProxy = false)
{
setHeader,
getHeader,
+ getCookie,
+ setCookie,
+ clearCookie,
remoteAddress,
remotePort,
login: authContext.login,
diff --git a/packages/api/src/fastify/index.ts b/packages/api/src/fastify/index.ts
index 8d3fec499b..add945d83c 100644
--- a/packages/api/src/fastify/index.ts
+++ b/packages/api/src/fastify/index.ts
@@ -83,6 +83,9 @@ const createMiddlewareHook = (authConfig?: AuthConfig, trustProxy = false) =>
{
setHeader,
getHeader,
+ getCookie,
+ setCookie,
+ clearCookie,
remoteAddress,
remotePort,
login: authContext.login,
diff --git a/packages/api/src/gate-cookie.ts b/packages/api/src/gate-cookie.ts
new file mode 100644
index 0000000000..21435bdc45
--- /dev/null
+++ b/packages/api/src/gate-cookie.ts
@@ -0,0 +1,33 @@
+import type { CookieOptions } from './context.ts';
+
+const {
+ UNCHAINED_GATE_COOKIE_NAME = 'unchained_gate_passcode',
+ UNCHAINED_GATE_COOKIE_MAX_AGE_SECONDS = '86400', // 24 hours
+ UNCHAINED_COOKIE_PATH = '/',
+ UNCHAINED_COOKIE_DOMAIN,
+ UNCHAINED_COOKIE_SAMESITE = 'lax',
+ UNCHAINED_COOKIE_INSECURE,
+} = process.env;
+
+export const GATE_COOKIE_NAME = UNCHAINED_GATE_COOKIE_NAME;
+export const GATE_COOKIE_MAX_AGE = parseInt(UNCHAINED_GATE_COOKIE_MAX_AGE_SECONDS, 10) * 1000;
+
+const resolveSameSite = (): CookieOptions['sameSite'] =>
+ (
+ ({
+ none: 'none',
+ lax: 'lax',
+ strict: 'strict',
+ }) as Record
+ )[UNCHAINED_COOKIE_SAMESITE?.trim()?.toLowerCase()] || 'lax';
+
+export function getGateCookieOptions(maxAge?: number): CookieOptions {
+ return {
+ domain: UNCHAINED_COOKIE_DOMAIN,
+ path: UNCHAINED_COOKIE_PATH,
+ secure: !UNCHAINED_COOKIE_INSECURE,
+ httpOnly: true,
+ sameSite: resolveSameSite(),
+ maxAge,
+ };
+}
diff --git a/packages/api/src/resolvers/mutations/index.ts b/packages/api/src/resolvers/mutations/index.ts
index f155fb0e9c..3e531ad350 100755
--- a/packages/api/src/resolvers/mutations/index.ts
+++ b/packages/api/src/resolvers/mutations/index.ts
@@ -84,6 +84,11 @@ import updateWarehousingProvider from './warehousing/updateWarehousingProvider.t
import removeWarehousingProvider from './warehousing/removeWarehousingProvider.ts';
import exportToken from './warehousing/exportToken.ts';
import invalidateToken from './warehousing/invalidateToken.ts';
+import cancelTicket from './warehousing/cancelTicket.ts';
+import cancelEvent from './warehousing/cancelEvent.ts';
+import setEventScannerPassCode from './warehousing/setEventScannerPassCode.ts';
+import authenticateGate from './warehousing/authenticateGate.ts';
+import deauthenticateGate from './warehousing/deauthenticateGate.ts';
import setPassword from './accounts/setPassword.ts';
import setRoles from './users/setRoles.ts';
import setUsername from './accounts/setUsername.ts';
@@ -266,6 +271,11 @@ export default {
removeWarehousingProvider: acl(actions.manageWarehousingProviders)(removeWarehousingProvider),
exportToken: acl(actions.updateToken)(exportToken),
invalidateToken: acl(actions.updateToken)(invalidateToken),
+ cancelTicket: acl(actions.updateToken)(cancelTicket),
+ cancelEvent: acl(actions.manageProducts)(cancelEvent),
+ setEventScannerPassCode: acl(actions.manageProducts)(setEventScannerPassCode),
+ authenticateGate: acl(actions.validatePassCode)(authenticateGate),
+ deauthenticateGate: acl(actions.validatePassCode)(deauthenticateGate),
createFilter: acl(actions.manageFilters)(createFilter),
updateFilter: acl(actions.manageFilters)(updateFilter),
removeFilter: acl(actions.manageFilters)(removeFilter),
diff --git a/packages/api/src/resolvers/mutations/warehousing/authenticateGate.ts b/packages/api/src/resolvers/mutations/warehousing/authenticateGate.ts
new file mode 100644
index 0000000000..b045ddb2c9
--- /dev/null
+++ b/packages/api/src/resolvers/mutations/warehousing/authenticateGate.ts
@@ -0,0 +1,32 @@
+import { log } from '@unchainedshop/logger';
+import type { Context } from '../../../context.ts';
+import { TicketingModuleNotFoundError } from '../../../errors.ts';
+import { GATE_COOKIE_NAME, GATE_COOKIE_MAX_AGE, getGateCookieOptions } from '../../../gate-cookie.ts';
+
+export default async function authenticateGate(
+ root: never,
+ { passCode }: { passCode: string },
+ context: Context,
+) {
+ const { services, userId } = context;
+ log(`mutation authenticateGate`, { userId });
+
+ if (!passCode) return false;
+
+ const ticketingServices = services as unknown as {
+ ticketing?: {
+ isPassCodeValid: (passCode: string, productId?: string) => Promise;
+ };
+ };
+
+ if (!ticketingServices.ticketing?.isPassCodeValid) {
+ throw new TicketingModuleNotFoundError({});
+ }
+
+ const isValid = await ticketingServices.ticketing.isPassCodeValid(passCode);
+ if (!isValid) return false;
+
+ context.setCookie(GATE_COOKIE_NAME, passCode, getGateCookieOptions(GATE_COOKIE_MAX_AGE));
+
+ return true;
+}
diff --git a/packages/api/src/resolvers/mutations/warehousing/cancelEvent.ts b/packages/api/src/resolvers/mutations/warehousing/cancelEvent.ts
new file mode 100644
index 0000000000..a49ff21f7f
--- /dev/null
+++ b/packages/api/src/resolvers/mutations/warehousing/cancelEvent.ts
@@ -0,0 +1,45 @@
+import type { Context } from '../../../context.ts';
+import { log } from '@unchainedshop/logger';
+import { ProductStatus } from '@unchainedshop/core-products';
+import {
+ InvalidIdError,
+ ProductNotFoundError,
+ ProductWrongStatusError,
+ TicketingModuleNotFoundError,
+} from '../../../errors.ts';
+
+export default async function cancelEvent(
+ root: never,
+ { productId, generateDiscount }: { productId: string; generateDiscount?: boolean },
+ context: Context,
+) {
+ const { modules, services, userId, countryCode, currencyCode } = context;
+ log(`mutation cancelEvent ${productId}`, { userId });
+
+ if (!productId) throw new InvalidIdError({ productId });
+
+ const product = await modules.products.findProduct({ productId });
+ if (!product) throw new ProductNotFoundError({ productId });
+
+ if (product.status !== ProductStatus.ACTIVE) {
+ throw new ProductWrongStatusError({ productId });
+ }
+
+ const passes = (modules as unknown as Record).passes as any;
+ if (!passes?.cancelTicket) {
+ throw new TicketingModuleNotFoundError({});
+ }
+
+ const ticketingServices = (services as unknown as any).ticketing;
+ if (!ticketingServices?.cancelTicketsForProduct) {
+ throw new TicketingModuleNotFoundError({});
+ }
+
+ const result = await ticketingServices.cancelTicketsForProduct(productId, {
+ generateDiscount,
+ countryCode,
+ currencyCode,
+ });
+
+ return result.cancelledCount;
+}
diff --git a/packages/api/src/resolvers/mutations/warehousing/cancelTicket.ts b/packages/api/src/resolvers/mutations/warehousing/cancelTicket.ts
new file mode 100644
index 0000000000..4940a93c77
--- /dev/null
+++ b/packages/api/src/resolvers/mutations/warehousing/cancelTicket.ts
@@ -0,0 +1,48 @@
+import type { Context } from '../../../context.ts';
+import { log } from '@unchainedshop/logger';
+import {
+ InvalidIdError,
+ TokenNotFoundError,
+ TokenAlreadyRedeemedError,
+ TicketingModuleNotFoundError,
+} from '../../../errors.ts';
+
+export default async function cancelTicket(
+ root: never,
+ { tokenId, generateDiscount }: { tokenId: string; generateDiscount?: boolean },
+ context: Context,
+) {
+ const { modules, services, userId, countryCode, currencyCode } = context;
+ log(`mutation cancelTicket ${tokenId}`, { userId, generateDiscount });
+
+ if (!tokenId) throw new InvalidIdError({ tokenId });
+
+ const token = await modules.warehousing.findToken({ tokenId });
+ if (!token) throw new TokenNotFoundError({ tokenId });
+
+ if (token.meta?.cancelled) {
+ return token;
+ }
+
+ if (token.invalidatedDate) {
+ throw new TokenAlreadyRedeemedError({ tokenId });
+ }
+
+ const passes = (modules as unknown as Record).passes as any;
+ if (!passes?.cancelTicket) {
+ throw new TicketingModuleNotFoundError({});
+ }
+
+ const ticketingServices = (services as unknown as any).ticketing;
+ if (!ticketingServices?.cancelTicketWithDiscount) {
+ throw new TicketingModuleNotFoundError({});
+ }
+
+ const result = await ticketingServices.cancelTicketWithDiscount(tokenId, {
+ generateDiscount,
+ countryCode,
+ currencyCode,
+ });
+
+ return result.token;
+}
diff --git a/packages/api/src/resolvers/mutations/warehousing/deauthenticateGate.ts b/packages/api/src/resolvers/mutations/warehousing/deauthenticateGate.ts
new file mode 100644
index 0000000000..eea2a3273c
--- /dev/null
+++ b/packages/api/src/resolvers/mutations/warehousing/deauthenticateGate.ts
@@ -0,0 +1,12 @@
+import { log } from '@unchainedshop/logger';
+import type { Context } from '../../../context.ts';
+import { GATE_COOKIE_NAME, getGateCookieOptions } from '../../../gate-cookie.ts';
+
+export default async function deauthenticateGate(root: never, _: never, context: Context) {
+ const { userId } = context;
+ log(`mutation deauthenticateGate`, { userId });
+
+ context.clearCookie(GATE_COOKIE_NAME, getGateCookieOptions());
+
+ return true;
+}
diff --git a/packages/api/src/resolvers/mutations/warehousing/invalidateToken.ts b/packages/api/src/resolvers/mutations/warehousing/invalidateToken.ts
index ad489e0916..a81e006594 100644
--- a/packages/api/src/resolvers/mutations/warehousing/invalidateToken.ts
+++ b/packages/api/src/resolvers/mutations/warehousing/invalidateToken.ts
@@ -44,5 +44,7 @@ export default async function invalidateToken(
if (!isInvalidateable) throw new TokenWrongStatusError({ tokenId });
- return modules.warehousing.invalidateToken(tokenId);
+ const invalidatedToken = await modules.warehousing.invalidateToken(tokenId);
+
+ return invalidatedToken;
}
diff --git a/packages/api/src/resolvers/mutations/warehousing/setEventScannerPassCode.ts b/packages/api/src/resolvers/mutations/warehousing/setEventScannerPassCode.ts
new file mode 100644
index 0000000000..7e54fa88b5
--- /dev/null
+++ b/packages/api/src/resolvers/mutations/warehousing/setEventScannerPassCode.ts
@@ -0,0 +1,29 @@
+import { log } from '@unchainedshop/logger';
+import { InvalidIdError, ProductNotFoundError } from '../../../errors.ts';
+import type { Context } from '../../../context.ts';
+
+export default async function setEventScannerPassCode(
+ root: never,
+ { productId, passCode }: { productId: string; passCode?: string | null },
+ { modules, userId }: Context,
+) {
+ log(`mutation setEventScannerPassCode ${productId}`, { userId });
+
+ if (!productId) throw new InvalidIdError({ productId });
+
+ const product = await modules.products.findProduct({ productId });
+ if (!product) throw new ProductNotFoundError({ productId });
+
+ const existingMeta = (product.meta as Record) || {};
+ const updatedMeta = { ...existingMeta };
+
+ if (passCode === null || passCode === undefined) {
+ delete updatedMeta.scannerPassCode;
+ } else {
+ updatedMeta.scannerPassCode = passCode;
+ }
+
+ await modules.products.update(productId, { meta: updatedMeta });
+
+ return modules.products.findProduct({ productId });
+}
diff --git a/packages/api/src/resolvers/queries/index.ts b/packages/api/src/resolvers/queries/index.ts
index 1202330967..8c613f2a5a 100755
--- a/packages/api/src/resolvers/queries/index.ts
+++ b/packages/api/src/resolvers/queries/index.ts
@@ -64,6 +64,9 @@ import warehousingProvidersCount from './warehousing/warehousingProvidersCount.t
import token from './warehousing/token.ts';
import tokens from './warehousing/tokens.ts';
import tokensCount from './warehousing/tokensCount.ts';
+import ticketEvents from './ticketing/ticketEvents.ts';
+import ticketEventsCount from './ticketing/ticketEventsCount.ts';
+import isPassCodeValid from './ticketing/isPassCodeValid.ts';
import work from './worker/work.ts';
import workQueue from './worker/workQueue.ts';
import workStatistics from './worker/workStatistics.ts';
@@ -108,6 +111,9 @@ export default {
token: acl(actions.viewToken)(token),
tokens: acl(actions.viewTokens)(tokens),
tokensCount: acl(actions.viewTokens)(tokensCount),
+ ticketEvents: acl(actions.gateControl)(ticketEvents),
+ ticketEventsCount: acl(actions.gateControl)(ticketEventsCount),
+ isPassCodeValid: acl(actions.validatePassCode)(isPassCodeValid),
translatedProductTexts: acl(actions.viewTranslations)(translatedProductTexts),
translatedProductMediaTexts: acl(actions.viewTranslations)(translatedProductMediaTexts),
translatedProductVariationTexts: acl(actions.viewTranslations)(translatedProductVariationTexts),
diff --git a/packages/api/src/resolvers/queries/products/products.ts b/packages/api/src/resolvers/queries/products/products.ts
index ff6dbd7224..40456f6057 100644
--- a/packages/api/src/resolvers/queries/products/products.ts
+++ b/packages/api/src/resolvers/queries/products/products.ts
@@ -12,11 +12,6 @@ export default async function products(
},
{ modules, userId }: Context,
) {
- log(
- `query products: ${params.limit || 0} ${params.offset || 0} ${
- params.includeDrafts ? 'includeDrafts' : ''
- } ${params.slugs?.join(',')}`,
- { userId },
- );
+ log(`query products `, { ...params, userId });
return modules.products.findProducts(params);
}
diff --git a/packages/api/src/resolvers/queries/ticketing/isPassCodeValid.ts b/packages/api/src/resolvers/queries/ticketing/isPassCodeValid.ts
new file mode 100644
index 0000000000..93590412bf
--- /dev/null
+++ b/packages/api/src/resolvers/queries/ticketing/isPassCodeValid.ts
@@ -0,0 +1,29 @@
+import { log } from '@unchainedshop/logger';
+import type { Context } from '../../../context.ts';
+import { TicketingModuleNotFoundError } from '../../../errors.ts';
+import { GATE_COOKIE_NAME } from '../../../gate-cookie.ts';
+
+interface TicketingServices {
+ ticketing?: {
+ isPassCodeValid: (passCode: string, productId?: string) => Promise;
+ };
+}
+
+export default async function isPassCodeValid(
+ root: never,
+ { productId }: { productId?: string },
+ context: Context,
+) {
+ const { services, userId } = context;
+ log(`query isPassCodeValid`, { userId });
+
+ const passCode = context.getCookie(GATE_COOKIE_NAME);
+ if (!passCode) return false;
+
+ const ticketingServices = services as unknown as TicketingServices;
+ if (!ticketingServices.ticketing?.isPassCodeValid) {
+ throw new TicketingModuleNotFoundError({});
+ }
+
+ return ticketingServices.ticketing.isPassCodeValid(passCode, productId);
+}
diff --git a/packages/api/src/resolvers/queries/ticketing/ticketEvents.ts b/packages/api/src/resolvers/queries/ticketing/ticketEvents.ts
new file mode 100644
index 0000000000..b96ce9e03a
--- /dev/null
+++ b/packages/api/src/resolvers/queries/ticketing/ticketEvents.ts
@@ -0,0 +1,76 @@
+import { log } from '@unchainedshop/logger';
+import type { SortOption } from '@unchainedshop/utils';
+import type { Context } from '../../../context.ts';
+import { TicketingModuleNotFoundError } from '../../../errors.ts';
+import { GATE_COOKIE_NAME } from '../../../gate-cookie.ts';
+
+export default async function ticketEvents(
+ root: never,
+ {
+ queryString,
+ limit = 50,
+ offset = 0,
+ includeDrafts = true,
+ sort,
+ onlyInvalidateable = false,
+ }: {
+ queryString?: string;
+ limit: number;
+ offset: number;
+ includeDrafts?: boolean;
+ sort?: SortOption[];
+ onlyInvalidateable?: boolean;
+ },
+ context: Context,
+) {
+ const { modules, services, userId } = context;
+ log(`query ticketEvents`, { userId });
+
+ const passCode = context.getCookie?.(GATE_COOKIE_NAME);
+ const ticketingServices = (context.services as any)?.ticketing;
+
+ let products;
+
+ if (!userId && passCode) {
+ if (!ticketingServices?.productIdsForPassCode) {
+ throw new TicketingModuleNotFoundError({});
+ }
+ const productIds = await ticketingServices.productIdsForPassCode(passCode);
+ if (!productIds.length) return [];
+
+ const allProducts = await modules.products.findProducts({
+ type: 'TOKENIZED_PRODUCT',
+ queryString,
+ includeDrafts: false,
+ limit,
+ offset,
+ sort,
+ });
+
+ products = allProducts.filter((p) => productIds.includes(p._id));
+ } else {
+ products = await modules.products.findProducts({
+ type: 'TOKENIZED_PRODUCT',
+ queryString,
+ includeDrafts,
+ limit,
+ offset,
+ sort,
+ });
+ }
+
+ if (onlyInvalidateable) {
+ const filtered = await Promise.all(
+ products.map(async (product) => {
+ const tokens = await modules.warehousing.findTokens({ productId: product._id });
+ const hasInvalidateable = await Promise.all(
+ tokens.map((token) => services.warehousing.isTokenInvalidateable({ token })),
+ );
+ return hasInvalidateable.some(Boolean) ? product : null;
+ }),
+ );
+ return filtered.filter(Boolean);
+ }
+
+ return products;
+}
diff --git a/packages/api/src/resolvers/queries/ticketing/ticketEventsCount.ts b/packages/api/src/resolvers/queries/ticketing/ticketEventsCount.ts
new file mode 100644
index 0000000000..0018b62849
--- /dev/null
+++ b/packages/api/src/resolvers/queries/ticketing/ticketEventsCount.ts
@@ -0,0 +1,65 @@
+import { log } from '@unchainedshop/logger';
+import type { Context } from '../../../context.ts';
+import { TicketingModuleNotFoundError } from '../../../errors.ts';
+import { GATE_COOKIE_NAME } from '../../../gate-cookie.ts';
+
+export default async function ticketEventsCount(
+ root: never,
+ {
+ queryString,
+ includeDrafts = true,
+ onlyInvalidateable = false,
+ }: {
+ queryString?: string;
+ includeDrafts?: boolean;
+ onlyInvalidateable?: boolean;
+ },
+ context: Context,
+) {
+ const { modules, services, userId } = context;
+ log(`query ticketEventsCount`, { userId });
+
+ const passCode = context.getCookie?.(GATE_COOKIE_NAME);
+ const ticketingServices = (context.services as any)?.ticketing;
+
+ if (!userId && passCode) {
+ if (!ticketingServices?.productIdsForPassCode) {
+ throw new TicketingModuleNotFoundError({});
+ }
+ const productIds = await ticketingServices.productIdsForPassCode(passCode);
+ if (!onlyInvalidateable) return productIds.length;
+
+ let count = 0;
+ for (const productId of productIds) {
+ const tokens = await modules.warehousing.findTokens({ productId });
+ const hasInvalidateable = await Promise.all(
+ tokens.map((token) => services.warehousing.isTokenInvalidateable({ token })),
+ );
+ if (hasInvalidateable.some(Boolean)) count++;
+ }
+ return count;
+ }
+
+ if (onlyInvalidateable) {
+ const products = await modules.products.findProducts({
+ type: 'TOKENIZED_PRODUCT',
+ queryString,
+ includeDrafts,
+ });
+ let count = 0;
+ for (const product of products) {
+ const tokens = await modules.warehousing.findTokens({ productId: product._id });
+ const hasInvalidateable = await Promise.all(
+ tokens.map((token) => services.warehousing.isTokenInvalidateable({ token })),
+ );
+ if (hasInvalidateable.some(Boolean)) count++;
+ }
+ return count;
+ }
+
+ return modules.products.count({
+ type: 'TOKENIZED_PRODUCT',
+ queryString,
+ includeDrafts,
+ });
+}
diff --git a/packages/api/src/resolvers/type/product/product-tokenized-types.ts b/packages/api/src/resolvers/type/product/product-tokenized-types.ts
index ba9e6eabdc..9811d1e511 100644
--- a/packages/api/src/resolvers/type/product/product-tokenized-types.ts
+++ b/packages/api/src/resolvers/type/product/product-tokenized-types.ts
@@ -52,19 +52,36 @@ export const TokenizedProduct = {
});
},
+ isCanceled(product: Product): boolean {
+ return Boolean(product.meta?.cancelled);
+ },
+
async tokens(product: Product, params: never, requestContext: Context) {
- await checkAction(requestContext, actions.viewTokens, [undefined, params]);
+ try {
+ await checkAction(requestContext, actions.viewTokens, [product, params]);
+ } catch {
+ return [];
+ }
const tokens = await requestContext.modules.warehousing.findTokens({
productId: product._id,
});
return tokens;
},
async tokensCount(product: Product, params: never, requestContext: Context) {
- await checkAction(requestContext, actions.viewTokens, [undefined, params]);
+ try {
+ await checkAction(requestContext, actions.viewTokens, [product, params]);
+ } catch {
+ return 0;
+ }
return requestContext.modules.warehousing.tokensCount({
productId: product._id,
});
},
+
+ async scannerPassCode(product: Product, params: never, requestContext: Context) {
+ await checkAction(requestContext, actions.manageProducts, [undefined, params]);
+ return (product.meta as Record)?.scannerPassCode || null;
+ },
};
delete (TokenizedProduct as any).salesUnit;
diff --git a/packages/api/src/resolvers/type/token-types.ts b/packages/api/src/resolvers/type/token-types.ts
index 83952e4911..650209f60e 100644
--- a/packages/api/src/resolvers/type/token-types.ts
+++ b/packages/api/src/resolvers/type/token-types.ts
@@ -36,6 +36,10 @@ export const Token = {
return services.warehousing.isTokenInvalidateable({ token });
},
+ isCanceled: async (token: TokenSurrogate) => {
+ return Boolean(token.meta?.cancelled);
+ },
+
accessKey: async (token: TokenSurrogate, params: never, requestContext: Context) => {
const { modules } = requestContext;
await checkAction(requestContext, actions.updateToken, [undefined, { tokenId: token._id }]);
diff --git a/packages/api/src/roles/all.ts b/packages/api/src/roles/all.ts
index 05c8b1943e..7fec6131d0 100644
--- a/packages/api/src/roles/all.ts
+++ b/packages/api/src/roles/all.ts
@@ -1,4 +1,5 @@
import type { Context } from '../context.ts';
+import { GATE_COOKIE_NAME } from '../gate-cookie.ts';
export const all = (role, actions) => {
const isInLoginMutationResponse = (root) => {
@@ -111,7 +112,26 @@ export const all = (role, actions) => {
role.allow(actions.viewOrder, () => false);
role.allow(actions.viewQuotation, () => false);
role.allow(actions.viewEnrollment, () => false);
- role.allow(actions.viewTokens, () => false);
+ const hasValidPassCode = async (_root: any, _params: any, context: Context) => {
+ const passCode = context.getCookie?.(GATE_COOKIE_NAME);
+ if (!passCode) return false;
+ const ticketingServices = (context.services as any)?.ticketing;
+ if (!ticketingServices?.isPassCodeValid) return false;
+ return ticketingServices.isPassCodeValid(passCode);
+ };
+
+ role.allow(actions.validatePassCode, () => true);
+ role.allow(actions.gateControl, hasValidPassCode);
+
+ const hasValidPassCodeForProduct = async (root: any, _params: any, context: Context) => {
+ if (!root?._id) return false;
+ const passCode = context.getCookie?.(GATE_COOKIE_NAME);
+ if (!passCode) return false;
+ const ticketingServices = (context.services as any)?.ticketing;
+ if (!ticketingServices?.isPassCodeValid) return false;
+ return ticketingServices.isPassCodeValid(passCode, root._id);
+ };
+ role.allow(actions.viewTokens, hasValidPassCodeForProduct);
role.allow(actions.viewStatistics, () => false);
role.allow(actions.uploadUserAvatar, () => false);
role.allow(actions.uploadTempFile, () => false);
@@ -125,12 +145,31 @@ export const all = (role, actions) => {
role.allow(actions.viewUserOrders, isInLoginMutationResponse);
role.allow(actions.viewUserTokens, isInLoginMutationResponse);
role.allow(actions.viewUserQuotations, isInLoginMutationResponse);
- role.allow(actions.viewUserPrivateInfos, isInLoginMutationResponse);
+ const isInLoginOrHasValidPassCode = async (root: any, params: any, context: Context) => {
+ if (isInLoginMutationResponse(root)) return true;
+ return hasValidPassCode(root, params, context);
+ };
+ role.allow(actions.viewUserPrivateInfos, isInLoginOrHasValidPassCode);
role.allow(actions.viewUserEnrollments, isInLoginMutationResponse);
role.allow(actions.viewUserProductReviews, isInLoginMutationResponse);
- // special case: access to token sometimes works via a X-Token-AccessKey Header and thus should also be allowed for anonymous users
- role.allow(actions.updateToken, isOwnedToken);
+ // special case: access to token sometimes works via a X-Token-AccessKey Header or valid gate pass code
+ const hasValidPassCodeForToken = async (_root: any, params: any, context: Context) => {
+ const passCode = context.getCookie?.(GATE_COOKIE_NAME);
+ if (!passCode) return false;
+ const ticketingServices = (context.services as any)?.ticketing;
+ if (!ticketingServices?.isPassCodeValid) return false;
+ const tokenId = params?.tokenId;
+ if (!tokenId) return false;
+ const token = await context.modules.warehousing.findToken({ tokenId });
+ if (!token) return false;
+ return ticketingServices.isPassCodeValid(passCode, token.productId);
+ };
+ const isOwnedTokenOrValidPassCode = async (root: any, params: any, context: Context) => {
+ if (await isOwnedToken(root, params, context)) return true;
+ return hasValidPassCodeForToken(root, params, context);
+ };
+ role.allow(actions.updateToken, isOwnedTokenOrValidPassCode);
role.allow(actions.viewToken, isOwnedToken);
// special case: access to file downloads should work when meta.isPrivate is not set
diff --git a/packages/api/src/roles/index.ts b/packages/api/src/roles/index.ts
index fa89fed09c..1c0c5eaff0 100644
--- a/packages/api/src/roles/index.ts
+++ b/packages/api/src/roles/index.ts
@@ -123,6 +123,8 @@ const actions: Record = [
'downloadFile',
'uploadUserAvatar',
'uploadTempFile',
+ 'validatePassCode',
+ 'gateControl',
].reduce((oldValue, actionValue) => {
const newValue = oldValue;
newValue[actionValue] = actionValue;
diff --git a/packages/api/src/roles/loggedIn.ts b/packages/api/src/roles/loggedIn.ts
index 8df654f936..ac815f4a40 100644
--- a/packages/api/src/roles/loggedIn.ts
+++ b/packages/api/src/roles/loggedIn.ts
@@ -250,4 +250,6 @@ export const loggedIn = (role: any, actions: Record) => {
role.allow(actions.uploadUserAvatar, canUpdateAvatar);
role.allow(actions.uploadTempFile, canUpdateAvatar);
role.allow(actions.logoutAllSessions, () => true);
+ role.allow(actions.validatePassCode, () => true);
+ role.allow(actions.gateControl, () => true);
};
diff --git a/packages/api/src/schema/mutation.ts b/packages/api/src/schema/mutation.ts
index d5305ec405..a97e151407 100644
--- a/packages/api/src/schema/mutation.ts
+++ b/packages/api/src/schema/mutation.ts
@@ -858,6 +858,36 @@ export default [
invalidateToken(tokenId: ID!): Token!
exportToken(tokenId: ID!, quantity: Int! = 1, recipientWalletAddress: String!): Token!
+ """
+ Cancel a ticket (token). Sets the cancelled flag on the token metadata.
+ Optionally generates a discount code for reimbursement.
+ """
+ cancelTicket(tokenId: ID!, generateDiscount: Boolean): Token!
+
+ """
+ Cancel all tickets for an event (tokenized product). Invalidates all non-cancelled tokens.
+ Optionally generates discount codes for affected users.
+ Returns the number of tickets cancelled.
+ """
+ cancelEvent(productId: ID!, generateDiscount: Boolean): Int!
+
+ """
+ Set or remove the scanner pass code for gate control on a tokenized product.
+ Pass null to remove the pass code.
+ """
+ setEventScannerPassCode(productId: ID!, passCode: String): Product!
+
+ """
+ Authenticate gate control by validating a pass code and setting an HttpOnly cookie.
+ Returns true if the pass code is valid.
+ """
+ authenticateGate(passCode: String!): Boolean!
+
+ """
+ Deauthenticate gate control by clearing the gate pass code cookie.
+ """
+ deauthenticateGate: Boolean!
+
"""
Store user W3C Push subscription object
"""
diff --git a/packages/api/src/schema/query.ts b/packages/api/src/schema/query.ts
index 018447c250..df4af2215d 100644
--- a/packages/api/src/schema/query.ts
+++ b/packages/api/src/schema/query.ts
@@ -50,6 +50,7 @@ export default [
slugs: [String!]
includeDrafts: Boolean = false
queryString: String
+ type: ProductType
): Int! @cacheControl(maxAge: 180)
"""
@@ -65,6 +66,7 @@ export default [
offset: Int = 0
includeDrafts: Boolean = false
sort: [SortOptionInput!]
+ type: ProductType
): [Product!]!
"""
@@ -225,6 +227,33 @@ export default [
"""
tokensCount(queryString: String): Int!
+ """
+ List all ticket events (tokenized products), by default includes drafts
+ """
+ ticketEvents(
+ queryString: String
+ limit: Int = 50
+ offset: Int = 0
+ includeDrafts: Boolean = true
+ sort: [SortOptionInput!]
+ onlyInvalidateable: Boolean = false
+ ): [Product!]!
+
+ """
+ Returns total number of ticket events (tokenized products)
+ """
+ ticketEventsCount(
+ queryString: String
+ includeDrafts: Boolean = true
+ onlyInvalidateable: Boolean = false
+ ): Int!
+
+ """
+ Validates a scanner pass code for gate access. Pass code is read from the unchained_gate_passcode cookie (set via authenticateGate mutation).
+ Optionally restricted to a specific product.
+ """
+ isPassCodeValid(productId: ID): Boolean!
+
"""
Returns total number of payment providers, optionally filtered by type
"""
diff --git a/packages/api/src/schema/types/product/tokenized-product.ts b/packages/api/src/schema/types/product/tokenized-product.ts
index 6d2b7671fa..7fd6a2400a 100644
--- a/packages/api/src/schema/types/product/tokenized-product.ts
+++ b/packages/api/src/schema/types/product/tokenized-product.ts
@@ -61,6 +61,8 @@ export default [
contractConfiguration: ContractConfiguration
tokens: [Token!]!
tokensCount: Int!
+ isCanceled: Boolean
+ scannerPassCode: String @cacheControl(scope: PRIVATE, maxAge: 0)
}
`,
];
diff --git a/packages/api/src/schema/types/warehousing.ts b/packages/api/src/schema/types/warehousing.ts
index 8e21f192aa..f64af5115e 100644
--- a/packages/api/src/schema/types/warehousing.ts
+++ b/packages/api/src/schema/types/warehousing.ts
@@ -60,6 +60,7 @@ export default [
chainId: String
tokenSerialNumber: String
ercMetadata(forceLocale: Locale): JSON
+ isCanceled: Boolean
}
`,
];
diff --git a/packages/core-products/src/module/configureProductsModule.ts b/packages/core-products/src/module/configureProductsModule.ts
index 02782dd3f6..ebf7c374dd 100644
--- a/packages/core-products/src/module/configureProductsModule.ts
+++ b/packages/core-products/src/module/configureProductsModule.ts
@@ -34,6 +34,8 @@ export interface ProductQuery {
slugs?: string[];
tags?: string[];
skus?: string[];
+ type?: string;
+ contractStandard?: string;
bundleItemProductIds?: string[];
proxyAssignmentProductIds?: string[];
}
@@ -75,9 +77,19 @@ export const buildFindSelector = ({
skus,
bundleItemProductIds,
proxyAssignmentProductIds,
+ type,
+ contractStandard,
}: ProductQuery) => {
const selector: mongodb.Filter = productSelector ? { ...productSelector } : {};
+ if (type && !selector.type) {
+ selector.type = type as ProductType;
+ }
+
+ if (contractStandard) {
+ (selector as any)['tokenization.contractStandard'] = contractStandard;
+ }
+
if (productIds && !selector._id) {
selector._id = { $in: productIds };
}
diff --git a/packages/core/src/factory/index.ts b/packages/core/src/factory/index.ts
index dbb7359c6b..918e17569c 100644
--- a/packages/core/src/factory/index.ts
+++ b/packages/core/src/factory/index.ts
@@ -3,7 +3,6 @@ import registerPickUpDelivery from './registerPickUpDelivery.ts';
import registerShippingDelivery from './registerShippingDelivery.ts';
import registerWorker from './registerWorker.ts';
import registerPhysicalWarehousing from './registerPhysicalWarehousing.ts';
-import registerVirtualWarehousing from './registerVirtualWarehousing.ts';
import registerProductSearchFilter from './registerProductSearchFilter.ts';
import registerAssortmentSearchFilter from './registerAssortmentSearchFilter.ts';
import registerInvoicePayment from './registerInvoicePayment.ts';
@@ -15,7 +14,6 @@ export {
registerInvoicePayment,
registerWorker,
registerPhysicalWarehousing,
- registerVirtualWarehousing,
registerProductSearchFilter,
registerAssortmentSearchFilter,
};
diff --git a/packages/core/src/factory/registerVirtualWarehousing.ts b/packages/core/src/factory/registerVirtualWarehousing.ts
deleted file mode 100644
index 279cb39a75..0000000000
--- a/packages/core/src/factory/registerVirtualWarehousing.ts
+++ /dev/null
@@ -1,116 +0,0 @@
-import {
- type TokenSurrogate,
- type WarehousingConfiguration,
- WarehousingProviderType,
-} from '@unchainedshop/core-warehousing';
-import {
- WarehousingAdapter,
- type WarehousingContext,
- type IPlugin,
- type IWarehousingAdapter,
-} from '../core-index.ts';
-import { pluginRegistry } from '../plugins/PluginRegistry.ts';
-
-export default function registerVirtualWarehousing>({
- adapterId,
- orderIndex = 0,
- stock,
- tokenize,
- tokenMetadata,
- isInvalidateable,
-}: {
- adapterId: string;
- orderIndex?: number;
- stock?:
- | number
- | ((
- referenceDate: Date,
- configuration: WarehousingConfiguration,
- context: WarehousingContext,
- ) => Promise);
-
- tokenize: (
- configuration: WarehousingConfiguration,
- context: WarehousingContext,
- ) => Promise[]>;
-
- tokenMetadata?: (
- serialNumber: string,
- referenceDate: Date,
- configuration: WarehousingConfiguration,
- context: WarehousingContext,
- ) => Promise;
-
- isInvalidateable?: (
- serialNumber: string,
- referenceDate: Date,
- configuration: WarehousingConfiguration,
- context: WarehousingContext,
- ) => Promise;
-}): IPlugin {
- const adapter: IWarehousingAdapter = {
- ...WarehousingAdapter,
-
- key: 'shop.unchained.warehousing.virtual.' + adapterId,
- label: 'Virtual Wareshousing: ' + adapterId,
- version: '1.0.0',
- orderIndex,
-
- initialConfiguration: [{ key: 'shipping-hub', value: 'Shipping Hub' }],
-
- typeSupported: (type) => {
- return type === WarehousingProviderType.VIRTUAL;
- },
-
- actions: (configuration, context) => {
- return {
- ...WarehousingAdapter.actions(configuration, context),
-
- isActive() {
- return true;
- },
-
- configurationError() {
- return null;
- },
-
- stock: async (referenceDate) => {
- if (typeof stock === 'number') {
- return stock;
- } else if (typeof stock === 'function') {
- return stock(referenceDate, configuration, context);
- }
- return 99999;
- },
-
- async tokenize() {
- return tokenize(configuration, context);
- },
-
- async tokenMetadata(serialNumber, referenceDate) {
- if (tokenMetadata) {
- return tokenMetadata(serialNumber, referenceDate, configuration, context);
- }
- return null;
- },
-
- async isInvalidateable(serialNumber, referenceDate) {
- if (isInvalidateable) {
- return isInvalidateable(serialNumber, referenceDate, configuration, context);
- }
- return false;
- },
- };
- },
- };
-
- const plugin: IPlugin = {
- key: adapter.key,
- label: adapter.label,
- version: adapter.version,
- adapters: [adapter],
- };
-
- pluginRegistry.register(plugin);
- return plugin;
-}
diff --git a/packages/platform/src/setup/setupTemplates.ts b/packages/platform/src/setup/setupTemplates.ts
index a046cc31d1..21cbd29c27 100644
--- a/packages/platform/src/setup/setupTemplates.ts
+++ b/packages/platform/src/setup/setupTemplates.ts
@@ -9,6 +9,8 @@ import { resolveOrderConfirmationTemplate } from '../templates/resolveOrderConfi
import { resolveQuotationStatusTemplate } from '../templates/resolveQuotationStatusTemplate.ts';
import { resolveEnrollmentStatusTemplate } from '../templates/resolveEnrollmentStatusTemplate.ts';
import { resolveErrorReportTemplate } from '../templates/resolveErrorReportTemplate.ts';
+import { resolveEventCancelledTemplate } from '../templates/resolveEventCancelledTemplate.ts';
+import { resolveTicketCancelledTemplate } from '../templates/resolveTicketCancelledTemplate.ts';
export const MessageTypes = {
ACCOUNT_ACTION: 'ACCOUNT_ACTION',
@@ -18,6 +20,8 @@ export const MessageTypes = {
QUOTATION_STATUS: 'QUOTATION_STATUS',
ENROLLMENT_STATUS: 'ENROLLMENT_STATUS',
ERROR_REPORT: 'ERROR_REPORT',
+ EVENT_CANCELLED: 'EVENT_CANCELLED',
+ TICKET_CANCELLED: 'TICKET_CANCELLED',
} as const;
export type MessageTypes = (typeof MessageTypes)[keyof typeof MessageTypes];
@@ -30,6 +34,8 @@ export const setupTemplates = (unchainedAPI: UnchainedCore) => {
MessagingDirector.registerTemplate(MessageTypes.QUOTATION_STATUS, resolveQuotationStatusTemplate);
MessagingDirector.registerTemplate(MessageTypes.ENROLLMENT_STATUS, resolveEnrollmentStatusTemplate);
MessagingDirector.registerTemplate(MessageTypes.ERROR_REPORT, resolveErrorReportTemplate);
+ MessagingDirector.registerTemplate(MessageTypes.EVENT_CANCELLED, resolveEventCancelledTemplate);
+ MessagingDirector.registerTemplate(MessageTypes.TICKET_CANCELLED, resolveTicketCancelledTemplate);
subscribe('ORDER_CHECKOUT', async ({ payload }: RawPayloadType<{ order: Order }>) => {
const { order } = payload;
diff --git a/packages/platform/src/templates/resolveEventCancelledTemplate.ts b/packages/platform/src/templates/resolveEventCancelledTemplate.ts
new file mode 100644
index 0000000000..de43e88769
--- /dev/null
+++ b/packages/platform/src/templates/resolveEventCancelledTemplate.ts
@@ -0,0 +1,68 @@
+import type { TemplateResolver } from '@unchainedshop/core';
+
+const { EMAIL_FROM, EMAIL_WEBSITE_NAME = 'Unchained Shop', EMAIL_WEBSITE_URL } = process.env;
+
+export const resolveEventCancelledTemplate: TemplateResolver<{
+ productId: string;
+ userId: string;
+ discountCode?: string;
+ discountAmount?: number;
+}> = async ({ productId, userId, discountCode, discountAmount }, { modules }) => {
+ const user = await modules.users.findUserById(userId);
+ if (!user) return [];
+
+ const product = await modules.products.findProduct({ productId });
+ if (!product) return [];
+
+ const recipient = user.lastContact?.emailAddress || modules.users.primaryEmail(user)?.address;
+ if (!recipient) return [];
+
+ const locale = modules.users.userLocale(user);
+ const productText = await modules.products.texts.findLocalizedText({
+ productId,
+ locale,
+ });
+ const eventTitle = productText?.title || 'Event';
+
+ const slot = (product as any).tokenization?.ercMetadataProperties?.slot || product.meta?.slot;
+ const slotText = slot
+ ? new Date(slot).toLocaleString(locale.baseName, { dateStyle: 'medium', timeStyle: 'short' })
+ : '';
+
+ const location = product.meta?.location || '';
+
+ const eventDetails = [slotText, location].filter(Boolean).join(' at ');
+
+ let text = `Hello
+
+We regret to inform you that the event "${eventTitle}"${eventDetails ? ` (${eventDetails})` : ''} has been cancelled.`;
+
+ if (discountCode && discountAmount) {
+ text += `
+
+As compensation, you have received a discount code worth ${(discountAmount / 100).toFixed(2)}:
+
+ ${discountCode}
+
+You can use this code for future purchases.`;
+ }
+
+ text += `
+
+We apologize for the inconvenience.
+
+${EMAIL_WEBSITE_NAME}${EMAIL_WEBSITE_URL ? `\n${EMAIL_WEBSITE_URL}` : ''}
+`;
+
+ return [
+ {
+ type: 'EMAIL',
+ input: {
+ from: `${EMAIL_WEBSITE_NAME} <${EMAIL_FROM || 'noreply@unchained.local'}>`,
+ to: recipient,
+ subject: `${eventTitle}: Event Cancelled`,
+ text,
+ },
+ },
+ ];
+};
diff --git a/packages/platform/src/templates/resolveTicketCancelledTemplate.ts b/packages/platform/src/templates/resolveTicketCancelledTemplate.ts
new file mode 100644
index 0000000000..44ba89ebac
--- /dev/null
+++ b/packages/platform/src/templates/resolveTicketCancelledTemplate.ts
@@ -0,0 +1,71 @@
+import type { TemplateResolver } from '@unchainedshop/core';
+
+const { EMAIL_FROM, EMAIL_WEBSITE_NAME = 'Unchained Shop', EMAIL_WEBSITE_URL } = process.env;
+
+export const resolveTicketCancelledTemplate: TemplateResolver<{
+ tokenId: string;
+ userId: string;
+ discountCode?: string;
+ discountAmount?: number;
+}> = async ({ tokenId, userId, discountCode, discountAmount }, { modules }) => {
+ const user = await modules.users.findUserById(userId);
+ if (!user) return [];
+
+ const token = await modules.warehousing.findToken({ tokenId });
+ if (!token) return [];
+
+ const product = await modules.products.findProduct({ productId: token.productId });
+ if (!product) return [];
+
+ const recipient = user.lastContact?.emailAddress || modules.users.primaryEmail(user)?.address;
+ if (!recipient) return [];
+
+ const locale = modules.users.userLocale(user);
+ const productText = await modules.products.texts.findLocalizedText({
+ productId: token.productId,
+ locale,
+ });
+ const eventTitle = productText?.title || 'Event';
+
+ const slot = (product as any).tokenization?.ercMetadataProperties?.slot || product.meta?.slot;
+ const slotText = slot
+ ? new Date(slot).toLocaleString(locale.baseName, { dateStyle: 'medium', timeStyle: 'short' })
+ : '';
+
+ const location = product.meta?.location || '';
+
+ const eventDetails = [slotText, location].filter(Boolean).join(' at ');
+
+ let text = `Hello
+
+Your ticket for "${eventTitle}"${eventDetails ? ` (${eventDetails})` : ''} has been cancelled.`;
+
+ if (discountCode && discountAmount) {
+ text += `
+
+As compensation, you have received a discount code worth ${(discountAmount / 100).toFixed(2)}:
+
+ ${discountCode}
+
+You can use this code for future purchases.`;
+ }
+
+ text += `
+
+If you have any questions, please don't hesitate to contact us.
+
+${EMAIL_WEBSITE_NAME}${EMAIL_WEBSITE_URL ? `\n${EMAIL_WEBSITE_URL}` : ''}
+`;
+
+ return [
+ {
+ type: 'EMAIL',
+ input: {
+ from: `${EMAIL_WEBSITE_NAME} <${EMAIL_FROM || 'noreply@unchained.local'}>`,
+ to: recipient,
+ subject: `${eventTitle}: Ticket Cancelled`,
+ text,
+ },
+ },
+ ];
+};
diff --git a/packages/plugins/src/pricing/discount-reimbursement-code/adapter.ts b/packages/plugins/src/pricing/discount-reimbursement-code/adapter.ts
new file mode 100644
index 0000000000..540654da58
--- /dev/null
+++ b/packages/plugins/src/pricing/discount-reimbursement-code/adapter.ts
@@ -0,0 +1,86 @@
+import {
+ OrderDiscountAdapter,
+ type OrderDiscountConfiguration,
+ type IDiscountAdapter,
+} from '@unchainedshop/core';
+
+export interface PassesModule {
+ verifyDiscountCode: (code: string) => Promise;
+ discountCodeUsageBalance: (code: string) => Promise;
+}
+
+const getPassesModule = (modules: Record): PassesModule | null => {
+ const passes = modules.passes as PassesModule | undefined;
+ if (!passes?.verifyDiscountCode || !passes?.discountCodeUsageBalance) return null;
+ return passes;
+};
+
+export const ReimbursementCode: IDiscountAdapter = {
+ ...OrderDiscountAdapter,
+
+ key: 'shop.unchained.discount.reimbursement-code',
+ label: 'Reimbursement Code',
+ version: '1.0.0',
+ orderIndex: 2,
+
+ isManualAdditionAllowed: async () => true,
+ isManualRemovalAllowed: async () => true,
+
+ actions: async ({ context }) => {
+ const passes = getPassesModule(context.modules as unknown as Record);
+
+ const discountAmount = passes ? await passes.verifyDiscountCode(context.code!) : null;
+
+ const remainingDiscount = async (): Promise => {
+ if (!passes || discountAmount === null) return 0;
+ const used = await passes.discountCodeUsageBalance(context.code!);
+ const value = (discountAmount || 0) / 100;
+ return Math.max(0, Math.round((value - used) / value));
+ };
+
+ const orderPositions = await context.modules.orders.positions.findOrderPositions({
+ orderId: context?.order?._id as string,
+ });
+
+ const totalTickets = (orderPositions || []).reduce((sum, { quantity }) => sum + quantity, 0);
+
+ return {
+ ...(await OrderDiscountAdapter.actions({ context })),
+
+ reserve: async () => {
+ const remaining = await remainingDiscount();
+ return { remainingDiscount: remaining };
+ },
+
+ isValidForSystemTriggering: async () => false,
+
+ isValidForCodeTriggering: async () => {
+ if (discountAmount === null) return false;
+
+ const remaining = await remainingDiscount();
+ const reservation = context.orderDiscount?.reservation;
+
+ if (!remaining) {
+ if (reservation) return false;
+ throw new Error('DISCOUNT_USAGE_LIMIT_EXCEEDED');
+ }
+
+ if (reservation) {
+ const reservedItems = Math.max(0, Math.min(reservation.remainingDiscount, totalTickets));
+ const currentItems = Math.max(0, Math.min(remaining, totalTickets));
+ if (currentItems < reservedItems) return false;
+ }
+
+ return true;
+ },
+
+ discountForPricingAdapterKey({ pricingAdapterKey }) {
+ if (pricingAdapterKey !== 'shop.unchained.pricing.order-discount') return null;
+ if (discountAmount === null) return null;
+ return { fixedRate: discountAmount };
+ },
+ };
+ },
+};
+
+export default ReimbursementCode;
diff --git a/packages/plugins/src/pricing/discount-reimbursement-code/index.ts b/packages/plugins/src/pricing/discount-reimbursement-code/index.ts
new file mode 100644
index 0000000000..567fcb0db8
--- /dev/null
+++ b/packages/plugins/src/pricing/discount-reimbursement-code/index.ts
@@ -0,0 +1,14 @@
+import { type IPlugin } from '@unchainedshop/core';
+import { ReimbursementCode } from './adapter.ts';
+
+export const ReimbursementCodePlugin: IPlugin = {
+ key: 'shop.unchained.discount.reimbursement-code',
+ label: 'Reimbursement Code Discount Plugin',
+ version: '1.0.0',
+
+ adapters: [ReimbursementCode],
+};
+
+export default ReimbursementCodePlugin;
+
+export { ReimbursementCode } from './adapter.ts';
diff --git a/packages/plugins/src/warehousing/eth-minter/adapter.ts b/packages/plugins/src/warehousing/eth-minter/adapter.ts
index c6a6d55e59..7c14743a72 100644
--- a/packages/plugins/src/warehousing/eth-minter/adapter.ts
+++ b/packages/plugins/src/warehousing/eth-minter/adapter.ts
@@ -14,6 +14,64 @@ import { createLogger } from '@unchainedshop/logger';
const logger = createLogger('unchained:eth-minter');
+const buildTokenMetadata = async ({
+ product,
+ token,
+ tokenSerialNumber,
+ modules,
+ locale,
+ ercMetadataProperties,
+ tokenId,
+}: {
+ product: any;
+ token: any;
+ tokenSerialNumber: string;
+ modules: any;
+ locale: any;
+ ercMetadataProperties: any;
+ tokenId?: string;
+}) => {
+ const { ROOT_URL = 'http://localhost:4010' } = process.env;
+
+ const allLanguages = await modules.languages.findLanguages({
+ includeInactive: false,
+ });
+
+ const [firstMedia] = await modules.products.media.findProductMedias({
+ productId: product._id,
+ limit: 1,
+ });
+ const file = firstMedia && (await modules.files.findFile({ fileId: firstMedia.mediaId }));
+
+ const fileAdapter = file && getFileAdapter();
+ const signedUrl = await fileAdapter?.createDownloadURL(file!);
+ const url = signedUrl && (await modules.files.normalizeUrl(signedUrl, {}));
+ const text = await modules.products.texts.findLocalizedText({
+ productId: product._id,
+ locale: locale || systemLocale,
+ });
+
+ const name = `${text.title} #${tokenSerialNumber}`;
+
+ const isDefaultLanguageActive = locale ? locale.language === systemLocale.language : true;
+ const localization = isDefaultLanguageActive
+ ? {
+ uri: `${ROOT_URL}/erc-metadata/${product._id}/{locale}/${tokenId}.json`,
+ default: systemLocale.language,
+ locales: allLanguages.map((lang) => lang.isoCode),
+ }
+ : undefined;
+
+ return {
+ name,
+ description: text.description,
+ image: url,
+ properties: ercMetadataProperties,
+ localization,
+ ...(token?.meta || {}),
+ };
+};
+
export const ETHMinter: IWarehousingAdapter = {
...WarehousingAdapter,
@@ -29,21 +87,27 @@ export const ETHMinter: IWarehousingAdapter = {
},
actions: (configuration, context) => {
- const { MINTER_TOKEN_OFFSET = '0', ROOT_URL = 'http://localhost:4010' } = process.env;
+ const { MINTER_TOKEN_OFFSET = '0' } = process.env;
const { product, orderPosition, token, modules, locale } = context as WarehousingContext &
UnchainedCore;
const { contractAddress, contractStandard, tokenId, supply, ercMetadataProperties } =
product?.tokenization || {};
- const getTokensCreated = async () => {
- const existingTokens = await modules.warehousing.findTokens(
+
+ const getTokensCreated = async ({ skipCancelled = false } = {}) => {
+ const selector: Record =
contractStandard === ProductContractStandard.ERC721
- ? { contractAddress: contractStandard }
+ ? { productId: product!._id }
: {
- contractAddress: contractStandard,
+ productId: product!._id,
tokenSerialNumber: tokenId,
- },
- );
+ };
+
+ if (skipCancelled) {
+ selector['meta.cancelled'] = { $ne: true };
+ }
+
+ const existingTokens = await modules.warehousing.findTokens(selector);
const tokensCreated = existingTokens.reduce((acc, curToken) => {
return acc + curToken.quantity;
}, 0);
@@ -68,17 +132,33 @@ export const ETHMinter: IWarehousingAdapter = {
},
stock: async () => {
- const tokensCreated = await getTokensCreated();
+ const tokensCreated = await getTokensCreated({ skipCancelled: true });
return supply ? supply - tokensCreated : 0;
},
- tokenize: async () => {
- // Upload Image to IPFS
- // Upload Metadata to IPFS
- // Prepare metadata
+ async isInvalidateable(tokenSerialNumber, referenceDate) {
+ if (token?.invalidatedDate) return false;
+
+ const slot = ercMetadataProperties?.slot;
+ if (!slot) return true;
+
+ const currentDate = new Date(referenceDate);
+ const earliestEntry = new Date(slot);
+ earliestEntry.setHours(earliestEntry.getHours() - 2);
+
+ const latestEntry = new Date(slot);
+ latestEntry.setHours(latestEntry.getHours() + 1);
+
+ return (
+ earliestEntry.getTime() < currentDate.getTime() &&
+ latestEntry.getTime() > currentDate.getTime()
+ );
+ },
+
+ tokenize: async () => {
const chainId = configuration.find(({ key }) => key === 'chainId')?.value || undefined;
- const meta = { contractStandard };
+ const meta = { contractStandard, orderId: orderPosition?.orderId };
const tokensCreated = await getTokensCreated();
if (!orderPosition) {
@@ -121,43 +201,15 @@ export const ETHMinter: IWarehousingAdapter = {
throw new Error('Product not found in context');
}
- const allLanguages = await modules.languages.findLanguages({
- includeInactive: false,
+ return buildTokenMetadata({
+ product,
+ token,
+ tokenSerialNumber,
+ modules,
+ locale,
+ ercMetadataProperties,
+ tokenId,
});
-
- const [firstMedia] = await modules.products.media.findProductMedias({
- productId: product._id,
- limit: 1,
- });
- const file = firstMedia && (await modules.files.findFile({ fileId: firstMedia.mediaId }));
-
- const fileAdapter = file && getFileAdapter();
- const signedUrl = await fileAdapter?.createDownloadURL(file!);
- const url = signedUrl && (await modules.files.normalizeUrl(signedUrl, {}));
- const text = await modules.products.texts.findLocalizedText({
- productId: product._id,
- locale: locale || systemLocale,
- });
-
- const name = `${text.title} #${tokenSerialNumber}`;
-
- const isDefaultLanguageActive = locale ? locale.language === systemLocale.language : true;
- const localization = isDefaultLanguageActive
- ? {
- uri: `${ROOT_URL}/erc-metadata/${product._id}/${locale}/${tokenId}.json`,
- default: systemLocale.language,
- locales: allLanguages.map((lang) => lang.isoCode),
- }
- : undefined;
-
- return {
- name,
- description: text.description,
- image: url,
- properties: ercMetadataProperties,
- localization,
- ...(token?.meta || {}),
- };
},
};
},
diff --git a/packages/ticketing/package.json b/packages/ticketing/package.json
index ab905b3721..19122915ea 100644
--- a/packages/ticketing/package.json
+++ b/packages/ticketing/package.json
@@ -37,6 +37,7 @@
"@unchainedshop/api": "^4.6.0",
"@unchainedshop/core": "^4.6.0",
"@unchainedshop/core-files": "^4.6.0",
+ "@unchainedshop/core-orders": "^4.6.0",
"@unchainedshop/core-warehousing": "^4.6.0",
"@unchainedshop/core-worker": "^4.6.0",
"@unchainedshop/events": "^4.6.0",
diff --git a/packages/ticketing/src/discount-codes.ts b/packages/ticketing/src/discount-codes.ts
new file mode 100644
index 0000000000..e6922987b3
--- /dev/null
+++ b/packages/ticketing/src/discount-codes.ts
@@ -0,0 +1,108 @@
+export interface DiscountCodeHandlers {
+ generate: (amount: number) => Promise;
+ verify: (code: string) => Promise;
+}
+
+const defaultDiscountCodeSecret = '0000000000000000000000000000000000000000000000000000000000000000';
+
+async function siphash24Digest(payload: Uint8Array, key: Uint8Array): Promise {
+ const cryptoKey = await crypto.subtle.importKey(
+ 'raw',
+ key as ArrayBufferView,
+ { name: 'HMAC', hash: 'SHA-256' },
+ false,
+ ['sign'],
+ );
+ const signature = await crypto.subtle.sign('HMAC', cryptoKey, payload as ArrayBufferView);
+ return new Uint8Array(signature).slice(0, 8);
+}
+
+function toBase58(buffer: Uint8Array): string {
+ const ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
+ let num = 0n;
+ for (const byte of buffer) {
+ num = num * 256n + BigInt(byte);
+ }
+ if (num === 0n) return ALPHABET[0];
+ let result = '';
+ while (num > 0n) {
+ result = ALPHABET[Number(num % 58n)] + result;
+ num = num / 58n;
+ }
+ return result;
+}
+
+function fromBase58(str: string): Uint8Array {
+ const ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
+ let num = 0n;
+ for (const char of str) {
+ const index = ALPHABET.indexOf(char);
+ if (index === -1) throw new Error(`Invalid base58 character: ${char}`);
+ num = num * 58n + BigInt(index);
+ }
+ const bytes: number[] = [];
+ while (num > 0n) {
+ bytes.unshift(Number(num & 0xffn));
+ num = num >> 8n;
+ }
+ return new Uint8Array(bytes);
+}
+
+export function createDefaultDiscountCodeHandlers(): DiscountCodeHandlers {
+ const secret = process.env.DISCOUNT_CODE_SECRET || defaultDiscountCodeSecret;
+ const keyBytes = new Uint8Array(secret.match(/.{1,2}/g)!.map((byte) => parseInt(byte, 16)));
+
+ const generate = async (priceAmount: number): Promise => {
+ const salt = crypto.getRandomValues(new Uint16Array(1))[0];
+ const priceCents = Math.floor(priceAmount / 100);
+ const uint16Array = new Uint16Array([priceCents, salt]);
+ const payloadBuffer = new Uint8Array(
+ uint16Array.buffer,
+ uint16Array.byteOffset,
+ uint16Array.byteLength,
+ );
+
+ const hash = await siphash24Digest(payloadBuffer, keyBytes);
+ const signature = toBase58(hash);
+ const payload = toBase58(payloadBuffer);
+
+ return signature.replace(/(.{4})(.{4})(.*)/, `${payload}-$1-$2-$3`);
+ };
+
+ const verify = async (dashedSignature: string): Promise => {
+ try {
+ const [payload] = dashedSignature.split('-');
+ const payloadBytes = fromBase58(payload);
+ // Ensure we have exactly 4 bytes for two uint16 values
+ const padded = new Uint8Array(4);
+ padded.set(payloadBytes.slice(0, 4));
+ const uint16Array = new Uint16Array(padded.buffer);
+
+ const priceCents = uint16Array[0];
+ const salt = uint16Array[1];
+
+ const priceAmount = Math.floor(priceCents * 100);
+
+ // Need to regenerate with same salt for comparison
+ const saltedUint16 = new Uint16Array([priceCents, salt]);
+ const saltedPayload = new Uint8Array(
+ saltedUint16.buffer,
+ saltedUint16.byteOffset,
+ saltedUint16.byteLength,
+ );
+ const hash = await siphash24Digest(saltedPayload, keyBytes);
+ const sig = toBase58(hash);
+ const pay = toBase58(saltedPayload);
+ const expected = sig.replace(/(.{4})(.{4})(.*)/, `${pay}-$1-$2-$3`);
+
+ if (expected === dashedSignature) {
+ return priceAmount;
+ }
+ } catch {
+ /* invalid code */
+ }
+ return null;
+ };
+
+ return { generate, verify };
+}
diff --git a/packages/ticketing/src/index.ts b/packages/ticketing/src/index.ts
index c7c18c800f..7f72b1f6f6 100644
--- a/packages/ticketing/src/index.ts
+++ b/packages/ticketing/src/index.ts
@@ -1,19 +1,26 @@
import { subscribe } from '@unchainedshop/events';
import type { RawPayloadType } from '@unchainedshop/events';
import { WorkerEventTypes, type Work } from '@unchainedshop/core-worker';
-import type { UnchainedCore } from '@unchainedshop/core';
+import { type UnchainedCore } from '@unchainedshop/core';
import { RendererTypes, registerRenderer } from './template-registry.ts';
-import ticketingModules, { type TicketingModule } from './module.ts';
+import ticketingModules, { type TicketingModule, type TicketingOptions } from './module.ts';
import setupMagicKey from './magic-key.ts';
import ticketingServices, { type TicketingServices } from './services.ts';
+import type { DiscountCodeHandlers } from './discount-codes.ts';
export type TicketingAPI = UnchainedCore & {
modules: TicketingModule;
services: TicketingServices;
};
-export type { RendererTypes, TicketingModule, TicketingServices };
+export type {
+ RendererTypes,
+ TicketingModule,
+ TicketingServices,
+ TicketingOptions,
+ DiscountCodeHandlers,
+};
export { ticketingServices, ticketingModules };
@@ -39,10 +46,10 @@ export default function setupTicketing(
createAppleWalletPass,
createGoogleWalletPass,
}: {
- renderOrderPDF: any;
- createAppleWalletPass: any;
- createGoogleWalletPass: any;
- },
+ renderOrderPDF?: any;
+ createAppleWalletPass?: any;
+ createGoogleWalletPass?: any;
+ } = {},
) {
setupPDFTickets({
renderOrderPDF,
diff --git a/packages/ticketing/src/module.ts b/packages/ticketing/src/module.ts
index dfc967657a..0260f08f29 100644
--- a/packages/ticketing/src/module.ts
+++ b/packages/ticketing/src/module.ts
@@ -8,14 +8,22 @@ import type { File } from '@unchainedshop/core-files';
import { RendererTypes, getRenderer } from './template-registry.ts';
import { buildPassBinary, pushToApplePushNotificationService } from './mobile-tickets/apple-wallet.ts';
+import { type DiscountCodeHandlers, createDefaultDiscountCodeHandlers } from './discount-codes.ts';
+import { OrdersCollection, OrderStatus } from '@unchainedshop/core-orders';
export const APPLE_WALLET_PASSES_FILE_DIRECTORY = 'apple-wallet-passes';
const logger = createLogger('unchained:apple-wallet-webservice');
-const configurePasses = async ({ db }: ModuleInput>) => {
+export interface TicketingOptions {
+ discountCode?: DiscountCodeHandlers;
+}
+
+const configurePasses = async ({ db, options }: ModuleInput) => {
+ const discountCodeHandlers = options?.discountCode || createDefaultDiscountCodeHandlers();
const MediaObjects = await MediaObjectsCollection(db);
const TokenSurrogates = await TokenSurrogateCollection(db);
+ const Orders = await OrdersCollection(db);
await buildDbIndexes(TokenSurrogates as any, [
{ index: { 'meta.cancelled': 1 }, options: { sparse: true } },
@@ -224,6 +232,61 @@ const configurePasses = async ({ db }: ModuleInput>) => {
}
return TokenSurrogates.countDocuments(selector);
};
+ const discountCodeUsageBalance = async (discountCode: string): Promise => {
+ const orders = await Orders.aggregate([
+ {
+ $match: {
+ status: {
+ $in: [OrderStatus.CONFIRMED, OrderStatus.FULFILLED],
+ },
+ },
+ },
+ {
+ $lookup: {
+ from: 'order_discounts',
+ localField: 'calculation.discountId',
+ foreignField: '_id',
+ as: 'discounts',
+ },
+ },
+ {
+ $unwind: '$discounts',
+ },
+ {
+ $match: { 'discounts.code': discountCode },
+ },
+ {
+ $project: {
+ calculations: {
+ $filter: {
+ input: '$calculation',
+ as: 'calc',
+ cond: {
+ $and: [
+ { $eq: ['$$calc.category', 'DISCOUNTS'] },
+ { $eq: ['$$calc.discountId', '$discounts._id'] },
+ ],
+ },
+ },
+ },
+ },
+ },
+ ]).toArray();
+
+ return Math.round(
+ Math.abs(
+ orders.reduce((prev, { calculations }) => {
+ return (
+ prev +
+ (calculations as any[]).reduce(
+ (p: number, { amount }: { amount: number }) => p + amount / 100,
+ 0,
+ )
+ );
+ }, 0),
+ ),
+ );
+ };
return {
upsertAppleWalletPass,
@@ -237,6 +300,9 @@ const configurePasses = async ({ db }: ModuleInput>) => {
cancelTicket,
isTicketCancelled,
getTicketsCreated,
+ generateDiscountCode: discountCodeHandlers.generate,
+ verifyDiscountCode: discountCodeHandlers.verify,
+ discountCodeUsageBalance,
};
};
diff --git a/packages/ticketing/src/services.ts b/packages/ticketing/src/services.ts
index f016cb0ed7..32a9914cd8 100644
--- a/packages/ticketing/src/services.ts
+++ b/packages/ticketing/src/services.ts
@@ -1,10 +1,20 @@
+import { ProductType } from '@unchainedshop/core-products';
import type { TicketingModule } from './module.ts';
import type { Bound, UnchainedCore } from '@unchainedshop/core';
+interface DiscountOptions {
+ generateDiscount?: boolean;
+ countryCode?: string;
+ currencyCode?: string;
+}
+
async function cancelTicketsForProduct(
this: TicketingModule & UnchainedCore['modules'],
productId: string,
-): Promise {
+ options?: DiscountOptions,
+): Promise<{
+ cancelledCount: number;
+}> {
const tokensToCancel = await this.warehousing.findTokens({
productId,
'meta.cancelled': null,
@@ -16,20 +26,160 @@ async function cancelTicketsForProduct(
}
await this.products.update(productId, {
- $set: { 'meta.cancelled': true },
+ 'meta.cancelled': true,
+ });
+
+ const affectedUserIds = [...new Set(tokensToCancel.map((t) => t.userId).filter(Boolean))] as string[];
+
+ const discountByUser = new Map();
+
+ if (options?.generateDiscount && tokensToCancel.length > 0 && options.countryCode) {
+ const product = await this.products.findProduct({ productId });
+ const price =
+ product &&
+ (await this.products.prices.price(product, {
+ countryCode: options.countryCode,
+ currencyCode: options.currencyCode,
+ }));
+
+ if (price?.amount) {
+ const userTokenCounts = tokensToCancel.reduce(
+ (acc, token) => {
+ if (token.userId) {
+ acc[token.userId] = (acc[token.userId] || 0) + 1;
+ }
+ return acc;
+ },
+ {} as Record,
+ );
+
+ for (const [userId, quantity] of Object.entries(userTokenCounts)) {
+ const totalAmount = price.amount * quantity;
+ const discountCode = await this.passes.generateDiscountCode(totalAmount);
+ discountByUser.set(userId, { discountCode, amount: totalAmount });
+ }
+ }
+ }
+
+ await Promise.allSettled(
+ affectedUserIds.map(async (userId) => {
+ const discount = discountByUser.get(userId);
+ await this.worker.addWork({
+ type: 'MESSAGE',
+ input: {
+ template: 'EVENT_CANCELLED',
+ productId,
+ userId,
+ discountCode: discount?.discountCode,
+ discountAmount: discount?.amount,
+ },
+ });
+ }),
+ );
+
+ return { cancelledCount: tokensToCancel.length };
+}
+
+async function cancelTicketWithDiscount(
+ this: TicketingModule & UnchainedCore['modules'],
+ tokenId: string,
+ options?: DiscountOptions,
+): Promise<{ token: any }> {
+ const token = await this.warehousing.findToken({ tokenId });
+ await this.warehousing.invalidateToken(tokenId);
+ const cancelledToken = await this.passes.cancelTicket(tokenId);
+
+ let discountCode: string | undefined;
+ let discountAmount: number | undefined;
+
+ if (options?.generateDiscount && cancelledToken && options.countryCode) {
+ const product = await this.products.findProduct({ productId: cancelledToken.productId });
+ const price =
+ product &&
+ (await this.products.prices.price(product, {
+ countryCode: options.countryCode,
+ currencyCode: options.currencyCode,
+ }));
+
+ if (price?.amount) {
+ discountAmount = price.amount;
+ discountCode = await this.passes.generateDiscountCode(discountAmount);
+ }
+ }
+
+ if (token?.userId) {
+ await this.worker.addWork({
+ type: 'MESSAGE',
+ input: {
+ template: 'TICKET_CANCELLED',
+ tokenId,
+ userId: token.userId,
+ discountCode,
+ discountAmount,
+ },
+ });
+ }
+
+ return { token: cancelledToken };
+}
+
+async function isPassCodeValid(
+ this: TicketingModule & UnchainedCore['modules'],
+ passCode: string,
+ productId?: string,
+): Promise {
+ if (!passCode) return false;
+
+ const products = await this.products.findProducts({
+ type: ProductType.TOKENIZED_PRODUCT,
+ includeDrafts: false,
+ });
+
+ const matchingProducts = productId ? products.filter((p) => p._id === productId) : products;
+
+ return matchingProducts
+ .filter(Boolean)
+ .some(
+ (p) =>
+ (p.meta as Record)?.scannerPassCode?.toLowerCase().trim() ===
+ passCode.toLowerCase().trim(),
+ );
+}
+
+async function productIdsForPassCode(
+ this: TicketingModule & UnchainedCore['modules'],
+ passCode: string,
+): Promise {
+ if (!passCode) return [];
+
+ const products = await this.products.findProducts({
+ type: ProductType.TOKENIZED_PRODUCT,
+ includeDrafts: false,
});
- return tokensToCancel.length;
+ return products
+ .filter(
+ (p) =>
+ (p.meta as Record)?.scannerPassCode?.toLowerCase().trim() ===
+ passCode.toLowerCase().trim(),
+ )
+ .map((p) => p._id);
}
export default {
ticketing: {
cancelTicketsForProduct,
+ cancelTicketWithDiscount,
+ isPassCodeValid,
+ productIdsForPassCode,
},
};
export interface TicketingServices {
ticketing: {
cancelTicketsForProduct: Bound;
+ cancelTicketWithDiscount: Bound;
+ isPassCodeValid: Bound;
+ productIdsForPassCode: Bound;
};
}