Skip to content
KW
Back to projects

sample 04/in development

Atlas

A sales CRM that became a management system for small businesses, shaped to each company's industry.

  • Next
  • Prisma
  • Zod
  • Turbo

01

Context

Atlas started as a CRM for a sales team: a status pipeline, goals, email templates and sending through each seller's Google account.

In August the product changed direction. It became a multi-tenant platform where each business, such as a restaurant or a barbershop, gets a panel with the modules for its industry.

02

Starting point

The first version served a single sales team, running on demo data.

3

roles: seller, manager and admin

600

simulated accounts in demo mode

1

company per installation

03

Timeline

56 commits in the project history. These are the milestones.

  1. Jul 02, 2026

    First version

    A sales CRM with one portal per role and a demo mode with simulated data.

  2. Jul 09, 2026

    Gmail integration

    OAuth connection to the Google account, encrypted tokens, send limits and a kill switch for the whole integration.

  3. Jul 10, 2026

    Sign in with Google

    Individual identity per Google account, alongside password login.

  4. Aug 08, 2026

    Pivot to multi-tenant

    Foundation rebuilt: company and industry inside the signed session, company sign-up with its owner and the first complete module, Customers, following the repository, service and route pattern.

  5. Aug 09, 2026

    Operations modules

    Cash flow, catalog, service records, stock and revenue forecast.

  6. Aug 11, 2026

    CRM and analytics

    Customer segments with privacy-law consent and per-industry analytics panels.

  7. Aug 15, 2026

    Real database

    Moved from demo mode to Postgres, with the old schema's data backed up before the switch.

  8. Sep 08, 2026

    Alpha-only access

    Login closed to the alpha test group.

04

Decision map

Reconstructed from the commit history, the README and the project documentation.

01

Rebuild the foundation, not the whole product

Authentication, the repository pattern and the design system stayed. The sales domain left and a generic business model came in.

02

The company comes from the session

Every business route reads the company from headers the middleware injects from the signed session. The browser has no way to ask for another company's data.

03

Industry is configuration

A registry in code defines which modules each industry turns on and what things are called. A new industry is a new entry, not a new table.

04

One data model for everyone

Customers, catalog items, service records, ledger entries and stock serve every industry, with a flexible field for what's specific to each.

05

Demo without infrastructure

A repository factory switches between in-memory data and Prisma with Postgres. The whole product can be evaluated without setting up any database.

06

An alpha with minimal scope

The alpha serves only business owners, one per company, with no team management. Fewer live routes means less attack surface.

05

Architecture

The same five layers as this page, applied to the project.

  1. 000 m/interface

    • Panel shaped by industry
    • Design system with shadcn/ui
    • Charts with Recharts
  2. 030 m/route

    • Role-based middleware
    • HMAC-SHA256 signed session
    • Client-forged headers discarded
  3. 060 m/service

    • Per-module services
    • Zod validation
    • Industry and module registry
  4. 090 m/repository

    • Repository factory: memory or Prisma
    • Every query scoped by company
    • Soft delete
  5. 120 m/persistence

    • Postgres on Supabase
    • Passwords with scrypt
    • OAuth tokens encrypted with AES-256-GCM

06

Code

An excerpt from the private repository, just to show the layer at work.

The company never comes from the browser

Every business module starts here. The company id comes from the signed session, and without it the route doesn't go on.

apps/web/lib/auth/api-context.tsts
// Lê identidade + tenant dos headers injetados pelo middleware (assinados
// via cookie HMAC — o client não forja). Use em route handlers.

export async function getApiContext(): Promise<ApiContext | null> {
  const h = await headers();
  const userId = h.get("x-user-id");
  const role = h.get("x-user-role") as Role | null;
  if (!userId || !role) return null;
  return { userId, role, companyId: h.get("x-company-id") };
}

/**
 * Contexto que EXIGE empresa (módulos de negócio). SK_ADMIN sem empresa
 * selecionada recebe 400 — a visão cross-tenant tem rotas próprias (/api/sk).
 */
export async function requireCompany(): Promise<RequireCompanyResult> {
  const ctx = await getApiContext();
  if (!ctx) {
    return { error: NextResponse.json({ error: "Não autorizado" }, { status: 401 }) };
  }
  if (!ctx.companyId) {
    return {
      error: NextResponse.json(
        { error: "Nenhuma empresa no contexto." },
        { status: 400 }
      ),
    };
  }
  return { ctx: { ...ctx, companyId: ctx.companyId } };
}

The same item, different names per industry

A single table in the database, with each business's language on screen. That's what lets new industries come in without touching the data model.

apps/web/lib/sectors/index.tsts
// Terminologia do catálogo por setor (o mesmo CatalogItem se chama "prato"
// num restaurante e "serviço" num salão).
export function catalogTerms(sector: Sector | null): CatalogTerms {
  switch (sector) {
    case "CABELEIREIRO":
      return { title: "Serviços", singular: "serviço", plural: "serviços", addLabel: "Novo serviço" };
    case "RESTAURANTE":
    default:
      return { title: "Cardápio", singular: "item", plural: "itens", addLabel: "Novo item" };
  }
}

07

Outcome

2

live industries: restaurant and barbershop

11

tables in a model shared by every industry

3

roles: platform team, owner and staff

Current status

In alpha testing with business owners, starting with barbershops.