create-fullstack-scaffold 0.1.1 → 0.2.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 (155) hide show
  1. package/dist/cli/index.js +1673 -696
  2. package/dist/cli/index.js.map +1 -1
  3. package/package.json +16 -10
  4. package/template/.husky/pre-commit +1 -1
  5. package/template/modules.config.ts +29 -2
  6. package/template/package.json +12 -12
  7. package/template/patches/{typescript+5.8.3.patch → typescript+5.9.3.patch} +2 -4
  8. package/template/playwright.config.ts +7 -1
  9. package/template/src/admin/App.tsx +2 -2
  10. package/template/src/admin/components/CaptchaModal.tsx +7 -9
  11. package/template/src/admin/layouts/Header.tsx +56 -22
  12. package/template/src/admin/layouts/Layout.tsx +14 -9
  13. package/template/src/admin/layouts/Sidebar.tsx +122 -105
  14. package/template/src/admin/pages/CategoryManagementPage.tsx +241 -0
  15. package/template/src/admin/pages/ContentPage.tsx +2 -0
  16. package/template/src/admin/pages/DashboardPage.tsx +2 -0
  17. package/template/src/admin/pages/DisputesPage.tsx +2 -0
  18. package/template/src/admin/pages/MediaTestPage.tsx +1 -1
  19. package/template/src/admin/pages/OrdersPage.tsx +2 -0
  20. package/template/src/admin/pages/PluginDashboardPage.tsx +297 -0
  21. package/template/src/admin/pages/PluginManagementPage.tsx +340 -0
  22. package/template/src/admin/pages/PluginReviewPage.tsx +255 -0
  23. package/template/src/admin/pages/SystemLogsPage.tsx +11 -2
  24. package/template/src/admin/pages/TicketsPage.tsx +2 -0
  25. package/template/src/admin/pages/UsersPage.tsx +3 -2
  26. package/template/src/admin/stores/adminStore.ts +5 -0
  27. package/template/src/cli/index.ts +17 -19
  28. package/template/src/cli/modules/auth/index.ts +65 -0
  29. package/template/src/cli/modules/config/index.ts +74 -77
  30. package/template/src/cli/modules/index.ts +28 -6
  31. package/template/src/cli/modules/notification/index.ts +95 -79
  32. package/template/src/cli/modules/plugin/index.ts +111 -0
  33. package/template/src/cli/modules/todo/index.ts +99 -51
  34. package/template/src/cli/utils/auto-command.ts +7 -25
  35. package/template/src/cli/utils/index.ts +3 -1
  36. package/template/src/client/App.tsx +36 -16
  37. package/template/src/client/Layout.tsx +67 -10
  38. package/template/src/client/components/AuthButton.tsx +25 -18
  39. package/template/src/client/components/BottomTabBar.tsx +107 -0
  40. package/template/src/client/components/Navigation.tsx +162 -52
  41. package/template/src/client/components/__tests__/App.test.tsx +48 -32
  42. package/template/src/client/components/__tests__/AuthButton.test.tsx +78 -77
  43. package/template/src/client/components/__tests__/Navigation.test.tsx +31 -20
  44. package/template/src/client/components/index.ts +1 -0
  45. package/template/src/client/contexts/PresetContext.tsx +10 -0
  46. package/template/src/client/main.tsx +63 -8
  47. package/template/src/client/pages/CartPage.tsx +244 -0
  48. package/template/src/client/pages/CategoriesPage.tsx +100 -0
  49. package/template/src/client/pages/ContentDetailPage.tsx +9 -14
  50. package/template/src/client/pages/ContentListPage.tsx +4 -11
  51. package/template/src/client/pages/DashboardPage.tsx +261 -0
  52. package/template/src/client/pages/DeveloperDashboardPage.tsx +211 -0
  53. package/template/src/client/pages/LoginPage.tsx +127 -0
  54. package/template/src/client/pages/OrdersPage.tsx +196 -0
  55. package/template/src/client/pages/PluginDetailPage.tsx +345 -0
  56. package/template/src/client/pages/PluginsPage.tsx +223 -0
  57. package/template/src/client/pages/ProfilePage.tsx +206 -0
  58. package/template/src/client/pages/PublishPage.tsx +336 -0
  59. package/template/src/client/pages/RegisterPage.tsx +136 -0
  60. package/template/src/client/pages/SearchPage.tsx +204 -0
  61. package/template/src/client/pages/SettingsPage.tsx +220 -0
  62. package/template/src/client/pages/TopicsPage.tsx +180 -0
  63. package/template/src/client/pages/__tests__/LoginPage.test.tsx +170 -0
  64. package/template/src/client/pages/__tests__/RegisterPage.test.tsx +168 -0
  65. package/template/src/client/preset-ui-config.ts +492 -0
  66. package/template/src/client/services/apiClient.ts +14 -4
  67. package/template/src/client/stores/__tests__/authStore.test.ts +293 -38
  68. package/template/src/client/stores/__tests__/todoStore.test.ts +7 -17
  69. package/template/src/client/stores/authStore.ts +58 -5
  70. package/template/src/client/stores/chatWSStore.ts +9 -0
  71. package/template/src/client/stores/notificationStore.ts +4 -0
  72. package/template/src/client/stores/pluginStore.ts +279 -0
  73. package/template/src/client/stores/todoStore.ts +2 -6
  74. package/template/src/server/core/__tests__/isr-cache.test.ts +117 -0
  75. package/template/src/server/core/__tests__/isr-invalidation.test.ts +72 -0
  76. package/template/src/server/core/__tests__/ssr-renderer.test.ts +89 -0
  77. package/template/src/server/core/isr-cache.ts +239 -0
  78. package/template/src/server/core/isr-invalidation.ts +45 -0
  79. package/template/src/server/core/module-loader.ts +14 -7
  80. package/template/src/server/core/ssr-renderer.ts +240 -0
  81. package/template/src/server/db/init.ts +257 -10
  82. package/template/src/server/db/schema/developers.ts +20 -0
  83. package/template/src/server/db/schema/index.ts +2 -0
  84. package/template/src/server/db/schema/plugins.ts +114 -0
  85. package/template/src/server/db/test-setup.ts +91 -0
  86. package/template/src/server/entries/cloudflare.ts +79 -7
  87. package/template/src/server/entries/node.ts +48 -5
  88. package/template/src/server/middleware/__tests__/captcha.test.ts +23 -13
  89. package/template/src/server/middleware/auth.ts +8 -1
  90. package/template/src/server/middleware/captcha.ts +14 -4
  91. package/template/src/server/middleware/rate-limit.ts +7 -2
  92. package/template/src/server/module-admin/module.ts +9 -5
  93. package/template/src/server/module-admin/routes/admin-notification-routes.ts +43 -14
  94. package/template/src/server/module-admin/routes/admin-routes.ts +0 -2
  95. package/template/src/server/module-admin/routes/client-auth-routes.ts +90 -0
  96. package/template/src/server/module-admin/routes/dashboard-routes.ts +79 -0
  97. package/template/src/server/module-admin/services/admin-service.ts +68 -11
  98. package/template/src/server/module-auth/__tests__/auth-service.test.ts +239 -0
  99. package/template/src/server/module-auth/index.ts +7 -0
  100. package/template/src/server/module-auth/module.ts +40 -0
  101. package/template/src/server/module-auth/routes/auth-routes.ts +94 -0
  102. package/template/src/server/module-auth/routes/profile-routes.ts +31 -0
  103. package/template/src/server/module-auth/services/auth-service.ts +100 -0
  104. package/template/src/server/module-content/module.ts +10 -4
  105. package/template/src/server/module-content/routes/public-content-routes.ts +2 -2
  106. package/template/src/server/module-content/routes/topics-routes.ts +205 -0
  107. package/template/src/server/module-content/services/content-service.ts +18 -2
  108. package/template/src/server/module-dispute/routes/dispute-routes.ts +2 -1
  109. package/template/src/server/module-dispute/services/dispute-service.ts +1 -1
  110. package/template/src/server/module-notifications/__tests__/sse-rpc.test.ts +14 -14
  111. package/template/src/server/module-notifications/routes/notification-routes.ts +34 -8
  112. package/template/src/server/module-order/__tests__/order-route.test.ts +22 -2
  113. package/template/src/server/module-order/module.ts +15 -0
  114. package/template/src/server/module-order/routes/cart-routes.ts +103 -0
  115. package/template/src/server/module-order/routes/orders-mock-routes.ts +67 -0
  116. package/template/src/server/module-order/services/order-service.ts +1 -1
  117. package/template/src/server/module-plugin/__tests__/plugin-query-service.test.ts +203 -0
  118. package/template/src/server/module-plugin/__tests__/plugin-service.test.ts +234 -0
  119. package/template/src/server/module-plugin/index.ts +2 -0
  120. package/template/src/server/module-plugin/module.ts +52 -0
  121. package/template/src/server/module-plugin/routes/plugin-admin-routes.ts +261 -0
  122. package/template/src/server/module-plugin/routes/plugin-routes.ts +354 -0
  123. package/template/src/server/module-plugin/services/admin-category-service.ts +99 -0
  124. package/template/src/server/module-plugin/services/admin-plugin-service.ts +170 -0
  125. package/template/src/server/module-plugin/services/admin-stats-service.ts +41 -0
  126. package/template/src/server/module-plugin/services/plugin-query-service.ts +360 -0
  127. package/template/src/server/module-plugin/services/plugin-review-service.ts +95 -0
  128. package/template/src/server/module-plugin/services/plugin-service.ts +163 -0
  129. package/template/src/server/module-ticket/services/ticket-service.ts +1 -1
  130. package/template/src/server/module-todos/routes/todos-routes.ts +0 -4
  131. package/template/src/server/route-registry.ts +16 -1
  132. package/template/src/server/test-utils/test-client.ts +1 -2
  133. package/template/src/server/utils/auth.ts +6 -0
  134. package/template/src/server/utils/json.ts +13 -0
  135. package/template/src/shared/core/module-manifest.ts +3 -6
  136. package/template/src/shared/modules/auth/index.ts +12 -0
  137. package/template/src/shared/modules/auth/schemas.ts +50 -0
  138. package/template/src/shared/modules/cart/index.ts +1 -0
  139. package/template/src/shared/modules/cart/schemas.ts +41 -0
  140. package/template/src/shared/modules/community/index.ts +1 -0
  141. package/template/src/shared/modules/community/schemas.ts +58 -0
  142. package/template/src/shared/modules/dashboard/index.ts +1 -0
  143. package/template/src/shared/modules/dashboard/schemas.ts +35 -0
  144. package/template/src/shared/modules/index.ts +41 -0
  145. package/template/src/shared/modules/order/schemas.ts +29 -0
  146. package/template/src/shared/modules/plugins/index.ts +48 -0
  147. package/template/src/shared/modules/plugins/schemas.ts +227 -0
  148. package/template/src/shared/schemas/index.ts +130 -0
  149. package/template/tests/e2e/todo.spec.ts +23 -18
  150. package/template/tests/e2e/visual-screenshots.spec.ts +1824 -0
  151. package/template/uploads/.gitkeep +0 -0
  152. package/template/vite.config.ts +2 -1
  153. package/template/vitest.setup.ts +11 -0
  154. package/template/wrangler.toml +4 -3
  155. package/template/package-lock.json +0 -14554
@@ -0,0 +1,170 @@
1
+ import { eq, desc } from 'drizzle-orm'
2
+ import type { Plugin } from '@shared/schemas'
3
+ import { getDb, getRawClient } from '@server/db'
4
+ import { plugins } from '@server/db/schema'
5
+ import { NotFoundError } from '@server/utils/app-error'
6
+ import { mapRow } from './plugin-service'
7
+
8
+ export interface AdminListOptions {
9
+ page?: number
10
+ limit?: number
11
+ status?: string | null
12
+ }
13
+
14
+ export async function listPending(
15
+ options: AdminListOptions = {}
16
+ ): Promise<{ plugins: Plugin[]; total: number; page: number; limit: number }> {
17
+ const db = await getDb()
18
+ const page = options.page ?? 1
19
+ const limit = options.limit ?? 20
20
+ const offset = (page - 1) * limit
21
+
22
+ const rows = await db
23
+ .select()
24
+ .from(plugins)
25
+ .where(eq(plugins.status, 'pending'))
26
+ .orderBy(desc(plugins.createdAt))
27
+ .limit(limit)
28
+ .offset(offset)
29
+
30
+ const client = await getRawClient()
31
+ let total = 0
32
+ if (client && 'execute' in client) {
33
+ const result = await client.execute(
34
+ "SELECT COUNT(*) as count FROM plugins WHERE status = 'pending'"
35
+ )
36
+ total = (result.rows[0] as unknown as { count: number })?.count ?? 0
37
+ }
38
+
39
+ return { plugins: rows.map(mapRow), total, page, limit }
40
+ }
41
+
42
+ export async function approvePlugin(slug: string): Promise<Plugin> {
43
+ const db = await getDb()
44
+
45
+ const rows = await db.select().from(plugins).where(eq(plugins.slug, slug))
46
+ if (rows.length === 0) {
47
+ throw new NotFoundError('Plugin', slug)
48
+ }
49
+
50
+ const result = await db
51
+ .update(plugins)
52
+ .set({ status: 'approved', rejectReason: null, updatedAt: new Date() })
53
+ .where(eq(plugins.slug, slug))
54
+ .returning()
55
+ return mapRow(result[0])
56
+ }
57
+
58
+ export async function rejectPlugin(slug: string, reason: string): Promise<Plugin> {
59
+ const db = await getDb()
60
+
61
+ const rows = await db.select().from(plugins).where(eq(plugins.slug, slug))
62
+ if (rows.length === 0) {
63
+ throw new NotFoundError('Plugin', slug)
64
+ }
65
+
66
+ const result = await db
67
+ .update(plugins)
68
+ .set({ status: 'rejected', rejectReason: reason, updatedAt: new Date() })
69
+ .where(eq(plugins.slug, slug))
70
+ .returning()
71
+ return mapRow(result[0])
72
+ }
73
+
74
+ export async function toggleFeatured(slug: string): Promise<Plugin> {
75
+ const db = await getDb()
76
+
77
+ const rows = await db.select().from(plugins).where(eq(plugins.slug, slug))
78
+ if (rows.length === 0) {
79
+ throw new NotFoundError('Plugin', slug)
80
+ }
81
+
82
+ const result = await db
83
+ .update(plugins)
84
+ .set({ featured: !rows[0].featured, updatedAt: new Date() })
85
+ .where(eq(plugins.slug, slug))
86
+ .returning()
87
+ return mapRow(result[0])
88
+ }
89
+
90
+ export async function adminRemovePlugin(slug: string): Promise<void> {
91
+ const db = await getDb()
92
+
93
+ const rows = await db.select().from(plugins).where(eq(plugins.slug, slug))
94
+ if (rows.length === 0) {
95
+ throw new NotFoundError('Plugin', slug)
96
+ }
97
+
98
+ await db.delete(plugins).where(eq(plugins.slug, slug))
99
+ }
100
+
101
+ export async function listAllPlugins(
102
+ options: AdminListOptions = {}
103
+ ): Promise<{ plugins: Plugin[]; total: number; page: number; limit: number }> {
104
+ const db = await getDb()
105
+ const page = options.page ?? 1
106
+ const limit = options.limit ?? 20
107
+ const offset = (page - 1) * limit
108
+
109
+ const where = options.status
110
+ ? eq(plugins.status, options.status as 'pending' | 'approved' | 'rejected')
111
+ : undefined
112
+
113
+ const rows = options.status
114
+ ? await db
115
+ .select()
116
+ .from(plugins)
117
+ .where(where!)
118
+ .orderBy(desc(plugins.createdAt))
119
+ .limit(limit)
120
+ .offset(offset)
121
+ : await db.select().from(plugins).orderBy(desc(plugins.createdAt)).limit(limit).offset(offset)
122
+
123
+ const client = await getRawClient()
124
+ let total = 0
125
+ if (client && 'execute' in client) {
126
+ const query = options.status
127
+ ? `SELECT COUNT(*) as count FROM plugins WHERE status = '${options.status}'`
128
+ : 'SELECT COUNT(*) as count FROM plugins'
129
+ const result = await client.execute(query)
130
+ total = (result.rows[0] as unknown as { count: number })?.count ?? 0
131
+ }
132
+
133
+ return { plugins: rows.map(mapRow), total, page, limit }
134
+ }
135
+
136
+ export async function bulkApprove(slugs: string[]): Promise<number> {
137
+ const db = await getDb()
138
+ let approved = 0
139
+
140
+ for (const slug of slugs) {
141
+ const rows = await db.select().from(plugins).where(eq(plugins.slug, slug))
142
+ if (rows.length > 0) {
143
+ await db
144
+ .update(plugins)
145
+ .set({ status: 'approved', rejectReason: null, updatedAt: new Date() })
146
+ .where(eq(plugins.slug, slug))
147
+ approved++
148
+ }
149
+ }
150
+
151
+ return approved
152
+ }
153
+
154
+ export async function bulkReject(slugs: string[], reason: string): Promise<number> {
155
+ const db = await getDb()
156
+ let rejected = 0
157
+
158
+ for (const slug of slugs) {
159
+ const rows = await db.select().from(plugins).where(eq(plugins.slug, slug))
160
+ if (rows.length > 0) {
161
+ await db
162
+ .update(plugins)
163
+ .set({ status: 'rejected', rejectReason: reason, updatedAt: new Date() })
164
+ .where(eq(plugins.slug, slug))
165
+ rejected++
166
+ }
167
+ }
168
+
169
+ return rejected
170
+ }
@@ -0,0 +1,41 @@
1
+ import { getRawClient } from '@server/db'
2
+ import { getDb } from '@server/db'
3
+ import { plugins } from '@server/db/schema'
4
+ import type { AdminDashboardStats } from '@shared/schemas'
5
+ import { mapRow } from './plugin-service'
6
+
7
+ export async function getDashboardStats(): Promise<AdminDashboardStats> {
8
+ const client = await getRawClient()
9
+
10
+ if (!client || !('execute' in client)) {
11
+ return {
12
+ totalPlugins: 0,
13
+ pendingPlugins: 0,
14
+ totalDownloads: 0,
15
+ totalDevelopers: 0,
16
+ totalCategories: 0,
17
+ }
18
+ }
19
+
20
+ const [totalResult, pendingResult, downloadResult, developerResult, categoryResult] =
21
+ await Promise.all([
22
+ client.execute('SELECT COUNT(*) as count FROM plugins'),
23
+ client.execute("SELECT COUNT(*) as count FROM plugins WHERE status = 'pending'"),
24
+ client.execute('SELECT COALESCE(SUM(download_count), 0) as total FROM plugins'),
25
+ client.execute('SELECT COUNT(DISTINCT author_id) as count FROM plugins'),
26
+ client.execute('SELECT COUNT(*) as count FROM plugin_categories'),
27
+ ])
28
+
29
+ const db = await getDb()
30
+ const recentRows = await db.select().from(plugins).orderBy(plugins.createdAt).limit(5)
31
+ const recentSubmissions = recentRows.map(mapRow)
32
+
33
+ return {
34
+ totalPlugins: (totalResult.rows[0] as unknown as { count: number })?.count ?? 0,
35
+ pendingPlugins: (pendingResult.rows[0] as unknown as { count: number })?.count ?? 0,
36
+ totalDownloads: (downloadResult.rows[0] as unknown as { total: number })?.total ?? 0,
37
+ totalDevelopers: (developerResult.rows[0] as unknown as { count: number })?.count ?? 0,
38
+ totalCategories: (categoryResult.rows[0] as unknown as { count: number })?.count ?? 0,
39
+ recentSubmissions,
40
+ }
41
+ }
@@ -0,0 +1,360 @@
1
+ import { eq, desc, asc, sql, like, and, or } from 'drizzle-orm'
2
+ import type {
3
+ Plugin,
4
+ Version,
5
+ Category,
6
+ MarketplaceStats,
7
+ PluginListResponse,
8
+ } from '@shared/schemas'
9
+ import { getDb, getRawClient } from '@server/db'
10
+ import {
11
+ plugins,
12
+ pluginVersions,
13
+ pluginCategories,
14
+ pluginCategoryMappings,
15
+ } from '@server/db/schema'
16
+ import { NotFoundError } from '@server/utils/app-error'
17
+ import { mapRow } from './plugin-service'
18
+
19
+ function mapVersionRow(row: typeof pluginVersions.$inferSelect): Version {
20
+ return {
21
+ id: row.id,
22
+ pluginId: row.pluginId,
23
+ version: row.version,
24
+ changelog: row.changelog ?? undefined,
25
+ packageUrl: row.packageUrl ?? undefined,
26
+ fileSize: row.fileSize ?? undefined,
27
+ checksum: row.checksum ?? undefined,
28
+ status: row.status as Version['status'],
29
+ publishedAt: row.publishedAt.getTime(),
30
+ }
31
+ }
32
+
33
+ function mapCategoryRow(row: typeof pluginCategories.$inferSelect): Category {
34
+ return {
35
+ id: row.id,
36
+ name: row.name,
37
+ slug: row.slug,
38
+ description: row.description ?? undefined,
39
+ icon: row.icon ?? undefined,
40
+ sortOrder: row.sortOrder,
41
+ }
42
+ }
43
+
44
+ async function getCount(tableName: string, whereClause: string = ''): Promise<number> {
45
+ try {
46
+ const client = await getRawClient()
47
+ if (!client || !('execute' in client)) return 0
48
+ const query = whereClause
49
+ ? `SELECT COUNT(*) as count FROM ${tableName} WHERE ${whereClause}`
50
+ : `SELECT COUNT(*) as count FROM ${tableName}`
51
+ const result = await client.execute(query)
52
+ const row = result.rows[0] as unknown as { count: number } | undefined
53
+ return row?.count ?? 0
54
+ } catch {
55
+ return 0
56
+ }
57
+ }
58
+
59
+ export interface ListOptions {
60
+ page?: number
61
+ limit?: number
62
+ status?: string | null
63
+ sort?: 'newest' | 'popular' | 'downloads' | 'name'
64
+ featured?: boolean | null
65
+ }
66
+
67
+ export async function listPlugins(options: ListOptions = {}): Promise<PluginListResponse> {
68
+ try {
69
+ const db = await getDb()
70
+ const page = options.page ?? 1
71
+ const limit = options.limit ?? 20
72
+ const offset = (page - 1) * limit
73
+
74
+ const conditions: ReturnType<typeof eq>[] = []
75
+ if (options.status) {
76
+ conditions.push(eq(plugins.status, options.status as 'pending' | 'approved' | 'rejected'))
77
+ } else {
78
+ conditions.push(eq(plugins.status, 'approved'))
79
+ }
80
+ if (options.featured !== undefined && options.featured !== null) {
81
+ conditions.push(eq(plugins.featured, options.featured))
82
+ }
83
+
84
+ const where = conditions.length > 1 ? and(...conditions) : conditions[0]
85
+
86
+ let orderExpr
87
+ switch (options.sort) {
88
+ case 'popular':
89
+ orderExpr = desc(plugins.viewCount)
90
+ break
91
+ case 'downloads':
92
+ orderExpr = desc(plugins.downloadCount)
93
+ break
94
+ case 'name':
95
+ orderExpr = asc(plugins.name)
96
+ break
97
+ default:
98
+ orderExpr = desc(plugins.createdAt)
99
+ }
100
+
101
+ const rows = await db
102
+ .select()
103
+ .from(plugins)
104
+ .where(where)
105
+ .orderBy(orderExpr)
106
+ .limit(limit)
107
+ .offset(offset)
108
+ const total = await getCount(
109
+ 'plugins',
110
+ options.status ? `status = '${options.status}'` : "status = 'approved'"
111
+ )
112
+
113
+ return {
114
+ plugins: rows.map(mapRow),
115
+ total,
116
+ page,
117
+ limit,
118
+ }
119
+ } catch {
120
+ return { plugins: [], total: 0, page: options.page ?? 1, limit: options.limit ?? 20 }
121
+ }
122
+ }
123
+
124
+ export async function searchPlugins(
125
+ query: string,
126
+ options: { page?: number; limit?: number; category?: string | null } = {}
127
+ ): Promise<PluginListResponse> {
128
+ try {
129
+ const db = await getDb()
130
+ const page = options.page ?? 1
131
+ const limit = options.limit ?? 20
132
+ const offset = (page - 1) * limit
133
+
134
+ const searchTerm = `%${query}%`
135
+ const conditions = [
136
+ eq(plugins.status, 'approved'),
137
+ or(
138
+ like(plugins.name, searchTerm),
139
+ like(plugins.description, searchTerm),
140
+ like(plugins.tags, searchTerm),
141
+ like(plugins.authorName, searchTerm)
142
+ )!,
143
+ ]
144
+
145
+ if (options.category) {
146
+ const catRows = await db
147
+ .select()
148
+ .from(pluginCategories)
149
+ .where(eq(pluginCategories.slug, options.category))
150
+ if (catRows.length > 0) {
151
+ const mappingRows = await db
152
+ .select()
153
+ .from(pluginCategoryMappings)
154
+ .where(eq(pluginCategoryMappings.categoryId, catRows[0].id))
155
+ if (mappingRows.length > 0) {
156
+ const ids = mappingRows.map(p => p.pluginId)
157
+ conditions.push(
158
+ sql`${plugins.id} IN (${sql.join(
159
+ ids.map(id => sql`${id}`),
160
+ sql`, `
161
+ )})`
162
+ )
163
+ }
164
+ }
165
+ }
166
+
167
+ const where = and(...conditions)
168
+
169
+ const rows = await db
170
+ .select()
171
+ .from(plugins)
172
+ .where(where)
173
+ .orderBy(desc(plugins.downloadCount))
174
+ .limit(limit)
175
+ .offset(offset)
176
+
177
+ const client = await getRawClient()
178
+ let total = 0
179
+ if (client && 'execute' in client) {
180
+ try {
181
+ const escapedQuery = query.replace(/'/g, "''")
182
+ const result = await client.execute(
183
+ `SELECT COUNT(*) as count FROM plugins WHERE status = 'approved' AND (name LIKE '%${escapedQuery}%' OR description LIKE '%${escapedQuery}%' OR tags LIKE '%${escapedQuery}%' OR author_name LIKE '%${escapedQuery}%')`
184
+ )
185
+ total = (result.rows[0] as unknown as { count: number })?.count ?? 0
186
+ } catch {
187
+ total = 0
188
+ }
189
+ }
190
+
191
+ return {
192
+ plugins: rows.map(mapRow),
193
+ total,
194
+ page,
195
+ limit,
196
+ }
197
+ } catch {
198
+ return { plugins: [], total: 0, page: options.page ?? 1, limit: options.limit ?? 20 }
199
+ }
200
+ }
201
+
202
+ export async function getPluginBySlug(slug: string): Promise<Plugin> {
203
+ try {
204
+ const db = await getDb()
205
+
206
+ const rows = await db.select().from(plugins).where(eq(plugins.slug, slug))
207
+ if (rows.length === 0) {
208
+ throw new NotFoundError('Plugin', slug)
209
+ }
210
+
211
+ try {
212
+ await db
213
+ .update(plugins)
214
+ .set({ viewCount: rows[0].viewCount + 1 })
215
+ .where(eq(plugins.slug, slug))
216
+ } catch {
217
+ // view count update is non-critical
218
+ }
219
+
220
+ return mapRow(rows[0])
221
+ } catch (error) {
222
+ if (NotFoundError && error instanceof NotFoundError) throw error
223
+ throw new NotFoundError('Plugin', slug)
224
+ }
225
+ }
226
+
227
+ export async function getVersions(pluginId: string): Promise<Version[]> {
228
+ try {
229
+ const db = await getDb()
230
+ const rows = await db
231
+ .select()
232
+ .from(pluginVersions)
233
+ .where(eq(pluginVersions.pluginId, pluginId))
234
+ .orderBy(desc(pluginVersions.publishedAt))
235
+ return rows.map(mapVersionRow)
236
+ } catch {
237
+ return []
238
+ }
239
+ }
240
+
241
+ export async function listCategories(): Promise<Category[]> {
242
+ try {
243
+ const db = await getDb()
244
+ const rows = await db.select().from(pluginCategories).orderBy(asc(pluginCategories.sortOrder))
245
+ return rows.map(mapCategoryRow)
246
+ } catch {
247
+ return []
248
+ }
249
+ }
250
+
251
+ export async function getPluginsByCategory(
252
+ categorySlug: string,
253
+ options: { page?: number; limit?: number } = {}
254
+ ): Promise<PluginListResponse> {
255
+ try {
256
+ const db = await getDb()
257
+ const page = options.page ?? 1
258
+ const limit = options.limit ?? 20
259
+ const offset = (page - 1) * limit
260
+
261
+ const catRows = await db
262
+ .select()
263
+ .from(pluginCategories)
264
+ .where(eq(pluginCategories.slug, categorySlug))
265
+ if (catRows.length === 0) {
266
+ throw new NotFoundError('Category', categorySlug)
267
+ }
268
+
269
+ const categoryId = catRows[0].id
270
+ const mappingRows = await db
271
+ .select()
272
+ .from(pluginCategoryMappings)
273
+ .where(eq(pluginCategoryMappings.categoryId, categoryId))
274
+ const ids = mappingRows.map(p => p.pluginId)
275
+
276
+ if (ids.length === 0) {
277
+ return { plugins: [], total: 0, page, limit }
278
+ }
279
+
280
+ const where = and(
281
+ eq(plugins.status, 'approved'),
282
+ sql`${plugins.id} IN (${sql.join(
283
+ ids.map(id => sql`${id}`),
284
+ sql`, `
285
+ )})`
286
+ )
287
+
288
+ const rows = await db
289
+ .select()
290
+ .from(plugins)
291
+ .where(where)
292
+ .orderBy(desc(plugins.downloadCount))
293
+ .limit(limit)
294
+ .offset(offset)
295
+
296
+ const client = await getRawClient()
297
+ let total = 0
298
+ if (client && 'execute' in client) {
299
+ try {
300
+ const idsList = ids.map(id => `'${id}'`).join(',')
301
+ const result = await client.execute(
302
+ `SELECT COUNT(*) as count FROM plugins WHERE status = 'approved' AND id IN (${idsList})`
303
+ )
304
+ total = (result.rows[0] as unknown as { count: number })?.count ?? 0
305
+ } catch {
306
+ total = 0
307
+ }
308
+ }
309
+
310
+ return {
311
+ plugins: rows.map(mapRow),
312
+ total,
313
+ page,
314
+ limit,
315
+ }
316
+ } catch (error) {
317
+ if (NotFoundError && error instanceof NotFoundError) throw error
318
+ return { plugins: [], total: 0, page: options.page ?? 1, limit: options.limit ?? 20 }
319
+ }
320
+ }
321
+
322
+ export async function listMyPlugins(userId: string): Promise<Plugin[]> {
323
+ try {
324
+ const db = await getDb()
325
+ const rows = await db
326
+ .select()
327
+ .from(plugins)
328
+ .where(eq(plugins.authorId, userId))
329
+ .orderBy(desc(plugins.createdAt))
330
+ return rows.map(mapRow)
331
+ } catch {
332
+ return []
333
+ }
334
+ }
335
+
336
+ export async function getStats(): Promise<MarketplaceStats> {
337
+ try {
338
+ const client = await getRawClient()
339
+
340
+ if (!client || !('execute' in client)) {
341
+ return { totalPlugins: 0, totalDownloads: 0, totalDevelopers: 0, totalCategories: 0 }
342
+ }
343
+
344
+ const [totalResult, downloadResult, developerResult, categoryResult] = await Promise.all([
345
+ client.execute("SELECT COUNT(*) as count FROM plugins WHERE status = 'approved'"),
346
+ client.execute('SELECT COALESCE(SUM(download_count), 0) as total FROM plugins'),
347
+ client.execute('SELECT COUNT(DISTINCT author_id) as count FROM plugins'),
348
+ client.execute('SELECT COUNT(*) as count FROM plugin_categories'),
349
+ ])
350
+
351
+ return {
352
+ totalPlugins: (totalResult.rows[0] as unknown as { count: number })?.count ?? 0,
353
+ totalDownloads: (downloadResult.rows[0] as unknown as { total: number })?.total ?? 0,
354
+ totalDevelopers: (developerResult.rows[0] as unknown as { count: number })?.count ?? 0,
355
+ totalCategories: (categoryResult.rows[0] as unknown as { count: number })?.count ?? 0,
356
+ }
357
+ } catch {
358
+ return { totalPlugins: 0, totalDownloads: 0, totalDevelopers: 0, totalCategories: 0 }
359
+ }
360
+ }
@@ -0,0 +1,95 @@
1
+ import { eq, and } from 'drizzle-orm'
2
+ import type { Review } from '@shared/schemas'
3
+ import { getDb } from '@server/db'
4
+ import { pluginReviews, plugins } from '@server/db/schema'
5
+ import { generateUUID } from '@server/utils/uuid'
6
+ import { NotFoundError, AuthorizationError, ConflictError } from '@server/utils/app-error'
7
+
8
+ export interface SubmitReviewData {
9
+ pluginId: string
10
+ userId: string
11
+ userName: string
12
+ rating: number
13
+ title?: string | null
14
+ content?: string | null
15
+ }
16
+
17
+ function mapReviewRow(row: typeof pluginReviews.$inferSelect): Review {
18
+ return {
19
+ id: row.id,
20
+ pluginId: row.pluginId,
21
+ userId: row.userId,
22
+ userName: row.userName,
23
+ rating: row.rating,
24
+ title: row.title ?? undefined,
25
+ content: row.content ?? undefined,
26
+ createdAt: row.createdAt.getTime(),
27
+ }
28
+ }
29
+
30
+ export async function submitReview(data: SubmitReviewData): Promise<Review> {
31
+ const db = await getDb()
32
+
33
+ const pluginRows = await db.select().from(plugins).where(eq(plugins.id, data.pluginId))
34
+ if (pluginRows.length === 0) {
35
+ throw new NotFoundError('Plugin', data.pluginId)
36
+ }
37
+
38
+ const existing = await db
39
+ .select()
40
+ .from(pluginReviews)
41
+ .where(and(eq(pluginReviews.pluginId, data.pluginId), eq(pluginReviews.userId, data.userId)))
42
+ if (existing.length > 0) {
43
+ throw new ConflictError('You have already reviewed this plugin')
44
+ }
45
+
46
+ const id = generateUUID()
47
+ const now = new Date()
48
+ const result = await db
49
+ .insert(pluginReviews)
50
+ .values({
51
+ id,
52
+ pluginId: data.pluginId,
53
+ userId: data.userId,
54
+ userName: data.userName,
55
+ rating: data.rating,
56
+ title: data.title ?? null,
57
+ content: data.content ?? null,
58
+ createdAt: now,
59
+ })
60
+ .returning()
61
+
62
+ return mapReviewRow(result[0])
63
+ }
64
+
65
+ export async function getReviews(pluginId: string): Promise<Review[]> {
66
+ const db = await getDb()
67
+ const rows = await db
68
+ .select()
69
+ .from(pluginReviews)
70
+ .where(eq(pluginReviews.pluginId, pluginId))
71
+ .orderBy(pluginReviews.createdAt)
72
+ return rows.map(mapReviewRow)
73
+ }
74
+
75
+ export async function deleteReview(reviewId: string, userId: string): Promise<void> {
76
+ const db = await getDb()
77
+
78
+ const rows = await db.select().from(pluginReviews).where(eq(pluginReviews.id, reviewId))
79
+ if (rows.length === 0) {
80
+ throw new NotFoundError('Review', reviewId)
81
+ }
82
+
83
+ if (rows[0].userId !== userId) {
84
+ throw new AuthorizationError('Only the review author can delete this review')
85
+ }
86
+
87
+ await db.delete(pluginReviews).where(eq(pluginReviews.id, reviewId))
88
+ }
89
+
90
+ export async function getReviewById(reviewId: string): Promise<Review | null> {
91
+ const db = await getDb()
92
+ const rows = await db.select().from(pluginReviews).where(eq(pluginReviews.id, reviewId))
93
+ if (rows.length === 0) return null
94
+ return mapReviewRow(rows[0])
95
+ }