2026-09-09 15:42:53 -04:00
/**
* Mock Planning Center Services API for developing the pco-plan module
* without a Planning Center account.
*
* node tools/pco-mock.js [port] (default 24700)
* PCO_PLAN_API_BASE=http://127.0.0.1:24700 node server.js
*
* Serves JSON:API shapes matching api.planningcenteronline.com/services/v2:
* GET /services/v2/folders nested folders (parent rel)
* GET /services/v2/folders/{id}
* GET /services/v2/service_types every service type, with its
* parent folder relationship
* GET /services/v2/service_types/{id}
2026-09-14 15:35:47 -04:00
* GET /services/v2/service_types/{id}/item_note_categories
* the note categories the
* items below use, plus one
* ("Stage") no note uses yet
2026-09-09 15:42:53 -04:00
* GET /services/v2/service_types/{id}/plans ?filter=future|past, order,
* per_page — today's plan,
* next week's and last week's
* GET …/plans/{id}
* GET …/plans/{id}/plan_times rehearsal + two services; the
* first service starts 90 s
* after the mock boots so the
* "Starts in" state is visible
* GET …/plans/{id}/items?include=… items + Song / Arrangement /
* ItemNote compound documents
*
* Item titles deliberately match band-lineup-display/tmp/pp-mock.js's
* playlist, so running both mocks exercises the ProPresenter matching.
*
* Any request without an Authorization header (Basic or Bearer) gets a
* Planning-Center-style 401, so a missing/blank credential shows up the way
* it would in production. Every request is logged.
*/
'use strict' ;
const http = require ( 'http' );
const PORT = Number . parseInt ( process . argv [ 2 ] || '24700' , 10 );
const bootedAt = Date . now ();
/* ── data ───────────────────────────────────────────────────────────── */
const FOLDERS = [
{ id : '100' , name : 'Campuses' , parentId : '' },
{ id : '110' , name : 'North Campus' , parentId : '100' },
{ id : '120' , name : 'Downtown' , parentId : '100' },
{ id : '200' , name : 'Archive' , parentId : '' },
];
const SERVICE_TYPES = [
{ id : '1001' , name : 'Sunday Service' , frequency : 'Every week' , parentId : '110' },
{ id : '1002' , name : 'Wednesday Night' , frequency : 'Every week' , parentId : '110' },
{ id : '1003' , name : 'Downtown Sunday' , frequency : 'Every week' , parentId : '120' },
{ id : '1004' , name : 'Christmas Eve 2024' , frequency : 'Every 12 months' , parentId : '200' },
{ id : '1005' , name : 'Youth Night' , frequency : 'Every 2 weeks' , parentId : '' },
];
function iso ( ms ) {
return new Date ( ms ). toISOString ();
}
function longDate ( ms ) {
return new Date ( ms ). toLocaleDateString ( 'en-US' , { month : 'long' , day : 'numeric' , year : 'numeric' });
}
function shortDate ( ms ) {
return new Date ( ms ). toLocaleDateString ( 'en-US' , { month : 'short' , day : 'numeric' });
}
const DAY = 86400000 ;
const firstServiceStart = bootedAt + 90 * 1000 ;
/** Plans for service type 1001: last week, today, next week. */
function plansFor ( stId ) {
if ( stId !== '1001' && stId !== '1003' ) return [];
const mk = ( id , serviceStart , title , series ) => ({
id ,
serviceTypeId : stId ,
title ,
series ,
sortDate : serviceStart ,
times : [
{ id : ` ${ id } -r` , name : 'Rehearsal' , type : 'rehearsal' , start : serviceStart - 60 * 60000 , end : serviceStart - 15 * 60000 },
{ id : ` ${ id } -s1` , name : 'First Service' , type : 'service' , start : serviceStart , end : serviceStart + 75 * 60000 },
{ id : ` ${ id } -s2` , name : 'Second Service' , type : 'service' , start : serviceStart + 120 * 60000 , end : serviceStart + 195 * 60000 },
],
});
return [
2026-09-10 15:31:12 -04:00
mk ( ` ${ stId } -P1` , firstServiceStart - 7 * DAY , 'Part 1' , 'Living Hope' ),
mk ( ` ${ stId } -P2` , firstServiceStart , 'Part 2' , 'Living Hope' ),
mk ( ` ${ stId } -P3` , firstServiceStart + 7 * DAY , 'Part 3' , 'Living Hope' ),
2026-09-09 15:42:53 -04:00
];
}
function planRecord ( p ) {
const items = ITEMS . filter (( it ) => it . type !== 'header' );
return {
type : 'Plan' ,
id : p . id ,
attributes : {
title : p . title ,
series_title : p . series ,
dates : longDate ( p . sortDate ),
short_dates : shortDate ( p . sortDate ),
sort_date : iso ( p . sortDate ),
total_length : items . reduce (( s , it ) => s + it . length , 0 ),
items_count : ITEMS . length ,
plan_notes_count : 0 ,
planning_center_url : `https://services.planningcenteronline.com/plans/ ${ p . id } ` ,
},
relationships : { service_type : { data : { type : 'ServiceType' , id : p . serviceTypeId } } },
links : { self : `/services/v2/service_types/ ${ p . serviceTypeId } /plans/ ${ p . id } ` },
};
}
// Titles match pp-mock's playlist: Welcome, This Is The Day, Sermon: Living
// Hope, Bumper Video, Closing Song, Benediction.
const ITEMS = [
{ id : 'i1' , type : 'header' , title : 'Pre-Service' , pos : 'pre' , length : 0 },
{ id : 'i2' , type : 'media' , title : 'Countdown' , pos : 'pre' , length : 300 , desc : 'Start 5 min before' , notes : [[ 'Video' , 'Loop the countdown from 8:55' ]] },
{ id : 'i3' , type : 'header' , title : 'Worship' , pos : 'during' , length : 0 },
{ id : 'i4' , type : 'song' , title : 'This Is The Day' , pos : 'during' , length : 270 , key : 'G' , song : { id : 's1' , title : 'This Is The Day' , author : 'Fred Hammond' }, arr : 'Default Arrangement' , notes : [[ 'Audio' , 'Lead vocal: Sarah' ], [ 'Lighting' , 'Bright — house at 40%' ]] },
{ id : 'i5' , type : 'song' , title : 'Goodness of God' , pos : 'during' , length : 320 , key : 'A' , song : { id : 's2' , title : 'Goodness of God' , author : 'Bethel Music' , ccli : '7117726' }, arr : 'Acoustic' , notes : [[ 'ProPresenter' , 'Use the [ Full ] arrangement' ]] },
{ id : 'i6' , type : 'item' , title : 'Welcome' , pos : 'during' , length : 180 , desc : 'Pastor Mike — mention Fall Kickoff' , notes : [[ 'Audio' , 'Handheld 2 hot' ]] },
{ id : 'i7' , type : 'media' , title : 'Bumper Video' , pos : 'during' , length : 60 , notes : [[ 'Video' , 'Program to Playback A' ]] },
{ id : 'i8' , type : 'header' , title : 'Message' , pos : 'during' , length : 0 },
{ id : 'i9' , type : 'item' , title : 'Sermon: Living Hope' , pos : 'during' , length : 2100 , desc : '1 Peter 1:3-9' , notes : [[ 'ProPresenter' , 'Sermon slides from PCO' ], [ 'Lighting' , 'Stage wash, house at 20%' ]] },
{ id : 'i10' , type : 'song' , title : 'Closing Song' , pos : 'during' , length : 240 , key : 'D' , song : { id : 's3' , title : 'Build My Life' , author : 'Housefires' }, arr : 'Default Arrangement' },
{ id : 'i11' , type : 'item' , title : 'Benediction' , pos : 'during' , length : 90 },
{ id : 'i12' , type : 'header' , title : 'Post-Service' , pos : 'post' , length : 0 },
{ id : 'i13' , type : 'media' , title : 'Walk-out Loop' , pos : 'post' , length : 600 , notes : [[ 'Video' , 'Announcements loop until the room clears' ]] },
];
2026-09-14 15:35:47 -04:00
/**
* Item note categories, per service type in Planning Center: every category
* the items above use, in the order the mock account lists them, plus one no
* note uses yet — a tile that has never chosen shows it on as well.
*/
const NOTE_CATEGORIES = [ 'Audio' , 'Video' , 'Lighting' , 'ProPresenter' , 'Stage' ];
function noteCategoryRecord ( name , index , stId ) {
return {
type : 'ItemNoteCategory' ,
id : ` ${ stId } -nc ${ index + 1 } ` ,
attributes : { name , sequence : index + 1 , frequently_used : index < 2 , deleted_at : null },
relationships : { service_type : { data : { type : 'ServiceType' , id : stId } } },
};
}
2026-09-09 15:42:53 -04:00
function itemsDocument ( planId , include ) {
const wants = new Set ( String ( include || '' ). split ( ',' ). map (( s ) => s . trim ()). filter ( Boolean ));
const data = [];
const included = [];
ITEMS . forEach (( it , index ) => {
const relationships = {};
if ( it . song ) {
relationships . song = { data : { type : 'Song' , id : it . song . id } };
if ( wants . has ( 'song' )) {
included . push ({ type : 'Song' , id : it . song . id , attributes : { title : it . song . title , author : it . song . author , ccli_number : it . song . ccli ? Number ( it . song . ccli ) : null } });
}
if ( it . arr ) {
const arrId = ` ${ it . song . id } -arr` ;
relationships . arrangement = { data : { type : 'Arrangement' , id : arrId } };
if ( wants . has ( 'arrangement' )) included . push ({ type : 'Arrangement' , id : arrId , attributes : { name : it . arr } });
}
}
const noteRefs = ( it . notes || []). map (( n , i ) => ({ type : 'ItemNote' , id : ` ${ it . id } -n ${ i } ` }));
relationships . item_notes = { data : noteRefs };
if ( wants . has ( 'item_notes' )) {
( it . notes || []). forEach (([ category , content ], i ) => {
included . push ({ type : 'ItemNote' , id : ` ${ it . id } -n ${ i } ` , attributes : { category_name : category , content } });
});
}
data . push ({
type : 'Item' ,
id : ` ${ planId } - ${ it . id } ` ,
attributes : {
title : it . title ,
sequence : index + 1 ,
item_type : it . type ,
length : it . length ,
service_position : it . pos ,
description : it . desc || '' ,
html_details : '' ,
key_name : it . key || null ,
},
relationships ,
});
});
return { data , included , meta : { total_count : data . length , count : data . length } };
}
/* ── JSON:API plumbing ──────────────────────────────────────────────── */
function folderRecord ( f ) {
return {
type : 'Folder' ,
id : f . id ,
attributes : { name : f . name , container : f . parentId ? 'Folder' : 'Organization' },
relationships : { parent : { data : f . parentId ? { type : 'Folder' , id : f . parentId } : null } },
};
}
function serviceTypeRecord ( st ) {
return {
type : 'ServiceType' ,
id : st . id ,
attributes : { name : st . name , frequency : st . frequency , sequence : Number ( st . id ) },
relationships : { parent : { data : st . parentId ? { type : 'Folder' , id : st . parentId } : null } },
};
}
function planTimeRecord ( t ) {
return {
type : 'PlanTime' ,
id : t . id ,
attributes : { name : t . name , time_type : t . type , starts_at : iso ( t . start ), ends_at : iso ( t . end ), live_starts_at : null , live_ends_at : null },
};
}
function send ( res , status , body ) {
res . writeHead ( status , { 'Content-Type' : 'application/vnd.api+json; charset=utf-8' , 'Cache-Control' : 'no-store' });
res . end ( JSON . stringify ( body ));
}
function collection ( res , records , url ) {
send ( res , 200 , {
links : { self : url . pathname + url . search },
data : records ,
included : [],
meta : { total_count : records . length , count : records . length },
});
}
function notFound ( res ) {
send ( res , 404 , { errors : [{ code : 'not_found' , title : 'Not Found' , status : '404' , detail : 'The resource you requested could not be found' }] });
}
const server = http . createServer (( req , res ) => {
const url = new URL ( req . url , `http://127.0.0.1: ${ PORT } ` );
console . log ( `[pco-mock] ${ req . method } ${ url . pathname }${ url . search } ` );
if ( ! /^(Basic|Bearer) \S+/ . test ( String ( req . headers . authorization || '' ))) {
return send ( res , 401 , { errors : [{ code : 'unauthorized' , title : 'Unauthorized' , status : '401' , detail : 'This request could not be authenticated.' }] });
}
const parts = url . pathname . replace ( /^\/services\/v2\/?/ , '' ). split ( '/' ). filter ( Boolean );
if ( ! url . pathname . startsWith ( '/services/v2' )) return notFound ( res );
// /folders[/{id}]
if ( parts [ 0 ] === 'folders' ) {
if ( parts . length === 1 ) return collection ( res , FOLDERS . map ( folderRecord ), url );
const f = FOLDERS . find (( x ) => x . id === parts [ 1 ]);
if ( ! f ) return notFound ( res );
if ( parts . length === 2 ) return send ( res , 200 , { data : folderRecord ( f ) });
if ( parts [ 2 ] === 'folders' ) return collection ( res , FOLDERS . filter (( x ) => x . parentId === f . id ). map ( folderRecord ), url );
if ( parts [ 2 ] === 'service_types' ) return collection ( res , SERVICE_TYPES . filter (( x ) => x . parentId === f . id ). map ( serviceTypeRecord ), url );
return notFound ( res );
}
// /service_types[/{id}[/plans[/{pid}[/plan_times|items]]]]
if ( parts [ 0 ] === 'service_types' ) {
if ( parts . length === 1 ) return collection ( res , SERVICE_TYPES . map ( serviceTypeRecord ), url );
const st = SERVICE_TYPES . find (( x ) => x . id === parts [ 1 ]);
if ( ! st ) return notFound ( res );
if ( parts . length === 2 ) return send ( res , 200 , { data : serviceTypeRecord ( st ) });
2026-09-14 15:35:47 -04:00
if ( parts [ 2 ] === 'item_note_categories' && parts . length === 3 ) {
return collection ( res , NOTE_CATEGORIES . map (( name , i ) => noteCategoryRecord ( name , i , st . id )), url );
}
2026-09-09 15:42:53 -04:00
if ( parts [ 2 ] !== 'plans' ) return notFound ( res );
const plans = plansFor ( st . id );
if ( parts . length === 3 ) {
const filter = url . searchParams . get ( 'filter' ) || '' ;
const now = Date . now ();
let list = plans . slice ();
if ( filter === 'future' ) list = list . filter (( p ) => p . sortDate >= new Date ( now ). setHours ( 0 , 0 , 0 , 0 ));
else if ( filter === 'past' ) list = list . filter (( p ) => p . sortDate < now );
const order = url . searchParams . get ( 'order' ) || 'sort_date' ;
list . sort (( a , b ) => ( order . startsWith ( '-' ) ? b . sortDate - a . sortDate : a . sortDate - b . sortDate ));
const perPage = Math . max ( 1 , Math . min ( 100 , Number ( url . searchParams . get ( 'per_page' )) || 25 ));
return collection ( res , list . slice ( 0 , perPage ). map ( planRecord ), url );
}
const plan = plans . find (( p ) => p . id === parts [ 3 ]);
if ( ! plan ) return notFound ( res );
if ( parts . length === 4 ) return send ( res , 200 , { data : planRecord ( plan ) });
if ( parts [ 4 ] === 'plan_times' ) return collection ( res , plan . times . map ( planTimeRecord ), url );
if ( parts [ 4 ] === 'items' ) {
const doc = itemsDocument ( plan . id , url . searchParams . get ( 'include' ));
doc . links = { self : url . pathname + url . search };
return send ( res , 200 , doc );
}
return notFound ( res );
}
notFound ( res );
});
server . listen ( PORT , '127.0.0.1' , () => {
console . log ( `[pco-mock] Planning Center mock on http://127.0.0.1: ${ PORT } /services/v2` );
console . log ( `[pco-mock] today's plan " ${ plansFor ( '1001' )[ 1 ]. title } " — first service starts at ${ new Date ( firstServiceStart ). toLocaleTimeString () } ` );
console . log ( '[pco-mock] run ProdDash with: PCO_PLAN_API_BASE=http://127.0.0.1:%d node server.js' , PORT );
});