diff --git a/REUSE.toml b/REUSE.toml index 6192b7e151f..b733ffb966d 100644 --- a/REUSE.toml +++ b/REUSE.toml @@ -106,3 +106,9 @@ path = "packages/admin-portal/public/sql-wasm.wasm" precedence = "aggregate" SPDX-FileCopyrightText = ["", "2025 Sequent Tech "] SPDX-License-Identifier = "MIT" + +[[annotations]] +path = "packages/admin-portal/src/services/generated/ivr_emulator_wasm.d.ts" +precedence = "aggregate" +SPDX-FileCopyrightText = ["", "2026 Sequent Tech Inc "] +SPDX-License-Identifier = "AGPL-3.0-only" diff --git a/packages/admin-portal/.prettierignore b/packages/admin-portal/.prettierignore index 8fa0b5d75c0..ef65cdec781 100644 --- a/packages/admin-portal/.prettierignore +++ b/packages/admin-portal/.prettierignore @@ -47,4 +47,5 @@ src/gql/ .github public/tinymce/ public/intl-tel-input/ -public/sql-wasm.js \ No newline at end of file +public/sql-wasm.js +src/services/generated/ivr_emulator_wasm.d.ts diff --git a/packages/admin-portal/public/global-settings.json b/packages/admin-portal/public/global-settings.json index e4f33917b39..7961d0b587e 100644 --- a/packages/admin-portal/public/global-settings.json +++ b/packages/admin-portal/public/global-settings.json @@ -4,6 +4,7 @@ "ONLINE_VOTING_CLIENT_ID": "admin-portal", "KEYCLOAK_URL": "http://localhost:8090/", "HASURA_URL": "http://localhost:8080/v1/graphql", + "IVR_EMULATOR_BASE_URL": "/wasm/ivr_emulator_wasm", "APP_VERSION": "dev", "APP_HASH": "dev", "DEFAULT_EMAIL_SUBJECT": {"en": "Participate in {{election_event.name}}"}, diff --git a/packages/admin-portal/src/providers/IvrEmulatorContextProvider.tsx b/packages/admin-portal/src/providers/IvrEmulatorContextProvider.tsx new file mode 100644 index 00000000000..817a102268d --- /dev/null +++ b/packages/admin-portal/src/providers/IvrEmulatorContextProvider.tsx @@ -0,0 +1,54 @@ +// SPDX-FileCopyrightText: 2026 Sequent Tech Inc +// +// SPDX-License-Identifier: AGPL-3.0-only +import React, {createContext, useContext, useEffect, useState} from "react" +import {IvrEmulatorApi, IvrEmulatorError, loadIvrEmulator} from "@/services/IvrEmulator" +import {SettingsContext} from "@/providers/SettingsContextProvider" + +export enum IvrApiStatus { + UNAVAILABLE = "unavailable", + LOADING = "loading", + READY = "ready", + ERROR = "error", +} + +export interface IvrEmulatorContextType { + status: IvrApiStatus + api?: IvrEmulatorApi +} + +export const IvrEmulatorContext = createContext(undefined) +export const IvrEmulatorContextProvider: React.FC<{children: React.ReactNode}> = ({children}) => { + const [status, setStatus] = useState(IvrApiStatus.LOADING) + const [api, setApi] = useState(undefined) + const {globalSettings} = useContext(SettingsContext) + const url = globalSettings.IVR_EMULATOR_BASE_URL + ? globalSettings.IVR_EMULATOR_BASE_URL + : "/wasm/ivr_emulator_wasm" + + useEffect(() => { + loadIvrEmulator(url) + ?.then((resolvedApi) => { + setStatus(IvrApiStatus.READY) + setApi(resolvedApi) + }) + .catch((e) => { + if (e instanceof IvrEmulatorError && e.operation === "fetch") { + setStatus(IvrApiStatus.UNAVAILABLE) + } else { + setStatus(IvrApiStatus.ERROR) + } + }) + }, []) + const value = {status, api} + + return {children} +} + +export const useIvrEmulator = (): IvrEmulatorContextType => { + const emulator = useContext(IvrEmulatorContext) + if (!emulator) { + throw new Error("useIvrEmulator can't be used outside IvrEmulatorContext") + } + return emulator +} diff --git a/packages/admin-portal/src/providers/SettingsContextProvider.tsx b/packages/admin-portal/src/providers/SettingsContextProvider.tsx index cc671d77a14..81a1207821a 100644 --- a/packages/admin-portal/src/providers/SettingsContextProvider.tsx +++ b/packages/admin-portal/src/providers/SettingsContextProvider.tsx @@ -22,6 +22,7 @@ export interface GlobalSettings { PUBLIC_BUCKET_URL: string VOTING_PORTAL_URL: string RESULTS_PORTAL_URL: string + IVR_EMULATOR_BASE_URL: string ACTIVATE_MIRU_EXPORT: boolean CUSTOM_URLS_DOMAIN_NAME: string } @@ -87,6 +88,7 @@ const defaultSettingsValues: SettingsContextValues = { PUBLIC_BUCKET_URL: "http://127.0.0.1:9002/public/", VOTING_PORTAL_URL: "http://localhost:3000", RESULTS_PORTAL_URL: "http://localhost:3004", + IVR_EMULATOR_BASE_URL: "/wasm/ivr_emulator_wasm", ACTIVATE_MIRU_EXPORT: false, CUSTOM_URLS_DOMAIN_NAME: "google.com", }, diff --git a/packages/admin-portal/src/resources/ElectionEvent/EditElectionEventIvr.tsx b/packages/admin-portal/src/resources/ElectionEvent/EditElectionEventIvr.tsx index e53c679f56b..692f47d33bc 100644 --- a/packages/admin-portal/src/resources/ElectionEvent/EditElectionEventIvr.tsx +++ b/packages/admin-portal/src/resources/ElectionEvent/EditElectionEventIvr.tsx @@ -9,7 +9,9 @@ import {IPermissions} from "@/types/keycloak" import {PhoneBlacklist} from "./PhoneBlacklist" import {IvrConfig} from "./IvrConfig" import {IvrPrompts} from "./IvrPrompts" +import {IvrEmulator} from "./IvrEmulator" import {Box} from "@mui/material" +import {IvrEmulatorContextProvider} from "@/providers/IvrEmulatorContextProvider" const ConfigTab: React.FC = () => ( Loading...}> @@ -29,6 +31,14 @@ const PromptsTab: React.FC = () => ( ) +const EmulatorTab: React.FC = () => ( + Loading...}> + + + + +) + export const EditElectionEventIvr: React.FC = () => { const {t} = useTranslation() const authContext = useContext(AuthContext) @@ -52,6 +62,11 @@ export const EditElectionEventIvr: React.FC = () => { }) } + tabs.push({ + label: "Emulator", + component: EmulatorTab, + }) + return ( diff --git a/packages/admin-portal/src/resources/ElectionEvent/IvrEmulator.tsx b/packages/admin-portal/src/resources/ElectionEvent/IvrEmulator.tsx new file mode 100644 index 00000000000..f244af32cf1 --- /dev/null +++ b/packages/admin-portal/src/resources/ElectionEvent/IvrEmulator.tsx @@ -0,0 +1,546 @@ +// SPDX-FileCopyrightText: 2026 Sequent Tech Inc +// +// SPDX-License-Identifier: AGPL-3.0-only +import React, {useEffect, useMemo, useRef, useState} from "react" +import {BooleanInput, useGetList, useRecordContext} from "react-admin" +import {FormProvider, useForm} from "react-hook-form" +import {Alert, AlertTitle, Box, Button, Stack, TextField} from "@mui/material" +import {useTranslation} from "react-i18next" +import { + Sequent_Backend_Ballot_Style, + Sequent_Backend_Election, + Sequent_Backend_Election_Event, +} from "@/gql/graphql" +import {ElectionHeaderStyles} from "@/components/styles/ElectionHeaderStyles" +import {IvrApiStatus, useIvrEmulator} from "@/providers/IvrEmulatorContextProvider" +import {v4} from "uuid" +import Paper from "@mui/material/Paper" +import { + IvrEmulatorApi, + Action, + IvrEmulatorDriver, + PromptInfo, + EmulatorConfig, +} from "@/services/IvrEmulator" +import DialpadIcon from "@mui/icons-material/Dialpad" +import TimerIcon from "@mui/icons-material/Timer" +import {theme} from "@sequentech/ui-essentials" +import SelectArea from "@/components/area/SelectArea" +import {useFormContext, useWatch} from "react-hook-form" +import {useAliasRenderer} from "@/hooks/useAliasRenderer" +import {FormStyles} from "@/components/styles/FormStyles" + +type ExpectedInput = Extract +type Status = "Ready" | "Running" | "ExpectingInput" | "Disconnected" + +const CALLER_NUMBER = "+1234567890" + +const generateContactId = (): string => v4() + +type ConfigFormValues = { + areaId: string + electionIds: string[] + blacklistCaller: boolean +} + +const ConfigFormBody: React.FC<{ + electionEvent: Sequent_Backend_Election_Event + onStartSession: (config: EmulatorConfig) => void +}> = ({electionEvent, onStartSession}) => { + const {t} = useTranslation() + const aliasRenderer = useAliasRenderer() + const {control, getValues} = useFormContext() + + const areaId = useWatch({control, name: "areaId"}) + const electionIds = useWatch({control, name: "electionIds"}) + + const {data: rawBallotStyles} = useGetList( + "sequent_backend_ballot_style", + { + pagination: {page: 1, perPage: 300}, + // Sort in ascending order so newer styles overwrite older ones + // when constructing the map. + sort: {field: "created_at", order: "ASC"}, + filter: { + tenant_id: electionEvent.tenant_id, + election_event_id: electionEvent.id, + area_id: areaId, + }, + }, + { + enabled: !!areaId, + } + ) + + const availableBallotStyles = useMemo>(() => { + return new Map( + rawBallotStyles?.flatMap((style) => { + if ( + style.election_id && + typeof style.election_id === "string" && + style.ballot_eml && + !style.deleted_at + ) { + return [[style.election_id, style.ballot_eml]] + } else { + return [] + } + }) ?? [] + ) + }, [rawBallotStyles]) + + const {data: rawElections} = useGetList("sequent_backend_election", { + pagination: {page: 1, perPage: 300}, + sort: {field: "name", order: "DESC"}, + filter: { + tenant_id: electionEvent.tenant_id, + election_event_id: electionEvent.id, + }, + }) + + const electionChoices = useMemo<{id: string; name: string}[]>( + () => + rawElections + ?.filter((e) => availableBallotStyles.has(e.id)) + .map((e) => ({ + id: e.id, + name: aliasRenderer(e), + })) + .sort((a, b) => a.name.localeCompare(b.name)) ?? [], + [rawElections, availableBallotStyles] + ) + + const resolvedBallotStyles = useMemo>(() => { + let filtered = availableBallotStyles + .entries() + .filter(([electionId, _style]) => electionIds.includes(electionId)) + return new Map(filtered) + }, [electionIds, availableBallotStyles]) + + const generateConfig = (data: ConfigFormValues): EmulatorConfig => { + return { + tenant_id: electionEvent.tenant_id, + election_event_id: electionEvent.id, + caller_number: CALLER_NUMBER, + contact_id: generateContactId(), + blacklisted_numbers: data.blacklistCaller ? [CALLER_NUMBER] : [], + open_elections: Array.from(resolvedBallotStyles.keys()), + election_event: JSON.stringify(electionEvent), + ballot_styles: Array.from(resolvedBallotStyles.values()), + } + } + + const onSubmit = () => { + let config = generateConfig(getValues()) + onStartSession(config) + } + + return ( + + + + + + {resolvedBallotStyles.size < 1 ? ( + + {t("electionEventScreen.ivr.emulator.noStylesFound")} + + ) : null} + + + + ) +} + +const ConfigForm: React.FC<{ + electionEvent: Sequent_Backend_Election_Event + onStartSession: (config: EmulatorConfig) => void +}> = ({electionEvent, onStartSession}) => { + const methods = useForm({ + defaultValues: {areaId: "", electionIds: [], blacklistCaller: false}, + }) + + return ( + +
+ + +
+ ) +} + +const PromptLine: React.FC<{id: number; prompt: PromptInfo}> = ({id, prompt}) => { + const promptBody = useMemo(() => { + // Strip off the root ssml tag. + return prompt.prompt_text.replace(/^/, "").replace(/<\/speak>$/, "") + }, [prompt]) + const lang = useMemo(() => { + return prompt.language.slice(0, 2).toUpperCase() + }, [prompt]) + const langTitle = useMemo(() => { + return `${prompt.language}, ${prompt.voice_id}` + }, [prompt]) + + return ( + + + {lang} + + + {promptBody} + + + ) +} + +const EmulatorInterface: React.FC<{ + api: IvrEmulatorApi + config: EmulatorConfig + onStatusChange?: (status: Status) => void +}> = ({api, config, onStatusChange}) => { + const {t} = useTranslation() + const [prompts, setPrompts] = useState<[number, PromptInfo][]>([]) + const emulator = useRef(undefined) + const toDispose = useRef(new WeakSet()) + const inFlight = useRef(new WeakSet()) + const [expectedInput, setExpectedInput] = useState() + const [status, setStatus] = useState("Disconnected") + const [error, setError] = useState("") + const [input, setInput] = useState("") + const nextLogId = useRef(0) + + const addPrompt = (prompt: PromptInfo) => { + let id = nextLogId.current++ + setPrompts((current) => [...current, [id, prompt]]) + } + + const canSendInput = useMemo( + () => status === "ExpectingInput" && Boolean(input.trim()), + [input, status] + ) + + const changeStatus = (value: Status): void => { + setStatus(value) + onStatusChange?.(value) + } + + const startSession = (config: EmulatorConfig): void => { + if (emulator.current) { + console.warn("Refusing to start another emulator session while there's one running") + return + } + + try { + console.log("Creating a new session with config", config) + emulator.current = new api.IvrEmulatorDriver(config) + runEmulator(emulator.current) + } catch (e) { + console.error("Failed to create the emulator", e) + } + } + + const sendTimeout = () => { + const current = emulator.current + if (!current) { + console.warn("Attempted to sendTimeout without an active emulator") + return + } + if (inFlight.current.has(current)) { + return + } + + current.send_timeout() + runEmulator(current) + } + + const sendInput = () => { + const current = emulator.current + if (!current) { + console.warn("Attempted to sendInput without an active emulator") + return + } + if (inFlight.current.has(current)) { + return + } + + current.send_input(input) + runEmulator(current) + setInput("") + } + + const releaseDisposed = (disposed: IvrEmulatorDriver): void => { + if (!toDispose.current.has(disposed) || inFlight.current.has(disposed)) { + return + } + console.log("Releasing disposed emulator") + toDispose.current.delete(disposed) + disposed.free() + } + + // disposeEmulator must not be called again after the emulator has been released. + const disposeEmulator = (disposed: IvrEmulatorDriver): void => { + console.debug("Disposing emulator") + if (emulator.current === disposed) { + emulator.current = undefined + console.debug("Cleared current emulator") + } + toDispose.current.add(disposed) + releaseDisposed(disposed) + } + + const executeEmulatorLoop = async (emulator: IvrEmulatorDriver) => { + while (true) { + changeStatus("Running") + const action = await emulator.execute(true) + + // If disposal was requested while we were executing the wasm, + // such as the component being unmounted, stop working + // and allow releasing the emulator with its normal flow (the finally block). + if (toDispose.current.has(emulator)) { + return + } + switch (action.type) { + case "Prompt": + addPrompt(action.prompt) + break + case "Noop": + break + case "ExpectInput": + addPrompt(action.prompt) + setExpectedInput(action) + changeStatus("ExpectingInput") + return + case "Disconnect": + addPrompt(action.prompt) + changeStatus("Disconnected") + disposeEmulator(emulator) + return + } + } + } + + const runEmulator = (emulator: IvrEmulatorDriver) => { + if (inFlight.current.has(emulator)) { + return + } + + inFlight.current.add(emulator) + executeEmulatorLoop(emulator) + .catch((e) => { + console.error("Failed to execute the emulator", e) + if (!toDispose.current.has(emulator)) { + setError(`${e}`) + disposeEmulator(emulator) + } + }) + .finally(() => { + inFlight.current.delete(emulator) + releaseDisposed(emulator) + }) + } + + useEffect(() => { + setStatus("Ready") + startSession(config) + + return () => { + if (emulator.current) { + disposeEmulator(emulator.current) + } + } + }, []) + + return ( + + {error ? {error} : null} + + + {prompts.map(([id, prompt]) => ( + + ))} + + + {status !== "Disconnected" ? ( + +
{ + e.preventDefault() + canSendInput && sendInput() + }} + > + setInput(e.target.value.replace(/[^0-9*#]/g, ""))} + slotProps={{ + htmlInput: { + pattern: "[0-9*#]*", + maxLength: expectedInput?.max_digits, + }, + }} + disabled={!expectedInput} + autoFocus + sx={{fontFamily: "monospace"}} + placeholder={t("electionEventScreen.ivr.emulator.inputPlaceholder", { + maxDigits: expectedInput?.max_digits ?? "", + validInputs: expectedInput?.valid_inputs ?? "", + timeout: expectedInput?.timeout ?? 0, + })} + /> + +
+ + +
+
+ ) : null} + + {status === "Disconnected" ? ( + + {t("electionEventScreen.ivr.emulator.disconnected")} + + ) : null} +
+ ) +} + +export const IvrEmulator: React.FC = () => { + const {t} = useTranslation() + const record = useRecordContext() + const {status: apiStatus, api} = useIvrEmulator() + const [emulatorStatus, setEmulatorStatus] = useState(undefined) + const [config, setConfig] = useState(undefined) + + const statusAlert = useMemo(() => { + switch (apiStatus) { + case IvrApiStatus.UNAVAILABLE: + return ( + + {t("electionEventScreen.ivr.emulator.apiStatus.unavailable")} + + ) + case IvrApiStatus.LOADING: + return ( + + {t("electionEventScreen.ivr.emulator.apiStatus.loading")} + + ) + case IvrApiStatus.ERROR: + return ( + + {t("electionEventScreen.ivr.emulator.apiStatus.error")} + + ) + case IvrApiStatus.READY: + return null + } + }, [apiStatus]) + + if (!record?.id) { + return null + } + + const onStartSession = (config: EmulatorConfig): void => { + setEmulatorStatus("Ready") + setConfig(config) + } + + return ( + + + {t("electionEventScreen.ivr.emulator.infoMsg")} + + +
+ {statusAlert} + {api ? ( + <> + + + {t("electionEventScreen.ivr.emulator.hints.title")} + + +
  • + {t("electionEventScreen.ivr.emulator.hints.publishRequired")} +
  • +
  • + {t( + "electionEventScreen.ivr.emulator.hints.eventChangesImmediate" + )} +
  • +
  • {t("electionEventScreen.ivr.emulator.hints.credentials")}
  • +
    +
    + {record && !emulatorStatus ? ( + + ) : null} + {api && config && emulatorStatus && record ? ( +
    + + emulatorStatus && setEmulatorStatus(status) + } + /> + + +
    + ) : null} + + ) : null} +
    +
    + ) +} diff --git a/packages/admin-portal/src/services/IvrEmulator.ts b/packages/admin-portal/src/services/IvrEmulator.ts new file mode 100644 index 00000000000..8903d544dfe --- /dev/null +++ b/packages/admin-portal/src/services/IvrEmulator.ts @@ -0,0 +1,106 @@ +// SPDX-FileCopyrightText: 2026 Sequent Tech Inc +// +// SPDX-License-Identifier: AGPL-3.0-only +import type {WasmConfig} from "./generated/ivr_emulator_wasm" +type IvrWasmModule = typeof import("./generated/ivr_emulator_wasm") + +export type IvrEmulatorApi = Pick + +export type { + IvrEmulatorDriver, + Action, + PromptInfo, + EmulatorConfig, +} from "./generated/ivr_emulator_wasm" +let initPromise: Promise | null = null + +export class IvrEmulatorError extends Error { + constructor( + readonly operation: "fetch" | "load" | "init", + readonly msg: string, + options?: ErrorOptions + ) { + super(`Ivr emulator "${operation}" failed: ${msg}`, options) + + this.name = new.target.name + Object.setPrototypeOf(this, new.target.prototype) + } +} + +const resolveUrls = (baseUrl: string) => { + const base = new URL(baseUrl, document.baseURI) + base.search = "" + base.hash = "" + + const jsUrl = new URL(base) + jsUrl.pathname += ".js" + + const wasmUrl = new URL(base) + wasmUrl.pathname += "_bg.wasm" + + return {jsUrl, wasmUrl} +} + +const fetchWasm = async (url: string | URL): Promise => { + let response: Response + try { + response = await fetch(url) + } catch (cause) { + throw new IvrEmulatorError("fetch", `failed to fetch the wasm binary from "${url}"`, { + cause, + }) + } + + if (!response.ok) { + throw new IvrEmulatorError("fetch", `response for "${url}" was "${response.status}"`) + } + + return response +} + +const fetchShim = async (url: URL): Promise => { + try { + return (await import( + /* @vite-ignore */ + /* webpackIgnore: true */ + url.href + )) as IvrWasmModule + } catch (cause) { + throw new IvrEmulatorError("fetch", `import of js shim failed from ${url}`, {cause}) + } +} + +const fetchAndLoad = async (baseUrl: string): Promise => { + const {jsUrl, wasmUrl} = resolveUrls(baseUrl) + const [ivrModule, wasmResponse] = await Promise.all([fetchShim(jsUrl), fetchWasm(wasmUrl)]) + + try { + // Let wasm bindgen init itself + await ivrModule.default({module_or_path: wasmResponse}) + } catch (cause) { + throw new IvrEmulatorError("load", `failed to load the wasm from "${wasmUrl}"`, {cause}) + } + try { + let config: WasmConfig = { + logging: localStorage.getItem("sq.ivr-emulator.logging") ?? undefined, + } + ivrModule.init(config) + } catch (cause) { + throw new IvrEmulatorError("init", `init call failed`, {cause}) + } + return ivrModule +} + +export const loadIvrEmulator = (baseUrl: string): Promise | null => { + if (!initPromise) { + initPromise ??= fetchAndLoad(baseUrl).catch((e: any) => { + // Allow retry + initPromise = null + + console.error("Failed to init the emulator", e) + throw e + }) + } + + return initPromise +} diff --git a/packages/admin-portal/src/services/generated/ivr_emulator_wasm.d.ts b/packages/admin-portal/src/services/generated/ivr_emulator_wasm.d.ts new file mode 100644 index 00000000000..2e7c09805c1 --- /dev/null +++ b/packages/admin-portal/src/services/generated/ivr_emulator_wasm.d.ts @@ -0,0 +1,100 @@ +/* tslint:disable */ +/* eslint-disable */ +export interface EmulatorConfig { + /** + * E164 number (+132...) of the caller + */ + caller_number: string; + contact_id: string; + tenant_id: string; + election_event_id: string; + /** + * Jsonified election event. + */ + election_event: string; + /** + * Jsonified `ballot_eml`s + */ + ballot_styles: string[]; + /** + * `ElectionId`s + */ + open_elections: string[]; + /** + * E164 numbers (+132..) + */ + blacklisted_numbers: string[]; +} + +export interface PromptInfo { + prompt_text: string; + language: string; + voice_id: string; +} + +export interface WasmConfig { + logging: string | undefined; +} + +export type Action = { type: "Prompt"; prompt: PromptInfo } | { type: "ExpectInput"; prompt: PromptInfo; valid_inputs: string; max_digits: number; timeout: number } | { type: "Disconnect"; prompt: PromptInfo } | { type: "Noop" }; + + +export class IvrEmulatorDriver { + free(): void; + [Symbol.dispose](): void; + attributes(): any; + execute(until_io: boolean): Promise; + constructor(config: EmulatorConfig); + send_input(input: string): void; + send_timeout(): void; +} + +export function init(wasm_config: WasmConfig): void; + +export type InitInput = RequestInfo | URL | Response | BufferSource | WebAssembly.Module; + +export interface InitOutput { + readonly memory: WebAssembly.Memory; + readonly __wbg_ivremulatordriver_free: (a: number, b: number) => void; + readonly init: (a: any) => void; + readonly ivremulatordriver_attributes: (a: number) => [number, number, number]; + readonly ivremulatordriver_execute: (a: number, b: number) => any; + readonly ivremulatordriver_new: (a: any) => [number, number, number]; + readonly ivremulatordriver_send_input: (a: number, b: number, c: number) => void; + readonly ivremulatordriver_send_timeout: (a: number) => void; + readonly ring_core_0_17_14__bn_mul_mont: (a: number, b: number, c: number, d: number, e: number, f: number) => void; + readonly wasm_bindgen__closure__destroy__h7baa5a84311a3b04: (a: number, b: number) => void; + readonly wasm_bindgen__convert__closures_____invoke__h5fed3dcc0d3dcd7b: (a: number, b: number, c: any) => [number, number]; + readonly wasm_bindgen__convert__closures_____invoke__h331bfc0ec3639989: (a: number, b: number, c: any, d: any) => void; + readonly __wbindgen_malloc: (a: number, b: number) => number; + readonly __wbindgen_realloc: (a: number, b: number, c: number, d: number) => number; + readonly __wbindgen_exn_store: (a: number) => void; + readonly __externref_table_alloc: () => number; + readonly __wbindgen_externrefs: WebAssembly.Table; + readonly __externref_drop_slice: (a: number, b: number) => void; + readonly __wbindgen_free: (a: number, b: number, c: number) => void; + readonly __externref_table_dealloc: (a: number) => void; + readonly __wbindgen_start: () => void; +} + +export type SyncInitInput = BufferSource | WebAssembly.Module; + +/** + * Instantiates the given `module`, which can either be bytes or + * a precompiled `WebAssembly.Module`. + * + * @param {{ module: SyncInitInput }} module - Passing `SyncInitInput` directly is deprecated. + * + * @returns {InitOutput} + */ +export function initSync(module: { module: SyncInitInput } | SyncInitInput): InitOutput; + +/** + * If `module_or_path` is {RequestInfo} or {URL}, makes a request and + * for everything else, calls `WebAssembly.instantiate` directly. + * + * @param {{ module_or_path: InitInput | Promise }} module_or_path - Passing `InitInput` directly is deprecated. + * + * @returns {Promise} + */ +export default function __wbg_init (module_or_path: { module_or_path: InitInput | Promise } | InitInput | Promise): Promise; diff --git a/packages/admin-portal/src/translations/cat.ts b/packages/admin-portal/src/translations/cat.ts index 01d80331015..921a78686ac 100644 --- a/packages/admin-portal/src/translations/cat.ts +++ b/packages/admin-portal/src/translations/cat.ts @@ -570,6 +570,36 @@ const catalanTranslation: TranslationType = { noFilterMatch: "Cap entrada no coincideix amb els filtres indicats", phoneRequired: "El número de telèfon és obligatori", }, + emulator: { + infoMsg: + "Seleccioneu una àrea i les eleccions desitjades per provar la sessió IVR.", + apiStatus: { + unavailable: "El sistema de l'emulador no està disponible al vostre entorn", + loading: "S'està carregant el sistema de l'emulador", + error: "S'ha produït un error en carregar el sistema de l'emulador", + }, + hints: { + title: "Consells", + publishRequired: + "Qualsevol canvi fet a les eleccions, les conteses o els candidats s'ha de publicar primer perquè estigui disponible. A l'emulador només s'utilitzaran els estils de papereta publicats més recentment per a l'àrea corresponent.", + eventChangesImmediate: + "Els canvis fets a l'esdeveniment electoral, com ara la configuració IVR o les substitucions dels missatges, estan disponibles immediatament en reiniciar la sessió de l'emulador.", + credentials: + 'L\'identificador de votant i el PIN vàlids són "123" i "123".', + }, + sendDtmf: "Envia una entrada DTMF", + sendTimeout: "Envia el temps d'espera", + disconnected: "Desconnectat", + startSession: "Inicia una sessió nova", + endSession: "Finalitza la sessió", + noStylesFound: + "No s'ha trobat cap estil de papereta publicat que coincideixi amb les vostres seleccions", + inputPlaceholder: + "Introduïu la vostra entrada (màxim de dígits={{maxDigits}}, entrades vàlides={{validInputs}}, temps d'espera={{timeout}} s)", + blacklistCaller: "Bloqueja la persona que truca", + elections: "Eleccions", + area: "Àrea", + }, }, stats: { elegibleVoters: "Electors", diff --git a/packages/admin-portal/src/translations/en.ts b/packages/admin-portal/src/translations/en.ts index db0ad6a031f..b3df8b05f54 100644 --- a/packages/admin-portal/src/translations/en.ts +++ b/packages/admin-portal/src/translations/en.ts @@ -568,6 +568,33 @@ const englishTranslation = { noFilterMatch: "No entries match the given filters", phoneRequired: "Phone number is required", }, + emulator: { + infoMsg: "Select an area and desired elections to experience the IVR session.", + apiStatus: { + unavailable: "The emulator system is not available in your environment", + loading: "Loading the emulator system", + error: "Error loading the emulator system", + }, + hints: { + title: "Hints", + publishRequired: + "Any changes made to elections, contests or candidates must be published first to be available. Only the latest published ballot styles for the matching area will be used in the emulator.", + eventChangesImmediate: + "Changes made to the election event, such as IVR configuration or prompt overrides, are available immediately upon emulator session restart.", + credentials: 'The valid voter id and pin are "123" and "123".', + }, + sendDtmf: "Send DTMF input", + sendTimeout: "Send timeout", + disconnected: "Disconnected", + startSession: "Start new session", + endSession: "End the session", + noStylesFound: "No published ballot styles found matching your selections", + inputPlaceholder: + "Enter your input (max digits={{maxDigits}}, valid inputs={{validInputs}}, timeout={{timeout}}s)", + blacklistCaller: "Blocklist the caller", + elections: "Elections", + area: "Area", + }, }, stats: { elegibleVoters: "Eligible Voters", diff --git a/packages/admin-portal/src/translations/es.ts b/packages/admin-portal/src/translations/es.ts index 371e9ccc46b..770a871441a 100644 --- a/packages/admin-portal/src/translations/es.ts +++ b/packages/admin-portal/src/translations/es.ts @@ -571,6 +571,36 @@ const spanishTranslation: TranslationType = { noFilterMatch: "Ninguna entrada coincide con los filtros indicados", phoneRequired: "El número de teléfono es obligatorio", }, + emulator: { + infoMsg: + "Seleccione un área y las elecciones deseadas para probar la sesión de IVR.", + apiStatus: { + unavailable: "El sistema del emulador no está disponible en su entorno", + loading: "Cargando el sistema del emulador", + error: "Error al cargar el sistema del emulador", + }, + hints: { + title: "Sugerencias", + publishRequired: + "Cualquier cambio realizado en las elecciones, contiendas o candidatos debe publicarse primero para que esté disponible. En el emulador solo se utilizarán los estilos de boleta publicados más recientemente para el área correspondiente.", + eventChangesImmediate: + "Los cambios realizados en el evento electoral, como la configuración de IVR o las modificaciones de los mensajes, están disponibles inmediatamente al reiniciar la sesión del emulador.", + credentials: + 'El identificador de votante y el PIN válidos son "123" y "123".', + }, + sendDtmf: "Enviar entrada DTMF", + sendTimeout: "Enviar tiempo de espera", + disconnected: "Desconectado", + startSession: "Iniciar nueva sesión", + endSession: "Finalizar la sesión", + noStylesFound: + "No se encontraron estilos de boleta publicados que coincidan con sus selecciones", + inputPlaceholder: + "Introduzca su entrada (máximo de dígitos={{maxDigits}}, entradas válidas={{validInputs}}, tiempo de espera={{timeout}} s)", + blacklistCaller: "Bloquear a la persona que llama", + elections: "Elecciones", + area: "Área", + }, }, stats: { elegibleVoters: "Electores", diff --git a/packages/admin-portal/src/translations/eu.ts b/packages/admin-portal/src/translations/eu.ts index 1a74ab3f3bd..826158858f2 100644 --- a/packages/admin-portal/src/translations/eu.ts +++ b/packages/admin-portal/src/translations/eu.ts @@ -570,6 +570,35 @@ const basqueTranslation: TranslationType = { noFilterMatch: "Ez dago emandako iragazkiekin bat datorren sarrerarik", phoneRequired: "Telefono-zenbakia nahitaezkoa da", }, + emulator: { + infoMsg: + "Hautatu eremu bat eta nahi dituzun hauteskundeak IVR saioa probatzeko.", + apiStatus: { + unavailable: "Emuladore-sistema ez dago erabilgarri zure ingurunean", + loading: "Emuladore-sistema kargatzen", + error: "Errorea emuladore-sistema kargatzean", + }, + hints: { + title: "Aholkuak", + publishRequired: + "Hauteskundeetan, lehiaketetan edo hautagaietan egindako edozein aldaketa lehenik argitaratu behar da erabilgarri egon dadin. Dagokion eremurako azkenik argitaratutako boto-paper estiloak soilik erabiliko dira emuladorean.", + eventChangesImmediate: + "Hauteskunde-ekitaldian egindako aldaketak, hala nola IVR konfigurazioa edo mezuen gainidazketak, berehala egongo dira erabilgarri emuladorearen saioa berrabiaraztean.", + credentials: 'Baliozko hautesle-IDa eta PINa "123" eta "123" dira.', + }, + sendDtmf: "Bidali DTMF sarrera", + sendTimeout: "Bidali denbora-muga", + disconnected: "Deskonektatuta", + startSession: "Hasi saio berria", + endSession: "Amaitu saioa", + noStylesFound: + "Ez da aurkitu zure hautapenekin bat datorren argitaratutako boto-paper estilorik", + inputPlaceholder: + "Idatzi sarrera (gehienezko digitu kopurua={{maxDigits}}, baliozko sarrerak={{validInputs}}, denbora-muga={{timeout}} s)", + blacklistCaller: "Blokeatu deitzailea", + elections: "Hauteskundeak", + area: "Eremua", + }, }, stats: { elegibleVoters: "Bozkatzaile Eskudunak", diff --git a/packages/admin-portal/src/translations/fr.ts b/packages/admin-portal/src/translations/fr.ts index 60fbee35415..9c5c83fdc95 100644 --- a/packages/admin-portal/src/translations/fr.ts +++ b/packages/admin-portal/src/translations/fr.ts @@ -570,6 +570,36 @@ const frenchTranslation: TranslationType = { noFilterMatch: "Aucune entrée ne correspond aux filtres indiqués", phoneRequired: "Le numéro de téléphone est obligatoire", }, + emulator: { + infoMsg: + "Sélectionnez une zone et les élections souhaitées pour tester la session IVR.", + apiStatus: { + unavailable: + "Le système d'émulation n'est pas disponible dans votre environnement", + loading: "Chargement du système d'émulation", + error: "Erreur lors du chargement du système d'émulation", + }, + hints: { + title: "Conseils", + publishRequired: + "Toute modification apportée aux élections, aux scrutins ou aux candidats doit d'abord être publiée pour être disponible. Seuls les styles de bulletin publiés les plus récents correspondant à la zone seront utilisés dans l'émulateur.", + eventChangesImmediate: + "Les modifications apportées à l'événement électoral, telles que la configuration IVR ou la personnalisation des messages, sont disponibles immédiatement après le redémarrage de la session de l'émulateur.", + credentials: + 'L\'identifiant d\'électeur et le code PIN valides sont "123" et "123".', + }, + sendDtmf: "Envoyer une entrée DTMF", + sendTimeout: "Envoyer l'expiration du délai", + disconnected: "Déconnecté", + startSession: "Démarrer une nouvelle session", + endSession: "Terminer la session", + noStylesFound: "Aucun style de bulletin publié ne correspond à vos sélections", + inputPlaceholder: + "Saisissez votre entrée (nombre maximal de chiffres={{maxDigits}}, entrées valides={{validInputs}}, délai d'expiration={{timeout}} s)", + blacklistCaller: "Bloquer l'appelant", + elections: "Élections", + area: "Zone", + }, }, stats: { elegibleVoters: "Électeurs", diff --git a/packages/admin-portal/src/translations/gl.ts b/packages/admin-portal/src/translations/gl.ts index f1c4dbfc565..5c999f5d304 100644 --- a/packages/admin-portal/src/translations/gl.ts +++ b/packages/admin-portal/src/translations/gl.ts @@ -570,6 +570,36 @@ const galegoTranslation: TranslationType = { noFilterMatch: "Ningunha entrada coincide cos filtros indicados", phoneRequired: "O número de teléfono é obrigatorio", }, + emulator: { + infoMsg: + "Seleccione unha área e as eleccións desexadas para probar a sesión IVR.", + apiStatus: { + unavailable: "O sistema do emulador non está dispoñible na súa contorna", + loading: "Cargando o sistema do emulador", + error: "Erro ao cargar o sistema do emulador", + }, + hints: { + title: "Suxestións", + publishRequired: + "Calquera cambio realizado nas eleccións, nas contendas ou nos candidatos debe publicarse primeiro para que estea dispoñible. No emulador só se utilizarán os estilos de papeleta publicados máis recentemente para a área correspondente.", + eventChangesImmediate: + "Os cambios realizados no evento electoral, como a configuración IVR ou as modificacións das mensaxes, están dispoñibles inmediatamente ao reiniciar a sesión do emulador.", + credentials: + 'O identificador de votante e o PIN válidos son "123" e "123".', + }, + sendDtmf: "Enviar entrada DTMF", + sendTimeout: "Enviar tempo de espera", + disconnected: "Desconectado", + startSession: "Iniciar unha nova sesión", + endSession: "Finalizar a sesión", + noStylesFound: + "Non se atoparon estilos de papeleta publicados que coincidan coas súas seleccións", + inputPlaceholder: + "Introduza a súa entrada (máximo de díxitos={{maxDigits}}, entradas válidas={{validInputs}}, tempo de espera={{timeout}} s)", + blacklistCaller: "Bloquear a persoa que chama", + elections: "Eleccións", + area: "Área", + }, }, stats: { elegibleVoters: "Votantes Elixibles", diff --git a/packages/admin-portal/src/translations/nl.ts b/packages/admin-portal/src/translations/nl.ts index 81a48dfee79..c00a3689769 100644 --- a/packages/admin-portal/src/translations/nl.ts +++ b/packages/admin-portal/src/translations/nl.ts @@ -568,6 +568,35 @@ const dutchTranslation: TranslationType = { noFilterMatch: "Geen vermeldingen komen overeen met de opgegeven filters", phoneRequired: "Telefoonnummer is verplicht", }, + emulator: { + infoMsg: + "Selecteer een gebied en de gewenste verkiezingen om de IVR-sessie te doorlopen.", + apiStatus: { + unavailable: "Het emulatorsysteem is niet beschikbaar in uw omgeving", + loading: "Het emulatorsysteem wordt geladen", + error: "Fout bij het laden van het emulatorsysteem", + }, + hints: { + title: "Tips", + publishRequired: + "Wijzigingen aan verkiezingen, verkiezingsonderdelen of kandidaten moeten eerst worden gepubliceerd voordat ze beschikbaar zijn. Alleen de meest recent gepubliceerde stembiljetstijlen voor het overeenkomstige gebied worden in de emulator gebruikt.", + eventChangesImmediate: + "Wijzigingen aan het verkiezingsevenement, zoals de IVR-configuratie of aangepaste prompts, zijn direct beschikbaar nadat de emulatorsessie opnieuw is gestart.", + credentials: 'De geldige kiezer-ID en pincode zijn "123" en "123".', + }, + sendDtmf: "DTMF-invoer verzenden", + sendTimeout: "Time-out verzenden", + disconnected: "Verbinding verbroken", + startSession: "Nieuwe sessie starten", + endSession: "Sessie beëindigen", + noStylesFound: + "Geen gepubliceerde stembiljetstijlen gevonden die overeenkomen met uw selecties", + inputPlaceholder: + "Voer uw invoer in (maximaal aantal cijfers={{maxDigits}}, geldige invoer={{validInputs}}, time-out={{timeout}} sec.)", + blacklistCaller: "Beller blokkeren", + elections: "Verkiezingen", + area: "Gebied", + }, }, stats: { elegibleVoters: "Stemgerechtigde Kiezers", diff --git a/packages/admin-portal/src/translations/tl.ts b/packages/admin-portal/src/translations/tl.ts index aafac90b22d..aa9c57ab005 100644 --- a/packages/admin-portal/src/translations/tl.ts +++ b/packages/admin-portal/src/translations/tl.ts @@ -569,6 +569,35 @@ const tagalogTranslation: TranslationType = { noFilterMatch: "Walang entry na tumutugma sa ibinigay na mga filter", phoneRequired: "Kinakailangan ang numero ng telepono", }, + emulator: { + infoMsg: + "Pumili ng lugar at ng mga nais na halalan upang subukan ang IVR session.", + apiStatus: { + unavailable: "Hindi available ang emulator system sa iyong environment", + loading: "Nilo-load ang emulator system", + error: "Nagkaroon ng error sa pag-load ng emulator system", + }, + hints: { + title: "Mga pahiwatig", + publishRequired: + "Ang anumang pagbabagong ginawa sa mga halalan, mga contest, o mga kandidato ay dapat munang i-publish para maging available. Tanging ang pinakabagong na-publish na mga ballot style para sa tumutugmang lugar ang gagamitin sa emulator.", + eventChangesImmediate: + "Ang mga pagbabagong ginawa sa election event, gaya ng IVR configuration o mga pagbabago sa prompt, ay available kaagad kapag ni-restart ang emulator session.", + credentials: 'Ang valid na voter ID at PIN ay "123" at "123".', + }, + sendDtmf: "Magpadala ng DTMF input", + sendTimeout: "Magpadala ng timeout", + disconnected: "Nadiskonekta", + startSession: "Magsimula ng bagong session", + endSession: "Tapusin ang session", + noStylesFound: + "Walang nakitang na-publish na mga ballot style na tumutugma sa iyong mga pinili", + inputPlaceholder: + "Ilagay ang iyong input (max na digit={{maxDigits}}, mga valid na input={{validInputs}}, timeout={{timeout}} s)", + blacklistCaller: "I-block ang tumatawag", + elections: "Mga halalan", + area: "Lugar", + }, }, stats: { elegibleVoters: "Mga Kwalipikadong Botante", diff --git a/packages/admin-portal/webpack.config.cjs b/packages/admin-portal/webpack.config.cjs index 9149c927776..812ef7918b2 100644 --- a/packages/admin-portal/webpack.config.cjs +++ b/packages/admin-portal/webpack.config.cjs @@ -123,9 +123,16 @@ module.exports = function (env, argv) { }), ], devServer: { - static: { - directory: path.resolve(__dirname, "dist"), - }, + static: [ + {directory: path.resolve(__dirname, "dist")}, + { + directory: path.resolve( + __dirname, + "../../beyond/packages/ivr-emulator-wasm/web/wasm" + ), + publicPath: "/wasm", + }, + ], compress: true, // Enable gzip compression port: 3002, // Run on port 3002 open: true, // Automatically open the browser