@brandcast_app/zoomshift-api-client
Version:
Unofficial TypeScript/JavaScript client for ZoomShift employee scheduling API (reverse-engineered)
309 lines (308 loc) • 11.5 kB
JavaScript
"use strict";
/**
* ZoomShift API Client
*
* Unofficial TypeScript client for ZoomShift employee scheduling.
* Based on reverse-engineering ZoomShift's internal JSON endpoints.
*
* @license MIT
* @see https://github.com/jduncan-rva/zoomshift-api-client
*/
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.ZoomShiftClient = void 0;
const axios_1 = __importDefault(require("axios"));
const axios_cookiejar_support_1 = require("axios-cookiejar-support");
const tough_cookie_1 = require("tough-cookie");
const jsdom_1 = require("jsdom");
/**
* Main ZoomShift API client class
*
* @example
* ```typescript
* const client = new ZoomShiftClient({ debug: true });
*
* // Authenticate
* await client.authenticate('user@example.com', 'password', '58369');
*
* // Get shifts
* const shifts = await client.getShifts({
* startDate: '2025-10-10',
* endDate: '2025-10-17'
* });
* ```
*/
class ZoomShiftClient {
constructor(options = {}) {
this.authenticated = false;
this.scheduleId = null;
this.debug = options.debug || false;
this.cookieJar = new tough_cookie_1.CookieJar();
this.client = (0, axios_cookiejar_support_1.wrapper)(axios_1.default.create({
baseURL: ZoomShiftClient.BASE_URL,
timeout: options.timeout || 30000,
jar: this.cookieJar,
withCredentials: true,
headers: {
'User-Agent': options.userAgent || 'BrandCast ZoomShift Client/1.0',
'Accept': 'application/json, text/html',
},
}));
// Debug logging interceptors
if (this.debug) {
this.client.interceptors.request.use((config) => {
console.log('[ZoomShift] Request:', config.method?.toUpperCase(), config.url);
return config;
});
this.client.interceptors.response.use((response) => {
console.log('[ZoomShift] Response:', response.status, response.config.url);
return response;
}, (error) => {
console.error('[ZoomShift] Error:', error.message);
return Promise.reject(error);
});
}
}
/**
* Authenticate with ZoomShift
*
* @param email - ZoomShift account email
* @param password - ZoomShift account password
* @param scheduleId - ZoomShift schedule ID (found in URL)
* @returns Authentication response with schedule info
* @throws {ZoomShiftError} If authentication fails
*
* @example
* ```typescript
* const auth = await client.authenticate(
* 'manager@example.com',
* 'mypassword',
* '58369'
* );
* console.log('Authenticated:', auth.authenticated);
* ```
*/
async authenticate(email, password, scheduleId) {
try {
// Step 1: Get login page and extract CSRF token
if (this.debug) {
console.log('[ZoomShift] Fetching login page...');
}
const loginPage = await this.client.get('/session/new');
const dom = new jsdom_1.JSDOM(loginPage.data);
const csrfToken = dom.window.document
.querySelector('meta[name="csrf-token"]')
?.getAttribute('content');
if (!csrfToken) {
throw this.createError('CSRF_TOKEN_MISSING', 'Failed to extract CSRF token from login page');
}
if (this.debug) {
console.log('[ZoomShift] CSRF token extracted');
}
// Step 2: Submit login form
const formData = new URLSearchParams({
'authenticity_token': csrfToken,
'session[email]': email,
'session[password]': password,
});
if (this.debug) {
console.log('[ZoomShift] Submitting login form...');
}
const loginResponse = await this.client.post('/session', formData, {
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
maxRedirects: 0,
validateStatus: (status) => status >= 200 && status < 400,
});
// Step 3: Check if authentication succeeded
// Successful auth redirects away from /session/new
const finalUrl = loginResponse.request.path ||
loginResponse.request.res?.responseUrl ||
'';
if (finalUrl.includes('/session/new')) {
throw this.createError('AUTH_FAILED', 'Authentication failed - invalid credentials');
}
this.authenticated = true;
this.scheduleId = scheduleId;
if (this.debug) {
console.log('[ZoomShift] Authentication successful');
}
return {
authenticated: true,
scheduleId,
userName: email,
};
}
catch (error) {
this.authenticated = false;
if (this.isZoomShiftError(error)) {
throw error;
}
if (axios_1.default.isAxiosError(error)) {
throw this.createError('AUTH_REQUEST_FAILED', `Authentication request failed: ${error.message}`, error);
}
throw this.createError('AUTH_UNKNOWN_ERROR', 'Unknown authentication error occurred', error);
}
}
/**
* Get shifts for a date range
*
* @param request - Request parameters (date range, filters)
* @returns Array of shift objects
* @throws {ZoomShiftError} If not authenticated or request fails
*
* @example
* ```typescript
* const shifts = await client.getShifts({
* startDate: '2025-10-10',
* endDate: '2025-10-17',
* employeeId: '12345', // optional
* location: 'Main Store' // optional
* });
* ```
*/
async getShifts(request) {
if (!this.authenticated || !this.scheduleId) {
throw this.createError('NOT_AUTHENTICATED', 'Not authenticated - call authenticate() first');
}
try {
if (this.debug) {
console.log('[ZoomShift] Fetching shifts:', request);
}
const response = await this.client.get(`/${this.scheduleId}/shifts.json`, {
params: {
page: 1,
limit: 101,
pagination_adjustment: 0,
order: 'timeframe',
sort: 'asc',
rendition: 'calendar',
start_date: request.startDate,
end_date: request.endDate,
variation: 'all',
},
headers: {
'Accept': 'application/json',
'X-Requested-With': 'XMLHttpRequest',
},
});
// Transform ZoomShift API response to our interface
const shifts = this.transformShifts(response.data, request);
if (this.debug) {
console.log('[ZoomShift] Fetched', shifts.length, 'shifts');
}
return shifts;
}
catch (error) {
// Re-authenticate on 401
if (axios_1.default.isAxiosError(error) && error.response?.status === 401) {
this.authenticated = false;
throw this.createError('SESSION_EXPIRED', 'Session expired - please re-authenticate');
}
if (axios_1.default.isAxiosError(error)) {
throw this.createError('FETCH_FAILED', `Failed to fetch shifts: ${error.message}`, error);
}
throw this.createError('FETCH_UNKNOWN_ERROR', 'Unknown error while fetching shifts', error);
}
}
/**
* Transform ZoomShift API response to standardized format
* ZoomShift uses JSON:API format with data/attributes structure
*/
transformShifts(rawData, request) {
const shifts = [];
// ZoomShift API returns JSON:API format: { data: [...], meta: {...} }
if (!rawData || !Array.isArray(rawData.data)) {
if (this.debug) {
console.warn('[ZoomShift] Unexpected response structure:', rawData);
}
return shifts;
}
for (const shift of rawData.data) {
if (shift.type !== 'shift' || !shift.attributes) {
continue;
}
const attrs = shift.attributes;
const assignment = attrs.assignment_attributes || {};
// Build employee name from first/last name
const employeeName = [assignment.first_name, assignment.last_name]
.filter(Boolean)
.join(' ') || 'Unknown Employee';
const employeeId = assignment.user_id?.toString() || '';
// Filter by employeeId if specified
if (request.employeeId && employeeId !== request.employeeId) {
continue;
}
// Filter by location if specified
if (request.location && attrs.location_name !== request.location) {
continue;
}
// Convert duration from seconds to hours
const durationHours = attrs.duration ? attrs.duration / 3600 : 0;
const breakHours = attrs.break_time ? attrs.break_time / 3600 : undefined;
shifts.push({
id: shift.id,
employeeName,
employeeId,
role: attrs.position_name || 'Employee',
startTime: attrs.start_at,
endTime: attrs.end_at,
duration: durationHours,
breakDuration: breakHours,
location: attrs.location_name || undefined,
notes: attrs.note?.all || undefined,
status: this.mapStatus(attrs.published, attrs.confirmed),
});
}
return shifts;
}
/**
* Map ZoomShift status flags to our standard status
*
* ZoomShift doesn't have explicit status field, but we can infer from:
* - published: boolean - shift is published to schedule
* - confirmed: boolean - shift is confirmed by employee
* - start_at/end_at timestamps - can determine if in progress or completed
*/
mapStatus(published, confirmed) {
// For now, return 'scheduled' for all published shifts
// Future enhancement: check timestamps to determine in_progress/completed
return 'scheduled';
}
/**
* Check if client is authenticated
*/
isAuthenticated() {
return this.authenticated;
}
/**
* Get the schedule ID
*/
getScheduleId() {
return this.scheduleId;
}
/**
* Create a standardized error object
*/
createError(code, message, details) {
return {
code,
message,
details,
};
}
/**
* Type guard for ZoomShiftError
*/
isZoomShiftError(error) {
return (typeof error === 'object' &&
error !== null &&
'code' in error &&
'message' in error);
}
}
exports.ZoomShiftClient = ZoomShiftClient;
ZoomShiftClient.BASE_URL = 'https://app.zoomshift.com';