@forjakit/scheduling
v0.1.1
Published
Framework-agnostic scheduling engine for any domain that needs time-based event management. Handles sports matches, service appointments, room bookings, and anything else that involves scheduling participants at specific times.
Readme
@forjakit/scheduling
Framework-agnostic scheduling engine for any domain that needs time-based event management. Handles sports matches, service appointments, room bookings, and anything else that involves scheduling participants at specific times.
The core package contains zero framework or database dependencies. It defines the business logic, validation schemas, status lifecycle, and conflict detection. Storage and HTTP are handled by companion adapter packages.
Installation
pnpm add @forjakit/schedulingPeer dependency: zod >= 3.24.
Core Concepts
ScheduledEvent
The atomic unit of the module. Every scheduled event has:
| Field | Type | Description |
| ---------------- | ------------------ | ------------------------------------------------------- |
| id | string | Unique identifier (assigned by storage layer) |
| tenantId | string | Tenant isolation key |
| contextId | string | Groups related events (e.g., championship ID, business ID) |
| participants | Participant[] | 1..N participants involved in the event |
| scheduledAt | Date | Start time |
| durationMinutes| number | Duration used for end-time calculation and overlap detection |
| venue | Venue \| null | Optional location |
| status | EventStatus | Lifecycle state |
| metadata | TMeta | Opaque consumer-defined data |
| createdAt | Date | Creation timestamp |
| updatedAt | Date | Last update timestamp |
Participant
A generic participant in an event. The module never interprets the semantic -- the consumer defines what a participant means.
interface Participant {
id: string; // External ID from your domain (team ID, user ID, etc.)
label: string; // Display label for listings and conflict messages
type: string; // Consumer-defined tag: "team", "player", "professional", "client"
}Venue
An optional location where the event takes place.
interface Venue {
id: string;
label: string;
type?: string; // "court", "room", "chair", etc.
}Metadata Extension Pattern
Every consumer defines their own metadata shape via a Zod schema. The core module stores and retrieves it without inspecting it. The TMeta generic flows through the entire system -- service, storage, and schemas are all parameterized by it.
// Sports domain
const matchMeta = z.object({
round: z.number().int().positive(),
group: z.string().optional(),
});
// Appointment domain
const appointmentMeta = z.object({
serviceType: z.string(),
price: z.number().positive(),
notes: z.string().optional(),
});Creating a Service
import { createSchedulingService } from "@forjakit/scheduling";
import { z } from "zod";
const metadataSchema = z.object({
round: z.number().int().positive(),
group: z.string().optional(),
});
const service = createSchedulingService({
storage: myStorageAdapter, // implements SchedulingStorage<TMeta>
metadataSchema,
conflictDetection: { enabled: true }, // default: true
availability: { // optional, for appointment use cases
storage: myAvailabilityStorage,
defaultSlotDuration: 30,
},
});SchedulingServiceConfig
| Field | Type | Required | Default | Description |
| ------------------- | ------------------------------ | -------- | ------- | ------------------------------------------ |
| storage | SchedulingStorage<TMeta> | Yes | -- | Storage adapter for events |
| metadataSchema | ZodTypeAny | Yes | -- | Zod schema for consumer metadata |
| conflictDetection | { enabled: boolean } | No | { enabled: true } | Toggle participant conflict detection |
| availability | { storage, defaultSlotDuration? } | No | -- | Enables availability window management |
Schema Factory
createSchedulingSchemas() produces Zod schemas composed with your metadata schema. The service creates these internally, but you can also use them standalone for validation.
import { createSchedulingSchemas } from "@forjakit/scheduling";
import { z } from "zod";
// Sports match schemas
const schemas = createSchedulingSchemas(
z.object({
round: z.number().int().positive(),
group: z.string().optional(),
})
);
// Appointment schemas
const schemas = createSchedulingSchemas(
z.object({
serviceType: z.string(),
price: z.number().positive(),
})
);Produced Schemas
| Schema | Description |
| ---------------------- | ----------------------------------------------- |
| CreateEventInput | Full creation payload with metadata |
| UpdateEventInput | Partial update (all fields optional) |
| TransitionStatusInput| { status: EventStatus } |
| EventFiltersInput | Query filters for listing events |
| AvailabilityInput | Availability window input |
| ParticipantSchema | Participant validation |
| VenueSchema | Venue validation |
| EventStatusSchema | Enum: SCHEDULED, IN_PROGRESS, FINISHED, CANCELLED|
Service API
All methods validate input through Zod schemas before executing business logic.
scheduleEvent(input)
Creates a new scheduled event. Runs conflict detection if enabled.
- Params:
CreateEventInput-- tenantId, contextId, participants (min 1), scheduledAt, durationMinutes (default 60), venue (default null), metadata. - Returns:
Promise<ScheduledEvent<TMeta>> - Throws:
SchedulingErrorwith codePARTICIPANT_CONFLICTif any participant has an overlapping event.
const event = await service.scheduleEvent({
tenantId: "tenant-1",
contextId: "championship-abc",
participants: [
{ id: "team-1", label: "Eagles", type: "team" },
{ id: "team-2", label: "Hawks", type: "team" },
],
scheduledAt: new Date("2026-04-01T14:00:00Z"),
durationMinutes: 90,
venue: { id: "court-1", label: "Main Court", type: "court" },
metadata: { round: 1, group: "A" },
});getEvent(id, tenantId)
Retrieves a single event by ID within a tenant.
- Params:
id: string,tenantId: string - Returns:
Promise<ScheduledEvent<TMeta>> - Throws:
SchedulingErrorwith codeEVENT_NOT_FOUND(404).
updateEvent(id, tenantId, input)
Updates mutable fields of a SCHEDULED or IN_PROGRESS event. Re-runs conflict detection if scheduledAt or durationMinutes changed.
- Params:
id: string,tenantId: string,UpdateEventInput-- scheduledAt?, durationMinutes?, venue?, metadata?. - Returns:
Promise<ScheduledEvent<TMeta>> - Throws:
EVENT_NOT_FOUND(404) if the event does not exist.EVENT_NOT_MODIFIABLE(409) if the event is FINISHED or CANCELLED.PARTICIPANT_CONFLICT(409) if the new time overlaps another event (excludes the event being updated).
transitionStatus(id, tenantId, nextStatus)
Transitions an event's status following the allowed lifecycle.
- Params:
id: string,tenantId: string,nextStatus: EventStatus - Returns:
Promise<ScheduledEvent<TMeta>> - Throws:
EVENT_NOT_FOUND(404).INVALID_TRANSITION(409) if the transition is not allowed.
cancelEvent(id, tenantId)
Convenience method that calls transitionStatus(id, tenantId, "CANCELLED").
- Returns:
Promise<ScheduledEvent<TMeta>> - Throws: Same as
transitionStatus.
deleteEvent(id, tenantId)
Permanently deletes an event. Only SCHEDULED events can be deleted.
- Params:
id: string,tenantId: string - Returns:
Promise<void> - Throws:
EVENT_NOT_FOUND(404).EVENT_NOT_MODIFIABLE(409) if the event is not in SCHEDULED status.
listEvents(filters)
Lists events matching the given filters. All filters are AND-combined.
- Params:
EventFiltersInput-- tenantId (required), contextId?, participantId?, status? (single or array), from?, to?. - Returns:
Promise<ScheduledEvent<TMeta>[]>
const events = await service.listEvents({
tenantId: "tenant-1",
contextId: "championship-abc",
status: ["SCHEDULED", "IN_PROGRESS"],
from: new Date("2026-04-01"),
to: new Date("2026-04-30"),
});setAvailability(input)
Sets a recurring availability window for a participant. Upserts by (participantId, tenantId, dayOfWeek).
- Params:
AvailabilityInput-- participantId, participantType, tenantId, dayOfWeek (0-6, Sunday=0), startTime ("HH:MM"), endTime ("HH:MM"). - Returns:
Promise<void> - Throws:
FEATURE_NOT_CONFIGURED(500) if availability was not configured.
await service.setAvailability({
participantId: "barber-1",
participantType: "professional",
tenantId: "tenant-1",
dayOfWeek: 1, // Monday
startTime: "09:00",
endTime: "18:00",
});getAvailableSlots(participantId, tenantId, date, durationMinutes?)
Computes available time slots for a participant on a given date. Generates candidate slots from availability windows, then subtracts existing events.
- Params:
participantId: string,tenantId: string,date: Date,durationMinutes?: number(defaults to configured default or 60). - Returns:
Promise<TimeSlot[]>-- each slot hasstartsAt,endsAt,participantId,venueId. - Throws:
FEATURE_NOT_CONFIGURED(500) if availability was not configured.
const slots = await service.getAvailableSlots(
"barber-1",
"tenant-1",
new Date("2026-04-07"), // a Monday
30 // 30-minute slots
);
// [{ startsAt: ..., endsAt: ..., participantId: "barber-1", venueId: null }, ...]schemas
The service also exposes the composed Zod schemas via service.schemas for external use (e.g., route validation).
Status Lifecycle
SCHEDULED ──→ IN_PROGRESS ──→ FINISHED
│ │
└──→ CANCELLED ←┘Transition Rules
| From | Allowed Targets |
| ------------- | ---------------------------- |
| SCHEDULED | IN_PROGRESS, CANCELLED |
| IN_PROGRESS | FINISHED, CANCELLED |
| FINISHED | (terminal -- no transitions) |
| CANCELLED | (terminal -- no transitions) |
All events start as SCHEDULED. Terminal states (FINISHED, CANCELLED) cannot be transitioned. Attempting an invalid transition throws INVALID_TRANSITION (409).
Utility Functions
import { assertValidTransition, getAllowedTransitions } from "@forjakit/scheduling";
getAllowedTransitions("SCHEDULED"); // ["IN_PROGRESS", "CANCELLED"]
getAllowedTransitions("FINISHED"); // []
assertValidTransition("SCHEDULED", "IN_PROGRESS"); // OK
assertValidTransition("FINISHED", "SCHEDULED"); // throws SchedulingErrorConflict Detection
When enabled (default), the service prevents scheduling overlapping events for the same participant(s).
How it works:
- On
scheduleEventorupdateEvent(when time changes), the service computes the event's end time:scheduledAt + durationMinutes. - It calls
storage.findOverlappingEvents()with the tenant, participant IDs, start time, and end time. - If any overlapping events are returned, it identifies which participants conflict and throws
PARTICIPANT_CONFLICT. - On updates, the event being updated is excluded from the overlap check via
excludeEventId.
Only active events (not FINISHED or CANCELLED) are considered for conflicts.
Disabling conflict detection:
const service = createSchedulingService({
storage,
metadataSchema,
conflictDetection: { enabled: false },
});Utility Functions
import { computeEndTime, assertNoConflicts } from "@forjakit/scheduling";
const end = computeEndTime(new Date("2026-04-01T14:00:00Z"), 90);
// 2026-04-01T15:30:00.000ZAvailability Management
An opt-in feature for appointment-based scheduling (e.g., barbershops, clinics). Requires configuring availability in the service config.
Concept: Participants define recurring weekly availability windows (e.g., "Monday 09:00-18:00"). The service then computes open slots by subtracting existing events from those windows.
const service = createSchedulingService({
storage,
metadataSchema,
availability: {
storage: availabilityStorage, // implements AvailabilityStorage
defaultSlotDuration: 30, // minutes
},
});
// Define when a professional is available
await service.setAvailability({
participantId: "barber-1",
participantType: "professional",
tenantId: "tenant-1",
dayOfWeek: 1, // Monday
startTime: "09:00",
endTime: "18:00",
});
// Get open 30-minute slots for a specific date
const slots = await service.getAvailableSlots("barber-1", "tenant-1", new Date("2026-04-07"));AvailabilityWindow
| Field | Type | Description |
| ----------------- | -------- | -------------------------------------------- |
| participantId | string | The participant this window belongs to |
| participantType | string | Consumer-defined type tag |
| tenantId | string | Tenant isolation key |
| dayOfWeek | number | ISO day of week: 0 = Sunday, 6 = Saturday |
| startTime | string | "HH:MM" format |
| endTime | string | "HH:MM" format |
TimeSlot
| Field | Type | Description |
| --------------- | --------------- | ------------------------ |
| startsAt | Date | Slot start time |
| endsAt | Date | Slot end time |
| participantId | string | The participant |
| venueId | string \| null| Always null currently |
Error Handling
All business errors are thrown as SchedulingError instances with a machine-readable code and an HTTP-friendly statusCode.
import { SchedulingError } from "@forjakit/scheduling";
try {
await service.scheduleEvent(input);
} catch (err) {
if (err instanceof SchedulingError) {
console.log(err.code); // "PARTICIPANT_CONFLICT"
console.log(err.statusCode); // 409
console.log(err.message); // "Participant(s) team-1 already have an event at ..."
}
}Error Codes
| Code | HTTP Status | Thrown When |
| ------------------------ | ----------- | ------------------------------------------------ |
| EVENT_NOT_FOUND | 404 | Event ID does not exist in the tenant |
| EVENT_NOT_MODIFIABLE | 409 | Attempting to modify a FINISHED or CANCELLED event|
| INVALID_TRANSITION | 409 | Status transition is not allowed |
| PARTICIPANT_CONFLICT | 409 | Participant has an overlapping event |
| FEATURE_NOT_CONFIGURED | 500 | Using availability without configuring it |
Error Factories
The Errors object provides factory functions for creating errors programmatically:
import { Errors } from "@forjakit/scheduling";
Errors.eventNotFound("evt-123");
Errors.eventNotModifiable("FINISHED");
Errors.invalidTransition("FINISHED", "SCHEDULED");
Errors.participantConflict(["team-1", "team-2"], new Date());
Errors.featureNotConfigured("Availability");TypeScript Generics
The TMeta generic parameter flows through the entire system:
createSchedulingSchemas(metadataSchema: TMeta)
└→ schemas.CreateEventInput includes metadata: TMeta
createSchedulingService<TMeta>({ metadataSchema, storage })
└→ storage: SchedulingStorage<z.infer<TMeta>>
└→ service.scheduleEvent() → Promise<ScheduledEvent<z.infer<TMeta>>>
└→ service.getEvent() → Promise<ScheduledEvent<z.infer<TMeta>>>
└→ ...This means your IDE provides full autocompletion for event.metadata.round, event.metadata.serviceType, etc., based on the schema you provide.
Storage Interface
To use the core package directly, implement the SchedulingStorage<TMeta> interface:
interface SchedulingStorage<TMeta> {
createEvent(input: CreateEventInput<TMeta>): Promise<ScheduledEvent<TMeta>>;
findEventById(id: string, tenantId: string): Promise<ScheduledEvent<TMeta> | null>;
updateEvent(id: string, tenantId: string, input: UpdateEventInput<TMeta>): Promise<ScheduledEvent<TMeta>>;
updateEventStatus(id: string, tenantId: string, status: EventStatus): Promise<ScheduledEvent<TMeta>>;
deleteEvent(id: string, tenantId: string): Promise<void>;
listEvents(filters: EventFilters): Promise<ScheduledEvent<TMeta>[]>;
findOverlappingEvents(
tenantId: string,
participantIds: string[],
startsAt: Date,
endsAt: Date,
excludeEventId?: string
): Promise<ScheduledEvent<TMeta>[]>;
}For availability, implement AvailabilityStorage:
interface AvailabilityStorage {
upsertAvailability(input: AvailabilityWindow): Promise<AvailabilityWindow>;
findAvailability(participantId: string, tenantId: string): Promise<AvailabilityWindow[]>;
deleteAvailability(participantId: string, tenantId: string, dayOfWeek: number): Promise<void>;
}Or use @forjakit/scheduling-prisma for a ready-made Prisma implementation.
Full Working Example
import { createSchedulingService, type SchedulingStorage } from "@forjakit/scheduling";
import { z } from "zod";
// 1. Define your metadata schema
const matchMetadata = z.object({
round: z.number().int().positive(),
group: z.string().optional(),
});
type MatchMeta = z.infer<typeof matchMetadata>;
// 2. Implement storage (or use @forjakit/scheduling-prisma)
const storage: SchedulingStorage<MatchMeta> = {
// ... your implementation
};
// 3. Create the service
const scheduling = createSchedulingService({
storage,
metadataSchema: matchMetadata,
});
// 4. Schedule a match
const match = await scheduling.scheduleEvent({
tenantId: "org-1",
contextId: "championship-2026",
participants: [
{ id: "team-a", label: "Team Alpha", type: "team" },
{ id: "team-b", label: "Team Beta", type: "team" },
],
scheduledAt: new Date("2026-04-01T14:00:00Z"),
durationMinutes: 90,
venue: { id: "court-1", label: "Main Court", type: "court" },
metadata: { round: 1, group: "A" },
});
// 5. Transition status
await scheduling.transitionStatus(match.id, "org-1", "IN_PROGRESS");
await scheduling.transitionStatus(match.id, "org-1", "FINISHED");
// 6. List upcoming matches
const upcoming = await scheduling.listEvents({
tenantId: "org-1",
contextId: "championship-2026",
status: "SCHEDULED",
from: new Date(),
});Exports
// Service
export { createSchedulingService } from "@forjakit/scheduling";
export type { SchedulingService, SchedulingServiceConfig } from "@forjakit/scheduling";
// Schemas
export { createSchedulingSchemas, EventStatusSchema } from "@forjakit/scheduling";
export type { SchedulingSchemas } from "@forjakit/scheduling";
// Transitions
export { assertValidTransition, getAllowedTransitions } from "@forjakit/scheduling";
// Conflicts
export { assertNoConflicts, computeEndTime } from "@forjakit/scheduling";
// Errors
export { SchedulingError, Errors } from "@forjakit/scheduling";
// Types
export type {
EventStatus, Participant, Venue, ScheduledEvent,
AvailabilityWindow, TimeSlot,
CreateEventInput, UpdateEventInput, EventFilters,
SchedulingStorage, AvailabilityStorage,
} from "@forjakit/scheduling";