Working Monorepo + Supabase local dev + Migrations + First deployment


🚀 Weekend Action Plan

Day 1 – Foundations

  • Set up monorepo with pnpm workspaces
  • Create package structure: frontend, admin-dashboard, mobile, api, shared/ui, shared/config
  • Push to GitHub, set branch strategy (main, develop, feature/*)
  • Install Supabase CLI, run supabase start, verify dashboard

Day 2 – Database + ORM

  • Define schema with Drizzle ORM (organizations, users, properties, projects, tasks, photos)
  • Create and apply first migration, seed sample data
  • Connect API routes to database, test with seeded data

Day 3 – CI/CD + Deployment

  • Set up GitHub Actions for CI (install, lint, test)
  • Configure conditional deploys with paths-filter
  • Deploy frontend and API to Vercel
  • Wire up environment variables (local + staging + prod)

1) Drizzle schema + SQL migration

// packages/api/src/db/schema.ts
import { pgTable, serial, varchar, integer, text, json, timestamp } from "drizzle-orm/pg-core";

export const organizations = pgTable("organizations", {
  id: serial("id").primaryKey(),
  slug: varchar("slug", { length: 100 }).notNull().unique(),
  name: varchar("name", { length: 200 }).notNull(),
  owner_id: integer("owner_id").notNull(),
  created_at: timestamp("created_at").defaultNow().notNull()
});

// ... other tables here (users, properties, projects, tasks, photos)
-- db/migrations/001_init.sql

CREATE TABLE organizations (
  id              serial PRIMARY KEY,
  slug            varchar(100) NOT NULL UNIQUE,
  name            varchar(200) NOT NULL,
  owner_id        integer NOT NULL,
  created_at      timestamptz DEFAULT now() NOT NULL
);

-- ... other tables here (users, properties, projects, tasks, photos)

2) GitHub Actions workflow

# .github/workflows/ci-cd.yml
name: CI / Conditional Deploy

on:
  push:
    branches: [ main, develop ]
  pull_request:
    branches: [ main, develop ]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: "pnpm"
      - run: npm i -g pnpm
      - run: pnpm install
      - run: pnpm -w run lint || true
      - run: pnpm -w run test || true

  detect_changes:
    needs: test
    runs-on: ubuntu-latest
    outputs:
      frontend: ${{ steps.filter.outputs.frontend }}
      api: ${{ steps.filter.outputs.api }}
      admin: ${{ steps.filter.outputs.admin }}
    steps:
      - uses: actions/checkout@v4
      - id: filter
        uses: dorny/paths-filter@v3
        with:
          filters: |
            frontend:
              - 'packages/frontend/**'
            api:
              - 'packages/api/**'
            admin:
              - 'packages/admin-dashboard/**'

  deploy:
    needs: detect_changes
    runs-on: ubuntu-latest
    if: needs.detect_changes.outputs.frontend == 'true' || needs.detect_changes.outputs.api == 'true' || needs.detect_changes.outputs.admin == 'true'
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
      - run: npm i -g pnpm
      - run: pnpm install

      # Deploy commands (frontend, api, admin-dashboard) with vercel CLI

1) Drizzle schema + SQL migration

// packages/api/src/db/schema.ts
import { pgTable, serial, varchar, integer, text, json, timestamp } from "drizzle-orm/pg-core";

export const organizations = pgTable("organizations", {
  id: serial("id").primaryKey(),
  slug: varchar("slug", { length: 100 }).notNull().unique(),
  name: varchar("name", { length: 200 }).notNull(),
  owner_id: integer("owner_id").notNull(),
  created_at: timestamp("created_at").defaultNow().notNull()
});

export const users = pgTable("users", {
  id: serial("id").primaryKey(),
  org_id: integer("org_id").references(() => organizations.id).notNull(),
  email: varchar("email", { length: 320 }).notNull().unique(),
  name: varchar("name", { length: 200 }),
  role: varchar("role", { length: 32 }).default("member").notNull(),
  created_at: timestamp("created_at").defaultNow().notNull()
});

export const properties = pgTable("properties", {
  id: serial("id").primaryKey(),
  org_id: integer("org_id").references(() => organizations.id).notNull(),
  address: text("address").notNull(),
  metadata: json("metadata"),
  created_at: timestamp("created_at").defaultNow().notNull()
});

export const projects = pgTable("projects", {
  id: serial("id").primaryKey(),
  org_id: integer("org_id").references(() => organizations.id).notNull(),
  property_id: integer("property_id").references(() => properties.id),
  name: varchar("name", { length: 200 }).notNull(),
  created_at: timestamp("created_at").defaultNow().notNull()
});

export const tasks = pgTable("tasks", {
  id: serial("id").primaryKey(),
  org_id: integer("org_id").references(() => organizations.id).notNull(),
  project_id: integer("project_id").references(() => projects.id),
  description: text("description").notNull(),
  status: varchar("status", { length: 32 }).default("todo").notNull(),
  created_at: timestamp("created_at").defaultNow().notNull()
});

export const photos = pgTable("photos", {
  id: serial("id").primaryKey(),
  org_id: integer("org_id").references(() => organizations.id).notNull(),
  project_id: integer("project_id").references(() => projects.id),
  storage_path: text("storage_path").notNull(),
  exif: json("exif"),
  width: integer("width"),
  height: integer("height"),
  hash: varchar("hash", { length: 128 }),
  status: varchar("status", { length: 32 }).default("processing").notNull(),
  created_at: timestamp("created_at").defaultNow().notNull()
});
-- db/migrations/001_init.sql

CREATE TABLE organizations (
  id              serial PRIMARY KEY,
  slug            varchar(100) NOT NULL UNIQUE,
  name            varchar(200) NOT NULL,
  owner_id        integer NOT NULL,
  created_at      timestamptz DEFAULT now() NOT NULL
);

CREATE TABLE users (
  id              serial PRIMARY KEY,
  org_id          integer NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
  email           varchar(320) NOT NULL UNIQUE,
  name            varchar(200),
  role            varchar(32) NOT NULL DEFAULT 'member',
  created_at      timestamptz DEFAULT now() NOT NULL
);

CREATE TABLE properties (
  id              serial PRIMARY KEY,
  org_id          integer NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
  address         text NOT NULL,
  metadata        jsonb,
  created_at      timestamptz DEFAULT now() NOT NULL
);

CREATE TABLE projects (
  id              serial PRIMARY KEY,
  org_id          integer NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
  property_id     integer REFERENCES properties(id) ON DELETE SET NULL,
  name            varchar(200) NOT NULL,
  created_at      timestamptz DEFAULT now() NOT NULL
);

CREATE TABLE tasks (
  id              serial PRIMARY KEY,
  org_id          integer NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
  project_id      integer REFERENCES projects(id) ON DELETE SET NULL,
  description     text NOT NULL,
  status          varchar(32) NOT NULL DEFAULT 'todo',
  created_at      timestamptz DEFAULT now() NOT NULL
);

CREATE TABLE photos (
  id              serial PRIMARY KEY,
  org_id          integer NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
  project_id      integer REFERENCES projects(id) ON DELETE SET NULL,
  storage_path    text NOT NULL,
  exif            jsonb,
  width           integer,
  height          integer,
  hash            varchar(128),
  status          varchar(32) NOT NULL DEFAULT 'processing',
  created_at      timestamptz DEFAULT now() NOT NULL
);

INSERT INTO organizations (slug, name, owner_id) VALUES ('acme-landscaping','Acme Landscaping', 1);
INSERT INTO users (org_id, email, name, role) VALUES (1, 'owner@acme.test', 'Owner', 'owner');

2) GitHub Actions workflow

# .github/workflows/ci-cd.yml
name: CI / Conditional Deploy

on:
  push:
    branches: [ main, develop ]
  pull_request:
    branches: [ main, develop ]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: "pnpm"
      - run: npm i -g pnpm
      - run: pnpm install
      - run: pnpm -w run lint || true
      - run: pnpm -w run test || true

  detect_changes:
    needs: test
    runs-on: ubuntu-latest
    outputs:
      frontend: ${{ steps.filter.outputs.frontend }}
      api: ${{ steps.filter.outputs.api }}
      admin: ${{ steps.filter.outputs.admin }}
    steps:
      - uses: actions/checkout@v4
      - id: filter
        uses: dorny/paths-filter@v3
        with:
          filters: |
            frontend:
              - 'packages/frontend/**'
            api:
              - 'packages/api/**'
            admin:
              - 'packages/admin-dashboard/**'

  deploy:
    needs: detect_changes
    runs-on: ubuntu-latest
    if: needs.detect_changes.outputs.frontend == 'true' || needs.detect_changes.outputs.api == 'true' || needs.detect_changes.outputs.admin == 'true'
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
      - run: npm i -g pnpm
      - run: pnpm install

      - name: Deploy frontend
        if: needs.detect_changes.outputs.frontend == 'true'
        env:
          VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}
          VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}
          VERCEL_PROJECT_ID: ${{ secrets.VERCEL_FRONTEND_PROJECT_ID }}
        run: |
          cd packages/frontend
          npx vercel --token "$VERCEL_TOKEN" --prod --confirm --scope="$VERCEL_ORG_ID" --project="$VERCEL_PROJECT_ID"

      - name: Deploy api
        if: needs.detect_changes.outputs.api == 'true'
        env:
          VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}
          VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}
          VERCEL_PROJECT_ID: ${{ secrets.VERCEL_API_PROJECT_ID }}
        run: |
          cd packages/api
          npx vercel --token "$VERCEL_TOKEN" --prod --confirm --scope="$VERCEL_ORG_ID" --project="$VERCEL_PROJECT_ID"

      - name: Deploy admin
        if: needs.detect_changes.outputs.admin == 'true'
        env:
          VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}
          VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}
          VERCEL_PROJECT_ID: ${{ secrets.VERCEL_ADMIN_PROJECT_ID }}
        run: |
          cd packages/admin-dashboard
          npx vercel --token "$VERCEL_TOKEN" --prod --confirm --scope="$VERCEL_ORG_ID" --project="$VERCEL_PROJECT_ID"

Comments