feat: implement admin panel with multiple routes for user management, credits, jobs, and content moderation. Add admin dashboard for platform overview and integrate visibility controls for featured content. Enhance sidebar navigation for admin access and ensure proper session handling for admin users.
Browse files- apps/server/src/index.ts +24 -14
- apps/web/src/components/sidebar.tsx +25 -0
- apps/web/src/routeTree.gen.ts +153 -0
- apps/web/src/routes/__root.tsx +2 -1
- apps/web/src/routes/admin.credits.tsx +191 -0
- apps/web/src/routes/admin.featured.tsx +198 -0
- apps/web/src/routes/admin.index.tsx +126 -0
- apps/web/src/routes/admin.jobs.tsx +121 -0
- apps/web/src/routes/admin.moderation.tsx +94 -0
- apps/web/src/routes/admin.tsx +90 -0
- apps/web/src/routes/admin.users.tsx +157 -0
- apps/web/src/routes/generate.tsx +154 -43
- apps/web/src/routes/index.tsx +14 -9
- packages/api/src/__tests__/admin.integration.test.ts +307 -0
- packages/api/src/__tests__/credit.test.ts +166 -0
- packages/api/src/__tests__/test-setup.ts +10 -4
- packages/api/src/index.ts +23 -12
- packages/api/src/lib/credit.ts +214 -0
- packages/api/src/lib/rate-limit.ts +2 -3
- packages/api/src/logger.ts +45 -15
- packages/api/src/queue.ts +24 -3
- packages/api/src/routers/admin.ts +471 -0
- packages/api/src/routers/ai.ts +43 -3
- packages/api/src/routers/index.ts +2 -0
- packages/db/src/schema/admin.ts +92 -0
- packages/db/src/schema/app.ts +1 -0
- packages/db/src/schema/auth.ts +2 -0
- packages/db/src/schema/index.ts +1 -0
- packages/env/src/server.ts +6 -0
- packages/ui/src/styles/globals.css +8 -0
apps/server/src/index.ts
CHANGED
|
@@ -3,27 +3,37 @@ import { createContext } from "@labas/api/context";
|
|
| 3 |
import { appRouter } from "@labas/api/routers/index";
|
| 4 |
import { auth } from "@labas/auth";
|
| 5 |
import { env } from "@labas/env/server";
|
|
|
|
| 6 |
import { Hono } from "hono";
|
| 7 |
import { cors } from "hono/cors";
|
| 8 |
-
import { logger } from "hono/logger";
|
| 9 |
|
| 10 |
-
// Side-effect: start background job worker
|
| 11 |
import "@labas/api/queue";
|
| 12 |
|
| 13 |
const app = new Hono();
|
| 14 |
|
| 15 |
-
app.use(
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
app.use(
|
| 28 |
"/*",
|
| 29 |
cors({
|
|
|
|
| 3 |
import { appRouter } from "@labas/api/routers/index";
|
| 4 |
import { auth } from "@labas/auth";
|
| 5 |
import { env } from "@labas/env/server";
|
| 6 |
+
import { logger, withRequestId } from "@labas/api/logger";
|
| 7 |
import { Hono } from "hono";
|
| 8 |
import { cors } from "hono/cors";
|
|
|
|
| 9 |
|
|
|
|
| 10 |
import "@labas/api/queue";
|
| 11 |
|
| 12 |
const app = new Hono();
|
| 13 |
|
| 14 |
+
app.use("/*", async (c, next) => {
|
| 15 |
+
const reqId = c.req.header("x-request-id") ?? crypto.randomUUID();
|
| 16 |
+
const reqLogger = withRequestId(reqId);
|
| 17 |
+
const start = Date.now();
|
| 18 |
+
|
| 19 |
+
c.res.headers.set("x-request-id", reqId);
|
| 20 |
+
reqLogger.info(`${c.req.method} ${c.req.path}`);
|
| 21 |
+
|
| 22 |
+
await next();
|
| 23 |
+
|
| 24 |
+
const duration = Date.now() - start;
|
| 25 |
+
reqLogger.info(`${c.req.method} ${c.req.path} ${c.res.status}`, { durationMs: duration });
|
| 26 |
+
});
|
| 27 |
+
|
| 28 |
+
app.use("/*", async (c, next) => {
|
| 29 |
+
await next();
|
| 30 |
+
c.res.headers.set("X-Content-Type-Options", "nosniff");
|
| 31 |
+
c.res.headers.set("X-Frame-Options", "DENY");
|
| 32 |
+
c.res.headers.set("Referrer-Policy", "strict-origin-when-cross-origin");
|
| 33 |
+
c.res.headers.set("X-XSS-Protection", "0");
|
| 34 |
+
c.res.headers.set("Permissions-Policy", "camera=(), microphone=(), geolocation=()");
|
| 35 |
+
});
|
| 36 |
+
|
| 37 |
app.use(
|
| 38 |
"/*",
|
| 39 |
cors({
|
apps/web/src/components/sidebar.tsx
CHANGED
|
@@ -1,6 +1,8 @@
|
|
| 1 |
import { useState } from "react";
|
| 2 |
import { Link, useLocation, useNavigate } from "@tanstack/react-router";
|
|
|
|
| 3 |
import { authClient } from "@/lib/auth-client";
|
|
|
|
| 4 |
import { useSidebar } from "@/hooks/use-sidebar";
|
| 5 |
import { triggerGlobalTour } from "@/components/TourGuide";
|
| 6 |
|
|
@@ -89,6 +91,11 @@ export function Sidebar() {
|
|
| 89 |
const { data: session } = authClient.useSession();
|
| 90 |
const isLoggedIn = !!session;
|
| 91 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 92 |
async function handleSignOut() {
|
| 93 |
await authClient.signOut();
|
| 94 |
navigate({ to: "/landing" });
|
|
@@ -141,6 +148,24 @@ export function Sidebar() {
|
|
| 141 |
})}
|
| 142 |
</div>
|
| 143 |
))}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 144 |
</nav>
|
| 145 |
|
| 146 |
<div className="mt-auto space-y-1 pt-4 border-t border-[var(--oat-border)] w-full">
|
|
|
|
| 1 |
import { useState } from "react";
|
| 2 |
import { Link, useLocation, useNavigate } from "@tanstack/react-router";
|
| 3 |
+
import { useQuery } from "@tanstack/react-query";
|
| 4 |
import { authClient } from "@/lib/auth-client";
|
| 5 |
+
import { trpc } from "@/utils/trpc";
|
| 6 |
import { useSidebar } from "@/hooks/use-sidebar";
|
| 7 |
import { triggerGlobalTour } from "@/components/TourGuide";
|
| 8 |
|
|
|
|
| 91 |
const { data: session } = authClient.useSession();
|
| 92 |
const isLoggedIn = !!session;
|
| 93 |
|
| 94 |
+
const { data: adminData } = useQuery(
|
| 95 |
+
trpc.admin.isAdmin.queryOptions(undefined, { enabled: isLoggedIn }),
|
| 96 |
+
);
|
| 97 |
+
const isAdmin = !!adminData?.isAdmin;
|
| 98 |
+
|
| 99 |
async function handleSignOut() {
|
| 100 |
await authClient.signOut();
|
| 101 |
navigate({ to: "/landing" });
|
|
|
|
| 148 |
})}
|
| 149 |
</div>
|
| 150 |
))}
|
| 151 |
+
|
| 152 |
+
{isAdmin && (
|
| 153 |
+
<div className="space-y-1 pt-2 border-t border-[var(--matcha-400)]/30">
|
| 154 |
+
{!collapsed && (
|
| 155 |
+
<p className="px-3 text-[10px] font-bold text-[var(--matcha-600)] uppercase tracking-wider">
|
| 156 |
+
Admin
|
| 157 |
+
</p>
|
| 158 |
+
)}
|
| 159 |
+
{[{ to: "/admin", label: "Admin Panel", icon: "admin_panel_settings" }].map((item) => {
|
| 160 |
+
const isActive =
|
| 161 |
+
location.pathname === item.to ||
|
| 162 |
+
(location.pathname.startsWith(`${item.to}/`) && item.to !== "/");
|
| 163 |
+
return (
|
| 164 |
+
<NavLink key={item.to} item={item} isActive={isActive} collapsed={collapsed} />
|
| 165 |
+
);
|
| 166 |
+
})}
|
| 167 |
+
</div>
|
| 168 |
+
)}
|
| 169 |
</nav>
|
| 170 |
|
| 171 |
<div className="mt-auto space-y-1 pt-4 border-t border-[var(--oat-border)] w-full">
|
apps/web/src/routeTree.gen.ts
CHANGED
|
@@ -23,10 +23,17 @@ import { Route as GenerateRouteImport } from './routes/generate'
|
|
| 23 |
import { Route as ForgotPasswordRouteImport } from './routes/forgot-password'
|
| 24 |
import { Route as BankRouteImport } from './routes/bank'
|
| 25 |
import { Route as AnalyticsRouteImport } from './routes/analytics'
|
|
|
|
| 26 |
import { Route as IndexRouteImport } from './routes/index'
|
|
|
|
| 27 |
import { Route as ProfileUserIdRouteImport } from './routes/profile.$userId'
|
| 28 |
import { Route as PackageIdRouteImport } from './routes/package.$id'
|
| 29 |
import { Route as AttemptIdRouteImport } from './routes/attempt.$id'
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
import { Route as PackageIdIndexRouteImport } from './routes/package.$id.index'
|
| 31 |
import { Route as PackageIdTakeRouteImport } from './routes/package.$id.take'
|
| 32 |
import { Route as PackageIdAttemptAttemptIdRouteImport } from './routes/package.$id.attempt.$attemptId'
|
|
@@ -101,11 +108,21 @@ const AnalyticsRoute = AnalyticsRouteImport.update({
|
|
| 101 |
path: '/analytics',
|
| 102 |
getParentRoute: () => rootRouteImport,
|
| 103 |
} as any)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 104 |
const IndexRoute = IndexRouteImport.update({
|
| 105 |
id: '/',
|
| 106 |
path: '/',
|
| 107 |
getParentRoute: () => rootRouteImport,
|
| 108 |
} as any)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 109 |
const ProfileUserIdRoute = ProfileUserIdRouteImport.update({
|
| 110 |
id: '/profile/$userId',
|
| 111 |
path: '/profile/$userId',
|
|
@@ -121,6 +138,31 @@ const AttemptIdRoute = AttemptIdRouteImport.update({
|
|
| 121 |
path: '/attempt/$id',
|
| 122 |
getParentRoute: () => rootRouteImport,
|
| 123 |
} as any)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 124 |
const PackageIdIndexRoute = PackageIdIndexRouteImport.update({
|
| 125 |
id: '/',
|
| 126 |
path: '/',
|
|
@@ -140,6 +182,7 @@ const PackageIdAttemptAttemptIdRoute =
|
|
| 140 |
|
| 141 |
export interface FileRoutesByFullPath {
|
| 142 |
'/': typeof IndexRoute
|
|
|
|
| 143 |
'/analytics': typeof AnalyticsRoute
|
| 144 |
'/bank': typeof BankRoute
|
| 145 |
'/forgot-password': typeof ForgotPasswordRoute
|
|
@@ -154,9 +197,15 @@ export interface FileRoutesByFullPath {
|
|
| 154 |
'/settings': typeof SettingsRoute
|
| 155 |
'/setup-avatar': typeof SetupAvatarRoute
|
| 156 |
'/verify-email': typeof VerifyEmailRoute
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 157 |
'/attempt/$id': typeof AttemptIdRoute
|
| 158 |
'/package/$id': typeof PackageIdRouteWithChildren
|
| 159 |
'/profile/$userId': typeof ProfileUserIdRoute
|
|
|
|
| 160 |
'/package/$id/take': typeof PackageIdTakeRoute
|
| 161 |
'/package/$id/': typeof PackageIdIndexRoute
|
| 162 |
'/package/$id/attempt/$attemptId': typeof PackageIdAttemptAttemptIdRoute
|
|
@@ -177,8 +226,14 @@ export interface FileRoutesByTo {
|
|
| 177 |
'/settings': typeof SettingsRoute
|
| 178 |
'/setup-avatar': typeof SetupAvatarRoute
|
| 179 |
'/verify-email': typeof VerifyEmailRoute
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 180 |
'/attempt/$id': typeof AttemptIdRoute
|
| 181 |
'/profile/$userId': typeof ProfileUserIdRoute
|
|
|
|
| 182 |
'/package/$id/take': typeof PackageIdTakeRoute
|
| 183 |
'/package/$id': typeof PackageIdIndexRoute
|
| 184 |
'/package/$id/attempt/$attemptId': typeof PackageIdAttemptAttemptIdRoute
|
|
@@ -186,6 +241,7 @@ export interface FileRoutesByTo {
|
|
| 186 |
export interface FileRoutesById {
|
| 187 |
__root__: typeof rootRouteImport
|
| 188 |
'/': typeof IndexRoute
|
|
|
|
| 189 |
'/analytics': typeof AnalyticsRoute
|
| 190 |
'/bank': typeof BankRoute
|
| 191 |
'/forgot-password': typeof ForgotPasswordRoute
|
|
@@ -200,9 +256,15 @@ export interface FileRoutesById {
|
|
| 200 |
'/settings': typeof SettingsRoute
|
| 201 |
'/setup-avatar': typeof SetupAvatarRoute
|
| 202 |
'/verify-email': typeof VerifyEmailRoute
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 203 |
'/attempt/$id': typeof AttemptIdRoute
|
| 204 |
'/package/$id': typeof PackageIdRouteWithChildren
|
| 205 |
'/profile/$userId': typeof ProfileUserIdRoute
|
|
|
|
| 206 |
'/package/$id/take': typeof PackageIdTakeRoute
|
| 207 |
'/package/$id/': typeof PackageIdIndexRoute
|
| 208 |
'/package/$id/attempt/$attemptId': typeof PackageIdAttemptAttemptIdRoute
|
|
@@ -211,6 +273,7 @@ export interface FileRouteTypes {
|
|
| 211 |
fileRoutesByFullPath: FileRoutesByFullPath
|
| 212 |
fullPaths:
|
| 213 |
| '/'
|
|
|
|
| 214 |
| '/analytics'
|
| 215 |
| '/bank'
|
| 216 |
| '/forgot-password'
|
|
@@ -225,9 +288,15 @@ export interface FileRouteTypes {
|
|
| 225 |
| '/settings'
|
| 226 |
| '/setup-avatar'
|
| 227 |
| '/verify-email'
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 228 |
| '/attempt/$id'
|
| 229 |
| '/package/$id'
|
| 230 |
| '/profile/$userId'
|
|
|
|
| 231 |
| '/package/$id/take'
|
| 232 |
| '/package/$id/'
|
| 233 |
| '/package/$id/attempt/$attemptId'
|
|
@@ -248,14 +317,21 @@ export interface FileRouteTypes {
|
|
| 248 |
| '/settings'
|
| 249 |
| '/setup-avatar'
|
| 250 |
| '/verify-email'
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 251 |
| '/attempt/$id'
|
| 252 |
| '/profile/$userId'
|
|
|
|
| 253 |
| '/package/$id/take'
|
| 254 |
| '/package/$id'
|
| 255 |
| '/package/$id/attempt/$attemptId'
|
| 256 |
id:
|
| 257 |
| '__root__'
|
| 258 |
| '/'
|
|
|
|
| 259 |
| '/analytics'
|
| 260 |
| '/bank'
|
| 261 |
| '/forgot-password'
|
|
@@ -270,9 +346,15 @@ export interface FileRouteTypes {
|
|
| 270 |
| '/settings'
|
| 271 |
| '/setup-avatar'
|
| 272 |
| '/verify-email'
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 273 |
| '/attempt/$id'
|
| 274 |
| '/package/$id'
|
| 275 |
| '/profile/$userId'
|
|
|
|
| 276 |
| '/package/$id/take'
|
| 277 |
| '/package/$id/'
|
| 278 |
| '/package/$id/attempt/$attemptId'
|
|
@@ -280,6 +362,7 @@ export interface FileRouteTypes {
|
|
| 280 |
}
|
| 281 |
export interface RootRouteChildren {
|
| 282 |
IndexRoute: typeof IndexRoute
|
|
|
|
| 283 |
AnalyticsRoute: typeof AnalyticsRoute
|
| 284 |
BankRoute: typeof BankRoute
|
| 285 |
ForgotPasswordRoute: typeof ForgotPasswordRoute
|
|
@@ -399,6 +482,13 @@ declare module '@tanstack/react-router' {
|
|
| 399 |
preLoaderRoute: typeof AnalyticsRouteImport
|
| 400 |
parentRoute: typeof rootRouteImport
|
| 401 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 402 |
'/': {
|
| 403 |
id: '/'
|
| 404 |
path: '/'
|
|
@@ -406,6 +496,13 @@ declare module '@tanstack/react-router' {
|
|
| 406 |
preLoaderRoute: typeof IndexRouteImport
|
| 407 |
parentRoute: typeof rootRouteImport
|
| 408 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 409 |
'/profile/$userId': {
|
| 410 |
id: '/profile/$userId'
|
| 411 |
path: '/profile/$userId'
|
|
@@ -427,6 +524,41 @@ declare module '@tanstack/react-router' {
|
|
| 427 |
preLoaderRoute: typeof AttemptIdRouteImport
|
| 428 |
parentRoute: typeof rootRouteImport
|
| 429 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 430 |
'/package/$id/': {
|
| 431 |
id: '/package/$id/'
|
| 432 |
path: '/'
|
|
@@ -451,6 +583,26 @@ declare module '@tanstack/react-router' {
|
|
| 451 |
}
|
| 452 |
}
|
| 453 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 454 |
interface PackageIdRouteChildren {
|
| 455 |
PackageIdTakeRoute: typeof PackageIdTakeRoute
|
| 456 |
PackageIdIndexRoute: typeof PackageIdIndexRoute
|
|
@@ -469,6 +621,7 @@ const PackageIdRouteWithChildren = PackageIdRoute._addFileChildren(
|
|
| 469 |
|
| 470 |
const rootRouteChildren: RootRouteChildren = {
|
| 471 |
IndexRoute: IndexRoute,
|
|
|
|
| 472 |
AnalyticsRoute: AnalyticsRoute,
|
| 473 |
BankRoute: BankRoute,
|
| 474 |
ForgotPasswordRoute: ForgotPasswordRoute,
|
|
|
|
| 23 |
import { Route as ForgotPasswordRouteImport } from './routes/forgot-password'
|
| 24 |
import { Route as BankRouteImport } from './routes/bank'
|
| 25 |
import { Route as AnalyticsRouteImport } from './routes/analytics'
|
| 26 |
+
import { Route as AdminRouteImport } from './routes/admin'
|
| 27 |
import { Route as IndexRouteImport } from './routes/index'
|
| 28 |
+
import { Route as AdminIndexRouteImport } from './routes/admin.index'
|
| 29 |
import { Route as ProfileUserIdRouteImport } from './routes/profile.$userId'
|
| 30 |
import { Route as PackageIdRouteImport } from './routes/package.$id'
|
| 31 |
import { Route as AttemptIdRouteImport } from './routes/attempt.$id'
|
| 32 |
+
import { Route as AdminUsersRouteImport } from './routes/admin.users'
|
| 33 |
+
import { Route as AdminModerationRouteImport } from './routes/admin.moderation'
|
| 34 |
+
import { Route as AdminJobsRouteImport } from './routes/admin.jobs'
|
| 35 |
+
import { Route as AdminFeaturedRouteImport } from './routes/admin.featured'
|
| 36 |
+
import { Route as AdminCreditsRouteImport } from './routes/admin.credits'
|
| 37 |
import { Route as PackageIdIndexRouteImport } from './routes/package.$id.index'
|
| 38 |
import { Route as PackageIdTakeRouteImport } from './routes/package.$id.take'
|
| 39 |
import { Route as PackageIdAttemptAttemptIdRouteImport } from './routes/package.$id.attempt.$attemptId'
|
|
|
|
| 108 |
path: '/analytics',
|
| 109 |
getParentRoute: () => rootRouteImport,
|
| 110 |
} as any)
|
| 111 |
+
const AdminRoute = AdminRouteImport.update({
|
| 112 |
+
id: '/admin',
|
| 113 |
+
path: '/admin',
|
| 114 |
+
getParentRoute: () => rootRouteImport,
|
| 115 |
+
} as any)
|
| 116 |
const IndexRoute = IndexRouteImport.update({
|
| 117 |
id: '/',
|
| 118 |
path: '/',
|
| 119 |
getParentRoute: () => rootRouteImport,
|
| 120 |
} as any)
|
| 121 |
+
const AdminIndexRoute = AdminIndexRouteImport.update({
|
| 122 |
+
id: '/',
|
| 123 |
+
path: '/',
|
| 124 |
+
getParentRoute: () => AdminRoute,
|
| 125 |
+
} as any)
|
| 126 |
const ProfileUserIdRoute = ProfileUserIdRouteImport.update({
|
| 127 |
id: '/profile/$userId',
|
| 128 |
path: '/profile/$userId',
|
|
|
|
| 138 |
path: '/attempt/$id',
|
| 139 |
getParentRoute: () => rootRouteImport,
|
| 140 |
} as any)
|
| 141 |
+
const AdminUsersRoute = AdminUsersRouteImport.update({
|
| 142 |
+
id: '/users',
|
| 143 |
+
path: '/users',
|
| 144 |
+
getParentRoute: () => AdminRoute,
|
| 145 |
+
} as any)
|
| 146 |
+
const AdminModerationRoute = AdminModerationRouteImport.update({
|
| 147 |
+
id: '/moderation',
|
| 148 |
+
path: '/moderation',
|
| 149 |
+
getParentRoute: () => AdminRoute,
|
| 150 |
+
} as any)
|
| 151 |
+
const AdminJobsRoute = AdminJobsRouteImport.update({
|
| 152 |
+
id: '/jobs',
|
| 153 |
+
path: '/jobs',
|
| 154 |
+
getParentRoute: () => AdminRoute,
|
| 155 |
+
} as any)
|
| 156 |
+
const AdminFeaturedRoute = AdminFeaturedRouteImport.update({
|
| 157 |
+
id: '/featured',
|
| 158 |
+
path: '/featured',
|
| 159 |
+
getParentRoute: () => AdminRoute,
|
| 160 |
+
} as any)
|
| 161 |
+
const AdminCreditsRoute = AdminCreditsRouteImport.update({
|
| 162 |
+
id: '/credits',
|
| 163 |
+
path: '/credits',
|
| 164 |
+
getParentRoute: () => AdminRoute,
|
| 165 |
+
} as any)
|
| 166 |
const PackageIdIndexRoute = PackageIdIndexRouteImport.update({
|
| 167 |
id: '/',
|
| 168 |
path: '/',
|
|
|
|
| 182 |
|
| 183 |
export interface FileRoutesByFullPath {
|
| 184 |
'/': typeof IndexRoute
|
| 185 |
+
'/admin': typeof AdminRouteWithChildren
|
| 186 |
'/analytics': typeof AnalyticsRoute
|
| 187 |
'/bank': typeof BankRoute
|
| 188 |
'/forgot-password': typeof ForgotPasswordRoute
|
|
|
|
| 197 |
'/settings': typeof SettingsRoute
|
| 198 |
'/setup-avatar': typeof SetupAvatarRoute
|
| 199 |
'/verify-email': typeof VerifyEmailRoute
|
| 200 |
+
'/admin/credits': typeof AdminCreditsRoute
|
| 201 |
+
'/admin/featured': typeof AdminFeaturedRoute
|
| 202 |
+
'/admin/jobs': typeof AdminJobsRoute
|
| 203 |
+
'/admin/moderation': typeof AdminModerationRoute
|
| 204 |
+
'/admin/users': typeof AdminUsersRoute
|
| 205 |
'/attempt/$id': typeof AttemptIdRoute
|
| 206 |
'/package/$id': typeof PackageIdRouteWithChildren
|
| 207 |
'/profile/$userId': typeof ProfileUserIdRoute
|
| 208 |
+
'/admin/': typeof AdminIndexRoute
|
| 209 |
'/package/$id/take': typeof PackageIdTakeRoute
|
| 210 |
'/package/$id/': typeof PackageIdIndexRoute
|
| 211 |
'/package/$id/attempt/$attemptId': typeof PackageIdAttemptAttemptIdRoute
|
|
|
|
| 226 |
'/settings': typeof SettingsRoute
|
| 227 |
'/setup-avatar': typeof SetupAvatarRoute
|
| 228 |
'/verify-email': typeof VerifyEmailRoute
|
| 229 |
+
'/admin/credits': typeof AdminCreditsRoute
|
| 230 |
+
'/admin/featured': typeof AdminFeaturedRoute
|
| 231 |
+
'/admin/jobs': typeof AdminJobsRoute
|
| 232 |
+
'/admin/moderation': typeof AdminModerationRoute
|
| 233 |
+
'/admin/users': typeof AdminUsersRoute
|
| 234 |
'/attempt/$id': typeof AttemptIdRoute
|
| 235 |
'/profile/$userId': typeof ProfileUserIdRoute
|
| 236 |
+
'/admin': typeof AdminIndexRoute
|
| 237 |
'/package/$id/take': typeof PackageIdTakeRoute
|
| 238 |
'/package/$id': typeof PackageIdIndexRoute
|
| 239 |
'/package/$id/attempt/$attemptId': typeof PackageIdAttemptAttemptIdRoute
|
|
|
|
| 241 |
export interface FileRoutesById {
|
| 242 |
__root__: typeof rootRouteImport
|
| 243 |
'/': typeof IndexRoute
|
| 244 |
+
'/admin': typeof AdminRouteWithChildren
|
| 245 |
'/analytics': typeof AnalyticsRoute
|
| 246 |
'/bank': typeof BankRoute
|
| 247 |
'/forgot-password': typeof ForgotPasswordRoute
|
|
|
|
| 256 |
'/settings': typeof SettingsRoute
|
| 257 |
'/setup-avatar': typeof SetupAvatarRoute
|
| 258 |
'/verify-email': typeof VerifyEmailRoute
|
| 259 |
+
'/admin/credits': typeof AdminCreditsRoute
|
| 260 |
+
'/admin/featured': typeof AdminFeaturedRoute
|
| 261 |
+
'/admin/jobs': typeof AdminJobsRoute
|
| 262 |
+
'/admin/moderation': typeof AdminModerationRoute
|
| 263 |
+
'/admin/users': typeof AdminUsersRoute
|
| 264 |
'/attempt/$id': typeof AttemptIdRoute
|
| 265 |
'/package/$id': typeof PackageIdRouteWithChildren
|
| 266 |
'/profile/$userId': typeof ProfileUserIdRoute
|
| 267 |
+
'/admin/': typeof AdminIndexRoute
|
| 268 |
'/package/$id/take': typeof PackageIdTakeRoute
|
| 269 |
'/package/$id/': typeof PackageIdIndexRoute
|
| 270 |
'/package/$id/attempt/$attemptId': typeof PackageIdAttemptAttemptIdRoute
|
|
|
|
| 273 |
fileRoutesByFullPath: FileRoutesByFullPath
|
| 274 |
fullPaths:
|
| 275 |
| '/'
|
| 276 |
+
| '/admin'
|
| 277 |
| '/analytics'
|
| 278 |
| '/bank'
|
| 279 |
| '/forgot-password'
|
|
|
|
| 288 |
| '/settings'
|
| 289 |
| '/setup-avatar'
|
| 290 |
| '/verify-email'
|
| 291 |
+
| '/admin/credits'
|
| 292 |
+
| '/admin/featured'
|
| 293 |
+
| '/admin/jobs'
|
| 294 |
+
| '/admin/moderation'
|
| 295 |
+
| '/admin/users'
|
| 296 |
| '/attempt/$id'
|
| 297 |
| '/package/$id'
|
| 298 |
| '/profile/$userId'
|
| 299 |
+
| '/admin/'
|
| 300 |
| '/package/$id/take'
|
| 301 |
| '/package/$id/'
|
| 302 |
| '/package/$id/attempt/$attemptId'
|
|
|
|
| 317 |
| '/settings'
|
| 318 |
| '/setup-avatar'
|
| 319 |
| '/verify-email'
|
| 320 |
+
| '/admin/credits'
|
| 321 |
+
| '/admin/featured'
|
| 322 |
+
| '/admin/jobs'
|
| 323 |
+
| '/admin/moderation'
|
| 324 |
+
| '/admin/users'
|
| 325 |
| '/attempt/$id'
|
| 326 |
| '/profile/$userId'
|
| 327 |
+
| '/admin'
|
| 328 |
| '/package/$id/take'
|
| 329 |
| '/package/$id'
|
| 330 |
| '/package/$id/attempt/$attemptId'
|
| 331 |
id:
|
| 332 |
| '__root__'
|
| 333 |
| '/'
|
| 334 |
+
| '/admin'
|
| 335 |
| '/analytics'
|
| 336 |
| '/bank'
|
| 337 |
| '/forgot-password'
|
|
|
|
| 346 |
| '/settings'
|
| 347 |
| '/setup-avatar'
|
| 348 |
| '/verify-email'
|
| 349 |
+
| '/admin/credits'
|
| 350 |
+
| '/admin/featured'
|
| 351 |
+
| '/admin/jobs'
|
| 352 |
+
| '/admin/moderation'
|
| 353 |
+
| '/admin/users'
|
| 354 |
| '/attempt/$id'
|
| 355 |
| '/package/$id'
|
| 356 |
| '/profile/$userId'
|
| 357 |
+
| '/admin/'
|
| 358 |
| '/package/$id/take'
|
| 359 |
| '/package/$id/'
|
| 360 |
| '/package/$id/attempt/$attemptId'
|
|
|
|
| 362 |
}
|
| 363 |
export interface RootRouteChildren {
|
| 364 |
IndexRoute: typeof IndexRoute
|
| 365 |
+
AdminRoute: typeof AdminRouteWithChildren
|
| 366 |
AnalyticsRoute: typeof AnalyticsRoute
|
| 367 |
BankRoute: typeof BankRoute
|
| 368 |
ForgotPasswordRoute: typeof ForgotPasswordRoute
|
|
|
|
| 482 |
preLoaderRoute: typeof AnalyticsRouteImport
|
| 483 |
parentRoute: typeof rootRouteImport
|
| 484 |
}
|
| 485 |
+
'/admin': {
|
| 486 |
+
id: '/admin'
|
| 487 |
+
path: '/admin'
|
| 488 |
+
fullPath: '/admin'
|
| 489 |
+
preLoaderRoute: typeof AdminRouteImport
|
| 490 |
+
parentRoute: typeof rootRouteImport
|
| 491 |
+
}
|
| 492 |
'/': {
|
| 493 |
id: '/'
|
| 494 |
path: '/'
|
|
|
|
| 496 |
preLoaderRoute: typeof IndexRouteImport
|
| 497 |
parentRoute: typeof rootRouteImport
|
| 498 |
}
|
| 499 |
+
'/admin/': {
|
| 500 |
+
id: '/admin/'
|
| 501 |
+
path: '/'
|
| 502 |
+
fullPath: '/admin/'
|
| 503 |
+
preLoaderRoute: typeof AdminIndexRouteImport
|
| 504 |
+
parentRoute: typeof AdminRoute
|
| 505 |
+
}
|
| 506 |
'/profile/$userId': {
|
| 507 |
id: '/profile/$userId'
|
| 508 |
path: '/profile/$userId'
|
|
|
|
| 524 |
preLoaderRoute: typeof AttemptIdRouteImport
|
| 525 |
parentRoute: typeof rootRouteImport
|
| 526 |
}
|
| 527 |
+
'/admin/users': {
|
| 528 |
+
id: '/admin/users'
|
| 529 |
+
path: '/users'
|
| 530 |
+
fullPath: '/admin/users'
|
| 531 |
+
preLoaderRoute: typeof AdminUsersRouteImport
|
| 532 |
+
parentRoute: typeof AdminRoute
|
| 533 |
+
}
|
| 534 |
+
'/admin/moderation': {
|
| 535 |
+
id: '/admin/moderation'
|
| 536 |
+
path: '/moderation'
|
| 537 |
+
fullPath: '/admin/moderation'
|
| 538 |
+
preLoaderRoute: typeof AdminModerationRouteImport
|
| 539 |
+
parentRoute: typeof AdminRoute
|
| 540 |
+
}
|
| 541 |
+
'/admin/jobs': {
|
| 542 |
+
id: '/admin/jobs'
|
| 543 |
+
path: '/jobs'
|
| 544 |
+
fullPath: '/admin/jobs'
|
| 545 |
+
preLoaderRoute: typeof AdminJobsRouteImport
|
| 546 |
+
parentRoute: typeof AdminRoute
|
| 547 |
+
}
|
| 548 |
+
'/admin/featured': {
|
| 549 |
+
id: '/admin/featured'
|
| 550 |
+
path: '/featured'
|
| 551 |
+
fullPath: '/admin/featured'
|
| 552 |
+
preLoaderRoute: typeof AdminFeaturedRouteImport
|
| 553 |
+
parentRoute: typeof AdminRoute
|
| 554 |
+
}
|
| 555 |
+
'/admin/credits': {
|
| 556 |
+
id: '/admin/credits'
|
| 557 |
+
path: '/credits'
|
| 558 |
+
fullPath: '/admin/credits'
|
| 559 |
+
preLoaderRoute: typeof AdminCreditsRouteImport
|
| 560 |
+
parentRoute: typeof AdminRoute
|
| 561 |
+
}
|
| 562 |
'/package/$id/': {
|
| 563 |
id: '/package/$id/'
|
| 564 |
path: '/'
|
|
|
|
| 583 |
}
|
| 584 |
}
|
| 585 |
|
| 586 |
+
interface AdminRouteChildren {
|
| 587 |
+
AdminCreditsRoute: typeof AdminCreditsRoute
|
| 588 |
+
AdminFeaturedRoute: typeof AdminFeaturedRoute
|
| 589 |
+
AdminJobsRoute: typeof AdminJobsRoute
|
| 590 |
+
AdminModerationRoute: typeof AdminModerationRoute
|
| 591 |
+
AdminUsersRoute: typeof AdminUsersRoute
|
| 592 |
+
AdminIndexRoute: typeof AdminIndexRoute
|
| 593 |
+
}
|
| 594 |
+
|
| 595 |
+
const AdminRouteChildren: AdminRouteChildren = {
|
| 596 |
+
AdminCreditsRoute: AdminCreditsRoute,
|
| 597 |
+
AdminFeaturedRoute: AdminFeaturedRoute,
|
| 598 |
+
AdminJobsRoute: AdminJobsRoute,
|
| 599 |
+
AdminModerationRoute: AdminModerationRoute,
|
| 600 |
+
AdminUsersRoute: AdminUsersRoute,
|
| 601 |
+
AdminIndexRoute: AdminIndexRoute,
|
| 602 |
+
}
|
| 603 |
+
|
| 604 |
+
const AdminRouteWithChildren = AdminRoute._addFileChildren(AdminRouteChildren)
|
| 605 |
+
|
| 606 |
interface PackageIdRouteChildren {
|
| 607 |
PackageIdTakeRoute: typeof PackageIdTakeRoute
|
| 608 |
PackageIdIndexRoute: typeof PackageIdIndexRoute
|
|
|
|
| 621 |
|
| 622 |
const rootRouteChildren: RootRouteChildren = {
|
| 623 |
IndexRoute: IndexRoute,
|
| 624 |
+
AdminRoute: AdminRouteWithChildren,
|
| 625 |
AnalyticsRoute: AnalyticsRoute,
|
| 626 |
BankRoute: BankRoute,
|
| 627 |
ForgotPasswordRoute: ForgotPasswordRoute,
|
apps/web/src/routes/__root.tsx
CHANGED
|
@@ -41,7 +41,8 @@ function RootComponent() {
|
|
| 41 |
m.routeId === "/login" ||
|
| 42 |
m.routeId === "/setup-avatar" ||
|
| 43 |
m.routeId === "/verify-email" ||
|
| 44 |
-
m.routeId === "/forgot-password"
|
|
|
|
| 45 |
);
|
| 46 |
const isLanding = matches.some((m) => m.routeId === "/landing");
|
| 47 |
|
|
|
|
| 41 |
m.routeId === "/login" ||
|
| 42 |
m.routeId === "/setup-avatar" ||
|
| 43 |
m.routeId === "/verify-email" ||
|
| 44 |
+
m.routeId === "/forgot-password" ||
|
| 45 |
+
m.routeId.startsWith("/admin"),
|
| 46 |
);
|
| 47 |
const isLanding = matches.some((m) => m.routeId === "/landing");
|
| 48 |
|
apps/web/src/routes/admin.credits.tsx
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState } from "react";
|
| 2 |
+
import { createFileRoute } from "@tanstack/react-router";
|
| 3 |
+
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
| 4 |
+
import { trpc } from "@/utils/trpc";
|
| 5 |
+
import { Input } from "@labas/ui/components/input";
|
| 6 |
+
import { Button } from "@labas/ui/components/button";
|
| 7 |
+
import { toast } from "sonner";
|
| 8 |
+
|
| 9 |
+
export const Route = createFileRoute("/admin/credits")({
|
| 10 |
+
component: AdminCredits,
|
| 11 |
+
});
|
| 12 |
+
|
| 13 |
+
function AdminCredits() {
|
| 14 |
+
const [search, setSearch] = useState("");
|
| 15 |
+
const [debouncedSearch, setDebouncedSearch] = useState("");
|
| 16 |
+
const [selectedUserId, setSelectedUserId] = useState("");
|
| 17 |
+
const [selectedUserName, setSelectedUserName] = useState("");
|
| 18 |
+
const [amount, setAmount] = useState("");
|
| 19 |
+
const [description, setDescription] = useState("");
|
| 20 |
+
const queryClient = useQueryClient();
|
| 21 |
+
|
| 22 |
+
const userSearch = useQuery(
|
| 23 |
+
trpc.admin.listUsers.queryOptions(
|
| 24 |
+
{ search: debouncedSearch || undefined, limit: 10, offset: 0 },
|
| 25 |
+
{ enabled: true },
|
| 26 |
+
),
|
| 27 |
+
);
|
| 28 |
+
|
| 29 |
+
const creditQuery = useQuery(
|
| 30 |
+
trpc.admin.getCreditBalance.queryOptions(
|
| 31 |
+
{ userId: selectedUserId || "none" },
|
| 32 |
+
{ enabled: !!selectedUserId },
|
| 33 |
+
),
|
| 34 |
+
);
|
| 35 |
+
|
| 36 |
+
const historyQuery = useQuery(
|
| 37 |
+
trpc.admin.getCreditHistory.queryOptions(
|
| 38 |
+
{ userId: selectedUserId || "none" },
|
| 39 |
+
{ enabled: !!selectedUserId },
|
| 40 |
+
),
|
| 41 |
+
);
|
| 42 |
+
|
| 43 |
+
const adjustMutation = useMutation(
|
| 44 |
+
trpc.admin.adjustCredit.mutationOptions({
|
| 45 |
+
onSuccess: (data) => {
|
| 46 |
+
queryClient.invalidateQueries({ queryKey: trpc.admin.getCreditBalance.queryKey() });
|
| 47 |
+
queryClient.invalidateQueries({ queryKey: trpc.admin.getCreditHistory.queryKey() });
|
| 48 |
+
toast.success(`Credit updated. New balance: ${data.newBalance.toLocaleString()}`);
|
| 49 |
+
setAmount("");
|
| 50 |
+
setDescription("");
|
| 51 |
+
},
|
| 52 |
+
onError: (e: any) => toast.error(e.message),
|
| 53 |
+
}),
|
| 54 |
+
);
|
| 55 |
+
|
| 56 |
+
function handleSearch(val: string) {
|
| 57 |
+
setSearch(val);
|
| 58 |
+
const t = (window as any).__ct;
|
| 59 |
+
if (t) clearTimeout(t);
|
| 60 |
+
(window as any).__ct = setTimeout(() => {
|
| 61 |
+
setDebouncedSearch(val);
|
| 62 |
+
}, 300);
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
function selectUser(id: string, name: string) {
|
| 66 |
+
setSelectedUserId(id);
|
| 67 |
+
setSelectedUserName(name);
|
| 68 |
+
setSearch("");
|
| 69 |
+
setDebouncedSearch("");
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
function handleAdjust() {
|
| 73 |
+
const val = parseInt(amount, 10);
|
| 74 |
+
if (isNaN(val) || val === 0) {
|
| 75 |
+
toast.error("Enter a valid amount");
|
| 76 |
+
return;
|
| 77 |
+
}
|
| 78 |
+
adjustMutation.mutate({ userId: selectedUserId, amount: val, description: description || undefined });
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
return (
|
| 82 |
+
<div>
|
| 83 |
+
<h1 className="text-3xl font-headline font-bold text-[var(--clay-black)] mb-2">Credit Management</h1>
|
| 84 |
+
<p className="text-[var(--warm-charcoal)] mb-8">View and adjust token credits for users.</p>
|
| 85 |
+
|
| 86 |
+
<div className="bg-[var(--pure-white)] rounded-[var(--radius-xl)] border border-[var(--oat-border)] p-6 mb-8">
|
| 87 |
+
<h2 className="text-lg font-headline font-bold text-[var(--clay-black)] mb-4">Find User</h2>
|
| 88 |
+
<div className="relative">
|
| 89 |
+
<Input
|
| 90 |
+
placeholder="Search by name or email..."
|
| 91 |
+
value={search}
|
| 92 |
+
onChange={(e) => handleSearch(e.target.value)}
|
| 93 |
+
className="max-w-md"
|
| 94 |
+
/>
|
| 95 |
+
{userSearch.data && userSearch.data.users.length > 0 && !selectedUserId && (
|
| 96 |
+
<div className="absolute top-full mt-1 w-full max-w-md bg-[var(--pure-white)] border border-[var(--oat-border)] rounded-[var(--radius-lg)] shadow-lg z-10 max-h-60 overflow-y-auto">
|
| 97 |
+
{userSearch.data.users.map((u) => (
|
| 98 |
+
<button
|
| 99 |
+
key={u.id}
|
| 100 |
+
onClick={() => selectUser(u.id, u.name)}
|
| 101 |
+
className="w-full text-left px-4 py-2.5 hover:bg-[var(--oat-light)] transition-colors text-sm"
|
| 102 |
+
>
|
| 103 |
+
<span className="font-medium text-[var(--clay-black)]">{u.name}</span>
|
| 104 |
+
<span className="text-[var(--warm-charcoal)] ml-2">{u.email}</span>
|
| 105 |
+
</button>
|
| 106 |
+
))}
|
| 107 |
+
</div>
|
| 108 |
+
)}
|
| 109 |
+
</div>
|
| 110 |
+
|
| 111 |
+
{selectedUserId && (
|
| 112 |
+
<div className="mt-4">
|
| 113 |
+
<div className="flex items-center gap-3">
|
| 114 |
+
<span className="font-medium text-[var(--clay-black)]">{selectedUserName}</span>
|
| 115 |
+
<span className="text-xs text-[var(--warm-charcoal)] font-mono">{selectedUserId}</span>
|
| 116 |
+
<Button variant="ghost" size="sm" onClick={() => { setSelectedUserId(""); setSelectedUserName(""); }}>
|
| 117 |
+
Change
|
| 118 |
+
</Button>
|
| 119 |
+
</div>
|
| 120 |
+
{creditQuery.data && (
|
| 121 |
+
<div className="mt-3 p-4 bg-[var(--oat-light)] rounded-[var(--radius-lg)] grid grid-cols-2 gap-4">
|
| 122 |
+
<div>
|
| 123 |
+
<p className="text-xs text-[var(--warm-charcoal)] mb-1">Token Balance</p>
|
| 124 |
+
<p className="text-2xl font-headline font-bold text-[var(--clay-black)]">{creditQuery.data.tokenBalance.toLocaleString()}</p>
|
| 125 |
+
</div>
|
| 126 |
+
<div>
|
| 127 |
+
<p className="text-xs text-[var(--warm-charcoal)] mb-1">Lifetime Used</p>
|
| 128 |
+
<p className="text-2xl font-headline font-bold text-[var(--warm-charcoal)]">{creditQuery.data.lifetimeTokensUsed.toLocaleString()}</p>
|
| 129 |
+
</div>
|
| 130 |
+
</div>
|
| 131 |
+
)}
|
| 132 |
+
</div>
|
| 133 |
+
)}
|
| 134 |
+
</div>
|
| 135 |
+
|
| 136 |
+
{selectedUserId && (
|
| 137 |
+
<div className="bg-[var(--pure-white)] rounded-[var(--radius-xl)] border border-[var(--oat-border)] p-6 mb-8">
|
| 138 |
+
<h2 className="text-lg font-headline font-bold text-[var(--clay-black)] mb-4">Adjust Credits</h2>
|
| 139 |
+
<div className="flex flex-wrap gap-3 items-end">
|
| 140 |
+
<div>
|
| 141 |
+
<label className="text-xs text-[var(--warm-charcoal)] block mb-1">Amount (+ add, - deduct)</label>
|
| 142 |
+
<Input type="number" value={amount} onChange={(e) => setAmount(e.target.value)} placeholder="e.g. 50000" className="w-40" />
|
| 143 |
+
</div>
|
| 144 |
+
<div>
|
| 145 |
+
<label className="text-xs text-[var(--warm-charcoal)] block mb-1">Note</label>
|
| 146 |
+
<Input value={description} onChange={(e) => setDescription(e.target.value)} placeholder="Reason" className="w-48" />
|
| 147 |
+
</div>
|
| 148 |
+
<Button onClick={handleAdjust} disabled={adjustMutation.isPending}>Apply</Button>
|
| 149 |
+
</div>
|
| 150 |
+
<p className="text-xs text-[var(--warm-charcoal)] mt-3">
|
| 151 |
+
Credits are in tokens. ~1,000 tokens ≈ $0.01 with budget models.
|
| 152 |
+
</p>
|
| 153 |
+
</div>
|
| 154 |
+
)}
|
| 155 |
+
|
| 156 |
+
{historyQuery.data && historyQuery.data.transactions.length > 0 && (
|
| 157 |
+
<div className="bg-[var(--pure-white)] rounded-[var(--radius-xl)] border border-[var(--oat-border)] overflow-hidden">
|
| 158 |
+
<h2 className="text-lg font-headline font-bold text-[var(--clay-black)] px-6 pt-6 pb-2">Transaction History</h2>
|
| 159 |
+
<table className="w-full text-sm">
|
| 160 |
+
<thead className="bg-[var(--oat-light)] text-[var(--warm-charcoal)]">
|
| 161 |
+
<tr>
|
| 162 |
+
<th className="text-left px-4 py-3 font-medium">Type</th>
|
| 163 |
+
<th className="text-left px-4 py-3 font-medium">Amount</th>
|
| 164 |
+
<th className="text-left px-4 py-3 font-medium">Description</th>
|
| 165 |
+
<th className="text-left px-4 py-3 font-medium">Date</th>
|
| 166 |
+
</tr>
|
| 167 |
+
</thead>
|
| 168 |
+
<tbody>
|
| 169 |
+
{historyQuery.data.transactions.map((txn: any) => (
|
| 170 |
+
<tr key={txn.id} className="border-t border-[var(--oat-border)]">
|
| 171 |
+
<td className="px-4 py-3">
|
| 172 |
+
<span className={`text-xs font-semibold px-2 py-0.5 rounded-full ${
|
| 173 |
+
txn.type === "signup_bonus" ? "bg-[var(--sunbeam-300)] text-[var(--sunbeam-800)]" :
|
| 174 |
+
txn.type === "admin_adjust" ? "bg-[var(--matcha-300)] text-[var(--matcha-800)]" :
|
| 175 |
+
"bg-[var(--oat-border)] text-[var(--warm-charcoal)]"
|
| 176 |
+
}`}>{txn.type}</span>
|
| 177 |
+
</td>
|
| 178 |
+
<td className={`px-4 py-3 font-medium ${txn.amount >= 0 ? "text-[var(--matcha-700)]" : "text-[var(--clay-red)]"}`}>
|
| 179 |
+
{txn.amount >= 0 ? "+" : ""}{txn.amount.toLocaleString()}
|
| 180 |
+
</td>
|
| 181 |
+
<td className="px-4 py-3 text-[var(--warm-charcoal)]">{txn.description ?? "-"}</td>
|
| 182 |
+
<td className="px-4 py-3 text-[var(--warm-charcoal)]">{new Date(txn.createdAt).toLocaleDateString("id-ID", { day: "numeric", month: "short", year: "numeric" })}</td>
|
| 183 |
+
</tr>
|
| 184 |
+
))}
|
| 185 |
+
</tbody>
|
| 186 |
+
</table>
|
| 187 |
+
</div>
|
| 188 |
+
)}
|
| 189 |
+
</div>
|
| 190 |
+
);
|
| 191 |
+
}
|
apps/web/src/routes/admin.featured.tsx
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState } from "react";
|
| 2 |
+
import { createFileRoute } from "@tanstack/react-router";
|
| 3 |
+
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
| 4 |
+
import { trpc } from "@/utils/trpc";
|
| 5 |
+
import { Input } from "@labas/ui/components/input";
|
| 6 |
+
import { Button } from "@labas/ui/components/button";
|
| 7 |
+
import { toast } from "sonner";
|
| 8 |
+
|
| 9 |
+
export const Route = createFileRoute("/admin/featured")({
|
| 10 |
+
component: AdminFeatured,
|
| 11 |
+
});
|
| 12 |
+
|
| 13 |
+
type Tab = "featured" | "packages" | "questions";
|
| 14 |
+
|
| 15 |
+
function AdminFeatured() {
|
| 16 |
+
const [tab, setTab] = useState<Tab>("featured");
|
| 17 |
+
const [search, setSearch] = useState("");
|
| 18 |
+
const [debouncedSearch, setDebouncedSearch] = useState("");
|
| 19 |
+
const [page, setPage] = useState(1);
|
| 20 |
+
const limit = 15;
|
| 21 |
+
const queryClient = useQueryClient();
|
| 22 |
+
|
| 23 |
+
const featured = useQuery(trpc.admin.listFeatured.queryOptions());
|
| 24 |
+
const fPackages = featured.data?.packages ?? [];
|
| 25 |
+
const fQuestions = featured.data?.questions ?? [];
|
| 26 |
+
|
| 27 |
+
const searchQuery = useQuery(
|
| 28 |
+
trpc.admin.searchContent.queryOptions(
|
| 29 |
+
{ search: debouncedSearch, type: tab === "packages" ? "packages" : "questions", limit, offset: (page - 1) * limit },
|
| 30 |
+
{ enabled: tab !== "featured" },
|
| 31 |
+
),
|
| 32 |
+
);
|
| 33 |
+
|
| 34 |
+
const togglePkgMutation = useMutation(
|
| 35 |
+
trpc.admin.toggleFeaturedPackage.mutationOptions({
|
| 36 |
+
onSuccess: () => {
|
| 37 |
+
queryClient.invalidateQueries({ queryKey: trpc.admin.listFeatured.queryKey() });
|
| 38 |
+
queryClient.invalidateQueries({ queryKey: trpc.admin.searchContent.queryKey() });
|
| 39 |
+
toast.success("Package updated");
|
| 40 |
+
},
|
| 41 |
+
onError: (e: any) => toast.error(e.message),
|
| 42 |
+
}),
|
| 43 |
+
);
|
| 44 |
+
|
| 45 |
+
const toggleQMutation = useMutation(
|
| 46 |
+
trpc.admin.toggleFeaturedQuestion.mutationOptions({
|
| 47 |
+
onSuccess: () => {
|
| 48 |
+
queryClient.invalidateQueries({ queryKey: trpc.admin.listFeatured.queryKey() });
|
| 49 |
+
queryClient.invalidateQueries({ queryKey: trpc.admin.searchContent.queryKey() });
|
| 50 |
+
toast.success("Question updated");
|
| 51 |
+
},
|
| 52 |
+
onError: (e: any) => toast.error(e.message),
|
| 53 |
+
}),
|
| 54 |
+
);
|
| 55 |
+
|
| 56 |
+
function handleSearch(val: string) {
|
| 57 |
+
setSearch(val);
|
| 58 |
+
const t = (window as any).__ft;
|
| 59 |
+
if (t) clearTimeout(t);
|
| 60 |
+
(window as any).__ft = setTimeout(() => {
|
| 61 |
+
setDebouncedSearch(val);
|
| 62 |
+
setPage(1);
|
| 63 |
+
}, 300);
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
const tabs: { key: Tab; label: string }[] = [
|
| 67 |
+
{ key: "featured", label: "Currently Featured" },
|
| 68 |
+
{ key: "packages", label: "Browse Packages" },
|
| 69 |
+
{ key: "questions", label: "Browse Questions" },
|
| 70 |
+
];
|
| 71 |
+
|
| 72 |
+
return (
|
| 73 |
+
<div>
|
| 74 |
+
<h1 className="text-3xl font-headline font-bold text-[var(--clay-black)] mb-2">Editor's Pick</h1>
|
| 75 |
+
<p className="text-[var(--warm-charcoal)] mb-6">Curate featured content for the homepage.</p>
|
| 76 |
+
|
| 77 |
+
<div className="flex gap-2 mb-6 border-b border-[var(--oat-border)] pb-0">
|
| 78 |
+
{tabs.map((t) => (
|
| 79 |
+
<button
|
| 80 |
+
key={t.key}
|
| 81 |
+
onClick={() => { setTab(t.key); setSearch(""); setDebouncedSearch(""); setPage(1); }}
|
| 82 |
+
className={`px-4 py-2.5 text-sm font-medium rounded-t-[var(--radius-lg)] transition-colors ${
|
| 83 |
+
tab === t.key
|
| 84 |
+
? "bg-[var(--pure-white)] text-[var(--clay-black)] border border-[var(--oat-border)] border-b-[var(--pure-white)] -mb-[1px]"
|
| 85 |
+
: "text-[var(--warm-charcoal)] hover:text-[var(--clay-black)]"
|
| 86 |
+
}`}
|
| 87 |
+
>
|
| 88 |
+
{t.label}
|
| 89 |
+
</button>
|
| 90 |
+
))}
|
| 91 |
+
</div>
|
| 92 |
+
|
| 93 |
+
{tab === "featured" && (
|
| 94 |
+
<div className="space-y-8">
|
| 95 |
+
<section>
|
| 96 |
+
<div className="flex items-center gap-2 mb-4">
|
| 97 |
+
<h2 className="text-lg font-headline font-bold text-[var(--clay-black)]">Featured Packages</h2>
|
| 98 |
+
<span className="text-sm text-[var(--warm-charcoal)]">({fPackages.length})</span>
|
| 99 |
+
</div>
|
| 100 |
+
{fPackages.length === 0 ? (
|
| 101 |
+
<p className="text-sm text-[var(--warm-charcoal)] py-4">No featured packages. Browse and select from the tabs above.</p>
|
| 102 |
+
) : (
|
| 103 |
+
<div className="space-y-2">
|
| 104 |
+
{fPackages.map((pkg: any) => (
|
| 105 |
+
<div key={pkg.id} className="flex items-center justify-between bg-[var(--pure-white)] border border-[var(--oat-border)] rounded-[var(--radius-lg)] px-4 py-3">
|
| 106 |
+
<div>
|
| 107 |
+
<p className="font-medium text-[var(--clay-black)]">{pkg.title}</p>
|
| 108 |
+
<p className="text-xs text-[var(--warm-charcoal)]">{pkg.examTypeId}</p>
|
| 109 |
+
</div>
|
| 110 |
+
<Button variant="outline" onClick={() => togglePkgMutation.mutate({ packageId: pkg.id })} disabled={togglePkgMutation.isPending} className="h-9 rounded-[var(--radius-lg)] text-xs">Unfeature</Button>
|
| 111 |
+
</div>
|
| 112 |
+
))}
|
| 113 |
+
</div>
|
| 114 |
+
)}
|
| 115 |
+
</section>
|
| 116 |
+
|
| 117 |
+
<section>
|
| 118 |
+
<div className="flex items-center gap-2 mb-4">
|
| 119 |
+
<h2 className="text-lg font-headline font-bold text-[var(--clay-black)]">Featured Questions</h2>
|
| 120 |
+
<span className="text-sm text-[var(--warm-charcoal)]">({fQuestions.length})</span>
|
| 121 |
+
</div>
|
| 122 |
+
{fQuestions.length === 0 ? (
|
| 123 |
+
<p className="text-sm text-[var(--warm-charcoal)] py-4">No featured questions.</p>
|
| 124 |
+
) : (
|
| 125 |
+
<div className="space-y-2">
|
| 126 |
+
{fQuestions.map((q: any) => (
|
| 127 |
+
<div key={q.id} className="flex items-center justify-between bg-[var(--pure-white)] border border-[var(--oat-border)] rounded-[var(--radius-lg)] px-4 py-3">
|
| 128 |
+
<div className="flex-1 min-w-0">
|
| 129 |
+
<p className="font-medium text-[var(--clay-black)] truncate">{q.questionText}</p>
|
| 130 |
+
<p className="text-xs text-[var(--warm-charcoal)]">{q.format} · {q.examTypeId}</p>
|
| 131 |
+
</div>
|
| 132 |
+
<Button variant="outline" className="shrink-0 ml-4 h-9 rounded-[var(--radius-lg)] text-xs" onClick={() => toggleQMutation.mutate({ questionId: q.id })} disabled={toggleQMutation.isPending}>Unfeature</Button>
|
| 133 |
+
</div>
|
| 134 |
+
))}
|
| 135 |
+
</div>
|
| 136 |
+
)}
|
| 137 |
+
</section>
|
| 138 |
+
</div>
|
| 139 |
+
)}
|
| 140 |
+
|
| 141 |
+
{tab !== "featured" && (
|
| 142 |
+
<div>
|
| 143 |
+
<Input
|
| 144 |
+
placeholder={`Search ${tab}...`}
|
| 145 |
+
value={search}
|
| 146 |
+
onChange={(e) => handleSearch(e.target.value)}
|
| 147 |
+
className="max-w-md mb-4"
|
| 148 |
+
/>
|
| 149 |
+
|
| 150 |
+
{searchQuery.isLoading ? (
|
| 151 |
+
<p className="text-sm text-[var(--warm-charcoal)] py-8 text-center">Searching...</p>
|
| 152 |
+
) : (
|
| 153 |
+
<>
|
| 154 |
+
<p className="text-sm text-[var(--warm-charcoal)] mb-3">
|
| 155 |
+
{debouncedSearch ? `${searchQuery.data?.total ?? 0} results for "${debouncedSearch}"` : `Showing ${searchQuery.data?.total ?? 0} ${tab}`}
|
| 156 |
+
</p>
|
| 157 |
+
<div className="space-y-2">
|
| 158 |
+
{(searchQuery.data?.items ?? []).map((item: any) => (
|
| 159 |
+
<div key={item.id} className="flex items-center justify-between bg-[var(--pure-white)] border border-[var(--oat-border)] rounded-[var(--radius-lg)] px-4 py-3">
|
| 160 |
+
<div className="flex-1 min-w-0">
|
| 161 |
+
<p className="font-medium text-[var(--clay-black)] truncate">
|
| 162 |
+
{tab === "packages" ? item.title : item.questionText}
|
| 163 |
+
</p>
|
| 164 |
+
<p className="text-xs text-[var(--warm-charcoal)]">
|
| 165 |
+
{tab === "packages" ? item.examTypeId : `${item.format} · ${item.examTypeId}`}
|
| 166 |
+
{item.isFeatured && (
|
| 167 |
+
<span className="ml-2 text-xs font-semibold px-1.5 py-0.5 rounded-full bg-[var(--sunbeam-300)] text-[var(--sunbeam-800)]">Featured</span>
|
| 168 |
+
)}
|
| 169 |
+
</p>
|
| 170 |
+
</div>
|
| 171 |
+
<Button
|
| 172 |
+
variant={item.isFeatured ? "outline" : "default"}
|
| 173 |
+
className="shrink-0 ml-4 h-9 rounded-[var(--radius-lg)] text-xs"
|
| 174 |
+
onClick={() => {
|
| 175 |
+
if (tab === "packages") togglePkgMutation.mutate({ packageId: item.id });
|
| 176 |
+
else toggleQMutation.mutate({ questionId: item.id });
|
| 177 |
+
}}
|
| 178 |
+
disabled={togglePkgMutation.isPending || toggleQMutation.isPending}
|
| 179 |
+
>
|
| 180 |
+
{item.isFeatured ? "Unfeature" : "Feature"}
|
| 181 |
+
</Button>
|
| 182 |
+
</div>
|
| 183 |
+
))}
|
| 184 |
+
</div>
|
| 185 |
+
{searchQuery.data && (searchQuery.data.total as number) > limit && (
|
| 186 |
+
<div className="flex items-center justify-center gap-4 mt-6">
|
| 187 |
+
<Button variant="outline" size="sm" onClick={() => setPage((p) => Math.max(1, p - 1))} disabled={page <= 1}>Previous</Button>
|
| 188 |
+
<span className="text-sm text-[var(--warm-charcoal)]">Page {page} of {Math.ceil((searchQuery.data.total as number) / limit)}</span>
|
| 189 |
+
<Button variant="outline" size="sm" onClick={() => setPage((p) => p + 1)} disabled={page * limit >= (searchQuery.data.total as number)}>Next</Button>
|
| 190 |
+
</div>
|
| 191 |
+
)}
|
| 192 |
+
</>
|
| 193 |
+
)}
|
| 194 |
+
</div>
|
| 195 |
+
)}
|
| 196 |
+
</div>
|
| 197 |
+
);
|
| 198 |
+
}
|
apps/web/src/routes/admin.index.tsx
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState } from "react";
|
| 2 |
+
import { createFileRoute } from "@tanstack/react-router";
|
| 3 |
+
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
| 4 |
+
import { trpc } from "@/utils/trpc";
|
| 5 |
+
import { Input } from "@labas/ui/components/input";
|
| 6 |
+
import { Button } from "@labas/ui/components/button";
|
| 7 |
+
import { toast } from "sonner";
|
| 8 |
+
|
| 9 |
+
export const Route = createFileRoute("/admin/")({
|
| 10 |
+
component: AdminDashboard,
|
| 11 |
+
});
|
| 12 |
+
|
| 13 |
+
function StatCard({ label, value, sub }: { label: string; value: number | string; sub?: string }) {
|
| 14 |
+
return (
|
| 15 |
+
<div className="bg-[var(--pure-white)] rounded-[var(--radius-xl)] border border-[var(--oat-border)] p-6">
|
| 16 |
+
<p className="text-sm text-[var(--warm-charcoal)]">{label}</p>
|
| 17 |
+
<p className="text-3xl font-headline font-bold text-[var(--clay-black)] mt-2">
|
| 18 |
+
{typeof value === "number" ? value.toLocaleString() : value}
|
| 19 |
+
</p>
|
| 20 |
+
{sub && <p className="text-xs text-[var(--warm-charcoal)] mt-1">{sub}</p>}
|
| 21 |
+
</div>
|
| 22 |
+
);
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
function AdminDashboard() {
|
| 26 |
+
const [poolInput, setPoolInput] = useState("");
|
| 27 |
+
const queryClient = useQueryClient();
|
| 28 |
+
const stats = useQuery(trpc.admin.dashboardStats.queryOptions());
|
| 29 |
+
|
| 30 |
+
const setConfigMutation = useMutation(
|
| 31 |
+
trpc.admin.setPlatformConfig.mutationOptions({
|
| 32 |
+
onSuccess: () => {
|
| 33 |
+
queryClient.invalidateQueries({ queryKey: trpc.admin.dashboardStats.queryKey() });
|
| 34 |
+
toast.success("Config updated");
|
| 35 |
+
},
|
| 36 |
+
onError: (e: any) => toast.error(e.message),
|
| 37 |
+
}),
|
| 38 |
+
);
|
| 39 |
+
|
| 40 |
+
if (stats.isLoading) {
|
| 41 |
+
return (
|
| 42 |
+
<div className="flex items-center justify-center py-20">
|
| 43 |
+
<span className="material-symbols-outlined animate-spin text-3xl">progress_activity</span>
|
| 44 |
+
</div>
|
| 45 |
+
);
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
const s = stats.data;
|
| 49 |
+
const poolPct = s && s.poolMax > 0 ? Math.round((s.poolUsed ?? 0) / s.poolMax * 100) : 0;
|
| 50 |
+
const enabled = s?.freeCreditsEnabled ?? false;
|
| 51 |
+
|
| 52 |
+
return (
|
| 53 |
+
<div>
|
| 54 |
+
<h1 className="text-3xl font-headline font-bold text-[var(--clay-black)] mb-2">Admin Dashboard</h1>
|
| 55 |
+
<p className="text-[var(--warm-charcoal)] mb-8">Platform overview at a glance.</p>
|
| 56 |
+
|
| 57 |
+
<div className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-4 gap-4 mb-6">
|
| 58 |
+
<StatCard label="Total Users" value={s?.totalUsers ?? 0} />
|
| 59 |
+
<StatCard label="Questions" value={s?.totalQuestions ?? 0} />
|
| 60 |
+
<StatCard label="Packages" value={s?.totalPackages ?? 0} />
|
| 61 |
+
<StatCard label="Jobs" value={s?.totalJobs ?? 0} />
|
| 62 |
+
</div>
|
| 63 |
+
|
| 64 |
+
<div className="bg-[var(--pure-white)] rounded-[var(--radius-xl)] border border-[var(--oat-border)] p-6">
|
| 65 |
+
<div className="flex items-center justify-between mb-3">
|
| 66 |
+
<h2 className="text-lg font-headline font-bold text-[var(--clay-black)]">Free Credit Pool</h2>
|
| 67 |
+
<button
|
| 68 |
+
onClick={() => setConfigMutation.mutate({ key: "free_credits_enabled", value: String(!enabled) })}
|
| 69 |
+
disabled={setConfigMutation.isPending}
|
| 70 |
+
className={`relative inline-flex h-7 w-12 shrink-0 cursor-pointer rounded-full border-2 transition-colors duration-200 ${
|
| 71 |
+
enabled ? "bg-[var(--clay-black)] border-[var(--clay-black)]" : "bg-gray-300 border-gray-300"
|
| 72 |
+
}`}
|
| 73 |
+
>
|
| 74 |
+
<span
|
| 75 |
+
className={`pointer-events-none inline-block h-6 w-6 transform rounded-full bg-white shadow-lg transition duration-200 ${
|
| 76 |
+
enabled ? "translate-x-5" : "translate-x-0"
|
| 77 |
+
}`}
|
| 78 |
+
/>
|
| 79 |
+
</button>
|
| 80 |
+
</div>
|
| 81 |
+
<div className="flex items-baseline gap-4 mb-3">
|
| 82 |
+
<span className="text-3xl font-headline font-bold text-[var(--clay-black)]">{((s?.poolUsed ?? 0) / 1000).toFixed(0)}K</span>
|
| 83 |
+
<span className="text-sm text-[var(--warm-charcoal)]">used of {((s?.poolMax ?? 1000000) / 1000).toFixed(0)}K tokens</span>
|
| 84 |
+
</div>
|
| 85 |
+
<div className="w-full h-3 bg-[var(--oat-border)] rounded-full overflow-hidden mb-4">
|
| 86 |
+
<div
|
| 87 |
+
className={`h-full rounded-full transition-all ${poolPct > 90 ? "bg-[var(--clay-red)]" : "bg-[var(--matcha-500)]"}`}
|
| 88 |
+
style={{ width: `${Math.min(100, poolPct)}%` }}
|
| 89 |
+
/>
|
| 90 |
+
</div>
|
| 91 |
+
<p className="text-xs text-[var(--warm-charcoal)] mb-4">{poolPct}% used · {((s?.poolRemaining ?? 0) / 1000).toFixed(0)}K remaining</p>
|
| 92 |
+
|
| 93 |
+
<div className="flex gap-3 items-end pt-4 border-t border-[var(--oat-border)]">
|
| 94 |
+
<div>
|
| 95 |
+
<label className="text-xs text-[var(--warm-charcoal)] block mb-1">Pool Size</label>
|
| 96 |
+
<Input
|
| 97 |
+
type="number"
|
| 98 |
+
value={poolInput || String(s?.poolMax ?? "")}
|
| 99 |
+
onChange={(e) => setPoolInput(e.target.value)}
|
| 100 |
+
placeholder="1000000"
|
| 101 |
+
className="w-40 h-10 rounded-[var(--radius-lg)] border-[var(--oat-border)]"
|
| 102 |
+
/>
|
| 103 |
+
</div>
|
| 104 |
+
<Button
|
| 105 |
+
onClick={() => {
|
| 106 |
+
const val = parseInt(poolInput, 10);
|
| 107 |
+
if (!poolInput || isNaN(val) || val <= 0) {
|
| 108 |
+
toast.error("Enter a valid number");
|
| 109 |
+
return;
|
| 110 |
+
}
|
| 111 |
+
setConfigMutation.mutate({ key: "free_credits_max_pool", value: String(val) });
|
| 112 |
+
}}
|
| 113 |
+
disabled={setConfigMutation.isPending}
|
| 114 |
+
className="h-10 rounded-[var(--radius-lg)]"
|
| 115 |
+
>
|
| 116 |
+
Save
|
| 117 |
+
</Button>
|
| 118 |
+
</div>
|
| 119 |
+
</div>
|
| 120 |
+
|
| 121 |
+
{s && s.totalUsers === 0 && (
|
| 122 |
+
<p className="text-[var(--warm-charcoal)] text-sm mt-6">No data yet. Stats will appear as users start using the platform.</p>
|
| 123 |
+
)}
|
| 124 |
+
</div>
|
| 125 |
+
);
|
| 126 |
+
}
|
apps/web/src/routes/admin.jobs.tsx
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState } from "react";
|
| 2 |
+
import { createFileRoute } from "@tanstack/react-router";
|
| 3 |
+
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
| 4 |
+
import { trpc } from "@/utils/trpc";
|
| 5 |
+
import { Button } from "@labas/ui/components/button";
|
| 6 |
+
import { toast } from "sonner";
|
| 7 |
+
|
| 8 |
+
const STATUSES = ["all", "pending", "running", "completed", "failed", "cancelled"] as const;
|
| 9 |
+
|
| 10 |
+
export const Route = createFileRoute("/admin/jobs")({
|
| 11 |
+
component: AdminJobs,
|
| 12 |
+
});
|
| 13 |
+
|
| 14 |
+
function formatDate(dateStr: string | Date | null) {
|
| 15 |
+
if (!dateStr) return "-";
|
| 16 |
+
return new Date(dateStr).toLocaleDateString("id-ID", { day: "numeric", month: "short", hour: "2-digit", minute: "2-digit" });
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
function AdminJobs() {
|
| 20 |
+
const [status, setStatus] = useState<string>("all");
|
| 21 |
+
const queryClient = useQueryClient();
|
| 22 |
+
|
| 23 |
+
const jobs = useQuery(
|
| 24 |
+
trpc.admin.listAllJobs.queryOptions({
|
| 25 |
+
status: status !== "all" ? status : undefined,
|
| 26 |
+
limit: 30,
|
| 27 |
+
offset: 0,
|
| 28 |
+
}),
|
| 29 |
+
);
|
| 30 |
+
|
| 31 |
+
const cancelMutation = useMutation(
|
| 32 |
+
trpc.admin.cancelAnyJob.mutationOptions({
|
| 33 |
+
onSuccess: () => {
|
| 34 |
+
queryClient.invalidateQueries({ queryKey: trpc.admin.listAllJobs.queryKey() });
|
| 35 |
+
toast.success("Job cancelled");
|
| 36 |
+
},
|
| 37 |
+
onError: (e: any) => toast.error(e.message),
|
| 38 |
+
}),
|
| 39 |
+
);
|
| 40 |
+
|
| 41 |
+
return (
|
| 42 |
+
<div>
|
| 43 |
+
<h1 className="text-3xl font-headline font-bold text-[var(--clay-black)] mb-2">Generation Jobs</h1>
|
| 44 |
+
<p className="text-[var(--warm-charcoal)] mb-6">Monitor AI generation jobs across the platform.</p>
|
| 45 |
+
|
| 46 |
+
<div className="flex flex-wrap gap-2 mb-4">
|
| 47 |
+
{STATUSES.map((s) => (
|
| 48 |
+
<button
|
| 49 |
+
key={s}
|
| 50 |
+
onClick={() => setStatus(s)}
|
| 51 |
+
className={`px-3 py-1.5 text-xs font-semibold rounded-full transition-colors ${
|
| 52 |
+
status === s
|
| 53 |
+
? "bg-[var(--matcha-300)] text-[var(--matcha-800)]"
|
| 54 |
+
: "bg-[var(--oat-light)] text-[var(--warm-charcoal)] hover:bg-[var(--oat-border)]"
|
| 55 |
+
}`}
|
| 56 |
+
>
|
| 57 |
+
{s === "all" ? "All" : s}
|
| 58 |
+
</button>
|
| 59 |
+
))}
|
| 60 |
+
</div>
|
| 61 |
+
|
| 62 |
+
<div className="bg-[var(--pure-white)] rounded-[var(--radius-xl)] border border-[var(--oat-border)] overflow-hidden">
|
| 63 |
+
<table className="w-full text-sm">
|
| 64 |
+
<thead className="bg-[var(--oat-light)] text-[var(--warm-charcoal)]">
|
| 65 |
+
<tr>
|
| 66 |
+
<th className="text-left px-4 py-3 font-medium">User</th>
|
| 67 |
+
<th className="text-left px-4 py-3 font-medium">Exam</th>
|
| 68 |
+
<th className="text-left px-4 py-3 font-medium">Mode</th>
|
| 69 |
+
<th className="text-left px-4 py-3 font-medium">Progress</th>
|
| 70 |
+
<th className="text-left px-4 py-3 font-medium">Tokens</th>
|
| 71 |
+
<th className="text-left px-4 py-3 font-medium">Status</th>
|
| 72 |
+
<th className="text-left px-4 py-3 font-medium">Created</th>
|
| 73 |
+
<th className="text-right px-4 py-3 font-medium">Actions</th>
|
| 74 |
+
</tr>
|
| 75 |
+
</thead>
|
| 76 |
+
<tbody>
|
| 77 |
+
{jobs.isLoading ? (
|
| 78 |
+
<tr><td colSpan={8} className="text-center py-16 text-[var(--warm-charcoal)]">Loading...</td></tr>
|
| 79 |
+
) : (!jobs.data?.jobs || jobs.data.jobs.length === 0) ? (
|
| 80 |
+
<tr><td colSpan={8} className="text-center py-16 text-[var(--warm-charcoal)]">No jobs found.</td></tr>
|
| 81 |
+
) : (
|
| 82 |
+
jobs.data.jobs.map((job: any) => (
|
| 83 |
+
<tr key={job.id} className="border-t border-[var(--oat-border)] hover:bg-[var(--oat-light)]/50 transition-colors">
|
| 84 |
+
<td className="px-4 py-3 text-[var(--warm-charcoal)] font-mono text-xs">{job.userId?.slice(0, 10)}...</td>
|
| 85 |
+
<td className="px-4 py-3">{job.examTypeId}</td>
|
| 86 |
+
<td className="px-4 py-3 text-xs">{job.mode}</td>
|
| 87 |
+
<td className="px-4 py-3">
|
| 88 |
+
<div className="flex items-center gap-2">
|
| 89 |
+
<div className="w-16 h-1.5 bg-[var(--oat-border)] rounded-full overflow-hidden">
|
| 90 |
+
<div className={`h-full rounded-full transition-all ${job.progress === 100 ? "bg-[var(--matcha-500)]" : "bg-[var(--sunbeam-500)]"}`} style={{ width: `${job.progress ?? 0}%` }} />
|
| 91 |
+
</div>
|
| 92 |
+
<span className="text-xs text-[var(--warm-charcoal)]">{job.progress ?? 0}%</span>
|
| 93 |
+
</div>
|
| 94 |
+
</td>
|
| 95 |
+
<td className="px-4 py-3">{job.tokensUsed?.toLocaleString() ?? "-"}</td>
|
| 96 |
+
<td className="px-4 py-3">
|
| 97 |
+
<span className={`text-xs font-semibold px-2 py-0.5 rounded-full ${
|
| 98 |
+
job.status === "completed" ? "bg-[var(--matcha-300)] text-[var(--matcha-800)]" :
|
| 99 |
+
job.status === "failed" ? "bg-[var(--clay-red)]/10 text-[var(--clay-red)]" :
|
| 100 |
+
job.status === "cancelled" ? "bg-[var(--oat-border)] text-[var(--warm-charcoal)]" :
|
| 101 |
+
job.status === "running" ? "bg-[var(--sunbeam-300)] text-[var(--sunbeam-800)]" :
|
| 102 |
+
"bg-[var(--oat-border)] text-[var(--warm-charcoal)]"
|
| 103 |
+
}`}>{job.status}</span>
|
| 104 |
+
</td>
|
| 105 |
+
<td className="px-4 py-3 text-[var(--warm-charcoal)] text-xs">{formatDate(job.createdAt)}</td>
|
| 106 |
+
<td className="px-4 py-3 text-right">
|
| 107 |
+
{(job.status === "pending" || job.status === "running") && (
|
| 108 |
+
<Button variant="outline" className="text-[var(--clay-red)] h-9 rounded-[var(--radius-lg)] text-xs" onClick={() => cancelMutation.mutate({ jobId: job.id })} disabled={cancelMutation.isPending}>
|
| 109 |
+
Cancel
|
| 110 |
+
</Button>
|
| 111 |
+
)}
|
| 112 |
+
</td>
|
| 113 |
+
</tr>
|
| 114 |
+
))
|
| 115 |
+
)}
|
| 116 |
+
</tbody>
|
| 117 |
+
</table>
|
| 118 |
+
</div>
|
| 119 |
+
</div>
|
| 120 |
+
);
|
| 121 |
+
}
|
apps/web/src/routes/admin.moderation.tsx
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState } from "react";
|
| 2 |
+
import { createFileRoute } from "@tanstack/react-router";
|
| 3 |
+
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
| 4 |
+
import { trpc } from "@/utils/trpc";
|
| 5 |
+
import { Button } from "@labas/ui/components/button";
|
| 6 |
+
import { toast } from "sonner";
|
| 7 |
+
|
| 8 |
+
export const Route = createFileRoute("/admin/moderation")({
|
| 9 |
+
component: AdminModeration,
|
| 10 |
+
});
|
| 11 |
+
|
| 12 |
+
function formatDate(dateStr: string | Date | null) {
|
| 13 |
+
if (!dateStr) return "-";
|
| 14 |
+
return new Date(dateStr).toLocaleDateString("id-ID", { day: "numeric", month: "short", hour: "2-digit", minute: "2-digit" });
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
function AdminModeration() {
|
| 18 |
+
const [page, setPage] = useState(1);
|
| 19 |
+
const limit = 30;
|
| 20 |
+
const queryClient = useQueryClient();
|
| 21 |
+
|
| 22 |
+
const questions = useQuery(
|
| 23 |
+
trpc.admin.listLatestQuestions.queryOptions({ limit, offset: (page - 1) * limit }),
|
| 24 |
+
);
|
| 25 |
+
|
| 26 |
+
const toggleMutation = useMutation(
|
| 27 |
+
trpc.admin.togglePublicAny.mutationOptions({
|
| 28 |
+
onSuccess: (data) => {
|
| 29 |
+
queryClient.invalidateQueries({ queryKey: trpc.admin.listLatestQuestions.queryKey() });
|
| 30 |
+
toast.success(data.isPublic ? "Made public" : "Made private");
|
| 31 |
+
},
|
| 32 |
+
onError: (e: any) => toast.error(e.message),
|
| 33 |
+
}),
|
| 34 |
+
);
|
| 35 |
+
|
| 36 |
+
return (
|
| 37 |
+
<div>
|
| 38 |
+
<h1 className="text-3xl font-headline font-bold text-[var(--clay-black)] mb-2">Content Moderation</h1>
|
| 39 |
+
<p className="text-[var(--warm-charcoal)] mb-6">Review latest questions and manage visibility.</p>
|
| 40 |
+
|
| 41 |
+
<div className="bg-[var(--pure-white)] rounded-[var(--radius-xl)] border border-[var(--oat-border)] overflow-hidden">
|
| 42 |
+
<table className="w-full text-sm">
|
| 43 |
+
<thead className="bg-[var(--oat-light)] text-[var(--warm-charcoal)]">
|
| 44 |
+
<tr>
|
| 45 |
+
<th className="text-left px-4 py-3 font-medium">Question</th>
|
| 46 |
+
<th className="text-left px-4 py-3 font-medium">Exam</th>
|
| 47 |
+
<th className="text-left px-4 py-3 font-medium">Format</th>
|
| 48 |
+
<th className="text-left px-4 py-3 font-medium">Visibility</th>
|
| 49 |
+
<th className="text-left px-4 py-3 font-medium">Source</th>
|
| 50 |
+
<th className="text-left px-4 py-3 font-medium">Created</th>
|
| 51 |
+
<th className="text-right px-4 py-3 font-medium">Actions</th>
|
| 52 |
+
</tr>
|
| 53 |
+
</thead>
|
| 54 |
+
<tbody>
|
| 55 |
+
{questions.isLoading ? (
|
| 56 |
+
<tr><td colSpan={7} className="text-center py-16 text-[var(--warm-charcoal)]">Loading...</td></tr>
|
| 57 |
+
) : (!questions.data?.questions || questions.data.questions.length === 0) ? (
|
| 58 |
+
<tr><td colSpan={7} className="text-center py-16 text-[var(--warm-charcoal)]">No questions yet.</td></tr>
|
| 59 |
+
) : (
|
| 60 |
+
questions.data.questions.map((q: any) => (
|
| 61 |
+
<tr key={q.id} className="border-t border-[var(--oat-border)] hover:bg-[var(--oat-light)]/50 transition-colors">
|
| 62 |
+
<td className="px-4 py-3 max-w-xs">
|
| 63 |
+
<p className="font-medium text-[var(--clay-black)] truncate">{q.questionText}</p>
|
| 64 |
+
<p className="text-xs text-[var(--warm-charcoal)] truncate mt-0.5">{q.passageText?.slice(0, 80)}{(q.passageText?.length ?? 0) > 80 ? "..." : ""}</p>
|
| 65 |
+
</td>
|
| 66 |
+
<td className="px-4 py-3">{q.examTypeId}</td>
|
| 67 |
+
<td className="px-4 py-3 text-xs">{q.format}</td>
|
| 68 |
+
<td className="px-4 py-3">
|
| 69 |
+
<span className={`text-xs font-semibold px-2 py-0.5 rounded-full ${
|
| 70 |
+
q.isPublic ? "bg-[var(--matcha-300)] text-[var(--matcha-800)]" : "bg-[var(--oat-border)] text-[var(--warm-charcoal)]"
|
| 71 |
+
}`}>{q.isPublic ? "Public" : "Private"}</span>
|
| 72 |
+
</td>
|
| 73 |
+
<td className="px-4 py-3 text-xs">{q.source}</td>
|
| 74 |
+
<td className="px-4 py-3 text-[var(--warm-charcoal)] text-xs">{formatDate(q.createdAt)}</td>
|
| 75 |
+
<td className="px-4 py-3 text-right">
|
| 76 |
+
<Button variant="outline" className="h-9 rounded-[var(--radius-lg)] text-xs" onClick={() => toggleMutation.mutate({ questionId: q.id })} disabled={toggleMutation.isPending}>
|
| 77 |
+
{q.isPublic ? "Make Private" : "Make Public"}
|
| 78 |
+
</Button>
|
| 79 |
+
</td>
|
| 80 |
+
</tr>
|
| 81 |
+
))
|
| 82 |
+
)}
|
| 83 |
+
</tbody>
|
| 84 |
+
</table>
|
| 85 |
+
</div>
|
| 86 |
+
|
| 87 |
+
<div className="flex items-center justify-center gap-4 mt-6">
|
| 88 |
+
<Button variant="outline" className="h-9 rounded-[var(--radius-lg)] text-xs" onClick={() => setPage((p) => Math.max(1, p - 1))} disabled={page <= 1}>Previous</Button>
|
| 89 |
+
<span className="text-sm text-[var(--warm-charcoal)]">Page {page}</span>
|
| 90 |
+
<Button variant="outline" className="h-9 rounded-[var(--radius-lg)] text-xs" onClick={() => setPage((p) => p + 1)} disabled={(questions.data?.questions?.length ?? 0) < limit}>Next</Button>
|
| 91 |
+
</div>
|
| 92 |
+
</div>
|
| 93 |
+
);
|
| 94 |
+
}
|
apps/web/src/routes/admin.tsx
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { createFileRoute, Outlet, Link, redirect, useLocation } from "@tanstack/react-router";
|
| 2 |
+
import { useQuery } from "@tanstack/react-query";
|
| 3 |
+
import { authClient } from "@/lib/auth-client";
|
| 4 |
+
import { trpc } from "@/utils/trpc";
|
| 5 |
+
import { MaterialIcon } from "@/components/ui/MaterialIcon";
|
| 6 |
+
|
| 7 |
+
export const Route = createFileRoute("/admin")({
|
| 8 |
+
component: AdminLayout,
|
| 9 |
+
beforeLoad: async () => {
|
| 10 |
+
const session = await authClient.getSession();
|
| 11 |
+
if (!session.data) {
|
| 12 |
+
throw redirect({ to: "/login" });
|
| 13 |
+
}
|
| 14 |
+
return { session };
|
| 15 |
+
},
|
| 16 |
+
});
|
| 17 |
+
|
| 18 |
+
const adminNavItems = [
|
| 19 |
+
{ to: "/admin", label: "Dashboard", icon: "dashboard" },
|
| 20 |
+
{ to: "/admin/users", label: "Users", icon: "group" },
|
| 21 |
+
{ to: "/admin/featured", label: "Featured", icon: "star" },
|
| 22 |
+
{ to: "/admin/credits", label: "Credits", icon: "token" },
|
| 23 |
+
{ to: "/admin/jobs", label: "Jobs", icon: "schedule" },
|
| 24 |
+
{ to: "/admin/moderation", label: "Moderation", icon: "shield" },
|
| 25 |
+
];
|
| 26 |
+
|
| 27 |
+
function AdminLayout() {
|
| 28 |
+
const location = useLocation();
|
| 29 |
+
|
| 30 |
+
const { data: isAdminData, isLoading } = useQuery(
|
| 31 |
+
trpc.admin.isAdmin.queryOptions(),
|
| 32 |
+
);
|
| 33 |
+
|
| 34 |
+
if (isLoading) {
|
| 35 |
+
return (
|
| 36 |
+
<div className="min-h-screen flex items-center justify-center bg-[var(--warm-cream)]">
|
| 37 |
+
<span className="material-symbols-outlined animate-spin text-3xl text-[var(--clay-black)]">progress_activity</span>
|
| 38 |
+
</div>
|
| 39 |
+
);
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
if (!isAdminData?.isAdmin) {
|
| 43 |
+
return (
|
| 44 |
+
<div className="min-h-screen flex flex-col items-center justify-center bg-[var(--warm-cream)] gap-4">
|
| 45 |
+
<MaterialIcon name="gpp_bad" className="text-6xl text-[var(--clay-red)]" />
|
| 46 |
+
<h1 className="text-2xl font-headline font-bold text-[var(--clay-black)]">Access Denied</h1>
|
| 47 |
+
<p className="text-[var(--warm-charcoal)]">Only admins can access this page.</p>
|
| 48 |
+
<Link to="/" className="text-[var(--matcha-700)] underline">Back to Dashboard</Link>
|
| 49 |
+
</div>
|
| 50 |
+
);
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
return (
|
| 54 |
+
<div className="min-h-screen bg-[var(--warm-cream)]">
|
| 55 |
+
<div className="flex">
|
| 56 |
+
<aside className="w-56 shrink-0 min-h-screen border-r border-[var(--oat-border)] bg-[var(--pure-white)] px-4 py-6 flex flex-col">
|
| 57 |
+
<div className="mb-6 px-2">
|
| 58 |
+
<Link to="/" className="flex items-center gap-2">
|
| 59 |
+
<span className="material-symbols-outlined text-[var(--clay-black)]">arrow_back</span>
|
| 60 |
+
<span className="text-sm text-[var(--warm-charcoal)]">Back to App</span>
|
| 61 |
+
</Link>
|
| 62 |
+
</div>
|
| 63 |
+
<h2 className="px-2 mb-4 text-xs font-semibold uppercase tracking-wider text-[var(--warm-charcoal)]">Admin Panel</h2>
|
| 64 |
+
<nav className="flex flex-col gap-1">
|
| 65 |
+
{adminNavItems.map((item) => {
|
| 66 |
+
const isActive = location.pathname === item.to;
|
| 67 |
+
return (
|
| 68 |
+
<Link
|
| 69 |
+
key={item.to}
|
| 70 |
+
to={item.to}
|
| 71 |
+
className={`flex items-center gap-3 rounded-[var(--radius-lg)] py-2.5 px-3 text-sm font-medium transition-all ${
|
| 72 |
+
isActive
|
| 73 |
+
? "bg-[var(--matcha-300)] text-[var(--matcha-800)] font-semibold"
|
| 74 |
+
: "text-[var(--warm-charcoal)] hover:bg-[var(--oat-light)] hover:text-[var(--clay-black)]"
|
| 75 |
+
}`}
|
| 76 |
+
>
|
| 77 |
+
<MaterialIcon name={item.icon} className="text-lg" />
|
| 78 |
+
{item.label}
|
| 79 |
+
</Link>
|
| 80 |
+
);
|
| 81 |
+
})}
|
| 82 |
+
</nav>
|
| 83 |
+
</aside>
|
| 84 |
+
<main className="flex-1 px-8 py-8">
|
| 85 |
+
<Outlet />
|
| 86 |
+
</main>
|
| 87 |
+
</div>
|
| 88 |
+
</div>
|
| 89 |
+
);
|
| 90 |
+
}
|
apps/web/src/routes/admin.users.tsx
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState } from "react";
|
| 2 |
+
import { createFileRoute } from "@tanstack/react-router";
|
| 3 |
+
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
| 4 |
+
import { trpc } from "@/utils/trpc";
|
| 5 |
+
import { Input } from "@labas/ui/components/input";
|
| 6 |
+
import { Button } from "@labas/ui/components/button";
|
| 7 |
+
import { toast } from "sonner";
|
| 8 |
+
|
| 9 |
+
const PAGE_SIZE = 20;
|
| 10 |
+
|
| 11 |
+
function Pagination({ page, totalPages, onChange }: { page: number; totalPages: number; onChange: (p: number) => void }) {
|
| 12 |
+
if (totalPages <= 1) return null;
|
| 13 |
+
const pages: (number | "...")[] = [];
|
| 14 |
+
for (let i = 1; i <= totalPages; i++) {
|
| 15 |
+
if (i === 1 || i === totalPages || (i >= page - 2 && i <= page + 2)) pages.push(i);
|
| 16 |
+
else if (pages[pages.length - 1] !== "...") pages.push("...");
|
| 17 |
+
}
|
| 18 |
+
return (
|
| 19 |
+
<div className="flex items-center justify-center gap-1 mt-6">
|
| 20 |
+
<Button variant="outline" size="sm" onClick={() => onChange(page - 1)} disabled={page <= 1}>Previous</Button>
|
| 21 |
+
{pages.map((p, i) =>
|
| 22 |
+
p === "..." ? (
|
| 23 |
+
<span key={`e-${i}`} className="px-2 text-[var(--warm-charcoal)]">...</span>
|
| 24 |
+
) : (
|
| 25 |
+
<Button key={p} variant={p === page ? "default" : "outline"} size="sm" onClick={() => onChange(p as number)}>{p}</Button>
|
| 26 |
+
),
|
| 27 |
+
)}
|
| 28 |
+
<Button variant="outline" size="sm" onClick={() => onChange(page + 1)} disabled={page >= totalPages}>Next</Button>
|
| 29 |
+
</div>
|
| 30 |
+
);
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
export const Route = createFileRoute("/admin/users")({
|
| 34 |
+
component: AdminUsers,
|
| 35 |
+
});
|
| 36 |
+
|
| 37 |
+
function AdminUsers() {
|
| 38 |
+
const [search, setSearch] = useState("");
|
| 39 |
+
const [debouncedSearch, setDebouncedSearch] = useState("");
|
| 40 |
+
const [page, setPage] = useState(1);
|
| 41 |
+
|
| 42 |
+
const usersQuery = useQuery(
|
| 43 |
+
trpc.admin.listUsers.queryOptions({ search: debouncedSearch || undefined, limit: PAGE_SIZE, offset: (page - 1) * PAGE_SIZE }),
|
| 44 |
+
);
|
| 45 |
+
|
| 46 |
+
const users = usersQuery.data?.users ?? [];
|
| 47 |
+
const total = usersQuery.data?.total ?? 0;
|
| 48 |
+
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
| 49 |
+
|
| 50 |
+
function handleSearch(val: string) {
|
| 51 |
+
setSearch(val);
|
| 52 |
+
clearTimeout((window as any).__ut);
|
| 53 |
+
(window as any).__ut = setTimeout(() => { setDebouncedSearch(val); setPage(1); }, 300);
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
return (
|
| 57 |
+
<div>
|
| 58 |
+
<div className="flex items-center justify-between mb-6">
|
| 59 |
+
<div>
|
| 60 |
+
<h1 className="text-3xl font-headline font-bold text-[var(--clay-black)]">Users</h1>
|
| 61 |
+
<p className="text-[var(--warm-charcoal)] mt-1">{total.toLocaleString()} total users</p>
|
| 62 |
+
</div>
|
| 63 |
+
</div>
|
| 64 |
+
|
| 65 |
+
<div className="bg-[var(--pure-white)] rounded-[var(--radius-xl)] border border-[var(--oat-border)] overflow-hidden">
|
| 66 |
+
<div className="px-4 py-3 border-b border-[var(--oat-border)]">
|
| 67 |
+
<Input
|
| 68 |
+
placeholder="Search by name or email..."
|
| 69 |
+
value={search}
|
| 70 |
+
onChange={(e) => handleSearch(e.target.value)}
|
| 71 |
+
className="max-w-sm"
|
| 72 |
+
/>
|
| 73 |
+
</div>
|
| 74 |
+
<table className="w-full text-sm">
|
| 75 |
+
<thead className="bg-[var(--oat-light)] text-[var(--warm-charcoal)]">
|
| 76 |
+
<tr>
|
| 77 |
+
<th className="text-left px-4 py-3 font-medium w-[25%]">Name</th>
|
| 78 |
+
<th className="text-left px-4 py-3 font-medium w-[30%]">Email</th>
|
| 79 |
+
<th className="text-left px-4 py-3 font-medium w-[10%]">Role</th>
|
| 80 |
+
<th className="text-left px-4 py-3 font-medium w-[10%]">Status</th>
|
| 81 |
+
<th className="text-right px-4 py-3 font-medium w-[25%]">Actions</th>
|
| 82 |
+
</tr>
|
| 83 |
+
</thead>
|
| 84 |
+
<tbody>
|
| 85 |
+
{usersQuery.isLoading ? (
|
| 86 |
+
<tr><td colSpan={5} className="text-center py-16 text-[var(--warm-charcoal)]">Loading...</td></tr>
|
| 87 |
+
) : users.length === 0 ? (
|
| 88 |
+
<tr><td colSpan={5} className="text-center py-16 text-[var(--warm-charcoal)]">No users found.</td></tr>
|
| 89 |
+
) : (
|
| 90 |
+
users.map((u) => <UserRow key={u.id} user={u} />)
|
| 91 |
+
)}
|
| 92 |
+
</tbody>
|
| 93 |
+
</table>
|
| 94 |
+
</div>
|
| 95 |
+
|
| 96 |
+
<Pagination page={page} totalPages={totalPages} onChange={setPage} />
|
| 97 |
+
</div>
|
| 98 |
+
);
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
function UserRow({ user }: { user: { id: string; name: string; email: string; role: string; suspended: boolean; emailVerified: boolean } }) {
|
| 102 |
+
const queryClient = useQueryClient();
|
| 103 |
+
|
| 104 |
+
const suspendMutation = useMutation(
|
| 105 |
+
trpc.admin.suspendUser.mutationOptions({
|
| 106 |
+
onSuccess: (data) => {
|
| 107 |
+
queryClient.invalidateQueries({ queryKey: trpc.admin.listUsers.queryKey() });
|
| 108 |
+
toast.success(data.suspended ? "User suspended" : "User unsuspended");
|
| 109 |
+
},
|
| 110 |
+
onError: (e: any) => toast.error(e.message),
|
| 111 |
+
}),
|
| 112 |
+
);
|
| 113 |
+
|
| 114 |
+
const roleMutation = useMutation(
|
| 115 |
+
trpc.admin.setUserRole.mutationOptions({
|
| 116 |
+
onSuccess: (data) => {
|
| 117 |
+
queryClient.invalidateQueries({ queryKey: trpc.admin.listUsers.queryKey() });
|
| 118 |
+
toast.success(`Role: ${data.role}`);
|
| 119 |
+
},
|
| 120 |
+
onError: (e: any) => toast.error(e.message),
|
| 121 |
+
}),
|
| 122 |
+
);
|
| 123 |
+
|
| 124 |
+
return (
|
| 125 |
+
<tr className="border-t border-[var(--oat-border)] hover:bg-[var(--oat-light)]/50 transition-colors">
|
| 126 |
+
<td className="px-4 py-3 font-medium text-[var(--clay-black)]">{user.name}</td>
|
| 127 |
+
<td className="px-4 py-3 text-[var(--warm-charcoal)]">
|
| 128 |
+
{user.email}
|
| 129 |
+
{!user.emailVerified && (
|
| 130 |
+
<span className="ml-1.5 text-[10px] bg-[var(--sunbeam-300)] text-[var(--sunbeam-800)] px-1.5 py-0.5 rounded-full font-medium">unverified</span>
|
| 131 |
+
)}
|
| 132 |
+
</td>
|
| 133 |
+
<td className="px-4 py-3">
|
| 134 |
+
<span className={`text-xs font-semibold px-2 py-0.5 rounded-full ${user.role === "admin" ? "bg-[var(--matcha-300)] text-[var(--matcha-800)]" : "bg-[var(--oat-border)] text-[var(--warm-charcoal)]"}`}>
|
| 135 |
+
{user.role}
|
| 136 |
+
</span>
|
| 137 |
+
</td>
|
| 138 |
+
<td className="px-4 py-3">
|
| 139 |
+
<span className={`text-xs font-semibold px-2 py-0.5 rounded-full ${
|
| 140 |
+
user.suspended ? "bg-[var(--clay-red)]/10 text-[var(--clay-red)]" : "bg-[var(--matcha-300)] text-[var(--matcha-800)]"
|
| 141 |
+
}`}>
|
| 142 |
+
{user.suspended ? "Suspended" : "Active"}
|
| 143 |
+
</span>
|
| 144 |
+
</td>
|
| 145 |
+
<td className="px-4 py-3">
|
| 146 |
+
<div className="flex items-center justify-end gap-2">
|
| 147 |
+
<Button variant="outline" onClick={() => suspendMutation.mutate({ userId: user.id, suspended: !user.suspended })} disabled={suspendMutation.isPending} className="h-9 rounded-[var(--radius-lg)] text-xs">
|
| 148 |
+
{user.suspended ? "Unsuspend" : "Suspend"}
|
| 149 |
+
</Button>
|
| 150 |
+
<Button variant="outline" onClick={() => roleMutation.mutate({ userId: user.id, role: user.role === "admin" ? "user" : "admin" })} disabled={roleMutation.isPending} className="h-9 rounded-[var(--radius-lg)] text-xs">
|
| 151 |
+
{user.role === "admin" ? "Demote" : "Promote"}
|
| 152 |
+
</Button>
|
| 153 |
+
</div>
|
| 154 |
+
</td>
|
| 155 |
+
</tr>
|
| 156 |
+
);
|
| 157 |
+
}
|
apps/web/src/routes/generate.tsx
CHANGED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
import { useState, useEffect, useRef } from "react";
|
| 2 |
-
import { useMutation } from "@tanstack/react-query";
|
| 3 |
import { createFileRoute, redirect, Link } from "@tanstack/react-router";
|
| 4 |
import { authClient } from "@/lib/auth-client";
|
| 5 |
import { trpc } from "@/utils/trpc";
|
|
@@ -56,6 +56,13 @@ function RouteComponent() {
|
|
| 56 |
}, [configs, selectedKeyId]);
|
| 57 |
|
| 58 |
const selectedConfig = configs.find((c) => c.id === selectedKeyId);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 59 |
|
| 60 |
const {
|
| 61 |
activeCount,
|
|
@@ -162,8 +169,8 @@ function RouteComponent() {
|
|
| 162 |
};
|
| 163 |
|
| 164 |
const handleGenerate = () => {
|
| 165 |
-
if (!hasConfigs || !selectedConfig) {
|
| 166 |
-
setError("API key belum dikonfigurasi. Tambahkan di Settings.");
|
| 167 |
return;
|
| 168 |
}
|
| 169 |
if (selectedSections.length === 0) {
|
|
@@ -175,6 +182,15 @@ function RouteComponent() {
|
|
| 175 |
return;
|
| 176 |
}
|
| 177 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 178 |
generate.mutate({
|
| 179 |
examType: examType as any,
|
| 180 |
section: selectedSections[0] as any,
|
|
@@ -184,13 +200,8 @@ function RouteComponent() {
|
|
| 184 |
topics: selectedTopics,
|
| 185 |
questionCount,
|
| 186 |
mode,
|
| 187 |
-
apiKeyConfig
|
| 188 |
-
|
| 189 |
-
apiKey: selectedConfig.apiKey,
|
| 190 |
-
model: selectedConfig.modelName,
|
| 191 |
-
maxTokens: selectedConfig.maxTokens ?? 16384,
|
| 192 |
-
},
|
| 193 |
-
});
|
| 194 |
};
|
| 195 |
|
| 196 |
const sectionSplits = (() => {
|
|
@@ -233,7 +244,7 @@ function RouteComponent() {
|
|
| 233 |
</div>
|
| 234 |
</section>
|
| 235 |
|
| 236 |
-
{!hasConfigs && (
|
| 237 |
<div className="mb-8 p-4 rounded-[var(--radius-lg)] bg-[var(--badge-blue-bg)] text-[var(--badge-blue-text)] text-sm flex items-center gap-3 border-2 border-[var(--badge-blue-bg)]">
|
| 238 |
<MaterialIcon name="warning" />
|
| 239 |
<span>API key belum dikonfigurasi.</span>
|
|
@@ -243,40 +254,140 @@ function RouteComponent() {
|
|
| 243 |
</div>
|
| 244 |
)}
|
| 245 |
|
| 246 |
-
{hasConfigs && (
|
| 247 |
-
<div className="mb-8 p-4 rounded-[var(--radius-lg)] bg-[var(--
|
| 248 |
-
<
|
| 249 |
-
|
| 250 |
-
<
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
<SelectTrigger className="flex-1 h-11">
|
| 254 |
-
<SelectValue>
|
| 255 |
-
{selectedConfig ? `${selectedConfig.name} · ${selectedConfig.modelName}` : "Pilih provider..."}
|
| 256 |
-
</SelectValue>
|
| 257 |
-
</SelectTrigger>
|
| 258 |
-
<SelectContent>
|
| 259 |
-
{configs.map((c) => (
|
| 260 |
-
<SelectItem key={c.id} value={c.id}>
|
| 261 |
-
{c.name} · {c.modelName}
|
| 262 |
-
</SelectItem>
|
| 263 |
-
))}
|
| 264 |
-
</SelectContent>
|
| 265 |
-
</Select>
|
| 266 |
-
<Link to="/settings">
|
| 267 |
-
<Button
|
| 268 |
-
variant="outline"
|
| 269 |
-
size="xl"
|
| 270 |
-
className="rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] clay-hover"
|
| 271 |
-
>
|
| 272 |
-
<MaterialIcon name="settings" className="mr-1" />
|
| 273 |
-
Kelola
|
| 274 |
-
</Button>
|
| 275 |
-
</Link>
|
| 276 |
-
</div>
|
| 277 |
</div>
|
| 278 |
)}
|
| 279 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 280 |
<div className="grid grid-cols-1 lg:grid-cols-12 gap-10 items-start">
|
| 281 |
{/* Configuration Panel */}
|
| 282 |
<div className="lg:col-span-8 flex flex-col gap-10">
|
|
@@ -526,7 +637,7 @@ function RouteComponent() {
|
|
| 526 |
activeCount={activeCount}
|
| 527 |
maxParallel={MAX_PARALLEL}
|
| 528 |
generatePending={generate.isPending}
|
| 529 |
-
hasKey={hasConfigs}
|
| 530 |
error={error}
|
| 531 |
onGenerate={handleGenerate}
|
| 532 |
onDismissError={() => setError(null)}
|
|
|
|
| 1 |
import { useState, useEffect, useRef } from "react";
|
| 2 |
+
import { useMutation, useQuery } from "@tanstack/react-query";
|
| 3 |
import { createFileRoute, redirect, Link } from "@tanstack/react-router";
|
| 4 |
import { authClient } from "@/lib/auth-client";
|
| 5 |
import { trpc } from "@/utils/trpc";
|
|
|
|
| 56 |
}, [configs, selectedKeyId]);
|
| 57 |
|
| 58 |
const selectedConfig = configs.find((c) => c.id === selectedKeyId);
|
| 59 |
+
const [useFreeCredits, setUseFreeCredits] = useState(false);
|
| 60 |
+
|
| 61 |
+
const myCredit = useQuery(
|
| 62 |
+
trpc.admin.getMyCredit.queryOptions(),
|
| 63 |
+
);
|
| 64 |
+
const hasFreeCredits = myCredit.data?.freeCreditsEnabled === true;
|
| 65 |
+
const tokenBalance = myCredit.data?.tokenBalance ?? 0;
|
| 66 |
|
| 67 |
const {
|
| 68 |
activeCount,
|
|
|
|
| 169 |
};
|
| 170 |
|
| 171 |
const handleGenerate = () => {
|
| 172 |
+
if (!useFreeCredits && (!hasConfigs || !selectedConfig)) {
|
| 173 |
+
setError("API key belum dikonfigurasi. Tambahkan di Settings atau gunakan kredit gratis.");
|
| 174 |
return;
|
| 175 |
}
|
| 176 |
if (selectedSections.length === 0) {
|
|
|
|
| 182 |
return;
|
| 183 |
}
|
| 184 |
|
| 185 |
+
const apiKeyConfig = useFreeCredits
|
| 186 |
+
? undefined
|
| 187 |
+
: {
|
| 188 |
+
baseUrl: selectedConfig!.baseUrl,
|
| 189 |
+
apiKey: selectedConfig!.apiKey,
|
| 190 |
+
model: selectedConfig!.modelName,
|
| 191 |
+
maxTokens: selectedConfig!.maxTokens ?? 16384,
|
| 192 |
+
};
|
| 193 |
+
|
| 194 |
generate.mutate({
|
| 195 |
examType: examType as any,
|
| 196 |
section: selectedSections[0] as any,
|
|
|
|
| 200 |
topics: selectedTopics,
|
| 201 |
questionCount,
|
| 202 |
mode,
|
| 203 |
+
apiKeyConfig,
|
| 204 |
+
} as any);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 205 |
};
|
| 206 |
|
| 207 |
const sectionSplits = (() => {
|
|
|
|
| 244 |
</div>
|
| 245 |
</section>
|
| 246 |
|
| 247 |
+
{!hasConfigs && !useFreeCredits && !hasFreeCredits && (
|
| 248 |
<div className="mb-8 p-4 rounded-[var(--radius-lg)] bg-[var(--badge-blue-bg)] text-[var(--badge-blue-text)] text-sm flex items-center gap-3 border-2 border-[var(--badge-blue-bg)]">
|
| 249 |
<MaterialIcon name="warning" />
|
| 250 |
<span>API key belum dikonfigurasi.</span>
|
|
|
|
| 254 |
</div>
|
| 255 |
)}
|
| 256 |
|
| 257 |
+
{!hasConfigs && !useFreeCredits && hasFreeCredits && (
|
| 258 |
+
<div className="mb-8 p-4 rounded-[var(--radius-lg)] bg-[var(--matcha-300)]/30 text-[var(--matcha-800)] text-sm flex items-center gap-3 border-2 border-[var(--matcha-400)]">
|
| 259 |
+
<MaterialIcon name="tips_and_updates" />
|
| 260 |
+
<span>Belum ada API key. Kamu bisa pakai Free Credits!</span>
|
| 261 |
+
<button onClick={() => setUseFreeCredits(true)} className="font-semibold underline">
|
| 262 |
+
Gunakan Free Credits →
|
| 263 |
+
</button>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 264 |
</div>
|
| 265 |
)}
|
| 266 |
|
| 267 |
+
<div className="mb-8 p-5 rounded-[var(--radius-xl)] bg-[var(--pure-white)] border-2 border-[var(--oat-border)]">
|
| 268 |
+
<div className="flex items-center justify-between mb-3">
|
| 269 |
+
<label className="text-sm font-medium text-[var(--clay-black)]">Generation Mode</label>
|
| 270 |
+
</div>
|
| 271 |
+
<div className="flex items-center gap-4">
|
| 272 |
+
<button
|
| 273 |
+
onClick={() => { setUseFreeCredits(false); }}
|
| 274 |
+
className={`flex items-center gap-2 px-4 py-2.5 rounded-[var(--radius-lg)] text-sm font-medium transition-all ${
|
| 275 |
+
!useFreeCredits
|
| 276 |
+
? "bg-[var(--clay-black)] text-[var(--pure-white)]"
|
| 277 |
+
: "bg-[var(--oat-light)] text-[var(--warm-charcoal)] hover:bg-[var(--oat-border)]"
|
| 278 |
+
}`}
|
| 279 |
+
>
|
| 280 |
+
<MaterialIcon name="vpn_key" className="text-sm" />
|
| 281 |
+
BYOK
|
| 282 |
+
</button>
|
| 283 |
+
{hasFreeCredits && (
|
| 284 |
+
<button
|
| 285 |
+
onClick={() => { setUseFreeCredits(true); }}
|
| 286 |
+
className={`flex items-center gap-2 px-4 py-2.5 rounded-[var(--radius-lg)] text-sm font-medium transition-all ${
|
| 287 |
+
useFreeCredits
|
| 288 |
+
? "bg-[var(--clay-black)] text-[var(--pure-white)]"
|
| 289 |
+
: "bg-[var(--oat-light)] text-[var(--warm-charcoal)] hover:bg-[var(--oat-border)]"
|
| 290 |
+
}`}
|
| 291 |
+
>
|
| 292 |
+
<MaterialIcon name="stars" className="text-sm" />
|
| 293 |
+
Free Credits
|
| 294 |
+
</button>
|
| 295 |
+
)}
|
| 296 |
+
{useFreeCredits && (
|
| 297 |
+
<Link to="/settings" className="text-xs text-[var(--matcha-600)] underline ml-2">
|
| 298 |
+
Atur BYOK di Settings
|
| 299 |
+
</Link>
|
| 300 |
+
)}
|
| 301 |
+
</div>
|
| 302 |
+
|
| 303 |
+
{useFreeCredits && myCredit.data && (
|
| 304 |
+
<div className="mt-4 pt-4 border-t border-[var(--oat-border)] space-y-2">
|
| 305 |
+
<div className="flex items-center justify-between">
|
| 306 |
+
<span className="text-sm text-[var(--warm-charcoal)]">Token kamu</span>
|
| 307 |
+
<span className={`text-lg font-headline font-bold ${tokenBalance > 0 ? "text-[var(--clay-black)]" : "text-[var(--clay-red)]"}`}>
|
| 308 |
+
{tokenBalance.toLocaleString()}
|
| 309 |
+
</span>
|
| 310 |
+
</div>
|
| 311 |
+
{tokenBalance > 0 && (
|
| 312 |
+
<div className="w-full h-2 bg-[var(--oat-border)] rounded-full overflow-hidden">
|
| 313 |
+
<div
|
| 314 |
+
className="h-full bg-[var(--matcha-500)] rounded-full transition-all"
|
| 315 |
+
style={{ width: `${Math.min(100, (tokenBalance / 50000) * 100)}%` }}
|
| 316 |
+
/>
|
| 317 |
+
</div>
|
| 318 |
+
)}
|
| 319 |
+
{myCredit.data.cooldownRemaining > 0 && (
|
| 320 |
+
<p className="flex items-center gap-1.5 text-xs text-[var(--sunbeam-800)] bg-[var(--sunbeam-300)]/30 px-3 py-1.5 rounded-[var(--radius-md)]">
|
| 321 |
+
<MaterialIcon name="schedule" className="text-base leading-none shrink-0" />
|
| 322 |
+
<span>
|
| 323 |
+
Cooldown: {myCredit.data.cooldownRemaining} hari lagi untuk auto-refill.
|
| 324 |
+
</span>
|
| 325 |
+
</p>
|
| 326 |
+
)}
|
| 327 |
+
{tokenBalance <= 0 && myCredit.data.cooldownRemaining === 0 && (
|
| 328 |
+
<p className="text-xs text-[var(--matcha-700)] bg-[var(--matcha-300)]/30 px-3 py-1.5 rounded-[var(--radius-md)]">
|
| 329 |
+
Token habis. Auto-refill tersedia saat kamu generate.
|
| 330 |
+
</p>
|
| 331 |
+
)}
|
| 332 |
+
{tokenBalance <= 0 && myCredit.data.cooldownRemaining > 0 && (
|
| 333 |
+
<p className="text-xs text-[var(--clay-red)]/80 bg-[var(--clay-red)]/5 px-3 py-1.5 rounded-[var(--radius-md)]">
|
| 334 |
+
Token habis & dalam cooldown. Gunakan BYOK atau tunggu {myCredit.data.cooldownRemaining} hari.
|
| 335 |
+
</p>
|
| 336 |
+
)}
|
| 337 |
+
</div>
|
| 338 |
+
)}
|
| 339 |
+
|
| 340 |
+
{!useFreeCredits && hasConfigs && (
|
| 341 |
+
<div className="mt-4 pt-4 border-t border-[var(--oat-border)]">
|
| 342 |
+
<label className="text-sm font-medium text-[var(--clay-black)] mb-2 block">Provider / API Key</label>
|
| 343 |
+
<div className="flex gap-3">
|
| 344 |
+
<Select value={selectedKeyId} onValueChange={(v) => v && setSelectedKeyId(v)}>
|
| 345 |
+
<SelectTrigger className="flex-1 h-11">
|
| 346 |
+
<SelectValue>
|
| 347 |
+
{selectedConfig ? `${selectedConfig.name} · ${selectedConfig.modelName}` : "Pilih provider..."}
|
| 348 |
+
</SelectValue>
|
| 349 |
+
</SelectTrigger>
|
| 350 |
+
<SelectContent>
|
| 351 |
+
{configs.map((c) => (
|
| 352 |
+
<SelectItem key={c.id} value={c.id}>
|
| 353 |
+
{c.name} · {c.modelName}
|
| 354 |
+
</SelectItem>
|
| 355 |
+
))}
|
| 356 |
+
</SelectContent>
|
| 357 |
+
</Select>
|
| 358 |
+
<Link to="/settings">
|
| 359 |
+
<Button variant="outline" size="xl" className="rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] clay-hover">
|
| 360 |
+
<MaterialIcon name="settings" className="mr-1" />
|
| 361 |
+
Kelola
|
| 362 |
+
</Button>
|
| 363 |
+
</Link>
|
| 364 |
+
</div>
|
| 365 |
+
</div>
|
| 366 |
+
)}
|
| 367 |
+
|
| 368 |
+
{!useFreeCredits && !hasConfigs && !hasFreeCredits && (
|
| 369 |
+
<p className="mt-3 text-xs text-[var(--warm-charcoal)]">
|
| 370 |
+
Tambahkan API key di Settings dahulu.
|
| 371 |
+
</p>
|
| 372 |
+
)}
|
| 373 |
+
|
| 374 |
+
{!useFreeCredits && !hasConfigs && hasFreeCredits && (
|
| 375 |
+
<p className="mt-3 text-xs text-[var(--warm-charcoal)]">
|
| 376 |
+
Belum ada API key?{" "}
|
| 377 |
+
<button onClick={() => setUseFreeCredits(true)} className="text-[var(--matcha-600)] underline">
|
| 378 |
+
Gunakan kredit gratis
|
| 379 |
+
</button>
|
| 380 |
+
{" "}atau tambah di Settings.
|
| 381 |
+
</p>
|
| 382 |
+
)}
|
| 383 |
+
|
| 384 |
+
{useFreeCredits && !hasFreeCredits && (
|
| 385 |
+
<p className="mt-3 text-xs text-[var(--warm-charcoal)]">
|
| 386 |
+
Free credits sedang dinonaktifkan oleh admin.
|
| 387 |
+
</p>
|
| 388 |
+
)}
|
| 389 |
+
</div>
|
| 390 |
+
|
| 391 |
<div className="grid grid-cols-1 lg:grid-cols-12 gap-10 items-start">
|
| 392 |
{/* Configuration Panel */}
|
| 393 |
<div className="lg:col-span-8 flex flex-col gap-10">
|
|
|
|
| 637 |
activeCount={activeCount}
|
| 638 |
maxParallel={MAX_PARALLEL}
|
| 639 |
generatePending={generate.isPending}
|
| 640 |
+
hasKey={hasConfigs || useFreeCredits}
|
| 641 |
error={error}
|
| 642 |
onGenerate={handleGenerate}
|
| 643 |
onDismissError={() => setError(null)}
|
apps/web/src/routes/index.tsx
CHANGED
|
@@ -36,13 +36,15 @@ function HomeComponent() {
|
|
| 36 |
const recentAttempts = useQuery(
|
| 37 |
trpc.attempt.myAttempts.queryOptions({ limit: 5, offset: 0 }),
|
| 38 |
);
|
| 39 |
-
const
|
| 40 |
-
trpc.
|
| 41 |
);
|
| 42 |
|
| 43 |
const stats = overview.data;
|
| 44 |
const attempts = recentAttempts.data?.attempts ?? [];
|
| 45 |
-
const
|
|
|
|
|
|
|
| 46 |
|
| 47 |
const isNewUser = !stats || (stats.completedAttempts === 0 && stats.totalQuestionsAnswered === 0);
|
| 48 |
|
|
@@ -199,19 +201,22 @@ function HomeComponent() {
|
|
| 199 |
</Link>
|
| 200 |
</div>
|
| 201 |
|
| 202 |
-
{/*
|
| 203 |
-
{
|
| 204 |
<section className="mb-10">
|
| 205 |
<div className="flex items-center justify-between mb-4">
|
| 206 |
-
<
|
| 207 |
-
|
| 208 |
-
|
|
|
|
|
|
|
|
|
|
| 209 |
<Link to="/packages" className="text-sm text-[var(--matcha-600)] font-semibold hover:underline">
|
| 210 |
Lihat Semua
|
| 211 |
</Link>
|
| 212 |
</div>
|
| 213 |
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
| 214 |
-
{
|
| 215 |
<Link key={pkg.id} to="/package/$id" params={{ id: pkg.id }} className="block">
|
| 216 |
<Card className="clay-shadow clay-hover bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[var(--radius-xl)] h-full transition-all hover:border-[var(--matcha-400)]">
|
| 217 |
<CardContent className="p-5">
|
|
|
|
| 36 |
const recentAttempts = useQuery(
|
| 37 |
trpc.attempt.myAttempts.queryOptions({ limit: 5, offset: 0 }),
|
| 38 |
);
|
| 39 |
+
const featured = useQuery(
|
| 40 |
+
trpc.admin.listFeatured.queryOptions(),
|
| 41 |
);
|
| 42 |
|
| 43 |
const stats = overview.data;
|
| 44 |
const attempts = recentAttempts.data?.attempts ?? [];
|
| 45 |
+
const featuredPkg = featured.data?.packages ?? [];
|
| 46 |
+
const featuredQ = featured.data?.questions ?? [];
|
| 47 |
+
const hasFeatured = featuredPkg.length > 0 || featuredQ.length > 0;
|
| 48 |
|
| 49 |
const isNewUser = !stats || (stats.completedAttempts === 0 && stats.totalQuestionsAnswered === 0);
|
| 50 |
|
|
|
|
| 201 |
</Link>
|
| 202 |
</div>
|
| 203 |
|
| 204 |
+
{/* Editor's Pick */}
|
| 205 |
+
{hasFeatured && (
|
| 206 |
<section className="mb-10">
|
| 207 |
<div className="flex items-center justify-between mb-4">
|
| 208 |
+
<div className="flex items-center gap-2">
|
| 209 |
+
<MaterialIcon name="star" className="text-[var(--sunbeam-600)]" />
|
| 210 |
+
<h2 className="text-xl font-headline font-bold text-[var(--clay-black)]">
|
| 211 |
+
Editor's Pick
|
| 212 |
+
</h2>
|
| 213 |
+
</div>
|
| 214 |
<Link to="/packages" className="text-sm text-[var(--matcha-600)] font-semibold hover:underline">
|
| 215 |
Lihat Semua
|
| 216 |
</Link>
|
| 217 |
</div>
|
| 218 |
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
| 219 |
+
{featuredPkg.slice(0, 3).map((pkg: any) => (
|
| 220 |
<Link key={pkg.id} to="/package/$id" params={{ id: pkg.id }} className="block">
|
| 221 |
<Card className="clay-shadow clay-hover bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[var(--radius-xl)] h-full transition-all hover:border-[var(--matcha-400)]">
|
| 222 |
<CardContent className="p-5">
|
packages/api/src/__tests__/admin.integration.test.ts
ADDED
|
@@ -0,0 +1,307 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { describe, it, expect, beforeAll, afterAll, mock } from "bun:test";
|
| 2 |
+
import { getTestPGlite, createTestUserData, closeTestPGlite } from "./test-setup";
|
| 3 |
+
import { drizzle } from "drizzle-orm/pglite";
|
| 4 |
+
import * as schema from "../../../db/src/schema";
|
| 5 |
+
|
| 6 |
+
const MOCK_ENV = {
|
| 7 |
+
DATABASE_URL: "postgres://localhost:5432/test",
|
| 8 |
+
BETTER_AUTH_SECRET: "test-secret-key-32-chars-long!!!!",
|
| 9 |
+
BETTER_AUTH_URL: "http://localhost:3000",
|
| 10 |
+
CORS_ORIGIN: "http://localhost:5173",
|
| 11 |
+
API_KEY_ENCRYPTION_KEY: "test-encryption-key-32-chars!!",
|
| 12 |
+
REDIS_URL: "redis://localhost:6379",
|
| 13 |
+
SMTP_HOST: "smtp.test.com",
|
| 14 |
+
SMTP_PORT: "587",
|
| 15 |
+
SMTP_USER: "test@test.com",
|
| 16 |
+
SMTP_PASS: "password123",
|
| 17 |
+
SMTP_FROM: "test@test.com",
|
| 18 |
+
PLATFORM_AI_API_KEY: "sk-test-platform-key",
|
| 19 |
+
PLATFORM_AI_BASE_URL: "https://api.openai.com/v1",
|
| 20 |
+
PLATFORM_AI_MODEL: "gpt-4o-mini",
|
| 21 |
+
DEFAULT_SIGNUP_CREDIT_TOKENS: "50000",
|
| 22 |
+
};
|
| 23 |
+
|
| 24 |
+
mock.module("@labas/env/server", () => ({
|
| 25 |
+
env: MOCK_ENV,
|
| 26 |
+
}));
|
| 27 |
+
|
| 28 |
+
const TEST_QUESTION_ID = "b0000000-1000-4000-8000-000000000001";
|
| 29 |
+
const TEST_PACKAGE_ID = "a0000000-1000-4000-8000-000000000001";
|
| 30 |
+
|
| 31 |
+
describe("Admin Integration", () => {
|
| 32 |
+
let db: ReturnType<typeof drizzle>;
|
| 33 |
+
let adminCaller: Awaited<ReturnType<typeof import("../routers/admin").adminRouter.createCaller>> | null = null;
|
| 34 |
+
let userCaller: Awaited<ReturnType<typeof import("../routers/admin").adminRouter.createCaller>> | null = null;
|
| 35 |
+
let userData: Awaited<ReturnType<typeof createTestUserData>>;
|
| 36 |
+
let adminRouter: typeof import("../routers/admin").adminRouter;
|
| 37 |
+
|
| 38 |
+
beforeAll(async () => {
|
| 39 |
+
userData = await createTestUserData();
|
| 40 |
+
const pg = await getTestPGlite();
|
| 41 |
+
db = drizzle(pg, { schema });
|
| 42 |
+
|
| 43 |
+
mock.module("@labas/db", () => ({
|
| 44 |
+
db,
|
| 45 |
+
...schema,
|
| 46 |
+
}));
|
| 47 |
+
|
| 48 |
+
adminRouter = (await import("../routers/admin")).adminRouter;
|
| 49 |
+
|
| 50 |
+
adminCaller = adminRouter.createCaller({
|
| 51 |
+
session: {
|
| 52 |
+
user: { id: userData.user1.id, name: userData.user1.name, email: userData.user1.email, emailVerified: false, image: null },
|
| 53 |
+
expiresAt: new Date("2099-01-01"),
|
| 54 |
+
},
|
| 55 |
+
auth: null,
|
| 56 |
+
});
|
| 57 |
+
|
| 58 |
+
userCaller = adminRouter.createCaller({
|
| 59 |
+
session: {
|
| 60 |
+
user: { id: userData.user2.id, name: userData.user2.name, email: userData.user2.email, emailVerified: false, image: null },
|
| 61 |
+
expiresAt: new Date("2099-01-01"),
|
| 62 |
+
},
|
| 63 |
+
auth: null,
|
| 64 |
+
});
|
| 65 |
+
});
|
| 66 |
+
|
| 67 |
+
afterAll(async () => {
|
| 68 |
+
await closeTestPGlite();
|
| 69 |
+
});
|
| 70 |
+
|
| 71 |
+
// ── Schema Verification ─────────────────────────────────────
|
| 72 |
+
|
| 73 |
+
describe("Schema", () => {
|
| 74 |
+
it("user table has role column defaulting to user", async () => {
|
| 75 |
+
const rows = await db.execute(`SELECT column_name, column_default FROM information_schema.columns WHERE table_name = 'user' AND column_name = 'role'`);
|
| 76 |
+
expect(rows.rows.length).toBe(1);
|
| 77 |
+
expect(rows.rows[0].column_default).toContain("user");
|
| 78 |
+
});
|
| 79 |
+
|
| 80 |
+
it("user table has suspended column defaulting to false", async () => {
|
| 81 |
+
const rows = await db.execute(`SELECT column_name FROM information_schema.columns WHERE table_name = 'user' AND column_name = 'suspended'`);
|
| 82 |
+
expect(rows.rows.length).toBe(1);
|
| 83 |
+
});
|
| 84 |
+
|
| 85 |
+
it("question table has is_featured column", async () => {
|
| 86 |
+
const rows = await db.execute(`SELECT column_name FROM information_schema.columns WHERE table_name = 'question' AND column_name = 'is_featured'`);
|
| 87 |
+
expect(rows.rows.length).toBe(1);
|
| 88 |
+
});
|
| 89 |
+
|
| 90 |
+
it("user_credit table exists", async () => {
|
| 91 |
+
const rows = await db.execute(`SELECT table_name FROM information_schema.tables WHERE table_name = 'user_credit'`);
|
| 92 |
+
expect(rows.rows.length).toBe(1);
|
| 93 |
+
});
|
| 94 |
+
|
| 95 |
+
it("credit_transaction table exists", async () => {
|
| 96 |
+
const rows = await db.execute(`SELECT table_name FROM information_schema.tables WHERE table_name = 'credit_transaction'`);
|
| 97 |
+
expect(rows.rows.length).toBe(1);
|
| 98 |
+
});
|
| 99 |
+
|
| 100 |
+
it("admin_audit_log table exists", async () => {
|
| 101 |
+
const rows = await db.execute(`SELECT table_name FROM information_schema.tables WHERE table_name = 'admin_audit_log'`);
|
| 102 |
+
expect(rows.rows.length).toBe(1);
|
| 103 |
+
});
|
| 104 |
+
});
|
| 105 |
+
|
| 106 |
+
// ── Admin Middleware ─────────────────────────────────────────
|
| 107 |
+
|
| 108 |
+
describe("Middleware", () => {
|
| 109 |
+
it("non-admin user gets FORBIDDEN on admin procedures", async () => {
|
| 110 |
+
try {
|
| 111 |
+
await userCaller!.listUsers({});
|
| 112 |
+
expect.unreachable("Should have thrown");
|
| 113 |
+
} catch (e: any) {
|
| 114 |
+
expect(e.code).toBe("FORBIDDEN");
|
| 115 |
+
}
|
| 116 |
+
});
|
| 117 |
+
|
| 118 |
+
it("unauthenticated user gets UNAUTHORIZED on admin procedures", async () => {
|
| 119 |
+
const publicCaller = adminRouter.createCaller({
|
| 120 |
+
session: null,
|
| 121 |
+
auth: null,
|
| 122 |
+
});
|
| 123 |
+
try {
|
| 124 |
+
await publicCaller.listUsers({});
|
| 125 |
+
expect.unreachable("Should have thrown");
|
| 126 |
+
} catch (e: any) {
|
| 127 |
+
expect(e.code).toBe("UNAUTHORIZED");
|
| 128 |
+
}
|
| 129 |
+
});
|
| 130 |
+
});
|
| 131 |
+
|
| 132 |
+
// ── User Management ─────────────────────────────────────────
|
| 133 |
+
|
| 134 |
+
describe("User Management", () => {
|
| 135 |
+
it("lists users with pagination", async () => {
|
| 136 |
+
const result = await adminCaller!.listUsers({});
|
| 137 |
+
expect(result.users.length).toBeGreaterThanOrEqual(2);
|
| 138 |
+
expect(result.total).toBeGreaterThanOrEqual(2);
|
| 139 |
+
});
|
| 140 |
+
|
| 141 |
+
it("lists users with search", async () => {
|
| 142 |
+
const result = await adminCaller!.listUsers({ search: "Test" });
|
| 143 |
+
expect(result.users.length).toBeGreaterThanOrEqual(1);
|
| 144 |
+
});
|
| 145 |
+
|
| 146 |
+
it("suspends a user", async () => {
|
| 147 |
+
const suspended = await adminCaller!.suspendUser({ userId: userData.user2.id, suspended: true });
|
| 148 |
+
expect(suspended.suspended).toBe(true);
|
| 149 |
+
});
|
| 150 |
+
|
| 151 |
+
it("unsuspends a user", async () => {
|
| 152 |
+
const unsuspended = await adminCaller!.suspendUser({ userId: userData.user2.id, suspended: false });
|
| 153 |
+
expect(unsuspended.suspended).toBe(false);
|
| 154 |
+
});
|
| 155 |
+
|
| 156 |
+
it("cannot suspend self", async () => {
|
| 157 |
+
try {
|
| 158 |
+
await adminCaller!.suspendUser({ userId: userData.user1.id, suspended: true });
|
| 159 |
+
expect.unreachable("Should have thrown");
|
| 160 |
+
} catch (e: any) {
|
| 161 |
+
expect(e.code).toBe("FORBIDDEN");
|
| 162 |
+
}
|
| 163 |
+
});
|
| 164 |
+
});
|
| 165 |
+
|
| 166 |
+
// ── Role Management ─────────────────────────────────────────
|
| 167 |
+
|
| 168 |
+
describe("Role Management", () => {
|
| 169 |
+
it("sets another user to admin", async () => {
|
| 170 |
+
const updated = await adminCaller!.setUserRole({ userId: userData.user2.id, role: "admin" });
|
| 171 |
+
expect(updated.role).toBe("admin");
|
| 172 |
+
// Restore
|
| 173 |
+
await adminCaller!.setUserRole({ userId: userData.user2.id, role: "user" });
|
| 174 |
+
});
|
| 175 |
+
|
| 176 |
+
it("cannot change own role", async () => {
|
| 177 |
+
try {
|
| 178 |
+
await adminCaller!.setUserRole({ userId: userData.user1.id, role: "user" });
|
| 179 |
+
expect.unreachable("Should have thrown");
|
| 180 |
+
} catch (e: any) {
|
| 181 |
+
expect(e.code).toBe("FORBIDDEN");
|
| 182 |
+
}
|
| 183 |
+
});
|
| 184 |
+
});
|
| 185 |
+
|
| 186 |
+
// ── Credit Management ───────────────────────────────────────
|
| 187 |
+
|
| 188 |
+
describe("Credit Management", () => {
|
| 189 |
+
beforeAll(async () => {
|
| 190 |
+
await db.insert(schema.userCredit).values({
|
| 191 |
+
userId: userData.user2.id, tokenBalance: 10000,
|
| 192 |
+
}).onConflictDoNothing();
|
| 193 |
+
});
|
| 194 |
+
|
| 195 |
+
it("gets user credit balance", async () => {
|
| 196 |
+
const credit = await adminCaller!.getCreditBalance({ userId: userData.user2.id });
|
| 197 |
+
expect(credit.tokenBalance).toBe(10000);
|
| 198 |
+
});
|
| 199 |
+
|
| 200 |
+
it("adjusts credit (add)", async () => {
|
| 201 |
+
const result = await adminCaller!.adjustCredit({ userId: userData.user2.id, amount: 5000, description: "Bonus" });
|
| 202 |
+
expect(result.newBalance).toBe(15000);
|
| 203 |
+
});
|
| 204 |
+
|
| 205 |
+
it("adjusts credit (deduct)", async () => {
|
| 206 |
+
const result = await adminCaller!.adjustCredit({ userId: userData.user2.id, amount: -3000, description: "Deduct" });
|
| 207 |
+
expect(result.newBalance).toBe(12000);
|
| 208 |
+
});
|
| 209 |
+
|
| 210 |
+
it("gets credit transaction history", async () => {
|
| 211 |
+
const history = await adminCaller!.getCreditHistory({ userId: userData.user2.id });
|
| 212 |
+
expect(history.transactions.length).toBeGreaterThanOrEqual(2);
|
| 213 |
+
});
|
| 214 |
+
});
|
| 215 |
+
|
| 216 |
+
// ── Featured Management ─────────────────────────────────────
|
| 217 |
+
|
| 218 |
+
describe("Featured Management", () => {
|
| 219 |
+
it("toggles featured on a package", async () => {
|
| 220 |
+
const result = await adminCaller!.toggleFeaturedPackage({ packageId: TEST_PACKAGE_ID });
|
| 221 |
+
expect(result.isFeatured).toBe(true);
|
| 222 |
+
// Toggle back
|
| 223 |
+
await adminCaller!.toggleFeaturedPackage({ packageId: TEST_PACKAGE_ID });
|
| 224 |
+
});
|
| 225 |
+
|
| 226 |
+
it("toggles featured on a question", async () => {
|
| 227 |
+
const result = await adminCaller!.toggleFeaturedQuestion({ questionId: TEST_QUESTION_ID });
|
| 228 |
+
expect(result.isFeatured).toBe(true);
|
| 229 |
+
await adminCaller!.toggleFeaturedQuestion({ questionId: TEST_QUESTION_ID });
|
| 230 |
+
});
|
| 231 |
+
|
| 232 |
+
it("lists all featured items", async () => {
|
| 233 |
+
await adminCaller!.toggleFeaturedPackage({ packageId: TEST_PACKAGE_ID });
|
| 234 |
+
await adminCaller!.toggleFeaturedQuestion({ questionId: TEST_QUESTION_ID });
|
| 235 |
+
|
| 236 |
+
const featured = await adminCaller!.listFeatured();
|
| 237 |
+
expect(featured.packages.length).toBeGreaterThanOrEqual(1);
|
| 238 |
+
expect(featured.questions.length).toBeGreaterThanOrEqual(1);
|
| 239 |
+
|
| 240 |
+
await adminCaller!.toggleFeaturedPackage({ packageId: TEST_PACKAGE_ID });
|
| 241 |
+
await adminCaller!.toggleFeaturedQuestion({ questionId: TEST_QUESTION_ID });
|
| 242 |
+
});
|
| 243 |
+
|
| 244 |
+
it("returns NOT_FOUND for non-existent package", async () => {
|
| 245 |
+
try {
|
| 246 |
+
await adminCaller!.toggleFeaturedPackage({ packageId: "dddddddd-1000-4000-8000-000000000001" });
|
| 247 |
+
expect.unreachable("Should have thrown");
|
| 248 |
+
} catch (e: any) {
|
| 249 |
+
expect(e.code).toBe("NOT_FOUND");
|
| 250 |
+
}
|
| 251 |
+
});
|
| 252 |
+
});
|
| 253 |
+
|
| 254 |
+
// ── Generation Jobs View ────────────────────────────────────
|
| 255 |
+
|
| 256 |
+
describe("Generation Jobs", () => {
|
| 257 |
+
beforeAll(async () => {
|
| 258 |
+
// Import the full schema reference
|
| 259 |
+
const fullSchema = await import("../../../db/src/schema");
|
| 260 |
+
try {
|
| 261 |
+
await db.insert(fullSchema.generationJob).values({
|
| 262 |
+
userId: userData.user2.id,
|
| 263 |
+
status: "failed",
|
| 264 |
+
mode: "quick",
|
| 265 |
+
examTypeId: "IELTS",
|
| 266 |
+
sectionTypeId: "READING",
|
| 267 |
+
questionCount: 5,
|
| 268 |
+
tokensUsed: 1000,
|
| 269 |
+
});
|
| 270 |
+
} catch {
|
| 271 |
+
// generationJob may not be in test-setup schema yet - skip
|
| 272 |
+
}
|
| 273 |
+
});
|
| 274 |
+
|
| 275 |
+
it("lists all jobs across users", async () => {
|
| 276 |
+
const result = await adminCaller!.listAllJobs({});
|
| 277 |
+
expect(result.jobs).toBeDefined();
|
| 278 |
+
});
|
| 279 |
+
});
|
| 280 |
+
|
| 281 |
+
// ── Content Moderation ──────────────────────────────────────
|
| 282 |
+
|
| 283 |
+
describe("Content Moderation", () => {
|
| 284 |
+
it("lists latest questions for moderation", async () => {
|
| 285 |
+
const result = await adminCaller!.listLatestQuestions({});
|
| 286 |
+
expect(result.questions.length).toBeGreaterThanOrEqual(1);
|
| 287 |
+
});
|
| 288 |
+
|
| 289 |
+
it("toggles public on any question", async () => {
|
| 290 |
+
const result = await adminCaller!.togglePublicAny({ questionId: TEST_QUESTION_ID });
|
| 291 |
+
expect(result.isPublic).toBe(false);
|
| 292 |
+
// Toggle back
|
| 293 |
+
await adminCaller!.togglePublicAny({ questionId: TEST_QUESTION_ID });
|
| 294 |
+
});
|
| 295 |
+
});
|
| 296 |
+
|
| 297 |
+
// ── Dashboard Stats ─────────────────────────────────────────
|
| 298 |
+
|
| 299 |
+
describe("Dashboard Stats", () => {
|
| 300 |
+
it("returns platform-wide stats", async () => {
|
| 301 |
+
const stats = await adminCaller!.dashboardStats();
|
| 302 |
+
expect(stats.totalUsers).toBeGreaterThanOrEqual(2);
|
| 303 |
+
expect(stats.totalQuestions).toBeGreaterThanOrEqual(1);
|
| 304 |
+
expect(stats.totalPackages).toBeGreaterThanOrEqual(1);
|
| 305 |
+
});
|
| 306 |
+
});
|
| 307 |
+
});
|
packages/api/src/__tests__/credit.test.ts
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { describe, it, expect, beforeAll, afterAll, mock } from "bun:test";
|
| 2 |
+
import { getTestPGlite, createTestUserData, closeTestPGlite } from "./test-setup";
|
| 3 |
+
import { drizzle } from "drizzle-orm/pglite";
|
| 4 |
+
import * as schema from "../../../db/src/schema";
|
| 5 |
+
import { eq } from "drizzle-orm";
|
| 6 |
+
|
| 7 |
+
const MOCK_ENV = {
|
| 8 |
+
DATABASE_URL: "postgres://localhost:5432/test",
|
| 9 |
+
BETTER_AUTH_SECRET: "test-secret-key-32-chars-long!!!!",
|
| 10 |
+
BETTER_AUTH_URL: "http://localhost:3000",
|
| 11 |
+
CORS_ORIGIN: "http://localhost:5173",
|
| 12 |
+
API_KEY_ENCRYPTION_KEY: "test-encryption-key-32-chars!!",
|
| 13 |
+
REDIS_URL: "redis://localhost:6379",
|
| 14 |
+
SMTP_HOST: "smtp.test.com",
|
| 15 |
+
SMTP_PORT: "587",
|
| 16 |
+
SMTP_USER: "test@test.com",
|
| 17 |
+
SMTP_PASS: "password123",
|
| 18 |
+
SMTP_FROM: "test@test.com",
|
| 19 |
+
PLATFORM_AI_API_KEY: "sk-test-platform-key",
|
| 20 |
+
PLATFORM_AI_BASE_URL: "https://api.openai.com/v1",
|
| 21 |
+
PLATFORM_AI_MODEL: "gpt-4o-mini",
|
| 22 |
+
DEFAULT_SIGNUP_CREDIT_TOKENS: "50000",
|
| 23 |
+
};
|
| 24 |
+
|
| 25 |
+
mock.module("@labas/env/server", () => ({
|
| 26 |
+
env: MOCK_ENV,
|
| 27 |
+
}));
|
| 28 |
+
|
| 29 |
+
let userIdCounter = 0;
|
| 30 |
+
function makeUserId(label: string) {
|
| 31 |
+
userIdCounter++;
|
| 32 |
+
return `credit-${label}-${userIdCounter}`;
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
describe("Credit Utility", () => {
|
| 36 |
+
let db: ReturnType<typeof drizzle>;
|
| 37 |
+
let credit: typeof import("../lib/credit");
|
| 38 |
+
let userData: Awaited<ReturnType<typeof createTestUserData>>;
|
| 39 |
+
|
| 40 |
+
beforeAll(async () => {
|
| 41 |
+
userData = await createTestUserData();
|
| 42 |
+
const pg = await getTestPGlite();
|
| 43 |
+
db = drizzle(pg, { schema });
|
| 44 |
+
|
| 45 |
+
mock.module("@labas/db", () => ({
|
| 46 |
+
db,
|
| 47 |
+
...schema,
|
| 48 |
+
}));
|
| 49 |
+
|
| 50 |
+
credit = await import("../lib/credit");
|
| 51 |
+
});
|
| 52 |
+
|
| 53 |
+
afterAll(async () => {
|
| 54 |
+
await closeTestPGlite();
|
| 55 |
+
});
|
| 56 |
+
|
| 57 |
+
// ── Credit Deduction ──────────────────────────────────────
|
| 58 |
+
|
| 59 |
+
describe("deductCredit", () => {
|
| 60 |
+
it("deducts tokens from balance", async () => {
|
| 61 |
+
const uid = makeUserId("deduct");
|
| 62 |
+
await db.insert(schema.user).values({ id: uid, name: uid, email: `${uid}@test.com` });
|
| 63 |
+
await db.insert(schema.userCredit).values({ userId: uid, tokenBalance: 5000 });
|
| 64 |
+
|
| 65 |
+
const result = await credit.deductCredit(uid, 1000);
|
| 66 |
+
expect(result.newBalance).toBe(4000);
|
| 67 |
+
|
| 68 |
+
const [updated] = await db.select().from(schema.userCredit).where(eq(schema.userCredit.userId, uid)).limit(1);
|
| 69 |
+
expect(updated.tokenBalance).toBe(4000);
|
| 70 |
+
});
|
| 71 |
+
|
| 72 |
+
it("creates credit transaction record", async () => {
|
| 73 |
+
const uid = makeUserId("txn");
|
| 74 |
+
await db.insert(schema.user).values({ id: uid, name: uid, email: `${uid}@test.com` });
|
| 75 |
+
await db.insert(schema.userCredit).values({ userId: uid, tokenBalance: 5000 });
|
| 76 |
+
|
| 77 |
+
await credit.deductCredit(uid, 500);
|
| 78 |
+
|
| 79 |
+
const txns = await db.select().from(schema.creditTransaction).where(eq(schema.creditTransaction.userId, uid));
|
| 80 |
+
expect(txns.length).toBe(1);
|
| 81 |
+
expect(txns[0].amount).toBe(-500);
|
| 82 |
+
expect(txns[0].type).toBe("generation_spend");
|
| 83 |
+
});
|
| 84 |
+
|
| 85 |
+
it("throws when user has no credit balance", async () => {
|
| 86 |
+
const uid = makeUserId("nobalance");
|
| 87 |
+
await db.insert(schema.user).values({ id: uid, name: uid, email: `${uid}@test.com` });
|
| 88 |
+
|
| 89 |
+
try {
|
| 90 |
+
await credit.deductCredit(uid, 100);
|
| 91 |
+
expect.unreachable("Should have thrown");
|
| 92 |
+
} catch (e: any) {
|
| 93 |
+
expect(e.code).toBe("BAD_REQUEST");
|
| 94 |
+
expect(e.message).toContain("Insufficient credits");
|
| 95 |
+
}
|
| 96 |
+
});
|
| 97 |
+
|
| 98 |
+
it("handles insufficient balance", async () => {
|
| 99 |
+
const uid = makeUserId("lowbal");
|
| 100 |
+
await db.insert(schema.user).values({ id: uid, name: uid, email: `${uid}@test.com` });
|
| 101 |
+
await db.insert(schema.userCredit).values({ userId: uid, tokenBalance: 100 });
|
| 102 |
+
|
| 103 |
+
try {
|
| 104 |
+
await credit.deductCredit(uid, 500);
|
| 105 |
+
expect.unreachable("Should have thrown");
|
| 106 |
+
} catch (e: any) {
|
| 107 |
+
expect(e.code).toBe("BAD_REQUEST");
|
| 108 |
+
expect(e.message).toContain("Insufficient credits");
|
| 109 |
+
}
|
| 110 |
+
});
|
| 111 |
+
});
|
| 112 |
+
|
| 113 |
+
// ── getUserCredit ─────────────────────────────────────────
|
| 114 |
+
|
| 115 |
+
describe("getUserCredit", () => {
|
| 116 |
+
it("returns credit info for user with balance", async () => {
|
| 117 |
+
const uid = makeUserId("getcred");
|
| 118 |
+
await db.insert(schema.user).values({ id: uid, name: uid, email: `${uid}@test.com` });
|
| 119 |
+
await db.insert(schema.userCredit).values({ userId: uid, tokenBalance: 3000 });
|
| 120 |
+
|
| 121 |
+
const info = await credit.getUserCredit(uid);
|
| 122 |
+
expect(info.tokenBalance).toBe(3000);
|
| 123 |
+
});
|
| 124 |
+
|
| 125 |
+
it("returns zero for user without credit row", async () => {
|
| 126 |
+
const uid = makeUserId("nored");
|
| 127 |
+
await db.insert(schema.user).values({ id: uid, name: uid, email: `${uid}@test.com` });
|
| 128 |
+
|
| 129 |
+
const info = await credit.getUserCredit(uid);
|
| 130 |
+
expect(info.tokenBalance).toBe(0);
|
| 131 |
+
});
|
| 132 |
+
});
|
| 133 |
+
|
| 134 |
+
// ── isUserSuspended ───────────────────────────────────────
|
| 135 |
+
|
| 136 |
+
describe("isUserSuspended", () => {
|
| 137 |
+
it("returns false for active user", async () => {
|
| 138 |
+
const suspended = await credit.isUserSuspended(userData.user2.id);
|
| 139 |
+
expect(suspended).toBe(false);
|
| 140 |
+
});
|
| 141 |
+
|
| 142 |
+
it("returns true for suspended user", async () => {
|
| 143 |
+
await db.update(schema.user).set({ suspended: true }).where(eq(schema.user.id, userData.user2.id));
|
| 144 |
+
|
| 145 |
+
const suspended = await credit.isUserSuspended(userData.user2.id);
|
| 146 |
+
expect(suspended).toBe(true);
|
| 147 |
+
|
| 148 |
+
await db.update(schema.user).set({ suspended: false }).where(eq(schema.user.id, userData.user2.id));
|
| 149 |
+
});
|
| 150 |
+
});
|
| 151 |
+
|
| 152 |
+
// ── ensureUserCreditRow ───────────────────────────────────
|
| 153 |
+
|
| 154 |
+
describe("ensureUserCreditRow", () => {
|
| 155 |
+
it("creates row if not exists", async () => {
|
| 156 |
+
const uid = makeUserId("ensure");
|
| 157 |
+
await db.insert(schema.user).values({ id: uid, name: uid, email: `${uid}@test.com` });
|
| 158 |
+
|
| 159 |
+
await credit.ensureUserCreditRow(uid);
|
| 160 |
+
|
| 161 |
+
const [row] = await db.select().from(schema.userCredit).where(eq(schema.userCredit.userId, uid)).limit(1);
|
| 162 |
+
expect(row).toBeDefined();
|
| 163 |
+
expect(row.tokenBalance).toBe(0);
|
| 164 |
+
});
|
| 165 |
+
});
|
| 166 |
+
});
|
packages/api/src/__tests__/test-setup.ts
CHANGED
|
@@ -21,19 +21,24 @@ export async function closeTestPGlite() {
|
|
| 21 |
|
| 22 |
async function initSchema(pg: PGlite) {
|
| 23 |
const tables = [
|
| 24 |
-
`CREATE TABLE IF NOT EXISTS "user" (id text PRIMARY KEY, name text NOT NULL, email text NOT NULL UNIQUE, email_verified boolean DEFAULT false NOT NULL, image text, created_at timestamp DEFAULT now() NOT NULL, updated_at timestamp DEFAULT now() NOT NULL)`,
|
| 25 |
`CREATE TABLE IF NOT EXISTS "session" (id text PRIMARY KEY, expires_at timestamp NOT NULL, token text NOT NULL UNIQUE, created_at timestamp DEFAULT now() NOT NULL, updated_at timestamp DEFAULT now() NOT NULL, ip_address text, user_agent text, user_id text NOT NULL REFERENCES "user"(id) ON DELETE CASCADE)`,
|
| 26 |
`CREATE TABLE IF NOT EXISTS "account" (id text PRIMARY KEY, account_id text NOT NULL, provider_id text NOT NULL, user_id text NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, access_token text, refresh_token text, id_token text, access_token_expires_at timestamp, refresh_token_expires_at timestamp, scope text, password text, created_at timestamp DEFAULT now() NOT NULL)`,
|
| 27 |
`CREATE TABLE IF NOT EXISTS "verification" (id text PRIMARY KEY, identifier text NOT NULL, value text NOT NULL, expires_at timestamp NOT NULL, created_at timestamp DEFAULT now() NOT NULL, updated_at timestamp DEFAULT now() NOT NULL)`,
|
| 28 |
`CREATE TABLE IF NOT EXISTS "exam_type" (id text PRIMARY KEY, name text NOT NULL, language text NOT NULL, description text)`,
|
| 29 |
`CREATE TABLE IF NOT EXISTS "section_type" (id text PRIMARY KEY, name text NOT NULL)`,
|
| 30 |
-
`CREATE TABLE IF NOT EXISTS "question" (id uuid DEFAULT gen_random_uuid() PRIMARY KEY, exam_type_id text NOT NULL REFERENCES exam_type(id) ON DELETE CASCADE, section_type_id text NOT NULL REFERENCES section_type(id) ON DELETE CASCADE, format text NOT NULL, passage_text text NOT NULL, question_text text NOT NULL, options jsonb, correct_answer text NOT NULL, explanation text, difficulty integer DEFAULT 3 NOT NULL, skill_tags text[] DEFAULT '{}', is_case_sensitive boolean DEFAULT false NOT NULL, source text DEFAULT 'manual' NOT NULL, ai_model text, ai_prompt_used text, creator_user_id text NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, is_public boolean DEFAULT false NOT NULL, usage_count integer DEFAULT 0 NOT NULL, avg_rating integer, created_at timestamp DEFAULT now() NOT NULL, updated_at timestamp DEFAULT now() NOT NULL)`,
|
| 31 |
`CREATE TABLE IF NOT EXISTS "test_package" (id uuid DEFAULT gen_random_uuid() PRIMARY KEY, title text NOT NULL, description text, exam_type_id text NOT NULL REFERENCES exam_type(id) ON DELETE CASCADE, creator_user_id text NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, is_public boolean DEFAULT false NOT NULL, total_questions integer DEFAULT 0 NOT NULL, total_sections integer DEFAULT 0 NOT NULL, estimated_duration_min integer, usage_count integer DEFAULT 0 NOT NULL, avg_rating integer, is_featured boolean DEFAULT false NOT NULL, created_at timestamp DEFAULT now() NOT NULL, updated_at timestamp DEFAULT now() NOT NULL)`,
|
| 32 |
`CREATE TABLE IF NOT EXISTS "package_section" (id uuid DEFAULT gen_random_uuid() PRIMARY KEY, package_id uuid NOT NULL REFERENCES test_package(id) ON DELETE CASCADE, section_type_id text NOT NULL REFERENCES section_type(id) ON DELETE CASCADE, title text NOT NULL, order_index integer DEFAULT 0 NOT NULL, created_at timestamp DEFAULT now() NOT NULL)`,
|
| 33 |
`CREATE TABLE IF NOT EXISTS "section_question" (id uuid DEFAULT gen_random_uuid() PRIMARY KEY, section_id uuid NOT NULL REFERENCES package_section(id) ON DELETE CASCADE, question_id uuid NOT NULL REFERENCES question(id) ON DELETE CASCADE, order_index integer DEFAULT 0 NOT NULL)`,
|
| 34 |
`CREATE TABLE IF NOT EXISTS "test_attempt" (id uuid DEFAULT gen_random_uuid() PRIMARY KEY, user_id text NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, package_id uuid REFERENCES test_package(id) ON DELETE SET NULL, combo_id uuid, started_at timestamp DEFAULT now() NOT NULL, finished_at timestamp, total_score integer, max_score integer, status text DEFAULT 'in_progress' NOT NULL, created_at timestamp DEFAULT now() NOT NULL)`,
|
| 35 |
`CREATE TABLE IF NOT EXISTS "section_result" (id uuid DEFAULT gen_random_uuid() PRIMARY KEY, attempt_id uuid NOT NULL REFERENCES test_attempt(id) ON DELETE CASCADE, section_type_id text NOT NULL REFERENCES section_type(id) ON DELETE CASCADE, score integer, max_score integer, time_spent_sec integer, created_at timestamp DEFAULT now() NOT NULL)`,
|
| 36 |
`CREATE TABLE IF NOT EXISTS "answer" (id uuid DEFAULT gen_random_uuid() PRIMARY KEY, section_result_id uuid NOT NULL REFERENCES section_result(id) ON DELETE CASCADE, question_id uuid NOT NULL REFERENCES question(id) ON DELETE CASCADE, user_answer text, is_correct boolean, partial_score integer, time_spent_sec integer, created_at timestamp DEFAULT now() NOT NULL)`,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
];
|
| 38 |
|
| 39 |
for (const sql of tables) {
|
|
@@ -46,7 +51,7 @@ export async function createTestUserData() {
|
|
| 46 |
const db = drizzle(pg, { schema });
|
| 47 |
|
| 48 |
const [user1] = await db.insert(schema.user).values({
|
| 49 |
-
id: "user-1", name: "Test User", email: "test@test.com",
|
| 50 |
}).returning();
|
| 51 |
|
| 52 |
const [user2] = await db.insert(schema.user).values({
|
|
@@ -66,6 +71,7 @@ export async function createTestUserData() {
|
|
| 66 |
|
| 67 |
await db.insert(schema.question).values([
|
| 68 |
{
|
|
|
|
| 69 |
examTypeId: "IELTS", sectionTypeId: "READING", format: "multiple_choice",
|
| 70 |
passageText: "A".repeat(100), questionText: "What is X?",
|
| 71 |
options: [{ key: "A", text: "Opt A" }, { key: "B", text: "Opt B" }],
|
|
@@ -75,7 +81,7 @@ export async function createTestUserData() {
|
|
| 75 |
]);
|
| 76 |
|
| 77 |
await db.insert(schema.testPackage).values({
|
| 78 |
-
id: "
|
| 79 |
title: "Test Package", examTypeId: "IELTS",
|
| 80 |
creatorUserId: user1.id, isPublic: true,
|
| 81 |
});
|
|
|
|
| 21 |
|
| 22 |
async function initSchema(pg: PGlite) {
|
| 23 |
const tables = [
|
| 24 |
+
`CREATE TABLE IF NOT EXISTS "user" (id text PRIMARY KEY, name text NOT NULL, email text NOT NULL UNIQUE, email_verified boolean DEFAULT false NOT NULL, image text, role text DEFAULT 'user' NOT NULL, suspended boolean DEFAULT false NOT NULL, created_at timestamp DEFAULT now() NOT NULL, updated_at timestamp DEFAULT now() NOT NULL)`,
|
| 25 |
`CREATE TABLE IF NOT EXISTS "session" (id text PRIMARY KEY, expires_at timestamp NOT NULL, token text NOT NULL UNIQUE, created_at timestamp DEFAULT now() NOT NULL, updated_at timestamp DEFAULT now() NOT NULL, ip_address text, user_agent text, user_id text NOT NULL REFERENCES "user"(id) ON DELETE CASCADE)`,
|
| 26 |
`CREATE TABLE IF NOT EXISTS "account" (id text PRIMARY KEY, account_id text NOT NULL, provider_id text NOT NULL, user_id text NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, access_token text, refresh_token text, id_token text, access_token_expires_at timestamp, refresh_token_expires_at timestamp, scope text, password text, created_at timestamp DEFAULT now() NOT NULL)`,
|
| 27 |
`CREATE TABLE IF NOT EXISTS "verification" (id text PRIMARY KEY, identifier text NOT NULL, value text NOT NULL, expires_at timestamp NOT NULL, created_at timestamp DEFAULT now() NOT NULL, updated_at timestamp DEFAULT now() NOT NULL)`,
|
| 28 |
`CREATE TABLE IF NOT EXISTS "exam_type" (id text PRIMARY KEY, name text NOT NULL, language text NOT NULL, description text)`,
|
| 29 |
`CREATE TABLE IF NOT EXISTS "section_type" (id text PRIMARY KEY, name text NOT NULL)`,
|
| 30 |
+
`CREATE TABLE IF NOT EXISTS "question" (id uuid DEFAULT gen_random_uuid() PRIMARY KEY, exam_type_id text NOT NULL REFERENCES exam_type(id) ON DELETE CASCADE, section_type_id text NOT NULL REFERENCES section_type(id) ON DELETE CASCADE, format text NOT NULL, passage_text text NOT NULL, question_text text NOT NULL, options jsonb, correct_answer text NOT NULL, explanation text, difficulty integer DEFAULT 3 NOT NULL, skill_tags text[] DEFAULT '{}', is_case_sensitive boolean DEFAULT false NOT NULL, source text DEFAULT 'manual' NOT NULL, ai_model text, ai_prompt_used text, creator_user_id text NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, is_public boolean DEFAULT false NOT NULL, is_featured boolean DEFAULT false NOT NULL, usage_count integer DEFAULT 0 NOT NULL, avg_rating integer, created_at timestamp DEFAULT now() NOT NULL, updated_at timestamp DEFAULT now() NOT NULL)`,
|
| 31 |
`CREATE TABLE IF NOT EXISTS "test_package" (id uuid DEFAULT gen_random_uuid() PRIMARY KEY, title text NOT NULL, description text, exam_type_id text NOT NULL REFERENCES exam_type(id) ON DELETE CASCADE, creator_user_id text NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, is_public boolean DEFAULT false NOT NULL, total_questions integer DEFAULT 0 NOT NULL, total_sections integer DEFAULT 0 NOT NULL, estimated_duration_min integer, usage_count integer DEFAULT 0 NOT NULL, avg_rating integer, is_featured boolean DEFAULT false NOT NULL, created_at timestamp DEFAULT now() NOT NULL, updated_at timestamp DEFAULT now() NOT NULL)`,
|
| 32 |
`CREATE TABLE IF NOT EXISTS "package_section" (id uuid DEFAULT gen_random_uuid() PRIMARY KEY, package_id uuid NOT NULL REFERENCES test_package(id) ON DELETE CASCADE, section_type_id text NOT NULL REFERENCES section_type(id) ON DELETE CASCADE, title text NOT NULL, order_index integer DEFAULT 0 NOT NULL, created_at timestamp DEFAULT now() NOT NULL)`,
|
| 33 |
`CREATE TABLE IF NOT EXISTS "section_question" (id uuid DEFAULT gen_random_uuid() PRIMARY KEY, section_id uuid NOT NULL REFERENCES package_section(id) ON DELETE CASCADE, question_id uuid NOT NULL REFERENCES question(id) ON DELETE CASCADE, order_index integer DEFAULT 0 NOT NULL)`,
|
| 34 |
`CREATE TABLE IF NOT EXISTS "test_attempt" (id uuid DEFAULT gen_random_uuid() PRIMARY KEY, user_id text NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, package_id uuid REFERENCES test_package(id) ON DELETE SET NULL, combo_id uuid, started_at timestamp DEFAULT now() NOT NULL, finished_at timestamp, total_score integer, max_score integer, status text DEFAULT 'in_progress' NOT NULL, created_at timestamp DEFAULT now() NOT NULL)`,
|
| 35 |
`CREATE TABLE IF NOT EXISTS "section_result" (id uuid DEFAULT gen_random_uuid() PRIMARY KEY, attempt_id uuid NOT NULL REFERENCES test_attempt(id) ON DELETE CASCADE, section_type_id text NOT NULL REFERENCES section_type(id) ON DELETE CASCADE, score integer, max_score integer, time_spent_sec integer, created_at timestamp DEFAULT now() NOT NULL)`,
|
| 36 |
`CREATE TABLE IF NOT EXISTS "answer" (id uuid DEFAULT gen_random_uuid() PRIMARY KEY, section_result_id uuid NOT NULL REFERENCES section_result(id) ON DELETE CASCADE, question_id uuid NOT NULL REFERENCES question(id) ON DELETE CASCADE, user_answer text, is_correct boolean, partial_score integer, time_spent_sec integer, created_at timestamp DEFAULT now() NOT NULL)`,
|
| 37 |
+
`CREATE TABLE IF NOT EXISTS "generation_job" (id uuid DEFAULT gen_random_uuid() PRIMARY KEY, user_id text NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, status text DEFAULT 'pending' NOT NULL, mode text DEFAULT 'quick' NOT NULL, exam_type_id text NOT NULL, section_type_id text NOT NULL, question_count integer NOT NULL, progress integer DEFAULT 0 NOT NULL, progress_message text, logs jsonb, result_json jsonb, input_json jsonb, error_message text, tokens_used integer, duration_ms integer, created_at timestamp DEFAULT now() NOT NULL, updated_at timestamp DEFAULT now() NOT NULL, completed_at timestamp)`,
|
| 38 |
+
`CREATE TABLE IF NOT EXISTS "user_credit" (user_id text PRIMARY KEY REFERENCES "user"(id) ON DELETE CASCADE, token_balance integer DEFAULT 0 NOT NULL, lifetime_tokens_used integer DEFAULT 0 NOT NULL, created_at timestamp DEFAULT now() NOT NULL, updated_at timestamp DEFAULT now() NOT NULL)`,
|
| 39 |
+
`CREATE TABLE IF NOT EXISTS "credit_transaction" (id uuid DEFAULT gen_random_uuid() PRIMARY KEY, user_id text NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, amount integer NOT NULL, type text NOT NULL, description text, tokens_used integer, created_at timestamp DEFAULT now() NOT NULL)`,
|
| 40 |
+
`CREATE TABLE IF NOT EXISTS "admin_audit_log" (id uuid DEFAULT gen_random_uuid() PRIMARY KEY, admin_user_id text NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, action text NOT NULL, target_user_id text REFERENCES "user"(id) ON DELETE SET NULL, details jsonb, created_at timestamp DEFAULT now() NOT NULL)`,
|
| 41 |
+
`CREATE TABLE IF NOT EXISTS "platform_config" (key text PRIMARY KEY, value text NOT NULL, updated_at timestamp DEFAULT now() NOT NULL)`,
|
| 42 |
];
|
| 43 |
|
| 44 |
for (const sql of tables) {
|
|
|
|
| 51 |
const db = drizzle(pg, { schema });
|
| 52 |
|
| 53 |
const [user1] = await db.insert(schema.user).values({
|
| 54 |
+
id: "user-1", name: "Test User", email: "test@test.com", role: "admin",
|
| 55 |
}).returning();
|
| 56 |
|
| 57 |
const [user2] = await db.insert(schema.user).values({
|
|
|
|
| 71 |
|
| 72 |
await db.insert(schema.question).values([
|
| 73 |
{
|
| 74 |
+
id: "b0000000-1000-4000-8000-000000000001",
|
| 75 |
examTypeId: "IELTS", sectionTypeId: "READING", format: "multiple_choice",
|
| 76 |
passageText: "A".repeat(100), questionText: "What is X?",
|
| 77 |
options: [{ key: "A", text: "Opt A" }, { key: "B", text: "Opt B" }],
|
|
|
|
| 81 |
]);
|
| 82 |
|
| 83 |
await db.insert(schema.testPackage).values({
|
| 84 |
+
id: "a0000000-1000-4000-8000-000000000001",
|
| 85 |
title: "Test Package", examTypeId: "IELTS",
|
| 86 |
creatorUserId: user1.id, isPublic: true,
|
| 87 |
});
|
packages/api/src/index.ts
CHANGED
|
@@ -1,14 +1,29 @@
|
|
| 1 |
import { initTRPC, TRPCError } from "@trpc/server";
|
|
|
|
| 2 |
import { logger } from "./logger";
|
| 3 |
import type { Context } from "./context";
|
|
|
|
|
|
|
| 4 |
|
| 5 |
export const t = initTRPC.context<Context>().create();
|
| 6 |
|
| 7 |
export const router = t.router;
|
| 8 |
|
| 9 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
|
| 11 |
-
export const protectedProcedure =
|
| 12 |
if (!ctx.session) {
|
| 13 |
throw new TRPCError({
|
| 14 |
code: "UNAUTHORIZED",
|
|
@@ -24,14 +39,10 @@ export const protectedProcedure = t.procedure.use(({ ctx, next }) => {
|
|
| 24 |
});
|
| 25 |
});
|
| 26 |
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
userId: ctx.session?.user.id,
|
| 34 |
-
durationMs: Date.now() - start,
|
| 35 |
-
});
|
| 36 |
-
});
|
| 37 |
});
|
|
|
|
| 1 |
import { initTRPC, TRPCError } from "@trpc/server";
|
| 2 |
+
import { eq } from "drizzle-orm";
|
| 3 |
import { logger } from "./logger";
|
| 4 |
import type { Context } from "./context";
|
| 5 |
+
import { db } from "@labas/db";
|
| 6 |
+
import { user } from "@labas/db";
|
| 7 |
|
| 8 |
export const t = initTRPC.context<Context>().create();
|
| 9 |
|
| 10 |
export const router = t.router;
|
| 11 |
|
| 12 |
+
// Global logging for all procedures
|
| 13 |
+
const baseProcedure = t.procedure.use(({ ctx, path, type, next }) => {
|
| 14 |
+
const start = Date.now();
|
| 15 |
+
logger.info(`${type.toUpperCase()} ${path}`, { userId: ctx.session?.user.id });
|
| 16 |
+
return next().finally(() => {
|
| 17 |
+
logger.info(`${type.toUpperCase()} ${path} completed`, {
|
| 18 |
+
userId: ctx.session?.user.id,
|
| 19 |
+
durationMs: Date.now() - start,
|
| 20 |
+
});
|
| 21 |
+
});
|
| 22 |
+
});
|
| 23 |
+
|
| 24 |
+
export const publicProcedure = baseProcedure;
|
| 25 |
|
| 26 |
+
export const protectedProcedure = baseProcedure.use(({ ctx, next }) => {
|
| 27 |
if (!ctx.session) {
|
| 28 |
throw new TRPCError({
|
| 29 |
code: "UNAUTHORIZED",
|
|
|
|
| 39 |
});
|
| 40 |
});
|
| 41 |
|
| 42 |
+
export const adminProcedure = protectedProcedure.use(async ({ ctx, next }) => {
|
| 43 |
+
const [u] = await db.select().from(user).where(eq(user.id, ctx.session.user.id)).limit(1);
|
| 44 |
+
if (!u || u.role !== "admin") {
|
| 45 |
+
throw new TRPCError({ code: "FORBIDDEN", message: "Admin access required" });
|
| 46 |
+
}
|
| 47 |
+
return next({ ctx: { ...ctx, adminUser: u } });
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
});
|
packages/api/src/lib/credit.ts
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { eq, sql, and, desc, sum, gte } from "drizzle-orm";
|
| 2 |
+
import { db } from "@labas/db";
|
| 3 |
+
import { userCredit, creditTransaction, user, platformConfig } from "@labas/db";
|
| 4 |
+
import { TRPCError } from "@trpc/server";
|
| 5 |
+
import { env } from "@labas/env/server";
|
| 6 |
+
|
| 7 |
+
const COOLDOWN_DAYS = 7;
|
| 8 |
+
|
| 9 |
+
async function getConfigRaw(key: string): Promise<string | null> {
|
| 10 |
+
const [row] = await db
|
| 11 |
+
.select({ value: platformConfig.value })
|
| 12 |
+
.from(platformConfig)
|
| 13 |
+
.where(eq(platformConfig.key, key))
|
| 14 |
+
.limit(1);
|
| 15 |
+
return row?.value ?? null;
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
export async function getConfig(key: string, envFallback: () => string): Promise<string> {
|
| 19 |
+
const dbVal = await getConfigRaw(key);
|
| 20 |
+
return dbVal ?? envFallback();
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
export async function setConfig(key: string, value: string): Promise<void> {
|
| 24 |
+
await db
|
| 25 |
+
.insert(platformConfig)
|
| 26 |
+
.values({ key, value })
|
| 27 |
+
.onConflictDoUpdate({
|
| 28 |
+
target: [platformConfig.key],
|
| 29 |
+
set: { value },
|
| 30 |
+
});
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
async function isFreeCreditsEnabled(): Promise<boolean> {
|
| 34 |
+
const val = await getConfig("free_credits_enabled", () => String(env.FREE_CREDITS_ENABLED));
|
| 35 |
+
return val === "true";
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
async function getFreeCreditsMaxPool(): Promise<number> {
|
| 39 |
+
const val = await getConfig("free_credits_max_pool", () => String(env.FREE_CREDITS_MAX_POOL));
|
| 40 |
+
return parseInt(val, 10) || env.FREE_CREDITS_MAX_POOL;
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
export async function getUserCredit(userId: string) {
|
| 44 |
+
const [credit] = await db
|
| 45 |
+
.select()
|
| 46 |
+
.from(userCredit)
|
| 47 |
+
.where(eq(userCredit.userId, userId))
|
| 48 |
+
.limit(1);
|
| 49 |
+
|
| 50 |
+
return credit ?? { userId, tokenBalance: 0, lifetimeTokensUsed: 0 };
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
export async function ensureUserCreditRow(userId: string) {
|
| 54 |
+
await db
|
| 55 |
+
.insert(userCredit)
|
| 56 |
+
.values({ userId, tokenBalance: 0 })
|
| 57 |
+
.onConflictDoNothing();
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
export async function deductCredit(
|
| 61 |
+
userId: string,
|
| 62 |
+
tokens: number,
|
| 63 |
+
): Promise<{ newBalance: number }> {
|
| 64 |
+
await ensureUserCreditRow(userId);
|
| 65 |
+
|
| 66 |
+
const [current] = await db
|
| 67 |
+
.select({ balance: userCredit.tokenBalance })
|
| 68 |
+
.from(userCredit)
|
| 69 |
+
.where(eq(userCredit.userId, userId))
|
| 70 |
+
.limit(1);
|
| 71 |
+
|
| 72 |
+
if (!current) {
|
| 73 |
+
throw new TRPCError({ code: "BAD_REQUEST", message: "No credits available" });
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
if (current.balance < tokens) {
|
| 77 |
+
throw new TRPCError({ code: "BAD_REQUEST", message: "Insufficient credits" });
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
const [updated] = await db
|
| 81 |
+
.update(userCredit)
|
| 82 |
+
.set({
|
| 83 |
+
tokenBalance: sql`token_balance - ${tokens}`,
|
| 84 |
+
lifetimeTokensUsed: sql`lifetime_tokens_used + ${tokens}`,
|
| 85 |
+
})
|
| 86 |
+
.where(eq(userCredit.userId, userId))
|
| 87 |
+
.returning();
|
| 88 |
+
|
| 89 |
+
if (!updated) {
|
| 90 |
+
throw new TRPCError({ code: "BAD_REQUEST", message: "Failed to deduct credits" });
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
await db.insert(creditTransaction).values({
|
| 94 |
+
userId,
|
| 95 |
+
amount: -tokens,
|
| 96 |
+
type: "generation_spend",
|
| 97 |
+
tokensUsed: tokens,
|
| 98 |
+
});
|
| 99 |
+
|
| 100 |
+
return { newBalance: updated.tokenBalance };
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
export async function isUserSuspended(userId: string): Promise<boolean> {
|
| 104 |
+
const [u] = await db
|
| 105 |
+
.select({ suspended: user.suspended })
|
| 106 |
+
.from(user)
|
| 107 |
+
.where(eq(user.id, userId))
|
| 108 |
+
.limit(1);
|
| 109 |
+
|
| 110 |
+
return u?.suspended ?? false;
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
export async function grantSignupCredit(
|
| 114 |
+
userId: string,
|
| 115 |
+
amount: number,
|
| 116 |
+
): Promise<void> {
|
| 117 |
+
await ensureUserCreditRow(userId);
|
| 118 |
+
|
| 119 |
+
await db
|
| 120 |
+
.update(userCredit)
|
| 121 |
+
.set({
|
| 122 |
+
tokenBalance: sql`token_balance + ${amount}`,
|
| 123 |
+
})
|
| 124 |
+
.where(eq(userCredit.userId, userId));
|
| 125 |
+
|
| 126 |
+
await db.insert(creditTransaction).values({
|
| 127 |
+
userId,
|
| 128 |
+
amount,
|
| 129 |
+
type: "signup_bonus",
|
| 130 |
+
description: "Welcome credits",
|
| 131 |
+
});
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
+
export async function getPoolUsage() {
|
| 135 |
+
const maxPool = await getFreeCreditsMaxPool();
|
| 136 |
+
const [agg] = await db
|
| 137 |
+
.select({ totalDistributed: sum(creditTransaction.amount) })
|
| 138 |
+
.from(creditTransaction)
|
| 139 |
+
.where(gte(creditTransaction.amount, 0));
|
| 140 |
+
|
| 141 |
+
const totalDistributed = Number(agg?.totalDistributed ?? 0);
|
| 142 |
+
|
| 143 |
+
return {
|
| 144 |
+
totalDistributed,
|
| 145 |
+
maxPool,
|
| 146 |
+
remaining: Math.max(0, maxPool - totalDistributed),
|
| 147 |
+
};
|
| 148 |
+
}
|
| 149 |
+
|
| 150 |
+
export async function getLastRefillAt(userId: string) {
|
| 151 |
+
const [txn] = await db
|
| 152 |
+
.select({ createdAt: creditTransaction.createdAt })
|
| 153 |
+
.from(creditTransaction)
|
| 154 |
+
.where(
|
| 155 |
+
and(
|
| 156 |
+
eq(creditTransaction.userId, userId),
|
| 157 |
+
gte(creditTransaction.amount, 0),
|
| 158 |
+
),
|
| 159 |
+
)
|
| 160 |
+
.orderBy(desc(creditTransaction.createdAt))
|
| 161 |
+
.limit(1);
|
| 162 |
+
|
| 163 |
+
return txn ?? null;
|
| 164 |
+
}
|
| 165 |
+
|
| 166 |
+
function calcCooldownRemaining(lastRefillAt: Date | string | null): number {
|
| 167 |
+
if (!lastRefillAt) return 0;
|
| 168 |
+
const last = new Date(lastRefillAt).getTime();
|
| 169 |
+
const cooldownEnd = last + COOLDOWN_DAYS * 24 * 60 * 60 * 1000;
|
| 170 |
+
const remaining = Math.max(0, Math.ceil((cooldownEnd - Date.now()) / (24 * 60 * 60 * 1000)));
|
| 171 |
+
return remaining;
|
| 172 |
+
}
|
| 173 |
+
|
| 174 |
+
export async function autoRefillIfEligible(
|
| 175 |
+
userId: string,
|
| 176 |
+
): Promise<{ refilled: boolean; message: string; newBalance?: number }> {
|
| 177 |
+
if (!(await isFreeCreditsEnabled())) {
|
| 178 |
+
return { refilled: false, message: "Free credits are currently disabled." };
|
| 179 |
+
}
|
| 180 |
+
|
| 181 |
+
const pool = await getPoolUsage();
|
| 182 |
+
const amount = env.DEFAULT_SIGNUP_CREDIT_TOKENS;
|
| 183 |
+
|
| 184 |
+
if (pool.remaining < amount) {
|
| 185 |
+
return { refilled: false, message: "Free credit pool is exhausted. Contact admin." };
|
| 186 |
+
}
|
| 187 |
+
|
| 188 |
+
const lastRefill = await getLastRefillAt(userId);
|
| 189 |
+
const cooldown = calcCooldownRemaining(lastRefill?.createdAt ?? null);
|
| 190 |
+
|
| 191 |
+
if (cooldown > 0) {
|
| 192 |
+
return { refilled: false, message: `Cooldown active. Try again in ${cooldown} day(s).` };
|
| 193 |
+
}
|
| 194 |
+
|
| 195 |
+
await ensureUserCreditRow(userId);
|
| 196 |
+
|
| 197 |
+
await db
|
| 198 |
+
.update(userCredit)
|
| 199 |
+
.set({
|
| 200 |
+
tokenBalance: sql`token_balance + ${amount}`,
|
| 201 |
+
})
|
| 202 |
+
.where(eq(userCredit.userId, userId));
|
| 203 |
+
|
| 204 |
+
await db.insert(creditTransaction).values({
|
| 205 |
+
userId,
|
| 206 |
+
amount,
|
| 207 |
+
type: "auto_refill",
|
| 208 |
+
description: "Auto-refill from free credit pool",
|
| 209 |
+
});
|
| 210 |
+
|
| 211 |
+
const credit = await getUserCredit(userId);
|
| 212 |
+
|
| 213 |
+
return { refilled: true, message: `Refilled ${amount.toLocaleString()} tokens from free pool.`, newBalance: credit.tokenBalance };
|
| 214 |
+
}
|
packages/api/src/lib/rate-limit.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
| 1 |
import IORedis from "ioredis";
|
| 2 |
import { env } from "@labas/env/server";
|
| 3 |
import { TRPCError } from "@trpc/server";
|
|
|
|
| 4 |
|
| 5 |
let redis: IORedis | null = null;
|
| 6 |
|
|
@@ -55,9 +56,7 @@ export async function checkRateLimit(config: RateLimitConfig): Promise<void> {
|
|
| 55 |
}
|
| 56 |
} catch (err) {
|
| 57 |
if (err instanceof TRPCError) throw err;
|
| 58 |
-
|
| 59 |
-
// eslint-disable-next-line no-console
|
| 60 |
-
console.warn("[RATELIMIT] Redis unavailable, rate limit skipped:", (err as Error).message);
|
| 61 |
}
|
| 62 |
}
|
| 63 |
|
|
|
|
| 1 |
import IORedis from "ioredis";
|
| 2 |
import { env } from "@labas/env/server";
|
| 3 |
import { TRPCError } from "@trpc/server";
|
| 4 |
+
import { logger } from "@labas/api/logger";
|
| 5 |
|
| 6 |
let redis: IORedis | null = null;
|
| 7 |
|
|
|
|
| 56 |
}
|
| 57 |
} catch (err) {
|
| 58 |
if (err instanceof TRPCError) throw err;
|
| 59 |
+
logger.warn("[RATELIMIT] Redis unavailable, rate limit skipped", { error: (err as Error).message });
|
|
|
|
|
|
|
| 60 |
}
|
| 61 |
}
|
| 62 |
|
packages/api/src/logger.ts
CHANGED
|
@@ -1,29 +1,59 @@
|
|
| 1 |
import winston from "winston";
|
| 2 |
|
| 3 |
-
const { combine, timestamp, printf, colorize, errors } = winston.format;
|
| 4 |
|
| 5 |
-
const
|
| 6 |
-
|
|
|
|
|
|
|
|
|
|
| 7 |
if (Object.keys(metadata).length > 0) {
|
| 8 |
-
msg += `
|
| 9 |
}
|
| 10 |
if (stack) {
|
| 11 |
-
msg += `\n
|
| 12 |
}
|
| 13 |
return msg;
|
| 14 |
});
|
| 15 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
export const logger = winston.createLogger({
|
| 17 |
level: process.env.LOG_LEVEL ?? "info",
|
| 18 |
defaultMeta: { service: "labas-api" },
|
| 19 |
-
transports:
|
| 20 |
-
new winston.transports.Console({
|
| 21 |
-
format: combine(
|
| 22 |
-
colorize(),
|
| 23 |
-
timestamp({ format: "YYYY-MM-DD HH:mm:ss" }),
|
| 24 |
-
errors({ stack: true }),
|
| 25 |
-
customFormat,
|
| 26 |
-
),
|
| 27 |
-
}),
|
| 28 |
-
],
|
| 29 |
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
import winston from "winston";
|
| 2 |
|
| 3 |
+
const { combine, timestamp, printf, colorize, errors, json } = winston.format;
|
| 4 |
|
| 5 |
+
const isProduction = process.env.NODE_ENV === "production";
|
| 6 |
+
|
| 7 |
+
const devFormat = printf(({ level, message, timestamp, stack, reqId, ...metadata }) => {
|
| 8 |
+
const prefix = reqId ? `[${reqId}] ` : "";
|
| 9 |
+
let msg = `${timestamp} [${level}]: ${prefix}${message}`;
|
| 10 |
if (Object.keys(metadata).length > 0) {
|
| 11 |
+
msg += ` ${JSON.stringify(metadata)}`;
|
| 12 |
}
|
| 13 |
if (stack) {
|
| 14 |
+
msg += `\n${stack}`;
|
| 15 |
}
|
| 16 |
return msg;
|
| 17 |
});
|
| 18 |
|
| 19 |
+
function createTransports() {
|
| 20 |
+
const transports: winston.transport[] = [];
|
| 21 |
+
|
| 22 |
+
if (isProduction) {
|
| 23 |
+
transports.push(
|
| 24 |
+
new winston.transports.File({
|
| 25 |
+
filename: "logs/labas-error.log",
|
| 26 |
+
level: "error",
|
| 27 |
+
format: combine(timestamp(), errors({ stack: true }), json()),
|
| 28 |
+
maxsize: 10 * 1024 * 1024,
|
| 29 |
+
maxFiles: 14,
|
| 30 |
+
}),
|
| 31 |
+
new winston.transports.File({
|
| 32 |
+
filename: "logs/labas-combined.log",
|
| 33 |
+
format: combine(timestamp(), errors({ stack: true }), json()),
|
| 34 |
+
maxsize: 10 * 1024 * 1024,
|
| 35 |
+
maxFiles: 7,
|
| 36 |
+
}),
|
| 37 |
+
);
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
transports.push(
|
| 41 |
+
new winston.transports.Console({
|
| 42 |
+
format: isProduction
|
| 43 |
+
? combine(timestamp(), errors({ stack: true }), json())
|
| 44 |
+
: combine(colorize(), timestamp({ format: "YYYY-MM-DD HH:mm:ss" }), errors({ stack: true }), devFormat),
|
| 45 |
+
}),
|
| 46 |
+
);
|
| 47 |
+
|
| 48 |
+
return transports;
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
export const logger = winston.createLogger({
|
| 52 |
level: process.env.LOG_LEVEL ?? "info",
|
| 53 |
defaultMeta: { service: "labas-api" },
|
| 54 |
+
transports: createTransports(),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
});
|
| 56 |
+
|
| 57 |
+
export function withRequestId(reqId: string) {
|
| 58 |
+
return logger.child({ reqId });
|
| 59 |
+
}
|
packages/api/src/queue.ts
CHANGED
|
@@ -1,6 +1,11 @@
|
|
| 1 |
import { Queue, Worker, type Job } from "bullmq";
|
| 2 |
import IORedis from "ioredis";
|
| 3 |
import { env } from "@labas/env/server";
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
import {
|
| 5 |
generateQuestionsQuick,
|
| 6 |
generateQuestionsAgentic,
|
|
@@ -365,6 +370,20 @@ async function completeJobWithResult(params: {
|
|
| 365 |
notInArray(generationJob.status, ["cancelled"]),
|
| 366 |
),
|
| 367 |
);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 368 |
}
|
| 369 |
|
| 370 |
export type CancelGenerationJobResult =
|
|
@@ -419,11 +438,11 @@ export async function cancelGenerationJob(
|
|
| 419 |
try {
|
| 420 |
await bullJob.remove();
|
| 421 |
} catch {
|
| 422 |
-
|
| 423 |
}
|
| 424 |
}
|
| 425 |
} catch {
|
| 426 |
-
|
| 427 |
}
|
| 428 |
}
|
| 429 |
|
|
@@ -699,6 +718,7 @@ export const generationWorker = new Worker<FastJobData>(
|
|
| 699 |
try {
|
| 700 |
await runShard(shard);
|
| 701 |
} catch {
|
|
|
|
| 702 |
failedShards.push(shard);
|
| 703 |
}
|
| 704 |
});
|
|
@@ -957,6 +977,7 @@ export const generationQualityWorker = new Worker<QualityJobData>(
|
|
| 957 |
tokensUsed: result.meta.tokensUsed ?? 0,
|
| 958 |
};
|
| 959 |
} catch {
|
|
|
|
| 960 |
const fallbackQuestions = fastQuestions
|
| 961 |
.filter((q) => q.section === split.section)
|
| 962 |
.slice(0, split.count);
|
|
@@ -1125,7 +1146,7 @@ export function decryptInputFromDb(inputJson: unknown): GenerationInput {
|
|
| 1125 |
},
|
| 1126 |
};
|
| 1127 |
} catch {
|
| 1128 |
-
|
| 1129 |
return data as unknown as GenerationInput;
|
| 1130 |
}
|
| 1131 |
}
|
|
|
|
| 1 |
import { Queue, Worker, type Job } from "bullmq";
|
| 2 |
import IORedis from "ioredis";
|
| 3 |
import { env } from "@labas/env/server";
|
| 4 |
+
|
| 5 |
+
async function log(level: "debug" | "warn", message: string, meta?: Record<string, unknown>) {
|
| 6 |
+
const mod = await import("@labas/api/logger");
|
| 7 |
+
(mod.logger as any)[level](message, meta);
|
| 8 |
+
}
|
| 9 |
import {
|
| 10 |
generateQuestionsQuick,
|
| 11 |
generateQuestionsAgentic,
|
|
|
|
| 370 |
notInArray(generationJob.status, ["cancelled"]),
|
| 371 |
),
|
| 372 |
);
|
| 373 |
+
|
| 374 |
+
// Deduct from user credit if using platform generation
|
| 375 |
+
const inputAny = params.input as any;
|
| 376 |
+
if (inputAny._isPlatformGeneration && params.totalTokens > 0) {
|
| 377 |
+
const [jobRow] = await db
|
| 378 |
+
.select({ userId: generationJob.userId })
|
| 379 |
+
.from(generationJob)
|
| 380 |
+
.where(eq(generationJob.id, params.jobId))
|
| 381 |
+
.limit(1);
|
| 382 |
+
if (jobRow) {
|
| 383 |
+
const { deductCredit } = await import("@labas/api/lib/credit");
|
| 384 |
+
await deductCredit(jobRow.userId, params.totalTokens).catch(() => {});
|
| 385 |
+
}
|
| 386 |
+
}
|
| 387 |
}
|
| 388 |
|
| 389 |
export type CancelGenerationJobResult =
|
|
|
|
| 438 |
try {
|
| 439 |
await bullJob.remove();
|
| 440 |
} catch {
|
| 441 |
+
log("debug", "[QUEUE] Could not remove active job.", { jobId });
|
| 442 |
}
|
| 443 |
}
|
| 444 |
} catch {
|
| 445 |
+
log("warn", "[QUEUE] Failed to access queue during cancellation.", { jobId });
|
| 446 |
}
|
| 447 |
}
|
| 448 |
|
|
|
|
| 718 |
try {
|
| 719 |
await runShard(shard);
|
| 720 |
} catch {
|
| 721 |
+
log("warn", "[GENERATION] Fast shard failed.", { jobId, section: shard.section });
|
| 722 |
failedShards.push(shard);
|
| 723 |
}
|
| 724 |
});
|
|
|
|
| 977 |
tokensUsed: result.meta.tokensUsed ?? 0,
|
| 978 |
};
|
| 979 |
} catch {
|
| 980 |
+
log("warn", "[GENERATION] Quality phase failed, using fast questions.", { jobId, section: split.section });
|
| 981 |
const fallbackQuestions = fastQuestions
|
| 982 |
.filter((q) => q.section === split.section)
|
| 983 |
.slice(0, split.count);
|
|
|
|
| 1146 |
},
|
| 1147 |
};
|
| 1148 |
} catch {
|
| 1149 |
+
log("debug", "[QUEUE] API key decryption failed (legacy data).");
|
| 1150 |
return data as unknown as GenerationInput;
|
| 1151 |
}
|
| 1152 |
}
|
packages/api/src/routers/admin.ts
ADDED
|
@@ -0,0 +1,471 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { z } from "zod";
|
| 2 |
+
import { eq, desc, sql, count, ilike, or } from "drizzle-orm";
|
| 3 |
+
import { adminProcedure, protectedProcedure, router } from "../index";
|
| 4 |
+
import * as schema from "@labas/db";
|
| 5 |
+
import { db } from "@labas/db";
|
| 6 |
+
import { paginationSchema, paginateDefaults } from "../lib/pagination";
|
| 7 |
+
import { throwNotFound, throwForbidden, throwBadRequest } from "../lib/errors";
|
| 8 |
+
import { getUserCredit, getLastRefillAt, getPoolUsage, getConfig, setConfig } from "../lib/credit";
|
| 9 |
+
import { env } from "@labas/env/server";
|
| 10 |
+
|
| 11 |
+
function audit(adminUserId: string, action: string, targetUserId: string | null, details?: Record<string, unknown>) {
|
| 12 |
+
return db.insert(schema.adminAuditLog).values({
|
| 13 |
+
adminUserId,
|
| 14 |
+
action,
|
| 15 |
+
targetUserId,
|
| 16 |
+
details,
|
| 17 |
+
});
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
export const adminRouter = router({
|
| 21 |
+
isAdmin: protectedProcedure.query(async ({ ctx }) => {
|
| 22 |
+
const [u] = await db.select({ role: schema.user.role }).from(schema.user).where(eq(schema.user.id, ctx.session.user.id)).limit(1);
|
| 23 |
+
return { isAdmin: u?.role === "admin" };
|
| 24 |
+
}),
|
| 25 |
+
|
| 26 |
+
getMyCredit: protectedProcedure.query(async ({ ctx }) => {
|
| 27 |
+
const credit = await getUserCredit(ctx.session.user.id);
|
| 28 |
+
const lastRefill = await getLastRefillAt(ctx.session.user.id);
|
| 29 |
+
const cooldownMs = 7 * 24 * 60 * 60 * 1000;
|
| 30 |
+
const lastRefillTime = lastRefill?.createdAt ? new Date(lastRefill.createdAt).getTime() : 0;
|
| 31 |
+
const cooldownEnd = lastRefillTime + cooldownMs;
|
| 32 |
+
const cooldownRemaining = Math.max(0, Math.ceil((cooldownEnd - Date.now()) / (24 * 60 * 60 * 1000)));
|
| 33 |
+
|
| 34 |
+
const enabledVal = await getConfig("free_credits_enabled", () => String(env.FREE_CREDITS_ENABLED));
|
| 35 |
+
|
| 36 |
+
return {
|
| 37 |
+
tokenBalance: credit.tokenBalance,
|
| 38 |
+
lifetimeTokensUsed: credit.lifetimeTokensUsed,
|
| 39 |
+
freeCreditsEnabled: enabledVal === "true",
|
| 40 |
+
lastRefillAt: lastRefill?.createdAt ?? null,
|
| 41 |
+
cooldownDays: 7,
|
| 42 |
+
cooldownRemaining,
|
| 43 |
+
};
|
| 44 |
+
}),
|
| 45 |
+
|
| 46 |
+
// ── User Management ───────────────────────────────────────
|
| 47 |
+
|
| 48 |
+
listUsers: adminProcedure
|
| 49 |
+
.input(
|
| 50 |
+
z
|
| 51 |
+
.object({
|
| 52 |
+
search: z.string().optional(),
|
| 53 |
+
...paginationSchema.shape,
|
| 54 |
+
})
|
| 55 |
+
.optional(),
|
| 56 |
+
)
|
| 57 |
+
.query(async ({ input }) => {
|
| 58 |
+
const { limit, offset } = paginateDefaults(input);
|
| 59 |
+
const where = input?.search
|
| 60 |
+
? or(
|
| 61 |
+
ilike(schema.user.name, `%${input.search}%`),
|
| 62 |
+
ilike(schema.user.email, `%${input.search}%`),
|
| 63 |
+
)
|
| 64 |
+
: undefined;
|
| 65 |
+
|
| 66 |
+
const [total] = await db
|
| 67 |
+
.select({ count: count() })
|
| 68 |
+
.from(schema.user)
|
| 69 |
+
.where(where);
|
| 70 |
+
|
| 71 |
+
const users = await db
|
| 72 |
+
.select({
|
| 73 |
+
id: schema.user.id,
|
| 74 |
+
name: schema.user.name,
|
| 75 |
+
email: schema.user.email,
|
| 76 |
+
role: schema.user.role,
|
| 77 |
+
suspended: schema.user.suspended,
|
| 78 |
+
emailVerified: schema.user.emailVerified,
|
| 79 |
+
createdAt: schema.user.createdAt,
|
| 80 |
+
})
|
| 81 |
+
.from(schema.user)
|
| 82 |
+
.where(where)
|
| 83 |
+
.orderBy(desc(schema.user.createdAt))
|
| 84 |
+
.limit(limit)
|
| 85 |
+
.offset(offset);
|
| 86 |
+
|
| 87 |
+
return { users, total: Number(total?.count ?? 0) };
|
| 88 |
+
}),
|
| 89 |
+
|
| 90 |
+
getUserDetail: adminProcedure
|
| 91 |
+
.input(z.object({ userId: z.string() }))
|
| 92 |
+
.query(async ({ input }) => {
|
| 93 |
+
const [u] = await db.select().from(schema.user).where(eq(schema.user.id, input.userId)).limit(1);
|
| 94 |
+
if (!u) throwNotFound("User");
|
| 95 |
+
|
| 96 |
+
const [credit] = await db
|
| 97 |
+
.select()
|
| 98 |
+
.from(schema.userCredit)
|
| 99 |
+
.where(eq(schema.userCredit.userId, input.userId))
|
| 100 |
+
.limit(1);
|
| 101 |
+
|
| 102 |
+
return { user: u, credit: credit ?? null };
|
| 103 |
+
}),
|
| 104 |
+
|
| 105 |
+
suspendUser: adminProcedure
|
| 106 |
+
.input(z.object({ userId: z.string(), suspended: z.boolean() }))
|
| 107 |
+
.mutation(async ({ ctx, input }) => {
|
| 108 |
+
if (input.userId === ctx.session.user.id) throwForbidden("Cannot suspend yourself");
|
| 109 |
+
|
| 110 |
+
const [u] = await db
|
| 111 |
+
.update(schema.user)
|
| 112 |
+
.set({ suspended: input.suspended })
|
| 113 |
+
.where(eq(schema.user.id, input.userId))
|
| 114 |
+
.returning();
|
| 115 |
+
if (!u) throwNotFound("User");
|
| 116 |
+
|
| 117 |
+
await audit(ctx.session.user.id, input.suspended ? "suspend_user" : "unsuspend_user", input.userId);
|
| 118 |
+
return u;
|
| 119 |
+
}),
|
| 120 |
+
|
| 121 |
+
setUserRole: adminProcedure
|
| 122 |
+
.input(z.object({ userId: z.string(), role: z.enum(["user", "admin"]) }))
|
| 123 |
+
.mutation(async ({ ctx, input }) => {
|
| 124 |
+
if (input.userId === ctx.session.user.id) throwForbidden("Cannot change your own role");
|
| 125 |
+
|
| 126 |
+
const [u] = await db
|
| 127 |
+
.update(schema.user)
|
| 128 |
+
.set({ role: input.role })
|
| 129 |
+
.where(eq(schema.user.id, input.userId))
|
| 130 |
+
.returning();
|
| 131 |
+
if (!u) throwNotFound("User");
|
| 132 |
+
|
| 133 |
+
await audit(ctx.session.user.id, "change_role", input.userId, { newRole: input.role });
|
| 134 |
+
return u;
|
| 135 |
+
}),
|
| 136 |
+
|
| 137 |
+
// ── Credit Management ─────────────────────────────────────
|
| 138 |
+
|
| 139 |
+
getCreditBalance: adminProcedure
|
| 140 |
+
.input(z.object({ userId: z.string() }))
|
| 141 |
+
.query(async ({ input }) => {
|
| 142 |
+
const [credit] = await db
|
| 143 |
+
.select()
|
| 144 |
+
.from(schema.userCredit)
|
| 145 |
+
.where(eq(schema.userCredit.userId, input.userId))
|
| 146 |
+
.limit(1);
|
| 147 |
+
return credit ?? { userId: input.userId, tokenBalance: 0, lifetimeTokensUsed: 0 };
|
| 148 |
+
}),
|
| 149 |
+
|
| 150 |
+
adjustCredit: adminProcedure
|
| 151 |
+
.input(
|
| 152 |
+
z.object({
|
| 153 |
+
userId: z.string(),
|
| 154 |
+
amount: z.number().min(-1000000).max(1000000),
|
| 155 |
+
description: z.string().optional(),
|
| 156 |
+
}),
|
| 157 |
+
)
|
| 158 |
+
.mutation(async ({ ctx, input }) => {
|
| 159 |
+
const [user] = await db.select({ id: schema.user.id }).from(schema.user).where(eq(schema.user.id, input.userId)).limit(1);
|
| 160 |
+
if (!user) throwNotFound("User");
|
| 161 |
+
|
| 162 |
+
await db
|
| 163 |
+
.insert(schema.userCredit)
|
| 164 |
+
.values({ userId: input.userId, tokenBalance: 0 })
|
| 165 |
+
.onConflictDoNothing();
|
| 166 |
+
|
| 167 |
+
const [updated] = await db
|
| 168 |
+
.update(schema.userCredit)
|
| 169 |
+
.set({
|
| 170 |
+
tokenBalance: sql`token_balance + ${input.amount}`,
|
| 171 |
+
})
|
| 172 |
+
.where(eq(schema.userCredit.userId, input.userId))
|
| 173 |
+
.returning();
|
| 174 |
+
if (!updated) throwBadRequest("Failed to adjust credit");
|
| 175 |
+
|
| 176 |
+
await db.insert(schema.creditTransaction).values({
|
| 177 |
+
userId: input.userId,
|
| 178 |
+
amount: input.amount,
|
| 179 |
+
type: "admin_adjust",
|
| 180 |
+
description: input.description ?? null,
|
| 181 |
+
});
|
| 182 |
+
|
| 183 |
+
await audit(ctx.session.user.id, "adjust_credit", input.userId, {
|
| 184 |
+
amount: input.amount,
|
| 185 |
+
newBalance: updated.tokenBalance,
|
| 186 |
+
});
|
| 187 |
+
|
| 188 |
+
return { newBalance: updated.tokenBalance };
|
| 189 |
+
}),
|
| 190 |
+
|
| 191 |
+
getCreditHistory: adminProcedure
|
| 192 |
+
.input(z.object({ userId: z.string() }))
|
| 193 |
+
.query(async ({ input }) => {
|
| 194 |
+
const transactions = await db
|
| 195 |
+
.select()
|
| 196 |
+
.from(schema.creditTransaction)
|
| 197 |
+
.where(eq(schema.creditTransaction.userId, input.userId))
|
| 198 |
+
.orderBy(desc(schema.creditTransaction.createdAt))
|
| 199 |
+
.limit(50);
|
| 200 |
+
|
| 201 |
+
return { transactions };
|
| 202 |
+
}),
|
| 203 |
+
|
| 204 |
+
// ── Featured Management ──────────────────────────────────
|
| 205 |
+
|
| 206 |
+
toggleFeaturedPackage: adminProcedure
|
| 207 |
+
.input(z.object({ packageId: z.string().uuid() }))
|
| 208 |
+
.mutation(async ({ ctx, input }) => {
|
| 209 |
+
const [pkg] = await db
|
| 210 |
+
.select({ id: schema.testPackage.id, isFeatured: schema.testPackage.isFeatured })
|
| 211 |
+
.from(schema.testPackage)
|
| 212 |
+
.where(eq(schema.testPackage.id, input.packageId))
|
| 213 |
+
.limit(1);
|
| 214 |
+
if (!pkg) throwNotFound("Package");
|
| 215 |
+
|
| 216 |
+
const newValue = !pkg.isFeatured;
|
| 217 |
+
const [updated] = await db
|
| 218 |
+
.update(schema.testPackage)
|
| 219 |
+
.set({ isFeatured: newValue })
|
| 220 |
+
.where(eq(schema.testPackage.id, input.packageId))
|
| 221 |
+
.returning();
|
| 222 |
+
|
| 223 |
+
await audit(ctx.session.user.id, newValue ? "feature_package" : "unfeature_package", null, {
|
| 224 |
+
packageId: input.packageId,
|
| 225 |
+
});
|
| 226 |
+
return updated;
|
| 227 |
+
}),
|
| 228 |
+
|
| 229 |
+
toggleFeaturedQuestion: adminProcedure
|
| 230 |
+
.input(z.object({ questionId: z.string().uuid() }))
|
| 231 |
+
.mutation(async ({ ctx, input }) => {
|
| 232 |
+
const [q] = await db
|
| 233 |
+
.select({ id: schema.question.id, isFeatured: schema.question.isFeatured })
|
| 234 |
+
.from(schema.question)
|
| 235 |
+
.where(eq(schema.question.id, input.questionId))
|
| 236 |
+
.limit(1);
|
| 237 |
+
if (!q) throwNotFound("Question");
|
| 238 |
+
|
| 239 |
+
const newValue = !q.isFeatured;
|
| 240 |
+
const [updated] = await db
|
| 241 |
+
.update(schema.question)
|
| 242 |
+
.set({ isFeatured: newValue })
|
| 243 |
+
.where(eq(schema.question.id, input.questionId))
|
| 244 |
+
.returning();
|
| 245 |
+
|
| 246 |
+
await audit(ctx.session.user.id, newValue ? "feature_question" : "unfeature_question", null, {
|
| 247 |
+
questionId: input.questionId,
|
| 248 |
+
});
|
| 249 |
+
return updated;
|
| 250 |
+
}),
|
| 251 |
+
|
| 252 |
+
listFeatured: protectedProcedure.query(async () => {
|
| 253 |
+
const packages = await db
|
| 254 |
+
.select()
|
| 255 |
+
.from(schema.testPackage)
|
| 256 |
+
.where(eq(schema.testPackage.isFeatured, true))
|
| 257 |
+
.orderBy(desc(schema.testPackage.updatedAt));
|
| 258 |
+
|
| 259 |
+
const questions = await db
|
| 260 |
+
.select()
|
| 261 |
+
.from(schema.question)
|
| 262 |
+
.where(eq(schema.question.isFeatured, true))
|
| 263 |
+
.orderBy(desc(schema.question.updatedAt));
|
| 264 |
+
|
| 265 |
+
return { packages, questions };
|
| 266 |
+
}),
|
| 267 |
+
|
| 268 |
+
searchContent: adminProcedure
|
| 269 |
+
.input(
|
| 270 |
+
z.object({
|
| 271 |
+
search: z.string(),
|
| 272 |
+
type: z.enum(["packages", "questions"]),
|
| 273 |
+
limit: z.number().min(1).max(100).default(20),
|
| 274 |
+
offset: z.number().min(0).default(0),
|
| 275 |
+
}),
|
| 276 |
+
)
|
| 277 |
+
.query(async ({ input }) => {
|
| 278 |
+
if (input.type === "packages") {
|
| 279 |
+
const where = or(
|
| 280 |
+
ilike(schema.testPackage.title, `%${input.search}%`),
|
| 281 |
+
ilike(schema.testPackage.description ?? sql`''`, `%${input.search}%`),
|
| 282 |
+
);
|
| 283 |
+
|
| 284 |
+
const [total] = await db.select({ count: count() }).from(schema.testPackage).where(where);
|
| 285 |
+
|
| 286 |
+
const items = await db
|
| 287 |
+
.select()
|
| 288 |
+
.from(schema.testPackage)
|
| 289 |
+
.where(where)
|
| 290 |
+
.orderBy(desc(schema.testPackage.updatedAt))
|
| 291 |
+
.limit(input.limit)
|
| 292 |
+
.offset(input.offset);
|
| 293 |
+
|
| 294 |
+
return { items, total: Number(total?.count ?? 0) };
|
| 295 |
+
}
|
| 296 |
+
|
| 297 |
+
const where = or(
|
| 298 |
+
ilike(schema.question.questionText, `%${input.search}%`),
|
| 299 |
+
ilike(schema.question.passageText, `%${input.search}%`),
|
| 300 |
+
);
|
| 301 |
+
|
| 302 |
+
const [total] = await db.select({ count: count() }).from(schema.question).where(where);
|
| 303 |
+
|
| 304 |
+
const items = await db
|
| 305 |
+
.select()
|
| 306 |
+
.from(schema.question)
|
| 307 |
+
.where(where)
|
| 308 |
+
.orderBy(desc(schema.question.updatedAt))
|
| 309 |
+
.limit(input.limit)
|
| 310 |
+
.offset(input.offset);
|
| 311 |
+
|
| 312 |
+
return { items, total: Number(total?.count ?? 0) };
|
| 313 |
+
}),
|
| 314 |
+
|
| 315 |
+
// ── Generation Jobs ──────────────────────────────────────
|
| 316 |
+
|
| 317 |
+
listAllJobs: adminProcedure
|
| 318 |
+
.input(
|
| 319 |
+
z
|
| 320 |
+
.object({
|
| 321 |
+
status: z.string().optional(),
|
| 322 |
+
...paginationSchema.shape,
|
| 323 |
+
})
|
| 324 |
+
.optional(),
|
| 325 |
+
)
|
| 326 |
+
.query(async ({ input }) => {
|
| 327 |
+
const { limit, offset } = paginateDefaults(input);
|
| 328 |
+
const where = input?.status
|
| 329 |
+
? eq(schema.generationJob.status, input.status)
|
| 330 |
+
: undefined;
|
| 331 |
+
|
| 332 |
+
const [total] = await db
|
| 333 |
+
.select({ count: count() })
|
| 334 |
+
.from(schema.generationJob)
|
| 335 |
+
.where(where);
|
| 336 |
+
|
| 337 |
+
const jobs = await db
|
| 338 |
+
.select({
|
| 339 |
+
id: schema.generationJob.id,
|
| 340 |
+
userId: schema.generationJob.userId,
|
| 341 |
+
status: schema.generationJob.status,
|
| 342 |
+
mode: schema.generationJob.mode,
|
| 343 |
+
examTypeId: schema.generationJob.examTypeId,
|
| 344 |
+
sectionTypeId: schema.generationJob.sectionTypeId,
|
| 345 |
+
questionCount: schema.generationJob.questionCount,
|
| 346 |
+
progress: schema.generationJob.progress,
|
| 347 |
+
tokensUsed: schema.generationJob.tokensUsed,
|
| 348 |
+
errorMessage: schema.generationJob.errorMessage,
|
| 349 |
+
createdAt: schema.generationJob.createdAt,
|
| 350 |
+
completedAt: schema.generationJob.completedAt,
|
| 351 |
+
})
|
| 352 |
+
.from(schema.generationJob)
|
| 353 |
+
.where(where)
|
| 354 |
+
.orderBy(desc(schema.generationJob.createdAt))
|
| 355 |
+
.limit(limit)
|
| 356 |
+
.offset(offset);
|
| 357 |
+
|
| 358 |
+
return { jobs, total: Number(total?.count ?? 0) };
|
| 359 |
+
}),
|
| 360 |
+
|
| 361 |
+
cancelAnyJob: adminProcedure
|
| 362 |
+
.input(z.object({ jobId: z.string().uuid() }))
|
| 363 |
+
.mutation(async ({ ctx, input }) => {
|
| 364 |
+
const [job] = await db
|
| 365 |
+
.select()
|
| 366 |
+
.from(schema.generationJob)
|
| 367 |
+
.where(eq(schema.generationJob.id, input.jobId))
|
| 368 |
+
.limit(1);
|
| 369 |
+
if (!job) throwNotFound("Job");
|
| 370 |
+
|
| 371 |
+
if (job.status !== "pending" && job.status !== "running") {
|
| 372 |
+
throwBadRequest("Only pending or running jobs can be cancelled");
|
| 373 |
+
}
|
| 374 |
+
|
| 375 |
+
const [updated] = await db
|
| 376 |
+
.update(schema.generationJob)
|
| 377 |
+
.set({ status: "cancelled", completedAt: new Date() })
|
| 378 |
+
.where(eq(schema.generationJob.id, input.jobId))
|
| 379 |
+
.returning();
|
| 380 |
+
|
| 381 |
+
await audit(ctx.session.user.id, "cancel_job", job.userId, { jobId: input.jobId });
|
| 382 |
+
return updated;
|
| 383 |
+
}),
|
| 384 |
+
|
| 385 |
+
// ── Content Moderation ───────────────────────────────────
|
| 386 |
+
|
| 387 |
+
listLatestQuestions: adminProcedure
|
| 388 |
+
.input(
|
| 389 |
+
z
|
| 390 |
+
.object({
|
| 391 |
+
...paginationSchema.shape,
|
| 392 |
+
})
|
| 393 |
+
.optional(),
|
| 394 |
+
)
|
| 395 |
+
.query(async ({ input }) => {
|
| 396 |
+
const { limit, offset } = paginateDefaults(input);
|
| 397 |
+
|
| 398 |
+
const questions = await db
|
| 399 |
+
.select()
|
| 400 |
+
.from(schema.question)
|
| 401 |
+
.orderBy(desc(schema.question.createdAt))
|
| 402 |
+
.limit(limit)
|
| 403 |
+
.offset(offset);
|
| 404 |
+
|
| 405 |
+
return { questions };
|
| 406 |
+
}),
|
| 407 |
+
|
| 408 |
+
togglePublicAny: adminProcedure
|
| 409 |
+
.input(z.object({ questionId: z.string().uuid() }))
|
| 410 |
+
.mutation(async ({ ctx, input }) => {
|
| 411 |
+
const [q] = await db
|
| 412 |
+
.select({ id: schema.question.id, isPublic: schema.question.isPublic, creatorUserId: schema.question.creatorUserId })
|
| 413 |
+
.from(schema.question)
|
| 414 |
+
.where(eq(schema.question.id, input.questionId))
|
| 415 |
+
.limit(1);
|
| 416 |
+
if (!q) throwNotFound("Question");
|
| 417 |
+
|
| 418 |
+
const newValue = !q.isPublic;
|
| 419 |
+
const [updated] = await db
|
| 420 |
+
.update(schema.question)
|
| 421 |
+
.set({ isPublic: newValue })
|
| 422 |
+
.where(eq(schema.question.id, input.questionId))
|
| 423 |
+
.returning();
|
| 424 |
+
|
| 425 |
+
await audit(ctx.session.user.id, newValue ? "publish_question" : "unpublish_question", q.creatorUserId, {
|
| 426 |
+
questionId: input.questionId,
|
| 427 |
+
});
|
| 428 |
+
return updated;
|
| 429 |
+
}),
|
| 430 |
+
|
| 431 |
+
// ── Dashboard Stats ──────────────────────────────────────
|
| 432 |
+
|
| 433 |
+
dashboardStats: adminProcedure.query(async () => {
|
| 434 |
+
const [userCount] = await db.select({ count: count() }).from(schema.user);
|
| 435 |
+
const [questionCount] = await db.select({ count: count() }).from(schema.question);
|
| 436 |
+
const [packageCount] = await db.select({ count: count() }).from(schema.testPackage);
|
| 437 |
+
const [jobCount] = await db.select({ count: count() }).from(schema.generationJob);
|
| 438 |
+
const pool = await getPoolUsage();
|
| 439 |
+
const enabledVal = await getConfig("free_credits_enabled", () => String(env.FREE_CREDITS_ENABLED));
|
| 440 |
+
|
| 441 |
+
return {
|
| 442 |
+
totalUsers: Number(userCount?.count ?? 0),
|
| 443 |
+
totalQuestions: Number(questionCount?.count ?? 0),
|
| 444 |
+
totalPackages: Number(packageCount?.count ?? 0),
|
| 445 |
+
totalJobs: Number(jobCount?.count ?? 0),
|
| 446 |
+
poolUsed: pool.totalDistributed,
|
| 447 |
+
poolMax: pool.maxPool,
|
| 448 |
+
poolRemaining: pool.remaining,
|
| 449 |
+
freeCreditsEnabled: enabledVal === "true",
|
| 450 |
+
};
|
| 451 |
+
}),
|
| 452 |
+
|
| 453 |
+
getPlatformConfig: adminProcedure.query(async () => {
|
| 454 |
+
const [enabled, maxPool] = await Promise.all([
|
| 455 |
+
getConfig("free_credits_enabled", () => String(env.FREE_CREDITS_ENABLED)),
|
| 456 |
+
getConfig("free_credits_max_pool", () => String(env.FREE_CREDITS_MAX_POOL)),
|
| 457 |
+
]);
|
| 458 |
+
return {
|
| 459 |
+
freeCreditsEnabled: enabled === "true",
|
| 460 |
+
freeCreditsMaxPool: parseInt(maxPool, 10) || env.FREE_CREDITS_MAX_POOL,
|
| 461 |
+
};
|
| 462 |
+
}),
|
| 463 |
+
|
| 464 |
+
setPlatformConfig: adminProcedure
|
| 465 |
+
.input(z.object({ key: z.string(), value: z.string() }))
|
| 466 |
+
.mutation(async ({ ctx, input }) => {
|
| 467 |
+
await setConfig(input.key, input.value);
|
| 468 |
+
await audit(ctx.session.user.id, "set_config", null, { key: input.key, value: input.value });
|
| 469 |
+
return { ok: true };
|
| 470 |
+
}),
|
| 471 |
+
});
|
packages/api/src/routers/ai.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
| 1 |
import { z } from "zod";
|
| 2 |
import { eq, desc, and, gte, sql } from "drizzle-orm";
|
| 3 |
import { router, protectedProcedure } from "../index";
|
| 4 |
-
import { generationInputSchema } from "@labas/ai/schemas";
|
| 5 |
import { cancelGenerationJob, enqueueGeneration } from "../queue";
|
| 6 |
import { db } from "@labas/db";
|
| 7 |
import { question, generationJob } from "@labas/db";
|
|
@@ -9,9 +9,15 @@ import { paginationSchema, paginateDefaults } from "../lib/pagination";
|
|
| 9 |
import { throwNotFound, throwForbidden, throwBadRequest } from "../lib/errors";
|
| 10 |
import { checkDailyBudget } from "../lib/rate-limit";
|
| 11 |
import { decryptApiKey } from "../lib/encryption";
|
|
|
|
|
|
|
| 12 |
|
| 13 |
const DAILY_TOKEN_BUDGET = 500_000;
|
| 14 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
function sanitizeJobForResponse(row: typeof generationJob.$inferSelect) {
|
| 16 |
const { inputJson, ...safe } = row;
|
| 17 |
return safe;
|
|
@@ -19,13 +25,47 @@ function sanitizeJobForResponse(row: typeof generationJob.$inferSelect) {
|
|
| 19 |
|
| 20 |
export const aiRouter = router({
|
| 21 |
generate: protectedProcedure
|
| 22 |
-
.input(
|
| 23 |
.mutation(async ({ ctx, input }) => {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
const withinBudget = await checkDailyBudget(ctx.session.user.id, DAILY_TOKEN_BUDGET);
|
| 25 |
if (!withinBudget) {
|
| 26 |
throwBadRequest(`Daily token budget (${DAILY_TOKEN_BUDGET.toLocaleString()}) exceeded. Try again tomorrow.`);
|
| 27 |
}
|
| 28 |
-
const jobId = await enqueueGeneration(ctx.session.user.id,
|
| 29 |
return { jobId };
|
| 30 |
}),
|
| 31 |
|
|
|
|
| 1 |
import { z } from "zod";
|
| 2 |
import { eq, desc, and, gte, sql } from "drizzle-orm";
|
| 3 |
import { router, protectedProcedure } from "../index";
|
| 4 |
+
import { type GenerationInput, generationInputSchema } from "@labas/ai/schemas";
|
| 5 |
import { cancelGenerationJob, enqueueGeneration } from "../queue";
|
| 6 |
import { db } from "@labas/db";
|
| 7 |
import { question, generationJob } from "@labas/db";
|
|
|
|
| 9 |
import { throwNotFound, throwForbidden, throwBadRequest } from "../lib/errors";
|
| 10 |
import { checkDailyBudget } from "../lib/rate-limit";
|
| 11 |
import { decryptApiKey } from "../lib/encryption";
|
| 12 |
+
import { isUserSuspended, getUserCredit, autoRefillIfEligible } from "../lib/credit";
|
| 13 |
+
import { env } from "@labas/env/server";
|
| 14 |
|
| 15 |
const DAILY_TOKEN_BUDGET = 500_000;
|
| 16 |
|
| 17 |
+
const generateInputSchema = generationInputSchema.extend({
|
| 18 |
+
apiKeyConfig: generationInputSchema.shape.apiKeyConfig.optional(),
|
| 19 |
+
});
|
| 20 |
+
|
| 21 |
function sanitizeJobForResponse(row: typeof generationJob.$inferSelect) {
|
| 22 |
const { inputJson, ...safe } = row;
|
| 23 |
return safe;
|
|
|
|
| 25 |
|
| 26 |
export const aiRouter = router({
|
| 27 |
generate: protectedProcedure
|
| 28 |
+
.input(generateInputSchema)
|
| 29 |
.mutation(async ({ ctx, input }) => {
|
| 30 |
+
const suspended = await isUserSuspended(ctx.session.user.id);
|
| 31 |
+
if (suspended) throwForbidden("Account is suspended");
|
| 32 |
+
|
| 33 |
+
const hasApiKey = !!input.apiKeyConfig?.apiKey;
|
| 34 |
+
let resolvedInput: GenerationInput;
|
| 35 |
+
|
| 36 |
+
if (!hasApiKey) {
|
| 37 |
+
if (!env.FREE_CREDITS_ENABLED) {
|
| 38 |
+
throwBadRequest("Free credits are currently disabled. Use BYOK or contact admin.");
|
| 39 |
+
}
|
| 40 |
+
if (!env.PLATFORM_AI_API_KEY) {
|
| 41 |
+
throwBadRequest("Platform AI is not configured. Add your own API key in Settings.");
|
| 42 |
+
}
|
| 43 |
+
const credit = await getUserCredit(ctx.session.user.id);
|
| 44 |
+
if (credit.tokenBalance <= 0) {
|
| 45 |
+
const refill = await autoRefillIfEligible(ctx.session.user.id);
|
| 46 |
+
if (!refill.refilled) {
|
| 47 |
+
throwBadRequest(refill.message);
|
| 48 |
+
}
|
| 49 |
+
}
|
| 50 |
+
resolvedInput = {
|
| 51 |
+
...input,
|
| 52 |
+
apiKeyConfig: {
|
| 53 |
+
baseUrl: env.PLATFORM_AI_BASE_URL || "https://api.openai.com/v1",
|
| 54 |
+
apiKey: env.PLATFORM_AI_API_KEY,
|
| 55 |
+
model: env.PLATFORM_AI_MODEL || "gpt-4o-mini",
|
| 56 |
+
maxTokens: 4096,
|
| 57 |
+
},
|
| 58 |
+
_isPlatformGeneration: true,
|
| 59 |
+
} as GenerationInput;
|
| 60 |
+
} else {
|
| 61 |
+
resolvedInput = input as GenerationInput;
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
const withinBudget = await checkDailyBudget(ctx.session.user.id, DAILY_TOKEN_BUDGET);
|
| 65 |
if (!withinBudget) {
|
| 66 |
throwBadRequest(`Daily token budget (${DAILY_TOKEN_BUDGET.toLocaleString()}) exceeded. Try again tomorrow.`);
|
| 67 |
}
|
| 68 |
+
const jobId = await enqueueGeneration(ctx.session.user.id, resolvedInput);
|
| 69 |
return { jobId };
|
| 70 |
}),
|
| 71 |
|
packages/api/src/routers/index.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
| 1 |
import { protectedProcedure, publicProcedure, router } from "../index";
|
|
|
|
| 2 |
import { aiRouter } from "./ai";
|
| 3 |
import { attemptRouter } from "./attempt";
|
| 4 |
import { comboRouter } from "./combo";
|
|
@@ -22,6 +23,7 @@ export const appRouter = router({
|
|
| 22 |
user: ctx.session.user,
|
| 23 |
};
|
| 24 |
}),
|
|
|
|
| 25 |
ai: aiRouter,
|
| 26 |
attempt: attemptRouter,
|
| 27 |
combo: comboRouter,
|
|
|
|
| 1 |
import { protectedProcedure, publicProcedure, router } from "../index";
|
| 2 |
+
import { adminRouter } from "./admin";
|
| 3 |
import { aiRouter } from "./ai";
|
| 4 |
import { attemptRouter } from "./attempt";
|
| 5 |
import { comboRouter } from "./combo";
|
|
|
|
| 23 |
user: ctx.session.user,
|
| 24 |
};
|
| 25 |
}),
|
| 26 |
+
admin: adminRouter,
|
| 27 |
ai: aiRouter,
|
| 28 |
attempt: attemptRouter,
|
| 29 |
combo: comboRouter,
|
packages/db/src/schema/admin.ts
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { relations } from "drizzle-orm";
|
| 2 |
+
import { pgTable, text, integer, timestamp, jsonb, uuid, index } from "drizzle-orm/pg-core";
|
| 3 |
+
import { user } from "./auth";
|
| 4 |
+
|
| 5 |
+
export const userCredit = pgTable(
|
| 6 |
+
"user_credit",
|
| 7 |
+
{
|
| 8 |
+
userId: text("user_id")
|
| 9 |
+
.primaryKey()
|
| 10 |
+
.references(() => user.id, { onDelete: "cascade" }),
|
| 11 |
+
tokenBalance: integer("token_balance").default(0).notNull(),
|
| 12 |
+
lifetimeTokensUsed: integer("lifetime_tokens_used").default(0).notNull(),
|
| 13 |
+
createdAt: timestamp("created_at").defaultNow().notNull(),
|
| 14 |
+
updatedAt: timestamp("updated_at")
|
| 15 |
+
.defaultNow()
|
| 16 |
+
.$onUpdate(() => new Date())
|
| 17 |
+
.notNull(),
|
| 18 |
+
},
|
| 19 |
+
(table) => [index("userCredit_userId_idx").on(table.userId)],
|
| 20 |
+
);
|
| 21 |
+
|
| 22 |
+
export const creditTransaction = pgTable(
|
| 23 |
+
"credit_transaction",
|
| 24 |
+
{
|
| 25 |
+
id: uuid("id").defaultRandom().primaryKey(),
|
| 26 |
+
userId: text("user_id")
|
| 27 |
+
.notNull()
|
| 28 |
+
.references(() => user.id, { onDelete: "cascade" }),
|
| 29 |
+
amount: integer("amount").notNull(),
|
| 30 |
+
type: text("type").notNull(),
|
| 31 |
+
description: text("description"),
|
| 32 |
+
tokensUsed: integer("tokens_used"),
|
| 33 |
+
createdAt: timestamp("created_at").defaultNow().notNull(),
|
| 34 |
+
},
|
| 35 |
+
(table) => [index("creditTransaction_userId_idx").on(table.userId)],
|
| 36 |
+
);
|
| 37 |
+
|
| 38 |
+
export const adminAuditLog = pgTable(
|
| 39 |
+
"admin_audit_log",
|
| 40 |
+
{
|
| 41 |
+
id: uuid("id").defaultRandom().primaryKey(),
|
| 42 |
+
adminUserId: text("admin_user_id")
|
| 43 |
+
.notNull()
|
| 44 |
+
.references(() => user.id, { onDelete: "cascade" }),
|
| 45 |
+
action: text("action").notNull(),
|
| 46 |
+
targetUserId: text("target_user_id").references(() => user.id, {
|
| 47 |
+
onDelete: "set null",
|
| 48 |
+
}),
|
| 49 |
+
details: jsonb("details"),
|
| 50 |
+
createdAt: timestamp("created_at").defaultNow().notNull(),
|
| 51 |
+
},
|
| 52 |
+
(table) => [
|
| 53 |
+
index("adminAuditLog_adminUserId_idx").on(table.adminUserId),
|
| 54 |
+
index("adminAuditLog_action_idx").on(table.action),
|
| 55 |
+
],
|
| 56 |
+
);
|
| 57 |
+
|
| 58 |
+
export const userCreditRelations = relations(userCredit, ({ one }) => ({
|
| 59 |
+
user: one(user, {
|
| 60 |
+
fields: [userCredit.userId],
|
| 61 |
+
references: [user.id],
|
| 62 |
+
}),
|
| 63 |
+
}));
|
| 64 |
+
|
| 65 |
+
export const creditTransactionRelations = relations(creditTransaction, ({ one }) => ({
|
| 66 |
+
user: one(user, {
|
| 67 |
+
fields: [creditTransaction.userId],
|
| 68 |
+
references: [user.id],
|
| 69 |
+
}),
|
| 70 |
+
}));
|
| 71 |
+
|
| 72 |
+
export const adminAuditLogRelations = relations(adminAuditLog, ({ one }) => ({
|
| 73 |
+
admin: one(user, {
|
| 74 |
+
fields: [adminAuditLog.adminUserId],
|
| 75 |
+
references: [user.id],
|
| 76 |
+
relationName: "adminAuditLog_adminUser",
|
| 77 |
+
}),
|
| 78 |
+
target: one(user, {
|
| 79 |
+
fields: [adminAuditLog.targetUserId],
|
| 80 |
+
references: [user.id],
|
| 81 |
+
relationName: "adminAuditLog_targetUser",
|
| 82 |
+
}),
|
| 83 |
+
}));
|
| 84 |
+
|
| 85 |
+
export const platformConfig = pgTable("platform_config", {
|
| 86 |
+
key: text("key").primaryKey(),
|
| 87 |
+
value: text("value").notNull(),
|
| 88 |
+
updatedAt: timestamp("updated_at")
|
| 89 |
+
.defaultNow()
|
| 90 |
+
.$onUpdate(() => new Date())
|
| 91 |
+
.notNull(),
|
| 92 |
+
});
|
packages/db/src/schema/app.ts
CHANGED
|
@@ -61,6 +61,7 @@ export const question = pgTable(
|
|
| 61 |
.notNull()
|
| 62 |
.references(() => user.id, { onDelete: "cascade" }),
|
| 63 |
isPublic: boolean("is_public").default(false).notNull(),
|
|
|
|
| 64 |
usageCount: integer("usage_count").default(0).notNull(),
|
| 65 |
avgRating: integer("avg_rating"),
|
| 66 |
createdAt: timestamp("created_at").defaultNow().notNull(),
|
|
|
|
| 61 |
.notNull()
|
| 62 |
.references(() => user.id, { onDelete: "cascade" }),
|
| 63 |
isPublic: boolean("is_public").default(false).notNull(),
|
| 64 |
+
isFeatured: boolean("is_featured").default(false).notNull(),
|
| 65 |
usageCount: integer("usage_count").default(0).notNull(),
|
| 66 |
avgRating: integer("avg_rating"),
|
| 67 |
createdAt: timestamp("created_at").defaultNow().notNull(),
|
packages/db/src/schema/auth.ts
CHANGED
|
@@ -7,6 +7,8 @@ export const user = pgTable("user", {
|
|
| 7 |
email: text("email").notNull().unique(),
|
| 8 |
emailVerified: boolean("email_verified").default(false).notNull(),
|
| 9 |
image: text("image"),
|
|
|
|
|
|
|
| 10 |
createdAt: timestamp("created_at").defaultNow().notNull(),
|
| 11 |
updatedAt: timestamp("updated_at")
|
| 12 |
.defaultNow()
|
|
|
|
| 7 |
email: text("email").notNull().unique(),
|
| 8 |
emailVerified: boolean("email_verified").default(false).notNull(),
|
| 9 |
image: text("image"),
|
| 10 |
+
role: text("role").default("user").notNull(),
|
| 11 |
+
suspended: boolean("suspended").default(false).notNull(),
|
| 12 |
createdAt: timestamp("created_at").defaultNow().notNull(),
|
| 13 |
updatedAt: timestamp("updated_at")
|
| 14 |
.defaultNow()
|
packages/db/src/schema/index.ts
CHANGED
|
@@ -1,2 +1,3 @@
|
|
| 1 |
export * from "./auth";
|
| 2 |
export * from "./app";
|
|
|
|
|
|
| 1 |
export * from "./auth";
|
| 2 |
export * from "./app";
|
| 3 |
+
export * from "./admin";
|
packages/env/src/server.ts
CHANGED
|
@@ -16,6 +16,12 @@ export const env = createEnv({
|
|
| 16 |
SMTP_USER: z.string().min(1),
|
| 17 |
SMTP_PASS: z.string().min(1),
|
| 18 |
SMTP_FROM: z.string().min(1),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
},
|
| 20 |
runtimeEnv: process.env,
|
| 21 |
emptyStringAsUndefined: true,
|
|
|
|
| 16 |
SMTP_USER: z.string().min(1),
|
| 17 |
SMTP_PASS: z.string().min(1),
|
| 18 |
SMTP_FROM: z.string().min(1),
|
| 19 |
+
PLATFORM_AI_API_KEY: z.string().optional(),
|
| 20 |
+
PLATFORM_AI_BASE_URL: z.string().optional(),
|
| 21 |
+
PLATFORM_AI_MODEL: z.string().optional(),
|
| 22 |
+
FREE_CREDITS_ENABLED: z.coerce.boolean().default(false),
|
| 23 |
+
FREE_CREDITS_MAX_POOL: z.coerce.number().positive().default(1_000_000),
|
| 24 |
+
DEFAULT_SIGNUP_CREDIT_TOKENS: z.coerce.number().positive().default(50_000),
|
| 25 |
},
|
| 26 |
runtimeEnv: process.env,
|
| 27 |
emptyStringAsUndefined: true,
|
packages/ui/src/styles/globals.css
CHANGED
|
@@ -17,7 +17,11 @@
|
|
| 17 |
--warm-cream: #faf9f7;
|
| 18 |
|
| 19 |
/* Swatch Palette */
|
|
|
|
|
|
|
| 20 |
--matcha-300: #84e7a5;
|
|
|
|
|
|
|
| 21 |
--matcha-600: #078a52;
|
| 22 |
--matcha-700: #047a3f;
|
| 23 |
--matcha-800: #02492a;
|
|
@@ -240,7 +244,11 @@
|
|
| 240 |
--color-tertiary-fixed-dim: var(--tertiary-fixed-dim);
|
| 241 |
|
| 242 |
/* Swatch colors */
|
|
|
|
|
|
|
| 243 |
--color-matcha-300: var(--matcha-300);
|
|
|
|
|
|
|
| 244 |
--color-matcha-600: var(--matcha-600);
|
| 245 |
--color-matcha-800: var(--matcha-800);
|
| 246 |
--color-slushie-500: var(--slushie-500);
|
|
|
|
| 17 |
--warm-cream: #faf9f7;
|
| 18 |
|
| 19 |
/* Swatch Palette */
|
| 20 |
+
--matcha-50: #f2fcf6;
|
| 21 |
+
--matcha-100: #e3f9ec;
|
| 22 |
--matcha-300: #84e7a5;
|
| 23 |
+
--matcha-400: #5ac889;
|
| 24 |
+
--matcha-500: #31a96e;
|
| 25 |
--matcha-600: #078a52;
|
| 26 |
--matcha-700: #047a3f;
|
| 27 |
--matcha-800: #02492a;
|
|
|
|
| 244 |
--color-tertiary-fixed-dim: var(--tertiary-fixed-dim);
|
| 245 |
|
| 246 |
/* Swatch colors */
|
| 247 |
+
--color-matcha-50: var(--matcha-50);
|
| 248 |
+
--color-matcha-100: var(--matcha-100);
|
| 249 |
--color-matcha-300: var(--matcha-300);
|
| 250 |
+
--color-matcha-400: var(--matcha-400);
|
| 251 |
+
--color-matcha-500: var(--matcha-500);
|
| 252 |
--color-matcha-600: var(--matcha-600);
|
| 253 |
--color-matcha-800: var(--matcha-800);
|
| 254 |
--color-slushie-500: var(--slushie-500);
|