Port more changes from updated branch

This commit is contained in:
Darren Clarke 2026-02-12 12:01:56 +01:00
parent 1c7755f455
commit bf46bb5beb
10 changed files with 721 additions and 13 deletions

View file

@ -42,6 +42,10 @@ RUN pnpm install --frozen-lockfile
# This extracts addon files including CoffeeScript, Vue components, TypeScript, and CSS
RUN ruby contrib/link/install.rb
# Rebuild Vite frontend to include addon Vue components
# The base image has pre-built Vite assets, but addon Vue files need to be compiled
RUN RAILS_ENV=production bundle exec vite build --clobber
# Fix OpenSearch compatibility: Replace 'flattened' with 'flat_object'
# Elasticsearch uses 'flattened' but OpenSearch uses 'flat_object'
# Without this fix, search index creation fails with:

View file

@ -1,19 +1,47 @@
# frozen_string_literal: true
# OpenSearchController provides access to OpenSearch Dashboards URL and status.
# This allows the frontend to link directly to OpenSearch Dashboards for analytics.
# Copyright (C) 2025 Center for Digital Resilience
# License: AGPL-3.0-or-later
class OpensearchController < ApplicationController
prepend_before_action -> { authentication_check && authorize! }
# GET /api/v1/opensearch
# GET /opensearch
# Render the OpenSearch Dashboards iframe page
def index
enabled = Setting.get('opensearch_dashboards_enabled') == true
url = Setting.get('opensearch_dashboards_url')
# Get the OpenSearch Dashboards URL from environment or use default
@opensearch_url = ENV.fetch('OPENSEARCH_DASHBOARDS_URL', 'http://opensearch-dashboards:5601')
@default_dashboard = ENV.fetch('OPENSEARCH_DEFAULT_DASHBOARD_URL', '/app/dashboards')
render json: {
enabled: enabled,
url: enabled ? url : nil
}
render 'opensearch/index', layout: false
end
# GET /opensearch/dashboards/*path
# Proxy requests to OpenSearch Dashboards
# This allows embedding OpenSearch without CORS issues
def proxy
path = params[:path] || ''
opensearch_url = ENV.fetch('OPENSEARCH_DASHBOARDS_URL', 'http://opensearch-dashboards:5601')
target_url = "#{opensearch_url}/#{path}"
# Add query parameters if present
if request.query_string.present?
target_url = "#{target_url}?#{request.query_string}"
end
# Forward the request to OpenSearch Dashboards
response = HTTParty.get(
target_url,
headers: {
'x-forwarded-user' => current_user.email,
'x-forwarded-roles' => current_user.role_ids.map { |id| Role.find(id).name }.join(','),
}
)
# Return the response
render plain: response.body, status: response.code, content_type: response.headers['content-type']
rescue StandardError => e
Rails.logger.error "OpenSearch proxy error: #{e.message}"
render plain: 'Error connecting to OpenSearch Dashboards', status: :service_unavailable
end
end

View file

@ -0,0 +1,204 @@
<!-- Copyright (C) 2012-2025 Zammad Foundation, https://zammad-foundation.org/ -->
<!-- CDR Link Extension: Adds Signal notification channel support -->
<script setup lang="ts">
import { cloneDeep } from 'lodash-es'
import { storeToRefs } from 'pinia'
import { toRef, computed } from 'vue'
import useValue from '#shared/components/Form/composables/useValue.ts'
import type { FormFieldContext } from '#shared/components/Form/types/field.ts'
import { useApplicationStore } from '#shared/stores/application.ts'
import CommonSimpleTable from '#desktop/components/CommonTable/CommonSimpleTable.vue'
import type { TableSimpleHeader } from '#desktop/components/CommonTable/types.ts'
import {
NotificationMatrixColumnKey,
NotificationMatrixPathKey,
NotificationMatrixRowKey,
} from './types.ts'
const props = defineProps<{
context: FormFieldContext
}>()
const context = toRef(props, 'context')
const { localValue } = useValue(context)
// Get application config to check if Signal notifications are enabled
const { config } = storeToRefs(useApplicationStore())
const signalNotificationEnabled = computed(() =>
config.value?.signal_notification_enabled === true
)
// Base headers without Signal
const baseTableHeaders: TableSimpleHeader[] = [
{
key: 'name',
label: __('Name'),
},
{
key: NotificationMatrixColumnKey.MyTickets,
path: NotificationMatrixPathKey.Criteria,
label: __('My tickets'),
alignContent: 'center',
headerClass: 'w-20',
},
{
key: NotificationMatrixColumnKey.NotAssigned,
path: NotificationMatrixPathKey.Criteria,
label: __('Not assigned'),
alignContent: 'center',
headerClass: 'w-20',
},
{
key: NotificationMatrixColumnKey.SubscribedTickets,
path: NotificationMatrixPathKey.Criteria,
label: __('Subscribed tickets'),
alignContent: 'center',
headerClass: 'w-20',
},
{
key: NotificationMatrixColumnKey.AllTickets,
path: NotificationMatrixPathKey.Criteria,
label: __('All tickets'),
alignContent: 'center',
headerClass: 'w-20',
columnSeparator: true,
},
{
key: NotificationMatrixColumnKey.AlsoNotifyViaEmail,
path: NotificationMatrixPathKey.Channel,
label: __('Also notify via email'),
alignContent: 'center',
headerClass: 'w-20',
},
]
// Signal header to be conditionally added
const signalHeader: TableSimpleHeader = {
key: 'signal' as NotificationMatrixColumnKey,
path: NotificationMatrixPathKey.Channel,
label: __('Also notify via Signal'),
alignContent: 'center',
headerClass: 'w-20',
}
// Computed headers that include Signal when enabled
const tableHeaders = computed(() => {
if (signalNotificationEnabled.value) {
return [...baseTableHeaders, signalHeader]
}
return baseTableHeaders
})
// All column keys including Signal for the template
const allColumnKeys = computed(() => {
const keys = Object.values(NotificationMatrixColumnKey)
if (signalNotificationEnabled.value) {
keys.push('signal' as NotificationMatrixColumnKey)
}
return keys
})
const tableItems = [
{
id: 1,
key: NotificationMatrixRowKey.Create,
name: __('New ticket'),
},
{
id: 2,
key: NotificationMatrixRowKey.Update,
name: __('Ticket update'),
},
{
id: 3,
key: NotificationMatrixRowKey.ReminderReached,
name: __('Ticket reminder reached'),
},
{
id: 4,
key: NotificationMatrixRowKey.Escalation,
name: __('Ticket escalation'),
},
]
const valueLookup = (
rowKey: NotificationMatrixRowKey,
pathKey: NotificationMatrixPathKey,
columnKey: NotificationMatrixColumnKey | string,
) => {
const row = localValue.value?.[rowKey]
if (!row) return undefined
return row[pathKey]?.[columnKey]
}
const updateValue = (
rowKey: NotificationMatrixRowKey,
pathKey: NotificationMatrixPathKey,
columnKey: NotificationMatrixColumnKey | string,
state: boolean | undefined,
) => {
const values = cloneDeep(localValue.value) || {}
values[rowKey] = values[rowKey] || {}
values[rowKey][pathKey] = values[rowKey][pathKey] || {}
values[rowKey][pathKey][columnKey] = Boolean(state)
localValue.value = values
}
</script>
<template>
<output
:id="context.id"
:class="context.classes.input"
:name="context.node.name"
:aria-disabled="context.disabled"
:aria-describedby="context.describedBy"
v-bind="context.attrs"
>
<CommonSimpleTable
:caption="__('Notifications matrix')"
class="mb-4 w-full"
:headers="tableHeaders"
:items="tableItems"
>
<template
v-for="key in allColumnKeys"
:key="key"
#[`column-cell-${key}`]="{ item, header }"
>
<FormKit
:id="`notifications_${item.key}_${header.path}_${header.key}`"
:model-value="
valueLookup(
item.key as NotificationMatrixRowKey,
header.path as NotificationMatrixPathKey,
header.key as NotificationMatrixColumnKey,
)
"
type="checkbox"
:name="`notifications_${item.key}_${header.path}_${header.key}`"
:disabled="context.disabled"
:ignore="true"
:label-sr-only="true"
:label="`${i18n.t(item.name as string)} - ${i18n.t(header.label)}`"
@update:model-value="
updateValue(
item.key as NotificationMatrixRowKey,
header.path as NotificationMatrixPathKey,
header.key as NotificationMatrixColumnKey,
$event,
)
"
@blur="context.handlers.blur"
/>
</template>
</CommonSimpleTable>
</output>
</template>

View file

@ -0,0 +1,32 @@
// Copyright (C) 2012-2025 Zammad Foundation, https://zammad-foundation.org/
// CDR Link Extension: Adds Signal notification channel support
export enum NotificationMatrixRowKey {
Create = 'create',
Escalation = 'escalation',
ReminderReached = 'reminderReached',
Update = 'update',
}
export enum NotificationMatrixPathKey {
Criteria = 'criteria',
Channel = 'channel',
}
export enum NotificationMatrixColumnKey {
MyTickets = 'ownedByMe',
NotAssigned = 'ownedByNobody',
SubscribedTickets = 'subscribed',
AllTickets = 'no',
AlsoNotifyViaEmail = 'email',
// CDR Link: Signal notification channel
AlsoNotifyViaSignal = 'signal',
}
export type NotificationMatrix = {
[rowKey in NotificationMatrixRowKey]: {
[pathKey in NotificationMatrixPathKey]: {
[columnKey in NotificationMatrixColumnKey]: boolean
}
}
}

View file

@ -0,0 +1,321 @@
<!-- Copyright (C) 2012-2025 Zammad Foundation, https://zammad-foundation.org/ -->
<!-- CDR Link Extension: Adds Signal notification phone number field -->
<script setup lang="ts">
import { isEqual } from 'lodash-es'
import { storeToRefs } from 'pinia'
import { computed, ref, watch } from 'vue'
import {
NotificationTypes,
useNotifications,
} from '#shared/components/CommonNotifications/index.ts'
import Form from '#shared/components/Form/Form.vue'
import { type FormSubmitData } from '#shared/components/Form/types.ts'
import { useForm } from '#shared/components/Form/useForm.ts'
import { useConfirmation } from '#shared/composables/useConfirmation.ts'
import { defineFormSchema } from '#shared/form/defineFormSchema.ts'
import {
EnumFormUpdaterId,
EnumNotificationSoundFile,
type UserNotificationMatrixInput,
} from '#shared/graphql/types.ts'
import { convertToGraphQLId } from '#shared/graphql/utils.ts'
import { MutationHandler } from '#shared/server/apollo/handler/index.ts'
import { useSessionStore } from '#shared/stores/session.ts'
import { useApplicationStore } from '#shared/stores/application.ts'
import type { UserData } from '#shared/types/store.ts'
import CommonButton from '#desktop/components/CommonButton/CommonButton.vue'
import LayoutContent from '#desktop/components/layout/LayoutContent.vue'
import { useBreadcrumb } from '#desktop/pages/personal-setting/composables/useBreadcrumb.ts'
import { useUserCurrentNotificationPreferencesResetMutation } from '#desktop/pages/personal-setting/graphql/mutations/userCurrentNotificationPreferencesReset.api.ts'
import { useUserCurrentNotificationPreferencesUpdateMutation } from '#desktop/pages/personal-setting/graphql/mutations/userCurrentNotificationPreferencesUpdate.api.ts'
import type { NotificationFormData } from '#desktop/pages/personal-setting/types/notifications.ts'
// CDR Link: Extended form data type to include signal_uid
interface ExtendedNotificationFormData extends NotificationFormData {
signal_uid?: string
}
const { breadcrumbItems } = useBreadcrumb(__('Notifications'))
const { user } = storeToRefs(useSessionStore())
// CDR Link: Get application config for signal notification setting
const { config } = storeToRefs(useApplicationStore())
const signalNotificationEnabled = computed(() =>
config.value?.signal_notification_enabled === true
)
const { notify } = useNotifications()
const { waitForConfirmation } = useConfirmation()
const loading = ref(false)
const { form, onChangedField, formReset, values, isDirty } = useForm()
const soundOptions = Object.keys(EnumNotificationSoundFile).map((sound) => ({
label: sound,
value: sound,
}))
// CDR Link: Check if any Signal notification checkbox is selected in the matrix
const hasSignalNotificationSelected = computed(() => {
const matrix = values.value?.matrix
if (!matrix) return false
// Check all rows (create, update, reminderReached, escalation) for signal channel enabled
const rows = ['create', 'update', 'reminderReached', 'escalation']
return rows.some((row) => matrix[row]?.channel?.signal === true)
})
// CDR Link: Dynamically build schema based on signal notification setting
const schema = computed(() => {
const baseSchema = [
{
type: 'notifications',
name: 'matrix',
label: __('Notification matrix'),
labelSrOnly: true,
},
{
type: 'select',
name: 'group_ids',
label: __('Limit notifications to specific groups'),
help: __('Affects only notifications for not assigned and all tickets.'),
props: {
clearable: true,
multiple: true,
noOptionsLabelTranslation: true,
},
},
]
// CDR Link: Add Signal phone number field only when Signal notifications are enabled
// AND at least one Signal notification checkbox is selected
if (signalNotificationEnabled.value && hasSignalNotificationSelected.value) {
baseSchema.push({
type: 'text',
name: 'signal_uid',
label: __('Signal Phone Number'),
help: __('Use international format with country code (e.g., +1234567890). Required when Signal notifications are enabled in the matrix.'),
props: {
placeholder: '+1234567890',
},
} as any)
}
// Sound settings
baseSchema.push(
{
type: 'select',
name: 'file',
label: __('Notification sound'),
props: {
options: soundOptions,
},
} as any,
{
type: 'toggle',
name: 'enabled',
label: __('Play user interface sound effects'),
props: {
variants: { true: 'True', false: 'False' },
},
} as any,
)
return defineFormSchema(baseSchema)
})
const initialFormValues = computed<ExtendedNotificationFormData>((oldValues) => {
const { notificationConfig = {}, notificationSound = {} } =
user.value?.personalSettings || {}
const values: ExtendedNotificationFormData = {
group_ids: notificationConfig?.groupIds ?? [],
matrix: notificationConfig?.matrix || {},
// CDR Link: Include signal_uid from notification config
signal_uid: notificationConfig?.signalUid ?? '',
// Default notification sound settings are not present on the user preferences.
file: notificationSound?.file ?? EnumNotificationSoundFile.Xylo,
enabled: notificationSound?.enabled ?? true,
}
if (oldValues && isEqual(values, oldValues)) return oldValues
return values
})
watch(initialFormValues, (newValues) => {
// No reset needed when the form has already the correct state.
if (isEqual(values.value, newValues) && !isDirty.value) return
formReset({ values: newValues })
})
onChangedField('file', (fileName) => {
new Audio(`/assets/sounds/${fileName?.toString()}.mp3`)?.play()
})
// CDR Link: Save signal_uid via REST API
const saveSignalUid = async (signalUid: string | undefined) => {
if (!signalNotificationEnabled.value) return
try {
const response = await fetch('/api/v1/users/preferences', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
notification_config: {
signal_uid: signalUid || '',
},
}),
})
if (!response.ok) {
throw new Error('Failed to save Signal phone number')
}
} catch (error) {
console.error('Error saving signal_uid:', error)
throw error
}
}
const onSubmit = async (form: FormSubmitData<ExtendedNotificationFormData>) => {
loading.value = true
const notificationUpdateMutation = new MutationHandler(
useUserCurrentNotificationPreferencesUpdateMutation(),
{
errorNotificationMessage: __('Notification settings could not be saved.'),
},
)
try {
// CDR Link: Save signal_uid separately via REST API
if (signalNotificationEnabled.value) {
await saveSignalUid(form.signal_uid)
}
// Save other settings via GraphQL mutation
const response = await notificationUpdateMutation.send({
matrix: form.matrix as UserNotificationMatrixInput,
groupIds:
form?.group_ids?.map((id) => convertToGraphQLId('Group', id)) || [],
sound: {
file: form.file as EnumNotificationSoundFile,
enabled: form.enabled,
},
})
if (response?.userCurrentNotificationPreferencesUpdate) {
notify({
id: 'notification-update-success',
type: NotificationTypes.Success,
message: __('Notification settings have been saved successfully.'),
})
}
} finally {
loading.value = false
}
}
const resetFormToDefaults = (
personalSettings: UserData['personalSettings'],
) => {
const resetValues: any = {
matrix: personalSettings?.notificationConfig?.matrix || {},
}
// CDR Link: Reset signal_uid as well
if (signalNotificationEnabled.value) {
resetValues.signal_uid = ''
}
form.value?.resetForm({
values: resetValues,
})
}
const onResetToDefaultSettings = async () => {
const confirmed = await waitForConfirmation(
__('Are you sure? Your notifications settings will be reset to default.'),
)
if (!confirmed) return
loading.value = true
const notificationResetMutation = new MutationHandler(
useUserCurrentNotificationPreferencesResetMutation(),
{
errorNotificationMessage: __('Notification settings could not be reset.'),
},
)
try {
// CDR Link: Reset signal_uid via REST API
if (signalNotificationEnabled.value) {
await saveSignalUid('')
}
const response = await notificationResetMutation.send()
const personalSettings =
response?.userCurrentNotificationPreferencesReset?.user
?.personalSettings
if (!personalSettings) return
resetFormToDefaults(personalSettings)
notify({
id: 'notification-reset-success',
type: NotificationTypes.Success,
message: __('Notification settings have been reset to default.'),
})
} finally {
loading.value = false
}
}
</script>
<template>
<LayoutContent :breadcrumb-items="breadcrumbItems" width="narrow">
<div class="mb-4">
<Form
id="notifications-form"
ref="form"
:schema="schema"
:form-updater-id="EnumFormUpdaterId.FormUpdaterUpdaterUserNotifications"
form-updater-initial-only
:initial-values="initialFormValues"
@submit="onSubmit($event as FormSubmitData<ExtendedNotificationFormData>)"
>
<template #after-fields>
<div class="flex justify-end gap-2">
<CommonButton
size="medium"
variant="danger"
:disabled="loading"
@click="onResetToDefaultSettings"
>
{{ $t('Reset to Default Settings') }}
</CommonButton>
<CommonButton
size="medium"
type="submit"
variant="submit"
:disabled="loading"
>
{{ $t('Save Notifications') }}
</CommonButton>
</div>
</template>
</Form>
</div>
</LayoutContent>
</template>

View file

@ -0,0 +1,26 @@
<!DOCTYPE html>
<html>
<head>
<title>OpenSearch Dashboards - Zammad</title>
<style>
body, html {
margin: 0;
padding: 0;
height: 100%;
overflow: hidden;
}
#opensearch-iframe {
width: 100%;
height: 100%;
border: none;
}
</style>
</head>
<body>
<iframe
id="opensearch-iframe"
src="<%= @opensearch_url %><%= @default_dashboard %>"
allow="fullscreen"
></iframe>
</body>
</html>

View file

@ -0,0 +1,34 @@
# frozen_string_literal: true
# OpenSearch Compatibility Fix
#
# This initializer patches SearchIndexBackend to use 'flat_object' instead of 'flattened'
# for field type mapping. OpenSearch uses 'flat_object' while Elasticsearch uses 'flattened'.
#
# Without this fix, search index creation fails with:
# [o.o.c.m.MetadataCreateIndexService] failed on parsing mappings on index creation
# org.opensearch.index.mapper.MapperParsingException: Failed to parse mapping [_doc]:
# No handler for type [flattened] declared on field [preferences]
#
# See: https://github.com/zammad/zammad/blob/develop/lib/search_index_backend.rb
module SearchIndexBackendOpenSearchPatch
# Override the _mapping_item_type_es method to use flat_object instead of flattened
def _mapping_item_type_es(item_type)
# Call the original method
result = super(item_type)
# Replace 'flattened' with 'flat_object' for OpenSearch compatibility
if result == 'flattened'
return 'flat_object'
end
result
end
end
# Apply the patch after Rails has finished loading all classes
Rails.application.config.after_initialize do
SearchIndexBackend.singleton_class.prepend(SearchIndexBackendOpenSearchPatch)
Rails.logger.info 'OpenSearch compatibility patch applied successfully'
end

View file

@ -1,7 +1,10 @@
# frozen_string_literal: true
Zammad::Application.routes.draw do
scope Rails.configuration.api_path do
match '/opensearch', to: 'opensearch#index', via: :get
end
# Copyright (C) 2025 Center for Digital Resilience
# License: AGPL-3.0-or-later
Rails.application.routes.draw do
# OpenSearch Dashboards integration
get '/opensearch', to: 'opensearch#index'
get '/opensearch/*path', to: 'opensearch#proxy'
end

View file

@ -0,0 +1,48 @@
# frozen_string_literal: true
# Copyright (C) 2025 Center for Digital Resilience
# License: AGPL-3.0-or-later
class AddOpensearchNavigation < ActiveRecord::Migration[5.2]
def self.up
# Add permission for accessing OpenSearch dashboards
Permission.create_if_not_exists(
name: 'user_preferences.opensearch',
note: 'Access to OpenSearch Dashboards',
preferences: {}
)
# Create a navigation entry for OpenSearch
# This will add a menu item in the sidebar
NavBar.create_if_not_exists(
name: 'OpenSearch',
link: '/opensearch',
prio: 2600, # Position in menu (after Dashboard=1400, Tickets=2000, Stats=2500)
permission: ['user_preferences.opensearch'],
parent: '',
active: true
)
# Grant permission to admin and agent roles
%w[Admin Agent].each do |role_name|
role = Role.find_by(name: role_name)
next if !role
role.permission_grant('user_preferences.opensearch')
end
Rails.logger.info 'OpenSearch navigation menu item created successfully'
end
def self.down
# Remove navigation entry
navbar = NavBar.find_by(name: 'OpenSearch')
navbar&.destroy
# Remove permission
permission = Permission.find_by(name: 'user_preferences.opensearch')
permission&.destroy
Rails.logger.info 'OpenSearch navigation menu item removed'
end
end

File diff suppressed because one or more lines are too long