feat: invite trazability via inviteLog and pub-invite SSB events
tribes_model: generateInvite() stores inviteLog[] entry {code, generatedBy, generatedAt, status}
joinByInvite() updates inviteLog entry with {usedBy, usedAt, status:'used'}
main_models: acceptInvite() publishes type:'pub-invite' SSB message after handshake
records pubHost, pubKey, acceptedAt - first audit trail for pub invites
blockchain_model: adds 'invites' filter showing pub-invite blocks and tribes with inviteLog
blockchain_view: renders inviteLog table on tribe blocks, renders pub-invite block details
adds 'invites' filter button to blockexplorer UI
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
5ee14d9e1d
commit
0fc10be24c
5 changed files with 94 additions and 7 deletions
|
|
@ -4189,3 +4189,25 @@ form {
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
margin: 8px 0;
|
margin: 8px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Invite log blockchain styles */
|
||||||
|
.invite-log-block {
|
||||||
|
margin-top: 8px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
border-left: 3px solid #27ae60;
|
||||||
|
background: rgba(39,174,96,0.06);
|
||||||
|
border-radius: 0 4px 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-status--pending { color: #e67e22; font-weight: bold; }
|
||||||
|
.invite-status--used { color: #27ae60; font-weight: bold; }
|
||||||
|
|
||||||
|
.invite-code-cell {
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-key {
|
||||||
|
font-size: 11px;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -206,6 +206,9 @@ module.exports = ({ cooler }) => {
|
||||||
const cset = new Set(['courtsCase','courtsEvidence','courtsAnswer','courtsVerdict','courtsSettlement','courtsSettlementProposal','courtsSettlementAccepted','courtsNomination','courtsNominationVote']);
|
const cset = new Set(['courtsCase','courtsEvidence','courtsAnswer','courtsVerdict','courtsSettlement','courtsSettlementProposal','courtsSettlementAccepted','courtsNomination','courtsNominationVote']);
|
||||||
filtered = filtered.filter(b => b && cset.has(b.type));
|
filtered = filtered.filter(b => b && cset.has(b.type));
|
||||||
}
|
}
|
||||||
|
if (filter === 'INVITES' || filter === 'invites') {
|
||||||
|
filtered = filtered.filter(b => b && (b.type === 'pub-invite' || (b.type === 'tribe' && Array.isArray(b.content?.inviteLog) && b.content.inviteLog.length > 0)));
|
||||||
|
}
|
||||||
|
|
||||||
const s = search || {};
|
const s = search || {};
|
||||||
const authorQ = String(s.author || '').trim();
|
const authorQ = String(s.author || '').trim();
|
||||||
|
|
|
||||||
|
|
@ -722,11 +722,21 @@ models.meta = {
|
||||||
acceptInvite: async (invite) => {
|
acceptInvite: async (invite) => {
|
||||||
const ssb = await cooler.open();
|
const ssb = await cooler.open();
|
||||||
const code = toLegacyInvite(String(invite || ''));
|
const code = toLegacyInvite(String(invite || ''));
|
||||||
return await new Promise((resolve, reject) => {
|
const result = await new Promise((resolve, reject) => {
|
||||||
ssb.invite.accept(code, (err, res) => {
|
ssb.invite.accept(code, (err, res) => {
|
||||||
err ? reject(err) : resolve(res);
|
err ? reject(err) : resolve(res);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
const { host, pubId } = parseRemote(code);
|
||||||
|
await new Promise((resolve) => {
|
||||||
|
ssb.publish({
|
||||||
|
type: 'pub-invite',
|
||||||
|
pubHost: host || null,
|
||||||
|
pubKey: pubId || null,
|
||||||
|
acceptedAt: new Date().toISOString(),
|
||||||
|
}, (err) => resolve());
|
||||||
|
});
|
||||||
|
return result;
|
||||||
},
|
},
|
||||||
rebuild: async () => {
|
rebuild: async () => {
|
||||||
const ssb = await cooler.open();
|
const ssb = await cooler.open();
|
||||||
|
|
|
||||||
|
|
@ -104,7 +104,9 @@ module.exports = ({ cooler }) => {
|
||||||
}
|
}
|
||||||
const code = crypto.randomBytes(INVITE_CODE_BYTES).toString('hex');
|
const code = crypto.randomBytes(INVITE_CODE_BYTES).toString('hex');
|
||||||
const invites = Array.isArray(tribe.invites) ? [...tribe.invites, code] : [code];
|
const invites = Array.isArray(tribe.invites) ? [...tribe.invites, code] : [code];
|
||||||
await this.updateTribeInvites(tribeId, invites);
|
const inviteLog = Array.isArray(tribe.inviteLog) ? [...tribe.inviteLog] : [];
|
||||||
|
inviteLog.push({ code, generatedBy: userId, generatedAt: new Date().toISOString(), status: 'pending', usedBy: null, usedAt: null });
|
||||||
|
await this.updateTribeById(tribeId, { invites, inviteLog });
|
||||||
return code;
|
return code;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
@ -138,7 +140,12 @@ module.exports = ({ cooler }) => {
|
||||||
}
|
}
|
||||||
const members = [...tribe.members, userId];
|
const members = [...tribe.members, userId];
|
||||||
const invites = tribe.invites.filter(c => c !== code);
|
const invites = tribe.invites.filter(c => c !== code);
|
||||||
await this.updateTribeById(tribe.id, { members, invites });
|
const inviteLog = Array.isArray(tribe.inviteLog) ? tribe.inviteLog.map(entry =>
|
||||||
|
entry.code === code
|
||||||
|
? { ...entry, status: 'used', usedBy: userId, usedAt: new Date().toISOString() }
|
||||||
|
: entry
|
||||||
|
) : [];
|
||||||
|
await this.updateTribeById(tribe.id, { members, invites, inviteLog });
|
||||||
return tribe.id;
|
return tribe.id;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
@ -202,6 +209,7 @@ module.exports = ({ cooler }) => {
|
||||||
createdAt: tribe.content.createdAt,
|
createdAt: tribe.content.createdAt,
|
||||||
updatedAt: tribe.content.updatedAt,
|
updatedAt: tribe.content.updatedAt,
|
||||||
author: tribe.content.author,
|
author: tribe.content.author,
|
||||||
|
inviteLog: Array.isArray(tribe.content.inviteLog) ? tribe.content.inviteLog : [],
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
@ -230,6 +238,7 @@ module.exports = ({ cooler }) => {
|
||||||
createdAt: c.createdAt,
|
createdAt: c.createdAt,
|
||||||
updatedAt: c.updatedAt,
|
updatedAt: c.updatedAt,
|
||||||
author: c.author,
|
author: c.author,
|
||||||
|
inviteLog: Array.isArray(c.inviteLog) ? c.inviteLog : [],
|
||||||
_ts: entry._ts
|
_ts: entry._ts
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,12 +11,13 @@ const FILTER_LABELS = {
|
||||||
forum: i18n.typeForum, about: i18n.typeAbout, contact: i18n.typeContact, pub: i18n.typePub,
|
forum: i18n.typeForum, about: i18n.typeAbout, contact: i18n.typeContact, pub: i18n.typePub,
|
||||||
transfer: i18n.typeTransfer, market: i18n.typeMarket, job: i18n.typeJob, tribe: i18n.typeTribe,
|
transfer: i18n.typeTransfer, market: i18n.typeMarket, job: i18n.typeJob, tribe: i18n.typeTribe,
|
||||||
project: i18n.typeProject, banking: i18n.typeBanking, bankWallet: i18n.typeBankWallet, bankClaim: i18n.typeBankClaim,
|
project: i18n.typeProject, banking: i18n.typeBanking, bankWallet: i18n.typeBankWallet, bankClaim: i18n.typeBankClaim,
|
||||||
aiExchange: i18n.typeAiExchange, parliament: i18n.typeParliament, courts: i18n.typeCourts
|
aiExchange: i18n.typeAiExchange, parliament: i18n.typeParliament, courts: i18n.typeCourts,
|
||||||
|
'pub-invite': 'PUB INVITE', invites: 'INVITES'
|
||||||
};
|
};
|
||||||
|
|
||||||
const BASE_FILTERS = ['recent', 'all', 'mine', 'tombstone'];
|
const BASE_FILTERS = ['recent', 'all', 'mine', 'tombstone'];
|
||||||
const CAT_BLOCK1 = ['votes', 'event', 'task', 'report', 'parliament', 'courts'];
|
const CAT_BLOCK1 = ['votes', 'event', 'task', 'report', 'parliament', 'courts'];
|
||||||
const CAT_BLOCK2 = ['pub', 'tribe', 'about', 'contact', 'curriculum', 'vote', 'aiExchange'];
|
const CAT_BLOCK2 = ['pub', 'tribe', 'about', 'contact', 'curriculum', 'vote', 'aiExchange', 'invites'];
|
||||||
const CAT_BLOCK3 = ['banking', 'job', 'market', 'project', 'transfer', 'feed', 'post', 'pixelia'];
|
const CAT_BLOCK3 = ['banking', 'job', 'market', 'project', 'transfer', 'feed', 'post', 'pixelia'];
|
||||||
const CAT_BLOCK4 = ['forum', 'bookmark', 'image', 'video', 'audio', 'document'];
|
const CAT_BLOCK4 = ['forum', 'bookmark', 'image', 'video', 'audio', 'document'];
|
||||||
|
|
||||||
|
|
@ -97,6 +98,7 @@ const getViewDetailsAction = (type, block) => {
|
||||||
case 'vote': return `/thread/${encodeURIComponent(block.content.vote.link)}#${encodeURIComponent(block.content.vote.link)}`;
|
case 'vote': return `/thread/${encodeURIComponent(block.content.vote.link)}#${encodeURIComponent(block.content.vote.link)}`;
|
||||||
case 'contact': return `/inhabitants`;
|
case 'contact': return `/inhabitants`;
|
||||||
case 'pub': return `/invites`;
|
case 'pub': return `/invites`;
|
||||||
|
case 'pub-invite': return `/invites`;
|
||||||
case 'market': return `/market/${encodeURIComponent(block.id)}`;
|
case 'market': return `/market/${encodeURIComponent(block.id)}`;
|
||||||
case 'job': return `/jobs/${encodeURIComponent(block.id)}`;
|
case 'job': return `/jobs/${encodeURIComponent(block.id)}`;
|
||||||
case 'project': return `/projects/${encodeURIComponent(block.id)}`;
|
case 'project': return `/projects/${encodeURIComponent(block.id)}`;
|
||||||
|
|
@ -131,7 +133,47 @@ const TYPE_COLORS = {
|
||||||
parliamentTerm:'#8e44ad', parliamentProposal:'#8e44ad', parliamentLaw:'#8e44ad',
|
parliamentTerm:'#8e44ad', parliamentProposal:'#8e44ad', parliamentLaw:'#8e44ad',
|
||||||
parliamentCandidature:'#8e44ad', parliamentRevocation:'#8e44ad',
|
parliamentCandidature:'#8e44ad', parliamentRevocation:'#8e44ad',
|
||||||
courtsCase:'#c0392b', courtsEvidence:'#c0392b', courtsAnswer:'#c0392b',
|
courtsCase:'#c0392b', courtsEvidence:'#c0392b', courtsAnswer:'#c0392b',
|
||||||
courtsVerdict:'#c0392b', courtsSettlement:'#c0392b', courtsNomination:'#c0392b'
|
courtsVerdict:'#c0392b', courtsSettlement:'#c0392b', courtsNomination:'#c0392b',
|
||||||
|
'pub-invite': '#27ae60', invites: '#27ae60'
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderInviteExtra = (block) => {
|
||||||
|
if (block.type === 'pub-invite') {
|
||||||
|
const c = block.content;
|
||||||
|
return div({ class: 'invite-log-block' },
|
||||||
|
table({ class: 'block-info-table' },
|
||||||
|
tr(td({ class: 'card-label' }, 'PUB HOST'), td({ class: 'card-value' }, c.pubHost || '—')),
|
||||||
|
tr(td({ class: 'card-label' }, 'PUB KEY'), td({ class: 'card-value invite-key' }, c.pubKey ? a({ href: `/author/${encodeURIComponent(c.pubKey)}`, class: 'user-link' }, c.pubKey) : '—')),
|
||||||
|
tr(td({ class: 'card-label' }, 'ACCEPTED'), td({ class: 'card-value' }, c.acceptedAt ? moment(c.acceptedAt).format('YYYY-MM-DD HH:mm:ss') : '—'))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (block.type === 'tribe' && Array.isArray(block.content?.inviteLog) && block.content.inviteLog.length > 0) {
|
||||||
|
return div({ class: 'invite-log-block' },
|
||||||
|
strong('INVITE LOG:'),
|
||||||
|
table({ class: 'block-info-table' },
|
||||||
|
tr(
|
||||||
|
td({ class: 'card-label' }, 'CODE'),
|
||||||
|
td({ class: 'card-label' }, 'GENERATED BY'),
|
||||||
|
td({ class: 'card-label' }, 'GENERATED AT'),
|
||||||
|
td({ class: 'card-label' }, 'STATUS'),
|
||||||
|
td({ class: 'card-label' }, 'USED BY'),
|
||||||
|
td({ class: 'card-label' }, 'USED AT')
|
||||||
|
),
|
||||||
|
...block.content.inviteLog.map(entry =>
|
||||||
|
tr(
|
||||||
|
td({ class: 'card-value invite-code-cell' }, String(entry.code || '').slice(0, 12) + '…'),
|
||||||
|
td({ class: 'card-value' }, entry.generatedBy ? a({ href: `/author/${encodeURIComponent(entry.generatedBy)}`, class: 'user-link' }, String(entry.generatedBy).slice(0, 16) + '…') : '—'),
|
||||||
|
td({ class: 'card-value' }, entry.generatedAt ? moment(entry.generatedAt).format('YYYY-MM-DD HH:mm') : '—'),
|
||||||
|
td({ class: `card-value invite-status invite-status--${entry.status || 'pending'}` }, (entry.status || 'pending').toUpperCase()),
|
||||||
|
td({ class: 'card-value' }, entry.usedBy ? a({ href: `/author/${encodeURIComponent(entry.usedBy)}`, class: 'user-link' }, String(entry.usedBy).slice(0, 16) + '…') : '—'),
|
||||||
|
td({ class: 'card-value' }, entry.usedAt ? moment(entry.usedAt).format('YYYY-MM-DD HH:mm') : '—')
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderBlockDiagram = (blocks, qs) => {
|
const renderBlockDiagram = (blocks, qs) => {
|
||||||
|
|
@ -365,7 +407,8 @@ const renderBlockchainView = (blocks, filter, userId, search = {}) => {
|
||||||
tr(td({ class:'card-label' }, i18n.blockchainBlockID), td({ class:'card-value' }, block.id)),
|
tr(td({ class:'card-label' }, i18n.blockchainBlockID), td({ class:'card-value' }, block.id)),
|
||||||
tr(td({ class:'card-label' }, i18n.blockchainBlockType), td({ class:'card-value' }, (FILTER_LABELS[block.type]||block.type).toUpperCase())),
|
tr(td({ class:'card-label' }, i18n.blockchainBlockType), td({ class:'card-value' }, (FILTER_LABELS[block.type]||block.type).toUpperCase())),
|
||||||
tr(td({ class:'card-label' }, i18n.blockchainBlockAuthor), td({ class:'card-value' }, a({ href:`/author/${encodeURIComponent(block.author)}`, class:'block-author user-link' }, block.author)))
|
tr(td({ class:'card-label' }, i18n.blockchainBlockAuthor), td({ class:'card-value' }, a({ href:`/author/${encodeURIComponent(block.author)}`, class:'block-author user-link' }, block.author)))
|
||||||
)
|
),
|
||||||
|
renderInviteExtra(block)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue