Validation rules and business logic governing reservations in BookMe
BookMe enforces strict business rules to manage meeting room reservations fairly and efficiently. These rules are implemented across validation and service layers.
Error Message: “Time must be between 6:00 AM and 8:00 PM”
Both start_time and end_time must fall within school hours. A reservation starting at 7:00 PM and ending at 9:00 PM will be rejected because the end time is after 8:00 PM.
All times must be provided in UTC format to avoid timezone confusion:
// From internal/validator/validator.go:65func validateUTC(fl validator.FieldLevel) bool { t, ok := fl.Field().Interface().(time.Time) if !ok { return false } _, offset := t.Zone() return offset == 0}
Error Message: “Time must be in UTC format (e.g. 2026-02-23T06:00:00Z)”
Clients must send times in UTC (e.g., 2026-03-03T14:00:00Z). The server converts to Europe/Helsinki for business rule validation but stores times in UTC in the database.
This prevents a single student from monopolizing a meeting room for an entire day. Staff members, who may need rooms for longer events, workshops, or administrative purposes, are exempt from this limit.If students need a room longer than 4 hours, they can:
Make multiple back-to-back reservations (if no conflicts)
The overlap check uses database-level logic with SQL:
-- Checks for any reservation where:-- (new_start < existing_end) AND (new_end > existing_start)SELECT EXISTS( SELECT 1 FROM reservations WHERE room_id = $1 AND start_time < $2 -- input.EndTime AND end_time > $3 -- input.StartTime AND status = 'RESERVED') AS exists
Conflict Scenarios:
Existing
New Reservation
Result
10:00-12:00
11:00-13:00
❌ Conflict
10:00-12:00
12:00-14:00
✅ Allowed
10:00-12:00
09:00-11:00
❌ Conflict
10:00-12:00
14:00-16:00
✅ Allowed
Back-to-back reservations are allowed. A reservation ending at 12:00 does not conflict with one starting at 12:00.
Conflict detection happens inside a database transaction:
// From internal/service/reservation.go:103tx, err := s.db.BeginTx(ctx, &sql.TxOptions{ Isolation: sql.LevelReadCommitted,})
Why transactions are critical:Imagine two users trying to book the same room at the same time:
User A checks for conflicts → None found
User B checks for conflicts → None found
User A creates reservation → Success
User B creates reservation → Success (but now there’s a conflict!)
Transactions prevent this race condition. With LevelReadCommitted isolation:
User A starts transaction, checks conflicts → None
User B starts transaction, checks conflicts → None
User A inserts reservation and commits
User B tries to insert → conflict detected by database constraint or second check
The overlap check must happen inside the same transaction as the insert. If you check outside the transaction, you’ll have race conditions during high traffic.
// From internal/service/reservation.go:186go func() { emailCtx, cancel := context.WithTimeout(context.Background(), 40*time.Second) defer cancel() if err := s.email.SendConfirmation( emailCtx, dbUser.Email, room.Name, reservation.StartTime.Format("Monday, January 2, 2006 at 3:04 PM"), reservation.EndTime.Format("Monday, January 2, 2006 at 3:04 PM"), ); err != nil { slog.Error("failed to send confirmation email", "error", err) }}()
Key Points:
Runs in a goroutine (non-blocking)
Has its own 40-second timeout
Failures are logged but don’t affect the reservation
Uses HTML email templates from internal/email/templates/
Asynchronous operations improve user experience—the API responds immediately without waiting for email delivery or calendar creation. This is critical for responsiveness.However, it means these operations can fail silently. Monitor logs for failures.
There’s no email sent when a reservation is cancelled. This is a potential enhancement—users currently don’t receive confirmation that their reservation was deleted.
Validation: Converted to Europe/Helsinki for business rule checks
Database Storage: Stored in UTC
Calendar Events: Sent to Google Calendar in Europe/Helsinki
API Output: Returned in UTC
// Example from internal/service/reservation.go:254slots = append(slots, dto.ReservedSlotDto{ StartTime: res.StartTime.UTC(), EndTime: res.EndTime.UTC(),})
Always work with UTC in your client applications. Convert to local time only for display purposes. This avoids daylight saving time issues and makes the system work globally.
Validation errors are formatted to be user-friendly:
// From internal/validator/validator.go:121func formatFieldError(err validator.FieldError) string { switch err.Tag() { case "required": return "This field is required" case "futureTime": return "Time must be in the future" case "schoolHours": return "Time must be between 6:00 AM and 8:00 PM" case "maxDateRange": return "Date range cannot exceed 60 days" case "utc": return "Time must be in UTC format (e.g. 2026-02-23T06:00:00Z)" default: return fmt.Sprintf("Validation failed on '%s'", err.Tag()) }}
These messages are returned in the API response:
{ "error": "validation failed", "fields": { "start_time": "Time must be between 6:00 AM and 8:00 PM", "end_time": "Time must be in the future" }}
Business rules are enforced in multiple layers for defense in depth. Validators catch malformed input early, while service methods enforce domain logic. Database constraints provide a final safety net.