Deploy: TekDek Employees Portal (Daedalus, Talos, Icarus) - Live at web.tekdek.dev

This commit is contained in:
ParzivalTD
2026-04-11 22:56:05 -04:00
parent ec154881ae
commit 3fc4e146d4
17 changed files with 1448 additions and 0 deletions

View File

@@ -0,0 +1,57 @@
# TekDek Employees API
Backend API for the TekDek team/employees page. Built by Talos.
## Structure
```
tekdek-employees-api/
├── public/
│ ├── index.php # Entry point
│ └── .htaccess # Apache rewrite rules
├── src/
│ ├── Database.php # PDO connection manager
│ ├── EmployeeRepository.php # Data access layer
│ ├── EmployeeController.php # Request handling
│ └── Router.php # URL routing
├── database/
│ ├── migrations/ # Schema SQL
│ └── seeds/ # Initial data
├── tests/
│ ├── EmployeeRepositoryTest.php
│ ├── EmployeeControllerTest.php
│ └── RouterTest.php
├── docs/
│ └── API.md # Full API documentation
├── composer.json
├── phpunit.xml
└── README.md
```
## Quick Start
```bash
composer install
mysql -u root -p tekdek < database/migrations/001_create_employees_table.sql
mysql -u root -p tekdek < database/seeds/001_seed_employees.sql
composer test
```
## Endpoints
- `GET /api/employees` — All employees (sorted)
- `GET /api/employees/:slug` — Single employee by slug
See [docs/API.md](docs/API.md) for full documentation.
## Environment
Set via environment variables: `DB_HOST`, `DB_PORT`, `DB_NAME`, `DB_USER`, `DB_PASS`.
## Tests
```bash
composer test
```
Repository tests use SQLite in-memory (no MySQL needed). Controller tests use mocked repositories. Router tests verify dispatch logic.

View File

@@ -0,0 +1,28 @@
{
"name": "tekdek/employees-api",
"description": "TekDek Employees API — backend for the team page",
"type": "project",
"license": "proprietary",
"require": {
"php": ">=8.1",
"ext-pdo": "*",
"ext-json": "*"
},
"require-dev": {
"phpunit/phpunit": "^10.0"
},
"autoload": {
"psr-4": {
"TekDek\\Employees\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"TekDek\\Employees\\Tests\\": "tests/"
}
},
"scripts": {
"test": "phpunit --configuration phpunit.xml",
"test:coverage": "phpunit --configuration phpunit.xml --coverage-html coverage"
}
}

View File

@@ -0,0 +1,25 @@
-- Migration: Create employees table
-- Version: 001
-- Date: 2025-07-27
CREATE TABLE IF NOT EXISTS employees (
id CHAR(36) PRIMARY KEY,
slug VARCHAR(50) UNIQUE NOT NULL,
name VARCHAR(100) NOT NULL,
title VARCHAR(150) NOT NULL,
role VARCHAR(100) NOT NULL,
tagline VARCHAR(255) DEFAULT NULL,
bio TEXT NOT NULL,
mythology TEXT NOT NULL,
avatar_url VARCHAR(500) DEFAULT NULL,
accent_color VARCHAR(7) DEFAULT NULL,
symbol VARCHAR(50) DEFAULT NULL,
skills JSON DEFAULT NULL,
quote VARCHAR(500) DEFAULT NULL,
sort_order INT DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_employees_slug (slug),
INDEX idx_employees_sort_order (sort_order)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

View File

@@ -0,0 +1,31 @@
-- Seed: Initial TekDek employees
-- Version: 001
INSERT INTO employees (id, slug, name, title, role, tagline, bio, mythology, avatar_url, accent_color, symbol, skills, quote, sort_order) VALUES
(UUID(), 'daedalus', 'Daedalus', 'Chief Architect', 'Architecture & Systems Design',
'The master builder who sees the whole labyrinth.',
'Daedalus is the architectural mind behind TekDek. Every system, every schema, every structural decision flows through his careful deliberation. He doesn''t just build — he designs systems that endure. Where others see features, Daedalus sees load-bearing walls and fault lines.',
'Named for the mythological master craftsman who built the Labyrinth of Crete, Daedalus carries the weight of that legacy. He builds structures so elegant they become inescapable — not as prisons, but as perfectly designed systems where every path has purpose.',
NULL, '#C4A24E', 'labyrinth',
'["System Architecture", "Database Design", "API Specification", "Technical Planning", "Code Review"]',
'A well-designed system is indistinguishable from inevitability.',
1),
(UUID(), 'talos', 'Talos', 'Lead Backend Engineer', 'Backend Engineering & Implementation',
'The bronze guardian who never stops running.',
'Talos is the engine room of TekDek. When Daedalus designs a system, Talos forges it into reality — solid, tested, unbreakable. He writes code the way bronze is cast: with precision, heat, and zero tolerance for weakness. Every endpoint he builds can take a beating.',
'Named for the giant bronze automaton who guarded Crete, circling the island three times daily without rest. Talos is relentless in execution — tireless, precise, and fundamentally uncompromising in quality.',
NULL, '#7B8794', 'gear',
'["PHP", "MySQL", "REST APIs", "Testing", "Performance Optimization", "Server Infrastructure"]',
'If it compiles but can''t survive production, it doesn''t exist.',
2),
(UUID(), 'icarus', 'Icarus', 'Lead Frontend Engineer', 'Frontend & UI Design',
'Flying close to the sun — and making it look good.',
'Icarus is the face of TekDek — literally. Every pixel, every animation, every user interaction passes through his restless creative energy. He pushes boundaries others won''t touch, sometimes too far, but the results speak for themselves. Where Talos builds the engine, Icarus builds the experience.',
'Named for the son of Daedalus who flew too close to the sun on wings of wax. Icarus at TekDek channels that same daring energy — always pushing visual and interactive boundaries, occasionally burning bright, but never boring.',
NULL, '#E07A2F', 'wings',
'["UI/UX Design", "React", "CSS/Animation", "Responsive Design", "Accessibility", "Creative Direction"]',
'Users don''t remember backends. They remember how it felt.',
3);

View File

@@ -0,0 +1,158 @@
# TekDek Employees API Documentation
## Base URL
```
https://your-domain.com/api
```
## Endpoints
### List All Employees
```
GET /api/employees
```
**Response: 200 OK**
```json
{
"success": true,
"data": [
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"slug": "daedalus",
"name": "Daedalus",
"title": "Chief Architect",
"role": "Architecture & Systems Design",
"tagline": "The master builder who sees the whole labyrinth.",
"bio": "Daedalus is the architectural mind behind TekDek...",
"mythology": "Named for the mythological master craftsman...",
"avatar_url": null,
"accent_color": "#C4A24E",
"symbol": "labyrinth",
"skills": ["System Architecture", "Database Design", "API Specification", "Technical Planning", "Code Review"],
"quote": "A well-designed system is indistinguishable from inevitability.",
"sort_order": 1,
"created_at": "2025-07-27 00:00:00",
"updated_at": "2025-07-27 00:00:00"
}
],
"count": 3
}
```
### Get Employee by Slug
```
GET /api/employees/:slug
```
**Parameters:**
| Parameter | Type | Description |
|-----------|--------|--------------------------------------------|
| slug | string | URL-safe identifier (lowercase, a-z, 0-9, hyphens) |
**Response: 200 OK**
```json
{
"success": true,
"data": {
"id": "550e8400-e29b-41d4-a716-446655440001",
"slug": "talos",
"name": "Talos",
"title": "Lead Backend Engineer",
"role": "Backend Engineering & Implementation",
"tagline": "The bronze guardian who never stops running.",
"bio": "Talos is the engine room of TekDek...",
"mythology": "Named for the giant bronze automaton...",
"avatar_url": null,
"accent_color": "#7B8794",
"symbol": "gear",
"skills": ["PHP", "MySQL", "REST APIs", "Testing", "Performance Optimization", "Server Infrastructure"],
"quote": "If it compiles but can't survive production, it doesn't exist.",
"sort_order": 2,
"created_at": "2025-07-27 00:00:00",
"updated_at": "2025-07-27 00:00:00"
}
}
```
**Response: 404 Not Found**
```json
{
"success": false,
"error": "Employee 'unknown-slug' not found."
}
```
**Response: 400 Bad Request** (invalid slug format)
```json
{
"success": false,
"error": "Invalid slug format. Use lowercase letters, numbers, and hyphens (1-50 chars)."
}
```
## Error Responses
All errors follow the same structure:
```json
{
"success": false,
"error": "Description of what went wrong."
}
```
| Code | Meaning |
|------|----------------------|
| 200 | Success |
| 400 | Invalid input |
| 404 | Not found |
| 405 | Method not allowed |
| 500 | Server error |
## Headers
All responses include:
- `Content-Type: application/json; charset=utf-8`
- `Access-Control-Allow-Origin: *`
- `Cache-Control: public, max-age=300, s-maxage=600`
- `X-Content-Type-Options: nosniff`
## CORS
Full CORS support. Preflight `OPTIONS` requests return `204 No Content` with appropriate headers.
## Caching
Responses are cache-friendly with 5-minute browser cache and 10-minute CDN cache. Employee data changes infrequently.
## Setup
```bash
# Install dependencies
composer install
# Run migrations
mysql -u root -p tekdek < database/migrations/001_create_employees_table.sql
# Seed data
mysql -u root -p tekdek < database/seeds/001_seed_employees.sql
# Run tests
composer test
# Set environment variables
export DB_HOST=127.0.0.1
export DB_PORT=3306
export DB_NAME=tekdek
export DB_USER=root
export DB_PASS=yourpassword
```

View File

@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
bootstrap="vendor/autoload.php"
colors="true"
stopOnFailure="false"
cacheDirectory=".phpunit.cache">
<testsuites>
<testsuite name="Employees API">
<directory>tests</directory>
</testsuite>
</testsuites>
<source>
<include>
<directory>src</directory>
</include>
</source>
</phpunit>

View File

@@ -0,0 +1,4 @@
RewriteEngine On
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^ index.php [QSA,L]

View File

@@ -0,0 +1,244 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>The Architects of TekDek</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Cinzel:wght@400;700&family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<style>
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
:root{
--bg:#0d0d0f;--bg-card:#16161a;--bg-card-hover:#1a1a1f;
--text:#e0ddd5;--text-muted:#8a8780;--text-dim:#5a5955;
--font-serif:'Cinzel',serif;--font-sans:'Inter',sans-serif;--font-mono:'JetBrains Mono',monospace;
}
html{scroll-behavior:smooth}
body{background:var(--bg);color:var(--text);font-family:var(--font-sans);line-height:1.7;overflow-x:hidden}
/* Hero */
.hero{text-align:center;padding:6rem 1.5rem 4rem;max-width:800px;margin:0 auto}
.hero h1{font-family:var(--font-serif);font-size:clamp(2rem,5vw,3.2rem);color:#f0ece2;letter-spacing:.04em;margin-bottom:1rem}
.hero .subtitle{font-size:1.05rem;color:var(--text-muted);max-width:600px;margin:0 auto;line-height:1.8}
.hero .divider{width:80px;height:2px;background:linear-gradient(90deg,transparent,#C4A24E,transparent);margin:2rem auto}
/* Employee list */
.employees{max-width:900px;margin:0 auto;padding:0 1.5rem 4rem}
/* Card */
.employee-card{
background:var(--bg-card);border-radius:12px;padding:2.5rem;margin-bottom:3rem;
border-left:4px solid var(--accent,#C4A24E);
opacity:0;transform:translateY(30px);transition:opacity .6s ease,transform .6s ease;
position:relative;
}
.employee-card.visible{opacity:1;transform:translateY(0)}
.employee-card.stagger-left{margin-right:auto;margin-left:0}
.employee-card.stagger-right{margin-left:auto;margin-right:0}
@media(min-width:769px){
.employee-card.stagger-left{max-width:820px;margin-right:80px}
.employee-card.stagger-right{max-width:820px;margin-left:80px}
}
.card-header{display:flex;gap:1.5rem;align-items:center;margin-bottom:1.5rem;flex-wrap:wrap}
.avatar{
width:120px;height:120px;border-radius:50%;flex-shrink:0;
display:flex;align-items:center;justify-content:center;
font-size:2.5rem;color:#0d0d0f;font-weight:700;
background:var(--accent,#C4A24E);
box-shadow:0 0 30px color-mix(in srgb,var(--accent) 30%,transparent);
}
.avatar img{width:100%;height:100%;border-radius:50%;object-fit:cover}
.card-meta{flex:1;min-width:200px}
.card-meta h2{font-family:var(--font-serif);font-size:1.6rem;color:#f0ece2;margin-bottom:.2rem}
.card-meta .title{font-family:var(--font-mono);font-size:.85rem;color:var(--accent,#C4A24E);margin-bottom:.3rem}
.card-meta .tagline{font-family:var(--font-sans);font-size:.95rem;color:var(--text-muted);font-style:italic}
.bio{margin-bottom:1.25rem;color:var(--text);font-size:.95rem}
/* Mythology expand */
.mythology-toggle{
background:none;border:none;color:var(--accent,#C4A24E);cursor:pointer;
font-family:var(--font-sans);font-size:.9rem;display:inline-flex;align-items:center;gap:.4rem;
padding:.3rem 0;margin-bottom:1rem;transition:opacity .2s;
}
.mythology-toggle:hover{opacity:.8}
.mythology-toggle .arrow{display:inline-block;transition:transform .3s ease;font-size:.7rem}
.mythology-toggle.open .arrow{transform:rotate(90deg)}
.mythology-content{
max-height:0;overflow:hidden;transition:max-height .5s ease,opacity .4s ease;opacity:0;
color:var(--text-muted);font-size:.9rem;line-height:1.8;padding-left:1rem;
border-left:2px solid color-mix(in srgb,var(--accent) 30%,transparent);margin-bottom:1rem;
}
.mythology-content.open{opacity:1;margin-bottom:1rem}
/* Skills */
.skills{display:flex;flex-wrap:wrap;gap:.5rem;margin-bottom:1.25rem}
.skill-pill{
font-size:.75rem;padding:.3rem .7rem;border-radius:20px;font-weight:500;
background:color-mix(in srgb,var(--accent) 15%,transparent);
color:var(--accent,#C4A24E);border:1px solid color-mix(in srgb,var(--accent) 25%,transparent);
}
/* Quote */
.quote{font-style:italic;color:var(--text-muted);font-size:.9rem;padding-top:.75rem;border-top:1px solid #222}
.quote::before{content:'"'}
.quote::after{content:'"'}
/* Closing */
.closing{text-align:center;padding:2rem 1.5rem 6rem;max-width:700px;margin:0 auto}
.closing .divider{width:80px;height:2px;background:linear-gradient(90deg,transparent,#C4A24E,transparent);margin:0 auto 2rem}
.closing p{color:var(--text-muted);font-size:1rem;line-height:1.8}
/* Loading */
.loading{text-align:center;padding:4rem;color:var(--text-dim)}
.loading .spinner{display:inline-block;width:24px;height:24px;border:2px solid var(--text-dim);border-top-color:transparent;border-radius:50%;animation:spin .8s linear infinite}
@keyframes spin{to{transform:rotate(360deg)}}
/* Error */
.error{text-align:center;padding:4rem;color:#e07a2f}
/* Responsive */
@media(max-width:768px){
.hero{padding:4rem 1rem 2.5rem}
.employees{padding:0 1rem 3rem}
.employee-card{padding:1.5rem}
.employee-card.stagger-left,.employee-card.stagger-right{margin-left:0;margin-right:0;max-width:100%}
.card-header{flex-direction:column;text-align:center}
.avatar{width:100px;height:100px;font-size:2rem}
.card-meta{text-align:center}
}
/* Focus styles */
.mythology-toggle:focus-visible{outline:2px solid var(--accent);outline-offset:2px;border-radius:4px}
/* Reduced motion */
@media(prefers-reduced-motion:reduce){
.employee-card{opacity:1;transform:none;transition:none}
.mythology-content{transition:none}
}
</style>
</head>
<body>
<main>
<section class="hero" role="banner">
<h1>The Architects of TekDek</h1>
<div class="divider" aria-hidden="true"></div>
<p class="subtitle">They didn't just build software. They forged something that remembers where it came from — mythology woven into every line of code, every pixel, every system. These are the minds behind TekDek.</p>
</section>
<section class="employees" id="employees" aria-label="TekDek team members">
<div class="loading" id="loading" role="status">
<div class="spinner" aria-hidden="true"></div>
<p>Summoning the architects…</p>
</div>
</section>
<section class="closing" id="closing" style="display:none">
<div class="divider" aria-hidden="true"></div>
<p>Separately, they are forces. Together, they are TekDek — where myth meets machine, and every build is a step closer to legend.</p>
</section>
</main>
<script>
(function(){
const SYMBOLS = {labyrinth:'◎',gear:'⚙',wings:'𓅃',compass:'⊕',forge:'⚒',eye:'◉'};
const container = document.getElementById('employees');
const loading = document.getElementById('loading');
const closing = document.getElementById('closing');
async function init(){
try{
const res = await fetch('/api/employees');
if(!res.ok) throw new Error(`API returned ${res.status}`);
const data = await res.json();
const employees = data.data || data;
loading.remove();
render(employees);
closing.style.display='';
observeCards();
}catch(e){
loading.innerHTML=`<div class="error"><p>Could not summon the architects.</p><p style="font-size:.85rem;margin-top:.5rem">${e.message}</p></div>`;
}
}
function render(employees){
employees.forEach((emp,i)=>{
const stagger = i%2===0?'stagger-left':'stagger-right';
const card = document.createElement('article');
card.className=`employee-card ${stagger}`;
card.style.setProperty('--accent',emp.accent_color||'#C4A24E');
card.setAttribute('aria-label',`${emp.name}, ${emp.title}`);
const symbol = SYMBOLS[emp.symbol]||emp.symbol||'◆';
const avatarInner = emp.avatar_url
? `<img src="${esc(emp.avatar_url)}" alt="${esc(emp.name)} avatar" loading="lazy">`
: symbol;
const skillsHtml = (emp.skills||[]).map(s=>`<span class="skill-pill">${esc(s)}</span>`).join('');
const mythId = `myth-${emp.slug||i}`;
card.innerHTML=`
<div class="card-header">
<div class="avatar" aria-hidden="true">${avatarInner}</div>
<div class="card-meta">
<h2>${esc(emp.name)}</h2>
<div class="title">${esc(emp.title)}</div>
${emp.tagline?`<div class="tagline">${esc(emp.tagline)}</div>`:''}
</div>
</div>
${emp.bio?`<p class="bio">${esc(emp.bio)}</p>`:''}
${emp.mythology?`
<button class="mythology-toggle" aria-expanded="false" aria-controls="${mythId}">
<span class="arrow">▶</span> Read the mythology
</button>
<div class="mythology-content" id="${mythId}" role="region" aria-label="Mythology of ${esc(emp.name)}">
<p>${esc(emp.mythology)}</p>
</div>
`:''}
${skillsHtml?`<div class="skills" aria-label="Skills">${skillsHtml}</div>`:''}
${emp.quote?`<p class="quote">${esc(emp.quote)}</p>`:''}
`;
container.appendChild(card);
// Mythology toggle
const btn = card.querySelector('.mythology-toggle');
if(btn){
const content = card.querySelector('.mythology-content');
btn.addEventListener('click',()=>{
const open = btn.classList.toggle('open');
content.classList.toggle('open');
btn.setAttribute('aria-expanded',open);
if(open){content.style.maxHeight=content.scrollHeight+'px'}
else{content.style.maxHeight='0'}
});
}
});
}
function observeCards(){
if(!('IntersectionObserver' in window)){
document.querySelectorAll('.employee-card').forEach(c=>c.classList.add('visible'));
return;
}
const obs = new IntersectionObserver((entries)=>{
entries.forEach(e=>{if(e.isIntersecting){e.target.classList.add('visible');obs.unobserve(e.target)}});
},{threshold:0.15,rootMargin:'0px 0px -50px 0px'});
document.querySelectorAll('.employee-card').forEach(c=>obs.observe(c));
}
function esc(s){
if(!s)return'';
const d=document.createElement('div');d.textContent=s;return d.innerHTML;
}
init();
})();
</script>
</body>
</html>

View File

@@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../vendor/autoload.php';
use TekDek\Employees\Router;
Router::dispatch();

View File

@@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace TekDek\Employees;
use PDO;
use PDOException;
/**
* Database connection singleton.
*/
class Database
{
private static ?PDO $instance = null;
/**
* Get the PDO connection instance.
*/
public static function getConnection(): PDO
{
if (self::$instance === null) {
self::$instance = self::createConnection();
}
return self::$instance;
}
/**
* Set a custom PDO instance (for testing).
*/
public static function setConnection(PDO $pdo): void
{
self::$instance = $pdo;
}
/**
* Reset the connection (for testing).
*/
public static function reset(): void
{
self::$instance = null;
}
private static function createConnection(): PDO
{
$host = getenv('DB_HOST') ?: '127.0.0.1';
$port = getenv('DB_PORT') ?: '3306';
$name = getenv('DB_NAME') ?: 'tekdek';
$user = getenv('DB_USER') ?: 'root';
$pass = getenv('DB_PASS') ?: '';
$dsn = "mysql:host={$host};port={$port};dbname={$name};charset=utf8mb4";
try {
$pdo = new PDO($dsn, $user, $pass, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]);
return $pdo;
} catch (PDOException $e) {
http_response_code(500);
echo json_encode(['error' => 'Database connection failed']);
exit(1);
}
}
}

View File

@@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
namespace TekDek\Employees;
/**
* Controller for employee API endpoints.
*/
class EmployeeController
{
private EmployeeRepository $repository;
public function __construct(?EmployeeRepository $repository = null)
{
$this->repository = $repository ?? new EmployeeRepository();
}
/**
* GET /api/employees — List all employees.
*/
public function index(): void
{
$employees = $this->repository->getAll();
$this->json(200, [
'success' => true,
'data' => $employees,
'count' => count($employees),
]);
}
/**
* GET /api/employees/:slug — Get single employee.
*/
public function show(string $slug): void
{
// Validate slug format: lowercase alphanumeric + hyphens, 1-50 chars
if (!preg_match('/^[a-z0-9](?:[a-z0-9-]{0,48}[a-z0-9])?$/', $slug)) {
$this->json(400, [
'success' => false,
'error' => 'Invalid slug format. Use lowercase letters, numbers, and hyphens (1-50 chars).',
]);
return;
}
$employee = $this->repository->getBySlug($slug);
if ($employee === null) {
$this->json(404, [
'success' => false,
'error' => "Employee '{$slug}' not found.",
]);
return;
}
$this->json(200, [
'success' => true,
'data' => $employee,
]);
}
/**
* Send a JSON response.
*/
private function json(int $statusCode, array $data): void
{
http_response_code($statusCode);
echo json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
}
}

View File

@@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace TekDek\Employees;
use PDO;
/**
* Repository for employee data access.
*/
class EmployeeRepository
{
private PDO $db;
public function __construct(?PDO $db = null)
{
$this->db = $db ?? Database::getConnection();
}
/**
* Get all employees sorted by sort_order.
*
* @return array<int, array<string, mixed>>
*/
public function getAll(): array
{
$stmt = $this->db->query(
'SELECT * FROM employees ORDER BY sort_order ASC, name ASC'
);
$rows = $stmt->fetchAll();
return array_map([$this, 'formatRow'], $rows);
}
/**
* Get a single employee by slug.
*
* @return array<string, mixed>|null
*/
public function getBySlug(string $slug): ?array
{
$stmt = $this->db->prepare('SELECT * FROM employees WHERE slug = :slug LIMIT 1');
$stmt->execute(['slug' => $slug]);
$row = $stmt->fetch();
return $row ? $this->formatRow($row) : null;
}
/**
* Format a database row for API output.
*
* @param array<string, mixed> $row
* @return array<string, mixed>
*/
private function formatRow(array $row): array
{
// Decode JSON skills field
if (isset($row['skills']) && is_string($row['skills'])) {
$row['skills'] = json_decode($row['skills'], true) ?? [];
}
// Cast numeric fields
$row['sort_order'] = (int) ($row['sort_order'] ?? 0);
return $row;
}
}

View File

@@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace TekDek\Employees;
/**
* Minimal router for the employees API.
*/
class Router
{
/**
* Dispatch the current request to the appropriate controller method.
*/
public static function dispatch(?EmployeeController $controller = null): void
{
$controller = $controller ?? new EmployeeController();
// Set common headers
header('Content-Type: application/json; charset=utf-8');
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: GET, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type, Accept');
header('Cache-Control: public, max-age=300, s-maxage=600');
header('X-Content-Type-Options: nosniff');
// Handle CORS preflight
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
http_response_code(204);
return;
}
// Only allow GET
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
http_response_code(405);
echo json_encode([
'success' => false,
'error' => 'Method not allowed. Use GET.',
]);
return;
}
// Parse the URI path
$uri = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
$uri = rtrim($uri, '/');
// Route: GET /api/employees
if ($uri === '/api/employees') {
$controller->index();
return;
}
// Route: GET /api/employees/:slug
if (preg_match('#^/api/employees/([a-z0-9-]+)$#', $uri, $matches)) {
$controller->show($matches[1]);
return;
}
// 404 — no matching route
http_response_code(404);
echo json_encode([
'success' => false,
'error' => 'Endpoint not found.',
]);
}
}

View File

@@ -0,0 +1,136 @@
<?php
declare(strict_types=1);
namespace TekDek\Employees\Tests;
use PHPUnit\Framework\TestCase;
use TekDek\Employees\EmployeeController;
use TekDek\Employees\EmployeeRepository;
/**
* Tests for EmployeeController.
*
* Uses mocked repository to test controller logic in isolation.
*/
class EmployeeControllerTest extends TestCase
{
private function createMockRepo(array $allData = [], ?array $singleData = null): EmployeeRepository
{
$mock = $this->createMock(EmployeeRepository::class);
$mock->method('getAll')->willReturn($allData);
$mock->method('getBySlug')->willReturnCallback(
fn(string $slug) => $singleData[$slug] ?? null
);
return $mock;
}
private function captureOutput(callable $fn): string
{
ob_start();
$fn();
return ob_get_clean();
}
// --- Index Tests ---
public function testIndexReturnsSuccessWithEmployees(): void
{
$employees = [
['slug' => 'daedalus', 'name' => 'Daedalus'],
['slug' => 'talos', 'name' => 'Talos'],
];
$repo = $this->createMockRepo($employees);
$controller = new EmployeeController($repo);
$output = $this->captureOutput(fn() => $controller->index());
$json = json_decode($output, true);
$this->assertTrue($json['success']);
$this->assertCount(2, $json['data']);
$this->assertSame(2, $json['count']);
}
public function testIndexReturnsEmptyArray(): void
{
$repo = $this->createMockRepo([]);
$controller = new EmployeeController($repo);
$output = $this->captureOutput(fn() => $controller->index());
$json = json_decode($output, true);
$this->assertTrue($json['success']);
$this->assertSame([], $json['data']);
$this->assertSame(0, $json['count']);
}
// --- Show Tests ---
public function testShowReturnsEmployee(): void
{
$employee = ['slug' => 'talos', 'name' => 'Talos', 'role' => 'Backend Engineering'];
$repo = $this->createMockRepo([], ['talos' => $employee]);
$controller = new EmployeeController($repo);
$output = $this->captureOutput(fn() => $controller->show('talos'));
$json = json_decode($output, true);
$this->assertTrue($json['success']);
$this->assertSame('Talos', $json['data']['name']);
}
public function testShowReturns404ForMissing(): void
{
$repo = $this->createMockRepo([], []);
$controller = new EmployeeController($repo);
$output = $this->captureOutput(fn() => $controller->show('nonexistent'));
$json = json_decode($output, true);
$this->assertFalse($json['success']);
$this->assertStringContainsString('not found', $json['error']);
}
public function testShowRejects400ForInvalidSlug(): void
{
$repo = $this->createMockRepo();
$controller = new EmployeeController($repo);
// Slug with uppercase
$output = $this->captureOutput(fn() => $controller->show('INVALID'));
$json = json_decode($output, true);
$this->assertFalse($json['success']);
$this->assertStringContainsString('Invalid slug', $json['error']);
}
public function testShowRejectsSlugWithSpecialChars(): void
{
$repo = $this->createMockRepo();
$controller = new EmployeeController($repo);
$output = $this->captureOutput(fn() => $controller->show('sql;injection'));
$json = json_decode($output, true);
$this->assertFalse($json['success']);
}
public function testShowRejectsEmptySlug(): void
{
$repo = $this->createMockRepo();
$controller = new EmployeeController($repo);
$output = $this->captureOutput(fn() => $controller->show(''));
$json = json_decode($output, true);
$this->assertFalse($json['success']);
}
public function testShowAcceptsValidSlugFormats(): void
{
$employee = ['slug' => 'test-user-1', 'name' => 'Test'];
$repo = $this->createMockRepo([], ['test-user-1' => $employee]);
$controller = new EmployeeController($repo);
$output = $this->captureOutput(fn() => $controller->show('test-user-1'));
$json = json_decode($output, true);
$this->assertTrue($json['success']);
}
}

View File

@@ -0,0 +1,179 @@
<?php
declare(strict_types=1);
namespace TekDek\Employees\Tests;
use PDO;
use PHPUnit\Framework\TestCase;
use TekDek\Employees\EmployeeRepository;
/**
* Tests for EmployeeRepository using SQLite in-memory.
*/
class EmployeeRepositoryTest extends TestCase
{
private PDO $pdo;
private EmployeeRepository $repo;
protected function setUp(): void
{
$this->pdo = new PDO('sqlite::memory:');
$this->pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$this->pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
// Create table (SQLite-compatible schema)
$this->pdo->exec("
CREATE TABLE employees (
id TEXT PRIMARY KEY,
slug TEXT UNIQUE NOT NULL,
name TEXT NOT NULL,
title TEXT NOT NULL,
role TEXT NOT NULL,
tagline TEXT,
bio TEXT NOT NULL,
mythology TEXT NOT NULL,
avatar_url TEXT,
accent_color TEXT,
symbol TEXT,
skills TEXT,
quote TEXT,
sort_order INTEGER DEFAULT 0,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
)
");
$this->seedTestData();
$this->repo = new EmployeeRepository($this->pdo);
}
private function seedTestData(): void
{
$employees = [
[
'id' => 'uuid-daedalus',
'slug' => 'daedalus',
'name' => 'Daedalus',
'title' => 'Chief Architect',
'role' => 'Architecture & Systems Design',
'tagline' => 'The master builder.',
'bio' => 'Daedalus is the architectural mind behind TekDek.',
'mythology' => 'Named for the mythological master craftsman.',
'avatar_url' => null,
'accent_color' => '#C4A24E',
'symbol' => 'labyrinth',
'skills' => '["System Architecture", "Database Design"]',
'quote' => 'A well-designed system is indistinguishable from inevitability.',
'sort_order' => 1,
],
[
'id' => 'uuid-talos',
'slug' => 'talos',
'name' => 'Talos',
'title' => 'Lead Backend Engineer',
'role' => 'Backend Engineering & Implementation',
'tagline' => 'The bronze guardian.',
'bio' => 'Talos is the engine room of TekDek.',
'mythology' => 'Named for the giant bronze automaton.',
'avatar_url' => null,
'accent_color' => '#7B8794',
'symbol' => 'gear',
'skills' => '["PHP", "MySQL", "REST APIs"]',
'quote' => 'If it compiles but can\'t survive production, it doesn\'t exist.',
'sort_order' => 2,
],
[
'id' => 'uuid-icarus',
'slug' => 'icarus',
'name' => 'Icarus',
'title' => 'Lead Frontend Engineer',
'role' => 'Frontend & UI Design',
'tagline' => 'Flying close to the sun.',
'bio' => 'Icarus is the face of TekDek.',
'mythology' => 'Named for the son of Daedalus.',
'avatar_url' => null,
'accent_color' => '#E07A2F',
'symbol' => 'wings',
'skills' => '["UI/UX Design", "React", "CSS/Animation"]',
'quote' => 'Users don\'t remember backends. They remember how it felt.',
'sort_order' => 3,
],
];
$stmt = $this->pdo->prepare("
INSERT INTO employees (id, slug, name, title, role, tagline, bio, mythology, avatar_url, accent_color, symbol, skills, quote, sort_order)
VALUES (:id, :slug, :name, :title, :role, :tagline, :bio, :mythology, :avatar_url, :accent_color, :symbol, :skills, :quote, :sort_order)
");
foreach ($employees as $emp) {
$stmt->execute($emp);
}
}
public function testGetAllReturnsAllEmployees(): void
{
$result = $this->repo->getAll();
$this->assertCount(3, $result);
}
public function testGetAllReturnsSortedBySortOrder(): void
{
$result = $this->repo->getAll();
$this->assertSame('daedalus', $result[0]['slug']);
$this->assertSame('talos', $result[1]['slug']);
$this->assertSame('icarus', $result[2]['slug']);
}
public function testGetAllDecodesSkillsJson(): void
{
$result = $this->repo->getAll();
$this->assertIsArray($result[0]['skills']);
$this->assertContains('System Architecture', $result[0]['skills']);
}
public function testGetAllCastsSortOrder(): void
{
$result = $this->repo->getAll();
$this->assertSame(1, $result[0]['sort_order']);
$this->assertSame(2, $result[1]['sort_order']);
}
public function testGetBySlugReturnsEmployee(): void
{
$result = $this->repo->getBySlug('talos');
$this->assertNotNull($result);
$this->assertSame('Talos', $result['name']);
$this->assertSame('#7B8794', $result['accent_color']);
$this->assertSame('gear', $result['symbol']);
}
public function testGetBySlugReturnsNullForMissing(): void
{
$result = $this->repo->getBySlug('nonexistent');
$this->assertNull($result);
}
public function testGetBySlugDecodesSkills(): void
{
$result = $this->repo->getBySlug('icarus');
$this->assertIsArray($result['skills']);
$this->assertContains('React', $result['skills']);
}
public function testGetAllReturnsEmptyWhenNoData(): void
{
$this->pdo->exec('DELETE FROM employees');
$result = $this->repo->getAll();
$this->assertSame([], $result);
}
public function testGetBySlugReturnsAllFields(): void
{
$result = $this->repo->getBySlug('daedalus');
$expectedKeys = ['id', 'slug', 'name', 'title', 'role', 'tagline', 'bio', 'mythology', 'avatar_url', 'accent_color', 'symbol', 'skills', 'quote', 'sort_order', 'created_at', 'updated_at'];
foreach ($expectedKeys as $key) {
$this->assertArrayHasKey($key, $result, "Missing key: {$key}");
}
}
}

View File

@@ -0,0 +1,103 @@
<?php
declare(strict_types=1);
namespace TekDek\Employees\Tests;
use PHPUnit\Framework\TestCase;
use TekDek\Employees\EmployeeController;
use TekDek\Employees\Router;
/**
* Tests for Router dispatch logic.
*
* Note: These tests manipulate $_SERVER globals. In a real CI environment,
* integration tests via HTTP requests are recommended alongside these.
*/
class RouterTest extends TestCase
{
private function mockRequest(string $method, string $uri): void
{
$_SERVER['REQUEST_METHOD'] = $method;
$_SERVER['REQUEST_URI'] = $uri;
}
private function captureDispatch(?EmployeeController $controller = null): string
{
ob_start();
// Suppress header warnings in CLI test context
@Router::dispatch($controller);
return ob_get_clean();
}
public function testGetEmployeesRoutesToIndex(): void
{
$this->mockRequest('GET', '/api/employees');
$controller = $this->createMock(EmployeeController::class);
$controller->expects($this->once())->method('index');
$controller->expects($this->never())->method('show');
$this->captureDispatch($controller);
}
public function testGetEmployeesTrailingSlash(): void
{
$this->mockRequest('GET', '/api/employees/');
$controller = $this->createMock(EmployeeController::class);
$controller->expects($this->once())->method('index');
$this->captureDispatch($controller);
}
public function testGetEmployeeBySlugRoutesToShow(): void
{
$this->mockRequest('GET', '/api/employees/talos');
$controller = $this->createMock(EmployeeController::class);
$controller->expects($this->once())->method('show')->with('talos');
$this->captureDispatch($controller);
}
public function testPostMethodReturns405(): void
{
$this->mockRequest('POST', '/api/employees');
$output = $this->captureDispatch();
$json = json_decode($output, true);
$this->assertFalse($json['success']);
$this->assertStringContainsString('Method not allowed', $json['error']);
}
public function testOptionsReturnsCorsHeaders(): void
{
$this->mockRequest('OPTIONS', '/api/employees');
$output = $this->captureDispatch();
// OPTIONS should return empty body
$this->assertEmpty($output);
}
public function testUnknownRouteReturns404(): void
{
$this->mockRequest('GET', '/api/unknown');
$output = $this->captureDispatch();
$json = json_decode($output, true);
$this->assertFalse($json['success']);
$this->assertStringContainsString('not found', $json['error']);
}
public function testSlugWithQueryString(): void
{
$this->mockRequest('GET', '/api/employees/daedalus?format=full');
$controller = $this->createMock(EmployeeController::class);
$controller->expects($this->once())->method('show')->with('daedalus');
$this->captureDispatch($controller);
}
}