@allbluecn/web-app 0.4.17 → 0.4.18
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +10 -10
- package/src/components/billing/checkout-redirect.ts +1 -1
- package/src/components/changelog/changelog-page.tsx +1 -1
- package/src/components/layout/sidebar-03/app-sidebar.tsx +2 -3
- package/src/components/layout/sidebar-03/nav-notifications.tsx +115 -35
- package/src/components/layout/sidebar-03/user-menu.tsx +1 -1
- package/src/components/notifications/notification-list.tsx +232 -0
- package/src/components/notifications/notification-preferences.tsx +156 -0
- package/src/components/settings/ai/create-custom-provider-dialog.tsx +145 -0
- package/src/components/settings/ai/provider-grid.tsx +46 -25
- package/src/components/settings/personal-team/team/team-client.tsx +118 -15
- package/src/components/settings/personal-team/team/team-invitations.tsx +887 -416
- package/src/components/settings/personal-team/team/team-member-list.tsx +122 -136
- package/src/hooks/__tests__/use-notifications.test.ts +41 -0
- package/src/hooks/use-notifications.ts +119 -0
- package/src/lib/app-version.ts +4 -1
- package/src/lib/auth/auth.config.ts +17 -1
- package/src/lib/changelog/sdk-versions-types.ts +1 -0
- package/src/lib/changelog/sdk-versions.ts +29 -19
- package/src/lib/collections/ai/index.ts +0 -1
- package/src/lib/collections/index.ts +0 -1
- package/src/plugins/.gen-manifest.json +13 -0
- package/src/plugins/modules.gen.ts +20 -0
- package/src/plugins/plugins.gen.ts +20 -0
- package/src/plugins/ui.gen.ts +10 -0
- package/src/routeTree.gen.ts +70 -0
- package/src/routes/_login/login/route.tsx +10 -1
- package/src/routes/_login/register/route.lazy.tsx +3 -2
- package/src/routes/_login/register/route.tsx +5 -0
- package/src/routes/_nav/inspiration/route.tsx +8 -0
- package/src/routes/_nav/notifications/route.lazy.tsx +23 -0
- package/src/routes/_nav/notifications/route.tsx +8 -0
- package/src/routes/_nav/prd/$id/route.tsx +9 -0
- package/src/routes/_nav/prd/index.tsx +9 -0
- package/src/routes/_nav/prd/route.tsx +8 -0
- package/src/routes/_nav/settings/-settings-tabs.tsx +2 -1
- package/src/routes/_nav/settings/design/route.lazy.tsx +1 -1
- package/src/routes/_nav/settings/notifications/route.lazy.tsx +37 -0
- package/src/routes/_nav/settings/notifications/route.tsx +20 -0
- package/src/routes/_nav/settings/privacy/route.lazy.tsx +2 -2
- package/src/routes/_nav/settings/route.lazy.tsx +1 -1
- package/src/routes/_nav/tags/$id/route.tsx +8 -0
- package/src/routes/_nav/tags/index.tsx +8 -0
- package/src/routes/_nav/tags/route.tsx +8 -0
- package/src/routes/_nav/tags/settings/route.tsx +9 -0
- package/src/routes/api/notifications/stream.ts +88 -0
- package/src/routes/invite/$token/route.lazy.tsx +308 -44
- package/src/routes/invite/$token/route.tsx +16 -8
- package/src/routes/share/prd/$prdId/route.tsx +10 -0
- package/src/server/notifications/__tests__/notification-service.test.ts +114 -0
- package/src/server/notifications/__tests__/stream.test.ts +30 -0
- package/src/server/notifications/event-bus.ts +16 -0
- package/src/server/notifications/notification-service.ts +86 -0
- package/src/server/resources.ts +10 -2
- package/src/server/serverFns/__tests__/compliance.test.ts +6 -6
- package/src/server/serverFns/__tests__/notifications.test.ts +63 -0
- package/src/server/serverFns/__tests__/team-accept.test.ts +100 -12
- package/src/server/serverFns/__tests__/team-invitation.test.ts +130 -60
- package/src/server/serverFns/__tests__/team-join-request.test.ts +281 -0
- package/src/server/serverFns/__tests__/team-members.test.ts +39 -118
- package/src/{lib/collections/ai/ai-providers-collection.ts → server/serverFns/auth/setup.ts} +7 -12
- package/src/server/serverFns/billing.ts +23 -2
- package/src/server/serverFns/notifications.ts +155 -0
- package/src/server/serverFns/story/story-comments.ts +42 -0
- package/src/server/serverFns/team.ts +573 -53
- package/src/start.ts +23 -1
- package/vite.shared.ts +13 -0
|
@@ -33,10 +33,21 @@ vi.mock('@allbluecn/database', () => ({
|
|
|
33
33
|
teamMembership: {
|
|
34
34
|
findUnique: vi.fn(),
|
|
35
35
|
findMany: vi.fn(),
|
|
36
|
+
findFirst: vi.fn(),
|
|
36
37
|
delete: vi.fn(),
|
|
38
|
+
create: vi.fn(),
|
|
37
39
|
count: vi.fn(),
|
|
38
40
|
},
|
|
39
|
-
teamInvitation: {
|
|
41
|
+
teamInvitation: {
|
|
42
|
+
create: vi.fn(),
|
|
43
|
+
findFirst: vi.fn(),
|
|
44
|
+
findUnique: vi.fn(),
|
|
45
|
+
updateMany: vi.fn(),
|
|
46
|
+
findMany: vi.fn(),
|
|
47
|
+
update: vi.fn(),
|
|
48
|
+
count: vi.fn(),
|
|
49
|
+
deleteMany: vi.fn(),
|
|
50
|
+
},
|
|
40
51
|
projectMember: { deleteMany: vi.fn() },
|
|
41
52
|
$transaction: vi.fn(),
|
|
42
53
|
},
|
|
@@ -55,17 +66,13 @@ vi.mock('@/server/audit', () => ({
|
|
|
55
66
|
}))
|
|
56
67
|
|
|
57
68
|
import { prisma } from '@allbluecn/database'
|
|
58
|
-
import {
|
|
59
|
-
listTeamMembersFn,
|
|
60
|
-
removeTeamMemberFn,
|
|
61
|
-
leaveTeamFn,
|
|
62
|
-
listTeamUsersFn,
|
|
63
|
-
} from '../team'
|
|
69
|
+
import { listTeamMembersFn } from '../team'
|
|
64
70
|
|
|
65
71
|
describe('listTeamMembersFn', () => {
|
|
66
72
|
beforeEach(() => vi.clearAllMocks())
|
|
67
73
|
|
|
68
|
-
it('
|
|
74
|
+
it('已有 membership → 正常列出成员', async () => {
|
|
75
|
+
vi.mocked(prisma.teamMembership.findUnique).mockResolvedValue({ id: "m1", ownerId: "owner1" } as never)
|
|
69
76
|
vi.mocked(prisma.teamMembership.findMany).mockResolvedValue([
|
|
70
77
|
{
|
|
71
78
|
id: 'm1',
|
|
@@ -78,124 +85,38 @@ describe('listTeamMembersFn', () => {
|
|
|
78
85
|
] as never)
|
|
79
86
|
const result = await listTeamMembersFn()
|
|
80
87
|
expect(result).toHaveLength(1)
|
|
81
|
-
expect(result[0].seatType).toBe('member')
|
|
88
|
+
expect((result as any)[0].seatType).toBe('member')
|
|
89
|
+
expect((result as any)[0].memberId).toBe('u2')
|
|
82
90
|
})
|
|
83
|
-
})
|
|
84
|
-
|
|
85
|
-
describe('removeTeamMemberFn', () => {
|
|
86
|
-
beforeEach(() => vi.clearAllMocks())
|
|
87
|
-
|
|
88
|
-
it('owner 移除成员 → 事务删 membership + 级联删 ProjectMember', async () => {
|
|
89
|
-
vi.mocked(prisma.teamMembership.findUnique).mockResolvedValue({
|
|
90
|
-
id: 'm1',
|
|
91
|
-
ownerId: 'owner1',
|
|
92
|
-
memberId: 'u2',
|
|
93
|
-
seatType: 'member',
|
|
94
|
-
} as never)
|
|
95
|
-
let capturedTx: any
|
|
96
|
-
vi.mocked(prisma.$transaction).mockImplementation(async (fn: any) => {
|
|
97
|
-
capturedTx = {
|
|
98
|
-
teamMembership: { delete: vi.fn().mockResolvedValue({}) },
|
|
99
|
-
projectMember: { deleteMany: vi.fn().mockResolvedValue({ count: 3 }) },
|
|
100
|
-
}
|
|
101
|
-
return fn(capturedTx)
|
|
102
|
-
})
|
|
103
|
-
const result = await removeTeamMemberFn({ data: { membershipId: 'm1' } })
|
|
104
|
-
expect(result.success).toBe(true)
|
|
105
|
-
expect(capturedTx.projectMember.deleteMany).toHaveBeenCalledWith(
|
|
106
|
-
expect.objectContaining({ where: { userId: 'u2' } }),
|
|
107
|
-
)
|
|
108
|
-
})
|
|
109
|
-
|
|
110
|
-
it('非 owner 移除 → 拒绝', async () => {
|
|
111
|
-
vi.mocked(prisma.teamMembership.findUnique).mockResolvedValue({
|
|
112
|
-
id: 'm1',
|
|
113
|
-
ownerId: 'other',
|
|
114
|
-
memberId: 'u2',
|
|
115
|
-
} as never)
|
|
116
|
-
await expect(removeTeamMemberFn({ data: { membershipId: 'm1' } }))
|
|
117
|
-
.rejects.toMatchObject({ message: expect.stringContaining('权限') })
|
|
118
|
-
})
|
|
119
|
-
|
|
120
|
-
it('成员不存在 → 拒绝', async () => {
|
|
121
|
-
vi.mocked(prisma.teamMembership.findUnique).mockResolvedValue(null)
|
|
122
|
-
await expect(removeTeamMemberFn({ data: { membershipId: 'm999' } }))
|
|
123
|
-
.rejects.toMatchObject({ message: expect.stringContaining('不存在') })
|
|
124
|
-
})
|
|
125
|
-
})
|
|
126
|
-
|
|
127
|
-
describe('leaveTeamFn', () => {
|
|
128
|
-
beforeEach(() => vi.clearAllMocks())
|
|
129
91
|
|
|
130
|
-
it('
|
|
92
|
+
it('无 membership → 自动创建 owner 记录', async () => {
|
|
131
93
|
vi.mocked(prisma.teamMembership.findUnique).mockResolvedValue(null)
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
it('已加入团队 → 退出并级联删 ProjectMember', async () => {
|
|
136
|
-
vi.mocked(prisma.teamMembership.findUnique).mockResolvedValue({
|
|
137
|
-
id: 'm1',
|
|
94
|
+
vi.mocked(prisma.teamMembership.create).mockResolvedValue({
|
|
95
|
+
id: 'm-new',
|
|
138
96
|
ownerId: 'owner1',
|
|
139
97
|
memberId: 'owner1',
|
|
98
|
+
seatType: 'owner',
|
|
99
|
+
joinedAt: new Date(),
|
|
100
|
+
member: { id: 'owner1', username: 'A', email: 'a@x.com', avatarConfig: null, bgShape: null, bgColor: null },
|
|
140
101
|
} as never)
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
}
|
|
147
|
-
return fn(capturedTx)
|
|
148
|
-
})
|
|
149
|
-
const result = await leaveTeamFn()
|
|
150
|
-
expect(result.success).toBe(true)
|
|
151
|
-
expect(capturedTx.projectMember.deleteMany).toHaveBeenCalledWith(
|
|
152
|
-
expect.objectContaining({ where: { userId: 'owner1' } }),
|
|
102
|
+
const result = await listTeamMembersFn()
|
|
103
|
+
expect(prisma.teamMembership.create).toHaveBeenCalledWith(
|
|
104
|
+
expect.objectContaining({
|
|
105
|
+
data: expect.objectContaining({ ownerId: 'owner1', memberId: 'owner1', seatType: 'owner' }),
|
|
106
|
+
}),
|
|
153
107
|
)
|
|
154
|
-
|
|
155
|
-
})
|
|
156
|
-
|
|
157
|
-
describe('listTeamUsersFn', () => {
|
|
158
|
-
beforeEach(() => vi.clearAllMocks())
|
|
159
|
-
|
|
160
|
-
it('返回 owner + active 成员(含 seatType)', async () => {
|
|
161
|
-
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
|
162
|
-
id: 'owner1',
|
|
163
|
-
username: 'A',
|
|
164
|
-
email: 'a@x.com',
|
|
165
|
-
avatarConfig: null,
|
|
166
|
-
bgShape: null,
|
|
167
|
-
bgColor: null,
|
|
168
|
-
} as never)
|
|
169
|
-
vi.mocked(prisma.teamMembership.findUnique).mockResolvedValue({
|
|
170
|
-
ownerId: 'owner1',
|
|
171
|
-
memberId: 'owner1',
|
|
172
|
-
} as never)
|
|
173
|
-
vi.mocked(prisma.teamMembership.findMany).mockResolvedValue([
|
|
174
|
-
{ seatType: 'member', member: { id: 'u2', username: 'B', email: 'b@x.com', avatarConfig: null, bgShape: null, bgColor: null } },
|
|
175
|
-
{ seatType: 'guest', member: { id: 'u3', username: 'C', email: 'c@x.com', avatarConfig: null, bgShape: null, bgColor: null } },
|
|
176
|
-
] as never)
|
|
177
|
-
const result = await listTeamUsersFn()
|
|
178
|
-
expect(result).toHaveLength(3)
|
|
108
|
+
expect(result).toHaveLength(1)
|
|
179
109
|
expect(result[0].seatType).toBe('owner')
|
|
180
|
-
expect(result[0].
|
|
181
|
-
expect(result[1].seatType).toBe('member')
|
|
182
|
-
expect(result[2].seatType).toBe('guest')
|
|
110
|
+
expect(result[0].memberId).toBe('owner1')
|
|
183
111
|
})
|
|
184
112
|
|
|
185
|
-
it('
|
|
186
|
-
vi.mocked(prisma.
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
} as never)
|
|
194
|
-
vi.mocked(prisma.teamMembership.findUnique).mockResolvedValue(null)
|
|
195
|
-
vi.mocked(prisma.teamMembership.findMany).mockResolvedValue([] as never)
|
|
196
|
-
const result = await listTeamUsersFn()
|
|
197
|
-
expect(result).toHaveLength(1)
|
|
198
|
-
expect(result[0].seatType).toBe('owner')
|
|
199
|
-
expect(result[0].username).toBe('Solo')
|
|
113
|
+
it('已有 membership → 不创建,直接查询', async () => {
|
|
114
|
+
vi.mocked(prisma.teamMembership.findUnique).mockResolvedValue({ id: "m1", ownerId: "owner1" } as never)
|
|
115
|
+
vi.mocked(prisma.teamMembership.findMany).mockResolvedValue([])
|
|
116
|
+
await listTeamMembersFn()
|
|
117
|
+
expect(prisma.teamMembership.create).not.toHaveBeenCalled()
|
|
118
|
+
expect(prisma.teamMembership.findMany).toHaveBeenCalledWith(
|
|
119
|
+
expect.objectContaining({ where: { ownerId: 'owner1' } }),
|
|
120
|
+
)
|
|
200
121
|
})
|
|
201
|
-
})
|
|
122
|
+
})
|
package/src/{lib/collections/ai/ai-providers-collection.ts → server/serverFns/auth/setup.ts}
RENAMED
|
@@ -15,17 +15,12 @@
|
|
|
15
15
|
// You should have received a copy of the GNU Affero General Public License
|
|
16
16
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
17
17
|
|
|
18
|
-
import {
|
|
19
|
-
import {
|
|
20
|
-
import { listAiProvidersFn } from '@/server/serverFns/ai/providers'
|
|
21
|
-
import { getQueryClient } from '@allbluecn/shared/lib/query-client-seam'
|
|
18
|
+
import { createServerFn } from '@tanstack/react-start'
|
|
19
|
+
import { prisma } from '@allbluecn/database'
|
|
22
20
|
|
|
23
|
-
export const
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
queryKey: ['ai-providers'],
|
|
27
|
-
queryFn: () => listAiProvidersFn(),
|
|
28
|
-
getKey: (item) => item.meta.providerId,
|
|
29
|
-
queryClient: getQueryClient(),
|
|
21
|
+
export const checkAdminExistsFn = createServerFn({ method: 'GET' }).handler(async () => {
|
|
22
|
+
const count = await prisma.user.count({
|
|
23
|
+
where: { role: 'SUPER_ADMIN' },
|
|
30
24
|
})
|
|
31
|
-
|
|
25
|
+
return { hasAdmin: count > 0 }
|
|
26
|
+
})
|
|
@@ -32,6 +32,17 @@ const requireBilling = () => {
|
|
|
32
32
|
return billing
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
async function emitBillingNotification(userId: string, type: string, title: string, body: string, link?: string) {
|
|
36
|
+
void (async () => {
|
|
37
|
+
try {
|
|
38
|
+
const { createNotification } = await import('@/server/notifications/notification-service')
|
|
39
|
+
await createNotification({ userId, category: 'billing', type, title, body, link })
|
|
40
|
+
} catch (e) {
|
|
41
|
+
console.error('[billing] 通知发送失败:', e instanceof Error ? e.message : 'unknown')
|
|
42
|
+
}
|
|
43
|
+
})()
|
|
44
|
+
}
|
|
45
|
+
|
|
35
46
|
export const getEffectivePlanFn = createServerFn({ method: 'GET' }).handler(async () => {
|
|
36
47
|
const request = getRequest()
|
|
37
48
|
const session = await requireAuth(request)
|
|
@@ -92,10 +103,12 @@ export const createCustomerPortalSessionFn = createServerFn({ method: 'POST' }).
|
|
|
92
103
|
const userId = session.user!.id!
|
|
93
104
|
|
|
94
105
|
const baseUrl = process.env.VITE_APP_URL ?? 'http://localhost:3000'
|
|
95
|
-
|
|
106
|
+
const result = await requireBilling().createCustomerPortalSession({
|
|
96
107
|
userId,
|
|
97
108
|
returnUrl: `${baseUrl}/settings/billing`,
|
|
98
109
|
})
|
|
110
|
+
await emitBillingNotification(userId, 'billing.portal_access', '客户门户已打开', '您已打开管理订阅的客户门户', '/settings/billing')
|
|
111
|
+
return result
|
|
99
112
|
},
|
|
100
113
|
)
|
|
101
114
|
|
|
@@ -159,9 +172,17 @@ export const updateSeatsFn = createServerFn({ method: 'POST' })
|
|
|
159
172
|
)
|
|
160
173
|
}
|
|
161
174
|
|
|
162
|
-
|
|
175
|
+
const result = await requireBilling().updateSeats({
|
|
163
176
|
userId,
|
|
164
177
|
addonSeats: data.addonSeats,
|
|
165
178
|
successUrl: data.successUrl ?? `${baseUrl}/settings/billing?checkout=success`,
|
|
166
179
|
})
|
|
180
|
+
await emitBillingNotification(
|
|
181
|
+
userId,
|
|
182
|
+
'billing.seats_changed',
|
|
183
|
+
'席位变更',
|
|
184
|
+
`席位已调整为 ${subscription.plan.maxSeats + data.addonSeats}`,
|
|
185
|
+
'/settings/billing',
|
|
186
|
+
)
|
|
187
|
+
return result
|
|
167
188
|
})
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
import { createServerFn } from '@tanstack/react-start'
|
|
3
|
+
import { getRequest } from '@tanstack/react-start/server'
|
|
4
|
+
import { z } from 'zod'
|
|
5
|
+
import { prisma } from '@allbluecn/database'
|
|
6
|
+
import { requireAuth } from '@/server/auth-helpers'
|
|
7
|
+
import {
|
|
8
|
+
NOTIFICATION_CATEGORIES,
|
|
9
|
+
type NotificationCategory,
|
|
10
|
+
type CategoryPreferences,
|
|
11
|
+
type NotificationItem,
|
|
12
|
+
type NotificationListResult,
|
|
13
|
+
} from '@allbluecn/shared'
|
|
14
|
+
import {
|
|
15
|
+
getOrCreatePreferences,
|
|
16
|
+
DEFAULT_CATEGORIES,
|
|
17
|
+
} from '@/server/notifications/notification-service'
|
|
18
|
+
|
|
19
|
+
const PAGE_SIZE = 20
|
|
20
|
+
|
|
21
|
+
const listSchema = z.object({
|
|
22
|
+
category: z.enum(NOTIFICATION_CATEGORIES).optional(),
|
|
23
|
+
cursor: z.string().optional(),
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
export const listNotificationsFn = createServerFn({ method: 'GET' })
|
|
27
|
+
.validator(listSchema)
|
|
28
|
+
.handler(async ({ data }): Promise<NotificationListResult> => {
|
|
29
|
+
const request = getRequest()
|
|
30
|
+
const session = await requireAuth(request)
|
|
31
|
+
const userId = session.user!.id!
|
|
32
|
+
|
|
33
|
+
const items = await prisma.notification.findMany({
|
|
34
|
+
where: {
|
|
35
|
+
userId,
|
|
36
|
+
...(data.category ? { category: data.category } : {}),
|
|
37
|
+
},
|
|
38
|
+
orderBy: { createdAt: 'desc' },
|
|
39
|
+
take: PAGE_SIZE + 1,
|
|
40
|
+
...(data.cursor ? { cursor: { id: data.cursor }, skip: 1 } : {}),
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
const hasMore = items.length > PAGE_SIZE
|
|
44
|
+
const page = (hasMore ? items.slice(0, PAGE_SIZE) : items).map(
|
|
45
|
+
(n): NotificationItem => ({
|
|
46
|
+
id: n.id,
|
|
47
|
+
category: n.category as NotificationCategory,
|
|
48
|
+
type: n.type,
|
|
49
|
+
title: n.title,
|
|
50
|
+
body: n.body,
|
|
51
|
+
link: n.link,
|
|
52
|
+
readAt: n.readAt ? n.readAt.toISOString() : null,
|
|
53
|
+
createdAt: n.createdAt.toISOString(),
|
|
54
|
+
}),
|
|
55
|
+
)
|
|
56
|
+
return { items: page, total: page.length, hasMore }
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
const markReadSchema = z.object({ id: z.string().min(1) })
|
|
60
|
+
|
|
61
|
+
export const markNotificationReadFn = createServerFn({ method: 'POST' })
|
|
62
|
+
.validator(markReadSchema)
|
|
63
|
+
.handler(async ({ data }) => {
|
|
64
|
+
const request = getRequest()
|
|
65
|
+
const session = await requireAuth(request)
|
|
66
|
+
const userId = session.user!.id!
|
|
67
|
+
await prisma.notification.updateMany({
|
|
68
|
+
where: { id: data.id, userId, readAt: null },
|
|
69
|
+
data: { readAt: new Date() },
|
|
70
|
+
})
|
|
71
|
+
return { success: true }
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
export const markAllNotificationsReadFn = createServerFn({ method: 'POST' })
|
|
75
|
+
.validator(z.object({}).optional())
|
|
76
|
+
.handler(async () => {
|
|
77
|
+
const request = getRequest()
|
|
78
|
+
const session = await requireAuth(request)
|
|
79
|
+
const userId = session.user!.id!
|
|
80
|
+
const result = await prisma.notification.updateMany({
|
|
81
|
+
where: { userId, readAt: null },
|
|
82
|
+
data: { readAt: new Date() },
|
|
83
|
+
})
|
|
84
|
+
return { success: true, count: result.count }
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
const deleteSchema = z.object({ id: z.string().min(1) })
|
|
88
|
+
|
|
89
|
+
export const deleteNotificationFn = createServerFn({ method: 'POST' })
|
|
90
|
+
.validator(deleteSchema)
|
|
91
|
+
.handler(async ({ data }) => {
|
|
92
|
+
const request = getRequest()
|
|
93
|
+
const session = await requireAuth(request)
|
|
94
|
+
const userId = session.user!.id!
|
|
95
|
+
await prisma.notification.deleteMany({ where: { id: data.id, userId } })
|
|
96
|
+
return { success: true }
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
export const getUnreadCountFn = createServerFn({ method: 'GET' })
|
|
100
|
+
.validator(z.object({}).optional())
|
|
101
|
+
.handler(async () => {
|
|
102
|
+
const request = getRequest()
|
|
103
|
+
const session = await requireAuth(request)
|
|
104
|
+
const userId = session.user!.id!
|
|
105
|
+
const count = await prisma.notification.count({
|
|
106
|
+
where: { userId, readAt: null },
|
|
107
|
+
})
|
|
108
|
+
return { count }
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
export const getNotificationPreferencesFn = createServerFn({ method: 'GET' })
|
|
112
|
+
.validator(z.object({}).optional())
|
|
113
|
+
.handler(async () => {
|
|
114
|
+
const request = getRequest()
|
|
115
|
+
const session = await requireAuth(request)
|
|
116
|
+
return getOrCreatePreferences(session.user!.id!)
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
const preferencesSchema = z.object({
|
|
120
|
+
categories: z.record(z.string(), z.boolean()),
|
|
121
|
+
dndEnabled: z.boolean(),
|
|
122
|
+
dndStart: z.string().regex(/^\d{2}:\d{2}$/),
|
|
123
|
+
dndEnd: z.string().regex(/^\d{2}:\d{2}$/),
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
export const updateNotificationPreferencesFn = createServerFn({
|
|
127
|
+
method: 'POST',
|
|
128
|
+
})
|
|
129
|
+
.validator(preferencesSchema)
|
|
130
|
+
.handler(async ({ data }) => {
|
|
131
|
+
const request = getRequest()
|
|
132
|
+
const session = await requireAuth(request)
|
|
133
|
+
const userId = session.user!.id!
|
|
134
|
+
const categories = { ...DEFAULT_CATEGORIES } as CategoryPreferences
|
|
135
|
+
for (const c of NOTIFICATION_CATEGORIES) {
|
|
136
|
+
if (typeof data.categories[c] === 'boolean') categories[c] = data.categories[c]
|
|
137
|
+
}
|
|
138
|
+
await prisma.notificationPreference.upsert({
|
|
139
|
+
where: { userId },
|
|
140
|
+
create: {
|
|
141
|
+
userId,
|
|
142
|
+
categories,
|
|
143
|
+
dndEnabled: data.dndEnabled,
|
|
144
|
+
dndStart: data.dndStart,
|
|
145
|
+
dndEnd: data.dndEnd,
|
|
146
|
+
},
|
|
147
|
+
update: {
|
|
148
|
+
categories,
|
|
149
|
+
dndEnabled: data.dndEnabled,
|
|
150
|
+
dndStart: data.dndStart,
|
|
151
|
+
dndEnd: data.dndEnd,
|
|
152
|
+
},
|
|
153
|
+
})
|
|
154
|
+
return { success: true }
|
|
155
|
+
})
|
|
@@ -22,6 +22,7 @@ import { prisma } from "@allbluecn/database";
|
|
|
22
22
|
import { requireAuth } from "@/server/auth-helpers";
|
|
23
23
|
import type { CommentWithReplies, ReactionGroup } from "@/components/story/types";
|
|
24
24
|
import { USER_BRIEF_SELECT, castUserBrief, aggregateReactions } from "./types";
|
|
25
|
+
import { createNotification } from '@/server/notifications/notification-service'
|
|
25
26
|
|
|
26
27
|
export const listCommentsFn = createServerFn({ method: "GET" })
|
|
27
28
|
.validator(z.object({ storyId: z.string() }))
|
|
@@ -101,6 +102,47 @@ export const createCommentFn = createServerFn({ method: "POST" })
|
|
|
101
102
|
},
|
|
102
103
|
});
|
|
103
104
|
|
|
105
|
+
const commenter = await prisma.user.findUnique({
|
|
106
|
+
where: { id: userId },
|
|
107
|
+
select: { username: true },
|
|
108
|
+
})
|
|
109
|
+
const story = await prisma.story.findUnique({
|
|
110
|
+
where: { id: data.storyId },
|
|
111
|
+
select: { createdById: true },
|
|
112
|
+
})
|
|
113
|
+
if (story?.createdById && story.createdById !== userId) {
|
|
114
|
+
void createNotification({
|
|
115
|
+
userId: story.createdById,
|
|
116
|
+
category: 'collaboration',
|
|
117
|
+
type: 'collaboration.comment',
|
|
118
|
+
title: `${commenter?.username ?? '有人'} 评论了你的 Story`,
|
|
119
|
+
link: `/stories/${data.storyId}`,
|
|
120
|
+
})
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const mentionMatches = data.content.match(/@([a-zA-Z0-9_\u4e00-\u9fa5]+)/g) ?? []
|
|
124
|
+
if (mentionMatches.length > 0) {
|
|
125
|
+
const mentionedNames = [...new Set(mentionMatches.map((m) => m.slice(1)))].filter(
|
|
126
|
+
(name) => name.toLowerCase() !== commenter?.username?.toLowerCase(),
|
|
127
|
+
)
|
|
128
|
+
if (mentionedNames.length > 0) {
|
|
129
|
+
const mentionedUsers = await prisma.user.findMany({
|
|
130
|
+
where: { username: { in: mentionedNames } },
|
|
131
|
+
select: { id: true },
|
|
132
|
+
})
|
|
133
|
+
for (const u of mentionedUsers) {
|
|
134
|
+
if (u.id === userId) continue
|
|
135
|
+
void createNotification({
|
|
136
|
+
userId: u.id,
|
|
137
|
+
category: 'collaboration',
|
|
138
|
+
type: 'collaboration.mention',
|
|
139
|
+
title: `${commenter?.username ?? '有人'} 在评论中提到了你`,
|
|
140
|
+
link: `/stories/${data.storyId}`,
|
|
141
|
+
})
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
104
146
|
await prisma.storyActivity.create({
|
|
105
147
|
data: {
|
|
106
148
|
storyId: data.storyId,
|