diff --git a/.env.example b/.env.example index ad58af7..58fb2c8 100644 --- a/.env.example +++ b/.env.example @@ -21,6 +21,9 @@ HSTS_SECONDS=3600 HSTS_INCLUDE_SUBDOMAINS=False HSTS_PRELOAD=False +# Rate limit for the public lead form, per client +PUBLIC_LEADS_RATE=20/hour + # Production only: console log level LOG_LEVEL=INFO diff --git a/.github/ISSUE_TEMPLATE/report.md b/.github/ISSUE_TEMPLATE/report.md new file mode 100644 index 0000000..b48365f --- /dev/null +++ b/.github/ISSUE_TEMPLATE/report.md @@ -0,0 +1,20 @@ +--- +name: Report a problem or share an idea +about: Issues are welcome but may go unanswered; the project is not actively maintained. +--- + + + +## What happened, or what you'd like + +## Steps to reproduce (for a problem) + +1. +2. + +## Where you ran it + + diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..7293d56 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,21 @@ + + +## What this changes + + + +## If it changes the backend + +- [ ] `flake8` +- [ ] `pytest` +- [ ] `python manage.py makemigrations --check --dry-run` +- [ ] `python manage.py spectacular --validate --fail-on-warn --file schema.yml`, with `frontend/src/types/api.ts` regenerated to match + +## If it changes the frontend + +- [ ] `pnpm lint` +- [ ] `pnpm test` +- [ ] `pnpm build` diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7fc280d..9fc22df 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,8 +3,12 @@ name: CI on: push: branches: [main] + tags: ["v*"] pull_request: +permissions: + contents: read + concurrency: group: ci-${{ github.ref }} cancel-in-progress: true @@ -40,7 +44,7 @@ jobs: - name: Check OpenAPI schema is up to date run: | - python manage.py spectacular --validate --file "$RUNNER_TEMP/schema.yml" + python manage.py spectacular --validate --fail-on-warn --file "$RUNNER_TEMP/schema.yml" diff -u schema.yml "$RUNNER_TEMP/schema.yml" || { echo "::error file=backend/schema.yml::schema.yml is stale. Run 'python manage.py spectacular --file schema.yml' and commit it." exit 1 @@ -115,6 +119,7 @@ jobs: for i in $(seq 60); do curl -fsS -o /dev/null http://localhost:8000/ && break; sleep 1; done curl -fsS http://localhost:8000/leads/7 | grep -q 'id="root"' curl -fsS -o /dev/null http://localhost:8000/static/admin/css/base.css + curl -fsS -o /dev/null http://localhost:8000/static/drf_spectacular_sidecar/swagger-ui-dist/swagger-ui-bundle.js test "$(curl -s -o /dev/null -w '%{http_code}' http://localhost:8000/api/leads/)" = 401 curl -fsS -X POST http://localhost:8000/api/auth/login/ \ -H 'Content-Type: application/json' \ @@ -123,3 +128,80 @@ jobs: - name: Container logs if: always() run: docker logs app || true + + # ---------------------------------------------------------------------------- + # Releases: pushing a v* tag publishes the image and the GitHub Release, only + # after every check above has passed + # ---------------------------------------------------------------------------- + publish: + name: Publish image + if: startsWith(github.ref, 'refs/tags/v') + needs: [backend, frontend, docker] + runs-on: ubuntu-24.04 + permissions: + contents: read + packages: write + steps: + - uses: actions/checkout@v7 + + - name: Tag matches the package version + run: | + version=$(node -p 'require("./frontend/package.json").version') + if [ "v$version" != "$GITHUB_REF_NAME" ]; then + echo "::error::Tag $GITHUB_REF_NAME does not match version $version in frontend/package.json" + exit 1 + fi + + - uses: docker/setup-qemu-action@v4.4.0 + - uses: docker/setup-buildx-action@v4.4.1 + - uses: docker/login-action@v4.6.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Image name + id: image + run: echo "name=ghcr.io/${GITHUB_REPOSITORY,,}" >> "$GITHUB_OUTPUT" + + - uses: docker/build-push-action@v7.4.0 + with: + context: . + platforms: linux/amd64,linux/arm64 + push: true + tags: | + ${{ steps.image.outputs.name }}:${{ github.ref_name }} + ${{ steps.image.outputs.name }}:latest + labels: | + org.opencontainers.image.version=${{ github.ref_name }} + org.opencontainers.image.revision=${{ github.sha }} + # The package page reads these from the multi-platform index + annotations: | + index:org.opencontainers.image.description=Lead management API and web app: JWT auth, roles, a validated status pipeline, notes and an automatic activity timeline. Django REST Framework + React. + index:org.opencontainers.image.source=https://github.com/${{ github.repository }} + index:org.opencontainers.image.licenses=MIT + + release: + name: GitHub Release + needs: publish + runs-on: ubuntu-24.04 + permissions: + contents: write + steps: + - uses: actions/checkout@v7 + + - name: Release assets + run: | + mkdir dist + cp backend/schema.yml dist/schema.yml + (cd dist && sha256sum -- * > SHA256SUMS && cat SHA256SUMS) + + - name: Release notes from CHANGELOG.md + run: | + awk -v heading="## ${GITHUB_REF_NAME#v} " 'index($0, heading) == 1 {found=1; next} /^## / {found=0} found' CHANGELOG.md > notes.md + test -s notes.md || { echo "::error::No CHANGELOG.md entry for ${GITHUB_REF_NAME#v}"; exit 1; } + + - name: Create the GitHub Release + env: + GH_TOKEN: ${{ github.token }} + run: gh release create "$GITHUB_REF_NAME" dist/* --verify-tag --title "lead-platform $GITHUB_REF_NAME" --notes-file notes.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..42aac2b --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,17 @@ +# Changelog + +## 1.0.0 (2026-09-26) + +The first and final planned release. lead-platform is complete as of this version and not actively maintained; fork it freely. + +- **Leads API:** create, list, retrieve, edit and delete leads. Filter by status, assignee, creator and company; search across name, email, phone and company; sort; and paginate (20 per page, up to 100). OpenAPI 3 docs are at `/api/docs/` and `/api/redoc/`. +- **Status pipeline:** New → Contacted → Qualified → Proposal → Won, and Lost from any open stage. Transitions are enforced by one service function, and a blocked change returns `400` with the reason. +- **Activity timeline:** creating, editing, status changes, assignment and notes are recorded automatically, in the same transaction as the change. +- **Roles:** Admins see and manage every lead; Members see and work on the leads they created or are assigned to. Only Admins delete or assign. Notes are edited or deleted by their author or an Admin. A lead a member can't see returns `404`. +- **Authentication:** JWTs with 15-minute access tokens and 7-day refresh tokens that rotate and are blacklisted after use. Includes logout, the current user, and the list of assignable members. +- **Public lead form:** an unauthenticated endpoint for a website enquiry form, rate-limited per client (`PUBLIC_LEADS_RATE`, default 20 an hour). +- **Web app:** React 19 and TypeScript, with types generated from the API schema. Sign-in with a shared, single-flight token refresh; a dashboard; a searchable, filterable and paginated lead list; and a lead page with status, assignment, notes and the timeline. +- **Quality:** 99 backend tests and 74 frontend tests. CI runs flake8, a missing-migrations check, a stale-schema and stale-types check, ESLint, a production build, and a Docker build with a smoke test. +- **Deployment:** one Docker image (amd64 and arm64) serves the API, Django admin and the web app with gunicorn and WhiteNoise. It applies migrations and creates the first admin on start. There is a Render blueprint for a free demo on Neon Postgres, and a `docker compose` setup for local Postgres. + +Release assets: `schema.yml` (the OpenAPI schema) and `SHA256SUMS`. Container image: `ghcr.io/code-kasha/lead-platform:v1.0.0` (also `latest`). diff --git a/CHECKLIST.md b/CHECKLIST.md index 8c8524d..4560c4e 100644 --- a/CHECKLIST.md +++ b/CHECKLIST.md @@ -77,7 +77,7 @@ - [x] Consistent Docstring - [x] Import Cleanup - [x] Query Optimization -- [x] 80 Automated Tests Passing +- [x] 99 Automated Tests Passing --- @@ -178,7 +178,7 @@ - [x] Frontend Build (`pnpm build`) - [x] Frontend Lint (`pnpm lint`) - [x] Frontend Tests (74/74, `pnpm test`) -- [x] Backend Tests (80/80) +- [x] Backend Tests (99/99) - [x] Manual End-to-End Testing - [x] Swagger Verification diff --git a/CLAUDE.md b/CLAUDE.md index 5bf0801..8460109 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,6 +8,9 @@ Full-stack Lead Management Platform: Django REST Framework API + React/TypeScrip Covers authentication, lead capture, assignment, a validated status lifecycle, notes, and an automatic activity timeline. +**Status: complete as of v1.0.0 and not actively maintained** (see `CONTRIBUTING.md`). Releases +are cut by tagging; see "Releases" below. + Repo: `github.com/code-kasha/lead-platform` · Author: Akash Damle ## Layout @@ -27,7 +30,7 @@ frontend/ layouts/ page shells pages/ route-level views routes/ routing and guards -docs/ architecture, assessments, DEPLOY.md +docs/ api.md (API reference), DEPLOY.md, architecture and assessments Dockerfile one image: builds the frontend, serves API + admin + SPA (gunicorn + WhiteNoise) render.yaml Render blueprint (free Docker web service; database on Neon) screenshots/ UI captures referenced by the README @@ -75,7 +78,7 @@ CI (`.github/workflows/ci.yml`) runs on PRs and pushes to `main`: flake8, a miss check, a stale-schema check (`backend/schema.yml` must match `manage.py spectacular`), pytest, a stale-types check (`frontend/src/types/api.ts` must match `openapi-typescript` output), then `pnpm lint`, `pnpm test` and `pnpm build`. A third job builds the Docker image and smoke-tests the -running container (SPA route, admin static, API 401, admin login). After changing views or serializers, regenerate both files: +running container (SPA route, admin and Swagger UI static, API 401, admin login). After changing views or serializers, regenerate both files: `python manage.py spectacular --file schema.yml` (from `backend/`) and `pnpm exec openapi-typescript ../backend/schema.yml -o src/types/api.ts` (from `frontend/`). Python lint config is `.flake8` (max line length 120). @@ -87,7 +90,15 @@ to `backend/frontend_dist/`; Django serves `index.html` for every path outside ` `/admin/` and `/static/` (`apps/common/views.py#frontend_index`, last route in `config/urls.py`), and WhiteNoise serves the assets. `docker-entrypoint.sh` runs `migrate` and `ensure_superuser` (idempotent) on every start. `docker compose up --build` runs it -locally against Postgres. +locally against Postgres. Swagger UI and ReDoc are served from `drf-spectacular-sidecar` +(`SWAGGER_UI_DIST`/`REDOC_DIST = "SIDECAR"`), not a CDN. + +## Releases + +Pushing a `v*` tag runs CI plus `publish` (checks the tag matches `version` in +`frontend/package.json`, then pushes a multi-arch image to `ghcr.io/code-kasha/lead-platform` +with the version tag and `latest`) and `release` (a GitHub Release whose notes are the matching +`## X.Y.Z (date)` entry in `CHANGELOG.md`, with `schema.yml` and `SHA256SUMS` attached). ## Conventions @@ -122,5 +133,6 @@ locally against Postgres. ## Known issues -- The backend requires **Python ≥ 3.12** (Django 6.0). Tests need `SECRET_KEY` and - `DATABASE_URL` set (e.g. `DATABASE_URL=sqlite:///:memory:`); the full suite is 80 tests. +- The backend requires **Python ≥ 3.12** (Django 6.1). Tests need `SECRET_KEY` and + `DATABASE_URL` set (e.g. `DATABASE_URL=sqlite:///:memory:`); the full suite is 99 tests + (frontend: 74). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..485e5da --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,48 @@ +# Contributing to lead-platform + +Thanks for looking. A quick word on where things stand, then how to work on the code. + +## The project is finished + +As of v1.0.0, lead-platform is complete and is not actively maintained. It works as-is, and no further updates are planned. Issues and pull requests are welcome, but they may go unanswered. Please don't wait on a reply: fork it, reuse it and grow it in whatever direction you need. + +## License + +The code is under the [MIT License](LICENSE), with no extra conditions. Use it, change it, host it or sell it. The license only asks that its notice stays with copies of the code. + +A mention of lead-platform in your project is appreciated, never required. + +## Changing the code + +The API comes first. The backend owns the contract, and the frontend uses TypeScript types generated from it. The code keeps these rules (details in [`CLAUDE.md`](CLAUDE.md) and [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md)): + +- **Views handle requests and responses only.** Business logic goes in `apps/*/services.py`: `Request → View → Serializer → Service → Database`. +- **Service functions are transactional**, and they are the only code that writes activity records. A change and its timeline entry are saved together, or neither is. +- **Status only changes through `change_lead_status`**, which enforces the transitions in `apps/leads/constants.py`. +- **Never hand-write API types in the frontend.** After changing a view or serializer, regenerate the schema and the types: + + ```sh + cd backend && python manage.py spectacular --validate --fail-on-warn --file schema.yml + cd ../frontend && pnpm exec openapi-typescript ../backend/schema.yml -o src/types/api.ts + ``` + +Use Python 3.12 or later, Node.js 22 and [pnpm](https://pnpm.io/). These are the checks CI runs on every push and pull request, along with a Docker build and smoke test: + +```sh +# backend/ (tests need SECRET_KEY and DATABASE_URL, e.g. sqlite:///:memory:) +pip install -r requirements.txt +flake8 +python manage.py makemigrations --check --dry-run +python manage.py spectacular --validate --fail-on-warn --file schema.yml +pytest + +# frontend/ +pnpm install --frozen-lockfile +pnpm lint +pnpm test +pnpm build +``` + +The 99 backend tests cover authentication, token rotation and blacklisting, role permissions, lead visibility for each role, create and edit with the activities they record, every allowed and blocked status change, assignment, notes and their owners, filtering, search, ordering, pagination, the public form and its rate limit, the schema, and the production settings. The 74 frontend tests cover the auth guard and the shared token refresh, the login flow, the dashboard, the lead list and its query parameters, and the lead page's status, assignment and notes against a fake API that enforces the backend's status rules. + +To release a fork, set `version` in `frontend/package.json`, add a `CHANGELOG.md` entry and push a `v*` tag. CI then publishes the container image to the GitHub Container Registry and creates the GitHub Release. See [`docs/DEPLOY.md`](docs/DEPLOY.md#ci-and-releases). diff --git a/Dockerfile b/Dockerfile index 5a16e9f..e12ce7e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,9 +4,10 @@ # React app (same origin, so the frontend calls /api directly). # ------------------------------------------------------------------------------ -# Stage 1: build the frontend +# Stage 1: build the frontend (static files, so it runs on the build machine's +# own platform even when the image is built for another one) # ------------------------------------------------------------------------------ -FROM node:22-slim AS frontend +FROM --platform=$BUILDPLATFORM node:22-slim AS frontend WORKDIR /app/frontend diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..93d719c --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Akash Damle + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index cbfbfcf..86374de 100644 --- a/README.md +++ b/README.md @@ -1,221 +1,212 @@ -# Lead Management Platform - -A full-stack lead management application built with Django REST Framework and React. It demonstrates a service-oriented backend architecture, role-based access control, an API-first contract with generated TypeScript types, and a modern React dashboard for managing sales leads. - ---- - -## Overview - -The application covers the complete lead workflow: authentication, lead capture, assignment, lifecycle tracking, notes, and an automatic activity history. - -Development follows an API-first approach — the backend publishes an OpenAPI specification, and the frontend consumes TypeScript models generated from it rather than maintaining duplicate interfaces by hand. - ---- - -## Features - -### Authentication - -- JWT authentication with token refresh -- Secure logout (token blacklisting) -- Current-user endpoint -- Role-based access control - -### Lead Management - -- Create, view, update, and delete leads -- Lead assignment to users -- Status lifecycle with transition validation -- Search, filtering, and ordering - -### Collaboration - -- Notes with create / edit / delete -- Automatic activity timeline -- Assignment and status-change history - -### API - -- RESTful endpoints with consistent response serializers -- OpenAPI / Swagger documentation -- Type-safe contracts shared with the frontend - -### Frontend - -- Responsive dashboard with protected routes -- React Query for server state -- Reusable UI component set -- Toast notifications and loading/empty/error states -- TypeScript models generated from OpenAPI - ---- - -## Technology Stack +A lead management platform: a Django REST Framework API that captures, assigns and moves sales leads through a validated pipeline, recording every change on a timeline, with a React and TypeScript app on top. It is built API-first, and the web app's types are generated from the API's OpenAPI schema. + +[![CI](https://github.com/code-kasha/lead-platform/actions/workflows/ci.yml/badge.svg)](https://github.com/code-kasha/lead-platform/actions/workflows/ci.yml) +[![Latest release](https://img.shields.io/github/v/release/code-kasha/lead-platform)](https://github.com/code-kasha/lead-platform/releases/latest) +[![Container image](https://img.shields.io/badge/container-ghcr.io-2496ed?logo=docker&logoColor=white)](https://github.com/code-kasha/lead-platform/pkgs/container/lead-platform) +[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) +[![Python 3.12](https://img.shields.io/badge/python-3.12-blue.svg)](https://www.python.org/) +[![Django 6.1](https://img.shields.io/badge/django-6.1-0c4b33.svg)](https://docs.djangoproject.com/en/6.1/) +[![DRF 3.18](https://img.shields.io/badge/DRF-3.18-a30000.svg)](https://www.django-rest-framework.org/) +[![React 19](https://img.shields.io/badge/react-19-61dafb.svg)](https://react.dev/) +[![TypeScript](https://img.shields.io/badge/typescript-6.0-3178c6.svg)](https://www.typescriptlang.org/) + +[Live demo](https://lead-platform-c3mw.onrender.com/) (until 26 December 2026) · [Download v1.0.0](https://github.com/code-kasha/lead-platform/releases/latest) · [API reference](docs/api.md) · [Swagger UI](https://lead-platform-c3mw.onrender.com/api/docs/) · [Deploy it yourself](docs/DEPLOY.md) · [Changelog](CHANGELOG.md) · [Contributing](CONTRIBUTING.md) + +The lead list: 20 of 26 leads with name, company, email and a coloured status badge (Lost, Won, Proposal, Qualified, Contacted), and Previous and Next buttons under the table. + +- **REST API** for leads, assignment, notes and an activity timeline, with OpenAPI 3 docs (Swagger UI and ReDoc). +- **A status pipeline enforced in one place**: New → Contacted → Qualified → Proposal → Won, or Lost. A blocked change returns `400` with the reason. +- **An automatic, transactional audit trail**: every create, edit, status change, assignment and note is recorded in the same transaction as the change. +- **Role-based access**: Admins manage everything. Members see and work on only the leads they created or are assigned to. +- **JWT authentication** with rotating, blacklisted refresh tokens. +- **A public enquiry form endpoint**, rate-limited per client. +- **A React 19 + TypeScript app** whose API types are generated from the schema, never written by hand. +- **99 backend and 74 frontend tests**, and CI that fails if the schema or the generated types go stale. +- **One Docker image** (amd64 and arm64) serving the API, admin and web app, published to the GitHub Container Registry. + +> **Status:** complete as of v1.0.0 and not actively maintained. It works as-is; fork it, reuse it, grow it. +> +> **Demo:** [lead-platform-c3mw.onrender.com](https://lead-platform-c3mw.onrender.com/) runs until 26 December 2026 on a free tier, so the first request after a quiet spell takes about a minute. After that date, run it yourself with Docker or Python. + +## Quick start + +Install [Git](https://git-scm.com/downloads), then clone the repository. Every command below runs from its folder: + +```sh +git clone https://github.com/code-kasha/lead-platform.git +cd lead-platform +``` -**Backend** — Python, Django, Django REST Framework, Simple JWT, drf-spectacular, django-filter, PostgreSQL +**With Docker Compose.** It builds the image and runs it against Postgres: -**Frontend** — React, TypeScript, Vite, React Query, React Router, Axios, Tailwind CSS, React Hook Form, Zod, React Hot Toast +```sh +docker compose up --build +``` ---- +Then open [localhost:8000](http://localhost:8000/) and sign in as `admin@example.com` / `admin-demo-password`. The API docs are at [/api/docs/](http://localhost:8000/api/docs/). + +**With the published image.** No clone needed. SQLite stands in for Postgres here, so this is only for trying it out: + +```sh +docker run -d --rm --name lead-demo -p 127.0.0.1:8000:8000 \ + -e SECRET_KEY=try-it-locally-only-not-a-real-secret-key \ + -e DATABASE_URL=sqlite:////tmp/demo.sqlite3 \ + -e ALLOWED_HOSTS=localhost,127.0.0.1 \ + -e CORS_ALLOWED_ORIGINS=http://localhost:8000 \ + -e CSRF_TRUSTED_ORIGINS=http://localhost:8000 \ + -e SECURE_SSL_REDIRECT=False \ + -e DJANGO_SUPERUSER_EMAIL=admin@example.com \ + -e DJANGO_SUPERUSER_PASSWORD=admin-demo-password \ + ghcr.io/code-kasha/lead-platform:v1.0.0 +``` -## Project Structure +Then open [localhost:8000](http://localhost:8000/). Run `docker stop lead-demo` when finished. -``` -backend/ - apps/ - accounts/ authentication, users, permissions - common/ shared models, pagination, serializers, utils - leads/ leads, notes, activities - services/ business logic - serializers/ request & response shapes - filters/ query filtering - config/ - settings/ split settings (base / development / production) +**With Python and Node.js.** Requires Python 3.12 or later, Node.js 22 and [pnpm](https://pnpm.io/). Copy `.env.example` to `.env`, then set `SECRET_KEY` to any long random string and `DATABASE_URL` to a Postgres URL, or to `sqlite:///db.sqlite3` to try it out. -frontend/ - src/ - api/ axios client + generated OpenAPI types - components/ reusable UI and layout - layouts/ page shells - pages/ route-level views - routes/ routing and route guards - types/ shared types +```sh +cd backend +python -m venv .venv && source .venv/bin/activate # Windows: .venv\Scripts\activate +pip install -r requirements.txt +python manage.py migrate +python manage.py createsuperuser +python manage.py runserver # API on http://127.0.0.1:8000 ``` -Business logic lives in dedicated service functions; views stay responsible only for request handling. +In a second terminal: -``` -Request → View → Serializer → Service → Database +```sh +cd frontend +pnpm install +pnpm dev # app on http://localhost:5173 ``` ---- +## The backend -## Business Rules +A Django project with three apps: `accounts` (users, roles, JWT), `leads` (leads, notes, activities) and `common` (shared base model, pagination, the web app's catch-all route). -Leads move through a configurable lifecycle: +### Layers -``` -NEW → CONTACTED → QUALIFIED → PROPOSAL → WON +```text +Request → View → Serializer → Service → Database ``` -A lead may also transition to **LOST** from any intermediate stage where permitted. Invalid transitions are rejected at the service layer, and every significant action writes an activity record automatically. +- **Views** handle HTTP only: permissions, picking a serializer, calling a service, returning a response. +- **Serializers** validate input and shape output. They are split by purpose under `apps/leads/serializers/`, so the fields a client may write (contact details) are separate from the fields it only reads (status, owner, assignee). +- **Services** (`apps/leads/services.py`) hold the business rules. Each one is a `@transaction.atomic` function with keyword-only arguments, and they are the **only** code that writes activity records. A change and its timeline entry are saved together, or neither is. ---- +### Rules the API enforces -## Getting Started +- **The status pipeline.** Allowed transitions are one mapping in `apps/leads/constants.py`, applied by `change_lead_status`. Status can only change through `POST /api/leads/{id}/status/`; a `status` sent to the edit endpoints is ignored. +- **Visibility and roles.** The lead queryset itself is filtered by role, so a Member asking for someone else's lead gets `404`, not `403`, and the API doesn't reveal that it exists. Object permissions decide who may edit, delete, assign, change status, and edit or delete a note. [The full table](docs/api.md#roles-and-access) is in the API reference. +- **Tokens.** Access tokens last 15 minutes. Refresh tokens last 7 days, rotate on every use, and are blacklisted once used or on logout. +- **The public form** (`POST /api/leads/public/`) needs no login and is throttled per client (`PUBLIC_LEADS_RATE`, default 20 an hour). Its leads have no creator, and their timeline says they came from the form. +- **Listing.** Filter by status, assignee, creator and company. Search name, email, phone and company. Sort by created, updated, name or status. Pages are 20 by default, up to 100. Querysets use `select_related`, so a page costs a fixed number of queries. -### Prerequisites +### API-first -- Python 3.12+ (Django 6.0) -- Node.js 22 and pnpm 10 (the versions CI uses) -- PostgreSQL (or set `DATABASE_URL` to a hosted instance) -- Or just Docker: `docker compose up --build` runs the production image locally (see [Deployment](#deployment)) +`drf-spectacular` generates the OpenAPI 3 schema from the code, with operation IDs, examples and error responses. The schema is committed as [`backend/schema.yml`](backend/schema.yml), and the frontend generates its TypeScript types from it with `openapi-typescript`. CI regenerates both (with `--validate --fail-on-warn`) and fails if either committed copy differs. The code, the docs and the client can't drift apart. -### Environment +### Production settings -Copy the example file and fill in your own values: +- Settings are split into `base`, `development` and `production`, picked by `DJANGO_ENV`. +- Production refuses to start if `SECRET_KEY`, `DATABASE_URL` or the host and origin lists are missing, and names the missing one. +- It uses HTTPS redirects, secure cookies, HSTS (configurable), timestamped console logging, and WhiteNoise for static files with compression and long-lived caching of hashed assets. +- The container applies migrations and creates the first admin from environment variables on start. This is idempotent, and it never overwrites a changed password. -```bash -cp .env.example .env -``` +### Tests -| Variable | Description | -| --- | --- | -| `SECRET_KEY` | Django secret key | -| `DEBUG` | `True` for local development | -| `DJANGO_ENV` | `development` or `production` | -| `ALLOWED_HOSTS` | Comma-separated host list (required in production) | -| `CORS_ALLOWED_ORIGINS` | Production only, required: comma-separated frontend origins allowed to call the API | -| `CSRF_TRUSTED_ORIGINS` | Production only, required: comma-separated trusted origins for CSRF | -| `HSTS_SECONDS` | Production only: HSTS max-age in seconds (default `3600`); raise in steps once HTTPS is stable | -| `HSTS_INCLUDE_SUBDOMAINS` / `HSTS_PRELOAD` | Production only: default `False`; enable only if every subdomain serves HTTPS | -| `LOG_LEVEL` | Production only: minimum level written to the console log (default `INFO`) | -| `DJANGO_SUPERUSER_EMAIL` / `DJANGO_SUPERUSER_PASSWORD` | Optional: create the first admin account on start, once (see `docs/DEPLOY.md`) | -| `DATABASE_URL` | Database connection URL, e.g. `postgres://user:pass@localhost:5432/db` | -| `VITE_API_URL` | Base URL of the API, e.g. `http://127.0.0.1:8000/api` (read by Vite from this same root `.env`) | - -### Backend - -```bash -cd backend -pip install -r requirements.txt -python manage.py migrate -python manage.py createsuperuser -python manage.py runserver -``` +99 pytest tests across the apps. They cover: -### Frontend +- Login, token refresh with rotation and blacklisting, logout, and the current user. +- Visibility for each role, and every permission rule. +- Create and edit, and the activities they record. +- Every allowed and blocked status transition. +- Assignment, and notes and their owners. +- Filtering, search, ordering and pagination. +- The public form and its rate limit. +- The production settings checks. -```bash -cd frontend -pnpm install -pnpm dev -``` +## The frontend -The frontend runs on `http://localhost:5173` and expects the API at `VITE_API_URL`. +React 19, TypeScript, Vite, TanStack Query, React Router, React Hook Form with Zod, and Tailwind CSS. ---- +- **Typed end to end:** `src/types/api.ts` is generated from the backend's schema, and the axios client and pages use those types. +- **Sign-in that survives expiry:** a route guard sends signed-out visitors to `/login` and returns them to the page they asked for. On a `401`, the client refreshes the token once, shared by every request waiting on it, because a rotated refresh token can only be used once. Then it retries, or ends the session cleanly. +- **Server state in TanStack Query:** mutations update or invalidate the exact queries they affect, so status, assignment, notes and the timeline stay in step without reloading the page. +- **Pages:** a public enquiry form, sign-in, a dashboard, a paginated lead list, create and edit forms, and a lead page with status, assignment, notes and the activity timeline. +- **74 Vitest + Testing Library tests** run against the rendered pages. The lead page tests use a fake API that enforces the backend's status rules. -## API Documentation +A lead in the Proposal stage: contact details, a status picker, an assignment picker, two notes by Jordan Lee, and a timeline of seven entries from Created through Assigned, three status changes and two notes. -With the backend running, Swagger UI is available at: +More in [`screenshots/`](screenshots/): the [public form](screenshots/public-form.png), [sign-in](screenshots/login.png), [dashboard](screenshots/dashboard.png), [lead form](screenshots/lead-form.png) and [Swagger UI](screenshots/swagger.png). -``` -/api/docs/ -``` +## Use it in your application -The OpenAPI schema is committed at `backend/schema.yml`. Frontend types are regenerated from it with `openapi-typescript`. +- **Call the API** from any language. It is plain JSON over HTTP with an OpenAPI schema at `/api/schema/`, so you can generate a client. The [API reference](docs/api.md) has every endpoint, rule and example. +- **Send enquiries from your website** to `POST /api/leads/public/`. Each one arrives as a new lead with its own timeline. +- **Run the Docker image** (`ghcr.io/code-kasha/lead-platform`) as your team's lead tracker, with any Postgres database. ---- +## Deploy it yourself -## Testing & Quality +[docs/DEPLOY.md](docs/DEPLOY.md) covers: +- Running the published image on any host. +- The free Render and Neon setup the live demo uses: `render.yaml` is a ready-made blueprint. +- Every environment variable. +- How the image is built. +- How CI publishes releases. -```bash -# backend -cd backend && pytest +## For developers -# frontend -cd frontend && pnpm lint && pnpm test && pnpm build +```text +backend/ + apps/accounts/ custom user (email login), roles, JWT views, ensure_superuser + apps/leads/ models, services, status transitions, permissions, filters, + serializers/ and views/ split by concern, OpenAPI docs, tests + apps/common/ base model, pagination, the web app's catch-all view + config/settings/ base, development and production, chosen by DJANGO_ENV + schema.yml the OpenAPI schema, committed and checked by CI +frontend/ + src/api/ axios client with single-flight token refresh + src/types/api.ts generated from backend/schema.yml + src/pages/ route-level views, with tests alongside + src/routes/ router and the auth guard + src/test/ render helpers, fixtures and a fake lead API +docs/ API reference, deployment, architecture notes +Dockerfile builds the web app, then one image serves API, admin and app ``` ---- - -## Screenshots - -Application screenshots are in [`screenshots/`](screenshots/). +The main checks, which CI also runs on every push and pull request along with a Docker build and smoke test: ---- +```sh +# backend/ +flake8 +python manage.py makemigrations --check --dry-run +python manage.py spectacular --validate --fail-on-warn --file schema.yml +pytest -## Deployment - -A single Docker image serves the API, the admin and the built frontend from one -origin. [`docs/DEPLOY.md`](docs/DEPLOY.md) walks through a free demo deployment -on Render (app) and Neon (Postgres), using the `render.yaml` blueprint, plus the -full list of production environment variables. - -```bash -docker compose up --build # production image + Postgres at http://localhost:8000 +# frontend/ +pnpm lint && pnpm test && pnpm build ``` ---- - -## Documentation +[`CONTRIBUTING.md`](CONTRIBUTING.md) lists the rules the code keeps. [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) and the numbered notes in [docs/](docs/) record the design decisions. -Additional design notes live in [`docs/`](docs/), including `ARCHITECTURE.md` and a set of assessment, migration, refactor, and standards write-ups. +## Where this could go ---- +lead-platform is finished, but it is the first piece of a fuller CRM, and there is plenty of room to grow it: -## Future Improvements +- **Contacts, companies and deals** as their own records, so a won lead becomes a customer with deals that have a value and an expected close date. +- **Tasks and reminders** on leads, with due dates and notifications for the assignee. +- **Pipeline analytics:** conversion rates between stages, time spent in each stage, and win rates by source and by member. +- **Email and calendar integration**, so conversations land on the timeline automatically. +- **Webhooks and CSV import and export**, to connect other tools and bring in existing lists. +- **Teams and multiple organisations**, with per-team pipelines and custom stages. +- **Background jobs and a shared cache** (Celery and Redis) for notifications, imports, and rate limits shared across workers. -- Email notifications -- Dashboard analytics -- File attachments -- Lead reminders -- Advanced reporting -- Bulk lead operations +## Contributing, license and credit ---- +lead-platform is complete as of v1.0.0 and not actively maintained: issues and pull requests may go unanswered, so fork it freely. The code is under the [MIT License](LICENSE) with no extra conditions. -## Author +If you build on it, [`CONTRIBUTING.md`](CONTRIBUTING.md) explains how the code is organised and how to release a fork. A mention is appreciated, never required. -Akash Damle +Created by Akash Damle ([@code-kasha](https://github.com/code-kasha)). diff --git a/backend/apps/leads/docs.py b/backend/apps/leads/docs.py index fdebf76..a1a42a2 100644 --- a/backend/apps/leads/docs.py +++ b/backend/apps/leads/docs.py @@ -17,13 +17,22 @@ from drf_spectacular.types import OpenApiTypes from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema +# Named after the URL segment ({id}) so it describes, rather than duplicates, +# the path parameter LEAD_PK_PARAMETER = OpenApiParameter( - name="pk", + name="id", type=OpenApiTypes.INT, location=OpenApiParameter.PATH, description="Unique lead identifier.", ) +NOTE_ID_PARAMETER = OpenApiParameter( + name="id", + type=OpenApiTypes.INT, + location=OpenApiParameter.PATH, + description="Unique note identifier.", +) + lead_list = extend_schema( tags=["Leads"], summary="List visible leads", @@ -186,28 +195,32 @@ }, ) +_NOTE_UPDATE_RESPONSES = { + 200: LeadNoteSerializer, + 400: BAD_REQUEST, + 401: UNAUTHORIZED, + 403: FORBIDDEN, + 404: NOT_FOUND, +} + lead_update_note = extend_schema( tags=["Leads"], operation_id="updateLeadNote", summary="Update a lead note", - description="Update the content of a lead note.", - parameters=[ - LEAD_PK_PARAMETER, - OpenApiParameter( - name="note_pk", - type=OpenApiTypes.INT, - location=OpenApiParameter.PATH, - description="Unique note identifier.", - ), - ], + description="Update the content of a lead note. Only its author or an administrator may edit it.", + parameters=[NOTE_ID_PARAMETER], request=LeadNoteUpdateSerializer, - responses={ - 200: LeadNoteSerializer, - 400: BAD_REQUEST, - 401: UNAUTHORIZED, - 403: FORBIDDEN, - 404: NOT_FOUND, - }, + responses=_NOTE_UPDATE_RESPONSES, +) + +lead_replace_note = extend_schema( + tags=["Leads"], + operation_id="replaceLeadNote", + summary="Replace a lead note", + description="Replace the content of a lead note (PUT). Same rules as updating it.", + parameters=[NOTE_ID_PARAMETER], + request=LeadNoteUpdateSerializer, + responses=_NOTE_UPDATE_RESPONSES, ) lead_delete_note = extend_schema( @@ -215,14 +228,7 @@ operation_id="deleteLeadNote", summary="Delete a lead note", description="Permanently delete a lead note the authenticated user may manage.", - parameters=[ - OpenApiParameter( - name="pk", - type=OpenApiTypes.INT, - location=OpenApiParameter.PATH, - description="Unique note identifier.", - ), - ], + parameters=[NOTE_ID_PARAMETER], responses={ 204: OpenApiResponse( description="Lead note deleted successfully.", diff --git a/backend/apps/leads/services.py b/backend/apps/leads/services.py index 18d6932..cad19a5 100644 --- a/backend/apps/leads/services.py +++ b/backend/apps/leads/services.py @@ -2,6 +2,8 @@ # Lead Business Services # ============================================================================== +from typing import Any + from apps.accounts.models import User from apps.leads.choices import ActivityType, LeadStatus from apps.leads.constants import ALLOWED_STATUS_TRANSITIONS @@ -10,6 +12,75 @@ from rest_framework.exceptions import ValidationError +@transaction.atomic +def create_lead( + *, + data: dict[str, Any], + created_by: User | None, +) -> Lead: + """Create a lead in the NEW status and record its creation activity. + + `created_by` is None for leads submitted through the public form. + """ + + lead = Lead.objects.create( + **data, + created_by=created_by, + ) + + description = ( + f"Lead created by {created_by.get_full_name()}." + if created_by is not None + else "Lead submitted through the public form." + ) + + LeadActivity.objects.create( + lead=lead, + user=created_by, + activity_type=ActivityType.CREATED, + description=description, + ) + + return lead + + +@transaction.atomic +def update_lead( + *, + lead: Lead, + data: dict[str, Any], + performed_by: User, +) -> Lead: + """Apply field changes to a lead and record which fields changed. + + Status and assignment have their own services; an update that changes + nothing is saved as a no-op and records no activity. + """ + + changed = [field for field, value in data.items() if getattr(lead, field) != value] + + if not changed: + return lead + + for field in changed: + setattr(lead, field, data[field]) + + # Input is validated by the serializer before it reaches the service + lead.save(update_fields=[*changed, "updated_at"]) + + labels = [field.replace("_", " ") for field in changed] + listed = labels[0] if len(labels) == 1 else f"{', '.join(labels[:-1])} and {labels[-1]}" + + LeadActivity.objects.create( + lead=lead, + user=performed_by, + activity_type=ActivityType.UPDATED, + description=f"{performed_by.get_full_name()} updated {listed}.", + ) + + return lead + + @transaction.atomic def add_lead_note( *, diff --git a/backend/apps/leads/tests/test_lead_api.py b/backend/apps/leads/tests/test_lead_api.py new file mode 100644 index 0000000..9eb7b63 --- /dev/null +++ b/backend/apps/leads/tests/test_lead_api.py @@ -0,0 +1,278 @@ +# ============================================================================== +# Lead API Tests +# ============================================================================== + +from typing import Any, cast +from unittest import mock + +from apps.accounts.models import User +from apps.accounts.tests.factories import create_admin, create_user +from apps.leads.choices import ActivityType, LeadStatus +from apps.leads.models import Lead, LeadActivity +from apps.leads.tests.factories import create_lead +from django.core.cache import cache +from rest_framework import status +from rest_framework.response import Response +from rest_framework.test import APIClient, APITestCase +from rest_framework.throttling import ScopedRateThrottle +from rest_framework_simplejwt.tokens import RefreshToken + +LEADS_URL = "/api/leads/" +PUBLIC_URL = "/api/leads/public/" + +NEW_LEAD = { + "first_name": "Ada", + "last_name": "Lovelace", + "email": "ada@example.com", + "phone": "5550100", + "company": "Engines Ltd", + "source": "Referral", +} + + +class LeadApiTestCase(APITestCase): + """Shared users and helpers for lead API tests.""" + + client: APIClient + + def setUp(self) -> None: + cache.clear() + + self.admin = create_admin() + self.member = create_user(email="member@test.com") + self.other = create_user(email="other@test.com") + + def authenticate(self, user: User) -> None: + """Send requests as the given user.""" + + token = RefreshToken.for_user(user).access_token + cast(APIClient, self.client).credentials(HTTP_AUTHORIZATION=f"Bearer {token}") + + def data(self, response: Any) -> Any: + return cast(Response, response).data + + +# ============================================================================== +# Visibility +# ============================================================================== + + +class LeadVisibilityTests(LeadApiTestCase): + """Members only see leads they created or are assigned to.""" + + def setUp(self) -> None: + super().setUp() + + self.created = create_lead(first_name="Created", created_by=self.member) + self.assigned = create_lead(first_name="Assigned", created_by=self.admin, assigned_to=self.member) + self.hidden = create_lead(first_name="Hidden", created_by=self.other) + + def names(self) -> set[str]: + return {lead["first_name"] for lead in self.data(self.client.get(LEADS_URL))["results"]} + + def test_admin_sees_every_lead(self) -> None: + self.authenticate(self.admin) + + self.assertEqual(self.names(), {"Created", "Assigned", "Hidden"}) + + def test_member_sees_created_and_assigned_leads_only(self) -> None: + self.authenticate(self.member) + + self.assertEqual(self.names(), {"Created", "Assigned"}) + + def test_member_cannot_retrieve_another_members_lead(self) -> None: + self.authenticate(self.member) + + response = self.client.get(f"{LEADS_URL}{self.hidden.pk}/") + + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + def test_anonymous_requests_are_rejected(self) -> None: + response = self.client.get(LEADS_URL) + + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + + +# ============================================================================== +# Create, update, delete +# ============================================================================== + + +class LeadWriteTests(LeadApiTestCase): + """Creating, editing and deleting leads, and the activity they record.""" + + def test_create_sets_owner_and_new_status_and_records_activity(self) -> None: + self.authenticate(self.member) + + response = self.client.post(LEADS_URL, NEW_LEAD, format="json") + + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + lead = Lead.objects.get(pk=self.data(response)["id"]) + self.assertEqual(lead.created_by, self.member) + self.assertEqual(lead.status, LeadStatus.NEW) + self.assertIsNone(lead.assigned_to) + + activity = LeadActivity.objects.get(lead=lead) + self.assertEqual(activity.activity_type, ActivityType.CREATED) + self.assertEqual(activity.user, self.member) + self.assertEqual(activity.description, "Lead created by Test User.") + + def test_create_rejects_a_phone_number_with_letters(self) -> None: + self.authenticate(self.member) + + response = self.client.post(LEADS_URL, {**NEW_LEAD, "phone": "555-CALL"}, format="json") + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn("phone", self.data(response)) + self.assertFalse(Lead.objects.exists()) + + def test_update_records_which_fields_changed(self) -> None: + lead = create_lead(created_by=self.member, company="Old Co", source="Website") + self.authenticate(self.member) + + response = self.client.patch( + f"{LEADS_URL}{lead.pk}/", + {"company": "New Co", "source": "Referral"}, + format="json", + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + lead.refresh_from_db() + self.assertEqual((lead.company, lead.source), ("New Co", "Referral")) + + activity = LeadActivity.objects.get(lead=lead, activity_type=ActivityType.UPDATED) + self.assertEqual(activity.user, self.member) + self.assertEqual(activity.description, "Test User updated company and source.") + + def test_update_without_changes_records_nothing(self) -> None: + lead = create_lead(created_by=self.member, company="Same Co") + self.authenticate(self.member) + + response = self.client.patch(f"{LEADS_URL}{lead.pk}/", {"company": "Same Co"}, format="json") + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertFalse(LeadActivity.objects.filter(lead=lead, activity_type=ActivityType.UPDATED).exists()) + + def test_update_cannot_change_status_or_owner(self) -> None: + lead = create_lead(created_by=self.member) + self.authenticate(self.member) + + self.client.patch( + f"{LEADS_URL}{lead.pk}/", + {"status": LeadStatus.WON, "created_by": self.other.pk, "assigned_to": self.other.pk}, + format="json", + ) + + lead.refresh_from_db() + self.assertEqual(lead.status, LeadStatus.NEW) + self.assertEqual(lead.created_by, self.member) + self.assertIsNone(lead.assigned_to) + + def test_assignee_can_edit_but_not_delete(self) -> None: + lead = create_lead(created_by=self.admin, assigned_to=self.member) + self.authenticate(self.member) + + edit = self.client.patch(f"{LEADS_URL}{lead.pk}/", {"company": "Edited"}, format="json") + delete = self.client.delete(f"{LEADS_URL}{lead.pk}/") + + self.assertEqual(edit.status_code, status.HTTP_200_OK) + self.assertEqual(delete.status_code, status.HTTP_403_FORBIDDEN) + self.assertTrue(Lead.objects.filter(pk=lead.pk).exists()) + + def test_admin_can_delete(self) -> None: + lead = create_lead(created_by=self.member) + self.authenticate(self.admin) + + response = self.client.delete(f"{LEADS_URL}{lead.pk}/") + + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.assertFalse(Lead.objects.filter(pk=lead.pk).exists()) + + +# ============================================================================== +# Filtering, search, ordering, pagination +# ============================================================================== + + +class LeadQueryTests(LeadApiTestCase): + """Query parameters on the lead list.""" + + def setUp(self) -> None: + super().setUp() + self.authenticate(self.admin) + + create_lead(first_name="Carol", company="Umbrella", status=LeadStatus.NEW, created_by=self.admin) + create_lead(first_name="Alice", company="Acme", status=LeadStatus.QUALIFIED, created_by=self.admin) + create_lead( + first_name="Bob", + company="Acme", + status=LeadStatus.NEW, + created_by=self.admin, + assigned_to=self.member, + ) + + def names(self, query: str) -> list[str]: + return [lead["first_name"] for lead in self.data(self.client.get(f"{LEADS_URL}?{query}"))["results"]] + + def test_filter_by_status(self) -> None: + self.assertEqual(sorted(self.names("status=NEW")), ["Bob", "Carol"]) + + def test_filter_by_assignee(self) -> None: + self.assertEqual(self.names(f"assigned_to={self.member.pk}"), ["Bob"]) + + def test_search_across_name_email_phone_and_company(self) -> None: + self.assertEqual(sorted(self.names("search=acme")), ["Alice", "Bob"]) + + def test_ordering(self) -> None: + self.assertEqual(self.names("ordering=first_name"), ["Alice", "Bob", "Carol"]) + self.assertEqual(self.names("ordering=-first_name"), ["Carol", "Bob", "Alice"]) + + def test_pagination_envelope_and_page_size(self) -> None: + body = self.data(self.client.get(f"{LEADS_URL}?page_size=2&ordering=first_name")) + + self.assertEqual(body["count"], 3) + self.assertEqual([lead["first_name"] for lead in body["results"]], ["Alice", "Bob"]) + self.assertIsNotNone(body["next"]) + + +# ============================================================================== +# Public form +# ============================================================================== + + +class PublicLeadTests(LeadApiTestCase): + """The unauthenticated lead form.""" + + def test_anonymous_visitor_can_submit_a_lead(self) -> None: + response = self.client.post(PUBLIC_URL, NEW_LEAD, format="json") + + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + lead = Lead.objects.get(pk=self.data(response)["id"]) + self.assertIsNone(lead.created_by) + self.assertEqual(lead.status, LeadStatus.NEW) + + activity = LeadActivity.objects.get(lead=lead) + self.assertEqual(activity.activity_type, ActivityType.CREATED) + self.assertIsNone(activity.user) + self.assertEqual(activity.description, "Lead submitted through the public form.") + + def test_public_submissions_are_rate_limited(self) -> None: + with mock.patch.dict(ScopedRateThrottle.THROTTLE_RATES, {"public_leads": "2/hour"}): + codes = [ + self.client.post(PUBLIC_URL, {**NEW_LEAD, "email": f"lead{i}@example.com"}, format="json").status_code + for i in range(3) + ] + + self.assertEqual(codes, [201, 201, 429]) + self.assertEqual(Lead.objects.count(), 2) + + def test_signed_in_endpoints_are_not_rate_limited_by_the_public_scope(self) -> None: + self.authenticate(self.member) + + with mock.patch.dict(ScopedRateThrottle.THROTTLE_RATES, {"public_leads": "1/hour"}): + codes = [ + self.client.post(LEADS_URL, {**NEW_LEAD, "email": f"lead{i}@example.com"}, format="json").status_code + for i in range(3) + ] + + self.assertEqual(codes, [201, 201, 201]) diff --git a/backend/apps/leads/views/lead.py b/backend/apps/leads/views/lead.py index 1e746f5..fc2bdcb 100644 --- a/backend/apps/leads/views/lead.py +++ b/backend/apps/leads/views/lead.py @@ -31,7 +31,7 @@ LeadSerializer, LeadUpdateSerializer, ) -from apps.leads.services import add_lead_note, assign_lead, change_lead_status +from apps.leads.services import add_lead_note, assign_lead, change_lead_status, create_lead, update_lead from django.db.models import Q from django.db.models.query import QuerySet from django_filters.rest_framework import DjangoFilterBackend @@ -43,6 +43,7 @@ from rest_framework.permissions import AllowAny from rest_framework.request import Request from rest_framework.response import Response +from rest_framework.throttling import ScopedRateThrottle from rest_framework.viewsets import ModelViewSet @@ -149,8 +150,9 @@ def create(self, request: Request, *args, **kwargs) -> Response: raise_exception=True, ) - lead = serializer.save( - created_by=request.user, + lead = create_lead( + data=serializer.validated_data, + created_by=cast(User, request.user), ) output = LeadSerializer( @@ -163,14 +165,16 @@ def create(self, request: Request, *args, **kwargs) -> Response: status=status.HTTP_201_CREATED, ) - def perform_create( + def perform_update( self, serializer, ) -> None: - """Create a lead owned by the authenticated user.""" + """Apply the edit through the service so the change is recorded.""" - serializer.save( - created_by=self.request.user, + serializer.instance = update_lead( + lead=serializer.instance, + data=serializer.validated_data, + performed_by=cast(User, self.request.user), ) @action( @@ -295,6 +299,8 @@ def add_note( CanChangeStatus, ], url_path="notes/list", + filter_backends=[], + pagination_class=None, ) def list_notes( self, @@ -323,6 +329,8 @@ def list_notes( CanChangeStatus, ], url_path="activities", + filter_backends=[], + pagination_class=None, ) def list_activities( self, @@ -357,6 +365,10 @@ class PublicLeadCreateView(CreateAPIView): serializer_class = LeadCreateSerializer permission_classes = [AllowAny] + # Anonymous and open to the internet, so cap submissions per client + throttle_classes = [ScopedRateThrottle] + throttle_scope = "public_leads" + def create(self, request: Request, *args, **kwargs) -> Response: serializer = self.get_serializer( data=request.data, @@ -366,7 +378,8 @@ def create(self, request: Request, *args, **kwargs) -> Response: raise_exception=True, ) - lead = serializer.save( + lead = create_lead( + data=serializer.validated_data, created_by=None, ) diff --git a/backend/apps/leads/views/note.py b/backend/apps/leads/views/note.py index 1f3ce9d..58bfbaf 100644 --- a/backend/apps/leads/views/note.py +++ b/backend/apps/leads/views/note.py @@ -6,7 +6,8 @@ from apps.accounts.choices import UserRole from apps.accounts.models import User -from apps.leads.docs import lead_delete_note, lead_update_note +from apps.leads.docs import lead_delete_note, lead_replace_note, lead_update_note +from apps.leads.services import delete_lead_note, update_lead_note from apps.leads.models import LeadNote from apps.leads.permissions import CanManageLeadNote from apps.leads.serializers import LeadNoteSerializer, LeadNoteUpdateSerializer @@ -19,6 +20,7 @@ @extend_schema_view( + update=lead_replace_note, partial_update=lead_update_note, destroy=lead_delete_note, ) @@ -74,3 +76,17 @@ def get_queryset(self) -> QuerySet[LeadNote]: return self.queryset return self.queryset.filter(Q(author=user) | Q(lead__created_by=user) | Q(lead__assigned_to=user)).distinct() + + def perform_update(self, serializer) -> None: + """Save the edit through the notes service.""" + + serializer.instance = update_lead_note( + note=serializer.instance, + # A PATCH may omit content; keep the current text then + content=serializer.validated_data.get("content", serializer.instance.content), + ) + + def perform_destroy(self, instance: LeadNote) -> None: + """Delete through the notes service.""" + + delete_lead_note(note=instance) diff --git a/backend/config/settings/rest.py b/backend/config/settings/rest.py index d2b6967..84d4dda 100644 --- a/backend/config/settings/rest.py +++ b/backend/config/settings/rest.py @@ -4,6 +4,8 @@ from datetime import timedelta +from decouple import config + REST_FRAMEWORK = { "DEFAULT_AUTHENTICATION_CLASSES": [ "rest_framework_simplejwt.authentication.JWTAuthentication", @@ -19,6 +21,10 @@ "DEFAULT_PAGINATION_CLASS": "apps.common.pagination.DefaultPagination", "DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema", "PAGE_SIZE": 20, + # Per-client limit on the unauthenticated public lead form + "DEFAULT_THROTTLE_RATES": { + "public_leads": config("PUBLIC_LEADS_RATE", default="20/hour"), + }, } diff --git a/backend/config/settings/spectacular.py b/backend/config/settings/spectacular.py index 45e0952..2c2926c 100644 --- a/backend/config/settings/spectacular.py +++ b/backend/config/settings/spectacular.py @@ -12,6 +12,10 @@ "SWAGGER_UI_SETTINGS": { "persistAuthorization": True, }, + # Serve Swagger UI and ReDoc from the pinned sidecar package, not a CDN + "SWAGGER_UI_DIST": "SIDECAR", + "SWAGGER_UI_FAVICON_HREF": "SIDECAR", + "REDOC_DIST": "SIDECAR", "TAGS": [ { "name": "Authentication", diff --git a/backend/schema.yml b/backend/schema.yml index 4110465..806a6a6 100644 --- a/backend/schema.yml +++ b/backend/schema.yml @@ -339,12 +339,6 @@ paths: parameters: - in: path name: id - schema: - type: integer - description: A unique integer value identifying this lead. - required: true - - in: path - name: pk schema: type: integer description: Unique lead identifier. @@ -386,12 +380,6 @@ paths: parameters: - in: path name: id - schema: - type: integer - description: A unique integer value identifying this lead. - required: true - - in: path - name: pk schema: type: integer description: Unique lead identifier. @@ -451,12 +439,6 @@ paths: parameters: - in: path name: id - schema: - type: integer - description: A unique integer value identifying this lead. - required: true - - in: path - name: pk schema: type: integer description: Unique lead identifier. @@ -514,12 +496,6 @@ paths: parameters: - in: path name: id - schema: - type: integer - description: A unique integer value identifying this lead. - required: true - - in: path - name: pk schema: type: integer description: Unique lead identifier. @@ -556,72 +532,12 @@ paths: user. summary: List lead activities parameters: - - in: query - name: assigned_to - schema: - type: integer - - in: query - name: company - schema: - type: string - - in: query - name: created_by - schema: - type: integer - in: path name: id - schema: - type: integer - description: A unique integer value identifying this lead. - required: true - - name: ordering - required: false - in: query - description: Which field to use when ordering the results. - schema: - type: string - - name: page - required: false - in: query - description: A page number within the paginated result set. - schema: - type: integer - - name: page_size - required: false - in: query - description: Number of results to return per page. - schema: - type: integer - - in: path - name: pk schema: type: integer description: Unique lead identifier. required: true - - name: search - required: false - in: query - description: A search term. - schema: - type: string - - in: query - name: status - schema: - type: string - enum: - - CONTACTED - - LOST - - NEW - - PROPOSAL - - QUALIFIED - - WON - description: |- - * `NEW` - New - * `CONTACTED` - Contacted - * `QUALIFIED` - Qualified - * `PROPOSAL` - Proposal - * `WON` - Won - * `LOST` - Lost tags: - Leads security: @@ -631,7 +547,9 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/PaginatedLeadActivityList' + type: array + items: + $ref: '#/components/schemas/LeadActivity' description: '' '401': content: @@ -659,12 +577,6 @@ paths: parameters: - in: path name: id - schema: - type: integer - description: A unique integer value identifying this lead. - required: true - - in: path - name: pk schema: type: integer description: Unique lead identifier. @@ -781,66 +693,12 @@ paths: description: Retrieve all notes for a lead visible to the authenticated user. summary: List lead notes parameters: - - in: query - name: assigned_to - schema: - type: integer - - in: query - name: company - schema: - type: string - - in: query - name: created_by - schema: - type: integer - in: path name: id schema: type: integer description: A unique integer value identifying this lead. required: true - - name: ordering - required: false - in: query - description: Which field to use when ordering the results. - schema: - type: string - - name: page - required: false - in: query - description: A page number within the paginated result set. - schema: - type: integer - - name: page_size - required: false - in: query - description: Number of results to return per page. - schema: - type: integer - - name: search - required: false - in: query - description: A search term. - schema: - type: string - - in: query - name: status - schema: - type: string - enum: - - CONTACTED - - LOST - - NEW - - PROPOSAL - - QUALIFIED - - WON - description: |- - * `NEW` - New - * `CONTACTED` - Contacted - * `QUALIFIED` - Qualified - * `PROPOSAL` - Proposal - * `WON` - Won - * `LOST` - Lost tags: - Leads security: @@ -850,7 +708,9 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/PaginatedLeadNoteList' + type: array + items: + $ref: '#/components/schemas/LeadNote' description: '' '401': content: @@ -878,12 +738,6 @@ paths: parameters: - in: path name: id - schema: - type: integer - description: A unique integer value identifying this lead. - required: true - - in: path - name: pk schema: type: integer description: Unique lead identifier. @@ -937,28 +791,30 @@ paths: description: Requested resource was not found. /api/leads/notes/{id}/: put: - operationId: leads_notes_update - description: Provide update and delete operations for lead notes. + operationId: replaceLeadNote + description: Replace the content of a lead note (PUT). Same rules as updating + it. + summary: Replace a lead note parameters: - in: path name: id schema: type: integer - description: A unique integer value identifying this lead note. + description: Unique note identifier. required: true tags: - - leads + - Leads requestBody: content: application/json: schema: - $ref: '#/components/schemas/LeadNoteRequest' + $ref: '#/components/schemas/LeadNoteUpdateRequest' application/x-www-form-urlencoded: schema: - $ref: '#/components/schemas/LeadNoteRequest' + $ref: '#/components/schemas/LeadNoteUpdateRequest' multipart/form-data: schema: - $ref: '#/components/schemas/LeadNoteRequest' + $ref: '#/components/schemas/LeadNoteUpdateRequest' required: true security: - jwtAuth: [] @@ -969,29 +825,42 @@ paths: schema: $ref: '#/components/schemas/LeadNote' description: '' + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + description: The request could not be processed. + '401': + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + description: Authentication credentials are invalid or missing. + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + description: You do not have permission to perform this action. + '404': + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + description: Requested resource was not found. patch: operationId: updateLeadNote - description: Update the content of a lead note. + description: Update the content of a lead note. Only its author or an administrator + may edit it. summary: Update a lead note parameters: - in: path name: id - schema: - type: integer - description: A unique integer value identifying this lead note. - required: true - - in: path - name: note_pk schema: type: integer description: Unique note identifier. required: true - - in: path - name: pk - schema: - type: integer - description: Unique lead identifier. - required: true tags: - Leads requestBody: @@ -1045,12 +914,6 @@ paths: parameters: - in: path name: id - schema: - type: integer - description: A unique integer value identifying this lead note. - required: true - - in: path - name: pk schema: type: integer description: Unique note identifier. @@ -1328,18 +1191,15 @@ components: minLength: 1 required: - content - LeadNoteRequest: + LeadNoteUpdateRequest: type: object - description: Serializer used for retrieving lead notes. + description: Serializer used when updating a lead note. properties: - lead: - type: integer content: type: string minLength: 1 required: - content - - lead LeadUpdateRequest: type: object description: Validate the fields accepted when updating a lead. @@ -1397,29 +1257,6 @@ components: description: Refresh token to invalidate. required: - refresh - PaginatedLeadActivityList: - type: object - required: - - count - - results - properties: - count: - type: integer - example: 123 - next: - type: string - nullable: true - format: uri - example: http://api.example.org/accounts/?page=4 - previous: - type: string - nullable: true - format: uri - example: http://api.example.org/accounts/?page=2 - results: - type: array - items: - $ref: '#/components/schemas/LeadActivity' PaginatedLeadList: type: object required: @@ -1443,29 +1280,6 @@ components: type: array items: $ref: '#/components/schemas/Lead' - PaginatedLeadNoteList: - type: object - required: - - count - - results - properties: - count: - type: integer - example: 123 - next: - type: string - nullable: true - format: uri - example: http://api.example.org/accounts/?page=4 - previous: - type: string - nullable: true - format: uri - example: http://api.example.org/accounts/?page=2 - results: - type: array - items: - $ref: '#/components/schemas/LeadNote' PaginatedUserSummaryList: type: object required: diff --git a/docs/DEPLOY.md b/docs/DEPLOY.md index 5d2fc1c..ab3c5e5 100644 --- a/docs/DEPLOY.md +++ b/docs/DEPLOY.md @@ -1,4 +1,12 @@ -# Deployment (free demo hosting) +# Deployment + +Three ways to run lead-platform: + +- **[The published image](#running-the-published-image)** from the GitHub Container Registry, on any host that runs containers. +- **[Render and Neon](#1-create-the-database-neon)**, free, which is how the [live demo](https://lead-platform-c3mw.onrender.com/) runs. +- **[`docker compose`](#running-the-production-image-locally)** on your own machine, with a local Postgres. + +## Free demo hosting One Docker image serves the whole app: the Django API, the Django admin, and the built React frontend (same origin, so no CORS setup). It runs on **Render's free @@ -15,7 +23,7 @@ which is why the database lives on Neon. --- -## 1. Create the database (Neon) +### 1. Create the database (Neon) 1. Sign up at [neon.com](https://neon.com) (GitHub login works; no card). 2. Create a project: pick **Postgres 17** and the region closest to your users. @@ -31,7 +39,7 @@ which is why the database lives on Neon. Use the direct (non-pooled) string: Django keeps its own connections open and doesn't work well behind the pooler's transaction mode. -## 2. Deploy the app (Render) +### 2. Deploy the app (Render) 1. Sign up at [render.com](https://render.com) with GitHub (no card) and give Render access to this repository. @@ -50,7 +58,7 @@ which is why the database lives on Neon. runs migrations, creates the admin account once, then starts the server. 4. Open the service URL (shown at the top of the service page) and log in. -## 3. After the first deploy +### 3. After the first deploy - **Team members**: log in at `/admin/` with the admin account and add users. New users get the *Member* role; set *Admin* where needed. There is no @@ -62,13 +70,31 @@ which is why the database lives on Neon. - **Logs**: Render's *Logs* tab shows requests and any errors with tracebacks (`LOG_LEVEL` controls verbosity, default `INFO`). -## Ending the demo +### Ending the demo Delete the service in Render and the project in Neon. Nothing is billed and no card is on file, so there is nothing else to cancel. --- +## Running the published image + +Every release is published as `ghcr.io/code-kasha/lead-platform`, for amd64 and arm64. Tags are `v1.0.0` and `latest`. The image needs a Postgres database and a few settings (see [Environment variables](#environment-variables)): + +```bash +docker run -d --name lead-platform -p 8000:8000 \ + -e SECRET_KEY='a-long-random-value' \ + -e DATABASE_URL='postgresql://USER:PASSWORD@HOST:5432/DB?sslmode=require' \ + -e ALLOWED_HOSTS=leads.example.com \ + -e CORS_ALLOWED_ORIGINS=https://leads.example.com \ + -e CSRF_TRUSTED_ORIGINS=https://leads.example.com \ + -e DJANGO_SUPERUSER_EMAIL=you@example.com \ + -e DJANGO_SUPERUSER_PASSWORD='a-long-password' \ + ghcr.io/code-kasha/lead-platform:v1.0.0 +``` + +Put it behind a reverse proxy that terminates TLS, such as Caddy or nginx, or a platform that does it for you. On start the container applies migrations and creates the admin account if it doesn't exist. + ## Running the production image locally ```bash @@ -97,7 +123,8 @@ Optional: | --- | --- | --- | | `DJANGO_SUPERUSER_EMAIL` / `_PASSWORD` | unset | Creates the first admin once. `_FIRST_NAME` / `_LAST_NAME` are optional. | | `LOG_LEVEL` | `INFO` | Console log level | -| `HSTS_SECONDS` | `3600` | See the README before raising it | +| `PUBLIC_LEADS_RATE` | `20/hour` | Rate limit for the public lead form, per client (`N/second`, `minute`, `hour` or `day`) | +| `HSTS_SECONDS` | `3600` | Raise it in steps once HTTPS is stable. `HSTS_INCLUDE_SUBDOMAINS` and `HSTS_PRELOAD` default to `False` | | `WEB_CONCURRENCY` | `2` | Gunicorn workers; 2 fits a 512 MB instance | | `SECURE_SSL_REDIRECT` | `True` | Only turn off to try the image over plain HTTP | @@ -115,3 +142,28 @@ Optional: Django serves `index.html` for every path outside `/api/`, `/admin/` and `/static/`, so deep links like `/leads/7` survive a reload. WhiteNoise serves the hashed files under `/assets/` gzipped, with a one-year immutable cache. + +The public form's rate limit is counted in each gunicorn worker's memory, so with +`WEB_CONCURRENCY=2` a client can make up to twice the limit. For a strict limit +across workers and instances, point Django's `CACHES` at Redis or Memcached. + +## CI and releases + +`.github/workflows/ci.yml` runs on every pull request and push to `main`: + +- **Backend:** flake8, a missing-migrations check, a check that `backend/schema.yml` matches the code (with `--validate --fail-on-warn`), and pytest. +- **Frontend:** a check that `src/types/api.ts` matches the schema, ESLint, Vitest, and a production build. +- **Docker image:** builds the image, starts it, and smoke-tests it: a deep link to the web app, admin static files, a `401` from the API, and an admin login. + +Pushing a `v*` tag runs the same checks, then two more jobs: + +1. **Publish image:** checks that the tag matches `version` in `frontend/package.json`, then builds the image for amd64 and arm64 and pushes it to `ghcr.io/code-kasha/lead-platform` with the version tag and `latest`. +2. **GitHub Release:** creates the release, with notes taken from the matching `CHANGELOG.md` entry and `schema.yml` and `SHA256SUMS` attached. + +To release a new version: set `version` in `frontend/package.json`, add a `## X.Y.Z (date)` entry to `CHANGELOG.md`, merge, then tag the merge commit: + +```bash +git tag -a vX.Y.Z -m "vX.Y.Z" && git push origin vX.Y.Z +``` + +A new package on the GitHub Container Registry starts out private. To let others pull it, open it under the repository's **Packages** and set its visibility to **Public** (*Package settings → Change visibility*). diff --git a/docs/api.md b/docs/api.md new file mode 100644 index 0000000..12fbc40 --- /dev/null +++ b/docs/api.md @@ -0,0 +1,210 @@ +# API reference + +The Lead Management API is a JSON REST API built with Django REST Framework. The OpenAPI 3 schema is committed as [`backend/schema.yml`](../backend/schema.yml) and served live: + +| | | +| --- | --- | +| Interactive docs (Swagger UI) | `/api/docs/` | +| ReDoc | `/api/redoc/` | +| OpenAPI schema | `/api/schema/` | + +Every example below uses `http://127.0.0.1:8000`. On the [live demo](https://lead-platform-c3mw.onrender.com/) the same paths work under `https://lead-platform-c3mw.onrender.com`. + +## Conventions + +- **JSON in, JSON out.** Send `Content-Type: application/json`. +- **Authentication:** `Authorization: Bearer ` on every endpoint except login, refresh and the public lead form. +- **Errors:** authentication, permission and not-found errors return `{"detail": "..."}`. Validation errors return an object keyed by field, for example `{"phone": ["Phone number must contain only digits."]}`, or `{"status": "Cannot change status from New to Won."}`. +- **Pagination:** list endpoints return `{"count", "next", "previous", "results"}`, 20 items per page by default. Use `?page=N` and `?page_size=N` (maximum 100). +- **Timestamps** are ISO 8601 in UTC. Every record has `created_at` and `updated_at`. + +## Roles and access + +Every user is either an **Admin** or a **Member**. The first account created with `ensure_superuser` is an Admin. Admins add further users in Django admin at `/admin/`. + +| Action | Admin | Member | +| --- | --- | --- | +| See leads | All leads | Leads they created or are assigned to | +| Create a lead | Yes | Yes (they become its creator) | +| Edit lead details | Any lead | Leads they created or are assigned to | +| Delete a lead | Yes | No | +| Change a lead's status | Any lead | Leads they created or are assigned to | +| Assign a lead | Yes | No | +| Add notes, read notes and activities | Any lead | Leads they created or are assigned to | +| Edit or delete a note | Any note | Notes they wrote | + +A lead a member cannot see answers `404 Not Found`, not `403`, so the API doesn't reveal that it exists. + +## Authentication + +Tokens are JWTs: +- **Access tokens** last 15 minutes. +- **Refresh tokens** last 7 days and are **rotated**. Each refresh returns a new refresh token, and the one you sent is blacklisted, so it can't be used again. + +### Log in + +`POST /api/auth/login/` + +```sh +curl -X POST http://127.0.0.1:8000/api/auth/login/ \ + -H 'Content-Type: application/json' \ + -d '{"email": "admin@example.com", "password": "..."}' +``` + +```json +{ "access": "eyJ...", "refresh": "eyJ..." } +``` + +Wrong credentials return `401` with `{"detail": "No active account found with the given credentials"}`. + +### Refresh + +`POST /api/auth/refresh/` with `{"refresh": "..."}` returns a new `access` **and** a new `refresh` token. Store both. Reusing the old refresh token returns `401` (`Token is blacklisted`). + +### Log out + +`POST /api/auth/logout/` with `{"refresh": "..."}`, authenticated. It blacklists the refresh token and returns `205 Reset Content`. + +### Current user + +`GET /api/auth/me/` returns `{"id", "email", "first_name", "last_name", "role"}`, where `role` is `ADMIN` or `MEMBER`. + +### Assignable members + +`GET /api/auth/users/` returns a paginated list of active Members as `{"id", "full_name"}`, ordered by name. It is used to fill the assignment picker. + +## Leads + +A lead: + +```json +{ + "id": 7, + "first_name": "Ada", + "last_name": "Lovelace", + "email": "ada@example.com", + "phone": "5550100", + "company": "Engines Ltd", + "source": "Referral", + "status": "QUALIFIED", + "created_by": { "id": 1, "full_name": "Alice Admin" }, + "assigned_to": { "id": 2, "full_name": "Bob Member" }, + "created_at": "2026-09-26T10:00:00Z", + "updated_at": "2026-09-26T12:30:00Z" +} +``` + +- `created_by` is `null` for leads submitted through the public form. +- `assigned_to` is `null` until someone assigns the lead. +- `phone` may only contain digits (an optional leading `+` is allowed). + +### List + +`GET /api/leads/` returns the leads visible to you, newest first. + +| Parameter | Effect | +| --- | --- | +| `status` | `NEW`, `CONTACTED`, `QUALIFIED`, `PROPOSAL`, `WON` or `LOST` | +| `assigned_to`, `created_by` | A user id | +| `company` | Exact company name | +| `search` | Case-insensitive match on first name, last name, email, phone or company | +| `ordering` | `created_at`, `updated_at`, `first_name`, `last_name` or `status`; prefix `-` for descending | +| `page`, `page_size` | Pagination | + +```sh +curl -H "Authorization: Bearer $ACCESS" \ + "http://127.0.0.1:8000/api/leads/?status=QUALIFIED&search=acme&ordering=-updated_at" +``` + +### Create + +`POST /api/leads/` with `first_name`, `last_name` and `email` (required), plus optional `phone`, `company` and `source`. + +- New leads always start in `NEW`, unassigned, with you as `created_by`. +- A **Created** activity is recorded. +- The response is `201` with the full lead. + +### Retrieve, edit, delete + +- `GET /api/leads/{id}/` returns one lead. +- `PATCH /api/leads/{id}/` changes some of the contact fields; `PUT` replaces all of them. A `status`, `assigned_to` or `created_by` field in the body is ignored: status and assignment have their own endpoints below. An edit that changes a value records an **Updated** activity naming the changed fields, for example "Bob Member updated company and source." An edit that changes nothing records nothing. +- `DELETE /api/leads/{id}/` deletes a lead, together with its notes and activities. Admins only. Returns `204`. + +### Public form + +`POST /api/leads/public/` takes the same fields as creating a lead, with **no authentication**. The website's enquiry form uses it. + +- The lead gets `created_by: null`. +- Its activity reads "Lead submitted through the public form." +- Submissions are **rate-limited per client** (`PUBLIC_LEADS_RATE`, default `20/hour`). Past the limit it returns `429 Too Many Requests`. + +## The status pipeline + +Status only moves forward through the pipeline. From any open stage it can go to `LOST`. `WON` and `LOST` are final. + +```text +NEW ──▶ CONTACTED ──▶ QUALIFIED ──▶ PROPOSAL ──▶ WON + │ │ │ │ + └──────────┴─────────────┴─────────────┴──────▶ LOST +``` + +`POST /api/leads/{id}/status/` with `{"status": "CONTACTED"}`: + +- **Allowed:** it returns `200` with the updated lead and records a **Status Changed** activity, for example "Status changed from New to Contacted by Bob Member." +- **Not allowed:** it returns `400` with the reason, for example `{"status": "Cannot change status from New to Won."}` or `{"status": "Lead is already in this status."}`. + +The rules live in one place (`apps/leads/constants.py`) and are enforced by the service layer. No other endpoint can change a lead's status. + +## Assignment + +`POST /api/leads/{id}/assign/` with `{"assigned_to": }`. Admins only. The user must be active. + +It returns `200` with the updated lead and records an **Assigned** activity, for example "Lead assigned to Bob Member by Alice Admin." Assigning a lead gives that member access to it. + +## Notes + +| Endpoint | Does | +| --- | --- | +| `POST /api/leads/{id}/notes/` | Adds a note (`{"content": "..."}`). Returns `201` and records a **Note Added** activity | +| `GET /api/leads/{id}/notes/list/` | Returns all notes for a lead as an array, newest first, each with its `author` | +| `PATCH` or `PUT /api/leads/notes/{id}/` | Changes a note's `content`. Its author or an Admin only | +| `DELETE /api/leads/notes/{id}/` | Deletes a note. Its author or an Admin only. Returns `204` | + +## Activity timeline + +`GET /api/leads/{id}/activities/` returns everything that happened to a lead as an array, newest first: + +```json +[ + { + "id": 12, + "lead": 7, + "user": { "id": 2, "full_name": "Bob Member" }, + "activity_type": "STATUS_CHANGED", + "description": "Status changed from Contacted to Qualified by Bob Member.", + "created_at": "2026-09-26T12:30:00Z", + "updated_at": "2026-09-26T12:30:00Z" + } +] +``` + +| `activity_type` | Recorded when | +| --- | --- | +| `CREATED` | A lead is created, in the app or through the public form (`user` is `null` for the form) | +| `UPDATED` | A lead's contact details change | +| `STATUS_CHANGED` | A status change succeeds | +| `ASSIGNED` | A lead is assigned | +| `NOTE_ADDED` | A note is added | + +Activities are written only by the service functions in `apps/leads/services.py`, in the same database transaction as the change they describe. If the change fails, no activity is recorded, and no change is saved without its activity. + +## Status codes + +| Code | Meaning | +| --- | --- | +| `200` / `201` / `204` / `205` | Success (read or update / created / deleted / logged out) | +| `400` | Invalid input or a disallowed status change; the body says which field and why | +| `401` | Missing, expired or blacklisted token, or wrong credentials | +| `403` | Signed in, but your role can't do this (for example a Member assigning or deleting) | +| `404` | The lead or note doesn't exist, or you aren't allowed to see it | +| `429` | The public form's rate limit was reached | diff --git a/frontend/package.json b/frontend/package.json index c62c6a1..939cfe3 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { - "name": "frontend", + "name": "lead-platform", "private": true, - "version": "0.0.0", + "version": "1.0.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index e515a2d..88da059 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -268,8 +268,11 @@ export interface paths { cookie?: never; }; get?: never; - /** @description Provide update and delete operations for lead notes. */ - put: operations["leads_notes_update"]; + /** + * Replace a lead note + * @description Replace the content of a lead note (PUT). Same rules as updating it. + */ + put: operations["replaceLeadNote"]; post?: never; /** * Delete a lead note @@ -280,7 +283,7 @@ export interface paths { head?: never; /** * Update a lead note - * @description Update the content of a lead note. + * @description Update the content of a lead note. Only its author or an administrator may edit it. */ patch: operations["updateLeadNote"]; trace?: never; @@ -401,9 +404,8 @@ export interface components { LeadNoteCreateRequest: { content: string; }; - /** @description Serializer used for retrieving lead notes. */ - LeadNoteRequest: { - lead: number; + /** @description Serializer used when updating a lead note. */ + LeadNoteUpdateRequest: { content: string; }; /** @description Validate the fields accepted when updating a lead. */ @@ -431,21 +433,6 @@ export interface components { /** @description Refresh token to invalidate. */ refresh: string; }; - PaginatedLeadActivityList: { - /** @example 123 */ - count: number; - /** - * Format: uri - * @example http://api.example.org/accounts/?page=4 - */ - next?: string | null; - /** - * Format: uri - * @example http://api.example.org/accounts/?page=2 - */ - previous?: string | null; - results: components["schemas"]["LeadActivity"][]; - }; PaginatedLeadList: { /** @example 123 */ count: number; @@ -461,21 +448,6 @@ export interface components { previous?: string | null; results: components["schemas"]["Lead"][]; }; - PaginatedLeadNoteList: { - /** @example 123 */ - count: number; - /** - * Format: uri - * @example http://api.example.org/accounts/?page=4 - */ - next?: string | null; - /** - * Format: uri - * @example http://api.example.org/accounts/?page=2 - */ - previous?: string | null; - results: components["schemas"]["LeadNote"][]; - }; PaginatedUserSummaryList: { /** @example 123 */ count: number; @@ -834,10 +806,8 @@ export interface operations { query?: never; header?: never; path: { - /** @description A unique integer value identifying this lead. */ - id: number; /** @description Unique lead identifier. */ - pk: number; + id: number; }; cookie?: never; }; @@ -885,10 +855,8 @@ export interface operations { query?: never; header?: never; path: { - /** @description A unique integer value identifying this lead. */ - id: number; /** @description Unique lead identifier. */ - pk: number; + id: number; }; cookie?: never; }; @@ -951,10 +919,8 @@ export interface operations { query?: never; header?: never; path: { - /** @description A unique integer value identifying this lead. */ - id: number; /** @description Unique lead identifier. */ - pk: number; + id: number; }; cookie?: never; }; @@ -1001,10 +967,8 @@ export interface operations { query?: never; header?: never; path: { - /** @description A unique integer value identifying this lead. */ - id: number; /** @description Unique lead identifier. */ - pk: number; + id: number; }; cookie?: never; }; @@ -1064,34 +1028,11 @@ export interface operations { }; leads_activities_list: { parameters: { - query?: { - assigned_to?: number; - company?: string; - created_by?: number; - /** @description Which field to use when ordering the results. */ - ordering?: string; - /** @description A page number within the paginated result set. */ - page?: number; - /** @description Number of results to return per page. */ - page_size?: number; - /** @description A search term. */ - search?: string; - /** - * @description * `NEW` - New - * * `CONTACTED` - Contacted - * * `QUALIFIED` - Qualified - * * `PROPOSAL` - Proposal - * * `WON` - Won - * * `LOST` - Lost - */ - status?: "CONTACTED" | "LOST" | "NEW" | "PROPOSAL" | "QUALIFIED" | "WON"; - }; + query?: never; header?: never; path: { - /** @description A unique integer value identifying this lead. */ - id: number; /** @description Unique lead identifier. */ - pk: number; + id: number; }; cookie?: never; }; @@ -1102,7 +1043,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["PaginatedLeadActivityList"]; + "application/json": components["schemas"]["LeadActivity"][]; }; }; /** @description Authentication credentials are invalid or missing. */ @@ -1139,10 +1080,8 @@ export interface operations { query?: never; header?: never; path: { - /** @description A unique integer value identifying this lead. */ - id: number; /** @description Unique lead identifier. */ - pk: number; + id: number; }; cookie?: never; }; @@ -1266,28 +1205,7 @@ export interface operations { }; leads_notes_list_list: { parameters: { - query?: { - assigned_to?: number; - company?: string; - created_by?: number; - /** @description Which field to use when ordering the results. */ - ordering?: string; - /** @description A page number within the paginated result set. */ - page?: number; - /** @description Number of results to return per page. */ - page_size?: number; - /** @description A search term. */ - search?: string; - /** - * @description * `NEW` - New - * * `CONTACTED` - Contacted - * * `QUALIFIED` - Qualified - * * `PROPOSAL` - Proposal - * * `WON` - Won - * * `LOST` - Lost - */ - status?: "CONTACTED" | "LOST" | "NEW" | "PROPOSAL" | "QUALIFIED" | "WON"; - }; + query?: never; header?: never; path: { /** @description A unique integer value identifying this lead. */ @@ -1302,7 +1220,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["PaginatedLeadNoteList"]; + "application/json": components["schemas"]["LeadNote"][]; }; }; /** @description Authentication credentials are invalid or missing. */ @@ -1339,10 +1257,8 @@ export interface operations { query?: never; header?: never; path: { - /** @description A unique integer value identifying this lead. */ - id: number; /** @description Unique lead identifier. */ - pk: number; + id: number; }; cookie?: never; }; @@ -1400,21 +1316,21 @@ export interface operations { }; }; }; - leads_notes_update: { + replaceLeadNote: { parameters: { query?: never; header?: never; path: { - /** @description A unique integer value identifying this lead note. */ + /** @description Unique note identifier. */ id: number; }; cookie?: never; }; requestBody: { content: { - "application/json": components["schemas"]["LeadNoteRequest"]; - "application/x-www-form-urlencoded": components["schemas"]["LeadNoteRequest"]; - "multipart/form-data": components["schemas"]["LeadNoteRequest"]; + "application/json": components["schemas"]["LeadNoteUpdateRequest"]; + "application/x-www-form-urlencoded": components["schemas"]["LeadNoteUpdateRequest"]; + "multipart/form-data": components["schemas"]["LeadNoteUpdateRequest"]; }; }; responses: { @@ -1426,6 +1342,42 @@ export interface operations { "application/json": components["schemas"]["LeadNote"]; }; }; + /** @description The request could not be processed. */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Authentication credentials are invalid or missing. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description You do not have permission to perform this action. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Requested resource was not found. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; }; }; deleteLeadNote: { @@ -1433,10 +1385,8 @@ export interface operations { query?: never; header?: never; path: { - /** @description A unique integer value identifying this lead note. */ - id: number; /** @description Unique note identifier. */ - pk: number; + id: number; }; cookie?: never; }; @@ -1483,12 +1433,8 @@ export interface operations { query?: never; header?: never; path: { - /** @description A unique integer value identifying this lead note. */ - id: number; /** @description Unique note identifier. */ - note_pk: number; - /** @description Unique lead identifier. */ - pk: number; + id: number; }; cookie?: never; }; diff --git a/screenshots/create lead.png b/screenshots/create lead.png deleted file mode 100644 index 750e3da..0000000 Binary files a/screenshots/create lead.png and /dev/null differ diff --git a/screenshots/dashboard.png b/screenshots/dashboard.png index f6cb2e2..c7f8e4a 100644 Binary files a/screenshots/dashboard.png and b/screenshots/dashboard.png differ diff --git a/screenshots/edit.png b/screenshots/edit.png deleted file mode 100644 index 4f6204f..0000000 Binary files a/screenshots/edit.png and /dev/null differ diff --git a/screenshots/landing_page.png b/screenshots/landing_page.png deleted file mode 100644 index 08c3ac4..0000000 Binary files a/screenshots/landing_page.png and /dev/null differ diff --git a/screenshots/lead details.png b/screenshots/lead details.png deleted file mode 100644 index 64c4678..0000000 Binary files a/screenshots/lead details.png and /dev/null differ diff --git a/screenshots/lead-detail.png b/screenshots/lead-detail.png new file mode 100644 index 0000000..4ad1158 Binary files /dev/null and b/screenshots/lead-detail.png differ diff --git a/screenshots/lead-form.png b/screenshots/lead-form.png new file mode 100644 index 0000000..286e69b Binary files /dev/null and b/screenshots/lead-form.png differ diff --git a/screenshots/leads.png b/screenshots/leads.png new file mode 100644 index 0000000..97ab234 Binary files /dev/null and b/screenshots/leads.png differ diff --git a/screenshots/login.png b/screenshots/login.png index 1873ab6..6893bd9 100644 Binary files a/screenshots/login.png and b/screenshots/login.png differ diff --git a/screenshots/my profile.png b/screenshots/my profile.png deleted file mode 100644 index 2d7550a..0000000 Binary files a/screenshots/my profile.png and /dev/null differ diff --git a/screenshots/notes.png b/screenshots/notes.png deleted file mode 100644 index 93b197d..0000000 Binary files a/screenshots/notes.png and /dev/null differ diff --git a/screenshots/public-form.png b/screenshots/public-form.png new file mode 100644 index 0000000..11692b3 Binary files /dev/null and b/screenshots/public-form.png differ diff --git a/screenshots/swagger.png b/screenshots/swagger.png new file mode 100644 index 0000000..a89832f Binary files /dev/null and b/screenshots/swagger.png differ diff --git a/screenshots/timeline.png b/screenshots/timeline.png deleted file mode 100644 index 070e115..0000000 Binary files a/screenshots/timeline.png and /dev/null differ diff --git a/screenshots/toast.png b/screenshots/toast.png deleted file mode 100644 index 94c80be..0000000 Binary files a/screenshots/toast.png and /dev/null differ diff --git a/screenshots/view leads.png b/screenshots/view leads.png deleted file mode 100644 index faff4b5..0000000 Binary files a/screenshots/view leads.png and /dev/null differ