Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
107 changes: 93 additions & 14 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
@@ -1,35 +1,114 @@
name: Deploy to DigitalOcean Droplet
name: CI & Deploy

on:
push:
branches:
- main
branches: [main]
pull_request:
branches: [main]

concurrency:
group: deploy-${{ github.ref }}
cancel-in-progress: false

jobs:
deploy:
ci:
name: Lint, Typecheck & Build
runs-on: ubuntu-latest
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true

# Build-time placeholders — real values are injected on the droplet
# from its .env via docker-compose build args. NEXT_PUBLIC_* values
# are public by design; nothing secret is needed to compile.
NEXT_PUBLIC_SUPABASE_URL: https://placeholder.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY: placeholder-anon-key
NEXT_PUBLIC_MAPBOX_TOKEN: placeholder-mapbox-token
steps:
- name: Checkout Code
uses: actions/checkout@v4

- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 24
cache: npm

- name: Install dependencies
run: npm ci

- name: Lint
run: npm run lint

- name: Typecheck
run: npm run typecheck

- name: Build
run: npm run build

deploy:
name: Deploy to DigitalOcean Droplet
needs: ci
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- name: Deploy to Droplet via SSH
uses: appleboy/ssh-action@v1.0.0
uses: appleboy/ssh-action@v1.2.4
with:
host: ${{ secrets.HOST }}
username: root
password: ${{ secrets.PASSWORD }}
# Alternatively, use SSH key if password auth is disabled:
# key: ${{ secrets.SSH_PRIVATE_KEY }}
# Recommended hardening: switch to key auth (secrets.SSH_PRIVATE_KEY)
# and a non-root deploy user once available.
script: |
set -e
cd /var/www/rentwise
git pull origin main

# Rebuild and restart the unified Next.js docker container in the background
docker-compose down
git fetch origin main
git reset --hard origin/main

# Rebuild and restart the unified Next.js docker container
docker-compose up -d --build

# Clean up old unused images to save space
docker image prune -f

smoke:
name: Post-deploy smoke tests (live site)
needs: deploy
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- name: Checkout Code
uses: actions/checkout@v4

- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 24
cache: npm

- name: Install dependencies
run: npm ci

- name: Install Playwright browsers
run: npx playwright install --with-deps chromium

- name: Wait for app to become healthy
run: |
for i in $(seq 1 30); do
if curl -fsS --max-time 5 "http://${{ secrets.HOST }}:3009/api/health" > /dev/null; then
echo "App healthy"; exit 0
fi
echo "Waiting for app... ($i/30)"; sleep 10
done
echo "App did not become healthy in time"; exit 1

- name: Run e2e smoke suite against production
env:
PLAYWRIGHT_BASE_URL: http://${{ secrets.HOST }}:3009
run: npm run test:e2e

- name: Upload Playwright report
if: failure()
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: playwright-report/
retention-days: 7
35 changes: 35 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# RentWise

Rental listings platform for Bangalore. Next.js 16 App Router + Supabase (auth, Postgres, RLS, realtime) + Mapbox. Data pipeline scrapes MagicBricks (cheerio/axios) and NoBroker (Apify actor) into the `properties` table.

## Commands

```bash
npm run dev # local dev server
npm run build # production build (standalone output)
npm run lint # eslint (flat config)
npm run typecheck # tsc --noEmit
npm run test:e2e # playwright smoke suite (defaults to live droplet URL)
npm run scrape # one-off Apify NoBroker sync
```

E2E against local build: `PLAYWRIGHT_BASE_URL=http://localhost:3000 npm run test:e2e`.

## Architecture

- `src/app/` — App Router pages. No API routes except `api/health`; pages talk to Supabase directly via `@supabase/ssr` clients in `src/utils/supabase/` (client/server/middleware variants).
- `src/middleware.ts` — refreshes Supabase session cookies on every request.
- Roles: `tenant` and `landlord`, stored in `user_metadata.role`; separate login/register pages per role. Access control is enforced by Supabase RLS policies (see `db/migrations/`), not app code.
- `scripts/` — scraper pipeline. `cron.ts` runs inside the same Docker container as the web server (started by `scripts/entrypoint.sh`), scheduled 06:00 & 18:00 IST. Zero-fake-data policy: scrapers never invent field values.
- `src/utils/analytics.ts` — first-party event tracking into `analytics_events` table (RLS: insert-only for clients). Fire-and-forget; must never throw.

## Deploy

Push to `main` → GitHub Actions (`.github/workflows/deploy.yml`): CI (lint/typecheck/build) → SSH to DigitalOcean droplet (`/var/www/rentwise`) → `docker-compose up -d --build` → Playwright smoke tests against http://157.245.110.163:3009. The droplet's `.env` supplies all secrets/build args; CI builds with placeholders.

## Constraints

- ESLint is pinned to v9: `eslint-plugin-react` (via eslint-config-next) breaks on ESLint 10.
- React Compiler lint (`react-hooks/set-state-in-effect`) is enforced — don't call setState-bearing callbacks synchronously in effects; use `.then()` chains or subscription callbacks.
- Listing images come from arbitrary scraped hosts — use `<img>`, not `next/image` (rule disabled in eslint config).
- DB schema changes go in `db/migrations/supabase_migration_phaseN_*.sql` and must be applied manually in the Supabase SQL editor.
19 changes: 11 additions & 8 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,12 +1,11 @@
# --- Stage 1: Build Image ---
FROM node:20-alpine AS builder
FROM node:24-alpine AS builder

WORKDIR /app

# Copy package files and install dependencies
# Copy package files and install dependencies (reproducible via lockfile)
COPY package.json package-lock.json ./
# Install dependencies using npm install to bypass lockfile strictness on version bumps
RUN npm install --legacy-peer-deps
RUN npm ci

# Copy full source and build application
COPY . .
Expand All @@ -21,20 +20,24 @@ ENV NEXT_PUBLIC_MAPBOX_TOKEN=$NEXT_PUBLIC_MAPBOX_TOKEN

RUN npm run build

# Drop dev-only packages so the runner stage copies a lean node_modules
# (tsx + scraper deps live in "dependencies" and survive the prune)
RUN npm prune --omit=dev

# --- Stage 2: Production Image ---
FROM node:20-alpine AS runner
FROM node:24-alpine AS runner

WORKDIR /app

ENV NODE_ENV production
ENV NEXT_TELEMETRY_DISABLED 1
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1

# Copy Next.js standalone build
COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static

# Copy scraper scripts + node_modules for cron
# Copy scraper scripts + pruned node_modules for the cron process
COPY --from=builder /app/scripts ./scripts
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./package.json
Expand Down
25 changes: 24 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# RentWise 2.0 - Premium Real Estate Engine

RentWise is a Next.js 15 powered real-estate platform designed with robust Server Components, fluid Framer Motion micro-animations, and a highly secure Supabase backend. It features an automated data ingestion pipeline powered by Apify to scrape properties in real-time.
RentWise is a Next.js 16 powered real-estate platform designed with robust Server Components, fluid Framer Motion micro-animations, and a highly secure Supabase backend. It features an automated data ingestion pipeline powered by Apify to scrape properties in real-time.

## 🚀 Key Features
- **Server-Side Rendered:** `app/properties/page.tsx` directly queries the database server-side for blisteringly fast SEO and load times.
Expand Down Expand Up @@ -58,3 +58,26 @@ If you ever want to force a scrape immediately without waiting for the schedule,
```bash
docker exec -it rentwise_next_app npm run scrape
```

---

## ✅ CI/CD Pipeline

Every push to `main` runs through `.github/workflows/deploy.yml`:

1. **CI** — `npm run lint`, `npm run typecheck`, `npm run build` (placeholder env; real values live only on the droplet).
2. **Deploy** — SSH into the droplet, `git reset --hard origin/main`, `docker-compose up -d --build` (no downtime `down` step).
3. **Smoke** — waits for `/api/health`, then runs the Playwright e2e suite against the live site. A failed smoke run flags the deploy red in GitHub.

Pull requests run the CI job only.

## 🧪 Testing

```bash
npm run test:e2e # against live droplet
PLAYWRIGHT_BASE_URL=http://localhost:3000 npm run test:e2e # against local build
```

## 📊 Analytics

First-party analytics events (page views, signups, logins) are written to the `analytics_events` Supabase table (`db/migrations/supabase_migration_phase11_analytics.sql` — run it once in the Supabase SQL editor). Query `analytics_daily_summary` for daily uniques per event.
41 changes: 41 additions & 0 deletions db/migrations/supabase_migration_phase11_analytics.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
-- Phase 11: Product analytics events
-- Lightweight first-party event tracking for user-growth metrics
-- (page views, signups, logins, applications) — pre-seed traction data.

create table if not exists public.analytics_events (
id bigint generated always as identity primary key,
event_name text not null,
user_id uuid references auth.users (id) on delete set null,
session_id text,
path text,
properties jsonb not null default '{}'::jsonb,
created_at timestamptz not null default now()
);

create index if not exists analytics_events_name_time_idx
on public.analytics_events (event_name, created_at desc);

create index if not exists analytics_events_time_idx
on public.analytics_events (created_at desc);

alter table public.analytics_events enable row level security;

-- Anyone (anon or authenticated) may write events; nobody but the
-- service role may read them back.
drop policy if exists "analytics insert for all" on public.analytics_events;
create policy "analytics insert for all"
on public.analytics_events
for insert
to anon, authenticated
with check (true);

-- Convenience view for quick dashboards (service-role only via RLS).
create or replace view public.analytics_daily_summary as
select
date_trunc('day', created_at) as day,
event_name,
count(*) as events,
count(distinct coalesce(user_id::text, session_id)) as uniques
from public.analytics_events
group by 1, 2
order by 1 desc, 3 desc;
22 changes: 12 additions & 10 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,17 +18,19 @@ services:
- NEXT_PUBLIC_MAPBOX_TOKEN=${NEXT_PUBLIC_MAPBOX_TOKEN}
- SUPABASE_SERVICE_ROLE_KEY=${SUPABASE_SERVICE_ROLE_KEY}
- APIFY_API_TOKEN=${APIFY_API_TOKEN}
- NEXT_PUBLIC_SITE_URL=${NEXT_PUBLIC_SITE_URL:-http://157.245.110.163:3009}
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:3000/ > /dev/null || exit 1"]
interval: 10s
test: ["CMD-SHELL", "wget -qO- http://localhost:3000/api/health > /dev/null || exit 1"]
interval: 15s
timeout: 5s
retries: 3
start_period: 90s
deploy:
resources:
limits:
cpus: '0.8'
memory: 800M
update_config:
order: start-first
failure_action: rollback
# Real limits for plain docker-compose (the old `deploy:` block is
# swarm-only and was silently ignored)
cpus: 0.8
mem_limit: 800m
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
7 changes: 7 additions & 0 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,13 @@ import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
{
rules: {
// Scraped listings serve images from arbitrary external hosts;
// next/image throws at runtime for domains not in remotePatterns.
"@next/next/no-img-element": "off",
},
},
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
Expand Down
17 changes: 17 additions & 0 deletions next.config.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,16 @@
import type { NextConfig } from "next";

const securityHeaders = [
{ key: 'X-Frame-Options', value: 'SAMEORIGIN' },
{ key: 'X-Content-Type-Options', value: 'nosniff' },
{ key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
{ key: 'X-DNS-Prefetch-Control', value: 'on' },
{ key: 'Permissions-Policy', value: 'camera=(), microphone=(), payment=()' },
];

const nextConfig: NextConfig = {
output: 'standalone',
poweredByHeader: false,
images: {
remotePatterns: [
{ protocol: 'https', hostname: 'img.staticmb.com' },
Expand All @@ -10,6 +19,14 @@ const nextConfig: NextConfig = {
{ protocol: 'https', hostname: 'assets.nobroker.in' },
],
},
async headers() {
return [
{
source: '/:path*',
headers: securityHeaders,
},
];
},
};

export default nextConfig;
Loading
Loading