Add routes to save/get project notification preferences (#30114)

* Add routes to save/get project notification preferences

* update route url

* improve zod schema

* remove unused json response

* update schema and fix tests

* add jsdoc types to pass type-check

* remove using zod strict()

GitOrigin-RevId: f3ab5c88b58bd5af71e0504d0efbe03bdf9b243c
This commit is contained in:
Domagoj Kriskovic
2025-12-15 11:33:13 +01:00
committed by Copybot
parent 3b32b0a61e
commit 7b00e5d9f5
4 changed files with 127 additions and 8 deletions

View File

@@ -12,6 +12,8 @@ const { isZodErrorLike } = require('zod-validation-error')
/**
* A helper function to safely get a nested value from an object
* using a path array (e.g., ["query", "resource_type"])
* @param {any} data
* @param {Array<PropertyKey>} path
*/
function getPathValue(data, path) {
let current = data
@@ -24,6 +26,10 @@ function getPathValue(data, path) {
return current
}
/**
* @param {any} issue
* @param {any} value
*/
const isRequiredError = (issue, value) =>
value === undefined &&
(issue.code === 'invalid_type' || issue.code === 'invalid_union')

View File

@@ -1,14 +1,14 @@
import { useTranslation } from 'react-i18next'
import RadioButtonSetting, { RadioOption } from '../radio-button-setting'
import { useState } from 'react'
type NotificationLevel = 'all' | 'replies' | 'off'
import {
NotificationLevel,
useProjectNotificationPreferences,
} from '../../../hooks/use-project-notification-preferences'
export default function ProjectNotificationsSetting() {
const { t } = useTranslation()
// TODO: Connect to project settings context when backend support is added
const [notificationLevel, setNotificationLevel] =
useState<NotificationLevel>('all')
const { notificationLevel, setNotificationLevel, isLoading } =
useProjectNotificationPreferences()
const options: Array<RadioOption<NotificationLevel>> = [
{
@@ -33,7 +33,7 @@ export default function ProjectNotificationsSetting() {
<RadioButtonSetting
id="projectNotifications"
options={options}
value={notificationLevel}
value={isLoading ? undefined : notificationLevel}
onChange={setNotificationLevel}
/>
<div className="project-notifications-beta-note">

View File

@@ -0,0 +1,112 @@
import { useCallback, useEffect, useState } from 'react'
import { useProjectContext } from '@/shared/context/project-context'
import { getJSON, postJSON } from '@/infrastructure/fetch-json'
import { debugConsole } from '@/utils/debugging'
import type { NotificationPreferencesSchema } from '../../../../../modules/notifications/app/src/types.ts'
export type NotificationLevel = 'all' | 'replies' | 'off'
/**
* Map UI notification level to backend preferences
*/
function levelToPreferences(
level: NotificationLevel
): NotificationPreferencesSchema {
switch (level) {
case 'all':
return {
commentOnOwnProject: true,
commentOnInvitedProject: true,
repliesOnOwnProject: true,
repliesOnInvitedProject: true,
repliesOnAuthoredThread: true,
repliesOnParticipatingThread: true,
}
case 'replies':
return {
commentOnOwnProject: false,
commentOnInvitedProject: false,
repliesOnOwnProject: false,
repliesOnInvitedProject: false,
repliesOnAuthoredThread: true,
repliesOnParticipatingThread: true,
}
case 'off':
return {
commentOnOwnProject: false,
commentOnInvitedProject: false,
repliesOnOwnProject: false,
repliesOnInvitedProject: false,
repliesOnAuthoredThread: false,
repliesOnParticipatingThread: false,
}
}
}
/**
* Map backend preferences to UI notification level
*/
function preferencesToLevel(
preferences: NotificationPreferencesSchema
): NotificationLevel {
// If all notifications are off
if (
!preferences.commentOnOwnProject &&
!preferences.commentOnInvitedProject &&
!preferences.repliesOnOwnProject &&
!preferences.repliesOnInvitedProject &&
!preferences.repliesOnAuthoredThread &&
!preferences.repliesOnParticipatingThread
) {
return 'off'
}
// If only reply-related notifications are on
if (
!preferences.commentOnOwnProject &&
!preferences.commentOnInvitedProject &&
(preferences.repliesOnAuthoredThread ||
preferences.repliesOnParticipatingThread)
) {
return 'replies'
}
// Default to 'all' for any other combination
return 'all'
}
export function useProjectNotificationPreferences() {
const { projectId } = useProjectContext()
const [notificationLevel, setNotificationLevel] =
useState<NotificationLevel>('all')
const [isLoading, setIsLoading] = useState(true)
// Load preferences on mount
useEffect(() => {
getJSON<NotificationPreferencesSchema>(
`/notifications/preferences/project/${projectId}`
)
.then(prefs => {
setNotificationLevel(preferencesToLevel(prefs))
})
.catch(debugConsole.error)
.finally(() => setIsLoading(false))
}, [projectId])
const setLevel = useCallback(
(level: NotificationLevel) => {
setNotificationLevel(level)
const preferences = levelToPreferences(level)
postJSON(`/notifications/preferences/project/${projectId}`, {
body: preferences,
}).catch(debugConsole.error)
},
[projectId]
)
return {
notificationLevel,
setNotificationLevel: setLevel,
isLoading,
}
}

View File

@@ -1,4 +1,5 @@
import AbstractMockApi from './AbstractMockApi.mjs'
import { ObjectId } from '../../../../app/src/infrastructure/mongodb.mjs'
class MockChatApi extends AbstractMockApi {
reset() {
@@ -21,7 +22,7 @@ class MockChatApi extends AbstractMockApi {
sendMessage(projectId, threadId, props) {
const message = {
id: Math.random().toString(),
id: new ObjectId().toString(),
content: props.content,
timestamp: Date.now(),
user_id: props.user_id,