File size: 6,262 Bytes
cd99321
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
import { create } from 'zustand'
import { inAppNotificationsApi } from '../api/client'

// The server contract (@trek/shared `inAppListResultSchema`) deliberately keeps
// each notification row as an open record — the registry-derived shape varies by
// type. This is the client's structured view of that row; the list/unread-count
// responses themselves are now typed + DEV-validated via inAppNotificationsApi.
export interface InAppNotification {
  id: number
  type: 'simple' | 'boolean' | 'navigate'
  scope: 'trip' | 'user' | 'admin'
  target: number
  sender_id: number | null
  sender_username: string | null
  sender_avatar: string | null
  recipient_id: number
  title_key: string
  title_params: Record<string, string>
  text_key: string
  text_params: Record<string, string>
  positive_text_key: string | null
  negative_text_key: string | null
  response: 'positive' | 'negative' | null
  navigate_text_key: string | null
  navigate_target: string | null
  is_read: boolean
  created_at: string
}

interface RawNotification extends Omit<InAppNotification, 'title_params' | 'text_params' | 'is_read'> {
  title_params: string | Record<string, string>
  text_params: string | Record<string, string>
  is_read: number | boolean
}

function normalizeNotification(raw: RawNotification): InAppNotification {
  return {
    ...raw,
    title_params: typeof raw.title_params === 'string' ? JSON.parse(raw.title_params || '{}') : raw.title_params,
    text_params: typeof raw.text_params === 'string' ? JSON.parse(raw.text_params || '{}') : raw.text_params,
    is_read: Boolean(raw.is_read),
  }
}

interface NotificationState {
  notifications: InAppNotification[]
  unreadCount: number
  total: number
  isLoading: boolean
  hasMore: boolean

  fetchNotifications: (reset?: boolean) => Promise<void>
  fetchUnreadCount: () => Promise<void>
  markRead: (id: number) => Promise<void>
  markUnread: (id: number) => Promise<void>
  markAllRead: () => Promise<void>
  deleteNotification: (id: number) => Promise<void>
  deleteAll: () => Promise<void>
  respondToBoolean: (id: number, response: 'positive' | 'negative') => Promise<void>

  handleNewNotification: (notification: RawNotification) => void
  handleUpdatedNotification: (notification: RawNotification) => void
}

const PAGE_SIZE = 20

export const useInAppNotificationStore = create<NotificationState>((set, get) => ({
  notifications: [],
  unreadCount: 0,
  total: 0,
  isLoading: false,
  hasMore: false,

  fetchNotifications: async (reset = false) => {
    const { notifications, isLoading } = get()
    if (isLoading) return

    set({ isLoading: true })
    try {
      const offset = reset ? 0 : notifications.length
      const data = await inAppNotificationsApi.list({ limit: PAGE_SIZE, offset })
      const normalized = (data.notifications as unknown as RawNotification[]).map(normalizeNotification)

      set({
        notifications: reset ? normalized : [...notifications, ...normalized],
        total: data.total,
        unreadCount: data.unread_count,
        hasMore: (reset ? normalized.length : notifications.length + normalized.length) < data.total,
        isLoading: false,
      })
    } catch {
      set({ isLoading: false })
    }
  },

  fetchUnreadCount: async () => {
    try {
      const data = await inAppNotificationsApi.unreadCount()
      set({ unreadCount: data.count })
    } catch {
      // best-effort
    }
  },

  markRead: async (id: number) => {
    try {
      await inAppNotificationsApi.markRead(id)
      set(state => ({
        notifications: state.notifications.map(n => n.id === id ? { ...n, is_read: true } : n),
        unreadCount: Math.max(0, state.unreadCount - (state.notifications.find(n => n.id === id)?.is_read ? 0 : 1)),
      }))
    } catch {
      // best-effort
    }
  },

  markUnread: async (id: number) => {
    try {
      await inAppNotificationsApi.markUnread(id)
      set(state => ({
        notifications: state.notifications.map(n => n.id === id ? { ...n, is_read: false } : n),
        unreadCount: state.unreadCount + (state.notifications.find(n => n.id === id)?.is_read ? 1 : 0),
      }))
    } catch {
      // best-effort
    }
  },

  markAllRead: async () => {
    try {
      await inAppNotificationsApi.markAllRead()
      set(state => ({
        notifications: state.notifications.map(n => ({ ...n, is_read: true })),
        unreadCount: 0,
      }))
    } catch {
      // best-effort
    }
  },

  deleteNotification: async (id: number) => {
    const notification = get().notifications.find(n => n.id === id)
    try {
      await inAppNotificationsApi.delete(id)
      set(state => ({
        notifications: state.notifications.filter(n => n.id !== id),
        total: Math.max(0, state.total - 1),
        unreadCount: notification && !notification.is_read ? Math.max(0, state.unreadCount - 1) : state.unreadCount,
      }))
    } catch {
      // best-effort
    }
  },

  deleteAll: async () => {
    try {
      await inAppNotificationsApi.deleteAll()
      set({ notifications: [], total: 0, unreadCount: 0, hasMore: false })
    } catch {
      // best-effort
    }
  },

  respondToBoolean: async (id: number, response: 'positive' | 'negative') => {
    try {
      const data = await inAppNotificationsApi.respond(id, response)
      if (data.notification) {
        const normalized = normalizeNotification(data.notification as RawNotification)
        set(state => ({
          notifications: state.notifications.map(n => n.id === id ? normalized : n),
          unreadCount: !state.notifications.find(n => n.id === id)?.is_read
            ? Math.max(0, state.unreadCount - 1)
            : state.unreadCount,
        }))
      }
    } catch {
      // best-effort
    }
  },

  handleNewNotification: (raw: RawNotification) => {
    const notification = normalizeNotification(raw)
    set(state => ({
      notifications: [notification, ...state.notifications],
      total: state.total + 1,
      unreadCount: state.unreadCount + 1,
    }))
  },

  handleUpdatedNotification: (raw: RawNotification) => {
    const notification = normalizeNotification(raw)
    set(state => ({
      notifications: state.notifications.map(n => n.id === notification.id ? notification : n),
    }))
  },
}))