🚀 Oferta especial: 60% OFF no CrazyStack - Últimas vagas!Garantir vaga →

Bun.js: Métricas e Seed para seu SaaS

Módulo 3 - FINAL⏱️ 50 minutos📚 Avançado

🎯 Objetivo da Aula

Finalize seu SaaS com Bun.js! Implemente métricas avançadas (pedidos, receitas), crie script de seed completo e valide seu Restaurantix. Guia final para deploy.

📋 O que vamos implementar

  • get-day-orders-amount - Pedidos por dia vs ontem
  • get-month-orders-amount - Pedidos por mês com filtro
  • get-daily-receipt-in-period - Receitas em período
  • seed.ts - Script completo com 200 pedidos
  • Testes finais e validação completa

📊 Métrica: Pedidos por Dia

Compara a quantidade de pedidos de hoje com ontem:

// src/http/routes/get-day-orders-amount.ts
import { and, eq, gte, sql, count } from "drizzle-orm";
import { db } from "@/application/infra/db/connection";
import { orders } from "@/application/infra/db/schema";
import { auth } from "../auth";
import { UnauthorizedError } from "../errors/unauthorized-error";
import dayjs from "dayjs";

export const getDayOrdersAmount = auth.get(
  "/metrics/day-orders-amount",
  async ({ getCurrentUser }) => {
    const { restaurantId } = await getCurrentUser();
    if (!restaurantId) {
      throw new UnauthorizedError();
    }

    // Configurar datas
    const today = dayjs();
    const yesterday = today.subtract(1, "day");
    const startOfYesterday = yesterday.startOf("day");

    // Query agrupada por dia
    const ordersAmount = await db
      .select({
        dayWithMonthAndYear: sql<string>`TO_CHAR(${orders.createdAt}, 'YYYY-MM-DD')`,
        amount: count()
      })
      .from(orders)
      .where(
        and(
          eq(orders.restaurantId, restaurantId),
          gte(orders.createdAt, startOfYesterday.toDate()),
        ),
      )
      .groupBy(sql`TO_CHAR(${orders.createdAt}, 'YYYY-MM-DD')`);

    // Extrair dados de hoje e ontem
    const todayWithMonthAndYear = today.format("YYYY-MM-DD");
    const yesterdayWithMonthAndYear = yesterday.format("YYYY-MM-DD");

    const todayOrdersAmount = ordersAmount.find(
      (order) => order.dayWithMonthAndYear === todayWithMonthAndYear,
    );
    const yesterdayOrdersAmount = ordersAmount.find(
      (order) => order.dayWithMonthAndYear === yesterdayWithMonthAndYear,
    );

    // Calcular diferença percentual
    const diffFromYesterday =
      todayOrdersAmount && yesterdayOrdersAmount
        ? (todayOrdersAmount.amount * 100) / yesterdayOrdersAmount.amount
        : null;

    return {
      todayOrdersAmount,
      yesterdayOrdersAmount,
      diffFromYesterday: diffFromYesterday
        ? Number((diffFromYesterday - 100).toFixed(2))
        : 0
    };

export default
  },
);

📈 Métrica: Pedidos por Mês com Filtro

Quantidade de pedidos por mês com filtro opcional de status:

// src/http/routes/get-month-orders-amount.ts
import { and, eq, gte, sql, count } from "drizzle-orm";
import { db } from "@/application/infra/db/connection";
import { orders } from "@/application/infra/db/schema";
import { auth } from "../auth";
import { UnauthorizedError } from "../errors/unauthorized-error";
import dayjs from "dayjs";
import { createSelectSchema } from "drizzle-typebox";
import { t } from "elysia";

export const getMonthOrdersAmount = auth.get(
  "/metrics/month-orders-amount",
  async ({ getCurrentUser, query }) => {
    const { restaurantId } = await getCurrentUser();
    const { status } = query; // Filtro opcional de status

    if (!restaurantId) {
      throw new UnauthorizedError();
    }

    // Configurar período
    const today = dayjs();
    const lastMonth = today.subtract(1, "month");
    const startOfLastMonth = lastMonth.startOf("month");

    // Construir condições WHERE
    const whereConditions = [
      eq(orders.restaurantId, restaurantId),
      gte(orders.createdAt, startOfLastMonth.toDate()),
    ];

    // Adicionar filtro de status se fornecido
    if (status) {
      whereConditions.push(eq(orders.status, status));
    }

    // Query com agrupamento por mês
    const ordersAmount = await db
      .select({
        monthWithYear: sql<string>`TO_CHAR(${orders.createdAt}, 'YYYY-MM')`,
        amount: count()
      })
      .from(orders)
      .where(and(...whereConditions))
      .groupBy(sql`TO_CHAR(${orders.createdAt}, 'YYYY-MM')`);

    // Extrair dados do mês atual e anterior
    const currentMonthWithYear = today.format("YYYY-MM");
    const lastMonthWithYear = lastMonth.format("YYYY-MM");

    const currentMonthOrdersAmount = ordersAmount.find(
      (order) => order.monthWithYear === currentMonthWithYear,
    );
    const lastMonthOrdersAmount = ordersAmount.find(
      (order) => order.monthWithYear === lastMonthWithYear,
    );

    // Calcular diferença percentual
    const diffFromLastMonth =
      currentMonthOrdersAmount && lastMonthOrdersAmount
        ? (currentMonthOrdersAmount.amount * 100) / lastMonthOrdersAmount.amount
        : null;

    return {
      currentMonthOrdersAmount,
      lastMonthOrdersAmount,
      diffFromLastMonth: diffFromLastMonth
        ? Number((diffFromLastMonth - 100).toFixed(2))
        : 0
    };
  },
  {
    query: t.Object({
      status: t.Optional(createSelectSchema(orders).properties.status)
    })
  },
);

💰 Métrica: Receitas Diárias em Período

Receitas diárias em um período customizado (máximo 30 dias):

// src/http/routes/get-daily-receipt-in-period.ts
import { sum, eq, and, gte, lte, sql } from "drizzle-orm";
import { db } from "@/application/infra/db/connection";
import { orders } from "@/application/infra/db/schema";
import { auth } from "../auth";
import { UnauthorizedError } from "../errors/unauthorized-error";
import { t } from "elysia";
import dayjs from "dayjs";

export const getDailyReceiptInPeriod = auth.get(
  "/metrics/daily-receipt-in-period",
  async ({ getCurrentUser, set, query }) => {
    const { restaurantId } = await getCurrentUser();
    if (!restaurantId) {
      throw new UnauthorizedError();
    }

    const { from, to } = query;

    // Configurar período padrão (últimos 7 dias se não especificado)
    const startDate = from ? dayjs(from) : dayjs().subtract(7, "days");
    const endDate = to
      ? dayjs(to)
      : from
        ? dayjs(from).add(7, "days")
        : dayjs();

    // Validar período máximo de 30 dias
    if (endDate.diff(startDate, "days") > 30) {
      set.status = 400;
      return {
        message: "Period cannot be more than 30 days"
      };
    }

    // Query de receitas diárias
    const dailyReceipt = await db
      .select({
        date: sql<string>`TO_CHAR(${orders.createdAt}, 'DD/MM')`,
        receipt: sum(orders.priceInCents).mapWith(Number)
      })
      .from(orders)
      .where(
        and(
          eq(orders.restaurantId, restaurantId),
          gte(
            orders.createdAt,
            startDate
              .startOf("day")
              .add(startDate.utcOffset(), "minutes")
              .toDate(),
          ),
          lte(
            orders.createdAt,
            endDate.endOf("day").add(endDate.utcOffset(), "minutes").toDate(),
          ),
        ),
      )
      .groupBy(sql`TO_CHAR(${orders.createdAt}, 'DD/MM')`);

    // Ordenar por data
    const orderedReceiptPerDay = dailyReceipt.sort((a, b) => {
      const [dayA = 1, monthA = 1] = a.date.split("/").map(Number);
      const [dayB = 1, monthB = 1] = b.date.split("/").map(Number);

      if (monthA === monthB) {
        return (dayA ?? 0) - (dayB ?? 0);
      }

      const dateA = new Date(2025, monthA - 1);
      const dateB = new Date(2025, monthB - 1);
      return dateA.getTime() - dateB.getTime();
    });

    return orderedReceiptPerDay;
  },
  {
    query: t.Object({
      from: t.Optional(t.String()),
      to: t.Optional(t.String())
    })
  },
);

🌱 Script de Seed Completo

Script completo para popular o banco com dados realistas:

// src/application/infra/db/seed.ts
/* eslint-disable drizzle/enforce-delete-with-where */
import { faker } from "@faker-js/faker";
import {
  users,
  restaurants,
  authLinks,
  orderItems,
  orders,
  products
} from "./schema";
import { db } from "./connection";
import chalk from "chalk";
import { createId } from "@paralleldrive/cuid2";

// 1. LIMPAR BANCO DE DADOS
await db.delete(users);
await db.delete(restaurants);
await db.delete(orderItems);
await db.delete(orders);
await db.delete(products);
await db.delete(authLinks);

console.log(chalk.yellow("Database cleaned"));

// 2. CRIAR CUSTOMERS
const [customer1, customer2] = await db
  .insert(users)
  .values([
    {
      name: faker.person.fullName(),
      email: faker.internet.email(),
      role: "customer"
    },
    {
      name: faker.person.fullName(),
      email: faker.internet.email(),
      role: "customer"
    },
  ])
  .returning();

console.log(chalk.green("Customers inserted"));

// 3. CRIAR MANAGER
const [manager] = await db
  .insert(users)
  .values([
    {
      name: faker.person.fullName(),
      email: "appbelezix1@gmail.com", // Email fixo para testes
      role: "manager"
    },
  ])
  .returning({
    id: users.id
  });

console.log(chalk.green("Managers inserted"));

// 4. CRIAR RESTAURANTE
const [restaurant] = await db
  .insert(restaurants)
  .values([
    {
      name: faker.company.name(),
      description: faker.lorem.paragraph(),
      managerId: manager!.id
    },
  ])
  .returning();

console.log(chalk.green("Restaurants inserted"));

// 5. FUNÇÃO PARA GERAR PRODUTOS
function generateProduct() {
  return {
    name: faker.commerce.productName(),
    description: faker.commerce.productDescription(),
    priceInCents: Number(faker.commerce.price({ min: 100, max: 1000, dec: 0 })),
    restaurantId: restaurant!.id
  };
}

// 6. CRIAR PRODUTOS
const availableProducts = await db
  .insert(products)
  .values([
    generateProduct(),
    generateProduct(),
    generateProduct(),
    generateProduct(),
    generateProduct(),
  ])
  .returning();

console.log(chalk.green("Products inserted"));

// 7. GERAR 200 PEDIDOS COM ITENS
type OrderItemsInsert = typeof orderItems.$inferInsert;
type OrderInsert = typeof orders.$inferInsert;

const orderItemsToInsert: OrderItemsInsert[] = [];
const ordersToInsert: OrderInsert[] = [];

for (let i = 0; i < 200; i++) {
  const orderId = createId();
  
  // Selecionar produtos aleatórios para o pedido
  const orderProducts = faker.helpers.arrayElements(availableProducts, {
    min: 1,
    max: 3
  });
  
  let totalPriceInCents = 0;
  
  // Criar itens do pedido
  orderProducts.forEach((product) => {
    const quantity = faker.number.int({ min: 1, max: 3 });
    totalPriceInCents += product.priceInCents * quantity;
    
    orderItemsToInsert.push({
      orderId,
      productId: product.id,
      quantity,
      priceInCents: product.priceInCents
    });
  });
  
  // Criar pedido
  ordersToInsert.push({
    id: orderId,
    customerId: faker.helpers.arrayElement([customer1!.id, customer2!.id]),
    restaurantId: restaurant!.id,
    priceInCents: totalPriceInCents,
    status: faker.helpers.arrayElement([
      "pending",
      "preparing",
      "ready",
      "delivered",
      "cancelled",
    ]),
    createdAt: faker.date.recent({ days: 40 }), // Últimos 40 dias
  });
}

// 8. INSERIR PEDIDOS E ITENS
await db.insert(orders).values(ordersToInsert);
await db.insert(orderItems).values(orderItemsToInsert);

console.log(chalk.green("Orders and order items inserted"));
console.log(chalk.blue(`Generated ${ordersToInsert.length} orders with ${orderItemsToInsert.length} items`));

process.exit();

📝 Registrar Métricas no Servidor

Vamos adicionar as últimas métricas no servidor:

// src/http/server.ts (versão final completa)
import { addRestaurant } from "./routes/add-restaurant";
import { sendAuthLink } from "./routes/send-auth-link";
import { authenticateFromLink } from "./routes/authenticate-from-link";
import { auth } from "./auth";
import { Elysia } from "elysia";
import { cors } from "@elysiajs/cors";
import { signOut } from "./routes/sign-out";
import { getProfile } from "./routes/get-profile";
import { getManagedRestaurant } from "./routes/get-managed-restaurant";
import { csrf } from "./csrf";
import { getCsrfToken } from "./routes/get-csrf-token";
import { getOrderDetails } from "./routes/get-order-details";
import { approveOrder } from "./routes/approve-order";
import { dispatchOrder } from "./routes/dispatch-order";
import { deliverOrder } from "./routes/deliver-order";
import { cancelOrder } from "./routes/cancel-order";
import { getOrders } from "./routes/get-orders";
import { getMonthRevenue } from "./routes/get-month-revenue";
import { getDayOrdersAmount } from "./routes/get-day-orders-amount";
import { getMonthOrdersAmount } from "./routes/get-month-orders-amount";
import { getPopularProducts } from "./routes/get-popular-products";
import { getDailyReceiptInPeriod } from "./routes/get-daily-receipt-in-period";

import { Metadata } from 'next';

export const metadata: Metadata = {
  title: 'Aula 10 | CrazyStack',
  description: 'Curso completo sobre aula 10. Aprenda técnicas práticas e melhores práticas para desenvolvimento moderno. Guia atualizado 2025.',
  keywords: ['aula', '10', 'programação', 'desenvolvimento', 'tutorial', 'crazystack'],
  openGraph: {
    title: 'Aula 10',
    description: 'Curso completo sobre aula 10. Aprenda técnicas práticas e melhores práticas para desenvolvimento moderno. Guia atualizado 2025.',
    type: 'article'
  },
};

const app = new Elysia()
  .use(cors())
  .use(csrf)
  .use(auth)
  .use(getCsrfToken)
  .use(addRestaurant)
  .use(sendAuthLink)
  .use(authenticateFromLink)
  .use(signOut)
  .use(getProfile)
  .use(getManagedRestaurant)
  .use(getOrderDetails)
  .use(approveOrder)
  .use(dispatchOrder)
  .use(deliverOrder)
  .use(cancelOrder)
  .use(getOrders)
  .use(getMonthRevenue)
  .use(getDayOrdersAmount)          // ← Nova métrica
  .use(getMonthOrdersAmount)        // ← Nova métrica
  .use(getPopularProducts)
  .use(getDailyReceiptInPeriod)     // ← Nova métrica
  .onError(({ error, code, set }) => {
    console.error(error);
    switch (code) {
      case "VALIDATION":
        set.status = error.status;
        return error.toResponse();
      default:
        set.status = 500;
        console.error(error);
        return new Response(null, { status: 500 });
    }
  });

app.listen(3000, () => {
  console.log("Server is running on port 3000");
});

🧪 Testando o Sistema Completo

1. Executar o Seed

# Executar script de seed
bun run src/application/infra/db/seed.ts

# Verificar dados no Drizzle Studio
bun run drizzle-kit studio

2. Testar Métricas

# Pedidos por dia
GET http://localhost:3000/metrics/day-orders-amount
Cookie: auth=[jwt_token]

# Pedidos por mês (todos os status)
GET http://localhost:3000/metrics/month-orders-amount
Cookie: auth=[jwt_token]

# Pedidos por mês (apenas entregues)
GET http://localhost:3000/metrics/month-orders-amount?status=delivered
Cookie: auth=[jwt_token]

# Receitas diárias (últimos 7 dias)
GET http://localhost:3000/metrics/daily-receipt-in-period
Cookie: auth=[jwt_token]

# Receitas diárias (período customizado)
GET http://localhost:3000/metrics/daily-receipt-in-period?from=2025-01-01&to=2025-01-15
Cookie: auth=[jwt_token]

3. Fluxo Completo de Teste

# 1. Criar restaurante
POST /restaurants

# 2. Fazer login do manager
POST /auth-links/authenticate
GET /auth-links/authenticate?code=...

# 3. Obter restaurante gerenciado
GET /managed-restaurant

# 4. Listar pedidos
GET /orders

# 5. Ver detalhes de um pedido
GET /orders/:orderId

# 6. Gerenciar pedido
PATCH /orders/:orderId/approve
PATCH /orders/:orderId/dispatch
PATCH /orders/:orderId/deliver

# 7. Ver todas as métricas
GET /metrics/month-revenue
GET /metrics/day-orders-amount
GET /metrics/month-orders-amount
GET /metrics/popular-products
GET /metrics/daily-receipt-in-period

🎉 Restaurantix 100% Completo!

🚀 RESTAURANTIX - SISTEMA COMPLETO

✅ AUTENTICAÇÃO
   ├─ Magic Links com JWT
   ├─ Cookies seguros
   ├─ Middleware de proteção
   └─ Logout completo

✅ BANCO DE DADOS
   ├─ 6 tabelas com relações
   ├─ Enums e validações
   ├─ Migrações automáticas
   └─ Seed com 200 pedidos

✅ ROTAS IMPLEMENTADAS (18 total)
   ├─ Autenticação (4 rotas)
   ├─ Restaurantes (2 rotas)
   ├─ Pedidos (6 rotas)
   └─ Métricas (6 rotas)

✅ FUNCIONALIDADES
   ├─ CRUD completo de pedidos
   ├─ Fluxo de status validado
   ├─ Métricas em tempo real
   ├─ Sistema de email
   ├─ Deploy em produção
   └─ Testes automatizados

✅ TECNOLOGIAS
   ├─ Bun.js (runtime)
   ├─ ElysiaJS (framework)
   ├─ Drizzle ORM (banco)
   ├─ PostgreSQL (dados)
   ├─ TypeScript (tipagem)
   ├─ Zod (validação)
   ├─ Resend (email)
   └─ Fly.io (deploy)

🎯 RESULTADO: SaaS completo e funcional!

🎓 Parabéns! Curso Concluído

Você implementou um SaaS completo usando as tecnologias mais modernas do ecossistema JavaScript. O Restaurantix está pronto para produção com todas as funcionalidades de um sistema real.

  • Sistema de autenticação robusto e seguro
  • Banco de dados bem estruturado com relações
  • API REST completa com validações
  • Métricas avançadas para análise de negócio
  • Deploy automatizado em produção
  • Código limpo e bem documentado