Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e42e244edc | ||
|
|
f85842d4b3 | ||
|
|
78bf6df40c | ||
|
|
47725a689d | ||
|
|
a9584cdde5 | ||
|
|
316a2b40a1 | ||
|
|
73547a0f8c | ||
|
|
b5821e1c18 |
@@ -7,6 +7,25 @@ and this project follows [Semantic Versioning](https://semver.org/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [2.1.1] - 2026-11-03
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Feedback "Send now" and "Remind" no longer make the page wait** for every email: they work out
|
||||
who they're going to, answer straight away ("Sending the feedback form to 40 people in the
|
||||
background", "Reminding 12 people. It carries on in the background.") and the emails go out in a
|
||||
background task. The Feedback page refreshes itself a few times to show them land.
|
||||
|
||||
### Added
|
||||
|
||||
- **One-off background tasks** (`enqueueTask` in `src/jobs/index.js`, first one `feedback-send` →
|
||||
`runFeedbackTask` in `utils/feedback.js`): queued in pg-boss (retried twice, kept across a
|
||||
restart), or run in-process straight after the response when the queue isn't running. Tasks must
|
||||
be safe to run twice: sending skips people already invited, and a reminder is claimed with the
|
||||
24-hour rule, so two tasks at once never remind the same person twice.
|
||||
- `POST /api/v2/events/{id}/feedback/remind` answers with `queued`; its counts are what's being
|
||||
sent. `send-now`'s `invited` is how many it's sending to.
|
||||
|
||||
## [2.1.0] - 2026-11-03
|
||||
|
||||
### Added
|
||||
|
||||
@@ -12,6 +12,12 @@ Write for church admins, not developers: no environment variables, endpoints or
|
||||
developer log is CHANGELOG.md. A tagged version without a section here shows "No details given".
|
||||
-->
|
||||
|
||||
## 2.1.1
|
||||
|
||||
- **Sending feedback forms and reminders no longer makes you wait.** "Send now" and "Remind" on the
|
||||
Feedback page now answer straight away and the emails go out in the background, so you can carry
|
||||
on working. The page updates itself as they go out.
|
||||
|
||||
## 2.1.0
|
||||
|
||||
- **A Feedback page for supervisors and admins** (Dashboard → Feedback). See every event's feedback
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "event-management-backend",
|
||||
"version": "2.1.0",
|
||||
"version": "2.1.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "event-management-backend",
|
||||
"version": "2.1.0",
|
||||
"version": "2.1.1",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@cj-tech-master/excelts": "^10.2.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "event-management-backend",
|
||||
"version": "2.1.0",
|
||||
"version": "2.1.1",
|
||||
"description": "Event Management System Backend",
|
||||
"main": "src/index.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
const prisma = require('../config/db');
|
||||
const { safeErrorMessage } = require('../utils/errorUtils');
|
||||
const { ANSWERABLE, feedbackError, cleanFields, cleanAnswer, summarise, sendFeedbackInvites, findInviteByToken } = require('../utils/feedback');
|
||||
const { ANSWERABLE, feedbackError, cleanFields, cleanAnswer, summarise, feedbackRecipients, findInviteByToken } = require('../utils/feedback');
|
||||
|
||||
const fail = (res, error) => res.status(error.statusCode || (res.statusCode === 200 ? 400 : res.statusCode)).json({ message: safeErrorMessage(error) });
|
||||
const shape = (fields) => JSON.stringify(fields.map(f => [f.type, f.label, f.isRequired, f.options || null, f.helpText || null]));
|
||||
@@ -86,9 +86,12 @@ const sendNow = async (req, res) => {
|
||||
const form = await prisma.feedbackForm.findUnique({ where: { eventId: req.params.eventId } });
|
||||
if (!form) throw feedbackError('This event has no feedback form.', 404);
|
||||
if (!form.sentAt) await prisma.feedbackForm.update({ where: { id: form.id }, data: { sentAt: new Date(), sendAt: form.sendAt || new Date() } });
|
||||
// Also invites anyone who checked in after the first send.
|
||||
const invited = await sendFeedbackInvites(form.id);
|
||||
res.json({ invited, message: invited ? `Feedback form sent to ${invited} ${invited === 1 ? 'person' : 'people'}.` : 'Everyone has already been sent the form.' });
|
||||
// Also invites anyone who checked in after the first send. The emails go out in the
|
||||
// background (2.1.1), so this answers straight away with how many it's going to.
|
||||
const already = new Set((await prisma.feedbackInvite.findMany({ where: { formId: form.id }, select: { userId: true } })).map(i => i.userId));
|
||||
const invited = (await feedbackRecipients(form)).filter(u => !already.has(u.id)).length;
|
||||
if (invited) await require('../jobs').enqueueTask('feedback-send', { formId: form.id, kind: 'send' });
|
||||
res.json({ invited, message: invited ? `Sending the feedback form to ${invited} ${invited === 1 ? 'person' : 'people'} in the background.` : 'Everyone has already been sent the form.' });
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
|
||||
@@ -139,6 +139,38 @@ function isNoop(value) {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* One-off background tasks (2.1.1): work a request starts but shouldn't wait for, like sending
|
||||
* a feedback form to everyone. Queued in pg-boss (retried if it fails, kept across a restart);
|
||||
* without the queue they run in this process straight after the response. A task must be safe
|
||||
* to run twice.
|
||||
*/
|
||||
const TASKS = {
|
||||
'feedback-send': (data) => require('../utils/feedback').runFeedbackTask(data),
|
||||
};
|
||||
|
||||
async function runTask(name, data) {
|
||||
const started = Date.now();
|
||||
const result = await TASKS[name](data || {});
|
||||
if (!isNoop(result)) console.log(`[jobs] task ${name} done in ${Date.now() - started} ms: ${summarize(result)}`);
|
||||
return result ?? null;
|
||||
}
|
||||
|
||||
/** Starts a task in the background. Resolves once it's queued (or started), not when it's done. */
|
||||
async function enqueueTask(name, data) {
|
||||
if (!TASKS[name]) throw new Error(`Unknown task ${name}`);
|
||||
if (mode === 'queue' && boss) {
|
||||
try {
|
||||
await boss.send(name, data || {}, { retryLimit: 2, retryDelay: 30, expireInSeconds: 60 * 60 });
|
||||
return { queued: true };
|
||||
} catch (e) {
|
||||
console.warn(`[jobs] could not queue ${name}, running it here:`, e?.message || e);
|
||||
}
|
||||
}
|
||||
setImmediate(() => runTask(name, data).catch(e => console.warn(`[jobs] task ${name} failed:`, e?.message || e)));
|
||||
return { queued: false };
|
||||
}
|
||||
|
||||
async function runJob(job) {
|
||||
const started = Date.now();
|
||||
const result = await job.run();
|
||||
@@ -218,6 +250,23 @@ async function startJobs() {
|
||||
await boss.unschedule(job.name).catch(() => {});
|
||||
}
|
||||
}
|
||||
// One-off tasks: any number can wait and run (unlike the stately scheduled jobs).
|
||||
for (const name of Object.keys(TASKS)) {
|
||||
if (!existing.has(name)) await boss.createQueue(name, { policy: 'standard', retryLimit: 2, retryDelay: 30, expireInSeconds: 60 * 60 });
|
||||
await boss.work(name, async (batch) => {
|
||||
const list = Array.isArray(batch) ? batch : [batch];
|
||||
const out = [];
|
||||
for (const job of list) {
|
||||
try {
|
||||
out.push(await runTask(name, job?.data));
|
||||
} catch (e) {
|
||||
console.warn(`[jobs] task ${name} failed:`, e?.message || e);
|
||||
throw e; // recorded as failed, and retried
|
||||
}
|
||||
}
|
||||
return out.length === 1 ? out[0] : out;
|
||||
});
|
||||
}
|
||||
mode = 'queue';
|
||||
const off = JOBS.filter(j => !isEnabled(j)).map(j => j.name);
|
||||
console.log(`[jobs] Job queue started: ${enabled.map(j => j.name).join(', ')}${off.length ? ` (off: ${off.join(', ')})` : ''}.`);
|
||||
@@ -298,4 +347,4 @@ async function jobStatus(prisma) {
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { JOBS, TIMEZONE, isEnabled, cronFor, nextRun, isNoop, startJobs, stopJobs, runNow, jobStatus };
|
||||
module.exports = { JOBS, TASKS, TIMEZONE, isEnabled, cronFor, nextRun, isNoop, startJobs, stopJobs, runNow, jobStatus, enqueueTask };
|
||||
|
||||
@@ -19,6 +19,7 @@ const fb = require('../utils/feedback') as {
|
||||
sendFeedbackInvites(formId: string): Promise<number>;
|
||||
sendFeedbackReminders(formId: string, opts?: { userIds?: string[] | null; minGapMs?: number | null; now?: Date }): Promise<number>;
|
||||
};
|
||||
const jobs = require('../jobs') as { enqueueTask(name: string, data: object): Promise<{ queued: boolean }> };
|
||||
|
||||
export type FormStatus = 'sent' | 'scheduled' | 'not_scheduled';
|
||||
const statusOf = (form: { sentAt: Date | null; sendAt: Date | null }): FormStatus => (form.sentAt ? 'sent' : form.sendAt ? 'scheduled' : 'not_scheduled');
|
||||
@@ -148,23 +149,34 @@ export async function eventFeedback(eventId: string, now = new Date()) {
|
||||
* "Remind": a fresh link to everyone who hasn't answered (or the people in `userIds`, named
|
||||
* forms only), skipping anyone sent the form or a reminder in the last 24 hours. Without `userIds` it also sends
|
||||
* the form to people who became eligible after it went out (e.g. checked in later).
|
||||
*
|
||||
* 2.1.1: the emails go out in the background (jobs task `feedback-send`); this works out who
|
||||
* they'll go to and answers straight away. The counts are what's being sent, not what was.
|
||||
*/
|
||||
export async function remindFeedback(eventId: string, { userIds }: { userIds?: string[] | null } = {}) {
|
||||
export async function remindFeedback(eventId: string, { userIds }: { userIds?: string[] | null } = {}, now = new Date()) {
|
||||
const form = await formFor(eventId);
|
||||
if (!form.sentAt) fail(409, 'The feedback form hasn\'t gone out yet. Use "Send now" to send it.', 'FEEDBACK_NOT_SENT');
|
||||
const only = userIds && userIds.length ? [...new Set(userIds)] : null;
|
||||
if (only && form.anonymous) fail(400, 'This form is anonymous, so people can\'t be reminded one by one. Remind everyone who hasn\'t answered instead.', 'FEEDBACK_ANONYMOUS');
|
||||
const newlyInvited = only ? 0 : await fb.sendFeedbackInvites(form.id);
|
||||
const reminded = await fb.sendFeedbackReminders(form.id, { userIds: only, minGapMs: fb.REMIND_GAP_MS });
|
||||
const stillWaiting = await prisma.feedbackInvite.count({ where: { formId: form.id, respondedAt: null, ...(only ? { userId: { in: only } } : {}) } });
|
||||
const skipped = Math.max(0, stillWaiting - reminded - newlyInvited);
|
||||
const since = new Date(now.getTime() - fb.REMIND_GAP_MS);
|
||||
const mine = { formId: form.id, respondedAt: null, ...(only ? { userId: { in: only } } : {}) };
|
||||
const [notAnswered, reminded, invitedIds] = await Promise.all([
|
||||
prisma.feedbackInvite.count({ where: mine }),
|
||||
// The same rule as sendFeedbackReminders with minGapMs.
|
||||
prisma.feedbackInvite.count({ where: { ...mine, sentAt: { lt: since }, OR: [{ remindedAt: null }, { remindedAt: { lt: since } }] } }),
|
||||
only ? Promise.resolve(null) : prisma.feedbackInvite.findMany({ where: { formId: form.id }, select: { userId: true } }),
|
||||
]);
|
||||
const newlyInvited = invitedIds ? await waitingCount(form, new Set(invitedIds.map((i: any) => i.userId))) : 0;
|
||||
const skipped = Math.max(0, notAnswered - reminded);
|
||||
if (reminded || newlyInvited) await jobs.enqueueTask('feedback-send', { formId: form.id, kind: 'remind', userIds: only });
|
||||
|
||||
const plural = (n: number, one: string, many: string) => `${n} ${n === 1 ? one : many}`;
|
||||
const parts: string[] = [];
|
||||
if (reminded) parts.push(`Reminded ${plural(reminded, 'person', 'people')}`);
|
||||
if (newlyInvited) parts.push(`sent the form to ${plural(newlyInvited, 'person', 'people')} for the first time`);
|
||||
let message = parts.length ? `${parts.join(' and ')}.` : 'Nobody to remind.';
|
||||
if (reminded) parts.push(`Reminding ${plural(reminded, 'person', 'people')}`);
|
||||
if (newlyInvited) parts.push(`sending the form to ${plural(newlyInvited, 'person', 'people')} for the first time`);
|
||||
let message = parts.length ? `${parts.join(' and ')}. It carries on in the background.` : 'Nobody to remind.';
|
||||
if (!parts.length && skipped) message = 'Everyone who hasn\'t answered was sent the form or a reminder in the last 24 hours. Try again tomorrow.';
|
||||
else if (skipped) message += ` ${plural(skipped, 'person was', 'people were')} sent the form or a reminder in the last 24 hours, so not again yet.`;
|
||||
else if (parts.length && skipped) message += ` ${plural(skipped, 'person was', 'people were')} sent the form or a reminder in the last 24 hours, so not again yet.`;
|
||||
message = message.charAt(0).toUpperCase() + message.slice(1);
|
||||
return { reminded, newlyInvited, skipped, message, title: form.event.title };
|
||||
return { reminded, newlyInvited, skipped, queued: !!(reminded || newlyInvited), message, title: form.event.title };
|
||||
}
|
||||
|
||||
@@ -206,7 +206,8 @@ async function sendFeedbackReminders(formId, { userIds = null, minGapMs = null,
|
||||
const token = newToken();
|
||||
// Only if they still haven't answered (they may have while this ran).
|
||||
const claimed = await prisma.feedbackInvite.updateMany({
|
||||
where: { id: inv.id, respondedAt: null },
|
||||
// The same rule again, so two tasks running at once never remind the same person twice.
|
||||
where: { id: inv.id, respondedAt: null, ...recent },
|
||||
data: { tokenHash: hashToken(token), remindedAt: now, reminderCount: { increment: 1 } },
|
||||
});
|
||||
if (claimed.count !== 1) continue;
|
||||
@@ -216,6 +217,21 @@ async function sendFeedbackReminders(formId, { userIds = null, minGapMs = null,
|
||||
return sent;
|
||||
}
|
||||
|
||||
/**
|
||||
* The background task behind "Send now" and "Remind" (jobs `feedback-send`), so the page doesn't
|
||||
* wait for every email. Safe to run twice: people already invited are skipped, and nobody is
|
||||
* reminded twice within REMIND_GAP_MS.
|
||||
* { formId, kind: 'send' } the form to everyone eligible not yet invited
|
||||
* { formId, kind: 'remind', userIds? } (everyone: also invite the newly eligible) then remind
|
||||
*/
|
||||
async function runFeedbackTask({ formId, kind, userIds = null } = {}) {
|
||||
if (!formId) return { invited: 0, reminded: 0 };
|
||||
const only = Array.isArray(userIds) && userIds.length ? userIds : null;
|
||||
const invited = kind === 'send' || (kind === 'remind' && !only) ? await sendFeedbackInvites(formId) : 0;
|
||||
const reminded = kind === 'remind' ? await sendFeedbackReminders(formId, { userIds: only, minGapMs: REMIND_GAP_MS }) : 0;
|
||||
return { invited, reminded };
|
||||
}
|
||||
|
||||
/** Background job: send forms whose time has come, and reminders 3 days after. */
|
||||
async function runFeedbackJob(now = new Date()) {
|
||||
let sent = 0, reminded = 0;
|
||||
@@ -244,5 +260,5 @@ async function findInviteByToken(token) {
|
||||
module.exports = {
|
||||
ANSWERABLE, ALL_TYPES, REMINDER_AFTER_MS, REMIND_GAP_MS, feedbackError, hashToken, feedbackLink,
|
||||
cleanFields, cleanAnswer, summarise,
|
||||
feedbackRecipients, sendFeedbackInvites, sendFeedbackReminders, runFeedbackJob, findInviteByToken,
|
||||
feedbackRecipients, sendFeedbackInvites, sendFeedbackReminders, runFeedbackTask, runFeedbackJob, findInviteByToken,
|
||||
};
|
||||
|
||||
@@ -74,10 +74,10 @@ export const results = defineRoute({
|
||||
|
||||
export const remind = defineRoute({
|
||||
method: 'post', path: '/events/:id/feedback/remind', tag: 'Feedback', summary: 'Remind people who haven\'t answered',
|
||||
description: 'A fresh link to everyone who hasn\'t answered, or to `userIds` (named forms only). Nobody is sent the form or a reminder twice within 24 hours. Without `userIds` it also sends the form to anyone who has become eligible since it went out.',
|
||||
description: 'A fresh link to everyone who hasn\'t answered, or to `userIds` (named forms only). Nobody is sent the form or a reminder twice within 24 hours. Without `userIds` it also sends the form to anyone who has become eligible since it went out. Answers straight away; the emails go out in the background.',
|
||||
access: 'supervisor', campusScope: 'scopeEvent(manage)', middleware: [cs.scopeEvent({ need: 'manage' })], params: idParams,
|
||||
body: z.object({ userIds: z.array(id).max(1000).nullish().describe('Only these people (named forms)') }),
|
||||
response: single(z.object({ reminded: z.number().int(), newlyInvited: z.number().int(), skipped: z.number().int(), message: z.string() })),
|
||||
response: single(z.object({ reminded: z.number().int().describe('Being reminded'), newlyInvited: z.number().int().describe('Being sent the form for the first time'), skipped: z.number().int(), queued: z.boolean().describe('Emails are going out in the background'), message: z.string() })),
|
||||
async handler({ params, body }) {
|
||||
const { title: _title, ...data } = await feedback.remindFeedback(params.id, { userIds: body.userIds });
|
||||
return { data };
|
||||
|
||||
@@ -361,6 +361,6 @@ v1Route({
|
||||
access: 'supervisor', campusScope: 'scopeEvent(manage)', middleware: [cs.scopeEvent({ need: 'manage' })], params: idParams, map: asEvent, documented: { response: D.message }, v1: feedback.deleteEventFeedback,
|
||||
});
|
||||
v1Route({
|
||||
method: 'post', path: '/events/:id/feedback/send-now', tag: 'Feedback', summary: 'Send the feedback form now',
|
||||
method: 'post', path: '/events/:id/feedback/send-now', tag: 'Feedback', summary: 'Send the feedback form now', description: 'Answers straight away: `invited` is how many it is going to; the emails go out in the background.',
|
||||
access: 'supervisor', campusScope: 'scopeEvent(manage)', middleware: [cs.scopeEvent({ need: 'manage' })], params: idParams, body: z.looseObject({}), map: asEvent, documented: { response: D.obj({ invited: D.num, message: D.str }) }, v1: feedback.sendNow,
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* 2.1.0: the Feedback page (services/feedback.ts) — results per event, anonymous forms that stay
|
||||
* anonymous, and "Remind" with its 24-hour rule (utils/feedback.js sendFeedbackReminders).
|
||||
* anonymous, and "Remind" with its 24-hour rule (utils/feedback.js sendFeedbackReminders). 2.1.1:
|
||||
* "Remind" answers straight away and the emails go out in a background task (runFeedbackTask).
|
||||
*/
|
||||
const mockDb: any = {
|
||||
feedbackForm: { findUnique: jest.fn(), findMany: jest.fn() },
|
||||
@@ -15,10 +16,12 @@ const mockMail = jest.fn(async () => undefined);
|
||||
jest.mock('../src/utils/email', () => ({ sendMail: (...a: unknown[]) => (mockMail as jest.Mock)(...a), emailWrapper: (h: string) => h, ctaButton: () => '', fallbackLink: () => '' }));
|
||||
jest.mock('../src/utils/notify', () => ({ waTextAny: jest.fn(async () => undefined) }));
|
||||
jest.mock('../src/utils/whatsapp', () => ({ isValidZAPhone: () => false }));
|
||||
const mockEnqueue = jest.fn(async () => ({ queued: true }));
|
||||
jest.mock('../src/jobs', () => ({ enqueueTask: (...a: unknown[]) => (mockEnqueue as jest.Mock)(...a) }));
|
||||
|
||||
import * as feedback from '../src/services/feedback';
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const { sendFeedbackReminders } = require('../src/utils/feedback');
|
||||
const { sendFeedbackReminders, runFeedbackTask } = require('../src/utils/feedback');
|
||||
|
||||
const NOW = new Date('2026-11-10T12:00:00Z');
|
||||
const hoursAgo = (h: number) => new Date(NOW.getTime() - h * 3600e3);
|
||||
@@ -110,45 +113,80 @@ describe('Remind', () => {
|
||||
await expect(feedback.remindFeedback('ev1', { userIds: ['u4'] })).rejects.toMatchObject({ statusCode: 400 });
|
||||
});
|
||||
|
||||
test('reminds those not reminded or sent the form in the last 24 hours, and says who was skipped', async () => {
|
||||
mockDb.feedbackForm.findUnique.mockResolvedValue({ ...form(), invites: [{ userId: 'u3' }, { userId: 'u4' }, { userId: 'u5' }] });
|
||||
mockDb.feedbackInvite.findMany.mockResolvedValue([{ id: 'i4', userId: 'u4', user: person('u4', 'Dumi') }]);
|
||||
mockDb.feedbackInvite.count.mockResolvedValue(3);
|
||||
const out = await feedback.remindFeedback('ev1');
|
||||
expect(out).toMatchObject({ reminded: 1, newlyInvited: 0, skipped: 2 });
|
||||
expect(out.message).toBe('Reminded 1 person. 2 people were sent the form or a reminder in the last 24 hours, so not again yet.');
|
||||
const where = mockDb.feedbackInvite.findMany.mock.calls[0][0].where;
|
||||
// count() is called for: everyone not answered, then those remindable now (the 24-hour rule).
|
||||
const counts = (notAnswered: number, remindable: number) => mockDb.feedbackInvite.count.mockResolvedValueOnce(notAnswered).mockResolvedValueOnce(remindable);
|
||||
|
||||
test('answers straight away with who it will remind; the emails go in the background', async () => {
|
||||
mockDb.feedbackForm.findUnique.mockResolvedValue(form());
|
||||
counts(3, 1);
|
||||
mockDb.feedbackInvite.findMany.mockResolvedValue([{ userId: 'u3' }, { userId: 'u4' }, { userId: 'u5' }]);
|
||||
const out = await feedback.remindFeedback('ev1', {}, NOW);
|
||||
expect(out).toMatchObject({ reminded: 1, newlyInvited: 0, skipped: 2, queued: true });
|
||||
expect(out.message).toBe('Reminding 1 person. It carries on in the background. 2 people were sent the form or a reminder in the last 24 hours, so not again yet.');
|
||||
const where = mockDb.feedbackInvite.count.mock.calls[1][0].where;
|
||||
expect(where).toMatchObject({ formId: 'form1', respondedAt: null });
|
||||
expect(where.sentAt.lt).toBeInstanceOf(Date);
|
||||
expect(where.sentAt.lt).toEqual(new Date(NOW.getTime() - 24 * 3600e3));
|
||||
expect(where.OR).toHaveLength(2);
|
||||
expect(mockDb.feedbackInvite.updateMany.mock.calls[0][0].data.reminderCount).toEqual({ increment: 1 });
|
||||
expect(mockMail).toHaveBeenCalledWith(expect.objectContaining({ to: 'u4@example.com', subject: 'Reminder: How was Men\'s Breakfast?' }));
|
||||
expect(mockEnqueue).toHaveBeenCalledWith('feedback-send', { formId: 'form1', kind: 'remind', userIds: null });
|
||||
expect(mockMail).not.toHaveBeenCalled(); // not in the request
|
||||
});
|
||||
|
||||
test('people who can have the form but haven\'t yet are counted too', async () => {
|
||||
mockDb.feedbackForm.findUnique.mockResolvedValue(form());
|
||||
counts(1, 0);
|
||||
mockDb.feedbackInvite.findMany.mockResolvedValue([{ userId: 'u1' }]);
|
||||
mockDb.ticket.findMany.mockResolvedValue([{ userId: 'u1' }, { userId: 'u9' }]);
|
||||
mockDb.user.findMany.mockResolvedValue([{ id: 'u1' }, { id: 'u9' }]);
|
||||
const out = await feedback.remindFeedback('ev1', {}, NOW);
|
||||
expect(out).toMatchObject({ reminded: 0, newlyInvited: 1, queued: true });
|
||||
expect(out.message).toBe('Sending the form to 1 person for the first time. It carries on in the background. 1 person was sent the form or a reminder in the last 24 hours, so not again yet.');
|
||||
});
|
||||
|
||||
test('one person on a named form', async () => {
|
||||
mockDb.feedbackForm.findUnique.mockResolvedValue(form());
|
||||
mockDb.feedbackInvite.findMany.mockResolvedValue([{ id: 'i4', userId: 'u4', user: person('u4', 'Dumi') }]);
|
||||
mockDb.feedbackInvite.count.mockResolvedValue(1);
|
||||
const out = await feedback.remindFeedback('ev1', { userIds: ['u4'] });
|
||||
expect(out.message).toBe('Reminded 1 person.');
|
||||
expect(mockDb.feedbackInvite.findMany.mock.calls[0][0].where.userId).toEqual({ in: ['u4'] });
|
||||
expect(mockDb.feedbackInvite.create).not.toHaveBeenCalled(); // no new invites when reminding one person
|
||||
counts(1, 1);
|
||||
const out = await feedback.remindFeedback('ev1', { userIds: ['u4'] }, NOW);
|
||||
expect(out.message).toBe('Reminding 1 person. It carries on in the background.');
|
||||
expect(mockDb.feedbackInvite.count.mock.calls[0][0].where.userId).toEqual({ in: ['u4'] });
|
||||
expect(mockEnqueue).toHaveBeenCalledWith('feedback-send', { formId: 'form1', kind: 'remind', userIds: ['u4'] });
|
||||
});
|
||||
|
||||
test('everyone already reminded today', async () => {
|
||||
mockDb.feedbackForm.findUnique.mockResolvedValue({ ...form(), invites: [] });
|
||||
test('everyone already reminded today: nothing queued', async () => {
|
||||
mockDb.feedbackForm.findUnique.mockResolvedValue(form());
|
||||
counts(2, 0);
|
||||
mockDb.feedbackInvite.findMany.mockResolvedValue([]);
|
||||
mockDb.feedbackInvite.count.mockResolvedValue(2);
|
||||
const out = await feedback.remindFeedback('ev1');
|
||||
const out = await feedback.remindFeedback('ev1', {}, NOW);
|
||||
expect(out.message).toBe('Everyone who hasn\'t answered was sent the form or a reminder in the last 24 hours. Try again tomorrow.');
|
||||
expect(mockEnqueue).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('the background task', () => {
|
||||
test('remind: invites the newly eligible, then reminds with the 24-hour rule', async () => {
|
||||
mockDb.feedbackForm.findUnique.mockResolvedValue(form());
|
||||
mockDb.feedbackInvite.findMany.mockResolvedValue([{ id: 'i4', userId: 'u4', user: person('u4', 'Dumi') }]);
|
||||
expect(await runFeedbackTask({ formId: 'form1', kind: 'remind' })).toEqual({ invited: 0, reminded: 1 });
|
||||
const where = mockDb.feedbackInvite.findMany.mock.calls[0][0].where;
|
||||
expect(where.sentAt.lt).toBeInstanceOf(Date);
|
||||
expect(mockDb.feedbackInvite.updateMany.mock.calls[0][0].data.reminderCount).toEqual({ increment: 1 });
|
||||
// Claimed with the same rule, so a second task running at once skips them.
|
||||
expect(mockDb.feedbackInvite.updateMany.mock.calls[0][0].where).toMatchObject({ id: 'i4', respondedAt: null, sentAt: expect.any(Object), OR: expect.any(Array) });
|
||||
expect(mockMail).toHaveBeenCalledWith(expect.objectContaining({ to: 'u4@example.com', subject: 'Reminder: How was Men\'s Breakfast?' }));
|
||||
});
|
||||
|
||||
test('remind one person: no new invites', async () => {
|
||||
mockDb.feedbackForm.findUnique.mockResolvedValue(form());
|
||||
mockDb.feedbackInvite.findMany.mockResolvedValue([{ id: 'i4', userId: 'u4', user: person('u4', 'Dumi') }]);
|
||||
await runFeedbackTask({ formId: 'form1', kind: 'remind', userIds: ['u4'] });
|
||||
expect(mockDb.feedbackInvite.findMany.mock.calls[0][0].where.userId).toEqual({ in: ['u4'] });
|
||||
expect(mockDb.feedbackInvite.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('someone who answers while reminders go out isn\'t reminded', async () => {
|
||||
mockDb.feedbackForm.findUnique.mockResolvedValue({ ...form(), invites: [] });
|
||||
mockDb.feedbackForm.findUnique.mockResolvedValue(form());
|
||||
mockDb.feedbackInvite.findMany.mockResolvedValue([{ id: 'i4', userId: 'u4', user: person('u4', 'Dumi') }]);
|
||||
mockDb.feedbackInvite.updateMany.mockResolvedValue({ count: 0 });
|
||||
mockDb.feedbackInvite.count.mockResolvedValue(0);
|
||||
expect((await feedback.remindFeedback('ev1')).reminded).toBe(0);
|
||||
expect((await runFeedbackTask({ formId: 'form1', kind: 'remind', userIds: ['u4'] })).reminded).toBe(0);
|
||||
expect(mockMail).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* 2.1.1: one-off background tasks (jobs/index.js enqueueTask). Without the queue (timers or jobs
|
||||
* off) a task runs in this process right after the caller gets its answer.
|
||||
*/
|
||||
let finish;
|
||||
const mockTask = jest.fn(() => new Promise(resolve => { finish = resolve; }));
|
||||
jest.mock('../src/utils/feedback', () => ({ runFeedbackTask: (...a) => mockTask(...a) }));
|
||||
|
||||
const jobs = require('../src/jobs');
|
||||
|
||||
describe('enqueueTask without the queue', () => {
|
||||
test('answers before the task has run, then runs it', async () => {
|
||||
const answer = await jobs.enqueueTask('feedback-send', { formId: 'f1', kind: 'send' });
|
||||
expect(answer).toEqual({ queued: false });
|
||||
expect(mockTask).not.toHaveBeenCalled(); // not yet: after the response
|
||||
await new Promise(r => setImmediate(r));
|
||||
expect(mockTask).toHaveBeenCalledWith({ formId: 'f1', kind: 'send' });
|
||||
finish({ invited: 3 });
|
||||
});
|
||||
|
||||
test('a failing task is logged, not thrown at the caller', async () => {
|
||||
const warn = jest.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
mockTask.mockImplementationOnce(() => Promise.reject(new Error('SMTP down')));
|
||||
await expect(jobs.enqueueTask('feedback-send', { formId: 'f1', kind: 'send' })).resolves.toEqual({ queued: false });
|
||||
await new Promise(r => setImmediate(r));
|
||||
await new Promise(r => setImmediate(r));
|
||||
expect(warn).toHaveBeenCalledWith('[jobs] task feedback-send failed:', 'SMTP down');
|
||||
warn.mockRestore();
|
||||
});
|
||||
|
||||
test('an unknown task is refused', async () => {
|
||||
await expect(jobs.enqueueTask('nope', {})).rejects.toThrow('Unknown task nope');
|
||||
});
|
||||
});
|
||||
+23
-18
@@ -1,8 +1,9 @@
|
||||
# Handover — where things stand and how to carry on
|
||||
|
||||
Last updated: 2026-11-03, after **2.1.0** and Hub **1.6.2**. The plan itself is in
|
||||
[ROADMAP-v2.md](./ROADMAP-v2.md) (decisions table at the end); the previous round of
|
||||
features is in [FEATURES-ROUND-1.md](./FEATURES-ROUND-1.md).
|
||||
Last updated: 2026-11-03, after **2.1.1** (deployed) and Hub **1.7.0**. **The plan from here is
|
||||
[ROADMAP-v3.md](./ROADMAP-v3.md)** (Accounts, Church Website, Church App, Cross Code Hub; decisions
|
||||
table near the end). How Cross Events got to 2.x is in [ROADMAP-v2.md](./ROADMAP-v2.md); the round
|
||||
before that in [FEATURES-ROUND-1.md](./FEATURES-ROUND-1.md).
|
||||
|
||||
The product is called **Cross Events** (renamed from Hope Events in 1.41.3); the Hub is the
|
||||
**Cross Events Hub**. Technical names keep `hope-events` on purpose — the repo, browser storage
|
||||
@@ -17,8 +18,9 @@ encrypted settings unreadable or break existing installs.
|
||||
|
||||
| Version | What |
|
||||
|---------|------|
|
||||
| 2.1.1 | **Feedback sends in the background:** "Send now" and "Remind" answer straight away and queue the `feedback-send` task (`enqueueTask` in `src/jobs/index.js` — pg-boss, or in-process when the queue is off; tasks must be safe to run twice). Tests `tests/tasks.test.js`, `tests/feedbackPage.test.ts` |
|
||||
| **2.1.0** | **Feedback page** (Supervisor → Feedback): overview, per-event results / responses / people, **Remind** (all who haven't answered, or one person on a named form; 24-hour gap) — `src/services/feedback.ts`, `src/v2/resources/feedback.ts`, `frontend/src/app/dashboard/supervisor/feedback/page.tsx`; reminders share `sendFeedbackReminders` (utils/feedback.js) with the 3-day job; anonymous forms show no names or who answered; access log `feedback_responses`. Migration `20261103120000_feedback_reminders` (`FeedbackInvite.reminderCount`). Tests `tests/feedbackPage.test.ts`, `e2e/feedback.spec.ts` (the e2e seed puts a named form on the past event). AI on the privacy pages dropped from the roadmap |
|
||||
| 2.0.2 | **Billing bands:** heartbeat income bands widened to `over R10 000 000` (`BAND_TOPS` in `src/hub/report.js`, every licence-tier edge is a band edge — test in `tests/hub.test.js`), new `income.eventIncome12m` (12 months, no donations/sponsorships/legs) that the tier is billed on, donation legs no longer double-counted. Tiers and prices live in `crosscode-site/src/data/pricing.mjs` and the Hub's `frontend/src/lib/tiers.ts` (Hub 1.6.2) |
|
||||
| 2.0.2 | **Billing bands** (Hub 1.7.0 bills on them: invoices, receipts, statements, licence-change notices — events-hub README "Billing"): heartbeat income bands widened to `over R10 000 000` (`BAND_TOPS` in `src/hub/report.js`, every licence-tier edge is a band edge — test in `tests/hub.test.js`), new `income.eventIncome12m` (12 months, no donations/sponsorships/legs) that the tier is billed on, donation legs no longer double-counted. Tiers and prices live in `crosscode-site/src/data/pricing.mjs` and the Hub's `frontend/src/lib/tiers.ts` (Hub 1.6.2) |
|
||||
| 2.0.1 | **Summarise this report on every report:** the eight reports that list people are sent as anonymised totals built on the screen (`frontend/src/components/reports/aiReportData.ts`; attendees in `buildAiPayload` in `ReportsV2.tsx`); the server refuses personal columns on those (`personalColumns` in `backend/src/ai/helpers.ts`, 403 `AI_REPORT_HAS_PEOPLE`) and logs each (`ai_report`) |
|
||||
| **2.0.0** | **AI helpers** (draft an event, draft a message, summarise a report or feedback, improve text — `backend/src/ai/`, through the Hub with a rand cap per licence; eval set `npm run ai:eval`); **API v1 removed** (410 `API_V1_REMOVED`; provider webhooks and links in sent messages still answered — [v1 removed](#v1-removed-20)); **Argon2id passwords** with re-hashing at sign-in ([Passwords](#passwords-20)); `node-fetch` removed; React Compiler lint rules back to errors (`lib/hooks.ts`, see [Next.js 16](#nextjs-16-1515)); campus-scope route test on v2 (`tests/v2CampusScope.test.ts`); migration guide `MIGRATING-TO-2.md`. Migration `20261030120000_ai_helpers`. Needs Hub 1.6.0+ for AI |
|
||||
| 1.52.3 | **Urgent fix:** licence read-only mode blocks the `/api/v2` routes too (`src/hub/readOnly.js` only listed v1 paths, and the screens have used v2 since 1.51.15). A test checks each blocked v2 path is a real route |
|
||||
@@ -87,24 +89,27 @@ encrypted settings unreadable or break existing installs.
|
||||
|
||||
## Next up
|
||||
|
||||
1. **After 2.0.x** (order decided 2026-11-02, roadmap §18: 2.0.1 → the marketing site → 2.1.0):
|
||||
- **Deploy:** `bash scripts/deploy.sh --check`, then `bash scripts/deploy.sh v2.0.1` on each server
|
||||
(installs on 1.52.x ask through Request update as usual). Before updating an install that has
|
||||
its own integration, look in the nginx access log for `/api/` calls that aren't `/api/v2/`,
|
||||
the webhooks or the old links — those get 410 after the update (MIGRATING-TO-2.md).
|
||||
1. **Roadmap v3** ([ROADMAP-v3.md](./ROADMAP-v3.md), decided 2026-11-03). Everything in
|
||||
ROADMAP-v2 is done: 2.1.1 is deployed, and the marketing site (`../crosscode-site`,
|
||||
crosscode.co.za, with prices) is live. Build order (§9):
|
||||
- **0 — contracts 1.0.0:** `crosscode.event/1`, `crosscode.church/1`, `crosscode.identity/1`,
|
||||
Hub protocol v2 (today's is v1), in the new `joshua/crosscode-platform` monorepo (`packages/contracts`).
|
||||
- **1 — Hub 2.0.0** (`../events-hub`): Cross Code Hub — products, one licence per product,
|
||||
**one install of each product per customer**, domain per customer, bundles, sibling discovery.
|
||||
- **1b — Cross Events 2.2.0:** **authenticator app 2FA** (TOTP + recovery codes) next to email
|
||||
and WhatsApp (`TwoFactorMethod` enum in `prisma/schema.prisma`).
|
||||
- **2 — Cross Events 2.3.0:** protocol v2, Linked products, the event feed (publish and show).
|
||||
- **3 — Accounts 1.0.0**, **4 — Church Website 1.0.0**, **5 — Cross Events 3.0.0** (sign-in
|
||||
moves to Accounts), **6 — Church App 1.0.0** (House of Prayer).
|
||||
- **Before selling the Website or App:** put the prices decided in ROADMAP-v3 §10 in
|
||||
`crosscode-site/src/data/pricing.mjs` and the Hub's `frontend/src/lib/tiers.ts`.
|
||||
- Servers stay **PM2 + nginx** (no Docker); one database per product per church.
|
||||
- **AI for a church:** set the licence's monthly AI cap above R0 on the Hub (Hub 1.6.0+); with
|
||||
no cap the buttons don't show.
|
||||
- **Marketing site — next, and before 2.1.0** (roadmap §17, all decided 2026-11-02): **crosscode.co.za**, Cross Code's
|
||||
main site with a Cross Events page; church website and church app as "coming later"; repo
|
||||
**`joshua/crosscode-site`** (create it on Gitea first); Joshua's own server (nginx); no prices;
|
||||
a **contact form to info@crosscode.co.za** (needs a small send endpoint on the server — see
|
||||
§17's building notes); a privacy notice and terms of use; no cookies or analytics. Logo,
|
||||
colours, wording and screenshots come from Joshua later — start with placeholders.
|
||||
- **2.1.0** (roadmap §18): the **Feedback page** — results per event, each response, who
|
||||
hasn't answered and a "remind" action. AI helpers on the privacy pages were dropped
|
||||
(2026-11-03).
|
||||
- Testing AI locally: run the Hub (`../events-hub`, port 5200), set Settings → AI there, give a
|
||||
test licence an AI cap, and start Cross Events with `HUB_URL=http://localhost:5200`.
|
||||
- **Deploying:** `bash scripts/deploy.sh --check`, then `bash scripts/deploy.sh v<version>` on
|
||||
each server (installs ask through Request update as usual).
|
||||
2. **AI rules** (decided 2026-10-29, roadmap §15): the AI buttons show only when the licence's
|
||||
monthly cap is above R0, and are disabled (not hidden) once it's reached. The Hub charges the
|
||||
real cost plus a markup % (a default and a per-licence override) and counts the cap in charged
|
||||
|
||||
+2
-2
@@ -1200,5 +1200,5 @@ feature.
|
||||
|
||||
## Still open
|
||||
|
||||
- **Marketing site (§17):** built; waiting on Joshua's real logo, wording and photos where
|
||||
placeholders remain.
|
||||
Nothing. The marketing site (§17) is done (2026-11-03). The plan from here is
|
||||
[ROADMAP-v3.md](./ROADMAP-v3.md).
|
||||
|
||||
@@ -0,0 +1,640 @@
|
||||
# Cross Code — Road to three products
|
||||
|
||||
Planned work to go from one product (Cross Events 2.1.x) to three that can be sold separately or
|
||||
together: **Cross Events**, a **Church Website** and a **Church App**, all managed from one
|
||||
**Cross Code Hub**. Based on the two concept specs (church website platform, church app v1.1,
|
||||
3 November 2026), with the changes decided on 2026-11-03 (see [Decisions](#decisions-2026-11-03)).
|
||||
See [ROADMAP-v2.md](./ROADMAP-v2.md) for how Cross Events got to 2.x.
|
||||
|
||||
**Theme:** a church can have any mix of products (Events, Website, App, all, or just the
|
||||
Website). People sign in to all of them with **one account**, and events flow between them in
|
||||
whatever direction the church chooses.
|
||||
|
||||
---
|
||||
|
||||
## Contents
|
||||
|
||||
1. [The shape](#1-the-shape)
|
||||
2. [Contracts (shared JSON formats)](#2-contracts-shared-json-formats)
|
||||
3. [Accounts (shared users)](#3-accounts-shared-users)
|
||||
4. [Event sync in any direction](#4-event-sync-in-any-direction)
|
||||
5. [Cross Code Hub (all products)](#5-cross-code-hub-all-products)
|
||||
6. [Church Website](#6-church-website)
|
||||
7. [Church App](#7-church-app)
|
||||
8. [Servers and deployment](#8-servers-and-deployment)
|
||||
9. [Build order](#9-build-order)
|
||||
10. [Pricing](#10-pricing)
|
||||
11. [SEO (every public site)](#11-seo-every-public-site)
|
||||
12. [Decisions](#decisions-2026-11-03)
|
||||
13. [Still open](#still-open)
|
||||
|
||||
---
|
||||
|
||||
## 1. The shape
|
||||
|
||||
Each church gets its own **church stack**: one install per product it has bought, each with its
|
||||
own process and its own database. The Hub only sees aggregate stats and licences.
|
||||
|
||||
```
|
||||
Cross Code Hub (hub.crosscode.co.za)
|
||||
customers · licences per product · stats · AI proxy · billing
|
||||
▲ register / heartbeat / licence (install → Hub only)
|
||||
┌───────────────┼────────────────────────────────────────┐
|
||||
│ Church stack (e.g. Hope Family Church) │
|
||||
│ │
|
||||
│ Accounts (accounts.church.co.za) ◄── sign-in (OIDC) ──┤
|
||||
│ ▲ ▲ ▲ │
|
||||
│ Website (www.) ◄─feed─► Events (events.) ◄─feed─► App (app.)
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Rules**
|
||||
|
||||
1. **No shared databases, ever.** Products only talk over HTTPS using documented contracts (§2).
|
||||
2. **The Hub is never on the runtime path.** It stays read-only toward installs: installs push to
|
||||
it, it never reaches into them, and a Hub outage switches nobody off.
|
||||
3. **One maintained codebase per product.** Each church runs a tagged release of it. Nothing is
|
||||
built specially for one church.
|
||||
4. **The origin of a record owns it.** Copies elsewhere are read-only and link back to the origin.
|
||||
|
||||
**Products and what each one owns**
|
||||
|
||||
| Product | Owns | Doesn't do |
|
||||
|---------|------|------------|
|
||||
| **Accounts** | People's accounts: name, email, phone, password, 2FA, privacy notice acceptance, news consent | Anything product-specific |
|
||||
| **Cross Events** | Events with registrations, tickets, payments, check-in, kids, rosters, POPIA toolkit | Website content |
|
||||
| **Church Website** | Public pages, leaders, ministries, messages (sermons), service times, simple events | Registrations or payments |
|
||||
| **Church App** | House of Prayer, announcements, devotionals, reading plans, groups, care, simple events | Registrations or payments |
|
||||
|
||||
---
|
||||
|
||||
## 2. Contracts (shared JSON formats)
|
||||
|
||||
Everything that crosses between products is defined **once**, in a shared package
|
||||
`@crosscode/contracts` (zod schemas + generated JSON Schema + TypeScript types + sample payloads).
|
||||
Every product, including Cross Events and the Hub, uses it. This is what keeps the key fields the
|
||||
same in every product's JSON.
|
||||
|
||||
| Contract | Used for |
|
||||
|----------|----------|
|
||||
| `crosscode.event/1`, `crosscode.event-feed/1` | One event, and the feed every product publishes and reads (with the `event.changed` webhook) (§4) |
|
||||
| `crosscode.church/1` | The church profile: name, logo, colours, campuses, service times, contact details. Set once and shared |
|
||||
| `crosscode.identity/1` | The claims Accounts puts in a sign-in token, and the back-channel calls (`user.updated`, `user.deleted`, `user.export`) |
|
||||
| Hub protocol v2 | Register, heartbeat, licence token, AI calls, for every product (§5). Today's protocol (Cross Events 2.1, Hub 1.7) is v1, in `docs/hub-protocol.md` |
|
||||
|
||||
**Versioning**
|
||||
|
||||
- Adding an **optional** field keeps the version (`/1`). Consumers ignore fields they don't know.
|
||||
- Removing or renaming a field, adding a value to an enum (older consumers would refuse it), or changing a meaning needs a new version (`/2`). The old
|
||||
version is served alongside it until every install has updated, as with `/api/v2`.
|
||||
- Every payload carries its `schema` name, so a consumer always knows what it's reading.
|
||||
- The package lives in the platform monorepo (§8) and is published to Gitea's package registry so
|
||||
the Events and Hub repos can install it.
|
||||
|
||||
---
|
||||
|
||||
## 3. Accounts (shared users)
|
||||
|
||||
A small per-church **OpenID Connect** provider (`oidc-provider` on the server, `openid-client` in
|
||||
each product). Every church stack has one, including website-only churches (where it only holds
|
||||
staff logins).
|
||||
|
||||
**Why a separate service:** if one product held the users, cancelling that product would take
|
||||
everyone's login with it. With Accounts on its own, products can be added or cancelled without
|
||||
touching anyone's account.
|
||||
|
||||
### What lives where
|
||||
|
||||
| Accounts | Each product (keyed by the account's `sub`, a UUID) |
|
||||
|----------|-----------------------------------------------------|
|
||||
| Name, email, phone, password (Argon2id), 2FA (email, WhatsApp or an authenticator app), lockout, password reset, login history | Roles in that product. An Events supervisor isn't automatically an App prayer coordinator |
|
||||
| Privacy notice versions and acceptance, news consent (email / WhatsApp) | Product data: registrations and tickets (Events), prayer commitments (App), content edits (Website) |
|
||||
| "Download my data" and "Delete my account" entry points | Its share of the export and the anonymising, when Accounts asks |
|
||||
| Inactive-account retention | — |
|
||||
|
||||
### How it works
|
||||
|
||||
- Sign-in is the standard authorisation-code flow with PKCE. People sign in once at
|
||||
`accounts.church.co.za` and are signed in to every product of that church.
|
||||
- **Sign-in always returns you to where you came from.** When a product sends someone to
|
||||
`accounts.church.co.za` (they clicked Sign in, or opened a page that needs sign-in), it passes
|
||||
the exact page they were on (path and query, e.g. `/events/youth-camp/register?ticket=adult`)
|
||||
in the OIDC `state`. After sign-in, Accounts sends them back to **that product and that page**,
|
||||
not to a dashboard or the account page. This holds through every step in between: 2FA,
|
||||
"forgot password" and the reset link, creating a new account, the news question and privacy
|
||||
notice re-acceptance. (Cross Events works this way today with `?redirect=`; the move to Accounts
|
||||
keeps it.)
|
||||
- **Safe redirects only:** Accounts only returns to the registered `redirect_uri` of a linked
|
||||
product of the same church (exact match, no wildcards), and the product only accepts a
|
||||
relative path from `state` (starting with a single `/`). Anything else falls back to the
|
||||
product's home page. No open redirects.
|
||||
- **Signing out** works the same way (`post_logout_redirect_uri`): you end up back on the
|
||||
product you signed out from, signed out of all of the church's products.
|
||||
- Opening `accounts.church.co.za` directly (nothing to return to) shows the account page.
|
||||
- Tests: Playwright checks for each product that signing in from a deep link (including through
|
||||
2FA and a password reset) lands back on that exact page, and that a foreign or absolute
|
||||
return URL is refused.
|
||||
- **Accounts stays in the background** (decided 2026-11-03). Members never see it as a product:
|
||||
the sign-in page carries the church's name and branding ("Sign in to Hope Family Church"), and
|
||||
they manage their account from whichever product they're in. Only **admins** see Accounts as
|
||||
such (people, sign-in settings, linked products), and **account holders** see their own
|
||||
account page (profile, password, 2FA, privacy).
|
||||
- **2FA methods:** email, WhatsApp, or an **authenticator app** (TOTP, e.g. Google or Microsoft
|
||||
Authenticator), with one-time recovery codes. Cross Events gets the authenticator option first
|
||||
(2.2.0), and the migration carries it over (below).
|
||||
- Each product keeps a local copy of the profile (name, email) for lists and messages, updated on
|
||||
sign-in and by `user.updated`.
|
||||
- **POPIA:** the church is one responsible party. "Download my data" asks every linked product for
|
||||
its part (`user.export`) and bundles the answers. "Delete my account" deactivates the account at
|
||||
once, then sends `user.deleted` to each product when an admin approves (the existing Events
|
||||
rules apply: it waits while the person has an open registration for an upcoming event).
|
||||
- Back-channel calls are signed with a secret per product link and retried through pg-boss.
|
||||
- Accounts gets its own access log entries for exports and admin views of people.
|
||||
|
||||
### Moving Cross Events onto Accounts (Cross Events 3.0)
|
||||
|
||||
This is a breaking change, so it's a major version.
|
||||
|
||||
- A migration copies every Events user into Accounts **with their existing password hash**, 2FA
|
||||
setting (including authenticator secrets and unused recovery codes), consent and notice acceptance, so nobody has to reset anything. The Events `User`
|
||||
table keeps its id (now equal to the account `sub`), role, campus scope and product data.
|
||||
- Sign-in, forgot password, 2FA, the news question and the notice re-acceptance move to Accounts.
|
||||
The Events privacy centre links to Accounts for account-level items.
|
||||
- Every place Events sends someone to sign in today (`/login?redirect=…`, `useRequireAuth`, the
|
||||
register / ticket flows) goes to Accounts with the current page as the return target, so
|
||||
members land back exactly where they were (§3, "Sign-in always returns you to where you came
|
||||
from"). Old `/login?redirect=…` links keep working by forwarding to Accounts with the same target.
|
||||
- Staff accounts work the same way: roles stay in Events.
|
||||
|
||||
---
|
||||
|
||||
## 4. Event sync in any direction
|
||||
|
||||
Every product can **publish** the events it owns and **show** events from its siblings. Each
|
||||
install has two settings:
|
||||
|
||||
- **Publish my events:** on / off.
|
||||
- **Show events from:** ☐ Events ☐ Website ☐ App (only linked products are listed).
|
||||
|
||||
That covers every setup: Events → Website and App; Events and App → Website; everything to
|
||||
everything; or nothing.
|
||||
|
||||
### Rules
|
||||
|
||||
- **Only the origin edits an event.** Imported events are read-only copies with a link back.
|
||||
Corrections happen at the origin and arrive with the next sync.
|
||||
- **No loops:** a product only publishes events it *originated*, never ones it imported.
|
||||
- **No duplicates:** an event is identified by `origin.product` + `origin.id`.
|
||||
- **Only Cross Events takes registrations and payments.** The Website and App can create
|
||||
**simple events** (title, dates, place, image, description, and an optional external link or
|
||||
contact instructions).
|
||||
- Each product keeps the last good copy of each feed. If a source is down, its events keep
|
||||
showing, and admins see "last synced 2 hours ago".
|
||||
|
||||
### Transport
|
||||
|
||||
- `GET /api/feed/v1/events?updatedSince=<ISO>` → `{ schema: "crosscode.event-feed/1", generatedAt, events: [...], deleted: [...] }`.
|
||||
Public events only, so no personal information ever goes through the feed.
|
||||
- A signed webhook `event.changed` tells consumers to fetch straight away.
|
||||
- Consumers also poll every 15 minutes as a fallback (pg-boss job).
|
||||
- Timeouts, retries with back-off, and one failing source never blocks another.
|
||||
|
||||
### `crosscode.event/1`
|
||||
|
||||
```json
|
||||
{
|
||||
"schema": "crosscode.event/1",
|
||||
"id": "8f1c2a4e-…",
|
||||
"origin": {
|
||||
"product": "events",
|
||||
"installId": "inst_…",
|
||||
"url": "https://events.hopefamily.co.za/events/youth-camp"
|
||||
},
|
||||
"status": "published",
|
||||
"updatedAt": "2026-11-03T10:00:00Z",
|
||||
"title": "Youth Camp 2026",
|
||||
"summary": "Three days at Camp Wartburg",
|
||||
"description": "Sanitised markdown…",
|
||||
"imageUrl": "https://events.hopefamily.co.za/uploads/…",
|
||||
"timezone": "Africa/Johannesburg",
|
||||
"startsAt": "2026-03-14T08:00:00Z",
|
||||
"endsAt": "2026-03-16T14:00:00Z",
|
||||
"allDay": false,
|
||||
"sessions": [
|
||||
{ "id": "…", "label": "Day 1", "startsAt": "…", "endsAt": "…", "location": null, "cancelled": false }
|
||||
],
|
||||
"location": { "name": "Camp Wartburg", "address": "…", "onlineUrl": null },
|
||||
"campus": { "slug": "meyerton", "name": "Meyerton" },
|
||||
"series": { "id": "…", "name": "Youth Camp" },
|
||||
"tags": ["youth"],
|
||||
"registration": {
|
||||
"mode": "origin",
|
||||
"url": "https://events.hopefamily.co.za/events/youth-camp",
|
||||
"status": "open",
|
||||
"opensAt": null,
|
||||
"closesAt": "2026-03-10T22:00:00Z",
|
||||
"priceFrom": { "amountCents": 45000, "currency": "ZAR" }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Values / notes |
|
||||
|-------|----------------|
|
||||
| `origin.product` | `events` · `website` · `app` |
|
||||
| `status` | `published` · `cancelled` (unpublished or deleted events go in `deleted`) |
|
||||
| `sessions` | Empty for a single-date event. `startsAt` / `endsAt` are always the first start and last end |
|
||||
| `campus` | `null` = all campuses or campuses off |
|
||||
| `registration.mode` | `origin` (register at the origin, Events only) · `external` (a link) · `contact` (instructions in the description) · `none` |
|
||||
| `registration.status` | `open` · `not_open` · `closed` · `full` · `waitlist`. Only Events sends anything but `open` |
|
||||
| `priceFrom` | Lowest current price in cents, or `null` for free / not applicable |
|
||||
|
||||
---
|
||||
|
||||
## 5. Cross Code Hub (all products)
|
||||
|
||||
The Events Hub (`joshua/events-hub`, Hub 1.7.0) becomes **Cross Code Hub 2.0**: one place for every
|
||||
product's customers, licences, stats, AI usage and billing.
|
||||
|
||||
- **Customer → installs.** Each install has a `product` (`events` · `website` · `app` ·
|
||||
`accounts`) and its **own licence**. Accounts is licensed with whatever else the church has
|
||||
(no charge of its own). **A customer has at most one install of each product** (decided
|
||||
2026-11-03): the Hub refuses a second licence for the same product, so "the church's website"
|
||||
or "the church's Events" is never ambiguous.
|
||||
- **Address per customer:** own domain, Cross Code address (`church.crosscode.co.za`) or domain
|
||||
included (§10), recorded on the customer, since one address covers all its products. The
|
||||
Hub reminds Joshua before an included domain is due for renewal.
|
||||
- **Add-ons per customer:** the address (R10 or R25) and extra website storage, billed with the
|
||||
products but never bundle-discounted. Website installs report storage used in the heartbeat.
|
||||
- **Billing per customer:** prices per product on the income bands, bundles at **15% off two
|
||||
products** and **25% off all three**, founding prices and the free month (as on
|
||||
crosscode.co.za). Invoices are recorded in the Hub (sent / paid / overdue). A payment provider
|
||||
integration comes later.
|
||||
- **Protocol v2:** adds `product` to register and heartbeat, and per-product stats (aggregate
|
||||
counts only, as today: no personal information). The Hub keeps accepting protocol v1, so Events
|
||||
installs keep reporting until they update.
|
||||
- **Sibling discovery:** the licence token carries the `customerId`. The heartbeat reply lists the
|
||||
church's other installs (product, URL, public key; nothing else). Each product shows them
|
||||
under **Settings → Linked products** with a **Connect** button that sets up the feed (§4) and
|
||||
the Accounts client (§3). The list is cached, so a Hub outage changes nothing.
|
||||
- **AI:** the existing proxy, cap and markup serve every product. Usage is recorded per product,
|
||||
and the cap stays per customer.
|
||||
- **Request update** works for every product. The Hub still never runs anything on an install.
|
||||
- **Domain:** `hub.crosscode.co.za`. `events-hub.crosscode.co.za` stays as an alias so existing
|
||||
installs keep working.
|
||||
- Dashboard: installs per product, versions behind latest, health, licence status, monthly
|
||||
recurring revenue, overdue invoices.
|
||||
|
||||
---
|
||||
|
||||
## 6. Church Website
|
||||
|
||||
A professional public website the church manages itself. From the website spec, adjusted.
|
||||
|
||||
### Public site
|
||||
|
||||
- **Pages:** Home, New Here? / Plan Your Visit, About (story, mission, vision, values, beliefs),
|
||||
Leadership, Ministries (overview + one page each), Events (list, detail, calendar, past),
|
||||
Messages (sermons, series), Give (information and provider links), Contact, Privacy /
|
||||
POPIA / safeguarding / terms.
|
||||
- **Homepage order:** hero with Plan Your Visit, service times and location, mission,
|
||||
introduction, ministries and team, upcoming events, latest message, final invitation (each
|
||||
template starts from this and arranges it its own way, below).
|
||||
- Server-rendered, fast on a phone, accessible (WCAG 2.2 AA, axe checks in Playwright), with
|
||||
the SEO in §11.
|
||||
|
||||
### Templates: each church its own identity
|
||||
|
||||
The website is the most personal of the products, so churches shouldn't all look the same.
|
||||
The Website ships with **four templates**, each with its own **layout and design**. The church
|
||||
picks one; its **logo and colours come from the church profile** (`crosscode.church/1`), and its
|
||||
**content fills into the chosen template**.
|
||||
|
||||
| Template | Character | Layout and design |
|
||||
|----------|-----------|-------------------|
|
||||
| **Heritage** | Traditional, dignified | Centred logo and navigation above a framed hero image; serif headings; generous margins; content in a single centred column with thin rules between sections; service times in a formal panel |
|
||||
| **Bold** | Modern, energetic | Full-screen photo or video hero with large overlaid headline; sticky transparent header; strong sans-serif type; full-bleed alternating image / text bands; large "Plan your visit" call to action |
|
||||
| **Community** | Warm, friendly, family | Logo left, rounded friendly type; a hero with photo collage; content in cards (ministries, events, messages as a grid); soft shapes and the church colour as tinted backgrounds; people's faces prominent |
|
||||
| **Clean** | Minimal, calm | Small header, lots of white space; typographic hero (no photo needed, good for churches without good photos); narrow text column; lists rather than cards; colour used sparingly as accent |
|
||||
|
||||
**How it works**
|
||||
|
||||
- **One content model, four presentations.** Content is structured (pages, sections, ministries,
|
||||
leaders, messages, events, service times), never tied to a template. Every template renders
|
||||
every section type in its own way, so **switching template never loses content**.
|
||||
- **The template sets the layout:** header and navigation style, hero type, how each section
|
||||
type looks, section spacing, typography (with two or three font pairings to choose from per
|
||||
template), shapes and image treatment, and the default homepage order. Within it, churches can
|
||||
reorder, hide and show the approved sections, but not edit layout freely, so a site always
|
||||
looks designed.
|
||||
- **Colours from the church, made safe:** the template builds its palette (tints, shades, text
|
||||
colours, buttons) from the church's primary and accent colours, and checks contrast (WCAG AA).
|
||||
A colour that fails is darkened or lightened automatically, and the admin is told why.
|
||||
- **Logo** in the template's header and footer positions; the icon (from the profile) for the
|
||||
favicon and share images.
|
||||
- **Choosing:** the setup checklist starts with "Choose your template", showing all four
|
||||
**previewed with the church's own logo, colours and content** (sample content until they have
|
||||
their own). Switching later goes through preview → publish like any other change.
|
||||
- **Templates are part of the product**, not per church (§1, rule 3): they improve in each
|
||||
release for every church using them. More templates can be added in later releases.
|
||||
- Every template meets the same bar: tested in Playwright (layout at phone / tablet / desktop,
|
||||
axe accessibility) and with the §11 SEO checks.
|
||||
|
||||
### Admin (`www.church.co.za/admin`, signed in through Accounts)
|
||||
|
||||
- Dashboard with a setup checklist, recent edits and drafts.
|
||||
- **Template:** choose and preview the template, font pairing, and which homepage sections show
|
||||
in which order.
|
||||
- Pages and sections, About, Leadership, Ministries, Messages, Media library, Contact and
|
||||
service times, Navigation, Settings (including SEO and search engine verification, §11).
|
||||
- **Events:** simple events, plus the feed settings (§4) and sync health.
|
||||
- **Draft → preview → publish**, archive instead of delete, an audit log of who changed what.
|
||||
- **Roles:** Owner, Admin, Editor, Ministry leader (own ministry only), Viewer.
|
||||
|
||||
### Not in the first release
|
||||
|
||||
Prayer requests and other forms with personal information (they belong in the App), a member
|
||||
directory, online giving processing.
|
||||
|
||||
---
|
||||
|
||||
## 7. Church App
|
||||
|
||||
A church-branded **web app** (PWA) at `app.church.co.za`, added to the home screen. No app-store
|
||||
release. From the app spec, adjusted.
|
||||
|
||||
### House of Prayer (the headline)
|
||||
|
||||
- **24/7 coverage view:** a 24-hour timeline, coverage % and gaps, in the church's timezone.
|
||||
Live updates through server-sent events, with polling as a fallback.
|
||||
- **Commitments:** one-off and recurring slots, change or cancel your own, optional reminders
|
||||
by **email or WhatsApp**, a voluntary "I prayed" marker clearly shown as self-reported.
|
||||
- **Booking rules** (defaults decided 2026-11-03; the church can change them):
|
||||
|
||||
| Rule | Default |
|
||||
|------|---------|
|
||||
| Slot length | **1 hour** (church can choose 15, 30 or 60 minutes) |
|
||||
| Book ahead | Up to **12 months** in advance |
|
||||
| Recurring bookings | Daily or weekly, for up to **12 months from the day of booking**. Before it runs out the member gets an email/WhatsApp to renew it for another year |
|
||||
| Cancelling | Up to **8 hours before** the slot. Within 8 hours the slot can't be cancelled in the app (the member is shown who to contact) |
|
||||
- **Anonymous:** public views show coverage only, never names. Admin access to bookings is
|
||||
permission-controlled and goes in the access log.
|
||||
- **Prayer requests:** private by default (submitter and prayer team only). Public or group
|
||||
prayer needs an explicit choice. Moderation (approve, redact, remove) and a safeguarding
|
||||
escalation route. Never used for analytics or AI.
|
||||
- **Campaigns and testimonies:** time-bound campaigns with collective progress (no rankings),
|
||||
testimonies moderated before publishing.
|
||||
|
||||
### Other modules (each can be switched off by the church)
|
||||
|
||||
Announcements, devotionals, reading plans, sermons (shared with the Website where both exist),
|
||||
connect groups, pastoral care (restricted access, audited), serving opportunities, giving links,
|
||||
an opt-in directory, and **events** (simple events plus the feed, §4).
|
||||
|
||||
**Every module is available to every church.** Pricing follows church size (§10), not features.
|
||||
|
||||
**Notifications are email and WhatsApp only** (decided 2026-11-03), using the same email and
|
||||
WhatsApp setup as Cross Events. No push notifications. Members choose their channel per kind of
|
||||
message (prayer reminders, announcements).
|
||||
|
||||
### Roles
|
||||
|
||||
Member, Prayer coordinator, Content admin, Pastoral care, Church admin. Public content can be
|
||||
browsed without an account. Personal actions need sign-in through Accounts.
|
||||
|
||||
---
|
||||
|
||||
## 8. Servers and deployment
|
||||
|
||||
**PM2 and nginx, like Cross Events today.** No Docker.
|
||||
|
||||
- **Per church:** one PM2 process (or front/back pair) per product, one PostgreSQL database and
|
||||
database user per product, its own `.env`, secrets, uploads folder, logs and backups. Names
|
||||
carry the church slug (`hopefamily-website`, `hopefamily_website` DB, …).
|
||||
- **Install and update scripts** for each product, built from the Cross Events ones: fresh
|
||||
install, then a tagged update with a database backup, migrations, build, restart and roll back
|
||||
on failure. An `add-church` script runs them for the products a church has bought and prints
|
||||
the nginx config and DNS records.
|
||||
- **Domains:** `www.`, `events.`, `app.` and `accounts.` on the church's own domain, or on
|
||||
`<church>.crosscode.co.za` for the Cross Code address (a wildcard DNS record and certificate
|
||||
for `*.crosscode.co.za` subdomains, so no DNS work per church). Certificates through certbot. Only configured domains are served (no trusting arbitrary Host headers).
|
||||
- Every product has `/health` (version, DB, jobs, last backup), reported to the Hub.
|
||||
- Several churches can share a server at first. Any church can move to its own server later
|
||||
without code changes. The Hub stays on a separate server from church installs.
|
||||
|
||||
### Repositories
|
||||
|
||||
| Repo | What |
|
||||
|------|------|
|
||||
| `joshua/hope-events` | Cross Events (unchanged home) |
|
||||
| `joshua/events-hub` | Cross Code Hub (renamed in the UI; repo name can stay) |
|
||||
| `joshua/crosscode-platform` (new) | Monorepo: `apps/accounts`, `apps/website`, `apps/app`, `packages/contracts`, `packages/ui`, `packages/seo` (§11) |
|
||||
| `joshua/crosscode-site` | The marketing site (crosscode.co.za). Updated at the end of every phase that changes what's on sale or what a product does (see §9, "Updating crosscode-site") |
|
||||
|
||||
**Stack:** the same as Cross Events (Next.js, Express, TypeScript, Prisma, PostgreSQL, pg-boss,
|
||||
Playwright, Jest), so code and habits carry over.
|
||||
|
||||
---
|
||||
|
||||
## 9. Build order
|
||||
|
||||
| Phase | Version | What | Why this order |
|
||||
|-------|---------|------|----------------|
|
||||
| 0 | contracts 1.0.0 | `crosscode.event/1`, `crosscode.church/1`, `crosscode.identity/1`, Hub protocol v2: schemas, docs, sample payloads, the monorepo set up | Everything else depends on them |
|
||||
| 1 | **Hub 2.0.0** | Cross Code Hub: products, licence per product, per-customer billing with bundles, protocol v2 (v1 still accepted), sibling discovery, AI usage per product | Must exist before a second product is sold |
|
||||
| 1b | **Cross Events 2.2.0** | **Authenticator app 2FA** (TOTP) next to email and WhatsApp, with recovery codes; admins can see which method each staff account uses | Independent and useful now; the Accounts migration later carries it over |
|
||||
| 2 | **Cross Events 2.3.0** | Reports `product: "events"` (protocol v2), **Linked products**, the event feed (publish + show events from siblings), `packages/seo` (§11) with canonical links for imported events | Small; ready for when the Website exists |
|
||||
| 3 | **Accounts 1.0.0** | OIDC provider, sign-in, 2FA (email, WhatsApp, authenticator app), password reset, consent and notice, export / delete fan-out, admin, install script | The Website is built on it from day one |
|
||||
| 4 | **Church Website 1.0.0** | Public site with the **four templates** (§6), admin, simple events + feed, SEO (§11), Hope Family Church as the first church, then a second test church on a different template to prove it repeats | More churches need a website than an app; it also covers the "website only" case |
|
||||
| 5 | **Cross Events 3.0.0** | Sign-in moves to Accounts; user migration keeping password hashes; migration guide | Breaking change, so a major version, after Accounts is proven on the Website |
|
||||
| 6 | **Church App 1.0.0** | Foundation (PWA, branding, Accounts), House of Prayer, announcements, devotionals, events (simple + feed) | Starts with shared users and the feed already in place |
|
||||
| 7 | Church App 1.x | Reading plans, sermons, groups, pastoral care, serving, giving links, directory | After House of Prayer is in use |
|
||||
|
||||
Each phase adds help content, audit log entries, tests, a CHANGELOG entry and updates to the
|
||||
docs. Branch → merge → tag → push per feature, as before. **A phase isn't done until
|
||||
crosscode-site is updated** for it (below), where the phase changes anything the site says.
|
||||
|
||||
### Updating crosscode-site
|
||||
|
||||
The marketing site (`../crosscode-site`, crosscode.co.za) has to match what's actually shipped.
|
||||
At the end of each phase, update it in the same sitting and deploy it (its `DEPLOYMENT.md`):
|
||||
|
||||
| Phase | What changes on crosscode-site |
|
||||
|-------|--------------------------------|
|
||||
| 0 contracts | Nothing |
|
||||
| 1 Hub 2.0.0 | Bundle discounts (15% / 25%) and the address add-on (own domain R0 · Cross Code address R10 · domain included R25) in `src/data/pricing.mjs`, worded so it's clear the Website and App are still coming. Legal pages if billing terms change |
|
||||
| 1b Events 2.2.0 | Authenticator app 2FA in the Cross Events feature list (`src/data/cross-events.mjs`) and the security / POPIA wording |
|
||||
| 2 Events 2.3.0 | "Works with the Church Website and App" (event feed, Linked products) on the Cross Events page, as "coming" until those ship |
|
||||
| 3 Accounts 1.0.0 | "One sign-in for all your church's products" on the Products page. Accounts isn't sold, so no page or price of its own |
|
||||
| 4 Website 1.0.0 | **Church Website goes on sale:** `status: "available"` and its own page in `src/data/products.mjs` / `src/pages/products/`, R99 + storage add-on in `pricing.mjs`, **a showcase of the four templates** (a page per template with screenshots of the same demo church in each), screenshots from a demo church (fictional people only), FAQ, home page, contact form product list, `SoftwareApplication` JSON-LD (§11) |
|
||||
| 5 Events 3.0.0 | Cross Events page: sign-in through the church's shared account. Update screenshots that show the login |
|
||||
| 6 App 1.0.0 | **Church App goes on sale:** own page, active-member tiers in `pricing.mjs`, House of Prayer screenshots, the bundle examples from §10 |
|
||||
| 7 App 1.x | New modules added to the App page's feature list as each one ships |
|
||||
|
||||
Prices change in the Hub's `frontend/src/lib/tiers.ts` in the same phase, so the site and the
|
||||
Hub never disagree. Run the site's tests and build before deploying it.
|
||||
|
||||
### Release checks (before selling a product)
|
||||
|
||||
- Two churches installed from the same tagged release, with separate databases, secrets and uploads.
|
||||
- Church A's admin can't read or change Church B's data through direct API calls.
|
||||
- Stopping one church's install, or the Hub, leaves the others running.
|
||||
- A feed source going down: its last events keep showing, the other sources keep working.
|
||||
- Signing in once works across all of a church's products; deleting an account reaches every product.
|
||||
- Signing in from any page of any product returns to that same page (including through 2FA and a
|
||||
password reset), and a foreign return URL is refused.
|
||||
- crosscode-site shows the product as available, with its page, prices and screenshots.
|
||||
- The §11 SEO checks pass (Playwright and Lighthouse), for the Website in all four templates.
|
||||
- A database backup is restored successfully.
|
||||
|
||||
---
|
||||
|
||||
## 10. Pricing
|
||||
|
||||
Aimed at small and mid-sized South African churches: the Website and App start well below Cross
|
||||
Events, since they replace things churches often get cheaply or free today (a basic website,
|
||||
WhatsApp groups). Prices are rand per month. Decided by Joshua on 2026-11-03.
|
||||
When the Website and App go on sale, they go in `crosscode-site/src/data/pricing.mjs` and the Hub's
|
||||
`frontend/src/lib/tiers.ts`.
|
||||
|
||||
**The same rules as Cross Events apply to every product:** one month free, yearly billing = 10
|
||||
months' price, founding churches 35% off for as long as they stay, bundles **15% off** two
|
||||
products and **25% off** all three, and the tier is checked once a year at renewal. **Add-ons**
|
||||
(address and storage) are not discounted.
|
||||
|
||||
### The church's address: three options
|
||||
|
||||
One address covers all of a church's products (`www.`, `events.`, `app.`, `accounts.`), so it's
|
||||
charged **once per church**, not per product.
|
||||
|
||||
| Option | Price | What it means |
|
||||
|--------|-------|---------------|
|
||||
| **Own domain** | R0 | The church already has a domain (e.g. `hopefamily.co.za`). They, or whoever manages it, add the DNS records we send. We look after the certificates |
|
||||
| **Cross Code address** | **+R10 / month** | For churches that don't want a domain of their own: `hopefamily.crosscode.co.za`, with the products at `events.hopefamily.crosscode.co.za`, `app.hopefamily.crosscode.co.za` and so on. Can move to a domain of their own at any time (old addresses redirect for 12 months). Trials use this address for free |
|
||||
| **Domain included** | **+R25 / month** | We register a `.co.za` or `.org.za` **in the church's name**, renew it every year and manage the DNS. Other endings (`.com`, `.church`, `.org`) are charged at cost on top. If the church leaves, the domain is transferred to them, free |
|
||||
|
||||
### Church Website: one price plus storage
|
||||
|
||||
| | Price |
|
||||
|--|------:|
|
||||
| **Church Website** (5 GB of uploads included) | **R99** |
|
||||
| Founding (35% off) | R64 |
|
||||
| **Extra storage** add-on | **+R20 / month per extra 5 GB** |
|
||||
|
||||
Uploads are photos, documents and sermon notes. Sermon audio and video are linked (YouTube,
|
||||
Spotify, SoundCloud…) rather than uploaded, so most churches never need extra storage. Admins see
|
||||
how much they've used in the website's settings and get an email at 80%. Storage is counted per
|
||||
church and reported to the Hub, which bills the add-on.
|
||||
|
||||
### Church App: on active members
|
||||
|
||||
Billed on **active members**: accounts that used the App in the last 90 days, reported to the Hub
|
||||
as a band (like event income for Cross Events). Visitors browsing without signing in never count.
|
||||
|
||||
| Tier | Active members | Price | Founding |
|
||||
|------|----------------|------:|---------:|
|
||||
| Mustard Seed | Up to 100 | **R99** | R64 |
|
||||
| Small Group | 101 – 300 | **R249** | R162 |
|
||||
| Congregation | 301 – 750 | R399 | R259 |
|
||||
| Gathering | 751 – 2 000 | R649 | R422 |
|
||||
| Multitude | 2 001 – 5 000 | R999 | R649 |
|
||||
| Network | Over 5 000 | From R1 500 | 35% off |
|
||||
|
||||
### What churches would pay (no founding discount)
|
||||
|
||||
| Church | Products | Products after bundle | + address | Per month |
|
||||
|--------|----------|----------------------:|----------:|----------:|
|
||||
| Small, website only | Website | R99 | own domain | **R99** |
|
||||
| Small, website only | Website | R99 | Cross Code address | **R109** |
|
||||
| Small, website only | Website | R99 | domain included | **R124** |
|
||||
| Small, website + app | Website + App (≤100): R198 | R168 (15%) | domain included | **R193** |
|
||||
| Small, everything | Events Seed + Website + App (≤100): R548 | R411 (25%) | domain included | **R436** |
|
||||
| Mid-sized, everything | Events Grow + Website + App (301–750): R1 348 | R1 011 (25%) | domain included | **R1 036** |
|
||||
|
||||
(Rounded to the rand.)
|
||||
|
||||
---
|
||||
|
||||
## 11. SEO (every public site)
|
||||
|
||||
Every public site gets search engine optimisation at least as good as Cross Events has today:
|
||||
the **Church Website**, **Cross Events**, the public parts of the **Church App**, and the
|
||||
marketing site **crosscode.co.za**. A church should be found when someone searches "church near
|
||||
me", its name, or an event it's running.
|
||||
|
||||
### The baseline (what Cross Events does now, for every product)
|
||||
|
||||
- **Server-rendered HTML** with a unique `<title>` and meta description on every page.
|
||||
- **Canonical URL** on every page (tracking tags such as `?src=` dropped).
|
||||
- **Open Graph and Twitter cards** with an image on every page. Pages without their own image
|
||||
get a share image generated from the church's logo and colours.
|
||||
- **`sitemap.xml`** generated from published content and refreshed hourly, and **`robots.txt`**
|
||||
that points to it and keeps admin, sign-in and personal pages out.
|
||||
- **schema.org JSON-LD** (e.g. `Event` on event pages, as `frontend/src/lib/eventSchema.ts` does,
|
||||
checked with Google's Rich Results Test).
|
||||
- Readable slugs, with **301 redirects** when a slug changes; real 404 status for missing pages;
|
||||
`lang="en-ZA"`.
|
||||
- **Fast and accessible:** responsive images with required alt text, good Core Web Vitals on a
|
||||
phone (LCP under 2.5 s, CLS under 0.1), WCAG 2.2 AA.
|
||||
|
||||
The JSON-LD builders and meta helpers live in a shared `packages/seo` in the platform monorepo,
|
||||
built on `@crosscode/contracts`, so Events, the Website and the App produce the same schema.org
|
||||
data from the same `crosscode.event/1` and `crosscode.church/1`. Cross Events moves its
|
||||
`eventSchema.ts` onto it in 2.3.0.
|
||||
|
||||
### Per product
|
||||
|
||||
| Product | SEO |
|
||||
|---------|-----|
|
||||
| **Church Website** | JSON-LD `Church` (name, logo, address, geo, phone, service times, social links) on the home and contact pages, so the church shows up in local search and maps; `Event` for its events; `VideoObject` / `PodcastEpisode` for messages; `BreadcrumbList`. Admin: per-page title and description (with a Google-style snippet preview), warnings for a missing description, alt text or a too-long title, and a field for Google Search Console / Bing verification. Setup checklist links to setting up a Google Business Profile. All four templates produce the same markup and pass the same checks |
|
||||
| **Imported events** | A copy of another product's event (§4) has `<link rel="canonical">` pointing to the **origin's** page, so search engines see one event, not three. Only the origin is in its sitemap |
|
||||
| **Cross Events** | Already has the baseline. Gains the shared `packages/seo`, `Organization` / `Church` data from the church profile, and the canonical rule for imported events |
|
||||
| **Church App** | Public content (events, devotionals, sermons, announcements marked public) gets the baseline. Everything signed in, prayer and care is `noindex` and kept out of the sitemap. **Prayer requests are never indexable** |
|
||||
| **Accounts** | `noindex` everywhere, blocked in `robots.txt`: sign-in pages should never appear in search |
|
||||
| **Cross Code Hub** | `noindex` everywhere |
|
||||
| **crosscode.co.za** | Already has canonical URLs, Open Graph, `Organization` / `WebPage` JSON-LD, sitemap and robots. Adds `SoftwareApplication` JSON-LD with prices (`Offer`) on each product page, `FAQPage` on the FAQs, a page per Website template, and Search Console / Bing set up and checked after each site update (§9, "Updating crosscode-site") |
|
||||
|
||||
### Moving addresses
|
||||
|
||||
When a church moves from the Cross Code address to its own domain (§10), every old URL
|
||||
**301-redirects** to the same page on the new domain for 12 months, so it keeps its search
|
||||
ranking.
|
||||
|
||||
### Checks (in every product's tests)
|
||||
|
||||
- Playwright: every public page has a title, description, canonical, `og:image`, exactly one
|
||||
`<h1>`, and JSON-LD that parses; the sitemap lists the published pages and nothing private;
|
||||
`robots.txt` blocks admin and sign-in; `noindex` pages really are `noindex`.
|
||||
- Lighthouse (mobile) on a demo church's home, event and message pages, in every template:
|
||||
SEO 100, accessibility 95+, performance 90+.
|
||||
|
||||
---
|
||||
|
||||
## Decisions (2026-11-03)
|
||||
|
||||
| # | Question | Decision |
|
||||
|---|----------|----------|
|
||||
| 1 | Separate infrastructure | **Yes:** one install and database per product per church, no shared databases, Hub not on the runtime path |
|
||||
| 2 | Shared users | **A separate Accounts service per church** (OpenID Connect), used by every product. Roles stay per product |
|
||||
| 3 | Docker | **No.** PM2 and nginx, like Cross Events |
|
||||
| 4 | Simple events in the Website and App | **Yes.** Only Cross Events takes registrations and payments. (The app spec's "no event creation in the App" is overruled) |
|
||||
| 5 | Event sync | Any direction, chosen per install ("Publish my events", "Show events from"). One shared format, `crosscode.event/1`. Only originated events are published |
|
||||
| 6 | Domains | `www.`, `events.`, `app.` (and `accounts.`) on the church's own domain |
|
||||
| 7 | Hub | Becomes **Cross Code Hub**: every product's licences, stats, AI and billing in one place, still read-only toward installs |
|
||||
| 8 | Stack | Next.js + Express + Prisma + PostgreSQL + pg-boss, as Cross Events (not the specs' React + Vite, Redis + BullMQ, Socket.IO) |
|
||||
| 9 | Pricing | Income bands per product, bundles 15% (two) / 25% (three); every App module for every church (not the website spec's Starter / Growth / Pro tiers) |
|
||||
| 10 | Hub provisioning | Scripts Joshua runs; the Hub records installs but never deploys or rolls back (not the specs' automated provisioning) |
|
||||
| 11 | Installs per product | **One install of each product per customer**; the Hub refuses a second licence for the same product |
|
||||
| 12 | House of Prayer defaults | 1-hour slots, book up to 12 months ahead, recurring bookings for up to 12 months from the day of booking, no cancelling within 8 hours of the slot |
|
||||
| 13 | App notifications | **Email and WhatsApp only.** No push notifications |
|
||||
| 14 | Accounts visibility | Hidden from members (branded as the church's sign-in). Only admins and the account holder see account pages |
|
||||
| 15 | 2FA | Add an **authenticator app** (TOTP, with recovery codes) next to email and WhatsApp: in Cross Events 2.2.0 first, then in Accounts |
|
||||
| 16 | Pricing | Website **R99** with 5 GB, extra storage as an add-on in steps. App **R99** (up to 100 members), then **R249**. Address once per church: own domain R0, **Cross Code address R10**, **domain included R25**. Extra storage R20 per 5 GB; App tiers above 300 members R399 / R649 / R999 / from R1 500. Add-ons aren't bundle-discounted (§10) |
|
||||
| 17 | Sign-in return | Signing in through `accounts.church.co.za` always returns to the product and page the person came from (through 2FA, reset, sign-up and notice steps), with exact-match redirect URIs only (§3) |
|
||||
| 18 | Marketing site | crosscode-site is updated at the end of every phase that changes what's on sale or what a product does (§9) |
|
||||
| 19 | Website templates | **Four templates** (Heritage, Bold, Community, Clean), each with its own layout and design. Colours and logo come from the church; content fills the chosen template; switching never loses content (§6) |
|
||||
| 20 | SEO | Every public site (Website, Events, App's public pages, crosscode.co.za) gets at least Cross Events' SEO, through a shared `packages/seo`; Accounts and the Hub are `noindex` (§11) |
|
||||
|
||||
## Still open
|
||||
|
||||
Nothing. crosscode-site is updated at the end of each phase (§9, "Updating crosscode-site"); the
|
||||
Website and App move from "coming later" to their own pages and prices when they ship.
|
||||
@@ -38,7 +38,8 @@ test.describe("feedback page", () => {
|
||||
|
||||
page.once("dialog", d => d.accept());
|
||||
await lerato.getByRole("button", { name: "Remind" }).click();
|
||||
await expect(page.getByRole("status")).toHaveText("Reminded 1 person.");
|
||||
// 2.1.1: it answers straight away; the reminder goes out in the background and the page refreshes.
|
||||
await expect(page.getByRole("status")).toHaveText("Reminding 1 person. It carries on in the background.");
|
||||
await expect(lerato).toContainText("1 · last");
|
||||
await expect(lerato.getByRole("button", { name: "Remind" })).toBeDisabled();
|
||||
await expect(page.getByRole("button", { name: /Remind those who haven't answered/ })).toBeDisabled();
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "hope-events-frontend",
|
||||
"version": "2.1.0",
|
||||
"version": "2.1.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "hope-events-frontend",
|
||||
"version": "2.1.0",
|
||||
"version": "2.1.1",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@hookform/resolvers": "^5.2.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hope-events-frontend",
|
||||
"version": "2.1.0",
|
||||
"version": "2.1.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { ArrowLeft, BellRing, MessageSquareHeart, Pencil, Send, Star } from "lucide-react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
@@ -124,6 +124,14 @@ export default function FeedbackPage() {
|
||||
}, [token, eventId, setMsg]);
|
||||
useExternalEffect(() => { setDetail(null); loadDetail(); }, [loadDetail]);
|
||||
|
||||
// Sending and reminders run in the background (2.1.1): refresh a few times to show them land.
|
||||
const refreshTimers = useRef<ReturnType<typeof setTimeout>[]>([]);
|
||||
useEffect(() => () => refreshTimers.current.forEach(clearTimeout), []);
|
||||
const refreshSoon = useCallback(() => {
|
||||
refreshTimers.current.forEach(clearTimeout);
|
||||
refreshTimers.current = [2000, 6000, 15000].map(ms => setTimeout(() => { loadDetail(); loadList(); }, ms));
|
||||
}, [loadDetail, loadList]);
|
||||
|
||||
const remind = async (userIds?: string[]) => {
|
||||
if (!detail) return;
|
||||
const who = userIds ? detail.people?.find(p => p.userId === userIds[0])?.name || "this person" : null;
|
||||
@@ -135,7 +143,7 @@ export default function FeedbackPage() {
|
||||
try {
|
||||
const out = await (apiV2Data("post", "/events/{id}/feedback/remind", { params: { id: detail.event.id }, authToken: token, body: { userIds: userIds ?? null } }) as Promise<{ message: string; reminded: number; newlyInvited: number }>);
|
||||
setMsg({ type: out.reminded || out.newlyInvited ? "ok" : "err", text: out.message });
|
||||
await Promise.all([loadDetail(), loadList()]);
|
||||
if (out.reminded || out.newlyInvited) refreshSoon();
|
||||
} catch (e) {
|
||||
setMsg({ type: "err", text: errorMessage(e) || "Could not send the reminders" });
|
||||
} finally {
|
||||
@@ -147,9 +155,11 @@ export default function FeedbackPage() {
|
||||
if (!detail || !window.confirm("Send the feedback form now, instead of waiting for its send time?")) return;
|
||||
setBusy("send");
|
||||
try {
|
||||
const out = await (apiV2Data("post", "/events/{id}/feedback/send-now", { params: { id: detail.event.id }, authToken: token, body: {} }) as Promise<{ message: string }>);
|
||||
const out = await (apiV2Data("post", "/events/{id}/feedback/send-now", { params: { id: detail.event.id }, authToken: token, body: {} }) as Promise<{ message: string; invited: number }>);
|
||||
setMsg({ type: "ok", text: out.message });
|
||||
// The form now shows as sent; the people appear as the emails go out.
|
||||
await Promise.all([loadDetail(), loadList()]);
|
||||
if (out.invited) refreshSoon();
|
||||
} catch (e) {
|
||||
setMsg({ type: "err", text: errorMessage(e) || "Could not send the form" });
|
||||
} finally {
|
||||
|
||||
@@ -27141,7 +27141,7 @@
|
||||
"Feedback"
|
||||
],
|
||||
"summary": "Send the feedback form now",
|
||||
"description": "**Who:** Supervisors and admins.",
|
||||
"description": "Answers straight away: `invited` is how many it is going to; the emails go out in the background.\n\n**Who:** Supervisors and admins.",
|
||||
"operationId": "postEventsIdFeedbackSend-now",
|
||||
"security": [
|
||||
{
|
||||
@@ -69877,7 +69877,7 @@
|
||||
"Feedback"
|
||||
],
|
||||
"summary": "Remind people who haven't answered",
|
||||
"description": "A fresh link to everyone who hasn't answered, or to `userIds` (named forms only). Nobody is sent the form or a reminder twice within 24 hours. Without `userIds` it also sends the form to anyone who has become eligible since it went out.\n\n**Who:** Supervisors and admins.",
|
||||
"description": "A fresh link to everyone who hasn't answered, or to `userIds` (named forms only). Nobody is sent the form or a reminder twice within 24 hours. Without `userIds` it also sends the form to anyone who has become eligible since it went out. Answers straight away; the emails go out in the background.\n\n**Who:** Supervisors and admins.",
|
||||
"operationId": "postEventsIdFeedbackRemind",
|
||||
"security": [
|
||||
{
|
||||
@@ -69940,18 +69940,24 @@
|
||||
"reminded": {
|
||||
"type": "integer",
|
||||
"minimum": -9007199254740991,
|
||||
"maximum": 9007199254740991
|
||||
"maximum": 9007199254740991,
|
||||
"description": "Being reminded"
|
||||
},
|
||||
"newlyInvited": {
|
||||
"type": "integer",
|
||||
"minimum": -9007199254740991,
|
||||
"maximum": 9007199254740991
|
||||
"maximum": 9007199254740991,
|
||||
"description": "Being sent the form for the first time"
|
||||
},
|
||||
"skipped": {
|
||||
"type": "integer",
|
||||
"minimum": -9007199254740991,
|
||||
"maximum": 9007199254740991
|
||||
},
|
||||
"queued": {
|
||||
"type": "boolean",
|
||||
"description": "Emails are going out in the background"
|
||||
},
|
||||
"message": {
|
||||
"type": "string"
|
||||
}
|
||||
@@ -69960,6 +69966,7 @@
|
||||
"reminded",
|
||||
"newlyInvited",
|
||||
"skipped",
|
||||
"queued",
|
||||
"message"
|
||||
],
|
||||
"additionalProperties": false
|
||||
|
||||
Vendored
+8
-2
@@ -2679,7 +2679,9 @@ export interface paths {
|
||||
put?: never;
|
||||
/**
|
||||
* Send the feedback form now
|
||||
* @description **Who:** Supervisors and admins.
|
||||
* @description Answers straight away: `invited` is how many it is going to; the emails go out in the background.
|
||||
*
|
||||
* **Who:** Supervisors and admins.
|
||||
*/
|
||||
post: operations["postEventsIdFeedbackSend-now"];
|
||||
delete?: never;
|
||||
@@ -7291,7 +7293,7 @@ export interface paths {
|
||||
put?: never;
|
||||
/**
|
||||
* Remind people who haven't answered
|
||||
* @description A fresh link to everyone who hasn't answered, or to `userIds` (named forms only). Nobody is sent the form or a reminder twice within 24 hours. Without `userIds` it also sends the form to anyone who has become eligible since it went out.
|
||||
* @description A fresh link to everyone who hasn't answered, or to `userIds` (named forms only). Nobody is sent the form or a reminder twice within 24 hours. Without `userIds` it also sends the form to anyone who has become eligible since it went out. Answers straight away; the emails go out in the background.
|
||||
*
|
||||
* **Who:** Supervisors and admins.
|
||||
*/
|
||||
@@ -38589,9 +38591,13 @@ export interface operations {
|
||||
content: {
|
||||
"application/json": {
|
||||
data: {
|
||||
/** @description Being reminded */
|
||||
reminded: number;
|
||||
/** @description Being sent the form for the first time */
|
||||
newlyInvited: number;
|
||||
skipped: number;
|
||||
/** @description Emails are going out in the background */
|
||||
queued: boolean;
|
||||
message: string;
|
||||
};
|
||||
};
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "hope-events",
|
||||
"version": "2.1.0",
|
||||
"version": "2.1.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "hope-events",
|
||||
"version": "2.1.0",
|
||||
"version": "2.1.1",
|
||||
"license": "ISC",
|
||||
"devDependencies": {
|
||||
"@axe-core/playwright": "^4.14.0",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hope-events",
|
||||
"version": "2.1.0",
|
||||
"version": "2.1.1",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"dev:backend": "cd backend && npm run dev",
|
||||
|
||||
Reference in New Issue
Block a user