rogasper commited on
Commit
bede2fc
·
1 Parent(s): f3abb72

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 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(logger());
16
- app.use(
17
- "/*",
18
- async (c, next) => {
19
- await next();
20
- c.res.headers.set("X-Content-Type-Options", "nosniff");
21
- c.res.headers.set("X-Frame-Options", "DENY");
22
- c.res.headers.set("Referrer-Policy", "strict-origin-when-cross-origin");
23
- c.res.headers.set("X-XSS-Protection", "0");
24
- c.res.headers.set("Permissions-Policy", "camera=(), microphone=(), geolocation=()");
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
- baseUrl: selectedConfig.baseUrl,
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(--pure-white)] border-2 border-[var(--oat-border)]">
248
- <label className="text-sm font-medium text-[var(--clay-black)] mb-2 block">
249
- Provider / API Key
250
- </label>
251
- <div className="flex gap-3">
252
- <Select value={selectedKeyId} onValueChange={(v) => v && setSelectedKeyId(v)}>
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 featuredPackages = useQuery(
40
- trpc.package.list.queryOptions({ isPublic: true, limit: 3 }),
41
  );
42
 
43
  const stats = overview.data;
44
  const attempts = recentAttempts.data?.attempts ?? [];
45
- const featured = featuredPackages.data?.packages ?? [];
 
 
46
 
47
  const isNewUser = !stats || (stats.completedAttempts === 0 && stats.totalQuestionsAnswered === 0);
48
 
@@ -199,19 +201,22 @@ function HomeComponent() {
199
  </Link>
200
  </div>
201
 
202
- {/* Featured Packages */}
203
- {featured.length > 0 && (
204
  <section className="mb-10">
205
  <div className="flex items-center justify-between mb-4">
206
- <h2 className="text-xl font-headline font-bold text-[var(--clay-black)]">
207
- Paket Unggulan
208
- </h2>
 
 
 
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
- {featured.map((pkg) => (
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: "00000000-0000-0000-0000-000000000001",
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
- export const publicProcedure = t.procedure;
 
 
 
 
 
 
 
 
 
 
 
 
10
 
11
- export const protectedProcedure = t.procedure.use(({ ctx, next }) => {
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
- // Logging middleware (available to use per-router)
28
- export const loggedProcedure = t.procedure.use(({ ctx, path, type, next }) => {
29
- const start = Date.now();
30
- logger.info(`${type.toUpperCase()} ${path}`, { userId: ctx.session?.user.id });
31
- return next().finally(() => {
32
- logger.info(`${type.toUpperCase()} ${path} completed`, {
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
- // If Redis is down, fail open (log warning, don't block traffic)
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 customFormat = printf(({ level, message, timestamp, stack, ...metadata }) => {
6
- let msg = `${timestamp} [${level}]: ${message}`;
 
 
 
7
  if (Object.keys(metadata).length > 0) {
8
- msg += ` \n ${JSON.stringify(metadata, null, 2)}`;
9
  }
10
  if (stack) {
11
- msg += `\n ${stack}`;
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
- // Likely active; worker exits cooperatively.
423
  }
424
  }
425
  } catch {
426
- // Ignore queue hiccups.
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
- // If decryption fails, key was likely stored unencrypted (legacy data)
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(generationInputSchema)
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, input);
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);