superapp
Examples

Orders Dashboard

Full CRUD example with auth, validation, and row-level security.

Use case: A sales team app where each rep creates and manages their own orders. Reps can only see, edit, and delete their own orders. Admins can view all orders.

Backend

engine.ts
import { createEngine } from '@superapp/backend'
import { betterAuthProvider } from '@superapp/backend/auth/better-auth'

const auth = betterAuthProvider({
  secret: process.env.AUTH_SECRET!,
  userTable: {
    table: 'main.users',
    matchOn: { column: 'id', jwtField: 'id' },
  },
})

const engine = createEngine({
  connections: {
    main: { type: 'postgres', url: process.env.PG_URL! },
  },
  auth,

  permissions: {
    // Rep sees only orders they created
    read_own_orders: {
      table: 'main.orders',
      operations: { select: true },
      columns: ['id', 'amount', 'status', 'created_by', 'created_at'],
      filter: { created_by: { $eq: '$user.id' } },
    },

    // Admin sees all orders
    read_all_orders: {
      table: 'main.orders',
      operations: { select: true },
      columns: ['id', 'amount', 'status', 'created_by', 'created_at'],
    },

    // Rep creates orders — ownership is enforced, amount must be positive,
    // new orders can only start as draft
    create_own_orders: {
      table: 'main.orders',
      operations: { insert: true },
      columns: ['amount', 'status'],
      check: {
        amount: { $gt: 0 },
        status: { $eq: 'draft' },
      },
      preset: {
        created_by: '$user.id',
        created_at: '$now',
      },
    },

    // Rep updates only their own orders — amount stays positive,
    // status transitions are restricted
    update_own_orders: {
      table: 'main.orders',
      operations: { update: true },
      columns: ['amount', 'status'],
      filter: { created_by: { $eq: '$user.id' } },
      check: {
        amount: { $gt: 0 },
        status: { $in: ['draft', 'active', 'cancelled'] },
      },
      preset: { updated_at: '$now' },
    },

    // Rep can only delete their own draft orders
    delete_own_draft_orders: {
      table: 'main.orders',
      operations: { delete: true },
      filter: {
        created_by: { $eq: '$user.id' },
        status: { $eq: 'draft' },
      },
    },
  },

  roles: {
    sales_rep: [
      'read_own_orders',
      'create_own_orders',
      'update_own_orders',
      'delete_own_draft_orders',
    ],
    admin: [
      'read_all_orders',
      'create_own_orders',
      'update_own_orders',
      'delete_own_draft_orders',
    ],
  },
})

How it works:

  • filter — injects a WHERE clause. A rep querying orders automatically gets WHERE created_by = ? — they never see other reps' orders.
  • check — validates incoming data. Sending amount: -5 or status: 'shipped' is rejected with 403 before any SQL runs.
  • preset — auto-sets created_by and timestamps. The client cannot override these — ownership is enforced server-side.

Client

app/orders/page.tsx
'use client'

import { useEffect, useState } from 'react'
import { useSession } from '@superapp/auth'
import { useDb } from '@/hooks/use-db'
import { desc } from 'drizzle-orm'
import * as schema from '@/generated/schema'

// Type is inferred from the Drizzle schema — no manual interface needed
type Order = Awaited<
  ReturnType<ReturnType<typeof useDb>['query']['orders']['findMany']>
>[number]

export default function OrdersDashboard() {
  const { data: session, isPending } = useSession()
  const db = useDb()
  const [orders, setOrders] = useState<Order[]>([])
  const [loading, setLoading] = useState(true)

  // Fetch orders — backend scopes to current user automatically
  const loadOrders = () => {
    if (!db) return
    setLoading(true)
    db.query.orders
      .findMany({
        orderBy: desc(schema.orders.createdAt),
        limit: 50,
      })
      .then(setOrders)
      .finally(() => setLoading(false))
  }

  useEffect(loadOrders, [db])

  // Create — backend enforces created_by and validates amount > 0
  const createOrder = async (amount: number) => {
    await db!.insert(schema.orders).values({ amount, status: 'draft' })
    loadOrders()
  }

  // Update — backend only allows own orders, validates status transitions
  const updateStatus = async (id: number, status: string) => {
    await db!.update(schema.orders).set({ status }).where({ id })
    loadOrders()
  }

  // Delete — backend only allows own draft orders
  const deleteOrder = async (id: number) => {
    await db!.delete(schema.orders).where({ id })
    loadOrders()
  }

  if (isPending) return <p>Loading session...</p>
  if (!session) return <p>Please sign in to view orders.</p>
  if (loading) return <p>Loading orders...</p>

  return (
    <table>
      <thead>
        <tr>
          <th>ID</th>
          <th>Amount</th>
          <th>Status</th>
          <th>Date</th>
          <th>Actions</th>
        </tr>
      </thead>
      <tbody>
        {orders.map((order) => (
          <tr key={order.id}>
            <td>{order.id}</td>
            <td>${order.amount.toFixed(2)}</td>
            <td>{order.status}</td>
            <td>{new Date(order.created_at).toLocaleDateString()}</td>
            <td>
              <button onClick={() => updateStatus(order.id, 'cancelled')}>
                Cancel
              </button>
              <button onClick={() => deleteOrder(order.id)}>Delete</button>
            </td>
          </tr>
        ))}
      </tbody>
    </table>
  )
}

On this page