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();
+ }
+}