A full-stack time in/out attendance system with face recognition, built as a modular monorepo.
graph TD
Browser["🖥️ React Frontend<br/>localhost:5173"]
Backend["☕ Spring Boot API<br/>localhost:8080"]
FaceService["🐍 Face Recognition<br/>FastAPI · localhost:5001"]
DB["🐘 PostgreSQL<br/>localhost:5432"]
Browser --> |HTTP/REST| Backend
Backend --> |JPA/Flyway| DB
Backend --> |HTTP multipart| FaceService
FaceService --> |DeepFace embeddings| FaceStorage["📁 ./data/embeddings"]
bundyclock/
├── backend/ ← Spring Boot (Java 23, Gradle KTS)
│ ├── build.gradle.kts
│ ├── settings.gradle.kts
│ └── src/
│ ├── main/
│ │ ├── java/com/bundyclock/
│ │ │ ├── BundyClockApplication.java
│ │ │ ├── auth/
│ │ │ │ ├── AuthController.java
│ │ │ │ ├── JwtAuthenticationFilter.java
│ │ │ │ └── JwtService.java
│ │ │ ├── config/
│ │ │ │ ├── AppConfig.java ← WebClient bean + HTTP timeouts
│ │ │ │ ├── CorsConfig.java ← PATCH added to allowed methods
│ │ │ │ ├── OpenApiConfig.java
│ │ │ │ ├── SecurityConfig.java
│ │ │ │ └── WebMvcConfig.java ← serves /uploads/** static files
│ │ │ ├── common/
│ │ │ │ ├── dto/
│ │ │ │ │ └── ApiResponse.java
│ │ │ │ └── exception/
│ │ │ │ ├── GlobalExceptionHandler.java
│ │ │ │ └── ResourceNotFoundException.java
│ │ │ └── domain/
│ │ │ ├── employee/
│ │ │ │ ├── Employee.java ← shift fields added
│ │ │ │ ├── EmployeeController.java
│ │ │ │ ├── EmployeeRepository.java
│ │ │ │ ├── EmployeeService.java
│ │ │ │ └── EmployeeServiceImpl.java
│ │ │ ├── attendance/
│ │ │ │ ├── AttendanceLog.java
│ │ │ │ ├── AttendanceController.java
│ │ │ │ ├── AttendanceLogRepository.java
│ │ │ │ ├── AttendanceService.java
│ │ │ │ └── AttendanceServiceImpl.java ← duplicate + shift guard
│ │ │ ├── shift/
│ │ │ │ ├── ShiftSchedule.java
│ │ │ │ ├── ShiftScheduleController.java
│ │ │ │ └── ShiftScheduleRepository.java
│ │ │ └── face/
│ │ │ ├── FaceEmbedding.java
│ │ │ ├── FaceController.java
│ │ │ ├── FaceEmbeddingRepository.java
│ │ │ ├── FaceService.java
│ │ │ ├── FaceServiceImpl.java ← WebClient + HTTP calls
│ │ │ ├── FaceStatusResponse.java
│ │ │ └── FaceVerifyResult.java
│ │ └── resources/
│ │ ├── application.yml
│ │ ├── application-dev.yml
│ │ └── db/migration/
│ │ ├── V1__create_initial_tables.sql
│ │ ├── V2__add_photo_url_to_employees.sql
│ │ ├── V3__add_status_to_employees.sql
│ │ └── V4__add_shift_schedules.sql
│ └── test/
│ ├── java/com/bundyclock/
│ │ ├── BundyClockApplicationTests.java
│ │ ├── auth/
│ │ │ └── AuthControllerTest.java
│ │ └── domain/
│ │ ├── attendance/AttendanceControllerTest.java
│ │ ├── employee/EmployeeControllerTest.java
│ │ └── face/FaceControllerTest.java
│ └── resources/application.yml ← H2 + app props
│
├── frontend/ ← React + Vite + MUI
│ ├── index.html
│ ├── package.json
│ ├── vite.config.js
│ └── src/
│ ├── main.jsx
│ ├── App.jsx
│ ├── context/
│ │ └── AppContext.jsx
│ ├── api/
│ │ ├── axiosClient.js ← 15s default / 120s for face calls
│ │ ├── auth.js
│ │ ├── employees.js
│ │ ├── attendance.js
│ │ ├── face.js
│ │ └── shifts.js
│ ├── components/
│ │ ├── WebcamCapture.jsx ← autoCapture countdown mode
│ │ ├── EmployeeCard.jsx ← Register Face button
│ │ └── NavigationBar.jsx
│ └── pages/
│ ├── Login.jsx
│ ├── EmployeeList.jsx
│ ├── EmployeeRegistration.jsx ← shift schedule selector added
│ ├── EmployeeProfile.jsx ← view/edit profile + upload photo + shift
│ ├── BundyClock.jsx ← auto face scan; freezes camera after success
│ ├── FaceRegistration.jsx ← auto-stops after first capture; sets profile photo
│ └── AttendanceLogs.jsx
│
├── face-recognition-service/ ← Python FastAPI + DeepFace
│ ├── run.py
│ ├── requirements.txt
│ ├── app/
│ │ ├── main.py
│ │ ├── core/
│ │ │ └── config.py
│ │ ├── routers/
│ │ │ └── face.py
│ │ ├── schemas/
│ │ │ └── face_schemas.py
│ │ └── services/
│ │ └── face_service.py ← multi-embedding accumulation
│ ├── data/
│ │ ├── faces/ ← raw face images
│ │ └── embeddings/ ← JSON embedding vectors (one file per employee)
│ └── tests/
│ ├── test_face_router.py ← router-level tests (all service calls mocked)
│ └── test_face_service.py ← service unit tests (disk I/O + DeepFace mocked)
│
├── start.bat ← one-command launcher (Windows)
├── start.sh ← one-command launcher (Git Bash / macOS / Linux)
├── stop.bat ← one-command terminator (Windows)
├── stop.sh ← one-command terminator (Git Bash / macOS / Linux)
└── bundyclock-postman-collection.json
└── CHANGELOG.md
| Tool | Version | Notes |
|---|---|---|
| Java JDK | 23 | Adoptium or Oracle |
| Gradle | 8.x | Bundled via wrapper (./gradlew) |
| PostgreSQL | 15+ | psql |
| Node.js | 20 LTS | nodejs.org |
| Python | 3.10+ | python.org |
| Git Bash | Any | Recommended terminal on Windows |
-- Run once in psql or pgAdmin
CREATE DATABASE bundyclock_db;
CREATE DATABASE bundyclock_dev;cd backend
# Git Bash (Windows) / macOS / Linux
./gradlew bootRun --args='--spring.profiles.active=dev'- API base URL: http://localhost:8080
- Swagger UI: http://localhost:8080/swagger-ui.html
- Flyway runs automatically and creates all tables on first start.
Edit src/main/resources/application.yml to adjust the DB credentials if needed.
cd frontend
npm install
npm run dev- App URL: http://localhost:5173
- Vite proxies
/api→http://localhost:8080
cd face-recognition-service
# Create a virtual environment (first time only)
python -m venv .venv
# Activate — Git Bash / macOS / Linux
source .venv/Scripts/activate # Git Bash on Windows
source .venv/bin/activate # macOS / Linux
# Install dependencies (first time only)
pip install -r requirements.txt
# Start service
python run.py- Service URL: http://localhost:5001
- API Docs: http://localhost:5001/docs
- Health check: http://localhost:5001/health
Note: First launch downloads the VGG-Face model weights (~580 MB) to
~/.deepface/weights/. Subsequent starts are fast.
Dependency note: TensorFlow 2.20+ requires the
tf-keraspackage. It is included inrequirements.txt.
Windows (Command Prompt or double-click):
start.batEach service opens in its own terminal window so you can watch logs independently. Close individual windows to stop a service.
Git Bash / macOS / Linux:
chmod +x start.sh # first time only
./start.shAll three services stream logs to the same terminal. Press Ctrl+C to stop all services at once.
After launch, the following URLs will be available:
| Service | URL |
|---|---|
| Frontend | http://localhost:5173 |
| Backend API | http://localhost:8080 |
| Face Service | http://localhost:5001 |
Kills any process running on ports 8080, 5001, and 5173.
Windows:
stop.batGit Bash / macOS / Linux:
./stop.sh
start.bat/start.shalso clears these ports automatically before launching, so re-running the start script is sufficient for a clean restart.
Use this approach if you need to control each service individually or pass custom flags.
# Terminal 1 — Backend
cd backend
./gradlew bootRun --args='--spring.profiles.active=dev'
# Terminal 2 — Frontend
cd frontend
npm install && npm run dev
# Terminal 3 — Face Recognition Service
cd face-recognition-service
source .venv/Scripts/activate # Git Bash on Windows
source .venv/bin/activate # macOS / Linux
python run.py# Kill processes on all service ports
for port in 8080 5001 5173; do
PID=$(netstat -ano | grep "LISTENING" | grep ":${port} " | awk '{print $NF}' | head -1)
[ -n "$PID" ] && taskkill.exe //F //PID $PID
doneController-layer tests use @WebMvcTest + Mockito mocks. No database or external services are required.
| Test class | Endpoints covered | Tests |
|---|---|---|
EmployeeControllerTest |
GET/POST/PUT/DELETE /api/employees, PATCH /{id}/photo |
12 |
AttendanceControllerTest |
POST time-in/out, GET /api/attendance |
8 |
FaceControllerTest |
POST /api/face/verify, /register, GET /employee/{id}/status |
8 |
AuthControllerTest |
POST /api/auth/login |
3 |
BundyClockApplicationTests |
Spring context load | 1 |
cd backend
# All tests
./gradlew test
# Specific controller
./gradlew test --tests "com.bundyclock.domain.employee.EmployeeControllerTest"
# Skip up-to-date cache and re-run
./gradlew cleanTest test
# View HTML report
start build/reports/tests/test/index.htmlTests use an in-memory H2 database — no PostgreSQL connection required:
spring:
datasource:
url: jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1;MODE=PostgreSQL
driver-class-name: org.h2.Driver
jpa:
hibernate:
ddl-auto: create-drop
flyway:
enabled: false@WebMvcTest— loads only the web layer (no JPA, no real beans).@Import(SecurityConfig.class)— CSRF disabled, all requests permitted.@WithMockUser— satisfies Spring Security principal requirement.@MockBean— replaces service with Mockito stub.MockMultipartFile— simulates multipart uploads (including photo PATCH).- Error paths (404, 409, 400) covered by stubbing exceptions →
GlobalExceptionHandler.
Component and API module tests run in JSDOM via Vitest. No real browser or server is needed.
| Test file | What is tested |
|---|---|
src/api/employees.test.js |
All 6 API functions — correct endpoint, method, payload; multipart boundary not overridden |
src/components/WebcamCapture.test.jsx |
Rendering, countdown behaviour, autoCapture toggling, error/success state captions |
src/pages/BundyClock.test.jsx |
Initial render, mode toggle, employee load, clock display |
cd frontend
# Install dependencies (first time)
npm install
# Run all tests once
npm test
# Watch mode (re-runs on file save)
npm run test:watch
# With coverage report
npm run test:coverage
# Coverage HTML: frontend/coverage/index.html- Create
*.test.jsx/*.test.jsnext to the file under test. - Mock external modules with
vi.mock('../path/to/module'). - Mock
react-webcamso tests run without camera hardware:vi.mock('react-webcam', () => ({ default: vi.fn(() => <video />) }))
- Use
vi.useFakeTimers()/vi.advanceTimersByTime()to test countdown logic.
Two test modules cover the FastAPI router and the service layer independently.
| Test file | What is tested |
|---|---|
tests/test_face_router.py |
All HTTP endpoints — validation (422), content-type guard (400), success / failure paths, accumulation, 500 on exception |
tests/test_face_service.py |
_cosine_similarity, register_face (success, no-face, accumulation), verify_face (match, no-match, threshold, best-of-multiple) |
cd face-recognition-service
source .venv/Scripts/activate # Windows Git Bash
source .venv/bin/activate # macOS / Linux
# All tests with verbose output
pytest tests/ -v
# Run a single file
pytest tests/test_face_router.py -v
# With coverage
pip install pytest-cov
pytest tests/ --cov=app --cov-report=term-missingpatch("app.routers.face.face_service.verify_face")— patches at the import location, not the definition location.monkeypatch.setattr("app.core.config.settings.EMBEDDINGS_DIR", ...)— redirects disk reads/writes totmp_pathpytest fixtures.DUMMY_IMAGE = b"\xff\xd8..."— JPEG magic bytes that pass the content-type guard without needing a real image.- No DeepFace model weights are downloaded during test runs.
Before employees can use the BundyClock, their face must be registered:
- Go to Employees → find the employee → click Register Face
- The webcam opens with a 3-second countdown — position the employee's face in the frame
- The photo is automatically captured and sent to the face service
- Register 2–5 photos from slightly different angles for best accuracy
- Each photo is accumulated (not overwritten) — all registered embeddings are used during verification
- Go to BundyClock and select Time In or Time Out
- Position your face in the frame — a 3-second countdown fires the auto-capture
- The system verifies the face against all registered embeddings
- On success: attendance is recorded; the camera freezes on the captured frame; the completed toggle button (Time In or Time Out) is disabled to prevent double-recording; a Scan Again button appears
- On face-not-recognised error: the error message shows for 3 seconds then the countdown restarts automatically so the next person can try
- On attendance API error (e.g. duplicate 409): auto-capture stops immediately — the error stays visible and Scan Again must be clicked manually
- Clicking Scan Again resets the camera, re-enables both toggles, and restarts the countdown
| Condition | Result |
|---|---|
| Employee already timed in today and tries to time in again | 409 — "Already timed in today. Please time out first." |
| Employee tries to time out with no time-in record today | 409 — "Cannot time out — no time-in record found for today." |
| Employee already timed out today and tries again | 409 — "Already timed out today." |
| Employee punches outside their shift window | 409 — "Outside shift hours. Your shift is HH:mm – HH:mm…" |
Shift window: attendance is allowed from 30 minutes before shift start until 2 hours after shift end. Overnight shifts (e.g. 22:00 – 06:00) are handled correctly. Employees with no shift assigned can punch at any time.
Import bundyclock-postman-collection.json in Postman.
Set collection variables:
| Variable | Value |
|---|---|
base_url |
http://localhost:8080 |
face_url |
http://localhost:5001 |
token |
(auto-populated on login) |
employee_id |
(paste a real UUID after creating an employee) |
Use these credentials and sample employee profiles to explore the system without setting up real data.
| Field | Value |
|---|---|
| Username | admin |
| Password | admin123 |
The admin account is authenticated via Spring Security (
InMemoryUserDetailsManager). On success,/api/auth/loginreturns a signed HS-256 JWT. All secured endpoints require anAuthorization: Bearer <token>header.
These employee records can be created via POST /api/employees (or imported through the Postman collection) to simulate a realistic workforce for testing attendance, face registration, and BundyClock flows.
| # | Name | Employee Code | Department | |
|---|---|---|---|---|
| 1 | Maria Santos | EMP-001 |
Engineering | maria.santos@bundyclock.local |
| 2 | James Rivera | EMP-002 |
Human Resources | james.rivera@bundyclock.local |
| 3 | Ana Reyes | EMP-003 |
Finance | ana.reyes@bundyclock.local |
| 4 | Carlo Mendoza | EMP-004 |
Operations | carlo.mendoza@bundyclock.local |
| 5 | Sofia Torres | EMP-005 |
Engineering | sofia.torres@bundyclock.local |
| 6 | Miguel Dela Cruz | EMP-006 |
Sales | miguel.delacruz@bundyclock.local |
| 7 | Lena Villanueva | EMP-007 |
IT Support | lena.villanueva@bundyclock.local |
| 8 | Ramon Castillo | EMP-008 |
Operations | ramon.castillo@bundyclock.local |
- Log in with the admin credentials above to get the stub token.
- In the Postman collection, open the Employees → Create Employee request.
- Copy-paste a sample profile from the table above into the request body:
{
"name": "Maria Santos",
"employeeCode": "EMP-001",
"department": "Engineering",
"email": "maria.santos@bundyclock.local"
}- Repeat for each employee you want to seed.
- After creating employees, use Face Registration (Register Face button on the Employee List page) to register their faces before testing the BundyClock flow.
| Scenario | Steps |
|---|---|
| Happy path — Time In | Select Time In, show a registered face, confirm attendance logged |
| Happy path — Time Out | After timing in, select Time Out, show same face |
| Duplicate Time In | Attempt Time In twice in the same day → expect 409 error |
| Unknown face | Show an unregistered face → verification fails gracefully |
| Admin login | POST {"username":"admin","password":"admin123"} to /api/auth/login |
| View logs | Open Attendance Logs page, filter by employee or date |
| Method | Endpoint | Description |
|---|---|---|
POST |
/api/auth/login |
Login (returns JWT stub) |
GET |
/api/employees |
List all employees |
POST |
/api/employees |
Create employee |
GET |
/api/employees/{id} |
Get employee by ID |
PUT |
/api/employees/{id} |
Update employee |
PATCH |
/api/employees/{id}/photo |
Upload / replace employee profile photo |
DELETE |
/api/employees/{id} |
Delete employee |
POST |
/api/attendance/time-in |
Record Time-In (with duplicate guard) |
POST |
/api/attendance/time-out |
Record Time-Out (with duplicate guard) |
GET |
/api/attendance |
Attendance logs (optional: employeeId, from, to ISO params) |
GET |
/api/attendance/employee/{id} |
Logs for one employee |
POST |
/api/face/verify |
Verify face (proxies to face-svc) |
POST |
/api/face/register |
Register face (proxies to face-svc) |
GET |
/api/face/employee/{id}/status |
Face registration status (embedding count, last registered) |
GET |
/api/shifts |
List all predefined shift schedules |
| Method | Endpoint | Description |
|---|---|---|
POST |
/verify-face |
Verify face image against all stored embeddings |
POST |
/register-face |
Register/accumulate face embedding for employee |
GET |
/health |
Health check |
┌──────────────────────┐ ┌───────────────────┐ ┌────────────────────┐
│ employees │ │ attendance_logs │ │ face_embeddings │
├──────────────────────┤ ├───────────────────┤ ├────────────────────┤
│ id (UUID PK) │◄─┐ │ id (UUID PK) │ │ id (UUID PK) │
│ name │ └────►│ employee_id (FK) │ ┌───►│ employee_id (FK) │
│ employee_code │ │ timestamp │ │ │ raw_image_path │
│ department │ │ type (IN/OUT) │ │ │ model_used │
│ email │ │ image_path │ │ │ created_at │
│ photo_url │ ┌────►│ confidence_score │ └────┤ │
│ status │ │ │ verified │ └────────────────────┘
│ shift_schedule_id FK─┼──┼─┐ │ notes │
│ custom_shift_start │ │ │ └───────────────────┘
│ custom_shift_end │ │ │
│ created_at │ │ │ ┌────────────────────┐
│ updated_at │ │ │ │ shift_schedules │
│ └──►├────────────────────┤
│ │ id (UUID PK) │
(same FK) │ name │
│ start_time (TIME) │
│ end_time (TIME) │
│ created_at │
└────────────────────┘
Embeddings on disk (face-recognition-service/data/embeddings/):
{employee_id}.json → { "employee_id": "...", "embeddings": [[...], [...]] }
Multiple photos per employee are accumulated — not overwritten.
Predefined shifts seeded by V4 migration:
Morning Shift 06:00 – 14:00
Day Shift 08:00 – 17:00
Afternoon Shift 14:00 – 22:00
Night Shift 22:00 – 06:00 (overnight)
-
Replace placeholder JWT with real Spring Security JWT filter chain— Done v0.7.0 (HS-256 jjwt,JwtService+JwtAuthenticationFilter) - Add role-based access control (
ADMIN,EMPLOYEE,KIOSKroles) - Enable HTTPS (TLS) with Let's Encrypt or a reverse proxy (nginx/caddy)
- Secrets management via AWS Secrets Manager, Azure Key Vault, or Vault by HashiCorp
- Rate-limit
/api/auth/loginwithbucket4jor nginx
- Consider
pgvectorextension for storing face embedding vectors natively with similarity search - Add DB connection pooling (HikariCP — already included by Spring Boot)
- Set up read replicas for attendance log queries
- Switch to
ArcFacemodel (more accurate than VGG-Face) in production - Use
retinafacedetector for better face detection in varied lighting - Implement liveness detection (anti-spoofing) to prevent photo attacks
- Store embeddings in PostgreSQL
pgvectorinstead of flat JSON files - Add confidence threshold tuning per environment
- Add React Query or SWR for server state management and caching
- Implement proper error boundaries
- Add PWA manifest for kiosk deployment
- Consider migrating to TypeScript
- Dockerize all three services (Dockerfile + docker-compose.yml)
- Set up CI/CD pipeline (GitHub Actions)
- Add structured logging (Logback JSON appender → ELK / Loki)
- Implement distributed tracing (OpenTelemetry)
JWT authentication returns a stub token — not validated by the backend.Fixed v0.7.0.- Image storage is local filesystem — will not work in stateless/containerised environments without a volume or object store.
No pagination on list endpoints.Fixed v0.7.0 —GET /api/employeesreturnsPage<Employee>withpage+sizeparams.- Face embeddings comparison is O(n × k) linear scan — use
pgvectorfor scale. - DeepFace model weights (~580 MB) are downloaded on first run; ensure internet access on first start.