From bb7cd46a4f492660d965ef5887cd7fa29f3af76d Mon Sep 17 00:00:00 2001 From: Nevo David Date: Thu, 30 Apr 2026 17:24:01 +0700 Subject: [PATCH] feat: errors --- apps/backend/src/api/api.module.ts | 2 + .../src/api/routes/admin.controller.ts | 47 ++ .../app/(app)/(site)/admin/errors/page.tsx | 17 + .../admin/admin-errors.component.tsx | 411 ++++++++++++++++++ .../src/components/layout/impersonate.tsx | 16 + .../src/database/prisma/database.module.ts | 4 + .../prisma/errors/errors.repository.ts | 143 ++++++ .../database/prisma/errors/errors.service.ts | 21 + 8 files changed, 661 insertions(+) create mode 100644 apps/backend/src/api/routes/admin.controller.ts create mode 100644 apps/frontend/src/app/(app)/(site)/admin/errors/page.tsx create mode 100644 apps/frontend/src/components/admin/admin-errors.component.tsx create mode 100644 libraries/nestjs-libraries/src/database/prisma/errors/errors.repository.ts create mode 100644 libraries/nestjs-libraries/src/database/prisma/errors/errors.service.ts diff --git a/apps/backend/src/api/api.module.ts b/apps/backend/src/api/api.module.ts index 0c3b5acc..40bc2a12 100644 --- a/apps/backend/src/api/api.module.ts +++ b/apps/backend/src/api/api.module.ts @@ -37,6 +37,7 @@ import { OAuthAppController } from '@gitroom/backend/api/routes/oauth-app.contro import { ApprovedAppsController } from '@gitroom/backend/api/routes/approved-apps.controller'; import { OAuthController, OAuthAuthorizedController } from '@gitroom/backend/api/routes/oauth.controller'; import { AnnouncementsController } from '@gitroom/backend/api/routes/announcements.controller'; +import { AdminController } from '@gitroom/backend/api/routes/admin.controller'; import { AuthProviderManager } from '@gitroom/backend/services/auth/providers/providers.manager'; import { GithubProvider } from '@gitroom/backend/services/auth/providers/github.provider'; import { GoogleProvider } from '@gitroom/backend/services/auth/providers/google.provider'; @@ -63,6 +64,7 @@ const authenticatedController = [ ApprovedAppsController, OAuthAuthorizedController, AnnouncementsController, + AdminController, ]; @Module({ imports: [UploadModule], diff --git a/apps/backend/src/api/routes/admin.controller.ts b/apps/backend/src/api/routes/admin.controller.ts new file mode 100644 index 00000000..a0673597 --- /dev/null +++ b/apps/backend/src/api/routes/admin.controller.ts @@ -0,0 +1,47 @@ +import { + Controller, + Get, + HttpException, + Query, +} from '@nestjs/common'; +import { GetUserFromRequest } from '@gitroom/nestjs-libraries/user/user.from.request'; +import { User } from '@prisma/client'; +import { ApiTags } from '@nestjs/swagger'; +import { ErrorsService } from '@gitroom/nestjs-libraries/database/prisma/errors/errors.service'; + +@ApiTags('Admin') +@Controller('/admin') +export class AdminController { + constructor(private _errorsService: ErrorsService) {} + + private assertSuperAdmin(user: User) { + if (!user?.isSuperAdmin) { + throw new HttpException('Unauthorized', 400); + } + } + + @Get('/errors') + async listErrors( + @GetUserFromRequest() user: User, + @Query('page') page?: string, + @Query('limit') limit?: string, + @Query('platform') platform?: string, + @Query('email') email?: string, + @Query('unknownFirst') unknownFirst?: string + ) { + this.assertSuperAdmin(user); + return this._errorsService.listErrors({ + page: page ? parseInt(page, 10) : 0, + limit: limit ? parseInt(limit, 10) : 20, + platform: platform || undefined, + email: email || undefined, + unknownFirst: unknownFirst === 'true' || unknownFirst === '1', + }); + } + + @Get('/errors/platforms') + async listPlatforms(@GetUserFromRequest() user: User) { + this.assertSuperAdmin(user); + return this._errorsService.listPlatforms(); + } +} diff --git a/apps/frontend/src/app/(app)/(site)/admin/errors/page.tsx b/apps/frontend/src/app/(app)/(site)/admin/errors/page.tsx new file mode 100644 index 00000000..c915101a --- /dev/null +++ b/apps/frontend/src/app/(app)/(site)/admin/errors/page.tsx @@ -0,0 +1,17 @@ +export const dynamic = 'force-dynamic'; +import { AdminErrorsComponent } from '@gitroom/frontend/components/admin/admin-errors.component'; +import { Metadata } from 'next'; +import { isGeneralServerSide } from '@gitroom/helpers/utils/is.general.server.side'; + +export const metadata: Metadata = { + title: `${isGeneralServerSide() ? 'Postiz' : 'Gitroom'} Admin Errors`, + description: '', +}; + +export default async function Page() { + return ( +
+ +
+ ); +} diff --git a/apps/frontend/src/components/admin/admin-errors.component.tsx b/apps/frontend/src/components/admin/admin-errors.component.tsx new file mode 100644 index 00000000..8daf99ef --- /dev/null +++ b/apps/frontend/src/components/admin/admin-errors.component.tsx @@ -0,0 +1,411 @@ +'use client'; + +import React, { FC, useCallback, useMemo, useState } from 'react'; +import useSWR from 'swr'; +import copy from 'copy-to-clipboard'; +import { useFetch } from '@gitroom/helpers/utils/custom.fetch'; +import { useUser } from '@gitroom/frontend/components/layout/user.context'; +import { useToaster } from '@gitroom/react/toaster/toaster'; +import { useModals } from '@gitroom/frontend/components/layout/new-modal'; +import { Button } from '@gitroom/react/form/button'; +import { LoadingComponent } from '@gitroom/frontend/components/layout/loading'; + +interface ErrorRow { + id: string; + message: string; + body: string; + platform: string; + postId: string; + createdAt: string; + organization: { + id: string; + name: string; + users: { user: { id: string; email: string; name: string | null } }[]; + }; + post: { id: string; content: string | null }; +} + +interface ErrorsResponse { + items: ErrorRow[]; + total: number; + page: number; + limit: number; + hasMore: boolean; +} + +const safeParse = (value: string) => { + try { + return JSON.parse(value); + } catch { + return value; + } +}; + +const ErrorDetailsModal: FC<{ row: ErrorRow }> = ({ row }) => { + const modal = useModals(); + const toaster = useToaster(); + const parsedMessage = useMemo(() => safeParse(row.message), [row.message]); + const parsedBody = useMemo(() => safeParse(row.body), [row.body]); + + const copyAll = useCallback(() => { + copy( + JSON.stringify( + { message: parsedMessage, body: parsedBody, meta: row }, + null, + 2 + ) + ); + toaster.show('Debug code copied to clipboard', 'success'); + }, [parsedMessage, parsedBody, row, toaster]); + + return ( +
+
+
Error Details
+
+ + +
+
+ +
+
+
Platform
+
{row.platform}
+
+
+
Created
+
{new Date(row.createdAt).toLocaleString()}
+
+
+
Organization
+
+ {row.organization?.name}{' '} + ({row.organization?.id}) +
+
+
+
Users
+
+ {row.organization?.users + ?.map((u) => u.user?.email) + .filter(Boolean) + .join(', ') || '—'} +
+
+
+
Post ID
+
{row.postId}
+
+
+ +
message
+
+        {typeof parsedMessage === 'string'
+          ? parsedMessage
+          : JSON.stringify(parsedMessage, null, 2)}
+      
+ +
body
+
+        {typeof parsedBody === 'string'
+          ? parsedBody
+          : JSON.stringify(parsedBody, null, 2)}
+      
+
+ ); +}; + +const usePlatformsList = () => { + const fetch = useFetch(); + return useSWR('/admin/errors/platforms', async (url: string) => { + const res = await fetch(url); + if (!res.ok) return []; + return res.json(); + }); +}; + +const useErrorsList = (params: { + page: number; + limit: number; + platform: string; + email: string; + unknownFirst: boolean; +}) => { + const fetch = useFetch(); + const query = new URLSearchParams({ + page: String(params.page), + limit: String(params.limit), + ...(params.platform ? { platform: params.platform } : {}), + ...(params.email ? { email: params.email } : {}), + unknownFirst: params.unknownFirst ? 'true' : 'false', + }); + const key = `/admin/errors?${query.toString()}`; + return useSWR(key, async (url: string) => { + const res = await fetch(url); + if (!res.ok) { + throw new Error('Failed to load errors'); + } + return res.json(); + }); +}; + +export const AdminErrorsComponent: FC = () => { + const user = useUser(); + const modal = useModals(); + const toaster = useToaster(); + + const [page, setPage] = useState(0); + const [limit, setLimit] = useState(20); + const [platform, setPlatform] = useState(''); + const [email, setEmail] = useState(''); + const [emailInput, setEmailInput] = useState(''); + const [unknownFirst, setUnknownFirst] = useState(true); + + const { data: platforms } = usePlatformsList(); + const { data, isLoading, error } = useErrorsList({ + page, + limit, + platform, + email, + unknownFirst, + }); + + const onApplyEmail = useCallback(() => { + setPage(0); + setEmail(emailInput.trim()); + }, [emailInput]); + + const onClear = useCallback(() => { + setPage(0); + setEmail(''); + setEmailInput(''); + setPlatform(''); + }, []); + + const openDetails = useCallback( + (row: ErrorRow) => { + modal.openModal({ + closeOnClickOutside: true, + withCloseButton: false, + classNames: { + modal: 'w-[100%] max-w-[1100px] text-textColor', + }, + children: , + }); + }, + [modal] + ); + + const copyRow = useCallback( + (row: ErrorRow) => { + copy( + JSON.stringify( + { message: safeParse(row.message), body: safeParse(row.body), meta: row }, + null, + 2 + ) + ); + toaster.show('Debug code copied to clipboard', 'success'); + }, + [toaster] + ); + + if (!user?.isSuperAdmin) { + return ( +
+ You do not have access to this page. +
+ ); + } + + const totalPages = data ? Math.max(1, Math.ceil(data.total / limit)) : 1; + + return ( +
+
+
Errors
+
+ {data ? `${data.total} total` : ''} +
+
+ +
+
+
Platform
+ +
+ +
+
Email contains
+
+ setEmailInput(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') onApplyEmail(); + }} + placeholder="user@example.com" + className="bg-newBgColorInner h-[38px] border border-newTableBorder rounded-[8px] px-[10px] text-[14px] text-textColor min-w-[240px]" + /> + +
+
+ + + +
+
Per page
+ +
+ + +
+ + {isLoading ? ( + + ) : error ? ( +
Failed to load errors.
+ ) : !data || data.items.length === 0 ? ( +
No errors found.
+ ) : ( +
+
+
Created
+
Platform
+
User / Org
+
Message
+
Actions
+
+ {data.items.map((row) => { + const isUnknown = (row.message || '').includes('Unknown Error'); + const emails = + row.organization?.users + ?.map((u) => u.user?.email) + .filter(Boolean) + .join(', ') || '—'; + const preview = + (row.message || '').length > 280 + ? row.message.slice(0, 280) + '…' + : row.message; + return ( +
+
+ {new Date(row.createdAt).toLocaleString()} +
+
+ + {row.platform} + +
+
+
{emails}
+
+ {row.organization?.name} +
+
+
+ {preview} +
+
+ + +
+
+ ); + })} +
+ )} + +
+
+ Page {page + 1} of {totalPages} +
+
+ + +
+
+
+ ); +}; diff --git a/apps/frontend/src/components/layout/impersonate.tsx b/apps/frontend/src/components/layout/impersonate.tsx index 7f9bf46d..4cc6bdad 100644 --- a/apps/frontend/src/components/layout/impersonate.tsx +++ b/apps/frontend/src/components/layout/impersonate.tsx @@ -411,6 +411,21 @@ const AddAnnouncement = () => { ); }; +const ViewErrors = () => { + const t = useT(); + const handleClick = useCallback(() => { + window.location.href = '/admin/errors'; + }, []); + return ( +
+ {t('view_errors', 'View Errors')} +
+ ); +}; + const ImportDebugPost = () => { const { openModal } = useModals(); const t = useT(); @@ -527,6 +542,7 @@ export const Impersonate = () => { + )} diff --git a/libraries/nestjs-libraries/src/database/prisma/database.module.ts b/libraries/nestjs-libraries/src/database/prisma/database.module.ts index 3a2f2a84..fbd79189 100644 --- a/libraries/nestjs-libraries/src/database/prisma/database.module.ts +++ b/libraries/nestjs-libraries/src/database/prisma/database.module.ts @@ -40,6 +40,8 @@ import { OAuthRepository } from '@gitroom/nestjs-libraries/database/prisma/oauth import { OAuthService } from '@gitroom/nestjs-libraries/database/prisma/oauth/oauth.service'; import { AnnouncementsRepository } from '@gitroom/nestjs-libraries/database/prisma/announcements/announcements.repository'; import { AnnouncementsService } from '@gitroom/nestjs-libraries/database/prisma/announcements/announcements.service'; +import { ErrorsRepository } from '@gitroom/nestjs-libraries/database/prisma/errors/errors.repository'; +import { ErrorsService } from '@gitroom/nestjs-libraries/database/prisma/errors/errors.service'; @Global() @Module({ @@ -89,6 +91,8 @@ import { AnnouncementsService } from '@gitroom/nestjs-libraries/database/prisma/ VideoManager, AnnouncementsRepository, AnnouncementsService, + ErrorsRepository, + ErrorsService, ], get exports() { return this.providers; diff --git a/libraries/nestjs-libraries/src/database/prisma/errors/errors.repository.ts b/libraries/nestjs-libraries/src/database/prisma/errors/errors.repository.ts new file mode 100644 index 00000000..0cb1f903 --- /dev/null +++ b/libraries/nestjs-libraries/src/database/prisma/errors/errors.repository.ts @@ -0,0 +1,143 @@ +import { PrismaRepository } from '@gitroom/nestjs-libraries/database/prisma/prisma.service'; +import { Injectable } from '@nestjs/common'; + +const UNKNOWN_TOKEN = 'Unknown Error'; + +interface ListErrorsParams { + page?: number; + limit?: number; + platform?: string; + email?: string; + unknownFirst?: boolean; +} + +@Injectable() +export class ErrorsRepository { + constructor(private _errors: PrismaRepository<'errors'>) {} + + private buildWhere(params: ListErrorsParams) { + const where: any = {}; + if (params.platform) { + where.platform = params.platform; + } + if (params.email) { + where.organization = { + users: { + some: { + user: { + email: { contains: params.email, mode: 'insensitive' }, + }, + }, + }, + }; + } + return where; + } + + private get include() { + return { + organization: { + select: { + id: true, + name: true, + users: { + select: { + user: { select: { id: true, email: true, name: true } }, + }, + }, + }, + }, + post: { select: { id: true, content: true } }, + } as const; + } + + async listPlatforms() { + const rows = await this._errors.model.errors.findMany({ + distinct: ['platform'], + select: { platform: true }, + orderBy: { platform: 'asc' }, + }); + return rows.map((r) => r.platform); + } + + async listErrors(params: ListErrorsParams) { + const page = Math.max(0, params.page || 0); + const limit = Math.min(Math.max(1, params.limit || 20), 100); + const skip = page * limit; + const where = this.buildWhere(params); + const include = this.include; + + if (!params.unknownFirst) { + const [items, total] = await Promise.all([ + this._errors.model.errors.findMany({ + where, + orderBy: { createdAt: 'desc' }, + skip, + take: limit, + include, + }), + this._errors.model.errors.count({ where }), + ]); + return { + items, + total, + page, + limit, + hasMore: skip + items.length < total, + }; + } + + const unknownWhere = { ...where, message: { contains: UNKNOWN_TOKEN } }; + const knownWhere = { + ...where, + NOT: { message: { contains: UNKNOWN_TOKEN } }, + }; + + const [unknownTotal, knownTotal] = await Promise.all([ + this._errors.model.errors.count({ where: unknownWhere }), + this._errors.model.errors.count({ where: knownWhere }), + ]); + + let unknownItems: any[] = []; + let knownItems: any[] = []; + + if (skip < unknownTotal) { + const takeUnknown = Math.min(unknownTotal - skip, limit); + unknownItems = await this._errors.model.errors.findMany({ + where: unknownWhere, + orderBy: { createdAt: 'desc' }, + skip, + take: takeUnknown, + include, + }); + const remaining = limit - unknownItems.length; + if (remaining > 0) { + knownItems = await this._errors.model.errors.findMany({ + where: knownWhere, + orderBy: { createdAt: 'desc' }, + skip: 0, + take: remaining, + include, + }); + } + } else { + knownItems = await this._errors.model.errors.findMany({ + where: knownWhere, + orderBy: { createdAt: 'desc' }, + skip: skip - unknownTotal, + take: limit, + include, + }); + } + + const items = [...unknownItems, ...knownItems]; + const total = unknownTotal + knownTotal; + return { + items, + total, + page, + limit, + hasMore: skip + items.length < total, + }; + } +} diff --git a/libraries/nestjs-libraries/src/database/prisma/errors/errors.service.ts b/libraries/nestjs-libraries/src/database/prisma/errors/errors.service.ts new file mode 100644 index 00000000..63d3c3ea --- /dev/null +++ b/libraries/nestjs-libraries/src/database/prisma/errors/errors.service.ts @@ -0,0 +1,21 @@ +import { Injectable } from '@nestjs/common'; +import { ErrorsRepository } from '@gitroom/nestjs-libraries/database/prisma/errors/errors.repository'; + +@Injectable() +export class ErrorsService { + constructor(private _errorsRepository: ErrorsRepository) {} + + listErrors(params: { + page?: number; + limit?: number; + platform?: string; + email?: string; + unknownFirst?: boolean; + }) { + return this._errorsRepository.listErrors(params); + } + + listPlatforms() { + return this._errorsRepository.listPlatforms(); + } +}