A RESTful backend for a content management system built for web agencies. It covers webpage content authoring, media management, client relationship management, client onboarding, and project progress tracking through a client-facing portal.
- Overview
- Tech Stack
- Prerequisites
- Getting Started
- Environment Variables
- Database Setup
- Running the Server
- API Documentation
- Project Structure
- Authentication
- API Reference
- Content Block Reference
- Client Lifecycle
- File Uploads
- Error Handling
Clover CMS provides two distinct surfaces:
Admin surface — Used internally by agency staff to manage website pages and content, upload media, manage client accounts, run projects, and post progress updates.
Client portal — A read-oriented surface that allows clients to log in, view their projects, track milestone completion, and read updates posted by the agency. Clients can also manage their own profile and credentials.
| Layer | Technology |
|---|---|
| Runtime | Node.js |
| Framework | Express 4 |
| Language | TypeScript |
| ORM | Prisma |
| Database | PostgreSQL |
| Authentication | JSON Web Tokens (JWT) |
| File Uploads | Multer (local disk storage) |
| Validation | Zod |
| API Docs | Swagger UI (OpenAPI 3.0) |
- Node.js 18 or later
- PostgreSQL 14 or later
- npm 9 or later
Clone the repository and install dependencies:
git clone <repository-url>
cd clover-backend
npm installCopy the environment variable template and fill in your values:
cp .env.example .env| Variable | Required | Default | Description |
|---|---|---|---|
DATABASE_URL |
Yes | — | PostgreSQL connection string |
JWT_SECRET |
Yes | — | Secret used to sign all JWTs. Use a long, random string in production. |
PORT |
No | 3000 |
Port the server listens on |
NODE_ENV |
No | development |
development or production |
BASE_URL |
No | http://localhost:3000 |
Public base URL, used to build onboarding and password reset links |
PORTAL_BASE_URL |
No | https://clients.cloverdesign.xyz |
Client portal base URL used in emails and client-facing links |
Example .env:
DATABASE_URL="postgresql://postgres:password@localhost:5432/clover_cms"
CORS_ORIGINS="http://localhost:5173"
JWT_SECRET="replace-this-with-a-long-random-secret"
PORT=3000
NODE_ENV=development
BASE_URL="http://localhost:3000"
PORTAL_BASE_URL="https://clients.cloverdesign.xyz"
The server will throw on startup if DATABASE_URL or JWT_SECRET are missing.
Push the Prisma schema to your database:
npm run db:pushTo use migrations instead (recommended for production):
npm run db:migrateTo open Prisma Studio (database browser):
npm run db:studioTo regenerate the Prisma client after changing prisma/schema.prisma:
npm run db:generateDevelopment mode with auto-restart on file changes:
npm run devProduction build:
npm run build
npm startOn startup the server prints:
Clover CMS API
Running on: http://localhost:3000
Docs: http://localhost:3000/docs
Environment: development
Port: 3000
Interactive Swagger UI is available at:
http://localhost:3000/docs
The raw OpenAPI 3.0 JSON spec is available at:
http://localhost:3000/docs.json
The spec can be imported directly into Postman, Insomnia, or any other API client that supports OpenAPI. The Swagger UI includes a persistent Authorize button — paste a JWT once and it will be included in all subsequent requests for the duration of your session.
clover-backend/
├── prisma/
│ └── schema.prisma # Database schema and enums
├── src/
│ ├── index.ts # App entry point, middleware, route mounting
│ ├── config/
│ │ ├── env.ts # Environment variable loading and validation
│ │ └── swagger.ts # OpenAPI 3.0 specification
│ ├── lib/
│ │ └── prisma.ts # Prisma client singleton
│ ├── middleware/
│ │ ├── auth.ts # JWT guards (requireAdmin, requireClient) and token signers
│ │ ├── errorHandler.ts # Central error handler and 404 catcher
│ │ └── upload.ts # Multer configuration for file uploads
│ ├── modules/
│ │ ├── auth/ # Admin registration and login
│ │ ├── pages/ # Page and content block management
│ │ ├── media/ # File upload and media library
│ │ ├── clients/ # Client CRUD, onboarding, and portal
│ │ └── projects/ # Project, milestone, and update management
│ └── utils/
│ └── response.ts # sendSuccess / sendError helpers
└── uploads/ # Uploaded files (served as static assets)
Each module contains a *.routes.ts file (router), a *.controller.ts file (request parsing and response), and a *.service.ts file (business logic and database access).
The API uses two separate JWT-based authentication flows to prevent token misuse between roles.
Issued by POST /api/auth/login. The payload contains { adminId, role: "admin" }. Admin tokens are valid for 7 days. Include the token in the Authorization header:
Authorization: Bearer <admin-token>
Issued by POST /api/portal/login and also at the end of the onboarding flow. The payload contains { clientId, role: "client" }. Client tokens are valid for 7 days. Admin tokens will be rejected on client-only routes and vice versa.
All endpoints are prefixed with /api. Responses always follow this envelope:
{
"success": true,
"message": "Human-readable status",
"data": { }
}On error:
{
"success": false,
"message": "Description of what went wrong"
}Returns the server status. No authentication required.
Response
{
"success": true,
"message": "Clover CMS API is running",
"timestamp": "2024-01-01T00:00:00.000Z",
"environment": "development"
}Create an admin account.
Body
{
"name": "Patrick Oguamanam",
"email": "admin@clover.com",
"password": "securepassword"
}Response — 201
{
"data": {
"id": "...",
"name": "Patrick Oguamanam",
"email": "admin@clover.com",
"createdAt": "..."
}
}Authenticate as an admin and receive a JWT.
Body
{
"email": "admin@clover.com",
"password": "securepassword"
}Response — 200
{
"data": {
"token": "<jwt>",
"admin": { "id": "...", "name": "...", "email": "..." }
}
}Get the currently authenticated admin's profile.
Auth — Admin JWT required
Pages are the top-level content containers for your website. Each page has a unique slug, metadata, and an ordered list of content blocks.
List all pages. Admin only.
Create a new page.
Body
{
"slug": "about-us",
"title": "About Us",
"description": "Learn about our agency",
"metaTitle": "About Us | Clover Agency",
"metaDesc": "We are a full-service digital agency.",
"isPublished": false
}Get a page and all of its blocks. Admin only.
Get a published page by its slug. Public — no authentication required. Returns 404 if the page does not exist or isPublished is false.
Update page metadata. Partial updates are supported.
Delete a page. All blocks belonging to the page are deleted via cascade.
Content blocks are the individual pieces of content within a page. They are ordered and each carries a content JSON payload and a styles JSON object for visual overrides.
Add a block to a page. The block is appended to the end of the page unless order is specified.
Body
{
"type": "HEADING",
"content": { "text": "Our Story", "level": 1 },
"styles": { "color": "#1a1a1a", "textAlign": "center" },
"isVisible": true
}Update a block's content, styles, visibility, or position.
Remove a block from a page.
Reorder multiple blocks in one request.
Body
{
"blocks": [
{ "id": "block_abc", "order": 0 },
{ "id": "block_def", "order": 1 },
{ "id": "block_ghi", "order": 2 }
]
}Upload a file. The request must be multipart/form-data with a single field named file. Uploaded files are stored under the uploads/ directory and served at /uploads/<filename>.
Accepted types and size limits:
| Type | Formats | Limit |
|---|---|---|
| Image | JPEG, PNG, GIF, WebP, SVG | 10 MB |
| Video | MP4, MOV, WebM | 100 MB |
Response — 201
{
"data": {
"id": "...",
"filename": "1720000000000-photo.jpg",
"originalName": "photo.jpg",
"url": "/uploads/1720000000000-photo.jpg",
"type": "IMAGE",
"mimeType": "image/jpeg",
"size": 204800,
"createdAt": "..."
}
}List all uploaded files, ordered by upload date descending.
Delete a media record and remove the file from disk.
List all clients with a project count. Admin only.
Create a new client record.
Body
{
"name": "Acme Corporation",
"email": "hello@acme.com",
"phone": "+1 555 000 0000",
"company": "Acme Corporation",
"notes": "Referred by existing client.",
"status": "LEAD"
}Client statuses: LEAD, ONBOARDING, ACTIVE, ON_HOLD, CHURNED.
Get a client and all of their projects.
Update a client record. Partial updates are supported.
Delete a client. All associated projects are deleted via cascade.
Generate a 7-day onboarding invite link for the client. Sets the client's status to ONBOARDING. Returns the URL and token.
In production, email the onboardingUrl to the client. In development, the URL is returned directly in the response body.
Response — 200
{
"data": {
"onboardingUrl": "http://localhost:3000/onboarding/abc123...",
"token": "abc123...",
"expiresAt": "..."
}
}These routes are public and do not require authentication.
Validate an onboarding token and return the client's name and email so the frontend can display a personalised welcome screen. Returns 400 if the token is invalid or expired.
Complete the onboarding process. Sets the client's password, optionally updates their phone and company, clears the onboarding token, marks onboardingCompletedAt, and sets their status to ACTIVE. Returns a client JWT on success.
Body
{
"password": "securepassword",
"phone": "+1 555 000 0001",
"company": "Acme Corporation"
}Response — 200
{
"data": {
"token": "<client-jwt>",
"client": { ... }
}
}Authenticate as a client.
Body
{
"email": "hello@acme.com",
"password": "securepassword"
}Request a password reset link. To prevent email enumeration, the response is identical regardless of whether the email exists in the system.
In production, the reset link should be emailed to the client. In development, the link and token are included in the response body.
Reset tokens expire after one hour.
Body
{
"email": "hello@acme.com"
}Reset the client's password using a valid reset token. The token is invalidated after use.
Body
{
"password": "newsecurepassword"
}Get the authenticated client's profile.
Auth — Client JWT required
Update the authenticated client's profile. Partial updates are supported. Email uniqueness is enforced.
Auth — Client JWT required
Body
{
"name": "Jane Smith",
"email": "jane@acme.com",
"phone": "+1 555 000 0002",
"company": "Acme Corporation"
}Change the authenticated client's password. Requires the current password to be provided. The new password must differ from the current one.
Auth — Client JWT required
Body
{
"currentPassword": "oldsecurepassword",
"newPassword": "newsecurepassword",
"confirmPassword": "newsecurepassword"
}List all projects belonging to the authenticated client. Milestones and visible updates are included.
Auth — Client JWT required
Get a single project. Only returns updates where isVisible is true. Returns 404 if the project belongs to a different client.
Auth — Client JWT required
All project routes require an admin JWT.
List all projects across all clients.
Create a project for an existing client.
Body
{
"clientId": "...",
"name": "Website Redesign",
"description": "Full redesign of the corporate website.",
"status": "PLANNING",
"progress": 0,
"startDate": "2024-02-01T00:00:00.000Z",
"endDate": "2024-05-01T00:00:00.000Z",
"budget": 12000.00,
"notes": "Client prefers weekly updates."
}Project statuses: PLANNING, IN_PROGRESS, REVIEW, COMPLETED, ON_HOLD, CANCELLED.
progress is an integer from 0 to 100 representing completion percentage.
Get a project with its milestones and all updates (including those hidden from the client).
Update a project. Partial updates are supported.
Delete a project. Milestones and updates are deleted via cascade.
All milestone routes require an admin JWT.
Add a milestone to a project.
Body
{
"title": "Design handoff",
"description": "Finalise all wireframes and high-fidelity mockups.",
"status": "PENDING",
"order": 1,
"dueDate": "2024-03-01T00:00:00.000Z"
}Milestone statuses: PENDING, IN_PROGRESS, COMPLETED. Setting status to COMPLETED automatically records completedAt.
Update a milestone. Partial updates are supported.
Delete a milestone.
Project updates are messages posted by the agency to document progress. Each update has an isVisible flag that controls whether it appears in the client portal.
Post an update on a project.
Body
{
"title": "Design phase complete",
"content": "All wireframes and mockups have been approved by the team. We are moving into development.",
"isVisible": true
}Set isVisible to false to keep the update internal (admin-only). Clients will not see it in their portal.
Delete a project update.
Each block type expects a specific shape in the content field. The styles field accepts any CSS-like key-value pairs and is applied by the frontend renderer.
{
"type": "HEADING",
"content": { "text": "Section Title", "level": 2 }
}level corresponds to HTML heading levels 1 through 6.
{
"type": "TEXT",
"content": { "text": "Paragraph content here. Supports plain text or HTML." }
}{
"type": "IMAGE",
"content": {
"src": "/uploads/1720000000000-hero.jpg",
"alt": "Hero image",
"href": "https://example.com"
}
}href is optional. When provided, the image renders as a link.
{
"type": "VIDEO",
"content": {
"src": "/uploads/1720000000000-intro.mp4",
"poster": "/uploads/1720000000000-thumbnail.jpg"
}
}{
"type": "BUTTON",
"content": {
"label": "Get in Touch",
"href": "/contact",
"target": "_self"
}
}{
"type": "DIVIDER",
"content": {}
}{
"type": "EMBED",
"content": { "embed_url": "https://www.youtube.com/embed/dQw4w9WgXcQ" }
}{
"type": "SPACER",
"content": { "height": "80px" }
}{
"type": "COLUMNS",
"content": {
"columns": [
{ "blocks": [] },
{ "blocks": [] }
]
}
}Each column in columns can contain a nested array of block objects following the same structure.
The typical flow from lead to active client:
- Admin creates a client record via
POST /api/clientswith statusLEAD. - Admin calls
POST /api/clients/:id/send-onboardingto generate an invite link. - The client receives the link and visits it. The frontend calls
GET /api/onboarding/:tokento display their name. - The client submits their password and additional details via
POST /api/onboarding/:token/complete. Their status is set toACTIVEand they receive a JWT. - The client can now log in at any time via
POST /api/portal/loginand access their portal. - The admin creates a project for the client via
POST /api/projects, adds milestones, and posts updates throughout the engagement. - The client tracks progress in real time through the portal.
Uploaded files are stored on the local filesystem under uploads/. The directory is served as static files at the /uploads path.
Filenames are sanitised and prefixed with a Unix timestamp to avoid collisions:
1720000000000-my-file-name.jpg
In production, consider replacing the local disk storage with a cloud provider such as AWS S3 or Cloudinary. This would require updating src/middleware/upload.ts and src/modules/media/media.service.ts.
All errors are handled by the central error middleware in src/middleware/errorHandler.ts and return a consistent JSON response.
Common HTTP status codes used throughout the API:
| Code | Meaning |
|---|---|
200 |
Success |
201 |
Resource created |
400 |
Validation error or bad request |
401 |
Missing or invalid token |
403 |
Authenticated but not authorised (wrong role) |
404 |
Resource not found |
409 |
Conflict (e.g. duplicate email or slug) |
500 |
Internal server error |
Unmatched routes return 404 with the message Route not found.