# Proptee API Documentation

Express + MySQL backend for the Proptee real-estate app.

> **Browsable version:** start the server and open **http://localhost:5000/docs** — rendered with **Scalar** from `openapi.json` (hand-written spec, not generated). The Markdown-rendered version is at `/docs/markdown`, and the raw spec at `/openapi.json`.

- **Base URL:** `http://localhost:5000/api`
- **Content type:** `application/json` (except static image files)
- **Architecture:** `routes → controller → service → model` — controllers handle HTTP, services hold async business logic, models hold all SQL

---

## Response format

Every response uses the same envelope:

**Success**

```json
{
  "success": true,
  "data": { }
}
```

List endpoints also return a `count` field.

**Error**

```json
{
  "success": false,
  "error": "Human-readable message"
}
```

### Status codes

| Code | Meaning |
|------|---------|
| `200` | OK |
| `201` | Created |
| `400` | Bad request — validation failed (missing/invalid fields) |
| `401` | Unauthorized — missing, invalid or expired token; bad login |
| `404` | Not found — resource or route does not exist |
| `409` | Conflict — duplicate title/email |
| `500` | Internal server error |

---

## Authentication

Auth uses **JWT Bearer tokens**. Passwords are hashed with bcrypt; tokens are signed with `JWT_SECRET` from `.env` and expire after `JWT_EXPIRES_IN` (default `12h`).

### Seeded admin account

| Field | Value |
|-------|-------|
| Email | `admin@propertee.com` |
| Password | `Admin123!` |
| Role | `superadmin` |

> Change this password in production.

### Public vs protected routes

| Access | Endpoints |
|--------|-----------|
| **Public** (no token) | `GET /api/health`, `GET /api/properties`, `GET /api/properties/:id`, `POST /api/auth/login`, `POST /api/contact`, `POST /api/tours`, `GET /uploads/:file` |
| **Protected** (token required) | everything else — all client routes, property create/update/delete, stats, contact list, uploads, register, `GET /api/auth/me` |

Send the token on every protected request:

```
Authorization: Bearer <token>
Content-Type: application/json
```

---

## Endpoints

### 1. Auth

#### `POST /api/auth/login` — Public

Log in and receive a token.

**Body**

| Field | Type | Required | Notes |
|-------|------|----------|-------|
| `email` | string | yes | matched case-insensitively |
| `password` | string | yes | |

```bash
curl -X POST http://localhost:5000/api/auth/login \
  -H "Content-Type: application/json" \
  -d '{"email":"admin@propertee.com","password":"Admin123!"}'
```

**Response `200`**

```json
{
  "success": true,
  "data": {
    "token": "eyJhbGciOiJIUzI1NiIs...",
    "admin": {
      "id": 1,
      "name": "Proptee Admin",
      "email": "admin@propertee.com",
      "role": "superadmin"
    }
  }
}
```

Wrong email or password → `401` `"Invalid email or password"` (same message for both, to avoid revealing which accounts exist).

---

#### `GET /api/auth/me` — Protected

Returns the currently authenticated admin. Use it to validate a stored token.

```bash
curl http://localhost:5000/api/auth/me -H "Authorization: Bearer $TOKEN"
```

**Response `200`**

```json
{
  "success": true,
  "data": { "id": 1, "name": "Proptee Admin", "email": "admin@propertee.com", "role": "superadmin" }
}
```

---

#### `POST /api/auth/register` — Protected

Create a new admin. Only authenticated admins can create accounts.

**Body**

| Field | Type | Required | Notes |
|-------|------|----------|-------|
| `name` | string | yes | |
| `email` | string | yes | valid email, unique |
| `password` | string | yes | minimum 8 characters |
| `role` | string | no | `admin` (default) or `superadmin` |

```bash
curl -X POST http://localhost:5000/api/auth/register \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $TOKEN" \
  -d '{"name":"Second Admin","email":"admin2@propertee.com","password":"Secret123!"}'
```

**Response `201`** → created admin object (no password hash).
Duplicate email → `409`. Short password → `400`.

---

### 2. Properties

**Property object**

```json
{
  "id": 1,
  "title": "Modern lakefront retreat",
  "location": "Austin, TX",
  "price": 1245000,
  "beds": 4,
  "baths": 3,
  "sqft": 2840,
  "image": "https://images.unsplash.com/photo-...",
  "tag": "Featured",
  "status": "Active",
  "views": 1248,
  "description": "A light-filled retreat designed around slow mornings...",
  "owner_id": 1,
  "owner": "Maya Chen",
  "created_at": "2026-09-26 12:45:41"
}
```

`owner` is the joined client name, or `null` if unassigned.

#### `GET /api/properties` — Public

List properties, newest first.

**Query parameters** (all optional)

| Param | Type | Example |
|-------|------|---------|
| `search` | string — matches title or location | `?search=lakefront` |
| `tag` | `Featured` \| `New listing` \| `Open Sunday` | `?tag=Featured` |
| `status` | `Active` \| `Pending` \| `Draft` | `?status=Active` |
| `limit` | positive number | `?limit=10` |

```bash
curl "http://localhost:5000/api/properties?search=austin&tag=Featured&limit=5"
```

**Response `200`** → `{ "success": true, "count": 1, "data": [ ... ] }`

Invalid `tag`/`status`/`limit` → `400`.

---

#### `GET /api/properties/:id` — Public

```bash
curl http://localhost:5000/api/properties/1
```

**Response `200`** → property object. Missing → `404 "Property not found"`. Non-numeric id → `400 "Invalid property id"`.

---

#### `POST /api/properties` — Protected

**Body**

| Field | Type | Required | Notes |
|-------|------|----------|-------|
| `title` | string | yes | unique (409 on duplicate) |
| `location` | string | yes | |
| `price` | number or string | yes | accepts `1245000` or `"₦1,245,000"` — stored as DECIMAL (₦ and $ both accepted) |
| `beds` | number | no | |
| `baths` | number | no | decimals allowed (e.g. `2.5`) |
| `sqft` | number | no | |
| `image` | string | no | full URL (upload via `/api/uploads` first) |
| `tag` | string | no | default `Featured` |
| `status` | string | no | default `Active` |
| `views` | number | no | default `0` |
| `description` | string | no | |
| `owner_id` | number | no | id of a client |

```bash
curl -X POST http://localhost:5000/api/properties \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $TOKEN" \
  -d '{"title":"Sunlit corner loft","location":"Seattle, WA","price":"₦615,000","beds":2,"tag":"New listing"}'
```

**Response `201`** → full created property object.
No token → `401`. Missing fields → `400 "Missing required field(s): ..."`. Duplicate title → `409`.

---

#### `PUT /api/properties/:id` — Protected

Partial update — send only the fields you want to change.

| Field | Notes |
|-------|-------|
| `title`, `location`, `price`, `beds`, `baths`, `sqft`, `image`, `tag`, `status`, `views`, `description`, `owner_id` | same validation as create; send `null` to clear `beds`/`baths`/`sqft`/`image`/`description`/`owner_id` |

```bash
curl -X PUT http://localhost:5000/api/properties/5 \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $TOKEN" \
  -d '{"status":"Pending","price":630000}'
```

**Response `200`** → updated property. Unknown id → `404`. Empty/invalid body → `400 "No valid fields to update"`.

---

#### `DELETE /api/properties/:id` — Protected

```bash
curl -X DELETE http://localhost:5000/api/properties/5 -H "Authorization: Bearer $TOKEN"
```

**Response `200`** → `{ "success": true, "message": "Property deleted" }`. Unknown id → `404`.

---

### 3. Clients

**Client object**

```json
{
  "id": 1,
  "name": "Maya Chen",
  "email": "maya@email.com",
  "status": "Active",
  "joined_at": "2025-01-15",
  "properties": 1
}
```

`properties` is the number of listings owned by the client.

#### `GET /api/clients` — Protected

**Query parameters:** `search` (name/email), `status` (`Active` | `Pending` | `Draft`).

```bash
curl http://localhost:5000/api/clients?status=Active -H "Authorization: Bearer $TOKEN"
```

**Response `200`** → `{ "success": true, "count": N, "data": [ ... ] }`

---

#### `GET /api/clients/:id` — Protected

**Response `200`** → client object. Unknown → `404 "Client not found"`.

---

#### `POST /api/clients` — Protected

| Field | Type | Required | Notes |
|-------|------|----------|-------|
| `name` | string | yes | |
| `email` | string | yes | valid + unique (409) |
| `status` | string | no | default `Active` |
| `joined_at` | string | no | `YYYY-MM-DD`, defaults to today |

**Response `201`** → created client.

---

#### `PUT /api/clients/:id` — Protected

Partial update of `name`, `email`, `status`. Changing email to an existing one → `409`.

**Response `200`** → updated client.

---

#### `DELETE /api/clients/:id` — Protected

Deletes the client. Their listings are kept and become unassigned (`owner_id = NULL`).

**Response `200`** → `{ "success": true, "message": "Client deleted" }`.

---

### 4. Stats

#### `GET /api/stats` — Protected

Aggregates for the admin dashboard.

```bash
curl http://localhost:5000/api/stats -H "Authorization: Bearer $TOKEN"
```

**Response `200`**

```json
{
  "success": true,
  "data": {
    "totalListings": 4,
    "activeListings": 2,
    "pendingListings": 1,
    "draftListings": 1,
    "totalViews": 2994,
    "avgListingPrice": 875000,
    "totalClients": 4,
    "activeClients": 2,
    "inquiries": 2,
    "tourRequests": 1
  }
}
```

---

### 5. Contact

#### `POST /api/contact` — Public

Submit the public contact form.

| Field | Type | Required |
|-------|------|----------|
| `name` | string | yes |
| `email` | string | yes (valid format) |
| `phone` | string | no (validated format if present) |
| `message` | string | yes |

```bash
curl -X POST http://localhost:5000/api/contact \
  -H "Content-Type: application/json" \
  -d '{"name":"Casey Ford","email":"casey@email.com","phone":"+1 555 0199","message":"Is the lakefront retreat still available?"}'
```

**Response `201`** → created message with `id` and `created_at`.

---

#### `GET /api/contact` — Protected

List messages, newest first. Optional `?limit=N`.

**Response `200`** → `{ "success": true, "count": N, "data": [ ... ] }`

---

### 6. Private tour requests

#### `POST /api/tours` — Public

Submit a private-tour request from a property page (name, email and phone are required).

| Field | Type | Required | Notes |
|-------|------|----------|-------|
| `name` | string | yes | |
| `email` | string | yes | valid format |
| `phone` | string | yes | 7–25 chars, digits/`+`/`()`/`-`/`.`/spaces |
| `propertyId` | number | no | must exist if sent (`404` otherwise) |
| `message` | string | no | preferred days, times, etc. |

```bash
curl -X POST http://localhost:5000/api/tours \
  -H "Content-Type: application/json" \
  -d '{"propertyId":1,"name":"Casey Ford","email":"casey@email.com","phone":"+1 555 0199","message":"Saturday morning works"}'
```

**Response `201`** → created request including `property_title` (joined from the property).

---

#### `GET /api/tours` — Protected

List tour requests, newest first (each joined with `property_title`). Optional `?limit=N`.

```bash
curl http://localhost:5000/api/tours -H "Authorization: Bearer $TOKEN"
```

**Response `200`** → `{ "success": true, "count": N, "data": [ ... ] }`

---

### 7. Image uploads

#### `POST /api/uploads` — Protected

Upload a listing photo from the browser (base64 data URL). The image is sent to **Cloudinary** (signed server-side upload) and the returned URL is the Cloudinary `secure_url`. Attach it as `image` when creating a property.

| Field | Type | Required | Notes |
|-------|------|----------|-------|
| `data` | string | yes | data URL: `data:image/png;base64,...` |
| `filename` | string | no | original filename (informational) |

Accepted types: `image/jpeg`, `image/png`, `image/webp`, `image/gif` — max **5 MB** decoded.

Configuration (`.env`): `CLOUDINARY_CLOUD_NAME`, `CLOUDINARY_API_KEY`, `CLOUDINARY_API_SECRET`.

```bash
curl -X POST http://localhost:5000/api/uploads \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $TOKEN" \
  -d '{"data":"data:image/png;base64,iVBORw0KGgo..."}'
```

**Response `201`**

```json
{
  "success": true,
  "data": {
    "url": "https://res.cloudinary.com/kvcyy1jg/image/upload/v1790423831/propertee/xyz.png",
    "publicId": "propertee/xyz",
    "filename": "propertee/xyz.png",
    "size": 70,
    "mimeType": "image/png",
    "width": 400,
    "height": 300
  }
}
```

`url` is an absolute `https://res.cloudinary.com/...` URL — use it directly in `<img>` tags.

Wrong format → `400`. Over 5 MB → `400 "image must be 5MB or smaller"`. Cloudinary/network errors → `502`.

> Images uploaded before Cloudinary was configured are still served from **`GET http://localhost:5000/uploads/<filename>`** (public, no token).

---

### 8. Health

#### `GET /api/health` — Public

```bash
curl http://localhost:5000/api/health
```

**Response `200`** → `{ "success": true, "status": "ok" }`

---

## Quick start

```bash
cd backend
npm install
mysql -u root < db/schema.sql   # creates DB, tables, seeds data
npm start                        # http://localhost:5000
```

Configuration lives in `.env` (copy from `.env.example`):

| Variable | Purpose |
|----------|---------|
| `PORT` | API port (default `5000`) |
| `CORS_ORIGIN` | Allowed origin (default `http://localhost:3000`) |
| `JWT_SECRET` | Token signing secret — generate with `node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"` |
| `JWT_EXPIRES_IN` | Token lifetime (default `12h`) |
| `DB_HOST`, `DB_PORT`, `DB_USER`, `DB_PASSWORD`, `DB_NAME` | MySQL connection |

---

## Enum reference

| Field | Allowed values |
|-------|----------------|
| `properties.tag` | `Featured`, `New listing`, `Open Sunday` |
| `properties.status` | `Active`, `Pending`, `Draft` |
| `clients.status` | `Active`, `Pending`, `Draft` |
| `admins.role` | `superadmin`, `admin` |
