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 && ( + + )} + {token.isInvalidateable && !token.invalidatedDate && ( + + )} + + )} +
+
+
+ ); +}; + +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 && ( + + )} + {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 && ( + + )} +

+ {selectedEventId + ? title + : formatMessage({ + id: isAdmin ? 'gate_all_events' : 'gate_active_events', + defaultMessage: isAdmin ? 'All Events' : 'Active Events', + })} +

+
+ {onLogout && ( + + )} +
+ + {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 ( + + ); + })} +
+ ); +}; + +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.', + })} +

+
+ setPassCode(e.target.value)} + placeholder={formatMessage({ + id: 'gate_passcode_placeholder', + defaultMessage: 'Pass code', + })} + className="mb-4 block w-full rounded-md border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-700 px-4 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" + required + autoFocus + /> + +
+
+
+ ); +}; + +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.', + })} + + + } + 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?', + })} + + + } + 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: '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.', + })} +

+
+
+ + 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" + /> +
+ + {product?.scannerPassCode && ( + + )} +
+ {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.', + })} + + + } + 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; }; }