Spring Boot backend for GuildWorkman, a marketplace that connects clients with skilled workers (electricians, plumbers, barbers, beauticians, carpenters, fashion designers, photographers, and more) for booked appointments and consultations, with in-app payments and reviews.
Monorepo layout. This repository now also houses the Soroban smart contracts (previously
guildworkman-contracts):
src/,pom.xml— the Spring Boot API (this document).soroban/— the Rust/Soroban on-chain escrow & reputation contracts (seesoroban/README.md).CI is path-filtered: backend changes run the Maven workflows,
soroban/**changes runsoroban-ci. SeeMIGRATION.mdfor how the merge was done.
- Project ecosystem
- Tech stack
- Architecture
- Domain model
- Features
- Not yet wired up
- Prerequisites
- Configuration
- Running locally
- Running with Docker
- API reference
- Testing
- Deployment
- Branching
- Contributing
GuildWorkman lives in two repositories:
| Repo | Role |
|---|---|
guildworkman-core (this repo) |
The Spring Boot backend (backend-api/, documented here) and the Soroban smart contracts (soroban-contracts/) — on-chain escrow, reputation, and loyalty rewards, designed to be called from this backend but not yet integrated (see Not yet wired up). |
guildworkman-web |
Next.js frontend that talks to this API over REST. |
- Java 17, Spring Boot 3.3.2
- Spring Web, Spring Data JPA, Spring Security, Lombok
- PostgreSQL (
org.postgresql:postgresql) - Auth0 java-jwt — JWT issuance/verification
- ModelMapper — DTO ↔ entity mapping
- Cloudinary (
cloudinary-http44) — media/image uploads - OkHttp + Gson — outbound HTTP calls (e.g. Paystack)
- json-patch — partial-update support
- Build: Maven (wrapper included), packaged as a runnable jar, containerized via Docker
Single Spring Boot module, package root com.guildworkman.api, in a
conventional layered structure:
controllers/ REST endpoints (@RestController)
services/
ServiceUtils/ service interfaces
implmentations/ interface implementations
paystack/ Paystack payment integration
data/models/ JPA entities
dto/requests/ inbound request payloads
dto/responses/ outbound response payloads
handler/ GlobalExceptionHandler
exceptions/ custom exceptions
config/ Spring config (security, mail, cloud, mapper, app)
utils/ JWT + validation helpers
Requests flow Controller → Service interface (ServiceUtils) → ServiceImpl (implmentations) → JPA repository, with DTOs converted to/from entities via
ModelMapper (config/MapperConfig.java) and every response wrapped in a
common ApiResponse { data, success } envelope. GlobalExceptionHandler
translates the custom exceptions in exceptions/ (e.g.
UserNotFoundException, AppointmentNotFoundException,
InvalidPasswordException) into consistent HTTP error responses.
Core JPA entities in data/models/:
| Entity | Relationships | Purpose |
|---|---|---|
Client |
1:N Appointment, 1:1 Address |
A user who books workers |
SkilledWorker |
1:N Skill, 1:1 Address |
A tradesperson who gets booked |
Skill |
N:1 SkilledWorker |
A trade/category a worker offers |
Address |
— | Shared geo/address data for clients and workers (backs the /nearby lookup) |
Appointment |
N:1 Client, N:1 SkilledWorker |
A booked job |
SlotReservation |
worker/client by id (booking/model/) |
A claim on one slot of a worker's calendar — held, then confirmed into an Appointment. The row double-booking prevention is built on |
Consultation |
N:1 Client, N:1 SkilledWorker, 1:1 ConsultationAvailability |
A pre-booking consultation between client and worker |
ConsultationAvailability |
1:1 Consultation |
Scheduled availability windows for a consultation |
Review |
N:1 (worker/appointment) | A client's rating/feedback on a completed job |
Transaction / TransactionHistory |
M:N | Payment records (Paystack) |
Notification |
— | In-app/email notification records |
Admin |
— | Admin-role user |
- Client and skilled-worker registration and login (JWT-based auth)
- Appointment booking, update, cancellation, and deletion
- Concurrency-safe slot reservation — database-level locking prevents two clients
double-booking a worker, plus a per-worker availability read
(
docs/APPOINTMENT_BOOKING.md) - Consultations: booking a consultation and scheduling client/worker availability
- Skilled-worker profile management, skill listing, and geo lookup (
/nearby) - Payment initiation/verification via Paystack (service layer +
PaymentServiceImpl) - Transactional email sending (mail service, provider-agnostic API key + URL config)
- Reviews and worker ratings (
ReviewServiceImpl) - Admin operations (
AdminServiceImpl) - Global exception handling with typed exceptions
A few service-layer pieces exist but currently have no REST endpoint exposing them — the business logic is implemented and (where applicable) tested, but not yet reachable over HTTP:
PaymentController(/payment) andMapController(/showMap) exist in the tree but are commented out and inactive. Payment logic lives inservices/paystack/PaymentServiceImpland can be re-exposed by uncommentingPaymentControlleronce its request/response wiring is finalized.AdminServiceImplhas no correspondingAdminControlleryet.- Reviews (
ReviewServiceImpl,PostReviewRequest/EditReviewRequest) aren't exposed onClientControllerorSkilledWorkerControllertoday, even though the service and DTOs are fully implemented and tested.
This also means none of guildworkman-contracts's Soroban contracts are
called from here yet — wiring escrow.create_appointment,
reputation.submit_review, and loyalty-token.mint into the appointment
and review flows requires a Soroban RPC client and per-role Stellar keypair
handling that doesn't exist in this codebase today. See that repo's README
for the intended integration flow.
- JDK 17
- PostgreSQL instance (local or remote)
- Maven (or just use the included
./mvnwwrapper — no local Maven install needed)
All configuration lives in src/main/resources/application.properties, entirely
as ${ENV_VAR:default} placeholders — there is no separate secrets file anymore.
Non-secret keys have real defaults; secret keys default to empty and must be
supplied via environment variables in every environment (local, CI, production).
See .env.example at the repo root for the full list with a short description
of each. Copy it to .env for local reference (never commit a populated one) and
export the values into your shell, IDE run configuration, or docker run --env-file.
| Property | Env var | Default |
|---|---|---|
spring.datasource.url |
DATABASE_URL |
jdbc:postgresql://localhost:5432/service |
spring.datasource.username |
DATABASE_USERNAME |
postgres |
spring.datasource.password |
DATABASE_PASSWORD |
password |
spring.jpa.hibernate.ddl-auto |
DDL_AUTO |
update |
mail.api.key |
MAIL_API_KEY |
(empty — required) |
mail.api.url |
MAIL_API_URL |
https://api.brevo.com/v3/smtp/email |
cloud.api.name |
CLOUDINARY_API_NAME |
(empty — required) |
cloud.api.key |
CLOUDINARY_API_KEY |
(empty — required) |
cloud.api.secret |
CLOUDINARY_API_SECRET |
(empty — required) |
paystack.secret.key |
PAYSTACK_SECRET_KEY |
(empty — required) |
paystack.verify.payment.url |
PAYSTACK_VERIFY_URL |
https://api.paystack.co/transaction/verify |
paystack.initiate.payment |
PAYSTACK_INITIATE_URL |
https://api.paystack.co/transaction/initialize |
spring.h2.console.enabled |
H2_CONSOLE_ENABLED |
false |
Security note: this repository's git history (both the old
secret.propertiescommitted file and, briefly, this repo's own earlier state) contains real credentials: a Postgres password for a Railway-hosted database, a Brevo/Sendinblue mail API key, a Cloudinary API key + secret, and a Paystack test secret key. Rotate/revoke all of these in their respective dashboards — removing them from the tracked file does not undo the exposure already baked into git history. Never commit real credentials anywhere in this repo again; supply them only as environment variables at deploy time.
A docker-compose.yml is provided for the database, so you don't need a local
PostgreSQL install:
docker compose up -d # starts Postgres on localhost:5432, db=service user=postgres password=password
export MAIL_API_KEY=...
export CLOUDINARY_API_NAME=...
export CLOUDINARY_API_KEY=...
export CLOUDINARY_API_SECRET=...
export PAYSTACK_SECRET_KEY=...
./mvnw spring-boot:runDATABASE_URL/DATABASE_USERNAME/DATABASE_PASSWORD already default to match
the docker-compose.yml credentials, so you don't need to export them unless
you're pointing at a different database.
The app starts on the default Spring Boot port (8080) unless overridden.
docker build -t guildworkman-api .
docker run -p 8080:8080 \
-e DATABASE_URL=jdbc:postgresql://<host>:5432/service \
-e DATABASE_USERNAME=postgres \
-e DATABASE_PASSWORD=yourpassword \
-e MAIL_API_KEY=... \
-e CLOUDINARY_API_NAME=... -e CLOUDINARY_API_KEY=... -e CLOUDINARY_API_SECRET=... \
-e PAYSTACK_SECRET_KEY=... \
guildworkman-apiThe Dockerfile is a two-stage build: Maven build stage (maven:3.8.7) →
runtime stage (openjdk:17) copying the packaged jar.
All endpoints are prefixed /api/v1/.... Responses are wrapped in a common
ApiResponse { data, success } envelope unless noted.
An interactive OpenAPI/Swagger UI is served at /swagger-ui.html
(spec at /v3/api-docs).
JWT authentication, RBAC, and rotating refresh tokens. Full design notes:
docs/AUTHENTICATION.md.
| Method | Path | Auth | Purpose |
|---|---|---|---|
| POST | /register |
public | Create an account, returns an access + refresh token pair |
| POST | /login |
public | Authenticate, returns a token pair |
| POST | /refresh |
refresh token | Rotate to a new token pair (revokes the old one; reuse revokes the session) |
| POST | /logout |
refresh token | Revoke the refresh token's whole family |
| GET | /me |
access token | The authenticated caller's identity |
/api/v1/admin/** requires the ADMIN role (see AdminController).
| Method | Path | Purpose |
|---|---|---|
| POST | /registerClient |
Register a new client |
| POST | /login |
Client login |
| POST | /bookAppointment |
Book an appointment in one step. Body: clientId, category, scheduleTime, plus optional skilledWorkerId (which tradesperson) and amount (agreed price). When a worker is named, the slot is claimed through the same concurrency guard as /api/v1/booking — a second caller racing for it gets 409 slot-unavailable |
| PUT | /updateAppointment?appointmentId= |
Set an appointment's status — body { "status": "ACCEPTED" } (also accepts amount, startTime) |
| PUT | /cancelAppointment?appointmentId= |
Cancel an appointment — no body. Leaves the record in place with status CANCELLED |
| DELETE | /deleteAppointment?appointmentId= |
Delete an appointment outright — no body |
| GET | /viewAllAppointment?clientId= |
List of a client's appointments (id, status, category, scheduleTime, amount, worker) |
| PUT | /updateClientProfile |
Update client profile |
| POST | /consult?clientId=&workerId=&details= |
Book a consultation |
| POST | /{consultationId}/availability?clientAvailability=&workerAvailability= |
Schedule consultation availability |
CORS: configured globally (see below) — no per-controller @CrossOrigin.
Concurrency-safe booking: two visitors can never both take the same slot on a
worker's calendar. Full design notes:
docs/APPOINTMENT_BOOKING.md.
| Method | Path | Purpose |
|---|---|---|
| POST | /reservations |
Hold a slot (201). Body: idempotencyKey, skilledWorkerId, clientId, slotStart, optional durationMinutes. Holds lapse after 5 minutes. Idempotent on idempotencyKey — see the X-Idempotent-Replay response header |
| GET | /reservations/{id} |
A reservation's current state |
| POST | /reservations/{id}/confirm |
Turn a hold into an appointment. Optional body: category, amount |
| DELETE | /reservations/{id} |
Release a hold early |
| GET | /workers/{workerId}/availability?from=&to= |
A worker's taken slots in a window — booked appointments and live holds. The read viewAllAppointment can't do, since that returns only the calling client's own bookings |
Losing a race for a slot is 409 slot-unavailable, not a 400: the request was
well-formed, it just arrived second.
These endpoints are unwrapped (no ApiResponse envelope) — they return the
reservation or availability object directly, as the escrow endpoints do.
| Method | Path | Purpose |
|---|---|---|
| POST | /registerSkilledWorker |
Register a new skilled worker |
| POST | /login |
Skilled-worker login |
| POST | /addSkill |
Add a skill to a worker's profile |
| GET | /findById?skilledWorkerId= |
Fetch a worker by ID |
| GET | /findByFullName?skilledWorkerFullName= |
Fetch a worker by name |
| PUT | /updateSkilledWorkerProfile |
Update worker profile |
| GET | /nearby?lat=&lon=&radius= |
Find workers near a location (radius defaults to 10) |
CORS: configured globally (see below) — no per-controller @CrossOrigin.
| Method | Path | Purpose |
|---|---|---|
| POST | /sendMail |
Send a transactional email |
CORS: configured globally (see below) — no per-controller @CrossOrigin.
CORS is configured in one place — config/WebConfig — from the
cors.allowed-origins property (env CORS_ALLOWED_ORIGINS), a comma-separated
list. Default:
http://localhost:3000,https://guildworkman-web.vercel.app
It uses allowedOriginPatterns, so wildcard patterns (e.g. https://*.vercel.app
for preview deployments) stay compatible with allowCredentials(true) — Spring
forbids that combination with a literal "*". Don't add @CrossOrigin to
controllers: handler-level annotations get combined with this config and
previously drifted out of sync.
Tests are @SpringBootTest-based and need a real database — bring up the
Postgres container first:
docker compose up -d
./mvnw testCI runs the same suite against a Postgres service container on every push/PR
to development (.github/workflows/test.yml).
Background @Scheduled pollers default to off in tests. pom.xml's
maven-surefire-plugin sets chain.events.poll-delay-ms,
escrow.orchestration.*-poll-delay-ms, and
escrow.reconciliation.poll-delay-ms to 1 hour via systemPropertyVariables
for every test JVM. Without this, a @SpringBootTest class that doesn't
explicitly disable scheduling leaves its poller running against the shared
test database for the rest of the test JVM's life (Spring caches
ApplicationContexts), racing with whatever test class runs next and
processing its rows out from under it — this was an actual source of
intermittent CI failures before the fix. Tests that specifically want a
poller running (e.g. ChainEventServiceIntegrationTest,
EscrowOrchestrationIntegrationTest) override the relevant property via
their own @SpringBootTest(properties = …), which takes precedence over
the surefire-level system properties. Don't remove that
systemPropertyVariables block without replacing it with an equivalent
per-test opt-out.
Existing tests cover MailServiceTest, ClientServiceTest,
SkilledWorkerServiceTest, AppointmentServiceTest, ReviewServiceTest, and
SkillServiceTest. All 14 tests pass as of this writing.
.github/workflows/build.yml builds and pushes a Docker image to Docker Hub
(meshackyaro/guildworkman-api:latest) on every push to development, then
deploys it via docker pull + docker run on the target host. That file
also contains several earlier, now-commented-out iterations of the same
pipeline (SSH-based deploy, alternate Maven caching) — only the final,
uncommented job at the bottom of the file is live.
Default branch is development. Feature/area work happens on
short-lived branches and merges back into development via PR — check
git branch -r for whatever's currently active rather than relying on a
hardcoded list here, since branches get created and deleted regularly.
Branch off development, keep real credentials out of your commits (only
.env.example placeholders, never a populated .env), and open a PR back
into development.