An account runs more than one service type — a Sunday morning, a Wednesday night, a youth service, a second campus — and until now that meant one ProdDash per type. The module now follows as many as are configured. Admin picks them in priority order. Shell 1.12.0 gives `multiselect` an `orderable: true` that stops being a ticklist: the picks sit in a numbered list with a nudge either way and a remove, over a dropdown holding what is left. Forty switches to read past is not a picker, and a ticklist cannot carry an order anyway. The order is the setting — when two types have a plan at the same date and time, the one higher up wins. The server gathers candidate plans from every ticked type and tiers them: today's plan first, then the next upcoming, then the most recent past, with the configured order breaking a tie. A type that fails is skipped rather than failing the read, unless it is an auth or rate-limit answer, which belongs to the whole connection. The types not being followed are read on a slower lane (45 s) into `state.others`, so they are there to look at without a second connection. Every tile's title bar carries a service-type control. Picking one moves every PCO Plan tile on that browser's dashboard — plan, teams and song key together — onto its plan, over a small page bus in `viewed-type.js`; the choice is per browser and survives a reload. The control hides itself when only one type is configured. Live machinery stays with the type being followed: another type's plan shows its running order with no now marker, no AbleSet and no service clock — it shows that plan's own scheduled time instead, since borrowing the running clock of a different service is exactly the misreading the amber top-bar stripe exists to prevent. Old configs carry `serviceTypeId`; it is read once into the new array and saved, and plan pins still resolve bare, so nothing has to be re-entered. `tools/pco-mock.js` takes `--types N` to pad the list out, for trying the picker against an account with dozens of them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
458 lines
21 KiB
JavaScript
458 lines
21 KiB
JavaScript
/**
|
|
* 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}
|
|
* Run: node tools/pco-mock.js [port] [--types N] (--types pads the service
|
|
* type list out to N, for trying a picker against an account that has dozens.)
|
|
*
|
|
* GET /services/v2/service_types every service type, with its
|
|
* parent folder relationship
|
|
* GET /services/v2/service_types/{id}
|
|
* GET /services/v2/service_types/{id}/teams every team, including one
|
|
* nobody is scheduled on
|
|
* GET /services/v2/service_types/{id}/item_note_categories
|
|
* the note categories the
|
|
* items below use, plus one
|
|
* ("Stage") no note uses yet
|
|
* 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
|
|
* GET …/plans/{id}/team_members?include=… who is scheduled: PlanPerson
|
|
* records + Team / Person, with
|
|
* confirmed / unconfirmed /
|
|
* declined statuses, a team
|
|
* nobody is on, and people with
|
|
* and without a photo
|
|
* GET /avatars/{personId}.svg those photos (initials on a
|
|
* disc) — the module proxies
|
|
* them like the real CDN
|
|
*
|
|
* 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);
|
|
// --types N pads the list out to N with planless filler, for accounts that
|
|
// carry dozens of them: enough to see whether a picker still works at that size.
|
|
const TYPE_COUNT = Number.parseInt((process.argv.find((a) => a.startsWith('--types')) || '').split('=')[1]
|
|
|| (process.argv[process.argv.indexOf('--types') + 1] || ''), 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: '' },
|
|
];
|
|
|
|
if (Number.isFinite(TYPE_COUNT) && TYPE_COUNT > SERVICE_TYPES.length) {
|
|
FOLDERS.push({ id: '300', name: 'Other Campuses', parentId: '' });
|
|
for (let i = SERVICE_TYPES.length; i < TYPE_COUNT; i += 1) {
|
|
SERVICE_TYPES.push({
|
|
id: String(2000 + i),
|
|
name: `Campus ${String.fromCharCode(65 + (i % 26))}${i >= 26 + 5 ? Math.floor(i / 26) : ''} Service`,
|
|
frequency: 'Every week',
|
|
parentId: i % 3 === 0 ? '300' : (i % 3 === 1 ? '110' : ''),
|
|
});
|
|
}
|
|
}
|
|
|
|
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 [
|
|
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'),
|
|
];
|
|
}
|
|
|
|
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']] },
|
|
];
|
|
|
|
/**
|
|
* 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 } } },
|
|
};
|
|
}
|
|
|
|
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 ──────────────────────────────────────────────── */
|
|
|
|
/**
|
|
* Who is scheduled, as Planning Center returns it from
|
|
* /plans/{id}/team_members?include=team,person: teams in their own order,
|
|
* people with a position and a scheduling status ("C" confirmed,
|
|
* "U" unconfirmed, "D" declined). `photo` false is somebody Planning Center
|
|
* has no picture for — the tile draws their initials instead.
|
|
*/
|
|
const TEAMS = [
|
|
{
|
|
id: 't1', name: 'Worship', sequence: 1, members: [
|
|
{ id: 'tp1', person: 'p1', name: 'Sarah Miles', position: 'Worship Leader', status: 'C', photo: true },
|
|
{ id: 'tp2', person: 'p2', name: 'Dan Whitfield', position: 'Acoustic Guitar', status: 'C', photo: true },
|
|
{ id: 'tp3', person: 'p3', name: 'Priya Raman', position: 'Keys', status: 'U', photo: true },
|
|
{ id: 'tp4', person: 'p4', name: 'Marcus Webb', position: 'Drums', status: 'C', photo: false },
|
|
{ id: 'tp5', person: 'p5', name: 'Elena Fischer', position: 'Bass', status: 'D', photo: true, decline: 'Out of town' },
|
|
{ id: 'tp6', person: 'p6', name: 'Joy Adeyemi', position: 'Vocals', status: 'C', photo: true },
|
|
{ id: 'tp7', person: 'p7', name: 'Tom Brennan', position: 'Vocals', status: 'U', photo: false },
|
|
],
|
|
},
|
|
{
|
|
id: 't2', name: 'Production', sequence: 2, members: [
|
|
{ id: 'tp8', person: 'p8', name: 'Chris Nolan', position: 'FOH Audio', status: 'C', photo: true, notes: 'Arrives 7:15 for line check' },
|
|
{ id: 'tp9', person: 'p9', name: 'Amara Okafor', position: 'ProPresenter', status: 'C', photo: true },
|
|
{ id: 'tp10', person: 'p10', name: 'Nate Sørensen', position: 'Lighting', status: 'U', photo: false },
|
|
{ id: 'tp11', person: 'p11', name: 'Bethany Cruz', position: 'Camera 1', status: 'C', photo: true },
|
|
{ id: 'tp12', person: 'p12', name: 'Owen Hart', position: 'Camera 2', status: 'C', photo: false },
|
|
],
|
|
},
|
|
{
|
|
id: 't3', name: 'Hosting', sequence: 3, members: [
|
|
{ id: 'tp13', person: 'p13', name: 'Pastor Mike Alvarez', position: 'MC', status: 'C', photo: true },
|
|
{ id: 'tp14', person: 'p14', name: 'Grace Lin', position: 'Greeter', status: 'C', photo: true },
|
|
{ id: 'tp15', person: 'p15', name: 'Sam Okonkwo', position: 'Greeter', status: 'U', photo: false },
|
|
],
|
|
},
|
|
// A team with nobody on it — a tile hides it unless "Teams with nobody" is on.
|
|
{ id: 't4', name: 'Kids', sequence: 4, members: [] },
|
|
];
|
|
|
|
/** A stand-in avatar: initials on a coloured disc, as an SVG the module can proxy. */
|
|
function avatarSvg(name) {
|
|
const words = String(name).trim().split(/\s+/);
|
|
const initials = (words.length > 1 ? words[0][0] + words[words.length - 1][0] : words[0].slice(0, 2)).toUpperCase();
|
|
let hash = 0;
|
|
for (const ch of name) hash = (hash * 31 + ch.charCodeAt(0)) % 360;
|
|
return `<svg xmlns="http://www.w3.org/2000/svg" width="96" height="96" viewBox="0 0 96 96">`
|
|
+ `<circle cx="48" cy="48" r="48" fill="hsl(${hash} 55% 42%)"/>`
|
|
+ `<text x="48" y="48" fill="#fff" font-family="system-ui,sans-serif" font-size="38" font-weight="700"`
|
|
+ ` text-anchor="middle" dominant-baseline="central">${initials}</text></svg>`;
|
|
}
|
|
|
|
function teamMembersDocument(planId, include, port) {
|
|
const wants = new Set(String(include || '').split(',').map((s) => s.trim()).filter(Boolean));
|
|
const data = [];
|
|
const included = [];
|
|
const seenTeams = new Set();
|
|
for (const team of TEAMS) {
|
|
for (const m of team.members) {
|
|
data.push({
|
|
type: 'PlanPerson',
|
|
id: m.id,
|
|
attributes: {
|
|
name: m.name,
|
|
status: m.status,
|
|
team_position_name: m.position,
|
|
notes: m.notes || null,
|
|
decline_reason: m.decline || null,
|
|
photo_thumbnail: m.photo ? `http://127.0.0.1:${port}/avatars/${m.person}.svg` : null,
|
|
status_updated_at: iso(bootedAt - 3600000),
|
|
},
|
|
relationships: {
|
|
plan: { data: { type: 'Plan', id: planId } },
|
|
team: { data: { type: 'Team', id: team.id } },
|
|
person: { data: { type: 'Person', id: m.person } },
|
|
},
|
|
});
|
|
if (wants.has('person')) {
|
|
included.push({
|
|
type: 'Person',
|
|
id: m.person,
|
|
attributes: {
|
|
name: m.name,
|
|
first_name: m.name.split(' ')[0],
|
|
last_name: m.name.split(' ').slice(-1)[0],
|
|
avatar: m.photo ? `http://127.0.0.1:${port}/avatars/${m.person}.svg` : null,
|
|
},
|
|
});
|
|
}
|
|
if (wants.has('team') && !seenTeams.has(team.id)) {
|
|
seenTeams.add(team.id);
|
|
included.push({
|
|
type: 'Team',
|
|
id: team.id,
|
|
attributes: { name: team.name, sequence: team.sequence, schedule_to: 'plan' },
|
|
});
|
|
}
|
|
}
|
|
}
|
|
return { data, included };
|
|
}
|
|
|
|
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}`);
|
|
|
|
// Photos are plain images on Planning Center's CDN — no auth there, none here.
|
|
const avatar = url.pathname.match(/^\/avatars\/([A-Za-z0-9_-]+)\.svg$/);
|
|
if (avatar) {
|
|
const person = [].concat(...TEAMS.map((t) => t.members)).find((m) => m.person === avatar[1]);
|
|
if (!person) return notFound(res);
|
|
const body = avatarSvg(person.name);
|
|
res.writeHead(200, { 'Content-Type': 'image/svg+xml', 'Content-Length': Buffer.byteLength(body), 'Cache-Control': 'public, max-age=3600' });
|
|
return void res.end(body);
|
|
}
|
|
|
|
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) });
|
|
if (parts[2] === 'teams' && parts.length === 3) {
|
|
return collection(res, TEAMS.map((t) => ({
|
|
type: 'Team', id: t.id,
|
|
attributes: { name: t.name, sequence: t.sequence, schedule_to: 'plan' },
|
|
relationships: { service_type: { data: { type: 'ServiceType', id: st.id } } },
|
|
})), url);
|
|
}
|
|
if (parts[2] === 'item_note_categories' && parts.length === 3) {
|
|
return collection(res, NOTE_CATEGORIES.map((name, i) => noteCategoryRecord(name, i, st.id)), url);
|
|
}
|
|
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] === 'team_members') {
|
|
const doc = teamMembersDocument(plan.id, url.searchParams.get('include'), PORT);
|
|
doc.links = { self: url.pathname + url.search };
|
|
return send(res, 200, doc);
|
|
}
|
|
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);
|
|
});
|