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:
Abhinav Raut
2025-07-21 01:58:30 +05:30
parent f05014f412
commit c35ab42b47
14 changed files with 361 additions and 42 deletions

View File

@@ -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
}

View File

@@ -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.

View File

@@ -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

View File

@@ -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',

View File

@@ -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(),

View File

@@ -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({

View File

@@ -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.

View 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>

View 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)
}

View File

@@ -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",

View File

@@ -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"`

View File

@@ -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)
}

View File

@@ -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;

View File

@@ -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
}