domma-cms 0.86.0 → 0.87.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "domma-cms",
3
- "version": "0.86.0",
3
+ "version": "0.87.0",
4
4
  "description": "File-based CMS powered by Domma and Fastify. Run npx domma-cms my-site to create a new project.",
5
5
  "type": "module",
6
6
  "main": "server/server.js",
@@ -87,6 +87,14 @@ export async function todoRoutes(fastify, opts = {}) {
87
87
  return request.user?.id ?? request.user?.sub ?? null;
88
88
  }
89
89
 
90
+ /** A to-do was completed, removed or re-dated: this person's notices follow at once (0.87). Never throws. */
91
+ async function tell(uid) {
92
+ try {
93
+ const {todoChanged} = await import('../../services/notification-sources.js');
94
+ await todoChanged(uid, await loadAll(uid), localDay(new Date()));
95
+ } catch { /* a notice never breaks a to-do */ }
96
+ }
97
+
90
98
  async function loadAll(uid) {
91
99
  const {entries} = await listEntries(SLUG, {limit: 10000, sort: 'createdAt', order: 'asc'});
92
100
  let items = entries.map(toTodo);
@@ -185,6 +193,7 @@ export async function todoRoutes(fastify, opts = {}) {
185
193
  }
186
194
 
187
195
  const updated = await updateEntry(SLUG, id, merged);
196
+ if ('status' in patch || 'dueAt' in patch) tell(entry.data.userId);
188
197
  return reply.send(toTodo(updated));
189
198
  });
190
199
 
@@ -194,6 +203,7 @@ export async function todoRoutes(fastify, opts = {}) {
194
203
  const all = await loadAll(uid);
195
204
  const completed = all.filter(t => t.status === 'completed');
196
205
  await Promise.all(completed.map(t => deleteEntry(SLUG, t.id)));
206
+ if (completed.length) tell(uid);
197
207
  return reply.send({success: true, removed: completed.length});
198
208
  });
199
209
 
@@ -207,6 +217,7 @@ export async function todoRoutes(fastify, opts = {}) {
207
217
  if (uid && entry.data.userId !== uid) return reply.code(404).send({error: 'Todo not found'});
208
218
 
209
219
  await deleteEntry(SLUG, id);
220
+ tell(entry.data.userId);
210
221
  return reply.send({ success: true });
211
222
  });
212
223
  }
@@ -18,7 +18,7 @@
18
18
  import fs from 'fs';
19
19
  import path from 'path';
20
20
  import {config, getConfig} from '../config.js';
21
- import {notify, registerNotificationSource} from './notify.js';
21
+ import {notify, registerNotificationSource, resolveNotification} from './notify.js';
22
22
 
23
23
  const day = (d = new Date()) => d.toISOString().slice(0, 10);
24
24
 
@@ -133,6 +133,53 @@ export function recordCrash(err) {
133
133
  }
134
134
 
135
135
  /** Register every core source. Called once, before plugins load. */
136
+ /**
137
+ * To-dos (0.87): each person's overdue and due-today to-dos, as at most TWO
138
+ * notices for them (one listing the overdue, one the due today) - never one
139
+ * per task, so twenty overdue tasks are one notice. Completed ones and those
140
+ * with no due day are left out. Pure.
141
+ *
142
+ * @param {object[]} todos - {id, userId, text, status, dueAt}
143
+ * @param {string} today - YYYY-MM-DD (the server's day)
144
+ * @returns {{raise: object[], clear: string[]}} notices, and the keys to resolve (users with nothing in that state)
145
+ */
146
+ export function todoNotices(todos, today) {
147
+ const endOfDay = new Date(Date.parse(`${today}T00:00:00Z`) + 86400000).toISOString();
148
+ const byUser = new Map();
149
+ for (const t of todos || []) {
150
+ if (!t?.userId) continue;
151
+ if (!byUser.has(t.userId)) byUser.set(t.userId, {overdue: [], today: []});
152
+ if (t.status === 'completed') continue;
153
+ const day = /^(\d{4}-\d{2}-\d{2})/.exec(String(t.dueAt ?? ''))?.[1];
154
+ if (!day) continue;
155
+ if (day < today) byUser.get(t.userId).overdue.push(t);
156
+ else if (day === today) byUser.get(t.userId).today.push(t);
157
+ }
158
+ const name = (t) => String(t.text || 'To-do').replace(/\s+/g, ' ').trim().slice(0, 80) || 'To-do';
159
+ const list = (ts) => ts.slice(0, 5).map(t => `- ${name(t)}`).join('\n') + (ts.length > 5 ? `\n- and ${ts.length - 5} more` : '');
160
+ const raise = [];
161
+ const clear = [];
162
+ for (const [uid, {overdue, today: due}] of byUser) {
163
+ const who = {userIds: [String(uid)]};
164
+ if (overdue.length) {
165
+ raise.push({title: overdue.length === 1 ? `Overdue: ${name(overdue[0])}` : `${overdue.length} to-dos are overdue`,
166
+ body: list(overdue), severity: 'warning', link: '#/todo', dedupeKey: `todo-overdue:${uid}`, audience: who});
167
+ } else clear.push(`todo-overdue:${uid}`);
168
+ if (due.length) {
169
+ raise.push({title: due.length === 1 ? `Due today: ${name(due[0])}` : `${due.length} to-dos are due today`,
170
+ body: list(due), severity: 'info', link: '#/todo', dedupeKey: `todo-today:${uid}:${today}`, audience: who, expiresAt: endOfDay});
171
+ } else clear.push(`todo-today:${uid}:${today}`);
172
+ }
173
+ return {raise, clear};
174
+ }
175
+
176
+ /** A person's to-dos changed (completed, deleted, re-dated): if nothing is overdue any more, their notice goes now. */
177
+ export async function todoChanged(userId, todos, today) {
178
+ if (!userId) return;
179
+ const {clear} = todoNotices((todos || []).filter(t => t.userId === userId).concat([{userId}]), today);
180
+ for (const key of clear) await resolveNotification(key, 'core:todo').catch(() => 0);
181
+ }
182
+
136
183
  export async function registerCoreSources() {
137
184
  const plugins = await import('./plugins.js');
138
185
  const {classifyEntitlement} = await import('./pluginEntitlement.js');
@@ -154,6 +201,20 @@ export async function registerCoreSources() {
154
201
  }
155
202
  });
156
203
 
204
+ registerNotificationSource({
205
+ id: 'core:todo', label: 'To-dos overdue or due today', severity: 'warning', every: 'daily', atBoot: true,
206
+ description: "Each morning: a person's overdue to-dos, and those due today - one notice each, to that person.",
207
+ async check({notify: raise, resolve, now}) {
208
+ const {listEntries} = await import('./collections.js');
209
+ const {entries} = await listEntries('todo-items', {limit: 100000}).catch(() => ({entries: []}));
210
+ const d = now || new Date();
211
+ const today = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
212
+ const {raise: notices, clear} = todoNotices(entries.map(e => ({id: e.id, ...e.data})), today);
213
+ for (const key of clear) await resolve(key);
214
+ for (const n of notices) await raise(n);
215
+ }
216
+ });
217
+
157
218
  registerNotificationSource({
158
219
  id: 'core:licence', label: 'Plugin licences', severity: 'warning', every: 'daily', atBoot: true,
159
220
  description: 'A licensed plugin that has stopped, is in its grace period, or ends within 14 days.',