@rackbops/plugin-tracker 0.5.0 → 0.6.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/README.md +92 -6
- package/dist/plugin.js +874 -176
- package/package.json +2 -2
package/dist/plugin.js
CHANGED
|
@@ -191,6 +191,14 @@ async function optOut(store, task, recipientId, now, occurrenceId = null) {
|
|
|
191
191
|
at
|
|
192
192
|
});
|
|
193
193
|
}
|
|
194
|
+
async function liftBlock(store, actor, blockId, now) {
|
|
195
|
+
if (!actor.admin)
|
|
196
|
+
throw new ConsentError("only an admin lifts a block");
|
|
197
|
+
const block = await store.getBlock(blockId);
|
|
198
|
+
if (!block)
|
|
199
|
+
throw new ConsentError(`no block ${blockId}`);
|
|
200
|
+
return store.liftBlock(blockId, actor.userId, now.toISOString());
|
|
201
|
+
}
|
|
194
202
|
|
|
195
203
|
// node_modules/@rackbops/docket-core/dist/lanes.js
|
|
196
204
|
var LANES = ["notify", "execute"];
|
|
@@ -1916,6 +1924,10 @@ class ClaimStore {
|
|
|
1916
1924
|
// plugins/tracker/src/delivery-health.ts
|
|
1917
1925
|
var PAUSE_AFTER = 3;
|
|
1918
1926
|
var PAUSE_DETAIL = `delivery paused: ${PAUSE_AFTER} DMs in a row could not be delivered (DMs closed, or the bot is blocked)`;
|
|
1927
|
+
function recipientPauseDetail(userId) {
|
|
1928
|
+
return `delivery to {${userId}} paused: ${PAUSE_AFTER} DMs in a row could not be delivered`;
|
|
1929
|
+
}
|
|
1930
|
+
var PERSON_REF = /\{(u[1-9][0-9]*)\}/g;
|
|
1919
1931
|
function decidePause(failures, alreadyPaused) {
|
|
1920
1932
|
return !alreadyPaused && failures >= PAUSE_AFTER;
|
|
1921
1933
|
}
|
|
@@ -1941,6 +1953,9 @@ class DeliveryHealth {
|
|
|
1941
1953
|
this.chain = next.catch(() => {});
|
|
1942
1954
|
return next;
|
|
1943
1955
|
}
|
|
1956
|
+
exclusive(fn) {
|
|
1957
|
+
return this.locked(fn);
|
|
1958
|
+
}
|
|
1944
1959
|
get(userId) {
|
|
1945
1960
|
const r = this.db.query("SELECT * FROM delivery_health WHERE user_id = ?").get(userId);
|
|
1946
1961
|
if (!r)
|
|
@@ -1959,6 +1974,8 @@ class DeliveryHealth {
|
|
|
1959
1974
|
async recordFailure(userId, error, at) {
|
|
1960
1975
|
const notices = [];
|
|
1961
1976
|
const result = await this.locked(async () => {
|
|
1977
|
+
if (!await this.store.getUser(userId))
|
|
1978
|
+
return { failures: 0, paused: false };
|
|
1962
1979
|
this.db.query(`INSERT INTO delivery_health (user_id, failures, last_error, last_failed_at) VALUES (?, 1, ?, ?)
|
|
1963
1980
|
ON CONFLICT (user_id) DO UPDATE SET failures = delivery_health.failures + 1,
|
|
1964
1981
|
last_error = excluded.last_error, last_failed_at = excluded.last_failed_at`).run(userId, error, at);
|
|
@@ -1995,7 +2012,7 @@ class DeliveryHealth {
|
|
|
1995
2012
|
if (changes === 0 || task.status !== "active")
|
|
1996
2013
|
continue;
|
|
1997
2014
|
await this.store.updateTask(task.id, { status: "paused", at });
|
|
1998
|
-
const detail = asRecipient ?
|
|
2015
|
+
const detail = asRecipient ? recipientPauseDetail(userId) : PAUSE_DETAIL;
|
|
1999
2016
|
await this.store.addTaskEvent({ taskId: task.id, actorId: null, kind: "paused", detail, at });
|
|
2000
2017
|
if (asRecipient) {
|
|
2001
2018
|
const owner = await this.store.getUser(task.ownerId);
|
|
@@ -2502,6 +2519,7 @@ async function restoreHeldRun(store, task, held, before, after, now) {
|
|
|
2502
2519
|
|
|
2503
2520
|
// plugins/tracker/src/actions.ts
|
|
2504
2521
|
var MAX_ANSWER = 2000;
|
|
2522
|
+
var NO_LONGER_LISTED = "You are no longer on this tracker's list.";
|
|
2505
2523
|
var NO_SUCH_TASK = "You have no task with that id. `/tasks` lists yours.";
|
|
2506
2524
|
function clip(text, max = MAX_ANSWER) {
|
|
2507
2525
|
return text.length <= max ? text : `${text.slice(0, max - 3)}...`;
|
|
@@ -3031,6 +3049,8 @@ async function sendShare(d, p) {
|
|
|
3031
3049
|
async function finishShare(d, p, err) {
|
|
3032
3050
|
if (err === null)
|
|
3033
3051
|
return `Invited <@${p.targetDiscordId}> to \`${p.taskId}\`. They get a DM to accept or decline.`;
|
|
3052
|
+
if (!await d.store.getUser(p.targetId) || !await d.store.getTask(p.taskId))
|
|
3053
|
+
return `<@${p.targetDiscordId}> ${CANNOT_SHARE}`;
|
|
3034
3054
|
await d.store.removeRecipient(p.taskId, p.targetId);
|
|
3035
3055
|
await d.store.addTaskEvent({
|
|
3036
3056
|
taskId: p.taskId,
|
|
@@ -3222,12 +3242,9 @@ function runView(o, replies, people, viewer, now) {
|
|
|
3222
3242
|
answers: replies.filter((r) => r.occurrenceId === o.id).map((r) => `${people.get(r.userId)?.displayName ?? r.userId} ${replyText(r)}`)
|
|
3223
3243
|
};
|
|
3224
3244
|
}
|
|
3225
|
-
function changeView(e, viewer, now) {
|
|
3226
|
-
|
|
3227
|
-
|
|
3228
|
-
kind: e.kind.replaceAll("_", " "),
|
|
3229
|
-
detail: e.kind === "paused" || e.kind === "resumed" ? e.detail : null
|
|
3230
|
-
};
|
|
3245
|
+
function changeView(e, viewer, now, names = new Map) {
|
|
3246
|
+
const detail = e.kind === "paused" || e.kind === "resumed" ? e.detail.replace(PERSON_REF, (_, id) => names.get(id) ?? "someone no longer on the tracker") : null;
|
|
3247
|
+
return { at: formatInstant(e.at, viewer.timeZone, now), kind: e.kind.replaceAll("_", " "), detail };
|
|
3231
3248
|
}
|
|
3232
3249
|
function runLine(v) {
|
|
3233
3250
|
const late = v.late ? " (late)" : "";
|
|
@@ -3258,6 +3275,16 @@ async function loadHistory(d, user, taskId) {
|
|
|
3258
3275
|
people.set(u.id, u);
|
|
3259
3276
|
}
|
|
3260
3277
|
}
|
|
3278
|
+
const names = new Map;
|
|
3279
|
+
for (const e of changes) {
|
|
3280
|
+
for (const [, id] of e.detail.matchAll(PERSON_REF)) {
|
|
3281
|
+
if (id === undefined || names.has(id))
|
|
3282
|
+
continue;
|
|
3283
|
+
const u = await d.store.getUser(id);
|
|
3284
|
+
if (u)
|
|
3285
|
+
names.set(id, u.displayName ?? id);
|
|
3286
|
+
}
|
|
3287
|
+
}
|
|
3261
3288
|
const owner = task.ownerId === user.id ? user : await d.store.getUser(task.ownerId);
|
|
3262
3289
|
return {
|
|
3263
3290
|
task,
|
|
@@ -3265,7 +3292,7 @@ async function loadHistory(d, user, taskId) {
|
|
|
3265
3292
|
next: next ? formatInstant(next.dueAt, user.timeZone, now) : null,
|
|
3266
3293
|
runs: runs.slice(-HISTORY_RUNS).map((o) => runView(o, replies, people, user, now)),
|
|
3267
3294
|
earlierRuns: Math.max(0, runs.length - HISTORY_RUNS),
|
|
3268
|
-
changes: changes.slice(-HISTORY_CHANGES).map((e) => changeView(e, user, now)),
|
|
3295
|
+
changes: changes.slice(-HISTORY_CHANGES).map((e) => changeView(e, user, now, names)),
|
|
3269
3296
|
earlierChanges: Math.max(0, changes.length - HISTORY_CHANGES)
|
|
3270
3297
|
};
|
|
3271
3298
|
}
|
|
@@ -3442,7 +3469,10 @@ async function trackPrice(d, user, input) {
|
|
|
3442
3469
|
finish: async (result) => said(await finishPrice(d, user, pending, result))
|
|
3443
3470
|
};
|
|
3444
3471
|
}
|
|
3445
|
-
async function finishPrice(d,
|
|
3472
|
+
async function finishPrice(d, asked, v, seen) {
|
|
3473
|
+
const user = await d.store.getUser(asked.id);
|
|
3474
|
+
if (!user || !d.admissions.isRegistered(user.id))
|
|
3475
|
+
return { ok: false, error: NO_LONGER_LISTED };
|
|
3446
3476
|
if (!seen.ok)
|
|
3447
3477
|
return seen;
|
|
3448
3478
|
const type = d.types.price;
|
|
@@ -4087,6 +4117,7 @@ var MIGRATIONS = [
|
|
|
4087
4117
|
CREATE INDEX web_sessions_expiry ON web_sessions (expires_at);
|
|
4088
4118
|
`
|
|
4089
4119
|
];
|
|
4120
|
+
var BUSY_TIMEOUT_MS = 5000;
|
|
4090
4121
|
function migrate(db) {
|
|
4091
4122
|
const current = db.query("PRAGMA user_version").get().user_version;
|
|
4092
4123
|
if (current > MIGRATIONS.length) {
|
|
@@ -4098,14 +4129,223 @@ function migrate(db) {
|
|
|
4098
4129
|
db.exec(`PRAGMA user_version = ${version + 1}`);
|
|
4099
4130
|
})();
|
|
4100
4131
|
}
|
|
4132
|
+
return current;
|
|
4101
4133
|
}
|
|
4102
4134
|
function openDatabase(path) {
|
|
4103
4135
|
const db = new Database(path, { create: true });
|
|
4104
|
-
|
|
4105
|
-
|
|
4106
|
-
|
|
4107
|
-
|
|
4108
|
-
|
|
4136
|
+
try {
|
|
4137
|
+
if (path !== ":memory:")
|
|
4138
|
+
db.exec("PRAGMA journal_mode = WAL");
|
|
4139
|
+
db.exec(`PRAGMA busy_timeout = ${BUSY_TIMEOUT_MS}`);
|
|
4140
|
+
db.exec("PRAGMA secure_delete = ON");
|
|
4141
|
+
migrate(db);
|
|
4142
|
+
return db;
|
|
4143
|
+
} catch (err) {
|
|
4144
|
+
db.close();
|
|
4145
|
+
throw err;
|
|
4146
|
+
}
|
|
4147
|
+
}
|
|
4148
|
+
var META_TABLE = "tracker_meta";
|
|
4149
|
+
var VACUUMED = "vacuumed_for_secure_delete";
|
|
4150
|
+
function vacuumOnce(db, path) {
|
|
4151
|
+
if (path === ":memory:")
|
|
4152
|
+
return { ran: false };
|
|
4153
|
+
try {
|
|
4154
|
+
db.exec(`CREATE TABLE IF NOT EXISTS ${META_TABLE} (key TEXT PRIMARY KEY, value TEXT NOT NULL)`);
|
|
4155
|
+
if (db.query(`SELECT 1 FROM ${META_TABLE} WHERE key = ?`).get(VACUUMED) !== null)
|
|
4156
|
+
return { ran: false };
|
|
4157
|
+
db.exec("PRAGMA busy_timeout = 0");
|
|
4158
|
+
try {
|
|
4159
|
+
db.exec("VACUUM");
|
|
4160
|
+
const r = db.query("PRAGMA wal_checkpoint(TRUNCATE)").get();
|
|
4161
|
+
if (r && Number(r.busy) !== 0)
|
|
4162
|
+
throw new Error("the write-ahead log is busy");
|
|
4163
|
+
} finally {
|
|
4164
|
+
db.exec(`PRAGMA busy_timeout = ${BUSY_TIMEOUT_MS}`);
|
|
4165
|
+
}
|
|
4166
|
+
db.query(`INSERT INTO ${META_TABLE} (key, value) VALUES (?, ?)`).run(VACUUMED, new Date().toISOString());
|
|
4167
|
+
return { ran: true };
|
|
4168
|
+
} catch (err) {
|
|
4169
|
+
return { ran: false, error: err instanceof Error ? err.message : String(err) };
|
|
4170
|
+
}
|
|
4171
|
+
}
|
|
4172
|
+
|
|
4173
|
+
// plugins/tracker/src/roster.ts
|
|
4174
|
+
var FORGOTTEN = "forgotten";
|
|
4175
|
+
function historyNames(kind, detail, id) {
|
|
4176
|
+
switch (kind) {
|
|
4177
|
+
case "recipient_invited":
|
|
4178
|
+
case "recipient_accepted":
|
|
4179
|
+
case "recipient_declined":
|
|
4180
|
+
case "recipient_opted_out":
|
|
4181
|
+
return detail === id;
|
|
4182
|
+
case "blocked":
|
|
4183
|
+
return detail === `${id} 24h` || detail === `${id} permanent`;
|
|
4184
|
+
case "recipient_removed":
|
|
4185
|
+
return detail.startsWith(`${id}: `);
|
|
4186
|
+
case "paused":
|
|
4187
|
+
return detail.startsWith(`delivery to {${id}} paused:`);
|
|
4188
|
+
default:
|
|
4189
|
+
return false;
|
|
4190
|
+
}
|
|
4191
|
+
}
|
|
4192
|
+
function eventNames(type, text, id) {
|
|
4193
|
+
return type === "delivered" && text.startsWith(`${id} `);
|
|
4194
|
+
}
|
|
4195
|
+
function errorForms(id) {
|
|
4196
|
+
return new RegExp(`(recipient |user |delivery to )${id}(?![0-9A-Za-z_])`, "g");
|
|
4197
|
+
}
|
|
4198
|
+
function errorNames(text, id) {
|
|
4199
|
+
return text !== null && errorForms(id).test(text);
|
|
4200
|
+
}
|
|
4201
|
+
function redactError(text, id) {
|
|
4202
|
+
return text.replace(errorForms(id), `$1(${FORGOTTEN})`);
|
|
4203
|
+
}
|
|
4204
|
+
|
|
4205
|
+
class Roster {
|
|
4206
|
+
db;
|
|
4207
|
+
constructor(db) {
|
|
4208
|
+
this.db = db;
|
|
4209
|
+
}
|
|
4210
|
+
people() {
|
|
4211
|
+
const rows = this.db.query(`SELECT u.seq, u.discord_id, u.display_name, u.time_zone, u.preferred_hour, u.admin, u.created_at,
|
|
4212
|
+
a.admitted_by, a.registered_at, h.paused_at, h.failures
|
|
4213
|
+
FROM users u
|
|
4214
|
+
LEFT JOIN admissions a ON a.user_id = 'u' || u.seq
|
|
4215
|
+
LEFT JOIN delivery_health h ON h.user_id = 'u' || u.seq
|
|
4216
|
+
ORDER BY u.seq`).all();
|
|
4217
|
+
const owned = this.db.query("SELECT owner_id, status, COUNT(*) AS n FROM tasks GROUP BY owner_id, status").all();
|
|
4218
|
+
const receiving = this.db.query(`SELECT r.user_id, COUNT(*) AS n FROM task_recipients r JOIN tasks t ON r.task_id = 't' || t.seq
|
|
4219
|
+
WHERE r.state = 'accepted' AND t.status IN ('active', 'paused') GROUP BY r.user_id`).all();
|
|
4220
|
+
return rows.map((r) => {
|
|
4221
|
+
const id = `u${Number(r.seq)}`;
|
|
4222
|
+
const counts = { active: 0, paused: 0, done: 0, archived: 0 };
|
|
4223
|
+
for (const o of owned)
|
|
4224
|
+
if (o.owner_id === id && o.status in counts)
|
|
4225
|
+
counts[o.status] = Number(o.n);
|
|
4226
|
+
const str = (v) => v === null || v === undefined ? null : String(v);
|
|
4227
|
+
return {
|
|
4228
|
+
id,
|
|
4229
|
+
discordId: str(r.discord_id),
|
|
4230
|
+
displayName: str(r.display_name),
|
|
4231
|
+
timeZone: String(r.time_zone),
|
|
4232
|
+
preferredHour: Number(r.preferred_hour),
|
|
4233
|
+
admin: Number(r.admin) === 1,
|
|
4234
|
+
createdAt: String(r.created_at),
|
|
4235
|
+
admittedBy: str(r.admitted_by),
|
|
4236
|
+
registeredAt: str(r.registered_at),
|
|
4237
|
+
deliveryPausedAt: str(r.paused_at),
|
|
4238
|
+
failures: Number(r.failures ?? 0),
|
|
4239
|
+
owned: counts,
|
|
4240
|
+
receiving: Number(receiving.find((x) => x.user_id === id)?.n ?? 0)
|
|
4241
|
+
};
|
|
4242
|
+
});
|
|
4243
|
+
}
|
|
4244
|
+
checkpoint() {
|
|
4245
|
+
this.db.exec("PRAGMA busy_timeout = 0");
|
|
4246
|
+
try {
|
|
4247
|
+
const r = this.db.query("PRAGMA wal_checkpoint(TRUNCATE)").get();
|
|
4248
|
+
return !r || Number(r.busy) === 0;
|
|
4249
|
+
} finally {
|
|
4250
|
+
this.db.exec(`PRAGMA busy_timeout = ${BUSY_TIMEOUT_MS}`);
|
|
4251
|
+
}
|
|
4252
|
+
}
|
|
4253
|
+
othersNamed(taskId, userId, name) {
|
|
4254
|
+
const ids = new Set;
|
|
4255
|
+
for (const r of this.db.query("SELECT user_id FROM task_recipients WHERE task_id = ?").all(taskId))
|
|
4256
|
+
ids.add(r.user_id);
|
|
4257
|
+
for (const r of this.db.query("SELECT detail FROM task_events WHERE task_id = ? AND kind LIKE 'recipient_%'").all(taskId)) {
|
|
4258
|
+
const id = /^(u[1-9][0-9]*)(:|$)/.exec(r.detail)?.[1];
|
|
4259
|
+
if (id)
|
|
4260
|
+
ids.add(id);
|
|
4261
|
+
}
|
|
4262
|
+
ids.delete(userId);
|
|
4263
|
+
const named = this.db.query("SELECT 1 FROM users WHERE seq = ? AND display_name = ?");
|
|
4264
|
+
return [...ids].some((id) => named.get(Number(id.slice(1)), name) !== null);
|
|
4265
|
+
}
|
|
4266
|
+
admins() {
|
|
4267
|
+
return this.db.query("SELECT COUNT(*) AS n FROM users WHERE admin = 1").get().n;
|
|
4268
|
+
}
|
|
4269
|
+
activeBlocks(now) {
|
|
4270
|
+
const rows = this.db.query(`SELECT seq, owner_id, recipient_id, expires_at, created_at FROM invite_blocks
|
|
4271
|
+
WHERE lifted_at IS NULL AND (expires_at IS NULL OR expires_at > ?) ORDER BY seq DESC`).all(now.toISOString());
|
|
4272
|
+
return rows.map((r) => ({
|
|
4273
|
+
id: `b${Number(r.seq)}`,
|
|
4274
|
+
ownerId: String(r.owner_id),
|
|
4275
|
+
recipientId: String(r.recipient_id),
|
|
4276
|
+
expiresAt: r.expires_at === null ? null : String(r.expires_at),
|
|
4277
|
+
createdAt: String(r.created_at)
|
|
4278
|
+
}));
|
|
4279
|
+
}
|
|
4280
|
+
erase(userId) {
|
|
4281
|
+
const seq = /^u([1-9][0-9]*)$/.exec(userId)?.[1];
|
|
4282
|
+
if (seq === undefined)
|
|
4283
|
+
throw new Error(`no user ${userId}`);
|
|
4284
|
+
return this.db.transaction(() => {
|
|
4285
|
+
const person = this.db.query("SELECT display_name FROM users WHERE seq = ?").get(Number(seq));
|
|
4286
|
+
if (!person)
|
|
4287
|
+
throw new Error(`no user ${userId}`);
|
|
4288
|
+
const rows = {};
|
|
4289
|
+
const del = (table, where, ...params) => {
|
|
4290
|
+
rows[table] = (rows[table] ?? 0) + this.db.query(`DELETE FROM ${table} WHERE ${where}`).run(...params).changes;
|
|
4291
|
+
};
|
|
4292
|
+
const THEIR_TASKS = "SELECT 't' || seq FROM tasks WHERE owner_id = ?";
|
|
4293
|
+
const THEIR_RUNS = `SELECT 'o' || seq FROM occurrences WHERE task_id IN (${THEIR_TASKS})`;
|
|
4294
|
+
const pausedFor = this.db.query(`SELECT task_id FROM delivery_pauses WHERE user_id = ? AND task_id NOT IN (${THEIR_TASKS})`).all(userId, userId).map((r) => r.task_id);
|
|
4295
|
+
const taskIds = (sql) => this.db.query(sql).all(userId).map((r) => r.t);
|
|
4296
|
+
const recipientOf = new Set([
|
|
4297
|
+
...pausedFor,
|
|
4298
|
+
...taskIds("SELECT task_id AS t FROM task_recipients WHERE user_id = ?"),
|
|
4299
|
+
...this.db.query("SELECT task_id AS t, kind, detail FROM task_events WHERE instr(detail, ?) > 0").all(userId).filter((r) => historyNames(r.kind, r.detail, userId)).map((r) => r.t)
|
|
4300
|
+
]);
|
|
4301
|
+
del("events", `occurrence_id IN (${THEIR_RUNS})`, userId);
|
|
4302
|
+
del("delivery_claims", `occurrence_id IN (${THEIR_RUNS}) OR user_id = ?`, userId, userId);
|
|
4303
|
+
del("replies", `task_id IN (${THEIR_TASKS}) OR user_id = ?`, userId, userId);
|
|
4304
|
+
del("task_events", `task_id IN (${THEIR_TASKS}) OR actor_id = ?`, userId, userId);
|
|
4305
|
+
del("series", `task_id IN (${THEIR_TASKS})`, userId);
|
|
4306
|
+
del("task_recipients", `task_id IN (${THEIR_TASKS}) OR user_id = ?`, userId, userId);
|
|
4307
|
+
del("delivery_pauses", `task_id IN (${THEIR_TASKS}) OR user_id = ?`, userId, userId);
|
|
4308
|
+
del("occurrences", `task_id IN (${THEIR_TASKS})`, userId);
|
|
4309
|
+
del("tasks", "owner_id = ?", userId);
|
|
4310
|
+
const containing = (table, columns, column) => this.db.query(`SELECT seq, ${columns} FROM ${table} WHERE instr(${column}, ?) > 0`).all(userId);
|
|
4311
|
+
for (const r of containing("task_events", "kind, detail", "detail")) {
|
|
4312
|
+
if (historyNames(String(r.kind), String(r.detail), userId))
|
|
4313
|
+
del("task_events", "seq = ?", Number(r.seq));
|
|
4314
|
+
}
|
|
4315
|
+
for (const r of containing("events", "type, text", "text")) {
|
|
4316
|
+
if (eventNames(String(r.type), String(r.text), userId))
|
|
4317
|
+
del("events", "seq = ?", Number(r.seq));
|
|
4318
|
+
else if (errorNames(String(r.text), userId))
|
|
4319
|
+
this.db.query("UPDATE events SET text = ? WHERE seq = ?").run(redactError(String(r.text), userId), r.seq);
|
|
4320
|
+
}
|
|
4321
|
+
for (const r of containing("occurrences", "error", "error")) {
|
|
4322
|
+
if (errorNames(String(r.error), userId))
|
|
4323
|
+
this.db.query("UPDATE occurrences SET error = ? WHERE seq = ?").run(redactError(String(r.error), userId), r.seq);
|
|
4324
|
+
}
|
|
4325
|
+
if (person.display_name !== null) {
|
|
4326
|
+
const legacy = `delivery to ${person.display_name} paused:`;
|
|
4327
|
+
const rowsNaming = this.db.query("SELECT seq, task_id, detail FROM task_events WHERE kind = 'paused' AND actor_id IS NULL").all();
|
|
4328
|
+
for (const r of rowsNaming) {
|
|
4329
|
+
if (!recipientOf.has(r.task_id) || !r.detail.startsWith(legacy))
|
|
4330
|
+
continue;
|
|
4331
|
+
if (this.othersNamed(r.task_id, userId, person.display_name))
|
|
4332
|
+
continue;
|
|
4333
|
+
del("task_events", "seq = ?", r.seq);
|
|
4334
|
+
}
|
|
4335
|
+
}
|
|
4336
|
+
del("invite_blocks", "owner_id = ? OR recipient_id = ?", userId, userId);
|
|
4337
|
+
this.db.query("UPDATE invite_blocks SET lifted_by = ? WHERE lifted_by = ?").run(FORGOTTEN, userId);
|
|
4338
|
+
del("admissions", "user_id = ?", userId);
|
|
4339
|
+
this.db.query("UPDATE admissions SET admitted_by = ? WHERE admitted_by = ?").run(FORGOTTEN, userId);
|
|
4340
|
+
del("delivery_health", "user_id = ?", userId);
|
|
4341
|
+
del("web_sessions", "user_id = ?", userId);
|
|
4342
|
+
del("web_login_tokens", "user_id = ?", userId);
|
|
4343
|
+
del("users", "seq = ?", Number(seq));
|
|
4344
|
+
const held = this.db.query("SELECT 1 FROM delivery_pauses WHERE task_id = ? LIMIT 1");
|
|
4345
|
+
const freed = [...new Set(pausedFor)].filter((t) => held.get(t) === null);
|
|
4346
|
+
return { rows, freed };
|
|
4347
|
+
})();
|
|
4348
|
+
}
|
|
4109
4349
|
}
|
|
4110
4350
|
|
|
4111
4351
|
// plugins/tracker/src/store.ts
|
|
@@ -4428,160 +4668,97 @@ class SqliteStore {
|
|
|
4428
4668
|
}
|
|
4429
4669
|
}
|
|
4430
4670
|
|
|
4431
|
-
// plugins/tracker/src/
|
|
4432
|
-
|
|
4433
|
-
|
|
4434
|
-
|
|
4435
|
-
|
|
4436
|
-
|
|
4437
|
-
|
|
4438
|
-
|
|
4439
|
-
|
|
4440
|
-
|
|
4441
|
-
return JSON.stringify(a) === JSON.stringify(b);
|
|
4442
|
-
}
|
|
4443
|
-
async function applyEdit(d, task, user, patch) {
|
|
4444
|
-
const fields = Object.keys(patch);
|
|
4445
|
-
if (fields.length === 0)
|
|
4446
|
-
return task;
|
|
4447
|
-
const at = d.clock.now().toISOString();
|
|
4448
|
-
const updated = await d.store.updateTask(task.id, { ...patch, at });
|
|
4449
|
-
await d.store.addTaskEvent({ taskId: task.id, actorId: user.id, kind: "edited", detail: fields.join(", "), at });
|
|
4450
|
-
return updated;
|
|
4671
|
+
// plugins/tracker/src/admin.ts
|
|
4672
|
+
var NO_SUCH_PERSON = "There is no such person on this tracker's list.";
|
|
4673
|
+
var NO_SUCH_BLOCK = "There is no such block in force.";
|
|
4674
|
+
var LAST_ADMIN = "That is the tracker's only admin: make someone else an admin first.";
|
|
4675
|
+
var BUSY = "The tracker is busy sending reminders; nothing was deleted. Try again in a minute.";
|
|
4676
|
+
var TICK_WAIT_MS = 20000;
|
|
4677
|
+
var CONFIGURED_ADMIN = "That admin is named in TRACKER_ADMIN_DISCORD_IDS, which makes them an admin again at every start: remove them from the configuration first.";
|
|
4678
|
+
var CONFIGURED_SELF = "You are named in TRACKER_ADMIN_DISCORD_IDS: this erases you now, but the next start makes you an admin again, as a new person, unless you are removed from the configuration first.";
|
|
4679
|
+
function isConfiguredAdmin(d, u) {
|
|
4680
|
+
return u.discordId !== null && d.configuredAdmins.has(u.discordId);
|
|
4451
4681
|
}
|
|
4452
|
-
|
|
4453
|
-
|
|
4454
|
-
return queued[0] ?? null;
|
|
4455
|
-
}
|
|
4456
|
-
function savedText(task, user, next, now, what) {
|
|
4457
|
-
const cadence = task.schedule ? describeSchedule(task.schedule, user, user.timeZone, now) : "no schedule";
|
|
4458
|
-
const when = task.status === "paused" ? "paused" : next ? formatInstant(next.dueAt, user.timeZone, now) : "nothing scheduled";
|
|
4459
|
-
return clip(`Saved \`${task.id}\` ${what}: ${cadence}. Next: ${when}.`);
|
|
4460
|
-
}
|
|
4461
|
-
var blank = (v) => v === undefined || v.trim() === "";
|
|
4462
|
-
async function editReminder(d, user, taskId, input) {
|
|
4463
|
-
const task = await owned(d, user, taskId, "reminder");
|
|
4464
|
-
if (typeof task === "string")
|
|
4465
|
-
return { ok: false, error: task };
|
|
4466
|
-
const config = task.config ?? {};
|
|
4467
|
-
const text = blank(input.text) ? String(config.text ?? "") : input.text;
|
|
4468
|
-
const repeat = input.repeat ?? repeatOf(task.schedule);
|
|
4469
|
-
const plan = reminderPlan(d, user, { text, repeat, ...input.when !== undefined ? { when: input.when } : {} }, task.schedule);
|
|
4470
|
-
if (!plan.ok)
|
|
4471
|
-
return plan;
|
|
4472
|
-
let updated = config.text === plan.text ? task : await applyEdit(d, task, user, { title: clip(plan.text, MAX_TITLE), config: { ...config, text: plan.text } });
|
|
4473
|
-
if (!same(plan.schedule, task.schedule))
|
|
4474
|
-
updated = await rescheduleKeepingSnoozes(d, updated, user, plan.schedule, user.id);
|
|
4475
|
-
return { ok: true, task: updated, text: savedText(updated, user, await nextRun(d, updated), d.clock.now(), "(reminder)") };
|
|
4682
|
+
function ticksSettled(d) {
|
|
4683
|
+
return d.lanes.idle(TICK_WAIT_MS);
|
|
4476
4684
|
}
|
|
4477
|
-
|
|
4478
|
-
|
|
4479
|
-
const ctx = { zone: owner.timeZone, preferredHour: owner.preferredHour };
|
|
4480
|
-
let after = new Date(Date.parse(`${date}T00:00:00Z`) - ((s.leadDays ?? 0) + 2) * DAY_MS3);
|
|
4481
|
-
for (let i = 0;i < 1000; i++) {
|
|
4482
|
-
const due = nextDue(s, after, ctx);
|
|
4483
|
-
if (!due)
|
|
4484
|
-
return null;
|
|
4485
|
-
const on = periodDate(s, due, owner.timeZone);
|
|
4486
|
-
if (on >= date)
|
|
4487
|
-
return on;
|
|
4488
|
-
after = due;
|
|
4489
|
-
}
|
|
4490
|
-
return null;
|
|
4685
|
+
function isPersonId(id) {
|
|
4686
|
+
return /^u[1-9][0-9]{0,15}$/.test(id);
|
|
4491
4687
|
}
|
|
4492
|
-
function
|
|
4493
|
-
|
|
4494
|
-
if (s?.kind !== "period")
|
|
4495
|
-
return "";
|
|
4496
|
-
const w = wallClock(now, owner.timeZone);
|
|
4497
|
-
const today = `${w.year}-${String(w.month).padStart(2, "0")}-${String(w.day).padStart(2, "0")}`;
|
|
4498
|
-
if (s.anchor >= today)
|
|
4499
|
-
return s.anchor;
|
|
4500
|
-
return periodDateFrom(s, today, owner) ?? s.anchor;
|
|
4688
|
+
async function person(d, id) {
|
|
4689
|
+
return isPersonId(id) ? d.store.getUser(id) : null;
|
|
4501
4690
|
}
|
|
4502
|
-
|
|
4503
|
-
|
|
4504
|
-
if (old?.kind !== "period")
|
|
4505
|
-
return false;
|
|
4506
|
-
const fired = (await d.store.listOccurrences({ taskId: task.id })).filter((o) => o.status !== "queued" && !o.dedupeKey.startsWith(SNOOZE_PREFIX));
|
|
4507
|
-
return fired.some((o) => periodDate(old, new Date(o.dueAt), owner.timeZone) === date);
|
|
4691
|
+
function who(u) {
|
|
4692
|
+
return u.displayName ?? u.discordId ?? u.id;
|
|
4508
4693
|
}
|
|
4509
|
-
async function
|
|
4510
|
-
|
|
4511
|
-
|
|
4512
|
-
|
|
4513
|
-
if (
|
|
4514
|
-
return { ok: false, error:
|
|
4515
|
-
|
|
4694
|
+
async function setAdminFlag(d, admin, targetId, flag) {
|
|
4695
|
+
if (!admin.admin)
|
|
4696
|
+
return { ok: false, error: NOT_ADMIN };
|
|
4697
|
+
const target = await person(d, targetId);
|
|
4698
|
+
if (!target)
|
|
4699
|
+
return { ok: false, error: NO_SUCH_PERSON };
|
|
4700
|
+
if (target.admin === flag)
|
|
4701
|
+
return { ok: true, text: `${who(target)} ${flag ? "is already" : "is not"} an admin.` };
|
|
4702
|
+
if (!flag && d.roster.admins() <= 1)
|
|
4703
|
+
return { ok: false, error: LAST_ADMIN };
|
|
4704
|
+
if (!flag && isConfiguredAdmin(d, target))
|
|
4705
|
+
return { ok: false, error: CONFIGURED_ADMIN };
|
|
4706
|
+
await d.store.updateUser(target.id, { admin: flag });
|
|
4707
|
+
d.log.info(`${admin.id} ${flag ? "made" : "revoked"} ${target.id} ${flag ? "an admin" : "as admin"}`);
|
|
4708
|
+
return { ok: true, text: flag ? `${who(target)} is now an admin.` : `${who(target)} is no longer an admin.` };
|
|
4709
|
+
}
|
|
4710
|
+
async function resumeDelivery(d, admin, targetId) {
|
|
4711
|
+
if (!admin.admin)
|
|
4712
|
+
return { ok: false, error: NOT_ADMIN };
|
|
4713
|
+
const target = await person(d, targetId);
|
|
4714
|
+
if (!target)
|
|
4715
|
+
return { ok: false, error: NO_SUCH_PERSON };
|
|
4716
|
+
const resumed = await d.health.resume(target.id, d.clock.now());
|
|
4717
|
+
if (!resumed)
|
|
4718
|
+
return { ok: false, error: `Delivery to ${who(target)} is not paused.` };
|
|
4719
|
+
return { ok: true, text: `Delivery to ${who(target)} resumed; ${resumed.tasks.length} task(s) back on.` };
|
|
4720
|
+
}
|
|
4721
|
+
async function liftDeclineBlock(d, admin, blockId) {
|
|
4722
|
+
if (!admin.admin)
|
|
4723
|
+
return { ok: false, error: NOT_ADMIN };
|
|
4724
|
+
const block = /^b[1-9][0-9]{0,15}$/.test(blockId) ? await d.store.getBlock(blockId) : null;
|
|
4516
4725
|
const now = d.clock.now();
|
|
4517
|
-
|
|
4518
|
-
|
|
4519
|
-
|
|
4520
|
-
const
|
|
4521
|
-
const
|
|
4522
|
-
|
|
4523
|
-
|
|
4524
|
-
|
|
4525
|
-
|
|
4526
|
-
every: input.every ?? old.every,
|
|
4527
|
-
unit: input.unit ?? old.unit,
|
|
4528
|
-
lead: input.lead ?? old.leadDays ?? 0,
|
|
4529
|
-
...note !== undefined ? { note } : {}
|
|
4530
|
-
});
|
|
4531
|
-
if (!plan.ok)
|
|
4532
|
-
return plan;
|
|
4533
|
-
const sameShape = plan.schedule.every === old.every && plan.schedule.unit === old.unit && plan.schedule.leadDays === (old.leadDays ?? 0);
|
|
4534
|
-
const schedule = sameShape && periodDateFrom(old, plan.schedule.anchor, user) === plan.schedule.anchor ? old : plan.schedule;
|
|
4535
|
-
const problems = scheduleProblems(schedule);
|
|
4536
|
-
if (problems.length > 0)
|
|
4537
|
-
return { ok: false, error: `That schedule does not work: ${problems.join("; ")}.` };
|
|
4538
|
-
const patch = {};
|
|
4539
|
-
if (plan.name !== task.title)
|
|
4540
|
-
patch.title = plan.name;
|
|
4541
|
-
const newAmount = plan.amount !== (carried ?? config.amount);
|
|
4542
|
-
const nextConfig = { amount: newAmount ? plan.amount : config.amount, currency: plan.currency, ...plan.note ? { note: plan.note } : {} };
|
|
4543
|
-
if (!same(nextConfig, config))
|
|
4544
|
-
patch.config = nextConfig;
|
|
4545
|
-
if (newAmount && carried !== null)
|
|
4546
|
-
patch.state = { ...state, amount: plan.amount };
|
|
4547
|
-
let updated = await applyEdit(d, task, user, patch);
|
|
4548
|
-
let next = await nextRun(d, updated);
|
|
4549
|
-
if (!same(schedule, old)) {
|
|
4550
|
-
const asked = await askedAbout(d, task, user, schedule.anchor);
|
|
4551
|
-
updated = await rescheduleKeepingSnoozes(d, updated, user, schedule, user.id);
|
|
4552
|
-
next = (asked ? null : await firstAskIfDue(d, updated, user, schedule)) ?? await nextRun(d, updated);
|
|
4553
|
-
}
|
|
4554
|
-
const ask = updated.status === "paused" ? "paused" : askText(next, plan.days, user, now);
|
|
4555
|
-
const cadence = describeSchedule(schedule, user, user.timeZone, now);
|
|
4556
|
-
return { ok: true, task: updated, text: clip(`Saved \`${updated.id}\` (renewal): ${cadence}. Next ask: ${ask}.`) };
|
|
4726
|
+
if (!block || (await activeBlock(d.store, block.ownerId, block.recipientId, now))?.id !== block.id)
|
|
4727
|
+
return { ok: false, error: NO_SUCH_BLOCK };
|
|
4728
|
+
await liftBlock(d.store, { userId: admin.id, admin: true }, block.id, now);
|
|
4729
|
+
const owner = await d.store.getUser(block.ownerId);
|
|
4730
|
+
const recipient = await d.store.getUser(block.recipientId);
|
|
4731
|
+
return {
|
|
4732
|
+
ok: true,
|
|
4733
|
+
text: `Lifted: ${owner ? who(owner) : block.ownerId} may invite ${recipient ? who(recipient) : block.recipientId} again.`
|
|
4734
|
+
};
|
|
4557
4735
|
}
|
|
4558
|
-
async function
|
|
4559
|
-
|
|
4560
|
-
|
|
4561
|
-
|
|
4562
|
-
|
|
4563
|
-
|
|
4564
|
-
|
|
4565
|
-
|
|
4566
|
-
|
|
4567
|
-
|
|
4568
|
-
|
|
4736
|
+
async function forgetPerson(d, actor, targetId) {
|
|
4737
|
+
if (actor.id !== targetId && !actor.admin)
|
|
4738
|
+
return { ok: false, error: NOT_ADMIN };
|
|
4739
|
+
const target = await person(d, targetId);
|
|
4740
|
+
if (!target)
|
|
4741
|
+
return { ok: false, error: NO_SUCH_PERSON };
|
|
4742
|
+
if (target.admin && d.roster.admins() <= 1)
|
|
4743
|
+
return { ok: false, error: LAST_ADMIN };
|
|
4744
|
+
if (actor.id !== target.id && isConfiguredAdmin(d, target))
|
|
4745
|
+
return { ok: false, error: CONFIGURED_ADMIN };
|
|
4746
|
+
const now = d.clock.now();
|
|
4747
|
+
const erased = await d.health.exclusive(async () => {
|
|
4748
|
+
if (d.lanes.busy())
|
|
4749
|
+
return null;
|
|
4750
|
+
const e = d.roster.erase(target.id);
|
|
4751
|
+
for (const taskId of e.freed)
|
|
4752
|
+
await d.health.release(taskId, null, "resumed: a recipient left the tracker", now);
|
|
4753
|
+
return e;
|
|
4569
4754
|
});
|
|
4570
|
-
if (
|
|
4571
|
-
return
|
|
4572
|
-
|
|
4573
|
-
|
|
4574
|
-
|
|
4575
|
-
|
|
4576
|
-
|
|
4577
|
-
if (!same(nextConfig, config))
|
|
4578
|
-
patch.config = nextConfig;
|
|
4579
|
-
let updated = await applyEdit(d, task, user, patch);
|
|
4580
|
-
if (schedule?.kind === "poll" && (schedule.every !== plan.hours || schedule.unit !== "hour")) {
|
|
4581
|
-
const next = { ...schedule, every: plan.hours, unit: "hour" };
|
|
4582
|
-
updated = await rescheduleKeepingSnoozes(d, updated, user, next, user.id);
|
|
4583
|
-
}
|
|
4584
|
-
return { ok: true, task: updated, text: savedText(updated, user, await nextRun(d, updated), d.clock.now(), "(price)") };
|
|
4755
|
+
if (erased === null)
|
|
4756
|
+
return { ok: false, error: BUSY };
|
|
4757
|
+
if (!d.roster.checkpoint())
|
|
4758
|
+
d.log.warn(`after forgetting ${target.id}: the write-ahead log is busy, so SQLite's next checkpoint copies it back`);
|
|
4759
|
+
const total = Object.values(erased.rows).reduce((a, b) => a + b, 0);
|
|
4760
|
+
d.log.info(`forgot ${target.id} (${actor.id === target.id ? "at their own request" : `removed by ${actor.id}`}): ${total} row(s) deleted`);
|
|
4761
|
+
return { ok: true, text: actor.id === target.id ? "Everything the tracker held about you is deleted." : `${who(target)} is removed from the tracker.` };
|
|
4585
4762
|
}
|
|
4586
4763
|
|
|
4587
4764
|
// node_modules/@rackbops/styles/rackbops-noir/bundle.css
|
|
@@ -5810,6 +5987,7 @@ function page(f) {
|
|
|
5810
5987
|
<span class="rb-muted">${f.signedIn.name}</span>
|
|
5811
5988
|
<a class="rb-link" href="${f.base}/">My tasks</a>
|
|
5812
5989
|
<a class="rb-link" href="${f.base}/settings">Settings</a>
|
|
5990
|
+
${f.signedIn.admin ? html`<a class="rb-link" href="${f.base}/admin">Admin</a>` : null}
|
|
5813
5991
|
<form method="post" action="${f.base}/logout"><input type="hidden" name="csrf" value="${f.signedIn.csrf}"><button class="rb-btn rb-btn--ghost rb-btn--sm" type="submit">Sign out</button></form>
|
|
5814
5992
|
</nav>` : null;
|
|
5815
5993
|
return html`<!doctype html>
|
|
@@ -5883,7 +6061,7 @@ function name3(u) {
|
|
|
5883
6061
|
return u.displayName ?? "you";
|
|
5884
6062
|
}
|
|
5885
6063
|
function framed(v, title, body) {
|
|
5886
|
-
return page({ base: v.base, title, signedIn: { name: name3(v.user), csrf: v.csrf }, body });
|
|
6064
|
+
return page({ base: v.base, title, signedIn: { name: name3(v.user), csrf: v.csrf, admin: v.user.admin }, body });
|
|
5887
6065
|
}
|
|
5888
6066
|
function signInHelpPage(base, note) {
|
|
5889
6067
|
return page({
|
|
@@ -5967,6 +6145,7 @@ function historyPage(v, h, extra = {}) {
|
|
|
5967
6145
|
<p><a class="rb-link" href="${v.base}/">My tasks</a></p>
|
|
5968
6146
|
<h1>${h.task.title}</h1>
|
|
5969
6147
|
${extra.flash ?? null}
|
|
6148
|
+
${extra.owner ? html`<p class="rb-muted">Owned by ${extra.owner}; you can see it, not change it.</p>` : null}
|
|
5970
6149
|
<p>${h.task.type}, ${h.task.status === "archived" ? "deleted" : h.task.status}, ${h.cadence}. Next: ${h.task.status === "active" ? h.next ?? "nothing scheduled" : "nothing, while it is not active"}.</p>
|
|
5971
6150
|
${extra.controls ?? null}
|
|
5972
6151
|
</section>
|
|
@@ -6001,6 +6180,10 @@ ${form.error ? html`<div class="rb-alert rb-alert--danger" role="alert"><p class
|
|
|
6001
6180
|
</div>
|
|
6002
6181
|
<div><button class="rb-btn rb-btn--primary" type="submit">Save</button></div>
|
|
6003
6182
|
</form>
|
|
6183
|
+
</section>
|
|
6184
|
+
<section>
|
|
6185
|
+
<h2>Forget me</h2>
|
|
6186
|
+
<p>Delete everything the tracker holds about you, for good. <a class="rb-link" href="${v.base}/forget">Forget me</a></p>
|
|
6004
6187
|
</section>`);
|
|
6005
6188
|
}
|
|
6006
6189
|
function notFoundPage(base, v) {
|
|
@@ -6152,9 +6335,441 @@ function notice(key, error) {
|
|
|
6152
6335
|
return text ? html`<div class="rb-alert rb-alert--success" role="status"><p class="rb-alert__title">${text}</p></div>` : null;
|
|
6153
6336
|
}
|
|
6154
6337
|
|
|
6338
|
+
// plugins/tracker/src/web/admin-pages.ts
|
|
6339
|
+
var CONFIGURED_NOTE = "Named in TRACKER_ADMIN_DISCORD_IDS: made an admin again at every start, so they cannot be revoked or removed here. Remove them from the configuration first.";
|
|
6340
|
+
var CONFIRM_WORD = "forget";
|
|
6341
|
+
function nameOf(p, fallback = "someone") {
|
|
6342
|
+
if (!p)
|
|
6343
|
+
return fallback;
|
|
6344
|
+
return p.displayName ?? p.id;
|
|
6345
|
+
}
|
|
6346
|
+
function unmention(text) {
|
|
6347
|
+
return text.replace(/<@([0-9]{17,20})>/g, "$1");
|
|
6348
|
+
}
|
|
6349
|
+
function flash(result) {
|
|
6350
|
+
if (!result)
|
|
6351
|
+
return null;
|
|
6352
|
+
const text = prose(unmention(result.text));
|
|
6353
|
+
return result.ok ? html`<div class="rb-alert rb-alert--success" role="status"><p>${text}</p></div>` : html`<div class="rb-alert rb-alert--danger" role="alert"><p>${text}</p></div>`;
|
|
6354
|
+
}
|
|
6355
|
+
function hidden(v) {
|
|
6356
|
+
return html`<input type="hidden" name="csrf" value="${v.csrf}">`;
|
|
6357
|
+
}
|
|
6358
|
+
function adminNav(v) {
|
|
6359
|
+
return html`<p class="tr-row"><a class="rb-link" href="${v.base}/admin">People</a> <a class="rb-link" href="${v.base}/admin/tasks">All tasks</a></p>`;
|
|
6360
|
+
}
|
|
6361
|
+
function personHref(v, id, action = "") {
|
|
6362
|
+
return `${v.base}/admin/people/${encodeURIComponent(id)}${action ? `/${action}` : ""}`;
|
|
6363
|
+
}
|
|
6364
|
+
function admittedBy(p, names) {
|
|
6365
|
+
if (p.admittedBy === null)
|
|
6366
|
+
return "the configuration";
|
|
6367
|
+
if (p.admittedBy === FORGOTTEN)
|
|
6368
|
+
return "an admin since forgotten";
|
|
6369
|
+
return names.get(p.admittedBy) ?? p.admittedBy;
|
|
6370
|
+
}
|
|
6371
|
+
function delivery(p, viewer, now) {
|
|
6372
|
+
if (p.deliveryPausedAt)
|
|
6373
|
+
return `paused since ${formatInstant(p.deliveryPausedAt, viewer.timeZone, now)}`;
|
|
6374
|
+
return p.failures > 0 ? `on (${p.failures} failed in a row)` : "on";
|
|
6375
|
+
}
|
|
6376
|
+
function counts(p) {
|
|
6377
|
+
const o = p.owned;
|
|
6378
|
+
return `${o.active} active, ${o.paused} paused, ${o.done} done, ${o.archived} deleted; receives ${p.receiving}`;
|
|
6379
|
+
}
|
|
6380
|
+
function adminPage(v, data, o = {}) {
|
|
6381
|
+
const names = new Map(data.people.map((p) => [p.id, nameOf(p)]));
|
|
6382
|
+
const rows = data.people.map((p) => html`<tr>
|
|
6383
|
+
<td><a class="rb-link" href="${personHref(v, p.id)}">${nameOf(p)}</a>${p.admin ? html` <span class="rb-badge">admin</span>` : null}</td>
|
|
6384
|
+
<td>${p.registeredAt ? "registered" : "admitted, not registered"}</td>
|
|
6385
|
+
<td>${p.timeZone}, ${String(p.preferredHour).padStart(2, "0")}:00</td>
|
|
6386
|
+
<td>${delivery(p, v.user, data.now)}</td>
|
|
6387
|
+
<td>${counts(p)}</td>
|
|
6388
|
+
</tr>`);
|
|
6389
|
+
const blocks = data.blocks.length === 0 ? html`<p class="rb-muted">No decline blocks are in force.</p>` : html`<ul>${data.blocks.map((b) => html`<li class="tr-row">${names.get(b.ownerId) ?? b.ownerId} may not invite ${names.get(b.recipientId) ?? b.recipientId} ${b.expiresAt ? `until ${formatInstant(b.expiresAt, v.user.timeZone, data.now)}` : "until an admin lifts it"}
|
|
6390
|
+
<form method="post" action="${v.base}/admin/blocks/${encodeURIComponent(b.id)}/lift">${hidden(v)}<button class="rb-btn rb-btn--ghost rb-btn--sm" type="submit">Lift</button></form></li>`)}</ul>`;
|
|
6391
|
+
return framed(v, "Admin", html`<section>
|
|
6392
|
+
<h1>People</h1>
|
|
6393
|
+
${adminNav(v)}
|
|
6394
|
+
${flash(o.result ?? null)}
|
|
6395
|
+
<div class="rb-table-scroll"><table class="rb-table">
|
|
6396
|
+
<thead><tr><th scope="col">Person</th><th scope="col">Status</th><th scope="col">Zone, hour</th><th scope="col">Delivery</th><th scope="col">Tasks</th></tr></thead>
|
|
6397
|
+
<tbody>${rows}</tbody>
|
|
6398
|
+
</table></div>
|
|
6399
|
+
<p class="rb-muted">Admitted by: ${data.people.map((p) => `${nameOf(p)} -- ${admittedBy(p, names)}`).join("; ")}.</p>
|
|
6400
|
+
</section>
|
|
6401
|
+
<section>
|
|
6402
|
+
<h2>Allow a person</h2>
|
|
6403
|
+
<form method="post" action="${v.base}/admin/allow" class="tr-stack">
|
|
6404
|
+
${hidden(v)}
|
|
6405
|
+
<div class="rb-field">
|
|
6406
|
+
<label class="rb-label" for="discord_id">Discord user id</label>
|
|
6407
|
+
<input class="rb-input" id="discord_id" name="discord_id" value="${o.allow ?? ""}" maxlength="20" inputmode="numeric" required>
|
|
6408
|
+
<p class="rb-field__help">What <code>/allow</code> does: they can then <code>/register</code>.</p>
|
|
6409
|
+
</div>
|
|
6410
|
+
<div><button class="rb-btn rb-btn--primary" type="submit">Allow</button></div>
|
|
6411
|
+
</form>
|
|
6412
|
+
</section>
|
|
6413
|
+
<section>
|
|
6414
|
+
<h2>Decline blocks</h2>
|
|
6415
|
+
${blocks}
|
|
6416
|
+
</section>`);
|
|
6417
|
+
}
|
|
6418
|
+
function adminTasksPage(v, tasks) {
|
|
6419
|
+
const rows = tasks.map(({ task, owner, receiving }) => html`<tr>
|
|
6420
|
+
<td><a class="rb-link" href="${taskHref(v, task.id)}">${task.title}</a></td>
|
|
6421
|
+
<td>${task.type}</td>
|
|
6422
|
+
<td>${owner}</td>
|
|
6423
|
+
<td>${task.status === "archived" ? "deleted" : task.status}</td>
|
|
6424
|
+
<td>${receiving}</td>
|
|
6425
|
+
</tr>`);
|
|
6426
|
+
return framed(v, "All tasks", html`<section>
|
|
6427
|
+
<h1>All tasks</h1>
|
|
6428
|
+
${adminNav(v)}
|
|
6429
|
+
${tasks.length === 0 ? html`<p class="rb-muted">There are no tasks.</p>` : html`<div class="rb-table-scroll"><table class="rb-table">
|
|
6430
|
+
<thead><tr><th scope="col">Task</th><th scope="col">Type</th><th scope="col">Owner</th><th scope="col">Status</th><th scope="col">Recipients</th></tr></thead>
|
|
6431
|
+
<tbody>${rows}</tbody>
|
|
6432
|
+
</table></div>`}
|
|
6433
|
+
</section>`);
|
|
6434
|
+
}
|
|
6435
|
+
function post2(v, p, action, label, style) {
|
|
6436
|
+
return html`<form method="post" action="${personHref(v, p.id, action)}">${hidden(v)}<button class="rb-btn ${style} rb-btn--sm" type="submit">${label}</button></form>`;
|
|
6437
|
+
}
|
|
6438
|
+
function personPage(v, p, tasks, now, result = null, configured = false) {
|
|
6439
|
+
return framed(v, nameOf(p), html`<section>
|
|
6440
|
+
${adminNav(v)}
|
|
6441
|
+
<h1>${nameOf(p)}${p.admin ? html` <span class="rb-badge">admin</span>` : null}</h1>
|
|
6442
|
+
${flash(result)}
|
|
6443
|
+
<ul>
|
|
6444
|
+
<li>Discord id: ${p.discordId ?? "none"}</li>
|
|
6445
|
+
<li>${p.registeredAt ? `Registered ${formatInstant(p.registeredAt, v.user.timeZone, now)}` : "Admitted, not registered yet"}</li>
|
|
6446
|
+
<li>Zone and hour: ${p.timeZone}, ${String(p.preferredHour).padStart(2, "0")}:00</li>
|
|
6447
|
+
<li>Delivery: ${delivery(p, v.user, now)}</li>
|
|
6448
|
+
<li>Tasks: ${counts(p)}</li>
|
|
6449
|
+
</ul>
|
|
6450
|
+
${configured ? html`<p class="rb-muted">${CONFIGURED_NOTE}</p>` : null}
|
|
6451
|
+
<div class="tr-row">
|
|
6452
|
+
${p.admin ? configured ? null : post2(v, p, "revoke", "Revoke admin", "rb-btn--ghost") : post2(v, p, "grant", "Make admin", "rb-btn--ghost")}
|
|
6453
|
+
${p.deliveryPausedAt ? post2(v, p, "resume-delivery", "Resume delivery", "rb-btn--accent") : null}
|
|
6454
|
+
${configured && p.id !== v.user.id ? null : post2(v, p, "forget", "Remove from the tracker", "rb-btn--danger")}
|
|
6455
|
+
</div>
|
|
6456
|
+
</section>
|
|
6457
|
+
<section>
|
|
6458
|
+
<h2>Their tasks</h2>
|
|
6459
|
+
${tasks.length === 0 ? html`<p class="rb-muted">None.</p>` : html`<ul>${tasks.map((t) => html`<li><a class="rb-link" href="${taskHref(v, t.id)}">${t.title}</a> -- ${t.type}, ${t.status === "archived" ? "deleted" : t.status}</li>`)}</ul>`}
|
|
6460
|
+
</section>`);
|
|
6461
|
+
}
|
|
6462
|
+
var WHAT_GOES = "every task (deleted ones too) with its runs, replies and history; your replies, answers and history on other people's tasks, " + "and your place on the tasks shared with you; decline blocks either way; delivery pauses; your settings and sign-in sessions; and your place on the list.";
|
|
6463
|
+
function forgetPage(v, note) {
|
|
6464
|
+
return framed(v, "Forget me", html`<section class="rb-card">
|
|
6465
|
+
<h1>Forget me</h1>
|
|
6466
|
+
<p>This deletes everything the tracker holds about you, at once and for good: ${WHAT_GOES}</p>
|
|
6467
|
+
<p>Nothing is kept or archived. Messages the bot already sent you stay in your Discord DMs, where only you can delete them. To use the tracker again later, an admin has to <code>/allow</code> you again, and you start from nothing.</p>
|
|
6468
|
+
${note ? html`<div class="rb-alert rb-alert--warning" role="note"><p>${note}</p></div>` : null}
|
|
6469
|
+
<form method="post" action="${v.base}/forget">${hidden(v)}<button class="rb-btn rb-btn--danger" type="submit">Continue</button></form>
|
|
6470
|
+
</section>`);
|
|
6471
|
+
}
|
|
6472
|
+
function confirmForgetPage(v, o) {
|
|
6473
|
+
const title = o.self ? "Delete everything about you?" : `Remove ${o.name} from the tracker?`;
|
|
6474
|
+
const what = o.self ? html`<p>Everything the tracker holds about you is deleted: ${WHAT_GOES} You are signed out.</p>` : html`<p>Everything the tracker holds about ${o.name} is deleted, as forget-me deletes it: their tasks, their replies and history, blocks, pauses, settings and sessions. They are signed out and must be allowed again to come back.</p>`;
|
|
6475
|
+
return framed(v, o.self ? "Forget me" : `Remove ${o.name}`, html`<section class="rb-card">
|
|
6476
|
+
<h1>${title}</h1>
|
|
6477
|
+
${what}
|
|
6478
|
+
${o.note ? html`<div class="rb-alert rb-alert--warning" role="note"><p>${o.note}</p></div>` : null}
|
|
6479
|
+
${o.error ? html`<div class="rb-alert rb-alert--danger" role="alert"><p>${o.error}</p></div>` : null}
|
|
6480
|
+
<form method="post" action="${o.action}" class="tr-stack">
|
|
6481
|
+
${hidden(v)}
|
|
6482
|
+
<input type="hidden" name="confirm" value="yes">
|
|
6483
|
+
<div class="rb-field">
|
|
6484
|
+
<label class="rb-label" for="word">Type ${CONFIRM_WORD} to confirm</label>
|
|
6485
|
+
<input class="rb-input" id="word" name="word" maxlength="20" autocomplete="off" required>
|
|
6486
|
+
</div>
|
|
6487
|
+
<div class="tr-row"><button class="rb-btn rb-btn--danger" type="submit">${o.self ? "Delete everything" : "Remove"}</button>
|
|
6488
|
+
<a class="rb-btn rb-btn--ghost" href="${o.self ? `${v.base}/settings` : `${v.base}/admin`}">Keep</a></div>
|
|
6489
|
+
</form>
|
|
6490
|
+
</section>`);
|
|
6491
|
+
}
|
|
6492
|
+
|
|
6493
|
+
// plugins/tracker/src/web/admin.ts
|
|
6494
|
+
function unknownPage(base) {
|
|
6495
|
+
return htmlResponse(notFoundPage(base), 404);
|
|
6496
|
+
}
|
|
6497
|
+
function fresh(a, admin, fn) {
|
|
6498
|
+
return a.queue(async () => {
|
|
6499
|
+
const me = await a.d.store.getUser(a.v.user.id);
|
|
6500
|
+
if (!me || admin && !me.admin)
|
|
6501
|
+
return null;
|
|
6502
|
+
return fn(me);
|
|
6503
|
+
});
|
|
6504
|
+
}
|
|
6505
|
+
function said2(done) {
|
|
6506
|
+
return done.ok ? { ok: true, text: done.text } : { ok: false, text: done.error };
|
|
6507
|
+
}
|
|
6508
|
+
function confirmed(form) {
|
|
6509
|
+
if (form.get("confirm") !== "yes")
|
|
6510
|
+
return "ask";
|
|
6511
|
+
return (form.get("word") ?? "").trim().toLowerCase() === CONFIRM_WORD ? "yes" : "wrong";
|
|
6512
|
+
}
|
|
6513
|
+
var WRONG_WORD = `Type ${CONFIRM_WORD} to confirm. Nothing was deleted.`;
|
|
6514
|
+
async function peoplePage(a, result, status, allow) {
|
|
6515
|
+
const data = { people: a.d.roster.people(), blocks: a.d.roster.activeBlocks(a.d.clock.now()), now: a.d.clock.now() };
|
|
6516
|
+
return htmlResponse(adminPage(a.v, data, { result, ...allow !== undefined ? { allow } : {} }), status);
|
|
6517
|
+
}
|
|
6518
|
+
async function onePerson(a, id, result = null) {
|
|
6519
|
+
const p = a.d.roster.people().find((x) => x.id === id);
|
|
6520
|
+
if (!p)
|
|
6521
|
+
return htmlResponse(notFoundPage(a.v.base, a.v), 404);
|
|
6522
|
+
const tasks = await a.d.store.listTasks({ ownerId: p.id });
|
|
6523
|
+
return htmlResponse(personPage(a.v, p, tasks, a.d.clock.now(), result, isConfiguredAdmin(a.d, p)), result && !result.ok ? 400 : 200);
|
|
6524
|
+
}
|
|
6525
|
+
async function adminGet(a, r) {
|
|
6526
|
+
if (r.kind === "admin")
|
|
6527
|
+
return peoplePage(a, null, 200);
|
|
6528
|
+
if (r.kind === "admin-person")
|
|
6529
|
+
return onePerson(a, r.id);
|
|
6530
|
+
const people = new Map(a.d.roster.people().map((p) => [p.id, nameOf(p)]));
|
|
6531
|
+
const rows = [];
|
|
6532
|
+
for (const task of await a.d.store.listTasks()) {
|
|
6533
|
+
const receiving = (await a.d.store.listRecipients(task.id)).filter((x) => x.state === "accepted").length;
|
|
6534
|
+
rows.push({ task, owner: people.get(task.ownerId) ?? task.ownerId, receiving });
|
|
6535
|
+
}
|
|
6536
|
+
return htmlResponse(adminTasksPage(a.v, rows));
|
|
6537
|
+
}
|
|
6538
|
+
async function adminPost(a, r, form) {
|
|
6539
|
+
const base = a.v.base;
|
|
6540
|
+
if (r.kind === "admin-allow") {
|
|
6541
|
+
const raw = (form.get("discord_id") ?? "").trim();
|
|
6542
|
+
if (!/^[0-9]{17,20}$/.test(raw))
|
|
6543
|
+
return peoplePage(a, { ok: false, text: "That is not a Discord user id (17 to 20 digits)." }, 400, raw.slice(0, 20));
|
|
6544
|
+
const membership = await a.memberOf(raw);
|
|
6545
|
+
const text = await fresh(a, true, (me) => allowPerson(a.d, me, { discordId: raw, bot: false, membership }));
|
|
6546
|
+
if (text === null)
|
|
6547
|
+
return unknownPage(base);
|
|
6548
|
+
const ok = text.startsWith("Allowed") || text.includes("already on the list");
|
|
6549
|
+
return peoplePage(a, { ok, text }, ok ? 200 : 400);
|
|
6550
|
+
}
|
|
6551
|
+
if (r.kind === "admin-lift") {
|
|
6552
|
+
const done = await fresh(a, true, (me) => liftDeclineBlock(a.d, me, r.id));
|
|
6553
|
+
if (done === null)
|
|
6554
|
+
return unknownPage(base);
|
|
6555
|
+
return peoplePage(a, said2(done), done.ok ? 200 : 400);
|
|
6556
|
+
}
|
|
6557
|
+
if (r.kind !== "admin-act")
|
|
6558
|
+
return unknownPage(base);
|
|
6559
|
+
if (r.action === "forget") {
|
|
6560
|
+
const target = a.d.roster.people().find((x) => x.id === r.id);
|
|
6561
|
+
if (!target)
|
|
6562
|
+
return htmlResponse(notFoundPage(base, a.v), 404);
|
|
6563
|
+
const step = confirmed(form);
|
|
6564
|
+
const self = target.id === a.v.user.id;
|
|
6565
|
+
const note = self && isConfiguredAdmin(a.d, target) ? { note: CONFIGURED_SELF } : {};
|
|
6566
|
+
const page = (error) => confirmForgetPage(a.v, { action: personHref(a.v, target.id, "forget"), self, name: nameOf(target), ...note, ...error ? { error } : {} });
|
|
6567
|
+
if (step === "ask")
|
|
6568
|
+
return htmlResponse(page());
|
|
6569
|
+
if (step === "wrong")
|
|
6570
|
+
return htmlResponse(page(WRONG_WORD), 400);
|
|
6571
|
+
if (!await ticksSettled(a.d))
|
|
6572
|
+
return htmlResponse(page(BUSY), 503);
|
|
6573
|
+
const done = await fresh(a, true, (me) => forgetPerson(a.d, me, target.id));
|
|
6574
|
+
if (done === null)
|
|
6575
|
+
return unknownPage(base);
|
|
6576
|
+
if (!done.ok)
|
|
6577
|
+
return htmlResponse(page(done.error), done.error === NO_SUCH_PERSON ? 404 : done.error === BUSY ? 503 : 400);
|
|
6578
|
+
if (self)
|
|
6579
|
+
return redirect(`${base}/signin?forgotten=1`, [a.signOutCookie]);
|
|
6580
|
+
return peoplePage(a, said2(done), 200);
|
|
6581
|
+
}
|
|
6582
|
+
const act = r.action === "grant" ? (me) => setAdminFlag(a.d, me, r.id, true) : r.action === "revoke" ? (me) => setAdminFlag(a.d, me, r.id, false) : (me) => resumeDelivery(a.d, me, r.id);
|
|
6583
|
+
const done = await fresh(a, true, act);
|
|
6584
|
+
if (done === null)
|
|
6585
|
+
return unknownPage(base);
|
|
6586
|
+
if (!done.ok && done.error === NO_SUCH_PERSON)
|
|
6587
|
+
return htmlResponse(notFoundPage(base, a.v), 404);
|
|
6588
|
+
const me = await a.d.store.getUser(a.v.user.id);
|
|
6589
|
+
if (!me?.admin)
|
|
6590
|
+
return redirect(`${base}/`);
|
|
6591
|
+
return onePerson(a, r.id, said2(done));
|
|
6592
|
+
}
|
|
6593
|
+
async function forgetRoute(a, method, form) {
|
|
6594
|
+
const base = a.v.base;
|
|
6595
|
+
const note = isConfiguredAdmin(a.d, a.v.user) ? CONFIGURED_SELF : undefined;
|
|
6596
|
+
if (method === "GET" || !form)
|
|
6597
|
+
return htmlResponse(forgetPage(a.v, note));
|
|
6598
|
+
const page = (error) => confirmForgetPage(a.v, { action: `${base}/forget`, self: true, name: nameOf(a.v.user), ...note ? { note } : {}, ...error ? { error } : {} });
|
|
6599
|
+
const step = confirmed(form);
|
|
6600
|
+
if (step === "ask")
|
|
6601
|
+
return htmlResponse(page());
|
|
6602
|
+
if (step === "wrong")
|
|
6603
|
+
return htmlResponse(page(WRONG_WORD), 400);
|
|
6604
|
+
if (!await ticksSettled(a.d))
|
|
6605
|
+
return htmlResponse(page(BUSY), 503);
|
|
6606
|
+
const done = await fresh(a, false, (me) => forgetPerson(a.d, me, me.id));
|
|
6607
|
+
if (done === null)
|
|
6608
|
+
return redirect(`${base}/signin`, [a.signOutCookie]);
|
|
6609
|
+
if (!done.ok)
|
|
6610
|
+
return htmlResponse(page(done.error), done.error === BUSY ? 503 : 400);
|
|
6611
|
+
return redirect(`${base}/signin?forgotten=1`, [a.signOutCookie]);
|
|
6612
|
+
}
|
|
6613
|
+
|
|
6614
|
+
// plugins/tracker/src/edit.ts
|
|
6615
|
+
async function owned(d, user, taskId, type) {
|
|
6616
|
+
const task = await ownLiveTask(d, user, taskId);
|
|
6617
|
+
if (!task || task.type !== type)
|
|
6618
|
+
return NO_SUCH_TASK;
|
|
6619
|
+
if (!editable(task))
|
|
6620
|
+
return FINISHED;
|
|
6621
|
+
return task;
|
|
6622
|
+
}
|
|
6623
|
+
function same(a, b) {
|
|
6624
|
+
return JSON.stringify(a) === JSON.stringify(b);
|
|
6625
|
+
}
|
|
6626
|
+
async function applyEdit(d, task, user, patch) {
|
|
6627
|
+
const fields = Object.keys(patch);
|
|
6628
|
+
if (fields.length === 0)
|
|
6629
|
+
return task;
|
|
6630
|
+
const at = d.clock.now().toISOString();
|
|
6631
|
+
const updated = await d.store.updateTask(task.id, { ...patch, at });
|
|
6632
|
+
await d.store.addTaskEvent({ taskId: task.id, actorId: user.id, kind: "edited", detail: fields.join(", "), at });
|
|
6633
|
+
return updated;
|
|
6634
|
+
}
|
|
6635
|
+
async function nextRun(d, task) {
|
|
6636
|
+
const queued = await d.store.listOccurrences({ taskId: task.id, status: "queued" });
|
|
6637
|
+
return queued[0] ?? null;
|
|
6638
|
+
}
|
|
6639
|
+
function savedText(task, user, next, now, what) {
|
|
6640
|
+
const cadence = task.schedule ? describeSchedule(task.schedule, user, user.timeZone, now) : "no schedule";
|
|
6641
|
+
const when = task.status === "paused" ? "paused" : next ? formatInstant(next.dueAt, user.timeZone, now) : "nothing scheduled";
|
|
6642
|
+
return clip(`Saved \`${task.id}\` ${what}: ${cadence}. Next: ${when}.`);
|
|
6643
|
+
}
|
|
6644
|
+
var blank = (v) => v === undefined || v.trim() === "";
|
|
6645
|
+
async function editReminder(d, user, taskId, input) {
|
|
6646
|
+
const task = await owned(d, user, taskId, "reminder");
|
|
6647
|
+
if (typeof task === "string")
|
|
6648
|
+
return { ok: false, error: task };
|
|
6649
|
+
const config = task.config ?? {};
|
|
6650
|
+
const text = blank(input.text) ? String(config.text ?? "") : input.text;
|
|
6651
|
+
const repeat = input.repeat ?? repeatOf(task.schedule);
|
|
6652
|
+
const plan = reminderPlan(d, user, { text, repeat, ...input.when !== undefined ? { when: input.when } : {} }, task.schedule);
|
|
6653
|
+
if (!plan.ok)
|
|
6654
|
+
return plan;
|
|
6655
|
+
let updated = config.text === plan.text ? task : await applyEdit(d, task, user, { title: clip(plan.text, MAX_TITLE), config: { ...config, text: plan.text } });
|
|
6656
|
+
if (!same(plan.schedule, task.schedule))
|
|
6657
|
+
updated = await rescheduleKeepingSnoozes(d, updated, user, plan.schedule, user.id);
|
|
6658
|
+
return { ok: true, task: updated, text: savedText(updated, user, await nextRun(d, updated), d.clock.now(), "(reminder)") };
|
|
6659
|
+
}
|
|
6660
|
+
var DAY_MS3 = 24 * 60 * 60 * 1000;
|
|
6661
|
+
function periodDateFrom(s, date, owner) {
|
|
6662
|
+
const ctx = { zone: owner.timeZone, preferredHour: owner.preferredHour };
|
|
6663
|
+
let after = new Date(Date.parse(`${date}T00:00:00Z`) - ((s.leadDays ?? 0) + 2) * DAY_MS3);
|
|
6664
|
+
for (let i = 0;i < 1000; i++) {
|
|
6665
|
+
const due = nextDue(s, after, ctx);
|
|
6666
|
+
if (!due)
|
|
6667
|
+
return null;
|
|
6668
|
+
const on = periodDate(s, due, owner.timeZone);
|
|
6669
|
+
if (on >= date)
|
|
6670
|
+
return on;
|
|
6671
|
+
after = due;
|
|
6672
|
+
}
|
|
6673
|
+
return null;
|
|
6674
|
+
}
|
|
6675
|
+
function renewalDate(task, owner, now) {
|
|
6676
|
+
const s = task.schedule;
|
|
6677
|
+
if (s?.kind !== "period")
|
|
6678
|
+
return "";
|
|
6679
|
+
const w = wallClock(now, owner.timeZone);
|
|
6680
|
+
const today = `${w.year}-${String(w.month).padStart(2, "0")}-${String(w.day).padStart(2, "0")}`;
|
|
6681
|
+
if (s.anchor >= today)
|
|
6682
|
+
return s.anchor;
|
|
6683
|
+
return periodDateFrom(s, today, owner) ?? s.anchor;
|
|
6684
|
+
}
|
|
6685
|
+
async function askedAbout(d, task, owner, date) {
|
|
6686
|
+
const old = task.schedule;
|
|
6687
|
+
if (old?.kind !== "period")
|
|
6688
|
+
return false;
|
|
6689
|
+
const fired = (await d.store.listOccurrences({ taskId: task.id })).filter((o) => o.status !== "queued" && !o.dedupeKey.startsWith(SNOOZE_PREFIX));
|
|
6690
|
+
return fired.some((o) => periodDate(old, new Date(o.dueAt), owner.timeZone) === date);
|
|
6691
|
+
}
|
|
6692
|
+
async function editRenewal(d, user, taskId, input) {
|
|
6693
|
+
const task = await owned(d, user, taskId, "renewal");
|
|
6694
|
+
if (typeof task === "string")
|
|
6695
|
+
return { ok: false, error: task };
|
|
6696
|
+
if (task.schedule?.kind !== "period")
|
|
6697
|
+
return { ok: false, error: FINISHED };
|
|
6698
|
+
const old = task.schedule;
|
|
6699
|
+
const now = d.clock.now();
|
|
6700
|
+
const config = task.config;
|
|
6701
|
+
const state = task.state;
|
|
6702
|
+
const carried = state && typeof state.amount === "number" ? state.amount : null;
|
|
6703
|
+
const note = input.note ?? config.note;
|
|
6704
|
+
const plan = renewalPlan(d, user, {
|
|
6705
|
+
name: blank(input.name) ? task.title : input.name,
|
|
6706
|
+
amount: input.amount ?? carried ?? config.amount,
|
|
6707
|
+
currency: blank(input.currency) ? config.currency : input.currency,
|
|
6708
|
+
renews: blank(input.renews) ? renewalDate(task, user, now) : input.renews,
|
|
6709
|
+
every: input.every ?? old.every,
|
|
6710
|
+
unit: input.unit ?? old.unit,
|
|
6711
|
+
lead: input.lead ?? old.leadDays ?? 0,
|
|
6712
|
+
...note !== undefined ? { note } : {}
|
|
6713
|
+
});
|
|
6714
|
+
if (!plan.ok)
|
|
6715
|
+
return plan;
|
|
6716
|
+
const sameShape = plan.schedule.every === old.every && plan.schedule.unit === old.unit && plan.schedule.leadDays === (old.leadDays ?? 0);
|
|
6717
|
+
const schedule = sameShape && periodDateFrom(old, plan.schedule.anchor, user) === plan.schedule.anchor ? old : plan.schedule;
|
|
6718
|
+
const problems = scheduleProblems(schedule);
|
|
6719
|
+
if (problems.length > 0)
|
|
6720
|
+
return { ok: false, error: `That schedule does not work: ${problems.join("; ")}.` };
|
|
6721
|
+
const patch = {};
|
|
6722
|
+
if (plan.name !== task.title)
|
|
6723
|
+
patch.title = plan.name;
|
|
6724
|
+
const newAmount = plan.amount !== (carried ?? config.amount);
|
|
6725
|
+
const nextConfig = { amount: newAmount ? plan.amount : config.amount, currency: plan.currency, ...plan.note ? { note: plan.note } : {} };
|
|
6726
|
+
if (!same(nextConfig, config))
|
|
6727
|
+
patch.config = nextConfig;
|
|
6728
|
+
if (newAmount && carried !== null)
|
|
6729
|
+
patch.state = { ...state, amount: plan.amount };
|
|
6730
|
+
let updated = await applyEdit(d, task, user, patch);
|
|
6731
|
+
let next = await nextRun(d, updated);
|
|
6732
|
+
if (!same(schedule, old)) {
|
|
6733
|
+
const asked = await askedAbout(d, task, user, schedule.anchor);
|
|
6734
|
+
updated = await rescheduleKeepingSnoozes(d, updated, user, schedule, user.id);
|
|
6735
|
+
next = (asked ? null : await firstAskIfDue(d, updated, user, schedule)) ?? await nextRun(d, updated);
|
|
6736
|
+
}
|
|
6737
|
+
const ask = updated.status === "paused" ? "paused" : askText(next, plan.days, user, now);
|
|
6738
|
+
const cadence = describeSchedule(schedule, user, user.timeZone, now);
|
|
6739
|
+
return { ok: true, task: updated, text: clip(`Saved \`${updated.id}\` (renewal): ${cadence}. Next ask: ${ask}.`) };
|
|
6740
|
+
}
|
|
6741
|
+
async function editPrice(d, user, taskId, input) {
|
|
6742
|
+
const task = await owned(d, user, taskId, "price");
|
|
6743
|
+
if (typeof task === "string")
|
|
6744
|
+
return { ok: false, error: task };
|
|
6745
|
+
const config = task.config;
|
|
6746
|
+
const schedule = task.schedule;
|
|
6747
|
+
const plan = priceSettingsPlan({
|
|
6748
|
+
name: input.name ?? task.title,
|
|
6749
|
+
hours: input.hours ?? (schedule?.kind === "poll" ? schedule.every : undefined),
|
|
6750
|
+
drop: input.drop ?? config.dropPercent ?? DEFAULT_DROP_PERCENT,
|
|
6751
|
+
baseline: input.baseline ?? config.baseline ?? DEFAULT_BASELINE
|
|
6752
|
+
});
|
|
6753
|
+
if (!plan.ok)
|
|
6754
|
+
return plan;
|
|
6755
|
+
const patch = {};
|
|
6756
|
+
const title = plan.name || titleFor(config.url);
|
|
6757
|
+
if (title !== task.title)
|
|
6758
|
+
patch.title = title;
|
|
6759
|
+
const nextConfig = { ...config, dropPercent: plan.drop, baseline: plan.baseline };
|
|
6760
|
+
if (!same(nextConfig, config))
|
|
6761
|
+
patch.config = nextConfig;
|
|
6762
|
+
let updated = await applyEdit(d, task, user, patch);
|
|
6763
|
+
if (schedule?.kind === "poll" && (schedule.every !== plan.hours || schedule.unit !== "hour")) {
|
|
6764
|
+
const next = { ...schedule, every: plan.hours, unit: "hour" };
|
|
6765
|
+
updated = await rescheduleKeepingSnoozes(d, updated, user, next, user.id);
|
|
6766
|
+
}
|
|
6767
|
+
return { ok: true, task: updated, text: savedText(updated, user, await nextRun(d, updated), d.clock.now(), "(price)") };
|
|
6768
|
+
}
|
|
6769
|
+
|
|
6155
6770
|
// plugins/tracker/src/web/editor.ts
|
|
6156
6771
|
var READING = "I am still reading the page of your last new price tracker; try again when it is done.";
|
|
6157
|
-
var GONE =
|
|
6772
|
+
var GONE = NO_LONGER_LISTED;
|
|
6158
6773
|
function asViewer(e, fn) {
|
|
6159
6774
|
return e.queue(async () => {
|
|
6160
6775
|
const user = await e.d.store.getUser(e.v.user.id);
|
|
@@ -6283,8 +6898,10 @@ async function taskPage(e, id, done, error) {
|
|
|
6283
6898
|
const view = await loadHistory(e.d, e.v.user, id);
|
|
6284
6899
|
if (!view)
|
|
6285
6900
|
return htmlResponse(notFoundPage(e.v.base, e.v), 404);
|
|
6286
|
-
const
|
|
6287
|
-
|
|
6901
|
+
const own = view.task.ownerId === e.v.user.id;
|
|
6902
|
+
const controls = own ? ownerControls(e.v, view.task) : null;
|
|
6903
|
+
const owner = own ? null : (await e.d.store.getUser(view.task.ownerId))?.displayName ?? view.task.ownerId;
|
|
6904
|
+
return htmlResponse(historyPage(e.v, view, { controls, flash: notice(done, error), owner }), error ? 400 : 200);
|
|
6288
6905
|
}
|
|
6289
6906
|
function current(d, user, task) {
|
|
6290
6907
|
const now = d.clock.now();
|
|
@@ -6359,6 +6976,19 @@ async function actionPost(e, id, action, form) {
|
|
|
6359
6976
|
var TASK_PATH = /^\/tasks\/([^/]+)$/;
|
|
6360
6977
|
var TASK_ACTION = /^\/tasks\/([^/]+)\/(edit|pause|resume|delete)$/;
|
|
6361
6978
|
var NEW_PATH = /^\/new\/([a-z]+)$/;
|
|
6979
|
+
var PERSON_PATH = /^\/admin\/people\/([^/]+)$/;
|
|
6980
|
+
var PERSON_ACTION = /^\/admin\/people\/([^/]+)\/(grant|revoke|resume-delivery|forget)$/;
|
|
6981
|
+
var BLOCK_LIFT = /^\/admin\/blocks\/([^/]+)\/lift$/;
|
|
6982
|
+
function isAdminRoute(r) {
|
|
6983
|
+
return r.kind.startsWith("admin");
|
|
6984
|
+
}
|
|
6985
|
+
function segment(raw) {
|
|
6986
|
+
try {
|
|
6987
|
+
return decodeURIComponent(raw ?? "");
|
|
6988
|
+
} catch {
|
|
6989
|
+
return "";
|
|
6990
|
+
}
|
|
6991
|
+
}
|
|
6362
6992
|
function route(path) {
|
|
6363
6993
|
if (path === "/")
|
|
6364
6994
|
return { kind: "tasks" };
|
|
@@ -6366,6 +6996,23 @@ function route(path) {
|
|
|
6366
6996
|
return { kind: "settings" };
|
|
6367
6997
|
if (path === "/logout")
|
|
6368
6998
|
return { kind: "logout" };
|
|
6999
|
+
if (path === "/forget")
|
|
7000
|
+
return { kind: "forget" };
|
|
7001
|
+
if (path === "/admin")
|
|
7002
|
+
return { kind: "admin" };
|
|
7003
|
+
if (path === "/admin/tasks")
|
|
7004
|
+
return { kind: "admin-tasks" };
|
|
7005
|
+
if (path === "/admin/allow")
|
|
7006
|
+
return { kind: "admin-allow" };
|
|
7007
|
+
const personAct = PERSON_ACTION.exec(path);
|
|
7008
|
+
if (personAct)
|
|
7009
|
+
return { kind: "admin-act", id: segment(personAct[1]), action: personAct[2] };
|
|
7010
|
+
const onePerson = PERSON_PATH.exec(path);
|
|
7011
|
+
if (onePerson)
|
|
7012
|
+
return { kind: "admin-person", id: segment(onePerson[1]) };
|
|
7013
|
+
const lift = BLOCK_LIFT.exec(path);
|
|
7014
|
+
if (lift)
|
|
7015
|
+
return { kind: "admin-lift", id: segment(lift[1]) };
|
|
6369
7016
|
const created = NEW_PATH.exec(path);
|
|
6370
7017
|
if (created) {
|
|
6371
7018
|
const type = created[1];
|
|
@@ -6374,12 +7021,7 @@ function route(path) {
|
|
|
6374
7021
|
const one = TASK_PATH.exec(path) ?? TASK_ACTION.exec(path);
|
|
6375
7022
|
if (!one)
|
|
6376
7023
|
return null;
|
|
6377
|
-
|
|
6378
|
-
try {
|
|
6379
|
-
id = decodeURIComponent(one[1] ?? "");
|
|
6380
|
-
} catch {
|
|
6381
|
-
id = "";
|
|
6382
|
-
}
|
|
7024
|
+
const id = segment(one[1]);
|
|
6383
7025
|
const action = one[2];
|
|
6384
7026
|
if (action === undefined)
|
|
6385
7027
|
return { kind: "task", id };
|
|
@@ -6388,9 +7030,9 @@ function route(path) {
|
|
|
6388
7030
|
return { kind: "act", id, action };
|
|
6389
7031
|
}
|
|
6390
7032
|
function methodsOf(r) {
|
|
6391
|
-
if (r.kind === "logout" || r.kind === "act")
|
|
7033
|
+
if (r.kind === "logout" || r.kind === "act" || r.kind === "admin-allow" || r.kind === "admin-act" || r.kind === "admin-lift")
|
|
6392
7034
|
return "POST";
|
|
6393
|
-
if (r.kind === "settings" || r.kind === "edit" || r.kind === "new")
|
|
7035
|
+
if (r.kind === "settings" || r.kind === "edit" || r.kind === "new" || r.kind === "forget")
|
|
6394
7036
|
return "GET, POST";
|
|
6395
7037
|
return "GET";
|
|
6396
7038
|
}
|
|
@@ -6443,6 +7085,11 @@ function plain(status, text, extra = {}) {
|
|
|
6443
7085
|
headers: { "Content-Type": "text/plain; charset=utf-8", "Cache-Control": "no-store", "X-Content-Type-Options": "nosniff", ...extra }
|
|
6444
7086
|
});
|
|
6445
7087
|
}
|
|
7088
|
+
function signInNote(q) {
|
|
7089
|
+
if (q.get("forgotten") === "1")
|
|
7090
|
+
return "Everything the tracker held about you is deleted, and you are signed out.";
|
|
7091
|
+
return q.get("out") === "1" ? "You are signed out." : undefined;
|
|
7092
|
+
}
|
|
6446
7093
|
async function lookupWithin(lookup, ms) {
|
|
6447
7094
|
let timer;
|
|
6448
7095
|
const late = new Promise((resolve) => {
|
|
@@ -6527,6 +7174,11 @@ function createWebHandler(w) {
|
|
|
6527
7174
|
d.sessions.deleteForUser(user.id);
|
|
6528
7175
|
return { kind: "signed-out", note: RECHECK_FAILED };
|
|
6529
7176
|
}
|
|
7177
|
+
async function memberOf(discordId) {
|
|
7178
|
+
if (w.guildId === null)
|
|
7179
|
+
return "not-checked";
|
|
7180
|
+
return await lookupWithin(() => w.membership(discordId), MEMBER_CHECK_TIMEOUT_MS) ?? "unknown";
|
|
7181
|
+
}
|
|
6530
7182
|
async function readForm(request) {
|
|
6531
7183
|
const type = request.headers.get("content-type") ?? "";
|
|
6532
7184
|
if (!type.toLowerCase().startsWith("application/x-www-form-urlencoded"))
|
|
@@ -6605,22 +7257,28 @@ function createWebHandler(w) {
|
|
|
6605
7257
|
});
|
|
6606
7258
|
}
|
|
6607
7259
|
if (path === "/signin")
|
|
6608
|
-
return only("GET") ?? htmlResponse(signInHelpPage(base, url.searchParams
|
|
7260
|
+
return only("GET") ?? htmlResponse(signInHelpPage(base, signInNote(url.searchParams)));
|
|
6609
7261
|
if (path === "/login")
|
|
6610
7262
|
return method === "GET" ? loginGet(d, url) : loginPost(d, request);
|
|
6611
7263
|
const r = route(path);
|
|
6612
7264
|
if (r === null)
|
|
6613
7265
|
return htmlResponse(notFoundPage(base), 404);
|
|
6614
7266
|
const allowed = methodsOf(r);
|
|
6615
|
-
|
|
7267
|
+
const admin = isAdminRoute(r);
|
|
7268
|
+
if (!admin && !allowed.split(", ").includes(method))
|
|
6616
7269
|
return plain(405, "Method not allowed", { Allow: allowed });
|
|
6617
7270
|
const auth = await authenticate(d, request);
|
|
6618
7271
|
if (auth.kind === "signed-out")
|
|
6619
7272
|
return htmlResponse(signInHelpPage(base, auth.note), 403, { "Set-Cookie": clear(SESSION_COOKIE) });
|
|
6620
7273
|
if (auth.kind !== "ok")
|
|
6621
7274
|
return redirect(`${base}/signin`, auth.kind === "stale" ? [clear(SESSION_COOKIE)] : []);
|
|
7275
|
+
if (admin && !auth.user.admin)
|
|
7276
|
+
return unknownPage(base);
|
|
7277
|
+
if (admin && !allowed.split(", ").includes(method))
|
|
7278
|
+
return plain(405, "Method not allowed", { Allow: allowed });
|
|
6622
7279
|
const v = { base, user: auth.user, csrf: auth.session.csrf };
|
|
6623
7280
|
const e = { d, v, queue: w.queue, reading };
|
|
7281
|
+
const a = { d, v, queue: w.queue, memberOf: (id) => memberOf(id), signOutCookie: clear(SESSION_COOKIE) };
|
|
6624
7282
|
if (method === "POST") {
|
|
6625
7283
|
const form = await readForm(request);
|
|
6626
7284
|
if (!form)
|
|
@@ -6639,6 +7297,12 @@ function createWebHandler(w) {
|
|
|
6639
7297
|
return editPost(e, r.id, form);
|
|
6640
7298
|
case "act":
|
|
6641
7299
|
return actionPost(e, r.id, r.action, form);
|
|
7300
|
+
case "forget":
|
|
7301
|
+
return forgetRoute(a, method, form);
|
|
7302
|
+
case "admin-allow":
|
|
7303
|
+
case "admin-act":
|
|
7304
|
+
case "admin-lift":
|
|
7305
|
+
return adminPost(a, r, form);
|
|
6642
7306
|
default:
|
|
6643
7307
|
return plain(405, "Method not allowed", { Allow: allowed });
|
|
6644
7308
|
}
|
|
@@ -6654,6 +7318,12 @@ function createWebHandler(w) {
|
|
|
6654
7318
|
return editGet(e, r.id);
|
|
6655
7319
|
case "new":
|
|
6656
7320
|
return newGet(e, r.type);
|
|
7321
|
+
case "forget":
|
|
7322
|
+
return forgetRoute(a, method, null);
|
|
7323
|
+
case "admin":
|
|
7324
|
+
case "admin-tasks":
|
|
7325
|
+
case "admin-person":
|
|
7326
|
+
return adminGet(a, r);
|
|
6657
7327
|
default:
|
|
6658
7328
|
return htmlResponse(settingsPage(v, { hour: String(auth.user.preferredHour), zone: auth.user.timeZone, saved: url.searchParams.get("saved") === "1" }));
|
|
6659
7329
|
}
|
|
@@ -6746,6 +7416,28 @@ function createPlugin(host, options = {}) {
|
|
|
6746
7416
|
if (failed > 0)
|
|
6747
7417
|
host.log.warn(`${kind === "poll" ? "poll" : "notify"} lane: ${ran} ran, ${failed} failed`);
|
|
6748
7418
|
}
|
|
7419
|
+
const running = new Set;
|
|
7420
|
+
const tracked = (p) => {
|
|
7421
|
+
running.add(p);
|
|
7422
|
+
const done = () => void running.delete(p);
|
|
7423
|
+
p.then(done, done);
|
|
7424
|
+
return p;
|
|
7425
|
+
};
|
|
7426
|
+
const lanes = {
|
|
7427
|
+
busy: () => running.size > 0,
|
|
7428
|
+
async idle(ms) {
|
|
7429
|
+
const until = Date.now() + ms;
|
|
7430
|
+
while (running.size > 0) {
|
|
7431
|
+
const left = until - Date.now();
|
|
7432
|
+
if (left <= 0)
|
|
7433
|
+
return false;
|
|
7434
|
+
let timer;
|
|
7435
|
+
await Promise.race([Promise.allSettled([...running]), new Promise((r) => timer = setTimeout(r, left))]);
|
|
7436
|
+
clearTimeout(timer);
|
|
7437
|
+
}
|
|
7438
|
+
return true;
|
|
7439
|
+
}
|
|
7440
|
+
};
|
|
6749
7441
|
let discordClient = null;
|
|
6750
7442
|
const capture = (interaction) => {
|
|
6751
7443
|
const client = interaction.client;
|
|
@@ -6789,6 +7481,9 @@ function createPlugin(host, options = {}) {
|
|
|
6789
7481
|
const openedStore = new SqliteStore(opened);
|
|
6790
7482
|
const openedClaims = new ClaimStore(opened);
|
|
6791
7483
|
try {
|
|
7484
|
+
const vacuum = vacuumOnce(opened, path);
|
|
7485
|
+
if ("error" in vacuum)
|
|
7486
|
+
host.log.warn(`could not vacuum the tracker's database (${vacuum.error}); trying again at the next start`);
|
|
6792
7487
|
const granted = await seedAdmins(openedStore, adminIds, clock.now());
|
|
6793
7488
|
const admissions = new Admissions(opened);
|
|
6794
7489
|
for (const id of adminIds) {
|
|
@@ -6831,6 +7526,9 @@ function createPlugin(host, options = {}) {
|
|
|
6831
7526
|
notifier: dm ? createDmNotifier({ store: openedStore, claims: openedClaims, dm, clock, log: host.log, health: delivery }) : NO_DM,
|
|
6832
7527
|
logins: new LoginLinks(opened),
|
|
6833
7528
|
sessions: new Sessions(opened),
|
|
7529
|
+
roster: new Roster(opened),
|
|
7530
|
+
configuredAdmins: new Set(adminIds),
|
|
7531
|
+
lanes,
|
|
6834
7532
|
webEditor: webOrigin !== null
|
|
6835
7533
|
};
|
|
6836
7534
|
if (guildId === null)
|
|
@@ -6851,11 +7549,11 @@ function createPlugin(host, options = {}) {
|
|
|
6851
7549
|
ticks: [
|
|
6852
7550
|
{
|
|
6853
7551
|
name: "notify",
|
|
6854
|
-
run: (signal) => tick("notify", signal)
|
|
7552
|
+
run: (signal) => tracked(tick("notify", signal))
|
|
6855
7553
|
},
|
|
6856
7554
|
{
|
|
6857
7555
|
name: "poll",
|
|
6858
|
-
run: (signal) => tick("poll", signal)
|
|
7556
|
+
run: (signal) => tracked(tick("poll", signal))
|
|
6859
7557
|
}
|
|
6860
7558
|
],
|
|
6861
7559
|
async http(request, info) {
|