mirror of
https://github.com/abhinavxd/libredesk.git
synced 2025-11-02 04:53:41 +00:00
feat: configurable visitor information collection with a form before starting chat.
fix: Chat initialization failing due to the JWT authenticated user doesn't exist in the DB yet. fix: Always upsert custom attribues instead of replacing.
This commit is contained in:
76
cmd/chat.go
76
cmd/chat.go
@@ -176,13 +176,11 @@ func handleChatInit(r *fastglue.Request) error {
|
||||
// Get authenticated data from context (set by middleware)
|
||||
// Middleware always validates inbox, so we can safely use non-optional getters
|
||||
claims := getWidgetClaimsOptional(r)
|
||||
|
||||
inboxID, err := getWidgetInboxID(r)
|
||||
if err != nil {
|
||||
app.lo.Error("error getting inbox ID from middleware context", "error", err)
|
||||
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.inbox}"), nil, envelope.GeneralError)
|
||||
}
|
||||
|
||||
inbox, err := getWidgetInbox(r)
|
||||
if err != nil {
|
||||
app.lo.Error("error getting inbox from middleware context", "error", err)
|
||||
@@ -205,13 +203,6 @@ func handleChatInit(r *fastglue.Request) error {
|
||||
|
||||
// Handle authenticated user vs visitor
|
||||
if claims != nil {
|
||||
// Use authenticated contact ID from middleware
|
||||
contactID, err = getWidgetContactID(r)
|
||||
if err != nil {
|
||||
app.lo.Error("error getting contact ID from middleware context", "error", err)
|
||||
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.user}"), nil, envelope.GeneralError)
|
||||
}
|
||||
|
||||
// Handle existing contacts with external user id - check if we need to create user
|
||||
if claims.ExternalUserID != "" {
|
||||
// Find or create user based on external_user_id.
|
||||
@@ -235,26 +226,54 @@ func handleChatInit(r *fastglue.Request) error {
|
||||
}
|
||||
|
||||
// Create new contact with external user ID.
|
||||
err = app.user.CreateContact(&umodels.User{
|
||||
var user = umodels.User{
|
||||
FirstName: firstName,
|
||||
LastName: lastName,
|
||||
Email: null.NewString(claims.Email, claims.Email != ""),
|
||||
ExternalUserID: null.NewString(claims.ExternalUserID, claims.ExternalUserID != ""),
|
||||
CustomAttributes: customAttribJSON,
|
||||
})
|
||||
}
|
||||
err = app.user.CreateContact(&user)
|
||||
if err != nil {
|
||||
app.lo.Error("error creating contact with external ID", "external_user_id", claims.ExternalUserID, "error", err)
|
||||
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorCreating", "name", "{globals.terms.user}"), nil, envelope.GeneralError)
|
||||
}
|
||||
contactID = user.ID
|
||||
} else {
|
||||
contactID = user.ID
|
||||
}
|
||||
contactID = user.ID
|
||||
isVisitor = false
|
||||
} else {
|
||||
isVisitor = claims.IsVisitor
|
||||
contactID, err = getWidgetContactID(r)
|
||||
if err != nil {
|
||||
app.lo.Error("error getting contact ID from middleware context", "error", err)
|
||||
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.user}"), nil, envelope.GeneralError)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Visitor user not authenticated, create a new visitor contact.
|
||||
isVisitor = true
|
||||
|
||||
// Validate visitor contact info based on configuration
|
||||
switch config.Visitors.RequireContactInfo {
|
||||
case "required":
|
||||
if req.VisitorName == "" {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.required", "name", "Name"), nil, envelope.InputError)
|
||||
}
|
||||
if req.VisitorEmail == "" {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.required", "name", "Email"), nil, envelope.InputError)
|
||||
}
|
||||
case "optional":
|
||||
// Allow empty fields, but if provided, validate email format
|
||||
if req.VisitorEmail != "" && !stringutil.ValidEmail(req.VisitorEmail) {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "Email"), nil, envelope.InputError)
|
||||
}
|
||||
default:
|
||||
req.VisitorEmail = ""
|
||||
req.VisitorName = ""
|
||||
}
|
||||
|
||||
visitor := umodels.User{
|
||||
Email: null.NewString(req.VisitorEmail, req.VisitorEmail != ""),
|
||||
FirstName: req.VisitorName,
|
||||
@@ -274,16 +293,20 @@ func handleChatInit(r *fastglue.Request) error {
|
||||
}
|
||||
|
||||
// Check conversation permissions based on user type.
|
||||
userConfig := config.Visitors
|
||||
if !isVisitor {
|
||||
userConfig = config.Users
|
||||
var allowStartConversation, preventMultipleConversations bool
|
||||
if isVisitor {
|
||||
allowStartConversation = config.Visitors.AllowStartConversation
|
||||
preventMultipleConversations = config.Visitors.PreventMultipleConversations
|
||||
} else {
|
||||
allowStartConversation = config.Users.AllowStartConversation
|
||||
preventMultipleConversations = config.Users.PreventMultipleConversations
|
||||
}
|
||||
|
||||
if !userConfig.AllowStartConversation {
|
||||
if !allowStartConversation {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusForbidden, app.i18n.T("globals.messages.notAllowed}"), nil, envelope.PermissionError)
|
||||
}
|
||||
|
||||
if userConfig.PreventMultipleConversations {
|
||||
if preventMultipleConversations {
|
||||
conversations, err := app.conversation.GetContactChatConversations(contactID, inboxID)
|
||||
if err != nil {
|
||||
userType := "visitor"
|
||||
@@ -364,12 +387,12 @@ func handleChatInit(r *fastglue.Request) error {
|
||||
"business_hours_id": resp.BusinessHoursID,
|
||||
"working_hours_utc_offset": resp.WorkingHoursUTCOffset,
|
||||
}
|
||||
|
||||
|
||||
// Only add JWT for visitor creation
|
||||
if newJWT != "" {
|
||||
response["jwt"] = newJWT
|
||||
}
|
||||
|
||||
|
||||
return r.SendEnvelope(response)
|
||||
}
|
||||
|
||||
@@ -409,7 +432,18 @@ func handleChatUpdateLastSeen(r *fastglue.Request) error {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
|
||||
return r.SendEnvelope(map[string]bool{"success": true})
|
||||
// Also update custom attributes from JWT claims, if present.
|
||||
// This avoids a separate handler and ensures contact attributes stay in sync.
|
||||
// Since this endpoint is hit frequently during chat, it's a good place to keep them updated.
|
||||
claims := getWidgetClaimsOptional(r)
|
||||
if claims != nil && len(claims.CustomAttributes) > 0 {
|
||||
if err := app.user.SaveCustomAttributes(contactID, claims.CustomAttributes, false); err != nil {
|
||||
app.lo.Error("error updating contact custom attributes", "contact_id", contactID, "error", err)
|
||||
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.user}"), nil, envelope.GeneralError)
|
||||
}
|
||||
}
|
||||
|
||||
return r.SendEnvelope(true)
|
||||
}
|
||||
|
||||
// handleChatGetConversation fetches a chat conversation by ID
|
||||
@@ -907,7 +941,7 @@ func resolveUserIDFromClaims(app *App, claims Claims) (int, error) {
|
||||
user, err := app.user.GetByExternalID(claims.ExternalUserID)
|
||||
if err != nil {
|
||||
app.lo.Error("error fetching user by external ID", "external_user_id", claims.ExternalUserID, "error", err)
|
||||
return 0, fmt.Errorf("user not found for external_user_id: %s", claims.ExternalUserID)
|
||||
return 0, fmt.Errorf("user not found for external_user_id %s: %w", claims.ExternalUserID, err)
|
||||
}
|
||||
return user.ID, nil
|
||||
}
|
||||
|
||||
@@ -578,7 +578,7 @@ func handleUpdateContactCustomAttributes(r *fastglue.Request) error {
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
if err := app.user.UpdateCustomAttributes(conversation.ContactID, attributes); err != nil {
|
||||
if err := app.user.SaveCustomAttributes(conversation.ContactID, attributes, false); err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
// Broadcast update.
|
||||
|
||||
@@ -87,8 +87,11 @@ func widgetAuth(next func(*fastglue.Request) error) func(*fastglue.Request) erro
|
||||
// Resolve user/contact ID from JWT claims
|
||||
contactID, err := resolveUserIDFromClaims(app, claims)
|
||||
if err != nil {
|
||||
app.lo.Error("error resolving user ID from JWT claims", "error", err)
|
||||
return r.SendErrorEnvelope(fasthttp.StatusUnauthorized, app.i18n.T("globals.terms.unAuthorized"), nil, envelope.UnauthorizedError)
|
||||
envErr, ok := err.(envelope.Error)
|
||||
if ok && envErr.ErrorType != envelope.NotFoundError {
|
||||
app.lo.Error("error resolving user ID from JWT claims", "error", err)
|
||||
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.user}"), nil, envelope.GeneralError)
|
||||
}
|
||||
}
|
||||
|
||||
// Store authenticated data in request context for downstream handlers
|
||||
|
||||
@@ -572,6 +572,58 @@
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
v-slot="{ componentField }"
|
||||
name="config.visitors.require_contact_info"
|
||||
>
|
||||
<FormItem>
|
||||
<FormLabel>{{ $t('admin.inbox.livechat.requireContactInfo') }}</FormLabel>
|
||||
<FormControl>
|
||||
<Select v-bind="componentField">
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="disabled">
|
||||
{{ $t('admin.inbox.livechat.requireContactInfo.disabled') }}
|
||||
</SelectItem>
|
||||
<SelectItem value="optional">
|
||||
{{ $t('admin.inbox.livechat.requireContactInfo.optional') }}
|
||||
</SelectItem>
|
||||
<SelectItem value="required">
|
||||
{{ $t('admin.inbox.livechat.requireContactInfo.required') }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormDescription>{{
|
||||
$t('admin.inbox.livechat.requireContactInfo.visitors.description')
|
||||
}}</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
v-if="form.values.config?.visitors?.require_contact_info !== 'disabled'"
|
||||
v-slot="{ componentField }"
|
||||
name="config.visitors.contact_info_message"
|
||||
>
|
||||
<FormItem>
|
||||
<FormLabel>{{ $t('admin.inbox.livechat.contactInfoMessage') }}</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
v-bind="componentField"
|
||||
placeholder="Please provide your contact information so we can assist you better."
|
||||
rows="2"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>{{
|
||||
$t('admin.inbox.livechat.contactInfoMessage.visitors.description')
|
||||
}}</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
<!-- Users Settings -->
|
||||
@@ -666,7 +718,7 @@ import {
|
||||
SelectValue
|
||||
} from '@shared-ui/components/ui/select'
|
||||
import { Tabs, TabsList, TabsTrigger } from '@shared-ui/components/ui/tabs'
|
||||
import { Copy, Plus, X } from 'lucide-vue-next'
|
||||
import { Plus, X } from 'lucide-vue-next'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const props = defineProps({
|
||||
@@ -735,7 +787,9 @@ const form = useForm({
|
||||
visitors: {
|
||||
start_conversation_button_text: 'Start conversation',
|
||||
allow_start_conversation: true,
|
||||
prevent_multiple_conversations: false
|
||||
prevent_multiple_conversations: false,
|
||||
require_contact_info: 'disabled',
|
||||
contact_info_message: ''
|
||||
},
|
||||
users: {
|
||||
start_conversation_button_text: 'Start conversation',
|
||||
|
||||
@@ -59,6 +59,8 @@ export const createFormSchema = (t) => z.object({
|
||||
start_conversation_button_text: z.string(),
|
||||
allow_start_conversation: z.boolean(),
|
||||
prevent_multiple_conversations: z.boolean(),
|
||||
require_contact_info: z.enum(['disabled', 'optional', 'required']).default('disabled'),
|
||||
contact_info_message: z.string().optional(),
|
||||
}),
|
||||
users: z.object({
|
||||
start_conversation_button_text: z.string(),
|
||||
|
||||
@@ -22,7 +22,7 @@ const userStore = useUserStore()
|
||||
// Initialize unread count tracking and sending to parent window.
|
||||
useUnreadCount()
|
||||
|
||||
onMounted(() => {
|
||||
onMounted(async () => {
|
||||
// Use pre-fetched widget config from main.js
|
||||
const widgetConfig = getCurrentInstance().appContext.config.globalProperties.$widgetConfig
|
||||
if (widgetConfig) {
|
||||
@@ -30,9 +30,12 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
initializeWebSocket()
|
||||
|
||||
widgetStore.openWidget()
|
||||
|
||||
setupParentMessageListeners()
|
||||
chatStore.fetchConversations()
|
||||
|
||||
await chatStore.fetchConversations()
|
||||
|
||||
// Notify parent window that Vue app is ready
|
||||
window.parent.postMessage({
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
<template>
|
||||
<div class="border-t focus:ring-0 focus:outline-none">
|
||||
<div class="p-2">
|
||||
<!-- Visitor Info Form -->
|
||||
<VisitorInfoForm v-if="showVisitorForm" @submit="handleVisitorInfoSubmit" />
|
||||
|
||||
<!-- Message Input -->
|
||||
<div v-if="!showVisitorForm" class="p-2">
|
||||
<!-- Unified Input Container -->
|
||||
<div class="border border-input rounded-lg bg-background focus-within:border-primary">
|
||||
<!-- Textarea Container -->
|
||||
@@ -58,6 +62,7 @@ import { sendWidgetTyping } from '../websocket.js'
|
||||
import { convertTextToHtml } from '@shared-ui/utils/string.js'
|
||||
import { useTypingIndicator } from '@shared-ui/composables/useTypingIndicator.js'
|
||||
import MessageInputActions from './MessageInputActions.vue'
|
||||
import VisitorInfoForm from './VisitorInfoForm.vue'
|
||||
import api from '@widget/api/index.js'
|
||||
|
||||
const emit = defineEmits(['error'])
|
||||
@@ -68,8 +73,23 @@ const messageInput = ref(null)
|
||||
const newMessage = ref('')
|
||||
const isUploading = ref(false)
|
||||
const isSending = ref(false)
|
||||
const visitorInfo = ref({ name: '', email: '' })
|
||||
const visitorInfoSubmitted = ref(false)
|
||||
const config = computed(() => widgetStore.config)
|
||||
|
||||
// Determine if visitor form should be shown
|
||||
const showVisitorForm = computed(() => {
|
||||
if (!userStore.isVisitor || userStore.userSessionToken) return false
|
||||
|
||||
const requireContactInfo = config.value?.visitors?.require_contact_info || 'disabled'
|
||||
|
||||
if (requireContactInfo !== 'disabled' && !visitorInfoSubmitted.value) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
})
|
||||
|
||||
// Setup typing indicator
|
||||
const { startTyping, stopTyping } = useTypingIndicator((isTyping) => {
|
||||
if (chatStore.currentConversation?.uuid) {
|
||||
@@ -77,10 +97,24 @@ const { startTyping, stopTyping } = useTypingIndicator((isTyping) => {
|
||||
}
|
||||
})
|
||||
|
||||
// Handle visitor info form submission
|
||||
const handleVisitorInfoSubmit = (info) => {
|
||||
visitorInfo.value = info
|
||||
visitorInfoSubmitted.value = true
|
||||
}
|
||||
|
||||
const initChatConversation = async (messageText) => {
|
||||
const resp = await api.initChatConversation({
|
||||
const payload = {
|
||||
message: messageText
|
||||
})
|
||||
}
|
||||
|
||||
// Add visitor info if user is a visitor
|
||||
if (userStore.isVisitor) {
|
||||
payload.visitor_name = visitorInfo.value.name
|
||||
payload.visitor_email = visitorInfo.value.email
|
||||
}
|
||||
|
||||
const resp = await api.initChatConversation(payload)
|
||||
const { conversation, jwt, messages } = resp.data.data
|
||||
|
||||
// Set user session token if not already set.
|
||||
|
||||
119
frontend/apps/widget/src/components/VisitorInfoForm.vue
Normal file
119
frontend/apps/widget/src/components/VisitorInfoForm.vue
Normal file
@@ -0,0 +1,119 @@
|
||||
<template>
|
||||
<div class="border-t bg-background p-4">
|
||||
<div v-if="showForm" class="space-y-4">
|
||||
<!-- Custom message -->
|
||||
<div v-if="contactInfoMessage" class="text-sm text-muted-foreground">
|
||||
{{ contactInfoMessage }}
|
||||
</div>
|
||||
|
||||
<!-- Default message -->
|
||||
<div v-else class="text-sm text-muted-foreground">
|
||||
{{ $t('globals.placeholders.helpUsServeYouBetter') }}
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="submitForm" class="space-y-4">
|
||||
<!-- Name input -->
|
||||
<FormField v-slot="{ componentField }" name="name">
|
||||
<FormItem>
|
||||
<FormLabel class="text-sm font-medium">
|
||||
{{ $t('globals.terms.name') }}
|
||||
<span v-if="isRequired" class="text-destructive">*</span>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
v-bind="componentField"
|
||||
type="text"
|
||||
:placeholder="$t('globals.placeholders.name')"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<!-- Email input -->
|
||||
<FormField v-slot="{ componentField }" name="email">
|
||||
<FormItem>
|
||||
<FormLabel class="text-sm font-medium">
|
||||
{{ $t('globals.terms.email') }}
|
||||
<span v-if="isRequired" class="text-destructive">*</span>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
v-bind="componentField"
|
||||
type="email"
|
||||
:placeholder="$t('globals.placeholders.email')"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<!-- Submit button -->
|
||||
<Button
|
||||
type="submit"
|
||||
class="w-full"
|
||||
:disabled="!meta.valid"
|
||||
>
|
||||
{{ $t('globals.terms.continue') }}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, watch } from 'vue'
|
||||
import { useForm } from 'vee-validate'
|
||||
import { toTypedSchema } from '@vee-validate/zod'
|
||||
import { Button } from '@shared-ui/components/ui/button'
|
||||
import { Input } from '@shared-ui/components/ui/input'
|
||||
import { FormControl, FormField, FormItem, FormLabel, FormMessage } from '@shared-ui/components/ui/form'
|
||||
import { useWidgetStore } from '../store/widget.js'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { createVisitorInfoSchema } from './visitorInfoFormSchema.js'
|
||||
|
||||
const emit = defineEmits(['submit'])
|
||||
const { t } = useI18n()
|
||||
const widgetStore = useWidgetStore()
|
||||
|
||||
const config = computed(() => widgetStore.config?.visitors || {})
|
||||
const requireContactInfo = computed(() => config.value.require_contact_info || 'disabled')
|
||||
const contactInfoMessage = computed(() => config.value.contact_info_message || '')
|
||||
|
||||
const showForm = computed(() => requireContactInfo.value !== 'disabled')
|
||||
const isRequired = computed(() => requireContactInfo.value === 'required')
|
||||
|
||||
// Create form with dynamic schema based on requirements
|
||||
const formSchema = computed(() =>
|
||||
toTypedSchema(createVisitorInfoSchema(t, requireContactInfo.value))
|
||||
)
|
||||
|
||||
const { handleSubmit, meta, resetForm } = useForm({
|
||||
validationSchema: formSchema,
|
||||
initialValues: {
|
||||
name: '',
|
||||
email: ''
|
||||
}
|
||||
})
|
||||
|
||||
const submitForm = handleSubmit((values) => {
|
||||
emit('submit', {
|
||||
name: values.name?.trim() || '',
|
||||
email: values.email?.trim() || ''
|
||||
})
|
||||
})
|
||||
|
||||
// Auto-submit for disabled mode
|
||||
watch(showForm, (newValue) => {
|
||||
if (!newValue) {
|
||||
emit('submit', { name: '', email: '' })
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
// Reset form when requirements change
|
||||
watch(requireContactInfo, () => {
|
||||
resetForm({
|
||||
values: { name: '', email: '' }
|
||||
})
|
||||
})
|
||||
</script>
|
||||
43
frontend/apps/widget/src/components/visitorInfoFormSchema.js
Normal file
43
frontend/apps/widget/src/components/visitorInfoFormSchema.js
Normal file
@@ -0,0 +1,43 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const createVisitorInfoSchema = (t, requireContactInfo) => {
|
||||
const baseSchema = {
|
||||
name: z.string().optional(),
|
||||
email: z.string().optional()
|
||||
}
|
||||
|
||||
if (requireContactInfo === 'required') {
|
||||
return z.object({
|
||||
name: z
|
||||
.string({
|
||||
required_error: t('globals.messages.required', { name: t('globals.terms.name') }),
|
||||
})
|
||||
.min(1, {
|
||||
message: t('globals.messages.required', { name: t('globals.terms.name') }),
|
||||
}),
|
||||
email: z
|
||||
.string({
|
||||
required_error: t('globals.messages.required', { name: t('globals.terms.email') }),
|
||||
})
|
||||
.min(1, {
|
||||
message: t('globals.messages.required', { name: t('globals.terms.email') }),
|
||||
})
|
||||
.email({
|
||||
message: t('globals.messages.invalidEmail'),
|
||||
}),
|
||||
})
|
||||
} else if (requireContactInfo === 'optional') {
|
||||
return z.object({
|
||||
name: z.string().optional(),
|
||||
email: z
|
||||
.string()
|
||||
.optional()
|
||||
.refine(val => !val || /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(val), {
|
||||
message: t('globals.messages.invalidEmail'),
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
// Disabled mode - no validation
|
||||
return z.object(baseSchema)
|
||||
}
|
||||
14
i18n/en.json
14
i18n/en.json
@@ -147,6 +147,7 @@
|
||||
"globals.terms.test": "Test | Tests",
|
||||
"globals.terms.confirmation": "Confirmation | Confirmations",
|
||||
"globals.terms.dialog": "Dialog | Dialogs",
|
||||
"globals.terms.continue": "Continue",
|
||||
"globals.terms.modal": "Modal | Modals",
|
||||
"globals.terms.timezone": "Timezone | Timezones",
|
||||
"globals.terms.language": "Language | Languages",
|
||||
@@ -276,6 +277,7 @@
|
||||
"globals.messages.type": "{name} type",
|
||||
"globals.messages.typeOf": "Type of {name}",
|
||||
"globals.messages.invalidEmailAddress": "Invalid email address",
|
||||
"globals.messages.invalidEmail": "Invalid email address",
|
||||
"globals.messages.selectAtLeastOne": "Please select at least one {name}",
|
||||
"globals.messages.strongPassword": "Password must be between {min} and {max} characters long, should contain at least one uppercase letter, one lowercase letter, one number, and one special character.",
|
||||
"globals.messages.couldNotReload": "Could not reload {name}",
|
||||
@@ -561,6 +563,15 @@
|
||||
"admin.inbox.livechat.preventReplyingToClosedConversations": "Prevent Replying to Closed Conversations",
|
||||
"admin.inbox.livechat.preventReplyingToClosedConversations.visitors.description": "Prevent visitors from replying to closed conversations",
|
||||
"admin.inbox.livechat.preventReplyingToClosedConversations.users.description": "Prevent users users from replying to closed conversations",
|
||||
"admin.inbox.livechat.requireContactInfo": "Require Contact Information",
|
||||
"admin.inbox.livechat.requireContactInfo.disabled": "Disabled",
|
||||
"admin.inbox.livechat.requireContactInfo.optional": "Optional",
|
||||
"admin.inbox.livechat.requireContactInfo.required": "Required",
|
||||
"admin.inbox.livechat.requireContactInfo.visitors.description": "Require visitors to provide contact information before starting a conversation",
|
||||
"admin.inbox.livechat.requireContactInfo.users.description": "Require users to provide contact information before starting a conversation",
|
||||
"admin.inbox.livechat.contactInfoMessage": "Contact Information Message",
|
||||
"admin.inbox.livechat.contactInfoMessage.visitors.description": "Custom message shown to visitors when collecting contact information",
|
||||
"admin.inbox.livechat.contactInfoMessage.users.description": "Custom message shown to users when collecting contact information",
|
||||
"admin.agent.deleteConfirmation": "This will permanently delete the agent. Consider disabling the account instead.",
|
||||
"admin.agent.apiKey.description": "Generate API keys for this agent to access libredesk programmatically.",
|
||||
"admin.agent.apiKey.noKey": "No API key has been generated for this agent.",
|
||||
@@ -721,6 +732,9 @@
|
||||
"contact.notes.help": "Add note for this contact to keep track of important information and conversations.",
|
||||
"globals.placeholders.typeMessage": "Type your message...",
|
||||
"globals.placeholders.tellUsMore": "Tell us more...",
|
||||
"globals.placeholders.name": "Enter your name",
|
||||
"globals.placeholders.email": "Enter your email address",
|
||||
"globals.placeholders.helpUsServeYouBetter": "Help us serve you better by providing your contact information.",
|
||||
"globals.days.sunday": "Sunday",
|
||||
"globals.days.monday": "Monday",
|
||||
"globals.days.tuesday": "Tuesday",
|
||||
|
||||
@@ -53,6 +53,8 @@ type Config struct {
|
||||
AllowStartConversation bool `json:"allow_start_conversation"`
|
||||
PreventMultipleConversations bool `json:"prevent_multiple_conversations"`
|
||||
StartConversationButtonText string `json:"start_conversation_button_text"`
|
||||
RequireContactInfo string `json:"require_contact_info"` // "disabled", "optional", "required"
|
||||
ContactInfoMessage string `json:"contact_info_message"` // Custom message for the form
|
||||
} `json:"visitors"`
|
||||
NoticeBanner struct {
|
||||
Text string `json:"text"`
|
||||
|
||||
@@ -22,7 +22,7 @@ func (u *Manager) CreateContact(user *models.User) error {
|
||||
|
||||
// If external_user_id is provided, insert with it.
|
||||
if user.ExternalUserID.Valid {
|
||||
if err := u.q.InsertContactWithExtID.QueryRow(user.Email, user.FirstName, user.LastName, password, user.AvatarURL, user.ExternalUserID).Scan(&user.ID); err != nil {
|
||||
if err := u.q.InsertContactWithExtID.QueryRow(user.Email, user.FirstName, user.LastName, password, user.AvatarURL, user.ExternalUserID, user.CustomAttributes).Scan(&user.ID); err != nil {
|
||||
u.lo.Error("error inserting contact with external ID", "error", err)
|
||||
return fmt.Errorf("insert contact with external ID: %w", err)
|
||||
}
|
||||
|
||||
@@ -107,6 +107,12 @@ SET custom_attributes = $2,
|
||||
updated_at = now()
|
||||
WHERE id = $1;
|
||||
|
||||
-- name: upsert-custom-attributes
|
||||
UPDATE users
|
||||
SET custom_attributes = COALESCE(custom_attributes, '{}'::jsonb) || $2,
|
||||
updated_at = now()
|
||||
WHERE id = $1
|
||||
|
||||
-- name: update-avatar
|
||||
UPDATE users
|
||||
SET avatar_url = $2, updated_at = now()
|
||||
@@ -154,8 +160,8 @@ JOIN roles r ON r.name = role_name
|
||||
RETURNING user_id;
|
||||
|
||||
-- name: insert-contact-with-external-id
|
||||
INSERT INTO users (email, type, first_name, last_name, "password", avatar_url, external_user_id)
|
||||
VALUES ($1, 'contact', $2, $3, $4, $5, $6)
|
||||
INSERT INTO users (email, type, first_name, last_name, "password", avatar_url, external_user_id, custom_attributes)
|
||||
VALUES ($1, 'contact', $2, $3, $4, $5, $6, $7)
|
||||
ON CONFLICT (external_user_id) WHERE type = 'contact' AND deleted_at IS NULL AND external_user_id IS NOT NULL
|
||||
DO UPDATE SET updated_at = now()
|
||||
RETURNING id;
|
||||
|
||||
@@ -69,6 +69,7 @@ type queries struct {
|
||||
UpdateContact *sqlx.Stmt `query:"update-contact"`
|
||||
UpdateAgent *sqlx.Stmt `query:"update-agent"`
|
||||
UpdateCustomAttributes *sqlx.Stmt `query:"update-custom-attributes"`
|
||||
UpsertCustomAttributes *sqlx.Stmt `query:"upsert-custom-attributes"`
|
||||
UpdateAvatar *sqlx.Stmt `query:"update-avatar"`
|
||||
UpdateAvailability *sqlx.Stmt `query:"update-availability"`
|
||||
UpdateLastActiveAt *sqlx.Stmt `query:"update-last-active-at"`
|
||||
@@ -258,19 +259,23 @@ func (u *Manager) UpdateLastActive(id int) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateCustomAttributes updates the custom attributes of an user.
|
||||
func (u *Manager) UpdateCustomAttributes(id int, customAttributes map[string]any) error {
|
||||
// Convert custom attributes to JSON.
|
||||
// SaveCustomAttributes sets or merges custom attributes for a user.
|
||||
// If replace is true, existing attributes are overwritten. Otherwise, attributes are merged.
|
||||
func (u *Manager) SaveCustomAttributes(id int, customAttributes map[string]any, replace bool) error {
|
||||
jsonb, err := json.Marshal(customAttributes)
|
||||
if err != nil {
|
||||
u.lo.Error("error marshalling custom attributes to JSON", "error", err)
|
||||
u.lo.Error("error marshalling custom attributes", "error", err)
|
||||
return envelope.NewError(envelope.GeneralError, u.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.user}"), nil)
|
||||
}
|
||||
// Update custom attributes in the database.
|
||||
if _, err := u.q.UpdateCustomAttributes.Exec(id, jsonb); err != nil {
|
||||
u.lo.Error("error updating user custom attributes", "error", err)
|
||||
var execErr error
|
||||
if replace {
|
||||
_, execErr = u.q.UpdateCustomAttributes.Exec(id, jsonb)
|
||||
} else {
|
||||
_, execErr = u.q.UpsertCustomAttributes.Exec(id, jsonb)
|
||||
}
|
||||
if execErr != nil {
|
||||
u.lo.Error("error saving custom attributes", "error", execErr)
|
||||
return envelope.NewError(envelope.GeneralError, u.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.user}"), nil)
|
||||
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user