318 lines
15 KiB
Markdown
318 lines
15 KiB
Markdown
# Pulse Clock
|
||
|
||
Multi-tenant time clock and workforce management system for hotel/hospitality environments. Employees punch in/out and take breaks via a kiosk interface; administrators monitor time cards, detect scheduling issues, and export payroll data.
|
||
|
||
## Overview
|
||
|
||
`pulse-clock` provides:
|
||
|
||
- **Employee Kiosk**: Web-based punch interface with webcam capture
|
||
- **Admin Dashboard**: Time grid view with issue detection
|
||
- **Management**: Hotels and employees CRUD
|
||
- **Payroll Export**: TSV download with validation checks
|
||
- **Validation Engine**: State machine enforcing valid punch sequences
|
||
|
||
## Prerequisites
|
||
|
||
- **Node.js** (v20.9 or higher)
|
||
- **npm** (package manager)
|
||
- **SQLite** (accessed through Drizzle and the libSQL client)
|
||
|
||
## Installation
|
||
|
||
```bash
|
||
cd pulse-clock
|
||
npm install
|
||
```
|
||
|
||
Copy the example environment file and configure:
|
||
|
||
```bash
|
||
cp .env.example .env
|
||
```
|
||
|
||
Edit `.env` with your configuration. Key variables:
|
||
|
||
| Variable | Description | Default |
|
||
| ------------------- | ------------------------------------ | ----------------------- |
|
||
| `PULSE_SECRET` | Login secret; use 32+ random chars | — |
|
||
| `PULSE_RATE_LIMIT` | API rate limit (requests per window) | `60` |
|
||
| `PULSE_RATE_WINDOW` | Rate limit window (seconds) | `60` |
|
||
| `DATABASE_URL` | SQLite database path | `./data/pulse-clock.db` |
|
||
|
||
## Usage
|
||
|
||
### Development Server
|
||
|
||
```bash
|
||
npm run dev
|
||
```
|
||
|
||
Open [http://localhost:3000](http://localhost:3000) to access the application.
|
||
|
||
### Build for Production
|
||
|
||
```bash
|
||
npm run build
|
||
```
|
||
|
||
## Architecture
|
||
|
||
```
|
||
┌─────────────────────────────────────────────────────────────────┐
|
||
│ Pulse Clock │
|
||
├─────────────────────────────────────────────────────────────────┤
|
||
│ │
|
||
│ ┌─────────────────┐ ┌─────────────────┐ │
|
||
│ │ Kiosk Mode │ │ Admin Dashboard │ │
|
||
│ │ /kiosk/* │ │ /admin/* │ │
|
||
│ └────────┬────────┘ └────────┬────────┘ │
|
||
│ │ │ │
|
||
│ ▼ ▼ │
|
||
│ ┌─────────────────────────────────────────────────────────┐ │
|
||
│ │ API Routes │ │
|
||
│ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │
|
||
│ │ │ /punch │ │ /time- │ │ /payroll │ │ │
|
||
│ │ │ │ │ entries │ │ │ │ │
|
||
│ │ └──────────┘ └──────────┘ └──────────┘ │ │
|
||
│ └─────────────────────────────────────────────────────────┘ │
|
||
│ │ │
|
||
│ ▼ │
|
||
│ ┌─────────────────────────────────────────────────────────┐ │
|
||
│ │ time-entry-service.ts │ │
|
||
│ │ (createValidatedTimeEntry) │ │
|
||
│ │ Punch State Machine │ │
|
||
│ └─────────────────────────────────────────────────────────┘ │
|
||
│ │ │
|
||
│ ▼ │
|
||
│ ┌─────────────────────────────────────────────────────────┐ │
|
||
│ │ SQLite Database │ │
|
||
│ │ companies | employees | time_entries | audit_log │ │
|
||
│ └─────────────────────────────────────────────────────────┘ │
|
||
│ │
|
||
└─────────────────────────────────────────────────────────────────┘
|
||
```
|
||
|
||
### Data Flow
|
||
|
||
```
|
||
┌─────────────────────────────────────────────────────────────────┐
|
||
│ Kiosk Punch Flow │
|
||
├─────────────────────────────────────────────────────────────────┤
|
||
│ │
|
||
│ Employee Selects → Webcam Capture → Punch Button Click │
|
||
│ │ │
|
||
│ ▼ │
|
||
│ ┌────────────────────────┐ │
|
||
│ │ POST /api/punch │ │
|
||
│ │ { employeeId, type }│ │
|
||
│ └────────────┬───────────┘ │
|
||
│ │ │
|
||
│ ▼ │
|
||
│ ┌────────────────────────┐ │
|
||
│ │ Punch State Machine │ │
|
||
│ │ validateNewEntry... │ │
|
||
│ └────────────┬───────────┘ │
|
||
│ │ │
|
||
│ ┌─────────────────────┼──────────────────┐ │
|
||
│ │ │ │ │
|
||
│ ▼ ▼ ▼ │
|
||
│ ┌───────────────┐ ┌───────────────┐ ┌──────────┐
|
||
│ │ Valid Punch │ │ Invalid Punch │ │ Duplicate│
|
||
│ │ → Save Entry │ │ → Reject │ │ → Ignore │
|
||
│ └───────────────┘ └───────────────┘ └──────────┘
|
||
│ │
|
||
└─────────────────────────────────────────────────────────────────┘
|
||
```
|
||
|
||
### Punch State Machine
|
||
|
||
```
|
||
Valid Transitions:
|
||
|
||
OUT ──────▶ IN (clock in)
|
||
IN ──────▶ OUT (clock out)
|
||
IN ──────▶ BREAK_OUT (start break)
|
||
BREAK_OUT ─▶ BREAK_IN (end break)
|
||
|
||
All other transitions are rejected unless forceOverride=true
|
||
```
|
||
|
||
## Key Features
|
||
|
||
### Employee Kiosk
|
||
|
||
- Live employee cards showing current status: **Off Duty** / **On Shift** / **On Break**
|
||
- Webcam capture on each punch (base64 stored in DB)
|
||
- Punch modal with Clock In, Clock Out, Start Break, End Break buttons
|
||
- 5-second duplicate-punch guard on kiosk clicks
|
||
- Auto-refreshes employee status every 5 seconds
|
||
- Elapsed shift time shown on employee cards (net of break time)
|
||
|
||
### Admin Dashboard
|
||
|
||
- Date range selector with Today/Yesterday/Last 7/Last 14/Last 30 presets
|
||
- Grid view: employees (rows) × calendar days (columns)
|
||
- Red cell highlighting for validation issues
|
||
- "Issues Only" filter toggle
|
||
- Detail drawer per employee/day: full punch timeline
|
||
- Timestamp editing through authenticated admin sessions
|
||
- Add manual punch entries
|
||
|
||
### Validation Engine
|
||
|
||
Detects per employee per day:
|
||
|
||
- First entry is not Clock In
|
||
- Consecutive duplicate entries (e.g., two INs in a row)
|
||
- Break started without prior Clock In
|
||
- Break ended without break start
|
||
- Break started but never ended
|
||
- Still clocked in at day end
|
||
- Missing clock out
|
||
|
||
### Payroll Export
|
||
|
||
- Date range presets including Pay Period 1-15 and Pay Period 16-31
|
||
- Preview table: Employee | Date | In | Out | Break (min) | Net Hours | Notes
|
||
- Copy to clipboard (TSV) and Download .tsv buttons
|
||
- Issues column flags validation problems per day row
|
||
- Summary strip with total hours and error counts
|
||
|
||
### Public Hosting Security
|
||
|
||
- All pages redirect to `/login` unless the `pulse_auth` cookie is valid.
|
||
- All API routes except `/api/authorize` and `/api/health` require the auth cookie.
|
||
- `PULSE_SECRET` is required and must be at least 32 characters.
|
||
- The auth cookie is `HttpOnly`, `SameSite=Lax`, `Secure` in production, and valid for 1 year.
|
||
- Login attempts and authenticated API calls are rate limited by IP.
|
||
- Mutating API requests require same-origin/CSRF protection.
|
||
|
||
### Multi-Tenancy
|
||
|
||
All data is scoped by `companyId`:
|
||
|
||
- Employees belong to a company
|
||
- Time entries scoped to company + employee
|
||
- Audit logs scoped to company
|
||
|
||
## Pages Reference
|
||
|
||
| Path | Description |
|
||
| -------------------- | -------------------------------- |
|
||
| `/` | Landing page with company grid |
|
||
| `/login` | Secret-based login page |
|
||
| `/kiosk` | Kiosk company selector |
|
||
| `/kiosk/[companyId]` | Employee kiosk interface |
|
||
| `/admin` | Admin time grid dashboard |
|
||
| `/admin/manage` | Hotels and employees CRUD |
|
||
| `/admin/payroll` | Payroll export with TSV download |
|
||
|
||
## API Reference
|
||
|
||
| Endpoint | Method | Description |
|
||
| ---------------------------- | ------------------------ | ------------------------------ |
|
||
| `/api/companies` | GET, POST | Company CRUD |
|
||
| `/api/employees` | GET, POST | Employee CRUD |
|
||
| `/api/employees/with-status` | GET | Employee list with live status |
|
||
| `/api/punch` | POST | Kiosk punch (state machine) |
|
||
| `/api/time-entries` | GET, POST, PATCH, DELETE | Time entry CRUD + manual edits |
|
||
| `/api/payroll` | GET | Payroll TSV generation |
|
||
| `/api/authorize` | GET, POST | Cookie auth check/login |
|
||
| `/api/health` | GET | Unauthenticated healthcheck |
|
||
|
||
### Authentication
|
||
|
||
Open `/login` and enter `PULSE_SECRET`. Successful login sets the `pulse_auth` cookie.
|
||
|
||
API calls from the browser use that cookie automatically. For non-browser scripts, first authenticate and store the cookie:
|
||
|
||
```bash
|
||
curl -c cookies.txt \
|
||
-H "Content-Type: application/json" \
|
||
-H "x-pulse-csrf: 1" \
|
||
-X POST https://localhost:3000/api/authorize \
|
||
-d '{"secret":"your-32-character-minimum-secret"}'
|
||
|
||
curl -b cookies.txt \
|
||
-H "Content-Type: application/json" \
|
||
-H "x-pulse-csrf: 1" \
|
||
-X PATCH https://localhost:3000/api/time-entries \
|
||
-d '{"id":123,"timestamp":1747507200}'
|
||
```
|
||
|
||
## Database Schema
|
||
|
||
```sql
|
||
companies (id, name, created_at)
|
||
employees (id, company_id, name, is_active, created_at)
|
||
time_entries (id, employee_id, company_id, type, timestamp, photo_base64, is_deleted)
|
||
audit_log (id, employee_id, company_id, admin_id, action, detail, created_at)
|
||
```
|
||
|
||
## Project Structure
|
||
|
||
```
|
||
pulse-clock/
|
||
├── src/
|
||
│ ├── app/ # Next.js App Router
|
||
│ │ ├── page.tsx # Landing page
|
||
│ │ ├── kiosk/
|
||
│ │ │ └── [companyId]/ # Kiosk UI
|
||
│ │ ├── admin/
|
||
│ │ │ ├── page.tsx # Time grid dashboard
|
||
│ │ │ ├── manage/page.tsx # Hotels & employees CRUD
|
||
│ │ │ └── payroll/page.tsx # Payroll export
|
||
│ │ └── api/ # API routes
|
||
│ ├── lib/ # Shared business logic
|
||
│ │ ├── api.ts # fetchApi wrapper
|
||
│ │ ├── validation.ts # State machine
|
||
│ │ ├── calculations.ts # Time calculations
|
||
│ │ ├── time.ts # Date utilities
|
||
│ │ └── time-entry-service.ts
|
||
│ ├── db/
|
||
│ │ ├── schema.ts # Drizzle schema
|
||
│ │ └── index.ts # Drizzle client and migrations
|
||
│ └── components/ # React components
|
||
├── drizzle/ # SQL migrations
|
||
└── package.json
|
||
```
|
||
|
||
## Docker / Coolify Deployment
|
||
|
||
The app includes a production `Dockerfile` for Coolify.
|
||
|
||
Recommended Coolify settings:
|
||
|
||
| Setting | Value |
|
||
| ------------------- | ------------ |
|
||
| Build Pack | Dockerfile |
|
||
| Dockerfile Location | `Dockerfile` |
|
||
| Port | `3000` |
|
||
| Persistent Storage | `/app/data` |
|
||
|
||
Set these environment variables in Coolify:
|
||
|
||
```bash
|
||
PULSE_SECRET=replace-with-at-least-32-random-characters
|
||
DATABASE_URL=/app/data/pulse-clock.db
|
||
PULSE_RATE_LIMIT=60
|
||
PULSE_RATE_WINDOW=60
|
||
```
|
||
|
||
Generate a deployment secret with:
|
||
|
||
```bash
|
||
openssl rand -base64 32
|
||
```
|
||
|
||
For local Docker testing:
|
||
|
||
```bash
|
||
docker build -t pulse-clock .
|
||
docker run --rm -p 3000:3000 -v pulse-clock-data:/app/data --env-file .env pulse-clock
|
||
```
|
||
|
||
SQLite migrations run automatically on startup, and the database is stored in `/app/data` when deployed with the Docker defaults.
|
||
Run one app replica per SQLite volume so startup migrations are not executed concurrently.
|