Files
pulsy/README.md
T

318 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.