create-fullstack-scaffold 0.5.6 → 0.6.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 (54) hide show
  1. package/dist/cli/index.js +80 -22
  2. package/dist/cli/index.js.map +1 -1
  3. package/package.json +12 -12
  4. package/template/package.json +10 -14
  5. package/template/src/cli/modules/tenant/index.ts +89 -0
  6. package/template/src/client/entry-server.tsx +7 -1
  7. package/template/src/client/main.tsx +0 -8
  8. package/template/src/client/pages/ContentDetailPage.tsx +1 -1
  9. package/template/src/client/pages/ContentListPage.tsx +1 -1
  10. package/template/src/client/pages/TodoPage.tsx +20 -4
  11. package/template/src/client/ssr-pages.ts +10 -21
  12. package/template/src/server/db/init.ts +0 -52
  13. package/template/src/server/db/schema/index.ts +3 -0
  14. package/template/src/server/db/schema/tenant-invitations.ts +33 -0
  15. package/template/src/server/db/schema/tenant-members.ts +38 -0
  16. package/template/src/server/db/schema/tenant-roles.ts +32 -0
  17. package/template/src/server/db/schema/todos.ts +4 -0
  18. package/template/src/server/db/test-setup.ts +128 -0
  19. package/template/src/server/entries/node.ts +38 -19
  20. package/template/src/server/index.ts +12 -0
  21. package/template/src/server/middleware/__tests__/tenant-isolation.test.ts +55 -9
  22. package/template/src/server/middleware/auth.ts +17 -19
  23. package/template/src/server/middleware/tenant-isolation.ts +4 -2
  24. package/template/src/server/module-auth/module.ts +2 -1
  25. package/template/src/server/module-auth/services/auth-service.ts +39 -0
  26. package/template/src/server/module-tenant/__tests__/tenant-routes.test.ts +97 -0
  27. package/template/src/server/module-tenant/__tests__/tenant-service.test.ts +201 -22
  28. package/template/src/server/module-tenant/module.ts +1 -1
  29. package/template/src/server/module-tenant/routes/tenant-routes.ts +339 -2
  30. package/template/src/server/module-tenant/services/tenant-service.ts +581 -17
  31. package/template/src/server/module-todos/routes/todos-routes.ts +10 -2
  32. package/template/src/server/module-todos/services/todo-service.ts +12 -2
  33. package/template/src/server/ssr-bridge.ts +10 -0
  34. package/template/src/server/utils/__tests__/captcha.test.ts +6 -4
  35. package/template/src/server/utils/id-helpers.ts +13 -0
  36. package/template/src/shared/modules/index.ts +29 -6
  37. package/template/src/shared/modules/tenant/index.ts +30 -0
  38. package/template/src/shared/modules/tenant/permissions.ts +54 -0
  39. package/template/src/shared/modules/tenant/role-templates.ts +66 -0
  40. package/template/src/shared/modules/tenant/schemas.ts +113 -1
  41. package/template/src/shared/modules/todos/schemas.ts +1 -0
  42. package/template/src/shared/schemas/index.ts +28 -0
  43. package/template/src/tenant/App.tsx +30 -23
  44. package/template/src/tenant/components/TenantGuard.tsx +12 -2
  45. package/template/src/tenant/layouts/Header.tsx +6 -2
  46. package/template/src/tenant/pages/InviteAcceptPage.tsx +97 -0
  47. package/template/src/tenant/pages/LoginPage.tsx +82 -0
  48. package/template/src/tenant/pages/SubscriptionPage.tsx +32 -55
  49. package/template/src/tenant/pages/UsersPage.tsx +134 -88
  50. package/template/src/tenant/services/tenantApi.ts +61 -0
  51. package/template/src/tenant/stores/tenantStore.ts +172 -49
  52. package/template/src/test/setup-db-path.ts +20 -0
  53. package/template/vite.config.ts +4 -73
  54. package/template/vitest.config.ts +3 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-fullstack-scaffold",
3
- "version": "0.5.6",
3
+ "version": "0.6.0",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "create-fullstack-scaffold": "dist/cli/index.js"
@@ -108,7 +108,7 @@
108
108
  "dependencies": {
109
109
  "@hono/node-server": "^1.19.15",
110
110
  "@hono/zod-openapi": "1.2.2",
111
- "@hono/zod-validator": "0.7.6",
111
+ "@hono/zod-validator": "0.8.0",
112
112
  "@inquirer/prompts": "^8.4.2",
113
113
  "@libsql/client": "^0.17.0",
114
114
  "chalk": "^5.6.2",
@@ -118,7 +118,7 @@
118
118
  "fs-extra": "^11.3.5",
119
119
  "hono": "4.12.34",
120
120
  "jsonwebtoken": "^9.0.3",
121
- "lucide-react": "^0.576.0",
121
+ "lucide-react": "^1.16.0",
122
122
  "ora": "^9.4.0",
123
123
  "pino": "^9.7.0",
124
124
  "pino-pretty": "^13.0.0",
@@ -131,12 +131,12 @@
131
131
  "zustand": "^5.0.2"
132
132
  },
133
133
  "devDependencies": {
134
- "@cloudflare/workers-types": "^4.20260503.1",
135
- "@commitlint/cli": "^20.4.3",
136
- "@commitlint/config-conventional": "^20.4.3",
134
+ "@cloudflare/workers-types": "^5.20260910.1",
135
+ "@commitlint/cli": "^21.0.0",
136
+ "@commitlint/config-conventional": "^21.0.0",
137
137
  "@eslint/js": "^9.39.4",
138
138
  "@hono/vite-dev-server": "^0.23.0",
139
- "@playwright/test": "^1.50.0",
139
+ "@playwright/test": "^1.60.0",
140
140
  "@tailwindcss/postcss": "^4.2.1",
141
141
  "@testing-library/dom": "^10.4.1",
142
142
  "@testing-library/jest-dom": "^6.8.0",
@@ -161,20 +161,20 @@
161
161
  "globals": "^16.0.0",
162
162
  "husky": "^9.1.7",
163
163
  "jsdom": "^26.0.3",
164
- "lint-staged": "^15.5.0",
164
+ "lint-staged": "^17.0.5",
165
165
  "minimatch": "^10.2.4",
166
166
  "patch-package": "^8.0.1",
167
- "playwright": "^1.50.0",
167
+ "playwright": "^1.60.0",
168
168
  "postcss": "^8.5.6",
169
169
  "prettier": "^3.5.3",
170
170
  "tailwindcss": "^4.2.1",
171
171
  "ts-import": "^5.0.0-beta.1",
172
172
  "tsup": "^8.5.0",
173
173
  "typescript": "^5.8.3",
174
- "typescript-eslint": "^8.25.0",
174
+ "typescript-eslint": "^8.59.4",
175
175
  "vite": "^6.2.3",
176
176
  "vitest": "^4.0.16",
177
- "wrangler": "^4.75.0"
177
+ "wrangler": "^4.131.0"
178
178
  },
179
179
  "lint-staged": {
180
180
  "*.{ts,tsx,js,jsx}": [
@@ -185,7 +185,7 @@
185
185
  "*.{json,md,css}": "prettier --write"
186
186
  },
187
187
  "engines": {
188
- "node": ">=18.0.0"
188
+ "node": ">=22.0.0"
189
189
  },
190
190
  "overrides": {
191
191
  "esbuild": "^0.25.0"
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "biomimic-todo-app",
3
3
  "private": true,
4
- "version": "0.5.6",
4
+ "version": "0.6.0",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "biomimic": "./dist/cli/index.js"
@@ -75,7 +75,7 @@
75
75
  "@dyyz1993/xcli-core": "^0.7.4",
76
76
  "@hono/node-server": "^1.19.15",
77
77
  "@hono/zod-openapi": "1.2.2",
78
- "@hono/zod-validator": "0.7.6",
78
+ "@hono/zod-validator": "0.8.0",
79
79
  "@ant-design/icons": "^5.6.1",
80
80
  "@libsql/client": "^0.17.0",
81
81
  "antd": "^5.24.0",
@@ -85,7 +85,7 @@
85
85
  "drizzle-orm": "^0.45.1",
86
86
  "hono": "4.12.34",
87
87
  "jsonwebtoken": "^9.0.3",
88
- "lucide-react": "^0.576.0",
88
+ "lucide-react": "^1.16.0",
89
89
  "pino": "^9.7.0",
90
90
  "pino-pretty": "^13.0.0",
91
91
  "react": "^18.3.1",
@@ -99,16 +99,12 @@
99
99
  "zustand": "^5.0.2"
100
100
  },
101
101
  "devDependencies": {
102
- "@cloudflare/workers-types": "^4.20260503.1",
103
- "@commitlint/cli": "^20.4.3",
104
- "@commitlint/config-conventional": "^20.4.3",
102
+ "@cloudflare/workers-types": "^5.20260910.1",
103
+ "@commitlint/cli": "^21.0.0",
104
+ "@commitlint/config-conventional": "^21.0.0",
105
105
  "@eslint/js": "^9.39.4",
106
106
  "@hono/vite-dev-server": "^0.23.0",
107
107
  "@playwright/test": "^1.60.0",
108
- "@prerenderer/renderer-jsdom": "^1.1.9",
109
- "@prerenderer/renderer-puppeteer": "^1.2.4",
110
- "puppeteer": "^24.43.1",
111
- "@prerenderer/rollup-plugin": "^0.3.12",
112
108
  "@tailwindcss/postcss": "^4.2.1",
113
109
  "@testing-library/dom": "^10.4.1",
114
110
  "@testing-library/jest-dom": "^6.8.0",
@@ -133,7 +129,7 @@
133
129
  "globals": "^16.0.0",
134
130
  "husky": "^9.1.7",
135
131
  "jsdom": "^26.0.3",
136
- "lint-staged": "^15.5.0",
132
+ "lint-staged": "^17.0.5",
137
133
  "minimatch": "^10.2.4",
138
134
  "patch-package": "^8.0.0",
139
135
  "playwright": "^1.60.0",
@@ -143,10 +139,10 @@
143
139
  "tsup": "^8.5.0",
144
140
  "tsx": "^4.21.0",
145
141
  "typescript": "^5.8.3",
146
- "typescript-eslint": "^8.25.0",
142
+ "typescript-eslint": "^8.59.4",
147
143
  "vite": "^6.2.3",
148
144
  "vitest": "^4.0.16",
149
- "wrangler": "^4.75.0"
145
+ "wrangler": "^4.131.0"
150
146
  },
151
147
  "lint-staged": {
152
148
  "*.{ts,tsx,js,jsx}": [
@@ -159,7 +155,7 @@
159
155
  ]
160
156
  },
161
157
  "engines": {
162
- "node": ">=18.0.0"
158
+ "node": ">=22.0.0"
163
159
  },
164
160
  "overrides": {
165
161
  "esbuild": "^0.25.0"
@@ -139,4 +139,93 @@ export function registerTenantCommands(site: SiteInstance) {
139
139
  }
140
140
  },
141
141
  })
142
+
143
+ site.command('members', {
144
+ description: 'List members of a tenant',
145
+ parameters: z.object({
146
+ id: z.string().describe('Tenant ID'),
147
+ }),
148
+ handler: async (params: unknown) => {
149
+ const p = params as { id: string }
150
+ try {
151
+ const client = getClient()
152
+ const res = await client.api.tenants[':tenantId'].members.$get({
153
+ param: { tenantId: p.id },
154
+ })
155
+ const data = await res.json()
156
+ return ok(data)
157
+ } catch (err) {
158
+ return fail(err instanceof Error ? err.message : 'Failed to list members')
159
+ }
160
+ },
161
+ })
162
+
163
+ site.command('roles', {
164
+ description: 'List roles of a tenant',
165
+ parameters: z.object({
166
+ id: z.string().describe('Tenant ID'),
167
+ }),
168
+ handler: async (params: unknown) => {
169
+ const p = params as { id: string }
170
+ try {
171
+ const client = getClient()
172
+ const res = await client.api.tenants[':tenantId'].roles.$get({
173
+ param: { tenantId: p.id },
174
+ })
175
+ const data = await res.json()
176
+ return ok(data)
177
+ } catch (err) {
178
+ return fail(err instanceof Error ? err.message : 'Failed to list roles')
179
+ }
180
+ },
181
+ })
182
+
183
+ site.command('invite', {
184
+ description: 'Invite a member to a tenant (returns a 7-day invitation link)',
185
+ parameters: z.object({
186
+ id: z.string().describe('Tenant ID'),
187
+ email: z.string().describe('Invitee email'),
188
+ 'role-id': z.string().describe('Role ID (see tenant roles command)'),
189
+ }),
190
+ handler: async (params: unknown) => {
191
+ const p = params as { id: string; email: string; 'role-id': string }
192
+ try {
193
+ const client = getClient()
194
+ const res = await client.api.tenants[':tenantId'].members.invite.$post({
195
+ param: { tenantId: p.id },
196
+ json: { email: p.email, roleId: p['role-id'] },
197
+ })
198
+ const data = await res.json()
199
+ if (data.success && data.data?.token) {
200
+ return ok({
201
+ ...data,
202
+ data: { ...data.data, inviteLink: `/tenant/invite/${data.data.token}` },
203
+ })
204
+ }
205
+ return ok(data)
206
+ } catch (err) {
207
+ return fail(err instanceof Error ? err.message : 'Failed to invite member')
208
+ }
209
+ },
210
+ })
211
+
212
+ site.command('invitation', {
213
+ description: 'Show invitation detail by token (public)',
214
+ parameters: z.object({
215
+ token: z.string().describe('Invitation token'),
216
+ }),
217
+ handler: async (params: unknown) => {
218
+ const p = params as { token: string }
219
+ try {
220
+ const client = getClient()
221
+ const res = await client.api.tenants.invitations[':token'].$get({
222
+ param: { token: p.token },
223
+ })
224
+ const data = await res.json()
225
+ return ok(data)
226
+ } catch (err) {
227
+ return fail(err instanceof Error ? err.message : 'Failed to get invitation')
228
+ }
229
+ },
230
+ })
142
231
  }
@@ -50,6 +50,11 @@ export function renderSSR(pathname: string, data: SSRData): SSRRenderResult {
50
50
  // 4. Render with helmet context to extract head tags
51
51
  const helmetContext: Record<string, unknown> = {}
52
52
 
53
+ // 服务端把 ISR 数据挂到 globalThis:组件的 ssrInitial*() 在 renderToString
54
+ // 期间与浏览器水合同源读取(globalThis === window),首帧即渲染真实内容
55
+ const globalData = globalThis as { __SSR_DATA__?: unknown }
56
+ globalData.__SSR_DATA__ = data
57
+
53
58
  try {
54
59
  // 页面级 SSR:ISR 路由有静态同构组件(ssr-pages)——渲染真实页面
55
60
  // 内容而非 lazy Loading 壳(SEO 核心);其余路由回退 AppRoutes 壳。
@@ -78,7 +83,8 @@ export function renderSSR(pathname: string, data: SSRData): SSRRenderResult {
78
83
 
79
84
  return { html, helmet: { title: titleStr, meta: metaStr } }
80
85
  } finally {
81
- // 6. Restore store state
86
+ // 6. Restore store state + 清理服务端数据挂载(防跨请求泄漏)
82
87
  restoreEntryStores(snapshot)
88
+ delete globalData.__SSR_DATA__
83
89
  }
84
90
  }
@@ -85,11 +85,3 @@ if (hasSsrMarkup) {
85
85
  </React.StrictMode>
86
86
  )
87
87
  }
88
-
89
- if (typeof window !== 'undefined') {
90
- requestAnimationFrame(() => {
91
- setTimeout(() => {
92
- document.dispatchEvent(new CustomEvent('prerender-ready'))
93
- }, 100)
94
- })
95
- }
@@ -7,7 +7,7 @@ import { apiClient } from '@client/services/apiClient'
7
7
  // SSR 首帧数据:ISR 服务端写入 __SSR_DATA__.content(详情页按 id 匹配)
8
8
  function ssrInitialContent(id?: string): Content | null {
9
9
  try {
10
- const d = (window as unknown as { __SSR_DATA__?: { content?: Content | null } }).__SSR_DATA__
10
+ const d = (globalThis as { __SSR_DATA__?: { content?: Content | null } }).__SSR_DATA__
11
11
  const c = d?.content ?? null
12
12
  return c && (!id || c.id === id) ? c : null
13
13
  } catch {
@@ -17,7 +17,7 @@ const CATEGORIES: { value: ContentCategory | ''; label: string }[] = [
17
17
  // 直接用它渲染(水合一致、爬虫可见真实内容),effect 再刷新
18
18
  function ssrInitialContents(): Content[] {
19
19
  try {
20
- const d = (window as unknown as { __SSR_DATA__?: { contents?: Content[] } }).__SSR_DATA__
20
+ const d = (globalThis as { __SSR_DATA__?: { contents?: Content[] } }).__SSR_DATA__
21
21
  return d?.contents ?? []
22
22
  } catch {
23
23
  return []
@@ -22,6 +22,19 @@ import type { Todo } from '@shared/schemas'
22
22
 
23
23
  type FilterType = 'all' | 'pending' | 'in_progress' | 'completed'
24
24
 
25
+ // SSR 首帧数据:ISR 管线把 registry fetch 的数据写进 __SSR_DATA__,
26
+ // 服务端 renderToString 与浏览器水合从同一 globalThis 读取。zustand v5 的
27
+ // 服务端快照固定为 getInitialState(setState 种子对 SSR 不可见),首帧
28
+ // 列表必须从这里兜底,挂载后 effect 再走 store 刷新
29
+ function ssrInitialTodos(): Todo[] {
30
+ try {
31
+ const d = (globalThis as { __SSR_DATA__?: { todos?: Todo[] } }).__SSR_DATA__
32
+ return d?.todos ?? []
33
+ } catch {
34
+ return []
35
+ }
36
+ }
37
+
25
38
  export const TodoPage: React.FC = () => {
26
39
  const todos = useTodoStore(state => state.todos)
27
40
  const loading = useTodoStore(state => state.loading)
@@ -37,6 +50,9 @@ export const TodoPage: React.FC = () => {
37
50
 
38
51
  const [newTodoTitle, setNewTodoTitle] = useState('')
39
52
  const [newTodoDescription, setNewTodoDescription] = useState('')
53
+ const [ssrTodos] = useState<Todo[]>(ssrInitialTodos)
54
+ // store 一旦有数据(挂载后 fetch 完成)即以 store 为准,首帧用 SSR 数据兜底
55
+ const visibleTodos = todos.length > 0 ? todos : ssrTodos
40
56
  const [filter, setFilter] = useState<FilterType>('all')
41
57
  const [expandedTodoId, setExpandedTodoId] = useState<number | null>(null)
42
58
  const fileInputRefs = useRef<Map<number, HTMLInputElement>>(new Map())
@@ -95,7 +111,7 @@ export const TodoPage: React.FC = () => {
95
111
  return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
96
112
  }
97
113
 
98
- const filteredTodos = todos.filter(todo => {
114
+ const filteredTodos = visibleTodos.filter(todo => {
99
115
  if (filter === 'all') return true
100
116
  return todo.status === filter
101
117
  })
@@ -174,17 +190,17 @@ export const TodoPage: React.FC = () => {
174
190
  </div>
175
191
  )}
176
192
 
177
- {loading && todos.length === 0 && (
193
+ {loading && visibleTodos.length === 0 && (
178
194
  <div className="flex items-center justify-center py-12" data-testid="loading-indicator">
179
195
  <LoadingSpinner size="lg" />
180
196
  </div>
181
197
  )}
182
198
 
183
- {todos.length > 0 && (
199
+ {visibleTodos.length > 0 && (
184
200
  <div className="mb-6 flex items-center justify-between">
185
201
  <div className="flex items-center gap-2" data-testid="todo-count">
186
202
  <span className="text-sm text-gray-500">Total:</span>
187
- <span className="text-sm font-medium text-gray-900">{todos.length}</span>
203
+ <span className="text-sm font-medium text-gray-900">{visibleTodos.length}</span>
188
204
  </div>
189
205
  <div className="flex items-center gap-2">
190
206
  <button
@@ -1,20 +1,11 @@
1
1
  /**
2
- * SSR 静态页面注册表(替代 preset-ui-config 的 lazy 组件用于服务端渲染)。
2
+ * SSR 静态页面注册表(模板全量版;CLI preset 重新生成,只导入实际存在的页面)。
3
3
  *
4
- * 为什么需要它:renderToString 是同步的,解析不了 React.lazy 的动态
5
- * import——直接用 preset-ui-config 会让 SSR body 永远是 Suspense 的
6
- * Loading 壳。这里用静态 import 提供同构组件映射,页面内容在服务端
7
- * 真正渲染进 HTML(ISR/SEO 的核心价值所在)。
8
- *
9
- * 客户端不走这里(CSR 仍用 lazy 代码分割),水合由 entry-server 渲染的
10
- * HTML + main.tsx 的 hydrateRoot 完成——两侧组件树来自同一源码,静态
11
- * 导入版多打一份包只进 server bundle(tsup 的 CF 构建),不影响 client
12
- * chunk 体积。
4
+ * renderToString 解析不了 React.lazy——ISR 路由的页面级 SSR 依赖这里的
5
+ * 静态同构组件,页面内容才真正渲染进 HTML(SEO/首屏的核心价值)。
13
6
  */
14
7
 
15
8
  import type { ComponentType } from 'react'
16
-
17
- // 按需静态导入 ISR 场景的页面(内容型、需要 SEO 的页面)
18
9
  import { TodoPage } from './pages/TodoPage'
19
10
  import { ContentListPage } from './pages/ContentListPage'
20
11
  import { ContentDetailPage } from './pages/ContentDetailPage'
@@ -22,11 +13,6 @@ import { TopicsPage } from './pages/TopicsPage'
22
13
 
23
14
  export type SsrPageComponent = ComponentType
24
15
 
25
- /**
26
- * 路由 → 同构组件映射。
27
- * 未列出的路由(管理后台、登录页等)SSR 输出布局壳——它们本来就不
28
- * 需要 SEO(登录后才可见),客户端 lazy 照常工作。
29
- */
30
16
  export const SSR_PAGES: Record<string, SsrPageComponent> = {
31
17
  '/todos': TodoPage,
32
18
  '/content': ContentListPage,
@@ -34,11 +20,14 @@ export const SSR_PAGES: Record<string, SsrPageComponent> = {
34
20
  '/topics': TopicsPage,
35
21
  }
36
22
 
37
- /** ISR 路径是否有关联的静态组件(决定 body 渲染还是仅 meta) */
23
+ const SSR_PATTERNS: Array<{ prefix: string; component: SsrPageComponent }> = [
24
+ { prefix: '/content/', component: ContentDetailPage },
25
+ ]
26
+
38
27
  export function getSsrPage(pathname: string): SsrPageComponent | null {
39
28
  if (SSR_PAGES[pathname]) return SSR_PAGES[pathname]
40
- // 详情页模式 /content/123
41
- const m = pathname.match(/^\/content\/[^/]+$/)
42
- if (m) return SSR_PAGES['/content/:id']
29
+ for (const p of SSR_PATTERNS) {
30
+ if (pathname.startsWith(p.prefix)) return p.component
31
+ }
43
32
  return null
44
33
  }
@@ -1,9 +1,6 @@
1
1
  import { getDb } from './driver'
2
2
  import { permissions, roles, rolePermissions } from './schema'
3
- import { developers } from './schema/developers'
4
3
  import { logger } from '../utils/logger'
5
- import { generateUUID } from '../utils/uuid'
6
- import { hash } from 'bcryptjs'
7
4
 
8
5
  const log = logger.db()
9
6
 
@@ -354,53 +351,6 @@ const initialRolePermissions = [
354
351
  { roleId: 'role_user', permissionId: 'perm_order_view' },
355
352
  ]
356
353
 
357
- async function seedDevelopersIfEmpty() {
358
- try {
359
- const db = await getDb()
360
-
361
- const existing = await db.select().from(developers)
362
- if (existing.length > 0) return
363
-
364
- log.info({}, 'Seeding developers...')
365
-
366
- const demoPasswordHash = await hash('demo123', 10)
367
- const adminPasswordHash = await hash('admin123', 10)
368
-
369
- const sampleDevelopers = [
370
- {
371
- id: generateUUID(),
372
- username: 'demo',
373
- email: 'demo@biomimic.app',
374
- passwordHash: demoPasswordHash,
375
- role: 'developer' as const,
376
- apiKey: generateUUID(),
377
- },
378
- {
379
- id: generateUUID(),
380
- username: 'superadmin',
381
- email: 'admin@biomimic.app',
382
- passwordHash: adminPasswordHash,
383
- role: 'admin' as const,
384
- apiKey: generateUUID(),
385
- },
386
- ]
387
-
388
- await db.insert(developers).values(sampleDevelopers)
389
-
390
- log.info({}, 'Developers seeding complete!')
391
- } catch (error) {
392
- // 如果表不存在,忽略错误(可能在某些模板中不需要 developers 表)
393
- if (
394
- error instanceof Error &&
395
- (error.message.includes('no such table') || error.message.includes('does not exist'))
396
- ) {
397
- log.debug({}, 'Developers table not found, skipping seed')
398
- return
399
- }
400
- throw error
401
- }
402
- }
403
-
404
354
  export async function initializeDatabase() {
405
355
  const db = await getDb()
406
356
 
@@ -445,8 +395,6 @@ export async function initializeDatabase() {
445
395
  )
446
396
  }
447
397
 
448
- await seedDevelopersIfEmpty()
449
-
450
398
  log.info({}, 'Database initialization complete!')
451
399
 
452
400
  log.info({}, 'Seeding module data...')
@@ -15,5 +15,8 @@ export * from './contents'
15
15
  export * from './developers'
16
16
  export * from './plugins'
17
17
  export * from './tenants'
18
+ export * from './tenant-roles'
19
+ export * from './tenant-members'
20
+ export * from './tenant-invitations'
18
21
  export * from './merchants'
19
22
  export * from './products'
@@ -0,0 +1,33 @@
1
+ import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core'
2
+ import { tenants } from './tenants'
3
+ import { tenantRoles } from './tenant-roles'
4
+
5
+ /**
6
+ * 租户邀请(email + 一次性 token,默认 7 天过期)。
7
+ * token 随邀请链接分发:/invite/:token 前端页凭 token 查详情并接受。
8
+ */
9
+ export const tenantInvitations = sqliteTable('tenant_invitations', {
10
+ id: text('id').primaryKey(),
11
+ tenantId: integer('tenant_id')
12
+ .notNull()
13
+ .references(() => tenants.id, { onDelete: 'cascade' }),
14
+ email: text('email').notNull(),
15
+ roleId: text('role_id')
16
+ .notNull()
17
+ .references(() => tenantRoles.id, { onDelete: 'cascade' }),
18
+ inviterId: text('inviter_id').notNull(),
19
+ token: text('token').notNull().unique(),
20
+ status: text('status', {
21
+ enum: ['pending', 'accepted', 'declined', 'expired', 'cancelled'],
22
+ })
23
+ .notNull()
24
+ .default('pending'),
25
+ expiresAt: text('expires_at').notNull(),
26
+ acceptedAt: text('accepted_at'),
27
+ createdAt: text('created_at').notNull(),
28
+ })
29
+
30
+ export type TenantInvitationTable = typeof tenantInvitations.$inferSelect
31
+ export type NewTenantInvitation = typeof tenantInvitations.$inferInsert
32
+
33
+ export type InvitationStatus = 'pending' | 'accepted' | 'declined' | 'expired' | 'cancelled'
@@ -0,0 +1,38 @@
1
+ import { sqliteTable, text, integer, unique } from 'drizzle-orm/sqlite-core'
2
+ import { tenants } from './tenants'
3
+ import { tenantRoles } from './tenant-roles'
4
+
5
+ /**
6
+ * 租户成员(用户与租户的归属关系)。userId 指向认证体系(developers.id /
7
+ * dev token 用户 id),删除为软删(status='left')保留审计轨迹。
8
+ */
9
+ export const tenantMembers = sqliteTable(
10
+ 'tenant_members',
11
+ {
12
+ id: text('id').primaryKey(),
13
+ tenantId: integer('tenant_id')
14
+ .notNull()
15
+ .references(() => tenants.id, { onDelete: 'cascade' }),
16
+ userId: text('user_id').notNull(),
17
+ roleId: text('role_id')
18
+ .notNull()
19
+ .references(() => tenantRoles.id, { onDelete: 'cascade' }),
20
+ status: text('status', {
21
+ enum: ['active', 'pending', 'suspended', 'left'],
22
+ })
23
+ .notNull()
24
+ .default('active'),
25
+ invitedBy: text('invited_by'),
26
+ invitedAt: text('invited_at'),
27
+ joinedAt: text('joined_at').notNull(),
28
+ lastActiveAt: text('last_active_at'),
29
+ },
30
+ table => ({
31
+ userTenantUnique: unique().on(table.userId, table.tenantId),
32
+ })
33
+ )
34
+
35
+ export type TenantMemberTable = typeof tenantMembers.$inferSelect
36
+ export type NewTenantMember = typeof tenantMembers.$inferInsert
37
+
38
+ export type TenantMemberStatus = 'active' | 'pending' | 'suspended' | 'left'
@@ -0,0 +1,32 @@
1
+ import { sqliteTable, text, integer, unique } from 'drizzle-orm/sqlite-core'
2
+ import { tenants } from './tenants'
3
+
4
+ /**
5
+ * 租户内自定义角色(每租户独立,与平台级 roles 表区分)。
6
+ * permissions 存 TenantPermission 枚举数组的 JSON 序列化。
7
+ */
8
+ export const tenantRoles = sqliteTable(
9
+ 'tenant_roles',
10
+ {
11
+ id: text('id').primaryKey(),
12
+ tenantId: integer('tenant_id')
13
+ .notNull()
14
+ .references(() => tenants.id, { onDelete: 'cascade' }),
15
+ code: text('code').notNull(),
16
+ name: text('name').notNull(),
17
+ label: text('label').notNull(),
18
+ description: text('description'),
19
+ permissions: text('permissions').notNull(),
20
+ isSystem: integer('is_system', { mode: 'boolean' }).notNull().default(false),
21
+ isActive: integer('is_active', { mode: 'boolean' }).notNull().default(true),
22
+ sortOrder: integer('sort_order').notNull().default(0),
23
+ createdAt: text('created_at').notNull(),
24
+ updatedAt: text('updated_at').notNull(),
25
+ },
26
+ table => ({
27
+ tenantCodeUnique: unique().on(table.tenantId, table.code),
28
+ })
29
+ )
30
+
31
+ export type TenantRoleTable = typeof tenantRoles.$inferSelect
32
+ export type NewTenantRole = typeof tenantRoles.$inferInsert
@@ -8,6 +8,9 @@ export const todos = sqliteTable(
8
8
  'todos',
9
9
  {
10
10
  id: integer('id').primaryKey({ autoIncrement: true }),
11
+ // 租户归属(saas 等含 tenant 模块的 preset 由隔离中间件填充;其他
12
+ // preset 恒为 null,列对它们无害)
13
+ tenantId: integer('tenant_id'),
11
14
  title: text('title').notNull(),
12
15
  description: text('description'),
13
16
  status: text('status', { enum: todoStatus }).notNull().default('pending'),
@@ -20,6 +23,7 @@ export const todos = sqliteTable(
20
23
  },
21
24
  table => ({
22
25
  statusIdx: index('todos_status_idx').on(table.status),
26
+ tenantIdx: index('todos_tenant_idx').on(table.tenantId),
23
27
  createdAtIdx: index('todos_created_at_idx').on(table.createdAt),
24
28
  updatedAtIdx: index('todos_updated_at_idx').on(table.updatedAt),
25
29
  })