@lobehub/lobehub 2.0.0-next.276 → 2.0.0-next.278

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.
Files changed (98) hide show
  1. package/.cursor/rules/db-migrations.mdc +1 -1
  2. package/.cursor/rules/debug-usage.mdc +7 -5
  3. package/.cursor/rules/desktop-controller-tests.mdc +2 -1
  4. package/.cursor/rules/desktop-feature-implementation.mdc +9 -5
  5. package/.cursor/rules/desktop-local-tools-implement.mdc +67 -66
  6. package/.cursor/rules/desktop-menu-configuration.mdc +21 -9
  7. package/.cursor/rules/desktop-window-management.mdc +17 -2
  8. package/.cursor/rules/drizzle-schema-style-guide.mdc +6 -6
  9. package/.cursor/rules/hotkey.mdc +1 -0
  10. package/.cursor/rules/i18n.mdc +1 -0
  11. package/.cursor/rules/project-structure.mdc +16 -3
  12. package/.cursor/rules/react.mdc +17 -5
  13. package/.cursor/rules/recent-data-usage.mdc +2 -1
  14. package/.cursor/rules/testing-guide/testing-guide.mdc +262 -238
  15. package/.cursor/rules/testing-guide/zustand-store-action-test.mdc +1 -1
  16. package/.cursor/rules/zustand-action-patterns.mdc +1 -1
  17. package/.cursor/rules/zustand-slice-organization.mdc +4 -4
  18. package/CHANGELOG.md +51 -0
  19. package/CLAUDE.md +1 -1
  20. package/GEMINI.md +1 -1
  21. package/changelog/v1.json +14 -0
  22. package/docs/development/database-schema.dbml +16 -0
  23. package/locales/en-US/chat.json +24 -0
  24. package/locales/en-US/setting.json +11 -0
  25. package/locales/zh-CN/chat.json +24 -0
  26. package/locales/zh-CN/setting.json +11 -0
  27. package/package.json +1 -1
  28. package/packages/builtin-tool-group-agent-builder/src/client/Inspector/BatchCreateAgents/index.tsx +2 -2
  29. package/packages/builtin-tool-group-agent-builder/src/client/Inspector/UpdateGroup/index.tsx +56 -56
  30. package/packages/builtin-tool-group-agent-builder/src/client/Render/BatchCreateAgents.tsx +3 -2
  31. package/packages/builtin-tool-group-agent-builder/src/executor.ts +2 -1
  32. package/packages/business/const/src/index.ts +3 -0
  33. package/packages/database/migrations/0069_add_topic_shares_table.sql +22 -0
  34. package/packages/database/migrations/meta/0069_snapshot.json +9704 -0
  35. package/packages/database/migrations/meta/_journal.json +7 -0
  36. package/packages/database/src/models/__tests__/topicShare.test.ts +318 -0
  37. package/packages/database/src/models/topicShare.ts +177 -0
  38. package/packages/database/src/schemas/topic.ts +44 -2
  39. package/packages/types/src/agentCronJob/index.ts +19 -23
  40. package/packages/types/src/conversation.ts +5 -0
  41. package/packages/types/src/serverConfig.ts +1 -0
  42. package/packages/types/src/topic/topic.ts +46 -0
  43. package/src/app/[variants]/(main)/agent/_layout/Sidebar/Cron/Actions.tsx +31 -0
  44. package/src/app/[variants]/(main)/agent/_layout/Sidebar/Cron/CronTopicGroup.tsx +10 -6
  45. package/src/app/[variants]/(main)/agent/_layout/Sidebar/Cron/index.tsx +7 -11
  46. package/src/app/[variants]/(main)/agent/_layout/Sidebar/Cron/useDropdownMenu.tsx +102 -0
  47. package/src/app/[variants]/(main)/agent/cron/[cronId]/CronConfig.ts +179 -0
  48. package/src/app/[variants]/(main)/agent/cron/[cronId]/features/CronJobContentEditor.tsx +111 -0
  49. package/src/app/[variants]/(main)/agent/cron/[cronId]/features/CronJobHeader.tsx +45 -0
  50. package/src/app/[variants]/(main)/agent/cron/[cronId]/features/CronJobSaveButton.tsx +31 -0
  51. package/src/app/[variants]/(main)/agent/cron/[cronId]/features/CronJobScheduleConfig.tsx +213 -0
  52. package/src/app/[variants]/(main)/agent/cron/[cronId]/index.tsx +186 -344
  53. package/src/app/[variants]/(main)/agent/features/Conversation/Header/ShareButton/index.tsx +24 -9
  54. package/src/app/[variants]/(main)/agent/profile/features/AgentCronJobs/index.tsx +42 -97
  55. package/src/app/[variants]/(main)/agent/profile/features/ProfileEditor/index.tsx +4 -20
  56. package/src/app/[variants]/(main)/community/features/UserAvatar/index.tsx +15 -5
  57. package/src/app/[variants]/(main)/group/_layout/Sidebar/GroupConfig/AgentProfilePopup.tsx +1 -6
  58. package/src/app/[variants]/(main)/group/features/Conversation/Header/ShareButton/index.tsx +26 -9
  59. package/src/app/[variants]/(main)/image/_layout/ConfigPanel/components/AspectRatioSelect/index.tsx +1 -2
  60. package/src/app/[variants]/(main)/image/_layout/ConfigPanel/components/ImageNum.tsx +54 -173
  61. package/src/app/[variants]/(main)/image/_layout/ConfigPanel/components/ResolutionSelect.tsx +22 -67
  62. package/src/app/[variants]/(mobile)/router/mobileRouter.config.tsx +18 -0
  63. package/src/app/[variants]/router/desktopRouter.config.tsx +18 -0
  64. package/src/app/[variants]/share/t/[id]/SharedMessageList.tsx +54 -0
  65. package/src/app/[variants]/share/t/[id]/_layout/index.tsx +170 -0
  66. package/src/app/[variants]/share/t/[id]/features/Portal/index.tsx +66 -0
  67. package/src/app/[variants]/share/t/[id]/index.tsx +112 -0
  68. package/src/app/robots.tsx +1 -1
  69. package/src/business/client/BusinessMobileRoutes.tsx +1 -1
  70. package/src/features/Conversation/ChatList/index.tsx +12 -5
  71. package/src/features/Conversation/Messages/AssistantGroup/Tool/Render/index.tsx +8 -4
  72. package/src/features/Conversation/Messages/AssistantGroup/Tool/index.tsx +15 -10
  73. package/src/features/Conversation/Messages/AssistantGroup/Tools.tsx +3 -1
  74. package/src/features/Conversation/Messages/AssistantGroup/components/ContentBlock.tsx +3 -2
  75. package/src/features/Conversation/Messages/AssistantGroup/components/GroupItem.tsx +2 -2
  76. package/src/features/Conversation/Messages/Supervisor/components/ContentBlock.tsx +25 -26
  77. package/src/features/Conversation/Messages/Supervisor/components/Group.tsx +4 -2
  78. package/src/features/Conversation/Messages/Tool/Tool/index.tsx +16 -12
  79. package/src/features/Conversation/Messages/Tool/index.tsx +20 -11
  80. package/src/features/Conversation/Messages/index.tsx +1 -1
  81. package/src/features/Conversation/store/slices/data/action.ts +2 -1
  82. package/src/features/SharePopover/index.tsx +215 -0
  83. package/src/features/SharePopover/style.ts +10 -0
  84. package/src/libs/next/proxy/define-config.ts +4 -1
  85. package/src/locales/default/chat.ts +26 -0
  86. package/src/proxy.ts +1 -0
  87. package/src/server/globalConfig/index.ts +1 -0
  88. package/src/server/routers/lambda/__tests__/message.test.ts +152 -0
  89. package/src/server/routers/lambda/__tests__/share.test.ts +227 -0
  90. package/src/server/routers/lambda/__tests__/topic.test.ts +174 -0
  91. package/src/server/routers/lambda/index.ts +2 -0
  92. package/src/server/routers/lambda/message.ts +37 -4
  93. package/src/server/routers/lambda/share.ts +55 -0
  94. package/src/server/routers/lambda/topic.ts +45 -0
  95. package/src/services/chatGroup/index.ts +1 -4
  96. package/src/services/message/index.ts +1 -0
  97. package/src/services/topic/index.ts +16 -0
  98. package/src/store/serverConfig/selectors.ts +1 -0
@@ -0,0 +1,215 @@
1
+ 'use client';
2
+
3
+ import { Button, Flexbox, Popover, copyToClipboard, usePopoverContext } from '@lobehub/ui';
4
+ import { App, Divider, Select, Skeleton, Typography } from 'antd';
5
+ import { CopyIcon, ExternalLinkIcon, LinkIcon, LockIcon } from 'lucide-react';
6
+ import { type ReactNode, memo, useCallback, useEffect, useRef, useState } from 'react';
7
+ import { useTranslation } from 'react-i18next';
8
+ import useSWR from 'swr';
9
+
10
+ import { useIsMobile } from '@/hooks/useIsMobile';
11
+ import { topicService } from '@/services/topic';
12
+ import { useChatStore } from '@/store/chat';
13
+
14
+ import { useStyles } from './style';
15
+
16
+ type Visibility = 'private' | 'link';
17
+
18
+ interface SharePopoverContentProps {
19
+ onOpenModal?: () => void;
20
+ }
21
+
22
+ const SharePopoverContent = memo<SharePopoverContentProps>(({ onOpenModal }) => {
23
+ const { t } = useTranslation('chat');
24
+ const { message, modal } = App.useApp();
25
+ const { styles } = useStyles();
26
+ const [updating, setUpdating] = useState(false);
27
+ const { close } = usePopoverContext();
28
+ const containerRef = useRef<HTMLDivElement>(null);
29
+
30
+ const activeTopicId = useChatStore((s) => s.activeTopicId);
31
+
32
+ const {
33
+ data: shareInfo,
34
+ isLoading,
35
+ mutate,
36
+ } = useSWR(
37
+ activeTopicId ? ['topic-share-info', activeTopicId] : null,
38
+ () => topicService.getShareInfo(activeTopicId!),
39
+ { revalidateOnFocus: false },
40
+ );
41
+
42
+ // Auto-create share record if not exists
43
+ useEffect(() => {
44
+ if (!isLoading && !shareInfo && activeTopicId) {
45
+ topicService.enableSharing(activeTopicId, 'private').then(() => mutate());
46
+ }
47
+ }, [isLoading, shareInfo, activeTopicId, mutate]);
48
+
49
+ const shareUrl = shareInfo?.id ? `${window.location.origin}/share/t/${shareInfo.id}` : '';
50
+ const currentVisibility = (shareInfo?.visibility as Visibility) || 'private';
51
+
52
+ const updateVisibility = useCallback(
53
+ async (visibility: Visibility) => {
54
+ if (!activeTopicId) return;
55
+
56
+ setUpdating(true);
57
+ try {
58
+ await topicService.updateShareVisibility(activeTopicId, visibility);
59
+ await mutate();
60
+ message.success(t('shareModal.link.visibilityUpdated'));
61
+ } catch {
62
+ message.error(t('shareModal.link.updateError'));
63
+ } finally {
64
+ setUpdating(false);
65
+ }
66
+ },
67
+ [activeTopicId, mutate, message, t],
68
+ );
69
+
70
+ const handleVisibilityChange = useCallback(
71
+ (visibility: Visibility) => {
72
+ // Show confirmation when changing from private to link
73
+ if (currentVisibility === 'private' && visibility === 'link') {
74
+ modal.confirm({
75
+ cancelText: t('cancel', { ns: 'common' }),
76
+ content: t('shareModal.popover.privacyWarning.content'),
77
+ okText: t('shareModal.popover.privacyWarning.confirm'),
78
+ onOk: () => updateVisibility(visibility),
79
+ title: t('shareModal.popover.privacyWarning.title'),
80
+ type: 'warning',
81
+ });
82
+ } else {
83
+ updateVisibility(visibility);
84
+ }
85
+ },
86
+ [currentVisibility, modal, t, updateVisibility],
87
+ );
88
+
89
+ const handleCopyLink = useCallback(async () => {
90
+ if (!shareUrl) return;
91
+ await copyToClipboard(shareUrl);
92
+ message.success(t('shareModal.copyLinkSuccess'));
93
+ }, [shareUrl, message, t]);
94
+
95
+ const handleOpenModal = useCallback(() => {
96
+ close();
97
+ onOpenModal?.();
98
+ }, [close, onOpenModal]);
99
+
100
+ // Loading state
101
+ if (isLoading || !shareInfo) {
102
+ return (
103
+ <Flexbox className={styles.container} gap={16}>
104
+ <Typography.Text strong>{t('share', { ns: 'common' })}</Typography.Text>
105
+ <Skeleton active paragraph={{ rows: 2 }} />
106
+ </Flexbox>
107
+ );
108
+ }
109
+
110
+ const visibilityOptions = [
111
+ {
112
+ icon: <LockIcon size={14} />,
113
+ label: t('shareModal.link.permissionPrivate'),
114
+ value: 'private',
115
+ },
116
+ {
117
+ icon: <LinkIcon size={14} />,
118
+ label: t('shareModal.link.permissionLink'),
119
+ value: 'link',
120
+ },
121
+ ];
122
+
123
+ const getVisibilityHint = () => {
124
+ switch (currentVisibility) {
125
+ case 'private': {
126
+ return t('shareModal.link.privateHint');
127
+ }
128
+ case 'link': {
129
+ return t('shareModal.link.linkHint');
130
+ }
131
+ }
132
+ };
133
+
134
+ return (
135
+ <Flexbox className={styles.container} gap={12} ref={containerRef}>
136
+ <Typography.Text strong>{t('shareModal.popover.title')}</Typography.Text>
137
+
138
+ <Flexbox gap={4}>
139
+ <Typography.Text type="secondary">{t('shareModal.popover.visibility')}</Typography.Text>
140
+ <Select
141
+ disabled={updating}
142
+ getPopupContainer={() => containerRef.current || document.body}
143
+ labelRender={({ value }) => {
144
+ const option = visibilityOptions.find((o) => o.value === value);
145
+ return (
146
+ <Flexbox align="center" gap={8} horizontal>
147
+ {option?.icon}
148
+ {option?.label}
149
+ </Flexbox>
150
+ );
151
+ }}
152
+ onChange={handleVisibilityChange}
153
+ optionRender={(option) => (
154
+ <Flexbox align="center" gap={8} horizontal>
155
+ {visibilityOptions.find((o) => o.value === option.value)?.icon}
156
+ {option.label}
157
+ </Flexbox>
158
+ )}
159
+ options={visibilityOptions}
160
+ style={{ width: '100%' }}
161
+ value={currentVisibility}
162
+ />
163
+ </Flexbox>
164
+
165
+ <Typography.Text className={styles.hint} type="secondary">
166
+ {getVisibilityHint()}
167
+ </Typography.Text>
168
+
169
+ <Divider style={{ margin: '4px 0' }} />
170
+
171
+ <Flexbox align="center" horizontal justify="space-between">
172
+ <Button
173
+ icon={ExternalLinkIcon}
174
+ onClick={handleOpenModal}
175
+ size="small"
176
+ type="text"
177
+ variant="text"
178
+ >
179
+ {t('shareModal.popover.moreOptions')}
180
+ </Button>
181
+ <Button icon={CopyIcon} onClick={handleCopyLink} size="small" type="primary">
182
+ {t('shareModal.copyLink')}
183
+ </Button>
184
+ </Flexbox>
185
+ </Flexbox>
186
+ );
187
+ });
188
+
189
+ interface SharePopoverProps {
190
+ children?: ReactNode;
191
+ onOpenModal?: () => void;
192
+ }
193
+
194
+ const SharePopover = memo<SharePopoverProps>(({ children, onOpenModal }) => {
195
+ const isMobile = useIsMobile();
196
+
197
+ return (
198
+ <Popover
199
+ arrow={false}
200
+ content={<SharePopoverContent onOpenModal={onOpenModal} />}
201
+ placement={isMobile ? 'top' : 'bottomRight'}
202
+ styles={{
203
+ content: {
204
+ padding: 0,
205
+ width: isMobile ? '100vw' : 366,
206
+ },
207
+ }}
208
+ trigger={['click']}
209
+ >
210
+ {children}
211
+ </Popover>
212
+ );
213
+ });
214
+
215
+ export default SharePopover;
@@ -0,0 +1,10 @@
1
+ import { createStyles } from 'antd-style';
2
+
3
+ export const useStyles = createStyles(({ css }) => ({
4
+ container: css`
5
+ padding: 16px;
6
+ `,
7
+ hint: css`
8
+ font-size: 12px;
9
+ `,
10
+ }));
@@ -8,7 +8,7 @@ import { auth } from '@/auth';
8
8
  import { LOBE_LOCALE_COOKIE } from '@/const/locale';
9
9
  import { isDesktop } from '@/const/version';
10
10
  import { appEnv } from '@/envs/app';
11
- import { OAUTH_AUTHORIZED , authEnv } from '@/envs/auth';
11
+ import { OAUTH_AUTHORIZED, authEnv } from '@/envs/auth';
12
12
  import NextAuth from '@/libs/next-auth';
13
13
  import { type Locales } from '@/locales/resources';
14
14
  import { parseBrowserLanguage } from '@/utils/locale';
@@ -107,6 +107,7 @@ export function defineConfig() {
107
107
  '/me',
108
108
  '/desktop-onboarding',
109
109
  '/onboarding',
110
+ '/share',
110
111
  ];
111
112
  const isSpaRoute = spaRoutes.some((route) => url.pathname.startsWith(route));
112
113
 
@@ -184,6 +185,8 @@ export function defineConfig() {
184
185
  '/oidc/token',
185
186
  // market
186
187
  '/market-auth-callback',
188
+ // public share pages
189
+ '/share(.*)',
187
190
  ]);
188
191
 
189
192
  const isProtectedRoute = createRouteMatcher([
@@ -283,6 +283,8 @@ export default {
283
283
  'sessionGroup.sorting': 'Group sorting updating...',
284
284
  'sessionGroup.tooLong': 'Group name length should be between 1-20',
285
285
  'shareModal.copy': 'Copy',
286
+ 'shareModal.copyLink': 'Copy Link',
287
+ 'shareModal.copyLinkSuccess': 'Link copied',
286
288
  'shareModal.download': 'Download Screenshot',
287
289
  'shareModal.downloadError': 'Download failed',
288
290
  'shareModal.downloadFile': 'Download File',
@@ -298,12 +300,27 @@ export default {
298
300
  'shareModal.imageType': 'Image Format',
299
301
  'shareModal.includeTool': 'Include Skill messages',
300
302
  'shareModal.includeUser': 'Include User Messages',
303
+ 'shareModal.link': 'Link',
304
+ 'shareModal.link.linkHint': 'Anyone with the link can view this topic',
305
+ 'shareModal.link.noTopic': 'Start a conversation first to share',
306
+ 'shareModal.link.permissionLink': 'Anyone with the link',
307
+ 'shareModal.link.permissionPrivate': 'Private',
308
+ 'shareModal.link.privateHint': 'Only you can access this link',
309
+ 'shareModal.link.updateError': 'Failed to update sharing settings',
310
+ 'shareModal.link.visibilityUpdated': 'Visibility updated',
301
311
  'shareModal.loadingPdf': 'Loading PDF...',
302
312
  'shareModal.noPdfData': 'No PDF data available',
303
313
  'shareModal.pdf': 'PDF',
304
314
  'shareModal.pdfErrorDescription': 'An error occurred while generating the PDF, please try again',
305
315
  'shareModal.pdfGenerationError': 'PDF generation failed',
306
316
  'shareModal.pdfReady': 'PDF is ready',
317
+ 'shareModal.popover.moreOptions': 'More share options',
318
+ 'shareModal.popover.privacyWarning.confirm': 'I understand, continue',
319
+ 'shareModal.popover.privacyWarning.content':
320
+ 'Please ensure the conversation does not contain any private or sensitive information before sharing. LobeHub is not responsible for any security issues that may arise from sharing.',
321
+ 'shareModal.popover.privacyWarning.title': 'Privacy Notice',
322
+ 'shareModal.popover.title': 'Share Topic',
323
+ 'shareModal.popover.visibility': 'Visibility',
307
324
  'shareModal.regeneratePdf': 'Regenerate PDF',
308
325
  'shareModal.screenshot': 'Screenshot',
309
326
  'shareModal.settings': 'Export Settings',
@@ -316,6 +333,15 @@ export default {
316
333
  'shareModal.withPluginInfo': 'Include Skill Information',
317
334
  'shareModal.withRole': 'Include Message Role',
318
335
  'shareModal.withSystemRole': 'Include Agent Profile',
336
+ 'sharePage.error.forbidden.subtitle': 'This share is private and not accessible.',
337
+ 'sharePage.error.forbidden.title': 'Access Denied',
338
+ 'sharePage.error.notFound.subtitle': 'This topic does not exist or has been removed.',
339
+ 'sharePage.error.notFound.title': 'Topic Not Found',
340
+ 'sharePage.error.unauthorized.action': 'Sign In',
341
+ 'sharePage.error.unauthorized.subtitle': 'Please sign in to view this shared topic.',
342
+ 'sharePage.error.unauthorized.title': 'Sign In Required',
343
+ 'sharePageDisclaimer':
344
+ 'This content is shared by a user and does not represent the views of LobeHub. LobeHub is not responsible for any consequences arising from this shared content.',
319
345
  'stt.action': 'Voice Input',
320
346
  'stt.loading': 'Recognizing...',
321
347
  'stt.prettifying': 'Polishing...',
package/src/proxy.ts CHANGED
@@ -26,6 +26,7 @@ export const config = {
26
26
  '/page(.*)',
27
27
  '/me',
28
28
  '/me(.*)',
29
+ '/share(.*)',
29
30
  '/desktop-onboarding',
30
31
  '/desktop-onboarding(.*)',
31
32
  '/onboarding',
@@ -74,6 +74,7 @@ export const getServerGlobalConfig = async () => {
74
74
  defaultAgent: {
75
75
  config: parseAgentConfig(DEFAULT_AGENT_CONFIG),
76
76
  },
77
+ enableBusinessFeatures: ENABLE_BUSINESS_FEATURES,
77
78
  enableEmailVerification: authEnv.AUTH_EMAIL_VERIFICATION,
78
79
  enableKlavis: !!klavisEnv.KLAVIS_API_KEY,
79
80
  enableLobehubSkill: !!(appEnv.MARKET_TRUSTED_CLIENT_SECRET && appEnv.MARKET_TRUSTED_CLIENT_ID),
@@ -1,13 +1,21 @@
1
1
  import { CreateMessageParams, UIChatMessage, UpdateMessageRAGParams } from '@lobechat/types';
2
+ import { TRPCError } from '@trpc/server';
2
3
  import { describe, expect, it, vi } from 'vitest';
3
4
 
4
5
  import { MessageModel } from '@/database/models/message';
6
+ import { TopicShareModel } from '@/database/models/topicShare';
5
7
  import { FileService } from '@/server/services/file';
6
8
 
7
9
  vi.mock('@/database/models/message', () => ({
8
10
  MessageModel: vi.fn(),
9
11
  }));
10
12
 
13
+ vi.mock('@/database/models/topicShare', () => ({
14
+ TopicShareModel: {
15
+ findByShareIdWithAccessCheck: vi.fn(),
16
+ },
17
+ }));
18
+
11
19
  vi.mock('@/server/services/file', () => ({
12
20
  FileService: vi.fn(),
13
21
  }));
@@ -345,4 +353,148 @@ describe('messageRouter', () => {
345
353
  expect(result.rowCount).toBe(5);
346
354
  });
347
355
  });
356
+
357
+ describe('topicShareId support', () => {
358
+ it('should get messages via topicShareId for link share', async () => {
359
+ const mockShare = {
360
+ visibility: 'link',
361
+ ownerId: 'owner-user',
362
+ shareId: 'share-123',
363
+ topicId: 'topic-1',
364
+ };
365
+
366
+ const mockMessages = [
367
+ { id: 'msg1', content: 'Hello', role: 'user' },
368
+ { id: 'msg2', content: 'Hi there', role: 'assistant' },
369
+ ];
370
+
371
+ const mockQuery = vi.fn().mockResolvedValue(mockMessages);
372
+ const mockGetFullFileUrl = vi
373
+ .fn()
374
+ .mockImplementation((path: string) => `https://cdn/${path}`);
375
+
376
+ vi.mocked(TopicShareModel.findByShareIdWithAccessCheck).mockResolvedValue(mockShare as any);
377
+ vi.mocked(MessageModel).mockImplementation(
378
+ () =>
379
+ ({
380
+ query: mockQuery,
381
+ }) as any,
382
+ );
383
+ vi.mocked(FileService).mockImplementation(
384
+ () =>
385
+ ({
386
+ getFullFileUrl: mockGetFullFileUrl,
387
+ }) as any,
388
+ );
389
+
390
+ // Simulate the router logic
391
+ const share = await TopicShareModel.findByShareIdWithAccessCheck(
392
+ {} as any,
393
+ 'share-123',
394
+ undefined,
395
+ );
396
+
397
+ expect(share).toBeDefined();
398
+ expect(share.topicId).toBe('topic-1');
399
+ expect(share.ownerId).toBe('owner-user');
400
+
401
+ // Create model using owner's id
402
+ const messageModel = new MessageModel({} as any, share.ownerId);
403
+ const result = await messageModel.query(
404
+ { topicId: share.topicId },
405
+ { postProcessUrl: mockGetFullFileUrl },
406
+ );
407
+
408
+ expect(result).toEqual(mockMessages);
409
+ });
410
+
411
+ it('should allow owner to access private share messages', async () => {
412
+ const mockShare = {
413
+ visibility: 'private',
414
+ ownerId: 'owner-user',
415
+ shareId: 'private-share',
416
+ topicId: 'topic-private',
417
+ };
418
+
419
+ vi.mocked(TopicShareModel.findByShareIdWithAccessCheck).mockResolvedValue(mockShare as any);
420
+
421
+ const share = await TopicShareModel.findByShareIdWithAccessCheck(
422
+ {} as any,
423
+ 'private-share',
424
+ 'owner-user', // Owner accessing
425
+ );
426
+
427
+ expect(share).toBeDefined();
428
+ expect(share.visibility).toBe('private');
429
+ });
430
+
431
+ it('should throw FORBIDDEN for private share accessed by non-owner', async () => {
432
+ vi.mocked(TopicShareModel.findByShareIdWithAccessCheck).mockRejectedValue(
433
+ new TRPCError({ code: 'FORBIDDEN', message: 'This share is private' }),
434
+ );
435
+
436
+ await expect(
437
+ TopicShareModel.findByShareIdWithAccessCheck({} as any, 'private-share', 'other-user'),
438
+ ).rejects.toThrow(TRPCError);
439
+
440
+ try {
441
+ await TopicShareModel.findByShareIdWithAccessCheck(
442
+ {} as any,
443
+ 'private-share',
444
+ 'other-user',
445
+ );
446
+ } catch (error) {
447
+ expect((error as TRPCError).code).toBe('FORBIDDEN');
448
+ }
449
+ });
450
+
451
+ it('should throw NOT_FOUND for non-existent share', async () => {
452
+ vi.mocked(TopicShareModel.findByShareIdWithAccessCheck).mockRejectedValue(
453
+ new TRPCError({ code: 'NOT_FOUND', message: 'Share not found' }),
454
+ );
455
+
456
+ await expect(
457
+ TopicShareModel.findByShareIdWithAccessCheck({} as any, 'non-existent', 'user1'),
458
+ ).rejects.toThrow(TRPCError);
459
+
460
+ try {
461
+ await TopicShareModel.findByShareIdWithAccessCheck({} as any, 'non-existent', 'user1');
462
+ } catch (error) {
463
+ expect((error as TRPCError).code).toBe('NOT_FOUND');
464
+ }
465
+ });
466
+
467
+ it('should use owner id to query messages for shared topic', async () => {
468
+ const mockShare = {
469
+ visibility: 'link',
470
+ ownerId: 'topic-owner',
471
+ shareId: 'share-abc',
472
+ topicId: 'shared-topic',
473
+ };
474
+
475
+ const mockQuery = vi.fn().mockResolvedValue([{ id: 'msg1' }]);
476
+
477
+ vi.mocked(TopicShareModel.findByShareIdWithAccessCheck).mockResolvedValue(mockShare as any);
478
+ vi.mocked(MessageModel).mockImplementation(
479
+ () =>
480
+ ({
481
+ query: mockQuery,
482
+ }) as any,
483
+ );
484
+
485
+ const share = await TopicShareModel.findByShareIdWithAccessCheck(
486
+ {} as any,
487
+ 'share-abc',
488
+ undefined,
489
+ );
490
+
491
+ // Verify we use the owner's id to create MessageModel
492
+ const messageModel = new MessageModel({} as any, share.ownerId);
493
+ await messageModel.query({ topicId: share.topicId }, {});
494
+
495
+ // Verify MessageModel was instantiated with owner's id
496
+ expect(MessageModel).toHaveBeenCalledWith({} as any, 'topic-owner');
497
+ expect(mockQuery).toHaveBeenCalledWith({ topicId: 'shared-topic' }, {});
498
+ });
499
+ });
348
500
  });