SavvySolve Docs

Admin Dashboard

Platform administration interface for managing users, solvers, tickets, sessions, and viewing metrics

Admin Dashboard

The admin dashboard provides administrators and owners with tools to monitor platform health, manage users and solvers, and oversee all tickets and sessions. Access requires the admin role or higher.

Accessing the Admin Dashboard

The admin section appears in the main sidebar for users with admin or owner roles. Navigation items include:

  • Overview - Platform metrics and KPIs
  • Users - User management and role assignment
  • Tickets - All customer support requests
  • Sessions - All support sessions
  • Solvers - Solver management and approval

The admin layout enforces role requirements via server-side guards:

app/(authenticated)/admin/layout.tsx
import { roleGuard } from "@/lib/auth";

export default async function AdminLayout({ children }) {
  await roleGuard("admin", "/dashboard");
  return <>{children}</>;
}

Overview Page

The overview page displays real-time platform metrics through the AdminMetrics component.

Key Metrics

MetricDescription
Active SessionsCurrently ongoing support sessions
Pending TicketsTickets awaiting solver claim
Total UsersAll registered platform users
Total SolversRegistered solvers (available/busy/offline breakdown)
Revenue TodayEarnings from completed sessions today
Revenue This WeekRolling 7-day revenue
Revenue This MonthRolling 30-day revenue
Average RatingMean customer rating across all sessions
Payment RatePercentage of completed sessions that are paid

Metrics API

Metrics are fetched via the admin.getMetrics tRPC procedure:

server/routers/admin.ts
getMetrics: adminProcedure.query(async ({ ctx }) => {
  const [
    activeSessions,
    pendingTickets,
    totalUsers,
    solverStats,
    revenueStats,
    ratingStats,
  ] = await Promise.all([
    db.select({ count: count() }).from(sessions)
      .where(eq(sessions.status, "active")),
    db.select({ count: count() }).from(tickets)
      .where(eq(tickets.status, "pending")),
    db.select({ count: count() }).from(users),
    // ... additional queries
  ]);

  return {
    activeSessions: activeSessions[0]?.count ?? 0,
    pendingTickets: pendingTickets[0]?.count ?? 0,
    // ... formatted metrics
  };
}),

User Management

The users page provides full user administration capabilities with a list view and detailed individual user pages.

Features

  • Search - Filter users by email or name
  • Role Filter - View users by role (customer, solver, admin, owner)
  • Pagination - 20 users per page
  • User Detail Page - View comprehensive user information at /admin/users/[id]
  • Role Assignment - Change user roles with notes for audit trail
  • Suspension - Remove access from problematic users
  • Audit Logging - All changes tracked with actor, timestamp, and notes

User Detail Page

Each user has a dedicated detail page showing:

  • User Information - Email, role, account creation date
  • Solver Information - If the user is a solver, shows status, employment type, and session count
  • Recent Tickets - Customer tickets associated with this user
  • Audit History - Complete log of all administrative actions taken on this user

Role Hierarchy and Permissions

The platform enforces a strict role hierarchy for permission boundaries:

RoleLevelCan AssignCan Modify
Customer1--
Solver2--
Admin3Customer, SolverCustomers, Solvers
Owner4All rolesAll users

Key restrictions:

  • Users cannot change their own role
  • Admins cannot assign admin or owner roles
  • Admins cannot modify other admins
  • Only owners can assign the admin or owner role
server/routers/admin.ts
const ROLE_HIERARCHY: Record<UserRole, number> = {
  customer: 1,
  solver: 2,
  admin: 3,
  owner: 4,
};

function canAssignRole(actorRole: UserRole, targetRole: UserRole): boolean {
  if (actorRole === "owner") return true;
  if (actorRole === "admin") {
    return targetRole === "customer" || targetRole === "solver";
  }
  return false;
}

function canModifyUser(
  actorRole: UserRole,
  targetRole: UserRole,
  isSelf: boolean
): boolean {
  if (isSelf) return false;
  if (actorRole === "owner") return true;
  return ROLE_HIERARCHY[actorRole] > ROLE_HIERARCHY[targetRole];
}

Changing User Roles

Role changes include optional notes for the audit trail:

server/routers/admin.ts
updateUserRole: adminProcedure
  .input(z.object({
    userId: z.string().uuid(),
    role: userRoleSchema,
    notes: z.string().optional(),
  }))
  .mutation(async ({ input, ctx }) => {
    const actor = ctx.user;
    const targetUser = await db.query.users.findFirst({
      where: eq(users.id, input.userId),
    });

    // Permission checks
    if (actor.id === targetUser.id) {
      throw new TRPCError({ code: "FORBIDDEN", message: "You cannot change your own role" });
    }
    if (!canModifyUser(actor.role, targetUser.role, false)) {
      throw new TRPCError({ code: "FORBIDDEN", message: `You cannot modify users with ${targetUser.role} role` });
    }
    if (!canAssignRole(actor.role, input.role)) {
      throw new TRPCError({ code: "FORBIDDEN", message: `You cannot assign the ${input.role} role` });
    }

    // Update role
    const [updated] = await db.update(users)
      .set({ role: input.role })
      .where(eq(users.id, input.userId))
      .returning();

    // Create audit log
    await createAuditLog({
      actorId: actor.id,
      action: "role_change",
      targetType: "user",
      targetId: input.userId,
      previousValue: { role: targetUser.role },
      newValue: { role: input.role },
      notes: input.notes,
    });

    return updated;
  }),

Suspending Users

Suspension follows the same permission boundaries and creates an audit log:

server/routers/admin.ts
suspendUser: adminProcedure
  .input(z.object({
    userId: z.string().uuid(),
    notes: z.string().optional(),
  }))
  .mutation(async ({ input, ctx }) => {
    const actor = ctx.user;
    const targetUser = await db.query.users.findFirst({
      where: eq(users.id, input.userId),
    });

    // Permission checks
    if (actor.id === targetUser.id) {
      throw new TRPCError({ code: "FORBIDDEN", message: "You cannot suspend yourself" });
    }
    if (!canModifyUser(actor.role, targetUser.role, false)) {
      throw new TRPCError({ code: "FORBIDDEN", message: `You cannot suspend users with ${targetUser.role} role` });
    }

    // Create audit log
    await createAuditLog({
      actorId: actor.id,
      action: "user_suspend",
      targetType: "user",
      targetId: input.userId,
      previousValue: { role: targetUser.role },
      notes: input.notes,
    });

    return { success: true };
  }),

Audit Logging

All administrative actions are logged to the audit_logs table for accountability and compliance.

Logged Actions

ActionDescription
role_changeUser role was modified
user_suspendUser was suspended
solver_approveSolver was approved to go live
solver_updateSolver settings were modified
application_approveSolver application was approved
application_rejectSolver application was rejected

Audit Log Schema

lib/db/schema/audit-logs.ts
export const auditLogs = pgTable("audit_logs", {
  id: uuid("id").primaryKey().defaultRandom(),
  actorId: uuid("actor_id").notNull().references(() => users.id),
  action: auditActionEnum("action").notNull(),
  targetType: varchar("target_type", { length: 50 }).notNull(),
  targetId: uuid("target_id").notNull(),
  previousValue: jsonb("previous_value"),
  newValue: jsonb("new_value"),
  notes: text("notes"),
  ipAddress: varchar("ip_address", { length: 45 }),
  userAgent: text("user_agent"),
  createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
});

Viewing Audit Logs

Audit logs appear in two places:

  1. User Detail Page - Shows logs related to that specific user
  2. Global Audit Log - Available via admin.getAuditLogs procedure (paginated)

Ticket Management

The tickets page provides a read-only view of all support requests.

Filtering Options

  • All - Every ticket regardless of status
  • Pending - Awaiting solver claim
  • Claimed - Assigned to a solver but session not started
  • In Progress - Active session underway
  • Completed - Successfully resolved
  • Cancelled - Abandoned by customer or solver
  • Refunded - Payment refunded

Displayed Information

ColumnDescription
CustomerName and phone number
DescriptionTruncated problem description
DeviceDevice type (computer, phone, tablet, other)
UrgencyPriority level (low, normal, high, urgent)
StatusCurrent ticket status with color-coded badge
CreatedTimestamp of ticket creation

Session Management

The sessions page shows all support sessions with their details.

Filtering Options

  • All - Every session
  • Not Started - Ticket claimed but session not begun
  • Active - Currently in progress
  • Paused - Temporarily on hold
  • Completed - Successfully finished
  • Cancelled - Abandoned or terminated early

Displayed Information

ColumnDescription
CustomerCustomer name from associated ticket
SolverAssigned solver's name
DurationSession length in MM:SS format
TierPricing tier (standard, premium, enterprise)
EarningsSolver's earnings in USD
StatusSession status with color-coded badge
StartedSession start timestamp

Solver Management

The solvers page enables solver oversight and approval workflows.

Pending Approvals Section

New solvers start in "onboarding" status and appear in a dedicated section at the top of the page. Each pending solver shows:

  • Name and email
  • Application timestamp
  • Approve button to activate the solver
server/routers/admin.ts
approveSolver: adminProcedure
  .input(z.object({ solverId: z.string().uuid() }))
  .mutation(async ({ input }) => {
    const solver = await db.query.solvers.findFirst({
      where: eq(solvers.id, input.solverId),
    });

    if (!solver) {
      throw new TRPCError({
        code: "NOT_FOUND",
        message: "Solver not found",
      });
    }

    if (solver.status !== "onboarding") {
      throw new TRPCError({
        code: "BAD_REQUEST",
        message: "Solver is not in onboarding status",
      });
    }

    await db.update(solvers)
      .set({ status: "available" })
      .where(eq(solvers.id, input.solverId));

    return { success: true };
  }),

Solver Table

The main table shows all active solvers with:

ColumnDescription
NameSolver's display name
EmploymentW-2 (employed) or 1099 (contract)
StatusCurrent availability status
SessionsTotal completed sessions
EarningsLifetime earnings
RatingAverage customer rating
ActionsEdit button for solver settings

Editing Solver Settings

Clicking edit opens a dialog to modify:

  • Employment Type - Switch between employed and contract
  • Google Email - Workspace email for Meet integration (employed only)
server/routers/admin.ts
updateSolver: adminProcedure
  .input(z.object({
    solverId: z.string().uuid(),
    employment: z.enum(["employed", "contract"]).optional(),
    status: solverStatusSchema.optional(),
    googleEmail: z.string().email().optional().nullable(),
    revenueSharePercent: z.number().min(0).max(100).optional(),
    callPreference: z.enum(["in_app", "bridge"]).optional(),
  }))
  .mutation(async ({ input }) => {
    const { solverId, ...updates } = input;
    
    // Only include provided fields
    const updateData: Partial<typeof solvers.$inferInsert> = {};
    if (updates.employment !== undefined) updateData.employment = updates.employment;
    if (updates.status !== undefined) updateData.status = updates.status;
    if (updates.googleEmail !== undefined) updateData.googleEmail = updates.googleEmail;
    if (updates.revenueSharePercent !== undefined) {
      updateData.revenueSharePercent = updates.revenueSharePercent;
    }
    if (updates.callPreference !== undefined) {
      updateData.callPreference = updates.callPreference;
    }

    await db.update(solvers)
      .set(updateData)
      .where(eq(solvers.id, solverId));

    return { success: true };
  }),

Filtering Options

  • Status - Available, busy, offline, onboarding
  • Employment - Employed (W-2) or Contract (1099)

File Structure

app/(authenticated)/admin/
├── layout.tsx           # Role guard and layout wrapper
├── page.tsx             # Overview with metrics
├── users/
│   ├── page.tsx         # User list with search/filter
│   └── [id]/
│       └── page.tsx     # User detail with role management
├── tickets/
│   └── page.tsx         # Ticket list view
├── sessions/
│   └── page.tsx         # Session list view
├── solvers/
│   └── page.tsx         # Solver management
└── applications/
    └── page.tsx         # Solver application review

components/admin/
├── AdminMetrics.tsx     # Metrics display component
└── index.ts             # Component exports

lib/db/schema/
└── audit-logs.ts        # Audit log table and enum

server/routers/
└── admin.ts             # All admin tRPC procedures

On this page