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
All checks were successful
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m50s
This commit is contained in:
@@ -558,7 +558,7 @@ describe('UserProfile - Profile Update', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('UserProfile - Email Digest Toggle', () => {
|
||||
describe('UserProfile - Notification Toggles', () => {
|
||||
let wrapper: VueWrapper<any>
|
||||
|
||||
function mountWithDigest(emailDigestEnabled: boolean) {
|
||||
@@ -592,85 +592,97 @@ describe('UserProfile - Email Digest Toggle', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
;(global.fetch as any).mockClear()
|
||||
// Default response for any extra fetch calls (e.g. PUT)
|
||||
;(global.fetch as any).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({}),
|
||||
})
|
||||
})
|
||||
|
||||
it('renders the email digest toggle button', async () => {
|
||||
it('initializes email_digest_enabled to true in initialData when profile returns true', async () => {
|
||||
wrapper = mountWithDigest(true)
|
||||
await flushPromises()
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.find('.toggle-btn').exists()).toBe(true)
|
||||
expect(wrapper.vm.initialData.email_digest_enabled).toBe(true)
|
||||
})
|
||||
|
||||
it('initializes toggle as enabled when email_digest_enabled is true', async () => {
|
||||
wrapper = mountWithDigest(true)
|
||||
await flushPromises()
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.vm.emailDigestEnabled).toBe(true)
|
||||
expect(wrapper.find('.toggle-btn').attributes('aria-pressed')).toBe('true')
|
||||
})
|
||||
|
||||
it('initializes toggle as disabled when email_digest_enabled is false', async () => {
|
||||
it('initializes email_digest_enabled to false in initialData when profile returns false', async () => {
|
||||
wrapper = mountWithDigest(false)
|
||||
await flushPromises()
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.vm.emailDigestEnabled).toBe(false)
|
||||
expect(wrapper.find('.toggle-btn').attributes('aria-pressed')).toBe('false')
|
||||
expect(wrapper.vm.initialData.email_digest_enabled).toBe(false)
|
||||
})
|
||||
|
||||
it('calls PUT /api/user/profile with email_digest_enabled: false when toggled off', async () => {
|
||||
it('initializes push_enabled to false in initialData when not subscribed', async () => {
|
||||
wrapper = mountWithDigest(true)
|
||||
await flushPromises()
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.vm.initialData.push_enabled).toBe(false)
|
||||
})
|
||||
|
||||
it('fields array includes email_digest_enabled as toggle type', async () => {
|
||||
wrapper = mountWithDigest(true)
|
||||
await flushPromises()
|
||||
await nextTick()
|
||||
|
||||
const digestField = wrapper.vm.fields.find((f: any) => f.name === 'email_digest_enabled')
|
||||
expect(digestField).toBeDefined()
|
||||
expect(digestField.type).toBe('toggle')
|
||||
})
|
||||
|
||||
it('fields array includes push_enabled as toggle type', async () => {
|
||||
wrapper = mountWithDigest(true)
|
||||
await flushPromises()
|
||||
await nextTick()
|
||||
|
||||
const pushField = wrapper.vm.fields.find((f: any) => f.name === 'push_enabled')
|
||||
expect(pushField).toBeDefined()
|
||||
expect(pushField.type).toBe('toggle')
|
||||
})
|
||||
|
||||
it('profile PUT includes email_digest_enabled when changed on submit', async () => {
|
||||
wrapper = mountWithDigest(true)
|
||||
await flushPromises()
|
||||
await nextTick()
|
||||
;(global.fetch as any).mockResolvedValueOnce({ ok: true, json: async () => ({}) })
|
||||
|
||||
await wrapper.vm.toggleDigest()
|
||||
await wrapper.vm.handleSubmit({
|
||||
image_id: null,
|
||||
first_name: 'Test',
|
||||
last_name: 'User',
|
||||
email: 'test@example.com',
|
||||
email_digest_enabled: false,
|
||||
push_enabled: false,
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
'/api/user/profile',
|
||||
expect.objectContaining({
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ email_digest_enabled: false }),
|
||||
}),
|
||||
)
|
||||
const putCall = (global.fetch as any).mock.calls.find((c: any[]) => c[1]?.method === 'PUT')
|
||||
expect(putCall).toBeDefined()
|
||||
const body = JSON.parse(putCall[1].body)
|
||||
expect(body.email_digest_enabled).toBe(false)
|
||||
})
|
||||
|
||||
it('calls PUT /api/user/profile with email_digest_enabled: true when toggled on', async () => {
|
||||
wrapper = mountWithDigest(false)
|
||||
await flushPromises()
|
||||
await nextTick()
|
||||
;(global.fetch as any).mockResolvedValueOnce({ ok: true, json: async () => ({}) })
|
||||
|
||||
await wrapper.vm.toggleDigest()
|
||||
await flushPromises()
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
'/api/user/profile',
|
||||
expect.objectContaining({
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ email_digest_enabled: true }),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('flips emailDigestEnabled state after toggle', async () => {
|
||||
it('profile PUT omits email_digest_enabled when unchanged on submit', async () => {
|
||||
wrapper = mountWithDigest(true)
|
||||
await flushPromises()
|
||||
await nextTick()
|
||||
;(global.fetch as any).mockResolvedValueOnce({ ok: true, json: async () => ({}) })
|
||||
|
||||
expect(wrapper.vm.emailDigestEnabled).toBe(true)
|
||||
await wrapper.vm.toggleDigest()
|
||||
await wrapper.vm.handleSubmit({
|
||||
image_id: null,
|
||||
first_name: 'Test',
|
||||
last_name: 'User',
|
||||
email: 'test@example.com',
|
||||
email_digest_enabled: true, // same as initial
|
||||
push_enabled: false,
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.vm.emailDigestEnabled).toBe(false)
|
||||
const putCall = (global.fetch as any).mock.calls.find((c: any[]) => c[1]?.method === 'PUT')
|
||||
expect(putCall).toBeDefined()
|
||||
const body = JSON.parse(putCall[1].body)
|
||||
expect(body.email_digest_enabled).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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' })
|
||||
}
|
||||
|
||||
@@ -4,7 +4,16 @@
|
||||
<form v-else @submit.prevent="submit" class="entity-form" ref="formRef">
|
||||
<template v-for="field in fields" :key="field.name">
|
||||
<div class="group">
|
||||
<label :for="field.name">
|
||||
<ToggleField
|
||||
v-if="field.type === 'toggle'"
|
||||
:label="field.label"
|
||||
:modelValue="formData[field.name]"
|
||||
@update:modelValue="(val: boolean) => (formData[field.name] = val)"
|
||||
:disabled="field.disabled"
|
||||
:description="field.description"
|
||||
:error="props.fieldErrors?.[field.name]"
|
||||
/>
|
||||
<label v-else :for="field.name">
|
||||
{{ field.label }}
|
||||
<!-- Custom field slot -->
|
||||
<slot
|
||||
@@ -70,18 +79,21 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, nextTick, watch, computed } from 'vue'
|
||||
import ImagePicker from '@/components/utils/ImagePicker.vue'
|
||||
import ToggleField from './ToggleField.vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import '@/assets/styles.css'
|
||||
|
||||
type Field = {
|
||||
name: string
|
||||
label: string
|
||||
type: 'text' | 'number' | 'image' | 'custom'
|
||||
type: 'text' | 'number' | 'image' | 'custom' | 'toggle'
|
||||
required?: boolean
|
||||
maxlength?: number
|
||||
min?: number
|
||||
max?: number
|
||||
imageType?: number
|
||||
description?: string
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(
|
||||
@@ -94,6 +106,7 @@ const props = withDefaults(
|
||||
error?: string | null
|
||||
title?: string
|
||||
requireDirty?: boolean
|
||||
fieldErrors?: Record<string, string>
|
||||
}>(),
|
||||
{
|
||||
requireDirty: true,
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
import { getCachedImageUrl, getCachedImageBlob } from '@/common/imageCache'
|
||||
import '@/assets/styles.css'
|
||||
import ModalDialog from './ModalDialog.vue'
|
||||
import { subscribeToPushWithResult } from '@/services/pushSubscription'
|
||||
import { subscribeToPushWithResult, isPushOptedOut } from '@/services/pushSubscription'
|
||||
|
||||
const router = useRouter()
|
||||
const show = ref(false)
|
||||
@@ -140,7 +140,9 @@ const submit = async () => {
|
||||
}
|
||||
// Authenticate parent and navigate
|
||||
authenticateParent(stayInParentMode.value)
|
||||
subscribeToPushWithResult() // fire-and-forget — browser gesture is satisfied by the PIN button click
|
||||
if (!isPushOptedOut()) {
|
||||
subscribeToPushWithResult() // fire-and-forget — browser gesture is satisfied by the PIN button click
|
||||
}
|
||||
close()
|
||||
const returnUrl = consumePendingReturnUrl()
|
||||
router.push(returnUrl || '/parent')
|
||||
|
||||
@@ -186,3 +186,25 @@ export async function isSubscribedToPush(): Promise<boolean> {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/** localStorage key used to persist the user's explicit push opt-out across logins. */
|
||||
const PUSH_OPT_OUT_KEY = 'push_opt_out'
|
||||
|
||||
/**
|
||||
* Persist or clear the user's push opt-out preference in localStorage.
|
||||
* Call with `true` when the user disables push, `false` when they enable it.
|
||||
*/
|
||||
export function setPushOptOut(optOut: boolean): void {
|
||||
if (optOut) {
|
||||
localStorage.setItem(PUSH_OPT_OUT_KEY, '1')
|
||||
} else {
|
||||
localStorage.removeItem(PUSH_OPT_OUT_KEY)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the user has explicitly opted out of push notifications.
|
||||
*/
|
||||
export function isPushOptedOut(): boolean {
|
||||
return localStorage.getItem(PUSH_OPT_OUT_KEY) === '1'
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user