@lobehub/chat 0.162.24 → 0.163.0

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 (96) hide show
  1. package/.github/workflows/release.yml +21 -2
  2. package/.github/workflows/sync.yml +1 -1
  3. package/.github/workflows/test.yml +35 -4
  4. package/CHANGELOG.md +50 -0
  5. package/LICENSE +38 -21
  6. package/README.md +8 -8
  7. package/README.zh-CN.md +8 -8
  8. package/codecov.yml +11 -0
  9. package/docs/self-hosting/platform/zeabur.mdx +1 -1
  10. package/docs/self-hosting/platform/zeabur.zh-CN.mdx +1 -1
  11. package/drizzle.config.ts +29 -0
  12. package/next.config.mjs +3 -0
  13. package/package.json +25 -5
  14. package/scripts/migrateServerDB/index.ts +30 -0
  15. package/src/app/(main)/(mobile)/me/(home)/features/useCategory.tsx +2 -1
  16. package/src/app/(main)/chat/@session/features/SessionListContent/List/Item/Actions.tsx +95 -88
  17. package/src/app/(main)/chat/settings/features/HeaderContent.tsx +37 -31
  18. package/src/app/(main)/settings/llm/components/ProviderConfig/index.tsx +11 -1
  19. package/src/app/api/middleware/auth/index.ts +1 -1
  20. package/src/app/api/webhooks/clerk/__tests__/fixtures/createUser.json +73 -0
  21. package/src/app/api/webhooks/clerk/route.ts +159 -0
  22. package/src/app/api/webhooks/clerk/validateRequest.ts +22 -0
  23. package/src/app/trpc/edge/[trpc]/route.ts +1 -1
  24. package/src/app/trpc/lambda/[trpc]/route.ts +26 -0
  25. package/src/config/auth.ts +2 -0
  26. package/src/config/db.ts +13 -1
  27. package/src/database/server/core/db.ts +44 -0
  28. package/src/database/server/core/dbForTest.ts +45 -0
  29. package/src/database/server/index.ts +1 -0
  30. package/src/database/server/migrations/0000_init.sql +439 -0
  31. package/src/database/server/migrations/0001_add_client_id.sql +9 -0
  32. package/src/database/server/migrations/0002_amusing_puma.sql +9 -0
  33. package/src/database/server/migrations/meta/0000_snapshot.json +1583 -0
  34. package/src/database/server/migrations/meta/0001_snapshot.json +1636 -0
  35. package/src/database/server/migrations/meta/0002_snapshot.json +1630 -0
  36. package/src/database/server/migrations/meta/_journal.json +27 -0
  37. package/src/database/server/models/__tests__/file.test.ts +140 -0
  38. package/src/database/server/models/__tests__/message.test.ts +847 -0
  39. package/src/database/server/models/__tests__/plugin.test.ts +172 -0
  40. package/src/database/server/models/__tests__/session.test.ts +595 -0
  41. package/src/database/server/models/__tests__/topic.test.ts +623 -0
  42. package/src/database/server/models/__tests__/user.test.ts +173 -0
  43. package/src/database/server/models/_template.ts +44 -0
  44. package/src/database/server/models/file.ts +51 -0
  45. package/src/database/server/models/message.ts +378 -0
  46. package/src/database/server/models/plugin.ts +63 -0
  47. package/src/database/server/models/session.ts +290 -0
  48. package/src/database/server/models/sessionGroup.ts +69 -0
  49. package/src/database/server/models/topic.ts +265 -0
  50. package/src/database/server/models/user.ts +138 -0
  51. package/src/database/server/modules/DataImporter/__tests__/fixtures/messages.json +1101 -0
  52. package/src/database/server/modules/DataImporter/__tests__/index.test.ts +954 -0
  53. package/src/database/server/modules/DataImporter/index.ts +333 -0
  54. package/src/database/server/schemas/_id.ts +15 -0
  55. package/src/database/server/schemas/lobechat.ts +601 -0
  56. package/src/database/server/utils/idGenerator.test.ts +39 -0
  57. package/src/database/server/utils/idGenerator.ts +26 -0
  58. package/src/features/AgentSetting/AgentModal/index.tsx +6 -7
  59. package/src/features/User/UserPanel/useMenu.tsx +43 -37
  60. package/src/libs/trpc/client.ts +52 -3
  61. package/src/server/files/s3.ts +21 -1
  62. package/src/server/keyVaultsEncrypt/index.test.ts +62 -0
  63. package/src/server/keyVaultsEncrypt/index.ts +93 -0
  64. package/src/server/mock.ts +1 -1
  65. package/src/server/routers/{index.ts → edge/index.ts} +3 -3
  66. package/src/server/routers/lambda/file.ts +49 -0
  67. package/src/server/routers/lambda/importer.ts +54 -0
  68. package/src/server/routers/lambda/index.ts +28 -0
  69. package/src/server/routers/lambda/message.ts +165 -0
  70. package/src/server/routers/lambda/plugin.ts +100 -0
  71. package/src/server/routers/lambda/session.ts +194 -0
  72. package/src/server/routers/lambda/sessionGroup.ts +77 -0
  73. package/src/server/routers/lambda/topic.ts +134 -0
  74. package/src/server/routers/lambda/user.ts +57 -0
  75. package/src/services/file/index.ts +4 -7
  76. package/src/services/file/server.ts +45 -0
  77. package/src/services/import/index.ts +4 -1
  78. package/src/services/import/server.ts +115 -0
  79. package/src/services/message/index.ts +4 -8
  80. package/src/services/message/server.ts +93 -0
  81. package/src/services/plugin/index.ts +4 -9
  82. package/src/services/plugin/server.ts +46 -0
  83. package/src/services/session/index.ts +4 -8
  84. package/src/services/session/server.ts +148 -0
  85. package/src/services/topic/index.ts +4 -9
  86. package/src/services/topic/server.ts +68 -0
  87. package/src/services/user/index.ts +4 -9
  88. package/src/services/user/server.ts +28 -0
  89. package/src/store/user/slices/modelList/selectors/keyVaults.test.ts +201 -0
  90. package/src/store/user/slices/modelList/selectors/keyVaults.ts +15 -3
  91. package/src/store/user/slices/modelList/selectors/modelConfig.test.ts +29 -1
  92. package/src/store/user/slices/modelList/selectors/modelConfig.ts +21 -1
  93. package/src/types/user/settings/keyVaults.ts +1 -1
  94. package/tests/setup-db.ts +7 -0
  95. package/vitest.config.ts +2 -1
  96. package/vitest.server.config.ts +23 -0
@@ -1,6 +1,7 @@
1
1
  import { ActionIcon, Icon } from '@lobehub/ui';
2
- import { App, Dropdown, type MenuProps } from 'antd';
2
+ import { App, Dropdown } from 'antd';
3
3
  import { createStyles } from 'antd-style';
4
+ import { ItemType } from 'antd/es/menu/interface';
4
5
  import isEqual from 'fast-deep-equal';
5
6
  import {
6
7
  Check,
@@ -16,6 +17,7 @@ import {
16
17
  import { memo, useMemo } from 'react';
17
18
  import { useTranslation } from 'react-i18next';
18
19
 
20
+ import { isServerMode } from '@/const/version';
19
21
  import { configService } from '@/services/config';
20
22
  import { useSessionStore } from '@/store/session';
21
23
  import { sessionHelpers } from '@/store/session/helpers';
@@ -58,108 +60,113 @@ const Actions = memo<ActionProps>(({ group, id, openCreateGroupModal, setOpen })
58
60
  const isDefault = group === SessionDefaultGroup.Default;
59
61
  // const hasDivider = !isDefault || Object.keys(sessionByGroup).length > 0;
60
62
 
61
- const items: MenuProps['items'] = useMemo(
62
- () => [
63
- {
64
- icon: <Icon icon={pin ? PinOff : Pin} />,
65
- key: 'pin',
66
- label: t(pin ? 'pinOff' : 'pin'),
67
- onClick: () => {
68
- pinSession(id, !pin);
69
- },
70
- },
71
- {
72
- icon: <Icon icon={LucideCopy} />,
73
- key: 'duplicate',
74
- label: t('duplicate', { ns: 'common' }),
75
- onClick: ({ domEvent }) => {
76
- domEvent.stopPropagation();
77
-
78
- duplicateSession(id);
79
- },
80
- },
81
- {
82
- type: 'divider',
83
- },
84
- {
85
- children: [
86
- ...sessionCustomGroups.map(({ id: groupId, name }) => ({
87
- icon: group === groupId ? <Icon icon={Check} /> : <div />,
88
- key: groupId,
89
- label: name,
63
+ const items = useMemo(
64
+ () =>
65
+ (
66
+ [
67
+ {
68
+ icon: <Icon icon={pin ? PinOff : Pin} />,
69
+ key: 'pin',
70
+ label: t(pin ? 'pinOff' : 'pin'),
90
71
  onClick: () => {
91
- updateSessionGroup(id, groupId);
72
+ pinSession(id, !pin);
92
73
  },
93
- })),
74
+ },
94
75
  {
95
- icon: isDefault ? <Icon icon={Check} /> : <div />,
96
- key: 'defaultList',
97
- label: t('defaultList'),
98
- onClick: () => {
99
- updateSessionGroup(id, SessionDefaultGroup.Default);
76
+ icon: <Icon icon={LucideCopy} />,
77
+ key: 'duplicate',
78
+ label: t('duplicate', { ns: 'common' }),
79
+ onClick: ({ domEvent }) => {
80
+ domEvent.stopPropagation();
81
+
82
+ duplicateSession(id);
100
83
  },
101
84
  },
102
85
  {
103
86
  type: 'divider',
104
87
  },
105
88
  {
106
- icon: <Icon icon={LucidePlus} />,
107
- key: 'createGroup',
108
- label: <div>{t('sessionGroup.createGroup')}</div>,
109
- onClick: ({ domEvent }) => {
110
- domEvent.stopPropagation();
111
- openCreateGroupModal();
112
- },
89
+ children: [
90
+ ...sessionCustomGroups.map(({ id: groupId, name }) => ({
91
+ icon: group === groupId ? <Icon icon={Check} /> : <div />,
92
+ key: groupId,
93
+ label: name,
94
+ onClick: () => {
95
+ updateSessionGroup(id, groupId);
96
+ },
97
+ })),
98
+ {
99
+ icon: isDefault ? <Icon icon={Check} /> : <div />,
100
+ key: 'defaultList',
101
+ label: t('defaultList'),
102
+ onClick: () => {
103
+ updateSessionGroup(id, SessionDefaultGroup.Default);
104
+ },
105
+ },
106
+ {
107
+ type: 'divider',
108
+ },
109
+ {
110
+ icon: <Icon icon={LucidePlus} />,
111
+ key: 'createGroup',
112
+ label: <div>{t('sessionGroup.createGroup')}</div>,
113
+ onClick: ({ domEvent }) => {
114
+ domEvent.stopPropagation();
115
+ openCreateGroupModal();
116
+ },
117
+ },
118
+ ],
119
+ icon: <Icon icon={ListTree} />,
120
+ key: 'moveGroup',
121
+ label: t('sessionGroup.moveGroup'),
113
122
  },
114
- ],
115
- icon: <Icon icon={ListTree} />,
116
- key: 'moveGroup',
117
- label: t('sessionGroup.moveGroup'),
118
- },
119
- {
120
- type: 'divider',
121
- },
122
- {
123
- children: [
124
123
  {
125
- key: 'agent',
126
- label: t('exportType.agent', { ns: 'common' }),
127
- onClick: () => {
128
- configService.exportSingleAgent(id);
129
- },
124
+ type: 'divider',
130
125
  },
126
+ isServerMode
127
+ ? undefined
128
+ : {
129
+ children: [
130
+ {
131
+ key: 'agent',
132
+ label: t('exportType.agent', { ns: 'common' }),
133
+ onClick: () => {
134
+ configService.exportSingleAgent(id);
135
+ },
136
+ },
137
+ {
138
+ key: 'agentWithMessage',
139
+ label: t('exportType.agentWithMessage', { ns: 'common' }),
140
+ onClick: () => {
141
+ configService.exportSingleSession(id);
142
+ },
143
+ },
144
+ ],
145
+ icon: <Icon icon={HardDriveDownload} />,
146
+ key: 'export',
147
+ label: t('export', { ns: 'common' }),
148
+ },
131
149
  {
132
- key: 'agentWithMessage',
133
- label: t('exportType.agentWithMessage', { ns: 'common' }),
134
- onClick: () => {
135
- configService.exportSingleSession(id);
150
+ danger: true,
151
+ icon: <Icon icon={Trash} />,
152
+ key: 'delete',
153
+ label: t('delete', { ns: 'common' }),
154
+ onClick: ({ domEvent }) => {
155
+ domEvent.stopPropagation();
156
+ modal.confirm({
157
+ centered: true,
158
+ okButtonProps: { danger: true },
159
+ onOk: async () => {
160
+ await removeSession(id);
161
+ message.success(t('confirmRemoveSessionSuccess'));
162
+ },
163
+ rootClassName: styles.modalRoot,
164
+ title: t('confirmRemoveSessionItemAlert'),
165
+ });
136
166
  },
137
167
  },
138
- ],
139
- icon: <Icon icon={HardDriveDownload} />,
140
- key: 'export',
141
- label: t('export', { ns: 'common' }),
142
- },
143
- {
144
- danger: true,
145
- icon: <Icon icon={Trash} />,
146
- key: 'delete',
147
- label: t('delete', { ns: 'common' }),
148
- onClick: ({ domEvent }) => {
149
- domEvent.stopPropagation();
150
- modal.confirm({
151
- centered: true,
152
- okButtonProps: { danger: true },
153
- onOk: async () => {
154
- await removeSession(id);
155
- message.success(t('confirmRemoveSessionSuccess'));
156
- },
157
- rootClassName: styles.modalRoot,
158
- title: t('confirmRemoveSessionItemAlert'),
159
- });
160
- },
161
- },
162
- ],
168
+ ] as ItemType[]
169
+ ).filter(Boolean),
163
170
  [id, pin],
164
171
  );
165
172
 
@@ -5,6 +5,7 @@ import { memo, useMemo } from 'react';
5
5
  import { useTranslation } from 'react-i18next';
6
6
 
7
7
  import { HEADER_ICON_SIZE } from '@/const/layoutTokens';
8
+ import { isServerMode } from '@/const/version';
8
9
  import { configService } from '@/services/config';
9
10
  import { useServerConfigStore } from '@/store/serverConfig';
10
11
  import { useSessionStore } from '@/store/session';
@@ -18,45 +19,50 @@ export const HeaderContent = memo<{ mobile?: boolean; modal?: boolean }>(({ moda
18
19
  const mobile = useServerConfigStore((s) => s.isMobile);
19
20
 
20
21
  const items = useMemo<MenuProps['items']>(
21
- () => [
22
- {
23
- key: 'agent',
24
- label: <div>{t('exportType.agent', { ns: 'common' })}</div>,
25
- onClick: () => {
26
- if (!id) return;
22
+ () =>
23
+ isServerMode
24
+ ? []
25
+ : [
26
+ {
27
+ key: 'agent',
28
+ label: <div>{t('exportType.agent', { ns: 'common' })}</div>,
29
+ onClick: () => {
30
+ if (!id) return;
27
31
 
28
- configService.exportSingleAgent(id);
29
- },
30
- },
31
- {
32
- key: 'agentWithMessage',
33
- label: <div>{t('exportType.agentWithMessage', { ns: 'common' })}</div>,
34
- onClick: () => {
35
- if (!id) return;
32
+ configService.exportSingleAgent(id);
33
+ },
34
+ },
35
+ {
36
+ key: 'agentWithMessage',
37
+ label: <div>{t('exportType.agentWithMessage', { ns: 'common' })}</div>,
38
+ onClick: () => {
39
+ if (!id) return;
36
40
 
37
- configService.exportSingleSession(id);
38
- },
39
- },
40
- ],
41
+ configService.exportSingleSession(id);
42
+ },
43
+ },
44
+ ],
41
45
  [],
42
46
  );
43
47
 
44
48
  return (
45
49
  <>
46
50
  <SubmitAgentButton modal={modal} />
47
- <Dropdown arrow={false} menu={{ items }} trigger={['click']}>
48
- {modal ? (
49
- <Button block icon={<Icon icon={HardDriveDownload} />}>
50
- {t('export', { ns: 'common' })}
51
- </Button>
52
- ) : (
53
- <ActionIcon
54
- icon={HardDriveDownload}
55
- size={HEADER_ICON_SIZE(mobile)}
56
- title={t('export', { ns: 'common' })}
57
- />
58
- )}
59
- </Dropdown>
51
+ {!isServerMode && (
52
+ <Dropdown arrow={false} menu={{ items }} trigger={['click']}>
53
+ {modal ? (
54
+ <Button block icon={<Icon icon={HardDriveDownload} />}>
55
+ {t('export', { ns: 'common' })}
56
+ </Button>
57
+ ) : (
58
+ <ActionIcon
59
+ icon={HardDriveDownload}
60
+ size={HEADER_ICON_SIZE(mobile)}
61
+ title={t('export', { ns: 'common' })}
62
+ />
63
+ )}
64
+ </Dropdown>
65
+ )}
60
66
  </>
61
67
  );
62
68
  });
@@ -87,12 +87,14 @@ const ProviderConfig = memo<ProviderConfigProps>(
87
87
  enabled,
88
88
  isFetchOnClient,
89
89
  isProviderEndpointNotEmpty,
90
+ isProviderApiKeyNotEmpty,
90
91
  ] = useUserStore((s) => [
91
92
  s.toggleProviderEnabled,
92
93
  s.setSettings,
93
94
  modelConfigSelectors.isProviderEnabled(id)(s),
94
95
  modelConfigSelectors.isProviderFetchOnClient(id)(s),
95
96
  keyVaultsConfigSelectors.isProviderEndpointNotEmpty(id)(s),
97
+ keyVaultsConfigSelectors.isProviderApiKeyNotEmpty(id)(s),
96
98
  ]);
97
99
 
98
100
  useSyncSettings(form);
@@ -122,7 +124,15 @@ const ProviderConfig = memo<ProviderConfigProps>(
122
124
  label: proxyUrl?.title || t('llm.proxyUrl.title'),
123
125
  name: [KeyVaultsConfigKey, id, LLMProviderBaseUrlKey],
124
126
  },
125
- (showBrowserRequest || (showEndpoint && isProviderEndpointNotEmpty)) && {
127
+ /*
128
+ * Conditions to show Client Fetch Switch
129
+ * 1. Component props
130
+ * 2. Provider allow to edit endpoint and the value of endpoint is not empty
131
+ * 3. There is an apikey provided by user
132
+ */
133
+ (showBrowserRequest ||
134
+ (showEndpoint && isProviderEndpointNotEmpty) ||
135
+ (showApiKey && isProviderApiKeyNotEmpty)) && {
126
136
  children: (
127
137
  <Switch
128
138
  onChange={(enabled) => {
@@ -10,7 +10,7 @@ import { ChatErrorType } from '@/types/fetch';
10
10
  import { checkAuthMethod, getJWTPayload } from './utils';
11
11
 
12
12
  type CreateRuntime = (jwtPayload: JWTPayload) => AgentRuntime;
13
- type RequestOptions = { createRuntime?: CreateRuntime, params: { provider: string }; };
13
+ type RequestOptions = { createRuntime?: CreateRuntime; params: { provider: string } };
14
14
 
15
15
  export type RequestHandler = (
16
16
  req: Request,
@@ -0,0 +1,73 @@
1
+ {
2
+ "backup_code_enabled": false,
3
+ "banned": false,
4
+ "create_organization_enabled": true,
5
+ "created_at": 1713709987911,
6
+ "delete_self_enabled": true,
7
+ "email_addresses": [
8
+ {
9
+ "created_at": 1713709977919,
10
+ "email_address": "arvinx@foxmail.com",
11
+ "id": "idn_2fPkD9X1lfzSn5lJVDGyochYq8k",
12
+ "linked_to": [],
13
+ "object": "email_address",
14
+ "reserved": false,
15
+ "updated_at": 1713709987951,
16
+ "verification": []
17
+ }
18
+ ],
19
+ "external_accounts": [
20
+ {
21
+ "approved_scopes": "read:user user:email",
22
+ "avatar_url": "https://avatars.githubusercontent.com/u/28616219?v=4",
23
+ "created_at": 1713709542104,
24
+ "email_address": "arvinx@foxmail.com",
25
+ "first_name": "Arvin",
26
+ "id": "eac_2fPjKROeJ1bBs8Uxa6RFMxKogTB",
27
+ "identification_id": "idn_2fPjyV3sqtQJZUbEzdK2y23a1bq",
28
+ "image_url": "https://img.clerk.com/eyJ0eXBlIjoicHJveHkiLCJzcmMiOiJodHRwczovL2F2YXRhcnMuZ2l0aHVidXNlcmNvbnRlbnQuY29tL3UvMjg2MTYyMTk/dj00IiwicyI6IkhCeHE5NmdlRk85ekRxMjJlR05EalUrbVFBbmVDZjRVQkpwNGYxcW5JajQifQ",
29
+ "label": null,
30
+ "last_name": "Xu",
31
+ "object": "external_account",
32
+ "provider": "oauth_github",
33
+ "provider_user_id": "28616219",
34
+ "public_metadata": {},
35
+ "updated_at": 1713709542104,
36
+ "username": "arvinxx",
37
+ "verification": {
38
+ "attempts": null,
39
+ "expire_at": 1713710140131,
40
+ "status": "verified",
41
+ "strategy": "oauth_github"
42
+ }
43
+ }
44
+ ],
45
+ "external_id": null,
46
+ "first_name": "Arvin",
47
+ "has_image": true,
48
+ "id": "user_2fPkELglwI48WpZVwwdAxBKBPK6",
49
+ "image_url": "https://img.clerk.com/eyJ0eXBlIjoicHJveHkiLCJzcmMiOiJodHRwczovL2ltYWdlcy5jbGVyay5kZXYvb2F1dGhfZ2l0aHViL2ltZ18yZlBrRU1adVpwdlpvZFBHcVREdHJnTzJJM3cifQ",
50
+ "last_active_at": 1713709987902,
51
+ "last_name": "Xu",
52
+ "last_sign_in_at": null,
53
+ "locked": false,
54
+ "lockout_expires_in_seconds": null,
55
+ "object": "user",
56
+ "passkeys": [],
57
+ "password_enabled": false,
58
+ "phone_numbers": [],
59
+ "primary_email_address_id": "idn_2fPkD9X1lfzSn5lJVDGyochYq8k",
60
+ "primary_phone_number_id": null,
61
+ "primary_web3_wallet_id": null,
62
+ "private_metadata": {},
63
+ "profile_image_url": "https://images.clerk.dev/oauth_github/img_2fPkEMZuZpvZodPGqTDtrgO2I3w",
64
+ "public_metadata": {},
65
+ "saml_accounts": [],
66
+ "totp_enabled": false,
67
+ "two_factor_enabled": false,
68
+ "unsafe_metadata": {},
69
+ "updated_at": 1713709987972,
70
+ "username": "arvinxx",
71
+ "verification_attempts_remaining": 100,
72
+ "web3_wallets": []
73
+ }
@@ -0,0 +1,159 @@
1
+ import { UserJSON } from '@clerk/backend';
2
+ import { NextResponse } from 'next/server';
3
+
4
+ import { authEnv } from '@/config/auth';
5
+ import { isServerMode } from '@/const/version';
6
+ import { UserModel } from '@/database/server/models/user';
7
+ import { pino } from '@/libs/logger';
8
+
9
+ import { validateRequest } from './validateRequest';
10
+
11
+ if (authEnv.NEXT_PUBLIC_ENABLE_CLERK_AUTH && isServerMode && !authEnv.CLERK_WEBHOOK_SECRET) {
12
+ throw new Error('`CLERK_WEBHOOK_SECRET` environment variable is missing');
13
+ }
14
+
15
+ const createUser = async (id: string, params: UserJSON) => {
16
+ pino.info('creating user due to clerk webhook');
17
+
18
+ const userModel = new UserModel();
19
+
20
+ // Check if user already exists
21
+ const res = await userModel.findById(id);
22
+
23
+ // If user already exists, skip creating a new user
24
+ if (res)
25
+ return NextResponse.json(
26
+ { message: 'user not created due to user already existing in the database', success: false },
27
+ { status: 200 },
28
+ );
29
+
30
+ const email = params.email_addresses.find((e) => e.id === params.primary_email_address_id);
31
+ const phone = params.phone_numbers.find((e) => e.id === params.primary_phone_number_id);
32
+
33
+ await userModel.createUser({
34
+ avatar: params.image_url,
35
+ clerkCreatedAt: new Date(params.created_at),
36
+ email: email?.email_address,
37
+ firstName: params.first_name,
38
+ id,
39
+ lastName: params.last_name,
40
+ phone: phone?.phone_number,
41
+ username: params.username,
42
+ });
43
+
44
+ return NextResponse.json({ message: 'user created', success: true }, { status: 200 });
45
+ };
46
+
47
+ const deleteUser = async (id?: string) => {
48
+ if (id) {
49
+ pino.info('delete user due to clerk webhook');
50
+ const userModel = new UserModel();
51
+
52
+ await userModel.deleteUser(id);
53
+
54
+ return NextResponse.json({ message: 'user deleted' }, { status: 200 });
55
+ } else {
56
+ pino.warn('clerk sent a delete user request, but no user ID was included in the payload');
57
+ return NextResponse.json({ message: 'ok' }, { status: 200 });
58
+ }
59
+ };
60
+
61
+ const updateUser = async (id: string, params: UserJSON) => {
62
+ pino.info('updating user due to clerk webhook');
63
+
64
+ const userModel = new UserModel();
65
+
66
+ // Check if user already exists
67
+ const res = await userModel.findById(id);
68
+
69
+ // If user not exists, skip update the user
70
+ if (!res)
71
+ return NextResponse.json(
72
+ {
73
+ message: "user not updated due to the user don't existing in the database",
74
+ success: false,
75
+ },
76
+ { status: 200 },
77
+ );
78
+
79
+ const email = params.email_addresses.find((e) => e.id === params.primary_email_address_id);
80
+ const phone = params.phone_numbers.find((e) => e.id === params.primary_phone_number_id);
81
+
82
+ await userModel.updateUser(id, {
83
+ avatar: params.image_url,
84
+ email: email?.email_address,
85
+ firstName: params.first_name,
86
+ id,
87
+ lastName: params.last_name,
88
+ phone: phone?.phone_number,
89
+ username: params.username,
90
+ });
91
+
92
+ return NextResponse.json({ message: 'user updated', success: true }, { status: 200 });
93
+ };
94
+
95
+ export const POST = async (req: Request): Promise<NextResponse> => {
96
+ const payload = await validateRequest(req, authEnv.CLERK_WEBHOOK_SECRET!);
97
+
98
+ if (!payload) {
99
+ return NextResponse.json(
100
+ { error: 'webhook verification failed or payload was malformed' },
101
+ { status: 400 },
102
+ );
103
+ }
104
+
105
+ const { type, data } = payload;
106
+
107
+ pino.trace(`clerk webhook payload: ${{ data, type }}`);
108
+
109
+ switch (type) {
110
+ case 'user.created': {
111
+ return createUser(data.id, data);
112
+ }
113
+ case 'user.deleted': {
114
+ return deleteUser(data.id);
115
+ }
116
+ case 'user.updated': {
117
+ return updateUser(data.id, data);
118
+ }
119
+
120
+ default: {
121
+ pino.warn(
122
+ `${req.url} received event type "${type}", but no handler is defined for this type`,
123
+ );
124
+ return NextResponse.json({ error: `uncreognised payload type: ${type}` }, { status: 400 });
125
+ }
126
+ // case 'user.updated':
127
+ // break;
128
+ // case 'session.created':
129
+ // break;
130
+ // case 'session.ended':
131
+ // break;
132
+ // case 'session.removed':
133
+ // break;
134
+ // case 'session.revoked':
135
+ // break;
136
+ // case 'email.created':
137
+ // break;
138
+ // case 'sms.created':
139
+ // break;
140
+ // case 'organization.created':
141
+ // break;
142
+ // case 'organization.updated':
143
+ // break;
144
+ // case 'organization.deleted':
145
+ // break;
146
+ // case 'organizationMembership.created':
147
+ // break;
148
+ // case 'organizationMembership.deleted':
149
+ // break;
150
+ // case 'organizationMembership.updated':
151
+ // break;
152
+ // case 'organizationInvitation.accepted':
153
+ // break;
154
+ // case 'organizationInvitation.created':
155
+ // break;
156
+ // case 'organizationInvitation.revoked':
157
+ // break;
158
+ }
159
+ };
@@ -0,0 +1,22 @@
1
+ import { WebhookEvent } from '@clerk/nextjs/server';
2
+ import { headers } from 'next/headers';
3
+ import { Webhook } from 'svix';
4
+
5
+ export const validateRequest = async (request: Request, secret: string) => {
6
+ const payloadString = await request.text();
7
+ const headerPayload = headers();
8
+
9
+ const svixHeaders = {
10
+ 'svix-id': headerPayload.get('svix-id')!,
11
+ 'svix-signature': headerPayload.get('svix-signature')!,
12
+ 'svix-timestamp': headerPayload.get('svix-timestamp')!,
13
+ };
14
+ const wh = new Webhook(secret);
15
+
16
+ try {
17
+ return wh.verify(payloadString, svixHeaders) as WebhookEvent;
18
+ } catch {
19
+ console.error('incoming webhook failed verification');
20
+ return;
21
+ }
22
+ };
@@ -3,7 +3,7 @@ import type { NextRequest } from 'next/server';
3
3
 
4
4
  import { pino } from '@/libs/logger';
5
5
  import { createContext } from '@/server/context';
6
- import { edgeRouter } from '@/server/routers';
6
+ import { edgeRouter } from '@/server/routers/edge';
7
7
 
8
8
  export const runtime = 'edge';
9
9
 
@@ -0,0 +1,26 @@
1
+ import { fetchRequestHandler } from '@trpc/server/adapters/fetch';
2
+ import type { NextRequest } from 'next/server';
3
+
4
+ import { pino } from '@/libs/logger';
5
+ import { createContext } from '@/server/context';
6
+ import { lambdaRouter } from '@/server/routers/lambda';
7
+
8
+ const handler = (req: NextRequest) =>
9
+ fetchRequestHandler({
10
+ /**
11
+ * @link https://trpc.io/docs/v11/context
12
+ */
13
+ createContext: () => createContext(req),
14
+
15
+ endpoint: '/trpc/lambda',
16
+
17
+ onError: ({ error, path, type }) => {
18
+ pino.info(`Error in tRPC handler (lambda) on path: ${path}, type: ${type}`);
19
+ console.error(error);
20
+ },
21
+
22
+ req,
23
+ router: lambdaRouter,
24
+ });
25
+
26
+ export { handler as GET, handler as POST };
@@ -75,6 +75,7 @@ export const getAuthConfig = () => {
75
75
  server: {
76
76
  // Clerk
77
77
  CLERK_SECRET_KEY: z.string().optional(),
78
+ CLERK_WEBHOOK_SECRET: z.string().optional(),
78
79
 
79
80
  // NEXT-AUTH
80
81
  NEXT_AUTH_SECRET: z.string().optional(),
@@ -110,6 +111,7 @@ export const getAuthConfig = () => {
110
111
  NEXT_PUBLIC_ENABLE_CLERK_AUTH: !!process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY,
111
112
  NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY,
112
113
  CLERK_SECRET_KEY: process.env.CLERK_SECRET_KEY,
114
+ CLERK_WEBHOOK_SECRET: process.env.CLERK_WEBHOOK_SECRET,
113
115
 
114
116
  // Next Auth
115
117
  NEXT_PUBLIC_ENABLE_NEXT_AUTH: