|
|
@@ -1,25 +1,51 @@
|
|
|
const pull = require('../server/node_modules/pull-stream');
|
|
|
const moment = require('../server/node_modules/moment');
|
|
|
const { getConfig } = require('../configs/config-manager.js');
|
|
|
+const categories = require('../backend/opinion_categories');
|
|
|
const logLimit = getConfig().ssbLogStream?.limit || 1000;
|
|
|
|
|
|
module.exports = ({ cooler }) => {
|
|
|
let ssb;
|
|
|
const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb; };
|
|
|
|
|
|
+ const getAllMessages = async (ssbClient) =>
|
|
|
+ new Promise((resolve, reject) => {
|
|
|
+ pull(
|
|
|
+ ssbClient.createLogStream({ limit: logLimit }),
|
|
|
+ pull.collect((err, msgs) => err ? reject(err) : resolve(msgs))
|
|
|
+ );
|
|
|
+ });
|
|
|
+
|
|
|
+ const resolveCurrentId = async (id) => {
|
|
|
+ const ssbClient = await openSsb();
|
|
|
+ const messages = await getAllMessages(ssbClient);
|
|
|
+ const forward = new Map();
|
|
|
+ for (const m of messages) {
|
|
|
+ const c = m.value?.content;
|
|
|
+ if (!c) continue;
|
|
|
+ if (c.type === 'transfer' && c.replaces) forward.set(c.replaces, m.key);
|
|
|
+ }
|
|
|
+ let cur = id;
|
|
|
+ while (forward.has(cur)) cur = forward.get(cur);
|
|
|
+ return cur;
|
|
|
+ };
|
|
|
+
|
|
|
+ const isValidId = (to) => /^@[A-Za-z0-9+/]+={0,2}\.ed25519$/.test(String(to || ''));
|
|
|
+
|
|
|
return {
|
|
|
type: 'transfer',
|
|
|
|
|
|
async createTransfer(to, concept, amount, deadline, tagsRaw = []) {
|
|
|
- const ssb = await openSsb();
|
|
|
- const userId = ssb.id;
|
|
|
- if (!/^@[A-Za-z0-9+\/]+= {0,2}\.ed25519$/.test(to)) throw new Error('Invalid recipient ID');
|
|
|
+ const ssbClient = await openSsb();
|
|
|
+ const userId = ssbClient.id;
|
|
|
+ if (!isValidId(to)) throw new Error('Invalid recipient ID');
|
|
|
const num = typeof amount === 'string' ? parseFloat(amount.replace(',', '.')) : amount;
|
|
|
if (isNaN(num) || num <= 0) throw new Error('Amount must be positive');
|
|
|
const dl = moment(deadline, moment.ISO_8601, true);
|
|
|
if (!dl.isValid() || dl.isBefore(moment())) throw new Error('Deadline must be in the future');
|
|
|
- const tags = Array.isArray(tagsRaw) ? tagsRaw.filter(Boolean) : tagsRaw.split(',').map(t => t.trim()).filter(Boolean);
|
|
|
+ const tags = Array.isArray(tagsRaw) ? tagsRaw.filter(Boolean) : String(tagsRaw).split(',').map(t => t.trim()).filter(Boolean);
|
|
|
const isSelf = to === userId;
|
|
|
+
|
|
|
const content = {
|
|
|
type: 'transfer',
|
|
|
from: userId,
|
|
|
@@ -34,29 +60,37 @@ module.exports = ({ cooler }) => {
|
|
|
opinions: {},
|
|
|
opinions_inhabitants: []
|
|
|
};
|
|
|
+
|
|
|
return new Promise((resolve, reject) => {
|
|
|
- ssb.publish(content, (err, msg) => err ? reject(err) : resolve(msg));
|
|
|
+ ssbClient.publish(content, (err, msg) => err ? reject(err) : resolve(msg));
|
|
|
});
|
|
|
},
|
|
|
|
|
|
async updateTransferById(id, to, concept, amount, deadline, tagsRaw = []) {
|
|
|
- const ssb = await openSsb();
|
|
|
- const userId = ssb.id;
|
|
|
- const old = await new Promise((res, rej) => ssb.get(id, (err, msg) => err || !msg?.content ? rej(err || new Error()) : res(msg)));
|
|
|
+ const ssbClient = await openSsb();
|
|
|
+ const userId = ssbClient.id;
|
|
|
+ const tipId = await resolveCurrentId(id);
|
|
|
+
|
|
|
+ const old = await new Promise((res, rej) =>
|
|
|
+ ssbClient.get(tipId, (err, msg) => err || !msg?.content ? rej(err || new Error()) : res(msg))
|
|
|
+ );
|
|
|
+
|
|
|
+ if (old.content.type !== 'transfer') throw new Error('Transfer not found');
|
|
|
if (Object.keys(old.content.opinions || {}).length > 0) throw new Error('Cannot edit transfer after it has received opinions.');
|
|
|
if (old.content.from !== userId) throw new Error('Not the author');
|
|
|
if (old.content.status !== 'UNCONFIRMED') throw new Error('Can only edit unconfirmed');
|
|
|
+ if (!isValidId(to)) throw new Error('Invalid recipient ID');
|
|
|
|
|
|
- const tomb = { type: 'tombstone', id, deletedAt: new Date().toISOString() };
|
|
|
- await new Promise((res, rej) => ssb.publish(tomb, err => err ? rej(err) : res()));
|
|
|
-
|
|
|
- if (!/^@[A-Za-z0-9+\/]+= {0,2}\.ed25519$/.test(to)) throw new Error('Invalid recipient ID');
|
|
|
const num = typeof amount === 'string' ? parseFloat(amount.replace(',', '.')) : amount;
|
|
|
if (isNaN(num) || num <= 0) throw new Error('Amount must be positive');
|
|
|
const dl = moment(deadline, moment.ISO_8601, true);
|
|
|
if (!dl.isValid() || dl.isBefore(moment())) throw new Error('Deadline must be in the future');
|
|
|
- const tags = Array.isArray(tagsRaw) ? tagsRaw.filter(Boolean) : tagsRaw.split(',').map(t => t.trim()).filter(Boolean);
|
|
|
+ const tags = Array.isArray(tagsRaw) ? tagsRaw.filter(Boolean) : String(tagsRaw).split(',').map(t => t.trim()).filter(Boolean);
|
|
|
const isSelf = to === userId;
|
|
|
+
|
|
|
+ const tombstone = { type: 'tombstone', target: tipId, deletedAt: new Date().toISOString(), author: userId };
|
|
|
+ await new Promise((res, rej) => ssbClient.publish(tombstone, err => err ? rej(err) : res()));
|
|
|
+
|
|
|
const updated = {
|
|
|
type: 'transfer',
|
|
|
from: userId,
|
|
|
@@ -71,103 +105,120 @@ module.exports = ({ cooler }) => {
|
|
|
opinions: {},
|
|
|
opinions_inhabitants: [],
|
|
|
updatedAt: new Date().toISOString(),
|
|
|
- replaces: id
|
|
|
+ replaces: tipId
|
|
|
};
|
|
|
+
|
|
|
return new Promise((resolve, reject) => {
|
|
|
- ssb.publish(updated, (err, msg) => err ? reject(err) : resolve(msg));
|
|
|
+ ssbClient.publish(updated, (err, msg) => err ? reject(err) : resolve(msg));
|
|
|
});
|
|
|
},
|
|
|
|
|
|
async confirmTransferById(id) {
|
|
|
- const ssb = await openSsb();
|
|
|
- const userId = ssb.id;
|
|
|
+ const ssbClient = await openSsb();
|
|
|
+ const userId = ssbClient.id;
|
|
|
+ const tipId = await resolveCurrentId(id);
|
|
|
+
|
|
|
return new Promise((resolve, reject) => {
|
|
|
- ssb.get(id, async (err, msg) => {
|
|
|
- if (err || !msg?.content) return reject(new Error('Not found'));
|
|
|
+ ssbClient.get(tipId, async (err, msg) => {
|
|
|
+ if (err || !msg?.content || msg.content.type !== 'transfer') return reject(new Error('Not found'));
|
|
|
const t = msg.content;
|
|
|
if (t.status !== 'UNCONFIRMED') return reject(new Error('Not unconfirmed'));
|
|
|
if (t.to !== userId) return reject(new Error('Not the recipient'));
|
|
|
|
|
|
- const newConfirmed = [...t.confirmedBy, userId].filter((v, i, a) => a.indexOf(v) === i);
|
|
|
+ const newConfirmed = [...(t.confirmedBy || []), userId].filter((v, i, a) => a.indexOf(v) === i);
|
|
|
const newStatus = newConfirmed.length >= 2 ? 'CLOSED' : 'UNCONFIRMED';
|
|
|
- const upd = { ...t, confirmedBy: newConfirmed, status: newStatus, updatedAt: new Date().toISOString(), replaces: id };
|
|
|
- const tombstone = { type: 'tombstone', id, deletedAt: new Date().toISOString() };
|
|
|
- await new Promise((res, rej) => ssb.publish(tombstone, (err) => err ? rej(err) : res()));
|
|
|
- ssb.publish(upd, (err, result) => err ? reject(err) : resolve(result));
|
|
|
+
|
|
|
+ const tombstone = { type: 'tombstone', target: tipId, deletedAt: new Date().toISOString(), author: userId };
|
|
|
+ await new Promise((res, rej) => ssbClient.publish(tombstone, e => e ? rej(e) : res()));
|
|
|
+
|
|
|
+ const upd = { ...t, confirmedBy: newConfirmed, status: newStatus, updatedAt: new Date().toISOString(), replaces: tipId };
|
|
|
+ ssbClient.publish(upd, (e2, result) => e2 ? reject(e2) : resolve(result));
|
|
|
});
|
|
|
});
|
|
|
},
|
|
|
|
|
|
async deleteTransferById(id) {
|
|
|
- const ssb = await openSsb();
|
|
|
- const userId = ssb.id;
|
|
|
+ const ssbClient = await openSsb();
|
|
|
+ const userId = ssbClient.id;
|
|
|
+ const tipId = await resolveCurrentId(id);
|
|
|
+
|
|
|
return new Promise((resolve, reject) => {
|
|
|
- ssb.get(id, (err, msg) => {
|
|
|
- if (err || !msg?.content) return reject(new Error('Not found'));
|
|
|
+ ssbClient.get(tipId, (err, msg) => {
|
|
|
+ if (err || !msg?.content || msg.content.type !== 'transfer') return reject(new Error('Not found'));
|
|
|
const t = msg.content;
|
|
|
if (t.from !== userId) return reject(new Error('Not the author'));
|
|
|
- if (t.status !== 'UNCONFIRMED' || t.confirmedBy.length >= 2) return reject(new Error('Not editable'));
|
|
|
+ if (t.status !== 'UNCONFIRMED' || (t.confirmedBy || []).length >= 2) return reject(new Error('Not editable'));
|
|
|
|
|
|
- const tomb = { type: 'tombstone', id, deletedAt: new Date().toISOString() };
|
|
|
- ssb.publish(tomb, err => err ? reject(err) : resolve());
|
|
|
+ const tombstone = { type: 'tombstone', target: tipId, deletedAt: new Date().toISOString(), author: userId };
|
|
|
+ ssbClient.publish(tombstone, err2 => err2 ? reject(err2) : resolve());
|
|
|
});
|
|
|
});
|
|
|
},
|
|
|
|
|
|
- async listAll(filter = 'all') {
|
|
|
- const ssb = await openSsb();
|
|
|
- return new Promise((resolve, reject) => {
|
|
|
- pull(
|
|
|
- ssb.createLogStream({ limit: logLimit }),
|
|
|
- pull.collect(async (err, results) => {
|
|
|
- if (err) return reject(err);
|
|
|
- const tombstoned = new Set();
|
|
|
- const replaces = new Map();
|
|
|
- const transfersById = new Map();
|
|
|
- const now = moment();
|
|
|
-
|
|
|
- for (const r of results) {
|
|
|
- const c = r.value?.content;
|
|
|
- const k = r.key;
|
|
|
- if (!c) continue;
|
|
|
- if (c.type === 'tombstone' && c.id) {
|
|
|
- tombstoned.add(c.id);
|
|
|
- continue;
|
|
|
- }
|
|
|
- if (c.type === 'transfer') {
|
|
|
- if (tombstoned.has(k)) continue;
|
|
|
- if (c.replaces) replaces.set(c.replaces, k);
|
|
|
- transfersById.set(k, { id: k, ...c });
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- for (const replacedId of replaces.keys()) {
|
|
|
- transfersById.delete(replacedId);
|
|
|
- }
|
|
|
-
|
|
|
- const deduped = Array.from(transfersById.values());
|
|
|
-
|
|
|
- for (const item of deduped) {
|
|
|
- const dl = moment(item.deadline);
|
|
|
- if (item.status === 'UNCONFIRMED' && dl.isBefore(now)) {
|
|
|
- item.status = (item.confirmedBy || []).length >= 2 ? 'CLOSED' : 'DISCARDED';
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- resolve(deduped);
|
|
|
- })
|
|
|
- );
|
|
|
- });
|
|
|
- },
|
|
|
+ async listAll(filter = 'all') {
|
|
|
+ const ssbClient = await openSsb();
|
|
|
+ const messages = await getAllMessages(ssbClient);
|
|
|
+
|
|
|
+ const tombstoned = new Set();
|
|
|
+ const replaces = new Map();
|
|
|
+ const latest = new Map();
|
|
|
+
|
|
|
+ for (const m of messages) {
|
|
|
+ const c = m.value?.content;
|
|
|
+ const k = m.key;
|
|
|
+ if (!c) continue;
|
|
|
+
|
|
|
+ if (c.type === 'tombstone') {
|
|
|
+ const tgt = c.target || c.id;
|
|
|
+ if (tgt) tombstoned.add(tgt);
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (c.type !== 'transfer') continue;
|
|
|
+
|
|
|
+ if (c.replaces) replaces.set(c.replaces, k);
|
|
|
+ latest.set(k, {
|
|
|
+ id: k,
|
|
|
+ from: c.from,
|
|
|
+ to: c.to,
|
|
|
+ concept: c.concept,
|
|
|
+ amount: c.amount,
|
|
|
+ createdAt: c.createdAt,
|
|
|
+ deadline: c.deadline,
|
|
|
+ confirmedBy: c.confirmedBy || [],
|
|
|
+ status: c.status,
|
|
|
+ tags: c.tags || [],
|
|
|
+ opinions: c.opinions || {},
|
|
|
+ opinions_inhabitants: c.opinions_inhabitants || []
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ for (const oldId of replaces.keys()) latest.delete(oldId);
|
|
|
+ for (const delId of tombstoned.values()) latest.delete(delId);
|
|
|
+
|
|
|
+ const now = moment();
|
|
|
+ const out = Array.from(latest.values());
|
|
|
+
|
|
|
+ for (const item of out) {
|
|
|
+ const dl = moment(item.deadline);
|
|
|
+ if (item.status === 'UNCONFIRMED' && dl.isValid() && dl.isBefore(now)) {
|
|
|
+ item.status = (item.confirmedBy || []).length >= 2 ? 'CLOSED' : 'DISCARDED';
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ return out;
|
|
|
+ },
|
|
|
|
|
|
async getTransferById(id) {
|
|
|
- const ssb = await openSsb();
|
|
|
+ const ssbClient = await openSsb();
|
|
|
+ const tipId = await resolveCurrentId(id);
|
|
|
+
|
|
|
return new Promise((resolve, reject) => {
|
|
|
- ssb.get(id, (err, msg) => {
|
|
|
- if (err || !msg?.content || msg.content.type === 'tombstone') return reject(new Error('Not found'));
|
|
|
+ ssbClient.get(tipId, (err, msg) => {
|
|
|
+ if (err || !msg?.content || msg.content.type !== 'transfer') return reject(new Error('Not found'));
|
|
|
const c = msg.content;
|
|
|
resolve({
|
|
|
- id,
|
|
|
+ id: tipId,
|
|
|
from: c.from,
|
|
|
to: c.to,
|
|
|
concept: c.concept,
|
|
|
@@ -185,12 +236,16 @@ module.exports = ({ cooler }) => {
|
|
|
},
|
|
|
|
|
|
async createOpinion(id, category) {
|
|
|
- const ssb = await openSsb();
|
|
|
- const userId = ssb.id;
|
|
|
+ if (!categories.includes(category)) throw new Error('Invalid voting category');
|
|
|
+ const ssbClient = await openSsb();
|
|
|
+ const userId = ssbClient.id;
|
|
|
+ const tipId = await resolveCurrentId(id);
|
|
|
+
|
|
|
return new Promise((resolve, reject) => {
|
|
|
- ssb.get(id, (err, msg) => {
|
|
|
+ ssbClient.get(tipId, async (err, msg) => {
|
|
|
if (err || !msg || msg.content?.type !== 'transfer') return reject(new Error('Transfer not found'));
|
|
|
if (msg.content.opinions_inhabitants?.includes(userId)) return reject(new Error('Already voted'));
|
|
|
+
|
|
|
const updated = {
|
|
|
...msg.content,
|
|
|
opinions: {
|
|
|
@@ -199,9 +254,13 @@ module.exports = ({ cooler }) => {
|
|
|
},
|
|
|
opinions_inhabitants: [...(msg.content.opinions_inhabitants || []), userId],
|
|
|
updatedAt: new Date().toISOString(),
|
|
|
- replaces: id
|
|
|
+ replaces: tipId
|
|
|
};
|
|
|
- ssb.publish(updated, (err2, result) => err2 ? reject(err2) : resolve(result));
|
|
|
+
|
|
|
+ const tombstone = { type: 'tombstone', target: tipId, deletedAt: new Date().toISOString(), author: userId };
|
|
|
+ await new Promise((res, rej) => ssbClient.publish(tombstone, e => e ? rej(e) : res()));
|
|
|
+
|
|
|
+ ssbClient.publish(updated, (e2, result) => e2 ? reject(e2) : resolve(result));
|
|
|
});
|
|
|
});
|
|
|
}
|