main: Planning Center birthdays, and the Test Birthdays diagnostic
pcoFetchStatus reports a status instead of throwing, so the People probe can tell "no access" from "no such record"; pcoFetchBirthday reads one person's birthdate as MM-DD, deliberately discarding the year so a volunteer's age never lands on this machine. The sync fetches one birthday per unique person, best-effort — a blocked token just yields '' and never fails the sync.
This commit is contained in:
+167
-1
@@ -238,6 +238,47 @@ async function pcoRequestJson(target, credentials, searchParams) {
|
|||||||
return payload || { data: [] };
|
return payload || { data: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Like pcoRequestJson but never throws on an HTTP error — returns the status so callers can tell
|
||||||
|
* "no access" (401/403) apart from "no such record" (404) apart from success. Used by the People
|
||||||
|
* birthdate probe/fetch, where the whole point is to report what the token is actually allowed to do.
|
||||||
|
*/
|
||||||
|
async function pcoFetchStatus(target, credentials, searchParams) {
|
||||||
|
const url = buildPcoUrl(target, searchParams);
|
||||||
|
const response = await fetch(url, {
|
||||||
|
headers: {
|
||||||
|
Accept: 'application/json',
|
||||||
|
Authorization: pcoAuthHeader(credentials),
|
||||||
|
'User-Agent': pcoUserAgent(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const text = await response.text();
|
||||||
|
let payload = null;
|
||||||
|
try {
|
||||||
|
payload = text ? JSON.parse(text) : null;
|
||||||
|
} catch {
|
||||||
|
payload = null;
|
||||||
|
}
|
||||||
|
return { status: response.status, ok: response.ok, payload };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch one person's birthday from the People API as "MM-DD" (year discarded). Services and People
|
||||||
|
* share the same person id, so the Services person id we already store doubles as the People id.
|
||||||
|
* Returns { status, birthday, fieldPresent } — birthday '' when the field is null/absent.
|
||||||
|
*/
|
||||||
|
async function pcoFetchBirthday(personId, credentials) {
|
||||||
|
if (!personId) return { status: 0, birthday: '', fieldPresent: false };
|
||||||
|
const { status, ok, payload } = await pcoFetchStatus(
|
||||||
|
`/people/v2/people/${encodeURIComponent(personId)}`,
|
||||||
|
credentials,
|
||||||
|
{ 'fields[Person]': 'birthdate' },
|
||||||
|
);
|
||||||
|
if (!ok) return { status, birthday: '', fieldPresent: false };
|
||||||
|
const attrs = payload?.data?.attributes || {};
|
||||||
|
return { status, birthday: normalizeBirthdayMmdd(attrs.birthdate), fieldPresent: 'birthdate' in attrs };
|
||||||
|
}
|
||||||
|
|
||||||
async function pcoFetchAll(target, credentials, searchParams) {
|
async function pcoFetchAll(target, credentials, searchParams) {
|
||||||
let nextUrl = buildPcoUrl(target, searchParams).toString();
|
let nextUrl = buildPcoUrl(target, searchParams).toString();
|
||||||
const data = [];
|
const data = [];
|
||||||
@@ -275,6 +316,15 @@ function emptyState() {
|
|||||||
pcoDisplayPositions: [],
|
pcoDisplayPositions: [],
|
||||||
proPresenter: { enabled: false, host: '', port: 0, playlistUuid: '', playlistName: '' },
|
proPresenter: { enabled: false, host: '', port: 0, playlistUuid: '', playlistName: '' },
|
||||||
displayServer: { enabled: false, port: DEFAULT_DISPLAY_PORT },
|
displayServer: { enabled: false, port: DEFAULT_DISPLAY_PORT },
|
||||||
|
birthdayMode: { enabled: true, intensity: 'subtle' },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Birthday Mode display settings (the per-person test override is NOT persisted — it lives only in the renderer). */
|
||||||
|
function normalizeBirthdayMode(entry) {
|
||||||
|
return {
|
||||||
|
enabled: entry?.enabled === undefined ? true : Boolean(entry.enabled),
|
||||||
|
intensity: entry?.intensity === 'full' ? 'full' : 'subtle',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -308,9 +358,26 @@ function normalizeSavedLibraryPerson(person) {
|
|||||||
source: person?.source === 'pco' ? 'pco' : 'local',
|
source: person?.source === 'pco' ? 'pco' : 'local',
|
||||||
pcoPersonId: typeof person?.pcoPersonId === 'string' ? person.pcoPersonId : '',
|
pcoPersonId: typeof person?.pcoPersonId === 'string' ? person.pcoPersonId : '',
|
||||||
pcoPlanPersonId: typeof person?.pcoPlanPersonId === 'string' ? person.pcoPlanPersonId : '',
|
pcoPlanPersonId: typeof person?.pcoPlanPersonId === 'string' ? person.pcoPlanPersonId : '',
|
||||||
|
// "MM-DD" only — we deliberately never persist the birth year (month + day is all
|
||||||
|
// Birthday Mode needs, and it keeps a volunteer's age off this machine). '' = none on file.
|
||||||
|
birthday: normalizeBirthdayMmdd(person?.birthday),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Coerce a Planning Center birthdate ("YYYY-MM-DD", or an already-trimmed "MM-DD") to "MM-DD", or '' if absent/invalid. */
|
||||||
|
function normalizeBirthdayMmdd(raw) {
|
||||||
|
const s = String(raw || '').trim();
|
||||||
|
const full = s.match(/^\d{4}-(\d{2})-(\d{2})$/); // PCO People birthdate shape
|
||||||
|
if (full) return `${full[1]}-${full[2]}`;
|
||||||
|
const md = s.match(/^(\d{2})-(\d{2})$/);
|
||||||
|
if (md) {
|
||||||
|
const mm = Number(md[1]);
|
||||||
|
const dd = Number(md[2]);
|
||||||
|
if (mm >= 1 && mm <= 12 && dd >= 1 && dd <= 31) return `${md[1]}-${md[2]}`;
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeTeamName(value) {
|
function normalizeTeamName(value) {
|
||||||
return String(value || '').trim().toLowerCase();
|
return String(value || '').trim().toLowerCase();
|
||||||
}
|
}
|
||||||
@@ -627,6 +694,20 @@ async function syncUpcomingPcoPlan(payload) {
|
|||||||
return a.displayName.localeCompare(b.displayName, undefined, { sensitivity: 'base' });
|
return a.displayName.localeCompare(b.displayName, undefined, { sensitivity: 'base' });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Pull each scheduled person's birthday from the People API (best-effort: a blocked token or a
|
||||||
|
// person with no date on file just yields '' and never fails the sync). Fetched once per unique
|
||||||
|
// person so a member in two positions isn't queried twice.
|
||||||
|
const birthdayByPerson = new Map();
|
||||||
|
for (const member of scheduledMembers) {
|
||||||
|
if (!member.personId || birthdayByPerson.has(member.personId)) continue;
|
||||||
|
try {
|
||||||
|
const { birthday } = await pcoFetchBirthday(member.personId, credentials);
|
||||||
|
birthdayByPerson.set(member.personId, birthday || '');
|
||||||
|
} catch {
|
||||||
|
birthdayByPerson.set(member.personId, '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const oldPcoFiles = new Set(
|
const oldPcoFiles = new Set(
|
||||||
(Array.isArray(baseState.library) ? baseState.library : [])
|
(Array.isArray(baseState.library) ? baseState.library : [])
|
||||||
.filter((entry) => entry?.source === 'pco' && typeof entry?.fileName === 'string')
|
.filter((entry) => entry?.source === 'pco' && typeof entry?.fileName === 'string')
|
||||||
@@ -652,6 +733,7 @@ async function syncUpcomingPcoPlan(payload) {
|
|||||||
source: 'pco',
|
source: 'pco',
|
||||||
pcoPersonId: member.personId,
|
pcoPersonId: member.personId,
|
||||||
pcoPlanPersonId: member.planPersonId,
|
pcoPlanPersonId: member.planPersonId,
|
||||||
|
birthday: birthdayByPerson.get(member.personId) || '',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -731,6 +813,7 @@ async function syncUpcomingPcoPlan(payload) {
|
|||||||
const atWriteTime = await loadStateRaw();
|
const atWriteTime = await loadStateRaw();
|
||||||
nextState.proPresenter = normalizeProPresenterConfig(atWriteTime.proPresenter);
|
nextState.proPresenter = normalizeProPresenterConfig(atWriteTime.proPresenter);
|
||||||
nextState.displayServer = normalizeDisplayServerConfig(atWriteTime.displayServer);
|
nextState.displayServer = normalizeDisplayServerConfig(atWriteTime.displayServer);
|
||||||
|
nextState.birthdayMode = normalizeBirthdayMode(atWriteTime.birthdayMode);
|
||||||
await saveStateRaw(nextState);
|
await saveStateRaw(nextState);
|
||||||
|
|
||||||
for (const fileName of oldPcoFiles) {
|
for (const fileName of oldPcoFiles) {
|
||||||
@@ -1222,6 +1305,7 @@ function migrateState(data) {
|
|||||||
pcoDisplayPositions: normalizePcoDisplayPositions(data.pcoDisplayPositions),
|
pcoDisplayPositions: normalizePcoDisplayPositions(data.pcoDisplayPositions),
|
||||||
proPresenter: normalizeProPresenterConfig(data.proPresenter),
|
proPresenter: normalizeProPresenterConfig(data.proPresenter),
|
||||||
displayServer: normalizeDisplayServerConfig(data.displayServer),
|
displayServer: normalizeDisplayServerConfig(data.displayServer),
|
||||||
|
birthdayMode: normalizeBirthdayMode(data.birthdayMode),
|
||||||
lineup: data.lineup.map((e) => {
|
lineup: data.lineup.map((e) => {
|
||||||
if (!e || typeof e !== 'object') return { personId: '', label: '' };
|
if (!e || typeof e !== 'object') return { personId: '', label: '' };
|
||||||
return {
|
return {
|
||||||
@@ -1432,7 +1516,10 @@ ipcMain.handle('save-state', async (_e, payload) => {
|
|||||||
// The renderer never edits this (it is changed through the display-* IPC handlers), so
|
// The renderer never edits this (it is changed through the display-* IPC handlers), so
|
||||||
// always carry the stored value forward rather than letting a save blank it out.
|
// always carry the stored value forward rather than letting a save blank it out.
|
||||||
const displayServer = normalizeDisplayServerConfig(existing.displayServer);
|
const displayServer = normalizeDisplayServerConfig(existing.displayServer);
|
||||||
await saveStateRaw({ library, layout, parallaxEnabled, slotLabels, slotNames, lineup, pcoServiceTypeId, pcoDisplayPositions, proPresenter, displayServer });
|
const birthdayMode = payload?.birthdayMode
|
||||||
|
? normalizeBirthdayMode(payload.birthdayMode)
|
||||||
|
: normalizeBirthdayMode(existing.birthdayMode);
|
||||||
|
await saveStateRaw({ library, layout, parallaxEnabled, slotLabels, slotNames, lineup, pcoServiceTypeId, pcoDisplayPositions, proPresenter, displayServer, birthdayMode });
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1511,6 +1598,85 @@ ipcMain.handle('pco-sync-upcoming-plan', async (_e, payload) => {
|
|||||||
return syncUpcomingPcoPlan(payload);
|
return syncUpcomingPcoPlan(payload);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Diagnostic behind the "Test Birthdays" button: does this Personal Access Token actually reach the
|
||||||
|
* People API and read birthdates, and how many synced band members have one on file. Read-only.
|
||||||
|
*/
|
||||||
|
ipcMain.handle('pco-test-birthdays', async () => {
|
||||||
|
const credentials = await loadPcoCredentials();
|
||||||
|
if (!credentials) return { connected: false };
|
||||||
|
|
||||||
|
// 1. Can the token read the People API at all?
|
||||||
|
let probe;
|
||||||
|
try {
|
||||||
|
probe = await pcoFetchStatus('/people/v2/people', credentials, { per_page: 1, 'fields[Person]': 'birthdate' });
|
||||||
|
} catch (err) {
|
||||||
|
return { connected: true, canRead: false, reason: 'network', detail: err instanceof Error ? err.message : String(err) };
|
||||||
|
}
|
||||||
|
if (!probe.ok) {
|
||||||
|
const detail =
|
||||||
|
probe.payload?.errors?.map((e) => e.detail || e.title).filter(Boolean).join(' | ') ||
|
||||||
|
`${probe.status}`;
|
||||||
|
return { connected: true, canRead: false, status: probe.status, detail };
|
||||||
|
}
|
||||||
|
const sampleAttrs = probe.payload?.data?.[0]?.attributes || null;
|
||||||
|
const fieldExposed = sampleAttrs ? 'birthdate' in sampleAttrs : true;
|
||||||
|
|
||||||
|
// 2. Coverage across the people we've actually synced (unique by person id).
|
||||||
|
const state = await loadStateRaw();
|
||||||
|
const seen = new Set();
|
||||||
|
const targets = [];
|
||||||
|
for (const p of Array.isArray(state.library) ? state.library : []) {
|
||||||
|
if (p?.source !== 'pco' || !p?.pcoPersonId || seen.has(p.pcoPersonId)) continue;
|
||||||
|
seen.add(p.pcoPersonId);
|
||||||
|
targets.push(p);
|
||||||
|
}
|
||||||
|
|
||||||
|
let withBirthday = 0;
|
||||||
|
let notFound = 0;
|
||||||
|
let errors = 0;
|
||||||
|
const found = [];
|
||||||
|
for (const p of targets) {
|
||||||
|
let r;
|
||||||
|
try {
|
||||||
|
r = await pcoFetchBirthday(p.pcoPersonId, credentials);
|
||||||
|
} catch {
|
||||||
|
errors += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (r.status === 404) { notFound += 1; continue; }
|
||||||
|
if (r.status && r.status >= 400) { errors += 1; continue; }
|
||||||
|
if (r.birthday) {
|
||||||
|
withBirthday += 1;
|
||||||
|
found.push({ name: String(p.lastName || p.originalName || 'Unknown').trim() || 'Unknown', mmdd: r.birthday });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Soonest upcoming birthday, for a concrete "it works" line.
|
||||||
|
const today = new Date();
|
||||||
|
const daysUntil = (mmdd) => {
|
||||||
|
const [mm, dd] = mmdd.split('-').map(Number);
|
||||||
|
let next = new Date(today.getFullYear(), mm - 1, dd);
|
||||||
|
const midnight = new Date(today.getFullYear(), today.getMonth(), today.getDate());
|
||||||
|
if (next < midnight) next = new Date(today.getFullYear() + 1, mm - 1, dd);
|
||||||
|
return Math.round((next - midnight) / 86400000);
|
||||||
|
};
|
||||||
|
const sortedFound = [...found].sort((a, b) => daysUntil(a.mmdd) - daysUntil(b.mmdd));
|
||||||
|
const nextUp = sortedFound[0] ? { ...sortedFound[0], inDays: daysUntil(sortedFound[0].mmdd) } : null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
connected: true,
|
||||||
|
canRead: true,
|
||||||
|
fieldExposed,
|
||||||
|
total: targets.length,
|
||||||
|
withBirthday,
|
||||||
|
notFound,
|
||||||
|
errors,
|
||||||
|
found: sortedFound.slice(0, 8),
|
||||||
|
nextUp,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
ipcMain.handle('pp-status', async () => {
|
ipcMain.handle('pp-status', async () => {
|
||||||
return {
|
return {
|
||||||
enabled: ppConfig.enabled,
|
enabled: ppConfig.enabled,
|
||||||
|
|||||||
Reference in New Issue
Block a user