feat: add push notification settings to user profile and update related functionality
All checks were successful
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m50s

This commit is contained in:
2026-04-20 10:43:09 -04:00
parent fd28c89cbf
commit b529ddaa02
8 changed files with 249 additions and 191 deletions

View File

@@ -8,6 +8,7 @@
:loading="loading"
:error="errorMsg"
:title="'User Profile'"
:fieldErrors="{ push_enabled: pushError }"
@submit="handleSubmit"
@cancel="router.back"
@add-image="onAddImage"
@@ -15,22 +16,6 @@
<template #custom-field-email="{ modelValue }">
<div class="email-actions">
<input id="email" type="email" :value="modelValue" disabled class="readonly-input" />
<ToggleField
label="Daily Digest"
:modelValue="emailDigestEnabled"
:disabled="savingDigest"
description="Receive a 9pm summary of pending chore and reward requests with one-click approve/deny links."
:error="digestError"
@update:modelValue="toggleDigest"
/>
<ToggleField
label="Push Notifications"
:modelValue="pushEnabled"
:disabled="savingPush || getPushPermissionState() === 'denied'"
description="Receive instant push notifications when a chore or reward needs your approval."
:error="pushError"
@update:modelValue="togglePush"
/>
<button type="button" class="btn-link btn-link-space" @click="goToChangeParentPin">
Change Parent PIN
</button>
@@ -114,16 +99,16 @@
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import EntityEditForm from '../shared/EntityEditForm.vue'
import ModalDialog from '../shared/ModalDialog.vue'
import ToggleField from '../shared/ToggleField.vue'
import {
isSubscribedToPush,
subscribeToPushWithResult,
unsubscribeFromPush,
getPushPermissionState,
setPushOptOut,
} from '@/services/pushSubscription'
import { parseErrorResponse, isEmailValid } from '@/common/api'
import { ALREADY_MARKED } from '@/common/errorCodes'
@@ -148,39 +133,45 @@ const showDeleteSuccess = ref(false)
const showDeleteError = ref(false)
const deleteErrorMessage = ref('')
const emailDigestEnabled = ref(true)
const savingDigest = ref(false)
const digestError = ref('')
const pushEnabled = ref(false)
const savingPush = ref(false)
const pushError = ref('')
const pushPermissionDenied = ref(false)
const initialData = ref<{
image_id: string | null
first_name: string
last_name: string
email: string
email_digest_enabled: boolean
push_enabled: boolean
}>({
image_id: null,
first_name: '',
last_name: '',
email: '',
email_digest_enabled: true,
push_enabled: false,
})
const fields: Array<{
name: string
label: string
type: 'image' | 'text' | 'custom'
imageType?: number
required?: boolean
maxlength?: number
}> = [
{ name: 'image_id', label: 'Image', type: 'image', imageType: 1 },
{ name: 'first_name', label: 'First Name', type: 'text', required: true, maxlength: 64 },
{ name: 'last_name', label: 'Last Name', type: 'text', required: true, maxlength: 64 },
{ name: 'email', label: 'Email Address', type: 'custom' },
]
const fields = computed(() => [
{ name: 'image_id', label: 'Image', type: 'image' as const, imageType: 1 },
{ name: 'first_name', label: 'First Name', type: 'text' as const, required: true, maxlength: 64 },
{ name: 'last_name', label: 'Last Name', type: 'text' as const, required: true, maxlength: 64 },
{ name: 'email', label: 'Email Address', type: 'custom' as const },
{
name: 'email_digest_enabled',
label: 'Daily Digest',
type: 'toggle' as const,
description:
'Receive a 9pm summary of pending chore and reward requests with one-click approve/deny links.',
},
{
name: 'push_enabled',
label: 'Push Notifications',
type: 'toggle' as const,
description: 'Receive instant push notifications when a chore or reward needs your approval.',
disabled: pushPermissionDenied.value,
},
])
onMounted(async () => {
loading.value = true
@@ -188,14 +179,16 @@ onMounted(async () => {
const res = await fetch('/api/user/profile')
if (!res.ok) throw new Error('Failed to load profile')
const data = await res.json()
pushPermissionDenied.value = getPushPermissionState() === 'denied'
const pushSubscribed = await isSubscribedToPush()
initialData.value = {
image_id: data.image_id || null,
first_name: data.first_name || '',
last_name: data.last_name || '',
email: data.email || '',
email_digest_enabled: data.email_digest_enabled !== false,
push_enabled: data.push_notifications_enabled !== false && pushSubscribed,
}
emailDigestEnabled.value = data.email_digest_enabled !== false
pushEnabled.value = await isSubscribedToPush()
} catch {
errorMsg.value = 'Could not load user profile.'
} finally {
@@ -217,6 +210,8 @@ function handleSubmit(form: {
first_name: string
last_name: string
email: string
email_digest_enabled?: boolean
push_enabled?: boolean
}) {
errorMsg.value = ''
loading.value = true
@@ -256,31 +251,73 @@ async function updateProfile(form: {
first_name: string
last_name: string
email: string
email_digest_enabled?: boolean
push_enabled?: boolean
}) {
fetch('/api/user/profile', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
const prevDigest = initialData.value.email_digest_enabled
const prevPush = initialData.value.push_enabled
try {
const body: Record<string, unknown> = {
first_name: form.first_name,
last_name: form.last_name,
image_id: form.image_id,
}),
})
.then(async (res) => {
if (!res.ok) throw new Error('Failed to update profile')
// Update initialData to reflect the saved state
initialData.value = { ...form }
modalTitle.value = 'Profile Updated'
modalSubtitle.value = ''
modalMessage.value = 'Your profile was updated successfully.'
showModal.value = true
})
.catch(() => {
errorMsg.value = 'Failed to update profile.'
})
.finally(() => {
loading.value = false
}
if (form.email_digest_enabled !== undefined && form.email_digest_enabled !== prevDigest) {
body.email_digest_enabled = form.email_digest_enabled
}
if (form.push_enabled !== undefined && form.push_enabled !== prevPush) {
body.push_notifications_enabled = form.push_enabled
}
const res = await fetch('/api/user/profile', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
if (!res.ok) throw new Error('Failed to update profile')
let actualPushEnabled = prevPush
if (form.push_enabled !== undefined && form.push_enabled !== prevPush) {
const pushOk = await applyPushChange(form.push_enabled)
actualPushEnabled = pushOk ? form.push_enabled : prevPush
}
initialData.value = {
...initialData.value,
...form,
push_enabled: actualPushEnabled,
}
modalTitle.value = 'Profile Updated'
modalSubtitle.value = ''
modalMessage.value = 'Your profile was updated successfully.'
showModal.value = true
} catch {
errorMsg.value = 'Failed to update profile.'
} finally {
loading.value = false
}
}
async function applyPushChange(newValue: boolean): Promise<boolean> {
pushError.value = ''
try {
if (newValue) {
const result = await subscribeToPushWithResult()
if (result.ok) {
setPushOptOut(false)
return true
}
pushError.value =
result.reason === 'permission_denied'
? 'Notifications are blocked. Enable them in your browser settings.'
: 'Failed to enable push notifications.'
return false
} else {
await unsubscribeFromPush()
setPushOptOut(true)
return true
}
} catch {
pushError.value = 'Failed to update push notification settings.'
return false
}
}
async function handlePasswordModalClose() {
@@ -317,47 +354,6 @@ async function resetPassword() {
}
}
async function toggleDigest(newValue?: boolean) {
const value = newValue ?? !emailDigestEnabled.value
digestError.value = ''
savingDigest.value = true
try {
const res = await fetch('/api/user/profile', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email_digest_enabled: value }),
})
if (!res.ok) throw new Error('Failed to update preference')
emailDigestEnabled.value = value
} catch {
digestError.value = 'Failed to save notification preference.'
} finally {
savingDigest.value = false
}
}
async function togglePush(newValue: boolean) {
pushError.value = ''
savingPush.value = true
try {
if (newValue) {
const result = await subscribeToPushWithResult()
if (result.ok) {
pushEnabled.value = true
} else if (result.reason === 'permission_denied') {
pushError.value = 'Notifications are blocked. Enable them in your browser settings.'
} else {
pushError.value = 'Failed to enable push notifications.'
}
} else {
await unsubscribeFromPush()
pushEnabled.value = false
}
} finally {
savingPush.value = false
}
}
function goToChangeParentPin() {
router.push({ name: 'ParentPinSetup' })
}