# API reference — Said Yaqub Sadat Personal Portfolio

Base URL: `http://127.0.0.1:8000/api` in development (the SPA proxies `/api` to it).
All request and response bodies are JSON unless stated otherwise.

## 1. Conventions

### 1.1 Response envelope

| Situation | Shape |
|---|---|
| Successful read | `{"data": …, "success": true}` |
| Successful write | `{"data": …, "message": "…", "success": true}` |
| Paginated read | `{"data": [...], "meta": {"total": 0, "current_page": 1, "last_page": 1, "per_page": 12}, "success": true}` |
| Validation error | `{"message": "…", "errors": {"field": ["message"]}, "success": false}` with HTTP 422 |
| Unauthenticated | HTTP 401 `{"message": "Unauthenticated."}` |
| Unauthorized (valid token, not admin) | HTTP 403 `{"message": "Unauthorized.", "success": false}` |
| Not found | HTTP 404 |
| Rate limited | HTTP 429 `{"message": "Too Many Attempts."}` |

### 1.2 Authentication

Admin endpoints require a Sanctum personal access token:

```
Authorization: Bearer <token>
```

`POST /api/admin/login` returns the token. The SPA stores it in `localStorage`
under `admin_token` and attaches it to every `/admin/` request automatically.

### 1.3 Rate limits

| Endpoint | Limit |
|---|---|
| `POST /api/contact` | 5 requests / minute / IP |
| `POST /api/admin/login` | 10 requests / minute / IP |

### 1.4 CORS

Allowed origins come from `FRONTEND_URL` in `backend/.env` (comma separated).
The `Access-Control-Allow-Origin` header is only sent for those origins.

---

## 2. Public endpoints

### `GET /api/profile`

Site profile and contact details.

```json
{
  "data": {
    "name": "Said Yaqub Sadat",
    "title": "Full-Stack Web Developer",
    "tagline": "Laravel • React • MySQL",
    "summary": "…",
    "bio": "…",
    "focus": "…",
    "values": "…",
    "location": "Kabul, Afghanistan",
    "email": "…",
    "phone": "…",
    "nationality": "Afghanistan",
    "photo": null,
    "logo": null,
    "cv_path": "cv/Said_Yaqub_Sadat_CV.pdf",
    "languages": ["Pashto", "English", "Dari"],
    "social": {
      "github": "https://github.com/saidyaqub1234",
      "linkedin": "https://linkedin.com/in/said-yaqubsadat-b286a5308",
      "portfolio": "https://saidyaqub1234.github.io/portfolio/"
    }
  },
  "success": true
}
```

### `GET /api/skills`

Returns every skill ordered by `sort_order`.
Each entry: `id`, `name`, `category`, `level`, `proficiency`, `description`, `sort_order`.

### `GET /api/projects`

**Published projects only** (`status = published`).

| Parameter | Type | Rules | Description |
|---|---|---|---|
| `search` | string | max 120 | matches title, short description, description and category |
| `category` | string | max 120 | exact category match |
| `tech` | string | max 120 | matches a technology inside `tech_stack` |
| `featured` | boolean | — | only featured projects when `true` |
| `per_page` | integer | 1–50 (default 12) | page size |
| `page` | integer | ≥ 1 | page number |

```json
{
  "data": [
    {
      "id": 1,
      "title": "Moraa MIS",
      "slug": "moraa-mis",
      "status": "published",
      "short_description": "…",
      "description": "…",
      "problem": "…",
      "solution": "…",
      "challenges": "…",
      "results": "…",
      "category": "MIS & Databases",
      "role": "…",
      "tech_stack": ["Laravel", "MySQL"],
      "image": "projects/moraa.jpg",
      "live_url": null,
      "repo_url": null,
      "featured": true,
      "sort_order": 1,
      "start_date": "2024-01-01",
      "end_date": null,
      "published_at": "2026-10-22 10:00:00",
      "images": []
    }
  ],
  "meta": { "total": 6, "current_page": 1, "last_page": 1, "per_page": 12 },
  "success": true
}
```

An out-of-range `per_page` returns HTTP 422.

### `GET /api/projects/{slug}`

A single **published** project, resolved by slug, including its screenshots in
`data.images` (`id`, `path`, `caption`, `alt_text`, `sort_order`).

Draft, archived and unknown slugs return HTTP 404 — the response does not reveal
whether a hidden project exists.

### `GET /api/categories`

Distinct, alphabetically sorted categories used by published projects.

### `GET /api/experiences`

Employment history ordered by `sort_order`: `id`, `role`, `company`, `location`,
`start_date`, `end_date`, `current`, `highlights` (array), `sort_order`.

### `GET /api/education`

Education entries ordered by `sort_order`: `id`, `institution`, `degree`, `period`,
`description`, `sort_order`.

### `GET /api/certificates`

Credentials ordered by `sort_order`: `id`, `title`, `issuer`, `issue_date`,
`credential_id`, `url`, `file_path`, `description`.

### `POST /api/contact`

Stores a contact submission. No authentication.

| Field | Rules |
|---|---|
| `name` | required, string, max 120 |
| `email` | required, valid email, max 180 |
| `subject` | nullable, max 150 |
| `message` | required, 10–2000 characters |
| `website` | honeypot — must be empty (`nullable|max:0`) |

```json
{
  "message": "Message sent successfully. I will get back to you soon!",
  "mail_sent": false,
  "success": true
}
```

HTTP 201 on success. `mail_sent` reports whether the notification email was actually
delivered — a stored message and a delivered email are reported separately.

### Other public routes (outside `/api`)

| Route | Purpose |
|---|---|
| `GET /cv` | Downloads the current CV as `Said_Yaqub_Sadat_CV.pdf` (HTTP 404 when no file is configured). Stable, human-friendly public URL |
| `GET /api/cv` | The same download behind the API prefix. The SPA uses this route so that a single-origin deployment can keep the SPA page at `/cv` without a routing conflict |
| `GET /sitemap.xml` | XML sitemap with the static pages and every published project |
| `GET /` | Redirects to `FRONTEND_URL` |

---

## 3. Admin endpoints

All routes below require `Authorization: Bearer <token>` **and** `is_admin = true`.

### `POST /api/admin/login`

Rate limited to 10/minute. Body: `email`, `password`.

```json
{
  "data": {
    "token": "1|xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
    "user": { "id": 1, "name": "Said Yaqub Sadat", "email": "…", "is_admin": true }
  },
  "success": true
}
```

Invalid credentials — wrong password, unknown address or a non-admin account — all
return HTTP 401 `{"message": "Invalid credentials", "success": false}` without
revealing which part was wrong. Each attempt is written to `activity_logs`.

Every sign-in issues a new personal access token. Sessions of other browsers and
tabs stay valid, so a new sign-in never signs an open panel out. A token is
revoked by `POST /api/admin/logout` or by a password change.

### `POST /api/admin/logout`

Deletes the current token. Response: `{"message": "Logged out successfully.", "success": true}`.

### `GET /api/admin/me`

Returns the authenticated user.

### `GET /api/admin/dashboard`

```json
{
  "data": {
    "stats": [ { "label": "Projects", "value": 6 }, { "label": "Published", "value": 6 }, … ],
    "recent_messages": [ … ],
    "recent_activity": [ { "id": 1, "action": "project.updated", "description": "…", "created_at": "…" } ]
  },
  "success": true
}
```

### Projects — `/api/admin/projects`

| Method | Path | Purpose |
|---|---|---|
| `GET` | `/api/admin/projects` | List all projects, including drafts and archived ones. Optional `status` and `search` filters |
| `POST` | `/api/admin/projects` | Create |
| `GET` | `/api/admin/projects/{id}` | Read one project |
| `PUT`/`PATCH` | `/api/admin/projects/{id}` | Update (partial payloads allowed) |
| `DELETE` | `/api/admin/projects/{id}` | Delete together with its screenshots |
| `POST` | `/api/admin/projects/{id}/images` | Attach a screenshot |
| `DELETE` | `/api/admin/projects/{id}/images/{imageId}` | Remove a screenshot |

Writable fields: `title` (required), `slug`, `short_description`, `description`,
`problem`, `solution`, `challenges`, `results`, `category`, `role`, `tech_stack`
(array), `image`, `live_url`, `repo_url`, `start_date`, `end_date`, `featured`,
`sort_order`, `status` (`draft|published|archived`).

* The slug is generated from the title and made unique automatically
  (`moraa-mis`, `moraa-mis-2`, …).
* `PATCH {"status": "draft"}` is enough to unpublish a project; the cover image is
  only deleted when the `image` key is part of the payload.
* Attaching a screenshot: `{"path": "projects/x.jpg", "caption": "…", "alt_text": "…"}`.

### Content modules

| Resource | Endpoint | Notes |
|---|---|---|
| Skills | `/api/admin/skills` | `name`, `category`, `level` (0–100), `proficiency`, `description`, `sort_order` |
| Experiences | `/api/admin/experiences` | `role`, `company`, `location`, `start_date`, `end_date`, `current`, `highlights` (array), `sort_order` |
| Education | `/api/admin/education` | `institution`, `degree`, `period`, `description`, `sort_order` |
| Certificates | `/api/admin/certificates` | `title` and `issuer` are required; `issue_date`, `credential_id`, `url`, `file_path`, `description`, `sort_order` are optional |

Each module supports `GET` (list), `POST` (create), `GET /{id}`, `PUT|PATCH /{id}`
and `DELETE /{id}`.

### Messages — `/api/admin/messages`

| Method | Path | Purpose |
|---|---|---|
| `GET` | `/api/admin/messages` | List, optional `status` and `search` filters |
| `GET` | `/api/admin/messages/{id}` | Read one message |
| `POST` | `/api/admin/messages/{id}/read` | Mark as read |
| `PATCH` | `/api/admin/messages/{id}` | Update the workflow state: `{"status": "new|read|replied|archived", "notes": "…"}` |
| `DELETE` | `/api/admin/messages/{id}` | Delete |

Setting `status` to `replied` stores `replied_at`. An unknown status returns HTTP 422.

### Settings — `/api/admin/settings`

* `GET /api/admin/settings` → `{"data": {"key": "value", …}, "success": true}`
* `POST /api/admin/settings` with `{"settings": {"title": "…", "email": "…"}}`

Only these keys are accepted: `name`, `title`, `tagline`, `summary`, `bio`, `focus`,
`values`, `location`, `email`, `phone`, `nationality`, `photo`, `cv_path`, `logo`,
`social_github`, `social_linkedin`, `social_portfolio`. Any other key is ignored.

### My profile — `/api/admin/profile`

| Method | Path | Purpose |
|---|---|---|
| `GET` | `/api/admin/profile` | The account of the signed-in administrator |
| `PUT`/`PATCH` | `/api/admin/profile` | Update own `name` and `email` |
| `PUT` | `/api/admin/profile/password` | Replace own password — body `{"current_password": "…", "password": "…", "password_confirmation": "…"}` |

The new password needs at least 8 characters. A wrong `current_password` returns
HTTP 422 with a `current_password` error. Other sessions of the same account are
revoked after a successful password change.

### Users — `/api/admin/users`

| Method | Path | Purpose |
|---|---|---|
| `GET` | `/api/admin/users` | List every account, administrators first |
| `POST` | `/api/admin/users` | Create an account |
| `GET` | `/api/admin/users/{id}` | Read one account |
| `PUT`/`PATCH` | `/api/admin/users/{id}` | Update `name`, `email`, `password`, `is_admin` |
| `DELETE` | `/api/admin/users/{id}` | Delete an account |

* Creating an account requires `name`, `email`, `password` and `password_confirmation`;
  `is_admin` (default `false`) decides whether the account may sign in to the dashboard.
* An existing email returns HTTP 422.
* The signed-in administrator cannot delete their own account (HTTP 422) and the last
  remaining administrator can neither be demoted nor deleted (HTTP 422).

### Media — `/api/admin/upload`

| Method | Path | Purpose |
|---|---|---|
| `POST` | `/api/admin/upload` | Upload an image (`multipart/form-data`, field `file`) |
| `DELETE` | `/api/admin/upload` | Delete a file — body `{"path": "projects/x.jpg"}` |
| `POST` | `/api/admin/upload/cv` | Upload the CV (PDF only) and store it as the active CV |
| `POST` | `/api/admin/upload/photo` | Upload the profile photo and make it the active one |
| `POST` | `/api/admin/upload/logo` | Upload the site logo and make it the active one |

Rules: images `jpeg, png, jpg, gif, svg, webp` up to 5 MB; CV `pdf` up to 10 MB; the
profile photo accepts `jpeg, png, jpg, webp` up to 5 MB (SVG is rejected here because
the file is embedded as an image). The stored file name is generated (slugified
original name + timestamp). Deletion only accepts paths inside `projects/`,
`certificates/`, `cv/`, `profile/` or `logo/`; traversal attempts return HTTP 422.

`POST /api/admin/upload/photo` stores the file in `profile/` and writes the path into
the `photo` setting in the same request, so the new photo is live immediately. It
also records an `media.photo_uploaded` entry in the activity log.

`POST /api/admin/upload/logo` behaves the same way for the `logo` setting and records
`media.logo_uploaded`. The logo is returned by `GET /api/profile` and is rendered in the
public navigation, the public footer, the admin sidebar and the admin login page.

### Activity log — `GET /api/admin/activity-logs`

Returns the audit trail ordered by newest first. Optional `per_page` (1–100, default 25).
Each entry: `id`, `user_id`, `action`, `subject_type`, `subject_id`, `description`,
`ip_address`, `created_at`, `user`.

## 4. Error handling

| Situation | Status |
|---|---|
| Missing or invalid token | 401 |
| Valid token without administrator rights | 403 |
| Invalid input | 422 with a per-field `errors` object |
| Unknown public project slug | 404 |
| Rate limit exceeded | 429 |
| Unexpected server error | 500 (production responses never include a stack trace) |

## 5. Client integration

`frontend/src/api/client.ts` creates the shared axios instance:

```ts
const baseURL = import.meta.env.VITE_API_URL ?? '/api';
```

* Development: `VITE_API_URL=/api` and the Vite dev server proxies `/api` and
  `/storage` to `http://127.0.0.1:8000`.
* Production: set `VITE_API_URL=https://api.example.com/api` and make sure the
  frontend origin is listed in the backend's `FRONTEND_URL`.
* `storageUrl(path)` builds the public URL of a media file, `cvUrl()` the CV
  download URL (`<api-root>/api/cv`).
