Skip to main content
Buildstory provides comprehensive audit logging and moderation workflows to maintain platform integrity and track administrative actions.

Audit Log System

All administrative and moderation actions are automatically logged to the adminAuditLog table for accountability and compliance.

Database Schema

From lib/db/schema.ts:331-343:
export const adminAuditLog = pgTable("admin_audit_log", {
  id: uuid("id").primaryKey().defaultRandom(),
  actorProfileId: uuid("actor_profile_id")
    .notNull()
    .references(() => profiles.id),
  action: text("action").notNull(),
  targetProfileId: uuid("target_profile_id")
    .references(() => profiles.id),
  metadata: text("metadata"), // JSON string
  createdAt: timestamp("created_at").defaultNow().notNull(),
});
Fields:
  • actorProfileId: Admin/moderator who performed the action
  • action: Action type (string identifier)
  • targetProfileId: User who was acted upon (nullable for non-user actions)
  • metadata: JSON-encoded context (reason, old/new values, etc.)
  • createdAt: Timestamp of action

Logged Actions

All moderation actions automatically log to audit table:
ActionActor RoleTargetMetadata
hide_userModerator/AdminUser profile IDNone
unhide_userModerator/AdminUser profile IDNone
ban_userModerator/AdminUser profile ID{ reason: string }
unban_userAdmin onlyUser profile IDNone
delete_userAdmin onlynull{ deletedProfileId, displayName, username, clerkId }
set_roleAdmin onlyUser profile ID{ oldRole, newRole }
approve_mentorAdmin onlynull{ applicationId, applicantName, applicantEmail }
decline_mentorAdmin onlynull{ applicationId, applicantName, applicantEmail }
delete_user sets targetProfileId to null because the profile record is deleted before logging. Metadata preserves identifying information.

Audit Log Viewer

Access Requirements:
role
string
required
Requires admin role to access /admin/audit
Page enforced in app/admin/audit/page.tsx:8. Features:
  • Displays all audit entries in reverse chronological order
  • Shows actor name (via display_name join)
  • Shows target name (if applicable, via left join)
  • Displays action type and metadata
  • Timestamp for each entry
Query (from lib/admin/queries.ts:217-239):
export async function getAuditLog(): Promise<AuditLogEntry[]> {
  const rows = await db
    .select({
      id: adminAuditLog.id,
      action: adminAuditLog.action,
      actorName: sql<string>`actor.display_name`.as("actor_name"),
      targetName: sql<string | null>`target.display_name`.as("target_name"),
      metadata: adminAuditLog.metadata,
      createdAt: adminAuditLog.createdAt,
    })
    .from(adminAuditLog)
    .innerJoin(
      sql`${profiles} as actor`,
      sql`actor.id = ${adminAuditLog.actorProfileId}`
    )
    .leftJoin(
      sql`${profiles} as target`,
      sql`target.id = ${adminAuditLog.targetProfileId}`
    )
    .orderBy(desc(adminAuditLog.createdAt));

  return rows;
}
Uses SQL aliases to join profiles twice (once for actor, once for target).

Ban Workflow

A comprehensive process for permanently blocking abusive users.

Initiating a Ban

  1. Navigate to /admin/users or /admin/users/[id]
  2. Click the ban icon (block symbol)
  3. Dialog prompts for optional ban reason
  4. Confirm action

Server Action Flow

From app/admin/users/actions.ts:113-182:
export async function banUser(data: {
  profileId: string;
  reason?: string;
}): Promise<ActionResult>
Steps:
  1. Authorization: Verify actor is moderator/admin
  2. Validation:
    • User exists and is not already banned
    • Target is not a super-admin
    • Moderators cannot ban admins
    • Cannot ban yourself
  3. Database Update:
    await db
      .update(profiles)
      .set({
        bannedAt: new Date(),
        bannedBy: actor.id,
        banReason: parsed.data.reason?.trim() || null,
      })
      .where(eq(profiles.id, data.profileId));
    
  4. Clerk Sync (non-blocking):
    await clerk.users.banUser(target.clerkId);
    
    Disables authentication. If Clerk call fails, Sentry reports but DB ban remains (source of truth).
  5. Audit Log:
    await logAudit(actor.id, "ban_user", data.profileId, {
      reason: data.reason?.trim() || null,
    });
    
  6. Revalidation: revalidatePath("/admin/users")

Middleware Redirect

Banned users are automatically redirected by app/proxy.ts:
const profile = await db.query.profiles.findFirst({
  where: eq(profiles.clerkId, userId),
  columns: { bannedAt: true },
});

if (profile?.bannedAt) {
  return NextResponse.redirect(new URL("/banned", req.url));
}

Ban Page

app/banned/page.tsx displays:
  • Ban notice
  • Ban reason (if provided)
  • Support contact link
No action buttons — only admins can unban via /admin/users.

Unbanning

Permission: Admin only From app/admin/users/actions.ts:184-237:
export async function unbanUser(data: {
  profileId: string;
}): Promise<ActionResult>
Clears both ban and hide status:
await db
  .update(profiles)
  .set({
    bannedAt: null,
    bannedBy: null,
    banReason: null,
    hiddenAt: null, // Also unhides
    hiddenBy: null,
  })
  .where(eq(profiles.id, data.profileId));
Also calls clerk.users.unbanUser(clerkId) to restore authentication.
Unbanning automatically unhides the user to ensure full account restoration.

Hide Workflow

A softer moderation action that removes profiles from public view without blocking authentication.

Use Cases

  • Temporary content policy violations
  • Suspicious accounts under investigation
  • User-requested profile privacy during sensitive situations

Hide vs Ban

FeatureHideBan
Profile visible in public listsNoNo
Can sign inYesNo
Can edit own profileYesNo
Can create projectsYesNo
Reversible by moderatorsYesNo (admin only)
Clerk authentication disabledNoYes

Server Action Flow

From app/admin/users/actions.ts:36-74:
export async function hideUser(data: {
  profileId: string;
}): Promise<ActionResult>
Checks:
  • User not already hidden
  • Target is not a super-admin
Database Update:
await db
  .update(profiles)
  .set({ hiddenAt: new Date(), hiddenBy: actor.id })
  .where(eq(profiles.id, data.profileId));
Does not interact with Clerk — purely a database visibility flag.

Public Query Filtering

All public-facing queries filter out hidden and banned profiles via isProfileVisible helper in lib/queries.ts:
function isProfileVisible(profile: { bannedAt: Date | null; hiddenAt: Date | null }) {
  return profile.bannedAt === null && profile.hiddenAt === null;
}
Used in:
  • getHackathonProfiles(): List page
  • getProfileByUsername(): Detail page
  • getHackathonProjects(): Project list (owner must be visible)
  • getProjectBySlug(): Project detail (owner must be visible)
Hidden users can still access their own dashboard and projects, but won’t appear in public directories.

Mentor Application Review

Access Requirements:
role
string
required
Requires admin role to access /admin/mentors
Page enforced in app/admin/mentors/page.tsx:11.

Application Schema

From lib/db/schema.ts:365-387:
export const mentorApplications = pgTable("mentor_applications", {
  id: uuid,
  name: text,
  email: text (unique),
  discordHandle: text,
  twitterHandle: text (nullable),
  websiteUrl: text (nullable),
  githubHandle: text (nullable),
  mentorTypes: text[],  // ["design", "technical", "growth"]
  background: text,
  availability: text,
  status: mentorApplicationStatusEnum,  // pending | approved | declined
  reviewedBy: uuid (FK to profiles, nullable),
  reviewedAt: timestamp (nullable),
  createdAt: timestamp,
  updatedAt: timestamp,
});

Review Interface

Navigate to /admin/mentors to view:
  • Statistics: Total applications, pending count, approved count, declined count
  • Application list: All applications with status badges
  • Details: Expand to see mentor type, background, availability
  • Actions: Approve or decline pending applications

Approval Workflow

From app/admin/mentors/actions.ts:20-69:
export async function approveMentorApplication(data: {
  applicationId: string;
}): Promise<ActionResult>
Steps:
  1. Verify actor is admin
  2. Fetch application and check status is pending
  3. Update application:
    await db
      .update(mentorApplications)
      .set({
        status: "approved",
        reviewedBy: actor.id,
        reviewedAt: new Date(),
      })
      .where(eq(mentorApplications.id, data.applicationId));
    
  4. Log to audit:
    await db.insert(adminAuditLog).values({
      actorProfileId: actor.id,
      action: "approve_mentor",
      targetProfileId: null,
      metadata: JSON.stringify({
        applicationId: data.applicationId,
        applicantName: application.name,
        applicantEmail: application.email,
      }),
    });
    
  5. Revalidate /admin/mentors
Approving a mentor application does not automatically assign a role. Admins must separately grant moderator or admin role via /admin/roles if elevated permissions are needed.

Decline Workflow

From app/admin/mentors/actions.ts:71-120:
export async function declineMentorApplication(data: {
  applicationId: string;
}): Promise<ActionResult>
Identical flow to approval, but sets status: "declined" and logs "decline_mentor" action.

Public Submission Form

Mentor applications are submitted via /mentor-apply (public, no auth required):
  • Collects contact info, social handles, mentor types, background, availability
  • Validates email uniqueness (DB constraint)
  • Inserts into mentorApplications with status: "pending"
  • Fires Discord notification via notifyMentorApplication webhook
Server action in app/mentor-apply/actions.ts.

Best Practices

Audit Log

  1. Review regularly to detect suspicious patterns (e.g., mass bans, frequent role escalations)
  2. Export for compliance if required by data protection regulations
  3. Reference before reversing actions to understand original context (especially ban reasons)

Moderation

  1. Default to hide for first offenses unless behavior is egregiously abusive
  2. Always provide ban reasons — logged metadata helps future reviewers understand context
  3. Coordinate with team before unbanning to ensure consensus on policy violations
  4. Monitor ban page traffic via analytics to track how many users hit the ban wall

Mentor Applications

  1. Respond to pending applications within 7 days to maintain applicant trust
  2. Document decision criteria in team guidelines (experience level, availability, mentor type balance)
  3. Follow up with approved mentors via email to onboard and assign Discord roles (manual process)
  4. Track approval rate (stats displayed on /admin/mentors) to ensure consistent standards

Build docs developers (and LLMs) love