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.
- package/dist/cli/index.js +1673 -696
- package/dist/cli/index.js.map +1 -1
- package/package.json +16 -10
- package/template/.husky/pre-commit +1 -1
- package/template/modules.config.ts +29 -2
- package/template/package.json +12 -12
- package/template/patches/{typescript+5.8.3.patch → typescript+5.9.3.patch} +2 -4
- package/template/playwright.config.ts +7 -1
- package/template/src/admin/App.tsx +2 -2
- package/template/src/admin/components/CaptchaModal.tsx +7 -9
- package/template/src/admin/layouts/Header.tsx +56 -22
- package/template/src/admin/layouts/Layout.tsx +14 -9
- package/template/src/admin/layouts/Sidebar.tsx +122 -105
- package/template/src/admin/pages/CategoryManagementPage.tsx +241 -0
- package/template/src/admin/pages/ContentPage.tsx +2 -0
- package/template/src/admin/pages/DashboardPage.tsx +2 -0
- package/template/src/admin/pages/DisputesPage.tsx +2 -0
- package/template/src/admin/pages/MediaTestPage.tsx +1 -1
- package/template/src/admin/pages/OrdersPage.tsx +2 -0
- package/template/src/admin/pages/PluginDashboardPage.tsx +297 -0
- package/template/src/admin/pages/PluginManagementPage.tsx +340 -0
- package/template/src/admin/pages/PluginReviewPage.tsx +255 -0
- package/template/src/admin/pages/SystemLogsPage.tsx +11 -2
- package/template/src/admin/pages/TicketsPage.tsx +2 -0
- package/template/src/admin/pages/UsersPage.tsx +3 -2
- package/template/src/admin/stores/adminStore.ts +5 -0
- package/template/src/cli/index.ts +17 -19
- package/template/src/cli/modules/auth/index.ts +65 -0
- package/template/src/cli/modules/config/index.ts +74 -77
- package/template/src/cli/modules/index.ts +28 -6
- package/template/src/cli/modules/notification/index.ts +95 -79
- package/template/src/cli/modules/plugin/index.ts +111 -0
- package/template/src/cli/modules/todo/index.ts +99 -51
- package/template/src/cli/utils/auto-command.ts +7 -25
- package/template/src/cli/utils/index.ts +3 -1
- package/template/src/client/App.tsx +36 -16
- package/template/src/client/Layout.tsx +67 -10
- package/template/src/client/components/AuthButton.tsx +25 -18
- package/template/src/client/components/BottomTabBar.tsx +107 -0
- package/template/src/client/components/Navigation.tsx +162 -52
- package/template/src/client/components/__tests__/App.test.tsx +48 -32
- package/template/src/client/components/__tests__/AuthButton.test.tsx +78 -77
- package/template/src/client/components/__tests__/Navigation.test.tsx +31 -20
- package/template/src/client/components/index.ts +1 -0
- package/template/src/client/contexts/PresetContext.tsx +10 -0
- package/template/src/client/main.tsx +63 -8
- package/template/src/client/pages/CartPage.tsx +244 -0
- package/template/src/client/pages/CategoriesPage.tsx +100 -0
- package/template/src/client/pages/ContentDetailPage.tsx +9 -14
- package/template/src/client/pages/ContentListPage.tsx +4 -11
- package/template/src/client/pages/DashboardPage.tsx +261 -0
- package/template/src/client/pages/DeveloperDashboardPage.tsx +211 -0
- package/template/src/client/pages/LoginPage.tsx +127 -0
- package/template/src/client/pages/OrdersPage.tsx +196 -0
- package/template/src/client/pages/PluginDetailPage.tsx +345 -0
- package/template/src/client/pages/PluginsPage.tsx +223 -0
- package/template/src/client/pages/ProfilePage.tsx +206 -0
- package/template/src/client/pages/PublishPage.tsx +336 -0
- package/template/src/client/pages/RegisterPage.tsx +136 -0
- package/template/src/client/pages/SearchPage.tsx +204 -0
- package/template/src/client/pages/SettingsPage.tsx +220 -0
- package/template/src/client/pages/TopicsPage.tsx +180 -0
- package/template/src/client/pages/__tests__/LoginPage.test.tsx +170 -0
- package/template/src/client/pages/__tests__/RegisterPage.test.tsx +168 -0
- package/template/src/client/preset-ui-config.ts +492 -0
- package/template/src/client/services/apiClient.ts +14 -4
- package/template/src/client/stores/__tests__/authStore.test.ts +293 -38
- package/template/src/client/stores/__tests__/todoStore.test.ts +7 -17
- package/template/src/client/stores/authStore.ts +58 -5
- package/template/src/client/stores/chatWSStore.ts +9 -0
- package/template/src/client/stores/notificationStore.ts +4 -0
- package/template/src/client/stores/pluginStore.ts +279 -0
- package/template/src/client/stores/todoStore.ts +2 -6
- package/template/src/server/core/__tests__/isr-cache.test.ts +117 -0
- package/template/src/server/core/__tests__/isr-invalidation.test.ts +72 -0
- package/template/src/server/core/__tests__/ssr-renderer.test.ts +89 -0
- package/template/src/server/core/isr-cache.ts +239 -0
- package/template/src/server/core/isr-invalidation.ts +45 -0
- package/template/src/server/core/module-loader.ts +14 -7
- package/template/src/server/core/ssr-renderer.ts +240 -0
- package/template/src/server/db/init.ts +257 -10
- package/template/src/server/db/schema/developers.ts +20 -0
- package/template/src/server/db/schema/index.ts +2 -0
- package/template/src/server/db/schema/plugins.ts +114 -0
- package/template/src/server/db/test-setup.ts +91 -0
- package/template/src/server/entries/cloudflare.ts +79 -7
- package/template/src/server/entries/node.ts +48 -5
- package/template/src/server/middleware/__tests__/captcha.test.ts +23 -13
- package/template/src/server/middleware/auth.ts +8 -1
- package/template/src/server/middleware/captcha.ts +14 -4
- package/template/src/server/middleware/rate-limit.ts +7 -2
- package/template/src/server/module-admin/module.ts +9 -5
- package/template/src/server/module-admin/routes/admin-notification-routes.ts +43 -14
- package/template/src/server/module-admin/routes/admin-routes.ts +0 -2
- package/template/src/server/module-admin/routes/client-auth-routes.ts +90 -0
- package/template/src/server/module-admin/routes/dashboard-routes.ts +79 -0
- package/template/src/server/module-admin/services/admin-service.ts +68 -11
- package/template/src/server/module-auth/__tests__/auth-service.test.ts +239 -0
- package/template/src/server/module-auth/index.ts +7 -0
- package/template/src/server/module-auth/module.ts +40 -0
- package/template/src/server/module-auth/routes/auth-routes.ts +94 -0
- package/template/src/server/module-auth/routes/profile-routes.ts +31 -0
- package/template/src/server/module-auth/services/auth-service.ts +100 -0
- package/template/src/server/module-content/module.ts +10 -4
- package/template/src/server/module-content/routes/public-content-routes.ts +2 -2
- package/template/src/server/module-content/routes/topics-routes.ts +205 -0
- package/template/src/server/module-content/services/content-service.ts +18 -2
- package/template/src/server/module-dispute/routes/dispute-routes.ts +2 -1
- package/template/src/server/module-dispute/services/dispute-service.ts +1 -1
- package/template/src/server/module-notifications/__tests__/sse-rpc.test.ts +14 -14
- package/template/src/server/module-notifications/routes/notification-routes.ts +34 -8
- package/template/src/server/module-order/__tests__/order-route.test.ts +22 -2
- package/template/src/server/module-order/module.ts +15 -0
- package/template/src/server/module-order/routes/cart-routes.ts +103 -0
- package/template/src/server/module-order/routes/orders-mock-routes.ts +67 -0
- package/template/src/server/module-order/services/order-service.ts +1 -1
- package/template/src/server/module-plugin/__tests__/plugin-query-service.test.ts +203 -0
- package/template/src/server/module-plugin/__tests__/plugin-service.test.ts +234 -0
- package/template/src/server/module-plugin/index.ts +2 -0
- package/template/src/server/module-plugin/module.ts +52 -0
- package/template/src/server/module-plugin/routes/plugin-admin-routes.ts +261 -0
- package/template/src/server/module-plugin/routes/plugin-routes.ts +354 -0
- package/template/src/server/module-plugin/services/admin-category-service.ts +99 -0
- package/template/src/server/module-plugin/services/admin-plugin-service.ts +170 -0
- package/template/src/server/module-plugin/services/admin-stats-service.ts +41 -0
- package/template/src/server/module-plugin/services/plugin-query-service.ts +360 -0
- package/template/src/server/module-plugin/services/plugin-review-service.ts +95 -0
- package/template/src/server/module-plugin/services/plugin-service.ts +163 -0
- package/template/src/server/module-ticket/services/ticket-service.ts +1 -1
- package/template/src/server/module-todos/routes/todos-routes.ts +0 -4
- package/template/src/server/route-registry.ts +16 -1
- package/template/src/server/test-utils/test-client.ts +1 -2
- package/template/src/server/utils/auth.ts +6 -0
- package/template/src/server/utils/json.ts +13 -0
- package/template/src/shared/core/module-manifest.ts +3 -6
- package/template/src/shared/modules/auth/index.ts +12 -0
- package/template/src/shared/modules/auth/schemas.ts +50 -0
- package/template/src/shared/modules/cart/index.ts +1 -0
- package/template/src/shared/modules/cart/schemas.ts +41 -0
- package/template/src/shared/modules/community/index.ts +1 -0
- package/template/src/shared/modules/community/schemas.ts +58 -0
- package/template/src/shared/modules/dashboard/index.ts +1 -0
- package/template/src/shared/modules/dashboard/schemas.ts +35 -0
- package/template/src/shared/modules/index.ts +41 -0
- package/template/src/shared/modules/order/schemas.ts +29 -0
- package/template/src/shared/modules/plugins/index.ts +48 -0
- package/template/src/shared/modules/plugins/schemas.ts +227 -0
- package/template/src/shared/schemas/index.ts +130 -0
- package/template/tests/e2e/todo.spec.ts +23 -18
- package/template/tests/e2e/visual-screenshots.spec.ts +1824 -0
- package/template/uploads/.gitkeep +0 -0
- package/template/vite.config.ts +2 -1
- package/template/vitest.setup.ts +11 -0
- package/template/wrangler.toml +4 -3
- 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
|
+
}
|