openvibers/openvibe.deals Commits
Moderation audit (ADR-022, WS-D task 1): deals.moderation.action from the outbox in the same transaction when a moderator edits, expires, disables, enables, reviews, merges or unmerges someone else's offer (offer.*, the submitter as owner_subject, the reason when given, changed field names only) or resolves or dismisses a report (flag.resolved|dismissed, the reporter as owner_subject); the submitter acting on their own offer and AI text deliveries report nothing. Pins: openvibe-contracts 0.49.0 → 0.53.0
Changes
@@@ -104,6 +104,7 @@ from the proposals in [docs/capabilities-proposal/](docs/capabilities-proposal/)| `deals.offer.expired` | submitter, moderator or stated end time | internal once expired || `deals.vote.changed` | a person's effective vote changed (payload: counts and hotness) | internal || `deals.watch.matched` | a watch matched a new observation (payload: `recipient`, offer, observation) — for Network's notification consumer, which does not exist yet | internal |++| `deals.moderation.action` | a moderator edited, expired, disabled, enabled, reviewed, merged or unmerged someone else's offer (`offer.*`), or resolved or dismissed a report (`flag.*`); for the network's moderation audit log (ADR-022, `common.moderation-action@1`), never the text | internal || `deals.index_document.upserted\|deleted` | the OpenVibe.Search document (`search.index-document@1`) or tombstone for offers and products | internal |Consumed: `sources.item.created|updated|removed` at `POST /internal/events` (signed,@@@ -15,7 +15,7 @@"express": "^4.21.2","express-rate-limit": "^7.5.0","helmet": "^8.1.0",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.49.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.53.0","openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v0.4.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.11.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.13.0"@@@ -1015,9 +1015,9 @@}},"node_modules/openvibe-contracts": {-- "version": "0.49.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.49.0",-- "integrity": "sha512-lapsLoBm3ZpndgiOMELdCBh2PJtuuyqj6SUhQsfwJkxnLdQfyhXfRFgiuLh+iQrg4kqtG9611191Mp8zAfk7fQ==",++ "version": "0.53.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.53.0",++ "integrity": "sha512-U+/QMB9dYHF+1ZS7ZZf+aYDokI8nuZmMZh1qOvisT7iB+QQ4uxr03E3wHm1/qOr+IHlt+7BzFaOPVw6K6cOSxA==","license": "AGPL-3.0-only","dependencies": {"ajv": "^8.17.1",@@@ -20,7 +20,7 @@"express": "^4.21.2","express-rate-limit": "^7.5.0","helmet": "^8.1.0",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.49.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.53.0","openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v0.4.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.11.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.13.0"@@@ -66,8 +66,8 @@ function createApp(opts = {}) {const watches = createWatches({ config, store, reads, catalog, publication, outbox });const limits = createLimits({ config, store });const access = createAccess();-- const offers = createOffers({ config, store, reads, catalog, publication, indexing, hotness, watches, limits, access, community });-- const flags = createFlags({ config, store, reads, limits, access, publication, logAction: offers.logAction });++ const offers = createOffers({ config, store, reads, catalog, publication, indexing, hotness, watches, limits, access, community, outbox });++ const flags = createFlags({ config, store, reads, limits, access, publication, logAction: offers.logAction, outbox });const votes = createVotes({ config, store, reads, hotness, limits, flags, people, outbox });const listings = createListings({ store });const importer = createImporter({ config, store, reads, catalog, offers, indexing, sources, log });@@@ -11,7 +11,7 @@ const { ApiError, newId, text, iso } = require('./util');const USER_KINDS = ['expired', 'price_wrong', 'spam', 'duplicate', 'other'];--function createFlags({ config, store, reads, limits, access, publication, logAction }) {++function createFlags({ config, store, reads, limits, access, publication, logAction, outbox = null }) {const { db } = store;const q = {get: db.prepare('SELECT * FROM deal_flags WHERE id = ?'),@@@ -63,7 +63,7 @@ function createFlags({ config, store, reads, limits, access, publication, logActreturn { flag: q.get.get(id), created: true };}-- function resolve(viewer, flagId, { status = 'resolved', resolution } = {}) {++ function resolve(viewer, flagId, { status = 'resolved', resolution } = {}, { traceparent } = {}) {if (!access.isModerator(viewer, 'deals.offer.moderate')) throw new ApiError(403, 'moderation.forbidden', 'Moderators only');if (!['resolved', 'dismissed'].includes(status)) throw new ApiError(422, 'request.invalid', 'status must be resolved or dismissed');const f = q.get.get(String(flagId || ''));@@@ -73,6 +73,14 @@ function createFlags({ config, store, reads, limits, access, publication, logActreturn store.tx(() => {q.resolve.run(status, actor, store.now(), text(resolution, { max: 300 }), store.now(), f.id);logAction(`flag.${status === 'resolved' ? 'resolve' : 'dismiss'}`, { offerId: f.offer_id, actor, reason: resolution || null, before: { flag: f.id, status: 'open' }, after: { status } });++ // A moderator's decision on a report goes to Network's moderation audit log (ADR-022).++ if (outbox) {++ const person = viewer.subject && /^usr_/.test(viewer.subject) ? viewer.subject : null;++ outbox.moderationAction({++ action: `flag.${status}`, target: { type: 'flag', id: f.id, owner_subject: f.reporter || null }, actorSubject: person,++ reason: text(resolution, { max: 300 }) || null, details: { offer_id: f.offer_id, kind: f.kind, origin: f.origin },++ }, { traceparent });++ }return { flag: q.get.get(f.id), changed: true };});}@@@ -28,7 +28,7 @@ function actorOf(viewer) {}function createOffers(deps) {-- const { config, store, reads, catalog, publication, indexing, hotness, watches, limits, access, community = null } = deps;++ const { config, store, reads, catalog, publication, indexing, hotness, watches, limits, access, community = null, outbox = null } = deps;const { db } = store;const q = {byUrl: db.prepare("SELECT * FROM deal_offers WHERE url_norm = ? AND status <> 'disabled' ORDER BY created_at LIMIT 1"),@@@ -70,6 +70,18 @@ function createOffers(deps) {};}++ /**++ * A moderator acting on an offer someone else submitted (or an import) is moderation: it also goes++ * to Network's moderation audit log (ADR-022), in the caller's transaction. The submitter (or a++ * service acting for them) acting on their own offer, and AI text deliveries, are not.++ */++ function moderated(viewer, root, action, { reason = null, details = {}, traceparent } = {}) {++ if (!outbox || !viewer || (viewer.kind === 'service' && viewer.origin === 'ai')) return null;++ if (viewer.subject && viewer.subject === root.submitted_by) return null;++ const person = viewer.subject && /^usr_/.test(viewer.subject) ? viewer.subject : null;++ return outbox.moderationAction({ action, target: { type: 'offer', id: root.id, owner_subject: root.submitted_by || null }, actorSubject: person, reason, details }, { traceparent });++ }++function logAction(action, { offerId = null, targetId = null, actor, reason = null, before = null, after = null }) {q.log.run(action, offerId, targetId, actor || 'svc:deals', reason, before ? JSON.stringify(before) : null, after ? JSON.stringify(after) : null, store.now());}@@@ -219,6 +231,7 @@ function createOffers(deps) {db.prepare(`UPDATE deal_offers SET ${keys.map((k) => `${k} = @${k}`).join(', ')}, updated_at = @now WHERE id = @id`).run({ ...changes, now: store.now(), id: root.id });const after = reads.get(root.id);indexing.emitOffer('deals.offer.updated', after, { actor, extra: { changed: keys }, traceparent });++ moderated(viewer, root, 'offer.edited', { details: { changed: keys }, traceparent });indexing.reindex(after);if (oldProduct && oldProduct !== after.product_id) indexing.indexProduct(catalog.product(oldProduct));return { offer: after, changed: true };@@@ -267,7 +280,11 @@ function createOffers(deps) {if (!access.canEdit(viewer, root)) throw new ApiError(403, 'offer.forbidden', 'Only the person who submitted this deal or a moderator can mark it expired');if (root.status !== 'active') throw new ApiError(409, 'offer.not_active', `This deal is ${root.status}`);const why = access.isStaff(viewer) ? `moderator${reason ? `: ${text(reason, { max: 200 })}` : ''}` : 'submitter';-- return store.tx(() => expireRow(root, { actor: actorOf(viewer), reason: why, traceparent }));++ return store.tx(() => {++ const after = expireRow(root, { actor: actorOf(viewer), reason: why, traceparent });++ moderated(viewer, root, 'offer.expired', { reason: text(reason, { max: 200 }) || null, traceparent });++ return after;++ });}/** Worker: offers whose STATED expiry has passed. Unknown expiry is never assumed. */@@@ -290,6 +307,7 @@ function createOffers(deps) {const now = store.now();db.prepare("UPDATE deal_offers SET status = 'disabled', disabled_at = ?, disabled_reason = ?, disabled_by = ?, updated_at = ? WHERE id = ?").run(now, why, actorOf(viewer), now, root.id);logAction('disable', { offerId: root.id, actor: actorOf(viewer), reason: why, before: { status: root.status }, after: { status: 'disabled' } });++ moderated(viewer, root, 'offer.disabled', { reason: why, details: { previous: root.status }, traceparent });const after = reads.get(root.id);indexing.emitOffer('deals.offer.updated', after, { actor: actorOf(viewer), extra: { changed: ['status'], moderation: 'disabled' }, traceparent });indexing.reindex(after);@@@ -315,6 +333,7 @@ function createOffers(deps) {db.prepare(`UPDATE deal_offers SET status = 'active', disabled_at = NULL, disabled_reason = NULL, disabled_by = NULL,expired_at = NULL, expired_reason = NULL, expires_at = ?, updated_at = ? WHERE id = ?`).run(keepExpiry, now, root.id);logAction('enable', { offerId: root.id, actor: actorOf(viewer), reason: text(reason, { max: 300 }), before: { status: root.status, disabled_reason: root.disabled_reason, expired_reason: root.expired_reason, expires_at: root.expires_at }, after: { status: 'active', expires_at: keepExpiry } });++ moderated(viewer, root, 'offer.enabled', { reason: text(reason, { max: 300 }) || null, details: { previous: root.status }, traceparent });const after = reads.get(root.id);indexing.emitOffer('deals.offer.updated', after, { actor: actorOf(viewer), extra: { changed: ['status'], moderation: 'enabled' }, traceparent });indexing.reindex(after);@@@ -335,6 +354,7 @@ function createOffers(deps) {const now = store.now();db.prepare("UPDATE deal_offers SET review_state = 'reviewed', reviewed_by = ?, reviewed_at = ?, updated_at = ? WHERE id = ?").run(reviewer, now, now, root.id);logAction('review', { offerId: root.id, actor: reviewer, reason: text(note, { max: 300 }), before: { review_state: 'pending' }, after: { review_state: 'reviewed' } });++ moderated(viewer, root, 'offer.reviewed', { reason: text(note, { max: 300 }) || null, traceparent });const after = reads.get(root.id);indexing.emitOffer('deals.offer.updated', after, { actor: reviewer, extra: { changed: ['review_state'] }, traceparent });indexing.reindex(after);@@@ -355,6 +375,7 @@ function createOffers(deps) {db.prepare('UPDATE deal_offers SET merged_into = ?, merged_at = ?, merged_by = ?, updated_at = ? WHERE id = ?').run(target.id, now, actor, now, dup.id);const after = { target: tallies(target) };logAction('merge', { offerId: dup.id, targetId: target.id, actor, reason: text(reason, { max: 300 }), before, after });++ moderated(viewer, dup, 'offer.merged', { reason: text(reason, { max: 300 }) || null, details: { into: target.id }, traceparent });hotness.snapshot(target.id, 'merge');indexing.emitOffer('deals.offer.updated', target, { actor, extra: { changed: ['merged'], merged_offer_id: dup.id }, traceparent });indexing.indexOffer(reads.get(dup.id));@@@ -377,6 +398,7 @@ function createOffers(deps) {const restored = reads.get(dup.id);const after = { target: tallies(parentRoot), duplicate: tallies(restored) };logAction('unmerge', { offerId: dup.id, targetId: parentRoot.id, actor, reason: text(reason, { max: 300 }), before, after });++ moderated(viewer, dup, 'offer.unmerged', { reason: text(reason, { max: 300 }) || null, details: { from: parentRoot.id }, traceparent });hotness.snapshot(parentRoot.id, 'unmerge');hotness.snapshot(restored.id, 'unmerge');indexing.emitOffer('deals.offer.updated', parentRoot, { actor, extra: { changed: ['unmerged'], unmerged_offer_id: dup.id }, traceparent });@@@ -8,6 +8,10 @@* deals.vote.changed a person's effective vote changed (payload: counts, not identities)* deals.watch.matched a watch matched a new observation (internal; Network's* notification consumer is future work; Deals never emails)++ * deals.moderation.action a moderator acted on someone else's offer or a report: edit,++ * expire, disable, enable, review, merge, unmerge, flag++ * resolution (common.moderation-action@1, ADR-022), for++ * Network's moderation audit log* deals.index_document.upserted|deleted the OpenVibe.Search document or tombstone (index-hooks* indexEvent), consumed by Search's '*.index_document.*'* subscription@@@ -52,8 +56,24 @@ function createDealsOutbox({ db, config, fetchImpl, now, log = console }) {return outbox.enqueue(envelope, { traceparent });}++ /**++ * deals.moderation.action, inside the caller's transaction. actorSubject: the moderator (null for++ * a service acting without a person); target: { type, id, owner_subject? }. Never the content.++ */++ function moderationAction({ action, target, actorSubject, reason = null, details = {} }, { traceparent } = {}) {++ const t = { type: target.type, id: String(target.id).slice(0, 200), owner_subject: target.owner_subject || null };++ return emit({++ event_type: 'deals.moderation.action',++ actor: actorSubject ? { type: 'user', id: actorSubject } : { type: 'service', id: 'deals' },++ subject: { type: 'moderation_action', id: `${t.type}:${t.id}`.slice(0, 200) },++ visibility: 'internal',++ payload: { action, target: t, actor_subject: actorSubject || null, reason: reason ? String(reason).slice(0, 500) : null, details: details || {} },++ }, { traceparent });++ }++return {emit,++ moderationAction,outbox,enabled,start() { if (enabled) outbox.start(); },@@@ -148,7 +148,7 @@ function createApi(ctx) {}));router.post('/flags/:id/resolve', guard('deals.offer.moderate'), jsonBody, run((req) => {-- const r = flags.resolve(req.viewer, req.params.id, req.body || {});++ const r = flags.resolve(req.viewer, req.params.id, req.body || {}, opts(req));return { flag: flags.dto(r.flag), changed: r.changed };}));@@@ -400,7 +400,7 @@ function createPages(ctx) {await act(req, res, '/mod', () => {const status = { resolve: 'resolved', dismiss: 'dismissed' }[req.params.action];if (!status) throw new ApiError(404, 'route.not_found', 'No such action');-- flags.resolve(req.viewer, req.params.id, { status, resolution: req.body.resolution });++ flags.resolve(req.viewer, req.params.id, { status, resolution: req.body.resolution }, { traceparent: req.ov && req.ov.traceparent });return '/mod';});}));nnew file mode 100644@@@ -0,0 +1,80 @@++'use strict';++/**++ * deals.moderation.action (ADR-022): a moderator acting on someone else's offer or on a report goes++ * to Network's moderation audit log, from the outbox in the same transaction; the submitter acting++ * on their own offer reports nothing.++ */++const assert = require('assert');++const contracts = require('openvibe-contracts');++const { boot, check, done } = require('./helpers/boot');++++(async () => {++ const t = await boot();++ const poster = t.network.addUser('poster');++ const reporter = t.network.addUser('reporter');++ const moderation = () => t.ctx.store.db.prepare('SELECT envelope FROM event_outbox ORDER BY id').all()++ .map((r) => JSON.parse(r.envelope)).filter((e) => e.event_type === 'deals.moderation.action');++ const valid = (e) => {++ assert.strictEqual(contracts.validate('events.event-envelope@1', e).valid, true, JSON.stringify(e));++ const r = contracts.validate('deals.moderation.action@1', e.payload);++ assert.strictEqual(r.valid, true, JSON.stringify(r.errors));++ assert.strictEqual(e.source, 'deals');++ assert.strictEqual(e.visibility, 'internal');++ assert.deepStrictEqual(e.actor, { type: 'user', id: t.mod.subject });++ assert.strictEqual(e.payload.actor_subject, t.mod.subject);++ };++++ await check('the submitter editing and expiring their own offer reports nothing', async () => {++ const o = await t.submit(poster, { url: 'https://s.example/own', title: 'My own offer', price: '5', currency: 'USD' });++ assert.strictEqual((await t.get(`/api/v1/offers/${o.id}`, { method: 'PATCH', as: poster, json: { title: 'My own offer, edited' } })).status, 200);++ assert.strictEqual((await t.get(`/api/v1/offers/${o.id}/expire`, { as: poster, json: {} })).status, 200);++ assert.strictEqual(moderation().length, 0);++ });++++ await check('a moderator disabling someone else\'s offer: exactly one valid event', async () => {++ const o = await t.submit(poster, { url: 'https://s.example/spam', title: 'Spammy offer', price: '1', currency: 'USD' });++ const r = await t.get(`/api/v1/offers/${o.id}/disable`, { as: t.mod, json: { reason: 'Referral spam' } });++ assert.strictEqual(r.status, 200, r.text);++ const ev = moderation();++ assert.strictEqual(ev.length, 1);++ valid(ev[0]);++ assert.deepStrictEqual(ev[0].subject, { type: 'moderation_action', id: `offer:${o.id}` });++ assert.strictEqual(ev[0].payload.action, 'offer.disabled');++ assert.deepStrictEqual(ev[0].payload.target, { type: 'offer', id: o.id, owner_subject: poster.subject });++ assert.strictEqual(ev[0].payload.reason, 'Referral spam');++ assert.deepStrictEqual(ev[0].payload.details, { previous: 'active' });++ assert.strictEqual((await t.get(`/api/v1/offers/${o.id}/disable`, { as: t.mod, json: { reason: 'Again' } })).status, 200);++ assert.strictEqual(moderation().length, 1, 'already disabled: nothing changed, nothing reported');++ });++++ await check('a moderator editing someone else\'s offer: one offer.edited naming the fields, never the text', async () => {++ const o = await t.submit(poster, { url: 'https://s.example/typo', title: 'Offer with a tpyo', price: '2', currency: 'USD' });++ const n = moderation().length;++ assert.strictEqual((await t.get(`/api/v1/offers/${o.id}`, { method: 'PATCH', as: t.mod, json: { title: 'Offer with a typo' } })).status, 200);++ const ev = moderation();++ assert.strictEqual(ev.length, n + 1);++ valid(ev[n]);++ assert.strictEqual(ev[n].payload.action, 'offer.edited');++ assert.deepStrictEqual(ev[n].payload.details, { changed: ['title'] });++ assert.ok(!JSON.stringify(ev[n]).includes('Offer with a typo'));++ });++++ await check('a moderator dismissing a person\'s report: one flag.dismissed with the reporter as owner', async () => {++ const o = await t.submit(poster, { url: 'https://s.example/reported', title: 'Reported offer', price: '3', currency: 'USD' });++ assert.ok((await t.get(`/api/v1/offers/${o.id}/flags`, { as: reporter, json: { kind: 'expired', reason: 'Sold out' } })).status < 300);++ const f = t.ctx.store.db.prepare('SELECT * FROM deal_flags WHERE offer_id = ?').get(o.id);++ const n = moderation().length;++ const r = await t.get(`/api/v1/flags/${f.id}/resolve`, { as: t.mod, json: { status: 'dismissed', resolution: 'Still in stock' } });++ assert.strictEqual(r.status, 200, r.text);++ const ev = moderation();++ assert.strictEqual(ev.length, n + 1);++ valid(ev[n]);++ assert.strictEqual(ev[n].payload.action, 'flag.dismissed');++ assert.deepStrictEqual(ev[n].payload.target, { type: 'flag', id: f.id, owner_subject: reporter.subject });++ assert.strictEqual(ev[n].payload.reason, 'Still in stock');++ assert.strictEqual(ev[n].payload.details.offer_id, o.id);++ });++++ await t.close();++ done();++})();
@@ -104,6 +104,7 @@ from the proposals in [docs/capabilities-proposal/](docs/capabilities-proposal/)
@@ -104,6 +104,7 @@ from the proposals in [docs/capabilities-proposal/](docs/capabilities-proposal/)
| `deals.offer.expired` | submitter, moderator or stated end time | internal once expired |
| `deals.offer.expired` | submitter, moderator or stated end time | internal once expired |
| `deals.vote.changed` | a person's effective vote changed (payload: counts and hotness) | internal |
| `deals.vote.changed` | a person's effective vote changed (payload: counts and hotness) | internal |
| `deals.watch.matched` | a watch matched a new observation (payload: `recipient`, offer, observation) — for Network's notification consumer, which does not exist yet | internal |
| `deals.watch.matched` | a watch matched a new observation (payload: `recipient`, offer, observation) — for Network's notification consumer, which does not exist yet | internal |
+| `deals.moderation.action` | a moderator edited, expired, disabled, enabled, reviewed, merged or unmerged someone else's offer (`offer.*`), or resolved or dismissed a report (`flag.*`); for the network's moderation audit log (ADR-022, `common.moderation-action@1`), never the text | internal |
| `deals.index_document.upserted\|deleted` | the OpenVibe.Search document (`search.index-document@1`) or tombstone for offers and products | internal |
| `deals.index_document.upserted\|deleted` | the OpenVibe.Search document (`search.index-document@1`) or tombstone for offers and products | internal |
Consumed: `sources.item.created|updated|removed` at `POST /internal/events` (signed,
Consumed: `sources.item.created|updated|removed` at `POST /internal/events` (signed,
@@ -15,7 +15,7 @@
@@ -15,7 +15,7 @@
"express": "^4.21.2",
"express": "^4.21.2",
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"helmet": "^8.1.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.49.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.53.0",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v0.4.0",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v0.4.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.11.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.11.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.13.0"
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.13.0"
@@ -1015,9 +1015,9 @@
@@ -1015,9 +1015,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"node_modules/openvibe-contracts": {
- "version": "0.49.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.49.0",
- "integrity": "sha512-lapsLoBm3ZpndgiOMELdCBh2PJtuuyqj6SUhQsfwJkxnLdQfyhXfRFgiuLh+iQrg4kqtG9611191Mp8zAfk7fQ==",
+ "version": "0.53.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.53.0",
+ "integrity": "sha512-U+/QMB9dYHF+1ZS7ZZf+aYDokI8nuZmMZh1qOvisT7iB+QQ4uxr03E3wHm1/qOr+IHlt+7BzFaOPVw6K6cOSxA==",
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -20,7 +20,7 @@
@@ -20,7 +20,7 @@
"express": "^4.21.2",
"express": "^4.21.2",
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"helmet": "^8.1.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.49.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.53.0",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v0.4.0",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v0.4.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.11.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.11.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.13.0"
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.13.0"
@@ -66,8 +66,8 @@ function createApp(opts = {}) {
@@ -66,8 +66,8 @@ function createApp(opts = {}) {
const watches = createWatches({ config, store, reads, catalog, publication, outbox });
const watches = createWatches({ config, store, reads, catalog, publication, outbox });
const limits = createLimits({ config, store });
const limits = createLimits({ config, store });
const access = createAccess();
const access = createAccess();
- const offers = createOffers({ config, store, reads, catalog, publication, indexing, hotness, watches, limits, access, community });
- const flags = createFlags({ config, store, reads, limits, access, publication, logAction: offers.logAction });
+ const offers = createOffers({ config, store, reads, catalog, publication, indexing, hotness, watches, limits, access, community, outbox });
+ const flags = createFlags({ config, store, reads, limits, access, publication, logAction: offers.logAction, outbox });
const votes = createVotes({ config, store, reads, hotness, limits, flags, people, outbox });
const votes = createVotes({ config, store, reads, hotness, limits, flags, people, outbox });
const listings = createListings({ store });
const listings = createListings({ store });
const importer = createImporter({ config, store, reads, catalog, offers, indexing, sources, log });
const importer = createImporter({ config, store, reads, catalog, offers, indexing, sources, log });
@@ -11,7 +11,7 @@ const { ApiError, newId, text, iso } = require('./util');
@@ -11,7 +11,7 @@ const { ApiError, newId, text, iso } = require('./util');
const USER_KINDS = ['expired', 'price_wrong', 'spam', 'duplicate', 'other'];
const USER_KINDS = ['expired', 'price_wrong', 'spam', 'duplicate', 'other'];
-function createFlags({ config, store, reads, limits, access, publication, logAction }) {
+function createFlags({ config, store, reads, limits, access, publication, logAction, outbox = null }) {
const { db } = store;
const { db } = store;
const q = {
const q = {
get: db.prepare('SELECT * FROM deal_flags WHERE id = ?'),
get: db.prepare('SELECT * FROM deal_flags WHERE id = ?'),
@@ -63,7 +63,7 @@ function createFlags({ config, store, reads, limits, access, publication, logAct
@@ -63,7 +63,7 @@ function createFlags({ config, store, reads, limits, access, publication, logAct
return { flag: q.get.get(id), created: true };
return { flag: q.get.get(id), created: true };
}
}
- function resolve(viewer, flagId, { status = 'resolved', resolution } = {}) {
+ function resolve(viewer, flagId, { status = 'resolved', resolution } = {}, { traceparent } = {}) {
if (!access.isModerator(viewer, 'deals.offer.moderate')) throw new ApiError(403, 'moderation.forbidden', 'Moderators only');
if (!access.isModerator(viewer, 'deals.offer.moderate')) throw new ApiError(403, 'moderation.forbidden', 'Moderators only');
if (!['resolved', 'dismissed'].includes(status)) throw new ApiError(422, 'request.invalid', 'status must be resolved or dismissed');
if (!['resolved', 'dismissed'].includes(status)) throw new ApiError(422, 'request.invalid', 'status must be resolved or dismissed');
const f = q.get.get(String(flagId || ''));
const f = q.get.get(String(flagId || ''));
@@ -73,6 +73,14 @@ function createFlags({ config, store, reads, limits, access, publication, logAct
@@ -73,6 +73,14 @@ function createFlags({ config, store, reads, limits, access, publication, logAct
return store.tx(() => {
return store.tx(() => {
q.resolve.run(status, actor, store.now(), text(resolution, { max: 300 }), store.now(), f.id);
q.resolve.run(status, actor, store.now(), text(resolution, { max: 300 }), store.now(), f.id);
logAction(`flag.${status === 'resolved' ? 'resolve' : 'dismiss'}`, { offerId: f.offer_id, actor, reason: resolution || null, before: { flag: f.id, status: 'open' }, after: { status } });
logAction(`flag.${status === 'resolved' ? 'resolve' : 'dismiss'}`, { offerId: f.offer_id, actor, reason: resolution || null, before: { flag: f.id, status: 'open' }, after: { status } });
+ // A moderator's decision on a report goes to Network's moderation audit log (ADR-022).
+ if (outbox) {
+ const person = viewer.subject && /^usr_/.test(viewer.subject) ? viewer.subject : null;
+ outbox.moderationAction({
+ action: `flag.${status}`, target: { type: 'flag', id: f.id, owner_subject: f.reporter || null }, actorSubject: person,
+ reason: text(resolution, { max: 300 }) || null, details: { offer_id: f.offer_id, kind: f.kind, origin: f.origin },
+ }, { traceparent });
+ }
return { flag: q.get.get(f.id), changed: true };
return { flag: q.get.get(f.id), changed: true };
});
});
}
}
@@ -28,7 +28,7 @@ function actorOf(viewer) {
@@ -28,7 +28,7 @@ function actorOf(viewer) {
}
}
function createOffers(deps) {
function createOffers(deps) {
- const { config, store, reads, catalog, publication, indexing, hotness, watches, limits, access, community = null } = deps;
+ const { config, store, reads, catalog, publication, indexing, hotness, watches, limits, access, community = null, outbox = null } = deps;
const { db } = store;
const { db } = store;
const q = {
const q = {
byUrl: db.prepare("SELECT * FROM deal_offers WHERE url_norm = ? AND status <> 'disabled' ORDER BY created_at LIMIT 1"),
byUrl: db.prepare("SELECT * FROM deal_offers WHERE url_norm = ? AND status <> 'disabled' ORDER BY created_at LIMIT 1"),
@@ -70,6 +70,18 @@ function createOffers(deps) {
@@ -70,6 +70,18 @@ function createOffers(deps) {
};
};
}
}
+ /**
+ * A moderator acting on an offer someone else submitted (or an import) is moderation: it also goes
+ * to Network's moderation audit log (ADR-022), in the caller's transaction. The submitter (or a
+ * service acting for them) acting on their own offer, and AI text deliveries, are not.
+ */
+ function moderated(viewer, root, action, { reason = null, details = {}, traceparent } = {}) {
+ if (!outbox || !viewer || (viewer.kind === 'service' && viewer.origin === 'ai')) return null;
+ if (viewer.subject && viewer.subject === root.submitted_by) return null;
+ const person = viewer.subject && /^usr_/.test(viewer.subject) ? viewer.subject : null;
+ return outbox.moderationAction({ action, target: { type: 'offer', id: root.id, owner_subject: root.submitted_by || null }, actorSubject: person, reason, details }, { traceparent });
+ }
+
function logAction(action, { offerId = null, targetId = null, actor, reason = null, before = null, after = null }) {
function logAction(action, { offerId = null, targetId = null, actor, reason = null, before = null, after = null }) {
q.log.run(action, offerId, targetId, actor || 'svc:deals', reason, before ? JSON.stringify(before) : null, after ? JSON.stringify(after) : null, store.now());
q.log.run(action, offerId, targetId, actor || 'svc:deals', reason, before ? JSON.stringify(before) : null, after ? JSON.stringify(after) : null, store.now());
}
}
@@ -219,6 +231,7 @@ function createOffers(deps) {
@@ -219,6 +231,7 @@ function createOffers(deps) {
db.prepare(`UPDATE deal_offers SET ${keys.map((k) => `${k} = @${k}`).join(', ')}, updated_at = @now WHERE id = @id`).run({ ...changes, now: store.now(), id: root.id });
db.prepare(`UPDATE deal_offers SET ${keys.map((k) => `${k} = @${k}`).join(', ')}, updated_at = @now WHERE id = @id`).run({ ...changes, now: store.now(), id: root.id });
const after = reads.get(root.id);
const after = reads.get(root.id);
indexing.emitOffer('deals.offer.updated', after, { actor, extra: { changed: keys }, traceparent });
indexing.emitOffer('deals.offer.updated', after, { actor, extra: { changed: keys }, traceparent });
+ moderated(viewer, root, 'offer.edited', { details: { changed: keys }, traceparent });
indexing.reindex(after);
indexing.reindex(after);
if (oldProduct && oldProduct !== after.product_id) indexing.indexProduct(catalog.product(oldProduct));
if (oldProduct && oldProduct !== after.product_id) indexing.indexProduct(catalog.product(oldProduct));
return { offer: after, changed: true };
return { offer: after, changed: true };
@@ -267,7 +280,11 @@ function createOffers(deps) {
@@ -267,7 +280,11 @@ function createOffers(deps) {
if (!access.canEdit(viewer, root)) throw new ApiError(403, 'offer.forbidden', 'Only the person who submitted this deal or a moderator can mark it expired');
if (!access.canEdit(viewer, root)) throw new ApiError(403, 'offer.forbidden', 'Only the person who submitted this deal or a moderator can mark it expired');
if (root.status !== 'active') throw new ApiError(409, 'offer.not_active', `This deal is ${root.status}`);
if (root.status !== 'active') throw new ApiError(409, 'offer.not_active', `This deal is ${root.status}`);
const why = access.isStaff(viewer) ? `moderator${reason ? `: ${text(reason, { max: 200 })}` : ''}` : 'submitter';
const why = access.isStaff(viewer) ? `moderator${reason ? `: ${text(reason, { max: 200 })}` : ''}` : 'submitter';
- return store.tx(() => expireRow(root, { actor: actorOf(viewer), reason: why, traceparent }));
+ return store.tx(() => {
+ const after = expireRow(root, { actor: actorOf(viewer), reason: why, traceparent });
+ moderated(viewer, root, 'offer.expired', { reason: text(reason, { max: 200 }) || null, traceparent });
+ return after;
+ });
}
}
/** Worker: offers whose STATED expiry has passed. Unknown expiry is never assumed. */
/** Worker: offers whose STATED expiry has passed. Unknown expiry is never assumed. */
@@ -290,6 +307,7 @@ function createOffers(deps) {
@@ -290,6 +307,7 @@ function createOffers(deps) {
const now = store.now();
const now = store.now();
db.prepare("UPDATE deal_offers SET status = 'disabled', disabled_at = ?, disabled_reason = ?, disabled_by = ?, updated_at = ? WHERE id = ?").run(now, why, actorOf(viewer), now, root.id);
db.prepare("UPDATE deal_offers SET status = 'disabled', disabled_at = ?, disabled_reason = ?, disabled_by = ?, updated_at = ? WHERE id = ?").run(now, why, actorOf(viewer), now, root.id);
logAction('disable', { offerId: root.id, actor: actorOf(viewer), reason: why, before: { status: root.status }, after: { status: 'disabled' } });
logAction('disable', { offerId: root.id, actor: actorOf(viewer), reason: why, before: { status: root.status }, after: { status: 'disabled' } });
+ moderated(viewer, root, 'offer.disabled', { reason: why, details: { previous: root.status }, traceparent });
const after = reads.get(root.id);
const after = reads.get(root.id);
indexing.emitOffer('deals.offer.updated', after, { actor: actorOf(viewer), extra: { changed: ['status'], moderation: 'disabled' }, traceparent });
indexing.emitOffer('deals.offer.updated', after, { actor: actorOf(viewer), extra: { changed: ['status'], moderation: 'disabled' }, traceparent });
indexing.reindex(after);
indexing.reindex(after);
@@ -315,6 +333,7 @@ function createOffers(deps) {
@@ -315,6 +333,7 @@ function createOffers(deps) {
db.prepare(`UPDATE deal_offers SET status = 'active', disabled_at = NULL, disabled_reason = NULL, disabled_by = NULL,
db.prepare(`UPDATE deal_offers SET status = 'active', disabled_at = NULL, disabled_reason = NULL, disabled_by = NULL,
expired_at = NULL, expired_reason = NULL, expires_at = ?, updated_at = ? WHERE id = ?`).run(keepExpiry, now, root.id);
expired_at = NULL, expired_reason = NULL, expires_at = ?, updated_at = ? WHERE id = ?`).run(keepExpiry, now, root.id);
logAction('enable', { offerId: root.id, actor: actorOf(viewer), reason: text(reason, { max: 300 }), before: { status: root.status, disabled_reason: root.disabled_reason, expired_reason: root.expired_reason, expires_at: root.expires_at }, after: { status: 'active', expires_at: keepExpiry } });
logAction('enable', { offerId: root.id, actor: actorOf(viewer), reason: text(reason, { max: 300 }), before: { status: root.status, disabled_reason: root.disabled_reason, expired_reason: root.expired_reason, expires_at: root.expires_at }, after: { status: 'active', expires_at: keepExpiry } });
+ moderated(viewer, root, 'offer.enabled', { reason: text(reason, { max: 300 }) || null, details: { previous: root.status }, traceparent });
const after = reads.get(root.id);
const after = reads.get(root.id);
indexing.emitOffer('deals.offer.updated', after, { actor: actorOf(viewer), extra: { changed: ['status'], moderation: 'enabled' }, traceparent });
indexing.emitOffer('deals.offer.updated', after, { actor: actorOf(viewer), extra: { changed: ['status'], moderation: 'enabled' }, traceparent });
indexing.reindex(after);
indexing.reindex(after);
@@ -335,6 +354,7 @@ function createOffers(deps) {
@@ -335,6 +354,7 @@ function createOffers(deps) {
const now = store.now();
const now = store.now();
db.prepare("UPDATE deal_offers SET review_state = 'reviewed', reviewed_by = ?, reviewed_at = ?, updated_at = ? WHERE id = ?").run(reviewer, now, now, root.id);
db.prepare("UPDATE deal_offers SET review_state = 'reviewed', reviewed_by = ?, reviewed_at = ?, updated_at = ? WHERE id = ?").run(reviewer, now, now, root.id);
logAction('review', { offerId: root.id, actor: reviewer, reason: text(note, { max: 300 }), before: { review_state: 'pending' }, after: { review_state: 'reviewed' } });
logAction('review', { offerId: root.id, actor: reviewer, reason: text(note, { max: 300 }), before: { review_state: 'pending' }, after: { review_state: 'reviewed' } });
+ moderated(viewer, root, 'offer.reviewed', { reason: text(note, { max: 300 }) || null, traceparent });
const after = reads.get(root.id);
const after = reads.get(root.id);
indexing.emitOffer('deals.offer.updated', after, { actor: reviewer, extra: { changed: ['review_state'] }, traceparent });
indexing.emitOffer('deals.offer.updated', after, { actor: reviewer, extra: { changed: ['review_state'] }, traceparent });
indexing.reindex(after);
indexing.reindex(after);
@@ -355,6 +375,7 @@ function createOffers(deps) {
@@ -355,6 +375,7 @@ function createOffers(deps) {
db.prepare('UPDATE deal_offers SET merged_into = ?, merged_at = ?, merged_by = ?, updated_at = ? WHERE id = ?').run(target.id, now, actor, now, dup.id);
db.prepare('UPDATE deal_offers SET merged_into = ?, merged_at = ?, merged_by = ?, updated_at = ? WHERE id = ?').run(target.id, now, actor, now, dup.id);
const after = { target: tallies(target) };
const after = { target: tallies(target) };
logAction('merge', { offerId: dup.id, targetId: target.id, actor, reason: text(reason, { max: 300 }), before, after });
logAction('merge', { offerId: dup.id, targetId: target.id, actor, reason: text(reason, { max: 300 }), before, after });
+ moderated(viewer, dup, 'offer.merged', { reason: text(reason, { max: 300 }) || null, details: { into: target.id }, traceparent });
hotness.snapshot(target.id, 'merge');
hotness.snapshot(target.id, 'merge');
indexing.emitOffer('deals.offer.updated', target, { actor, extra: { changed: ['merged'], merged_offer_id: dup.id }, traceparent });
indexing.emitOffer('deals.offer.updated', target, { actor, extra: { changed: ['merged'], merged_offer_id: dup.id }, traceparent });
indexing.indexOffer(reads.get(dup.id));
indexing.indexOffer(reads.get(dup.id));
@@ -377,6 +398,7 @@ function createOffers(deps) {
@@ -377,6 +398,7 @@ function createOffers(deps) {
const restored = reads.get(dup.id);
const restored = reads.get(dup.id);
const after = { target: tallies(parentRoot), duplicate: tallies(restored) };
const after = { target: tallies(parentRoot), duplicate: tallies(restored) };
logAction('unmerge', { offerId: dup.id, targetId: parentRoot.id, actor, reason: text(reason, { max: 300 }), before, after });
logAction('unmerge', { offerId: dup.id, targetId: parentRoot.id, actor, reason: text(reason, { max: 300 }), before, after });
+ moderated(viewer, dup, 'offer.unmerged', { reason: text(reason, { max: 300 }) || null, details: { from: parentRoot.id }, traceparent });
hotness.snapshot(parentRoot.id, 'unmerge');
hotness.snapshot(parentRoot.id, 'unmerge');
hotness.snapshot(restored.id, 'unmerge');
hotness.snapshot(restored.id, 'unmerge');
indexing.emitOffer('deals.offer.updated', parentRoot, { actor, extra: { changed: ['unmerged'], unmerged_offer_id: dup.id }, traceparent });
indexing.emitOffer('deals.offer.updated', parentRoot, { actor, extra: { changed: ['unmerged'], unmerged_offer_id: dup.id }, traceparent });
@@ -8,6 +8,10 @@
@@ -8,6 +8,10 @@
* deals.vote.changed a person's effective vote changed (payload: counts, not identities)
* deals.vote.changed a person's effective vote changed (payload: counts, not identities)
* deals.watch.matched a watch matched a new observation (internal; Network's
* deals.watch.matched a watch matched a new observation (internal; Network's
* notification consumer is future work; Deals never emails)
* notification consumer is future work; Deals never emails)
+ * deals.moderation.action a moderator acted on someone else's offer or a report: edit,
+ * expire, disable, enable, review, merge, unmerge, flag
+ * resolution (common.moderation-action@1, ADR-022), for
+ * Network's moderation audit log
* deals.index_document.upserted|deleted the OpenVibe.Search document or tombstone (index-hooks
* deals.index_document.upserted|deleted the OpenVibe.Search document or tombstone (index-hooks
* indexEvent), consumed by Search's '*.index_document.*'
* indexEvent), consumed by Search's '*.index_document.*'
* subscription
* subscription
@@ -52,8 +56,24 @@ function createDealsOutbox({ db, config, fetchImpl, now, log = console }) {
@@ -52,8 +56,24 @@ function createDealsOutbox({ db, config, fetchImpl, now, log = console }) {
return outbox.enqueue(envelope, { traceparent });
return outbox.enqueue(envelope, { traceparent });
}
}
+ /**
+ * deals.moderation.action, inside the caller's transaction. actorSubject: the moderator (null for
+ * a service acting without a person); target: { type, id, owner_subject? }. Never the content.
+ */
+ function moderationAction({ action, target, actorSubject, reason = null, details = {} }, { traceparent } = {}) {
+ const t = { type: target.type, id: String(target.id).slice(0, 200), owner_subject: target.owner_subject || null };
+ return emit({
+ event_type: 'deals.moderation.action',
+ actor: actorSubject ? { type: 'user', id: actorSubject } : { type: 'service', id: 'deals' },
+ subject: { type: 'moderation_action', id: `${t.type}:${t.id}`.slice(0, 200) },
+ visibility: 'internal',
+ payload: { action, target: t, actor_subject: actorSubject || null, reason: reason ? String(reason).slice(0, 500) : null, details: details || {} },
+ }, { traceparent });
+ }
+
return {
return {
emit,
emit,
+ moderationAction,
outbox,
outbox,
enabled,
enabled,
start() { if (enabled) outbox.start(); },
start() { if (enabled) outbox.start(); },
@@ -148,7 +148,7 @@ function createApi(ctx) {
@@ -148,7 +148,7 @@ function createApi(ctx) {
}));
}));
router.post('/flags/:id/resolve', guard('deals.offer.moderate'), jsonBody, run((req) => {
router.post('/flags/:id/resolve', guard('deals.offer.moderate'), jsonBody, run((req) => {
- const r = flags.resolve(req.viewer, req.params.id, req.body || {});
+ const r = flags.resolve(req.viewer, req.params.id, req.body || {}, opts(req));
return { flag: flags.dto(r.flag), changed: r.changed };
return { flag: flags.dto(r.flag), changed: r.changed };
}));
}));
@@ -400,7 +400,7 @@ function createPages(ctx) {
@@ -400,7 +400,7 @@ function createPages(ctx) {
await act(req, res, '/mod', () => {
await act(req, res, '/mod', () => {
const status = { resolve: 'resolved', dismiss: 'dismissed' }[req.params.action];
const status = { resolve: 'resolved', dismiss: 'dismissed' }[req.params.action];
if (!status) throw new ApiError(404, 'route.not_found', 'No such action');
if (!status) throw new ApiError(404, 'route.not_found', 'No such action');
- flags.resolve(req.viewer, req.params.id, { status, resolution: req.body.resolution });
+ flags.resolve(req.viewer, req.params.id, { status, resolution: req.body.resolution }, { traceparent: req.ov && req.ov.traceparent });
return '/mod';
return '/mod';
});
});
}));
}));
new file mode 100644
new file mode 100644
@@ -0,0 +1,80 @@
@@ -0,0 +1,80 @@
+'use strict';
+/**
+ * deals.moderation.action (ADR-022): a moderator acting on someone else's offer or on a report goes
+ * to Network's moderation audit log, from the outbox in the same transaction; the submitter acting
+ * on their own offer reports nothing.
+ */
+const assert = require('assert');
+const contracts = require('openvibe-contracts');
+const { boot, check, done } = require('./helpers/boot');
+
+(async () => {
+ const t = await boot();
+ const poster = t.network.addUser('poster');
+ const reporter = t.network.addUser('reporter');
+ const moderation = () => t.ctx.store.db.prepare('SELECT envelope FROM event_outbox ORDER BY id').all()
+ .map((r) => JSON.parse(r.envelope)).filter((e) => e.event_type === 'deals.moderation.action');
+ const valid = (e) => {
+ assert.strictEqual(contracts.validate('events.event-envelope@1', e).valid, true, JSON.stringify(e));
+ const r = contracts.validate('deals.moderation.action@1', e.payload);
+ assert.strictEqual(r.valid, true, JSON.stringify(r.errors));
+ assert.strictEqual(e.source, 'deals');
+ assert.strictEqual(e.visibility, 'internal');
+ assert.deepStrictEqual(e.actor, { type: 'user', id: t.mod.subject });
+ assert.strictEqual(e.payload.actor_subject, t.mod.subject);
+ };
+
+ await check('the submitter editing and expiring their own offer reports nothing', async () => {
+ const o = await t.submit(poster, { url: 'https://s.example/own', title: 'My own offer', price: '5', currency: 'USD' });
+ assert.strictEqual((await t.get(`/api/v1/offers/${o.id}`, { method: 'PATCH', as: poster, json: { title: 'My own offer, edited' } })).status, 200);
+ assert.strictEqual((await t.get(`/api/v1/offers/${o.id}/expire`, { as: poster, json: {} })).status, 200);
+ assert.strictEqual(moderation().length, 0);
+ });
+
+ await check('a moderator disabling someone else\'s offer: exactly one valid event', async () => {
+ const o = await t.submit(poster, { url: 'https://s.example/spam', title: 'Spammy offer', price: '1', currency: 'USD' });
+ const r = await t.get(`/api/v1/offers/${o.id}/disable`, { as: t.mod, json: { reason: 'Referral spam' } });
+ assert.strictEqual(r.status, 200, r.text);
+ const ev = moderation();
+ assert.strictEqual(ev.length, 1);
+ valid(ev[0]);
+ assert.deepStrictEqual(ev[0].subject, { type: 'moderation_action', id: `offer:${o.id}` });
+ assert.strictEqual(ev[0].payload.action, 'offer.disabled');
+ assert.deepStrictEqual(ev[0].payload.target, { type: 'offer', id: o.id, owner_subject: poster.subject });
+ assert.strictEqual(ev[0].payload.reason, 'Referral spam');
+ assert.deepStrictEqual(ev[0].payload.details, { previous: 'active' });
+ assert.strictEqual((await t.get(`/api/v1/offers/${o.id}/disable`, { as: t.mod, json: { reason: 'Again' } })).status, 200);
+ assert.strictEqual(moderation().length, 1, 'already disabled: nothing changed, nothing reported');
+ });
+
+ await check('a moderator editing someone else\'s offer: one offer.edited naming the fields, never the text', async () => {
+ const o = await t.submit(poster, { url: 'https://s.example/typo', title: 'Offer with a tpyo', price: '2', currency: 'USD' });
+ const n = moderation().length;
+ assert.strictEqual((await t.get(`/api/v1/offers/${o.id}`, { method: 'PATCH', as: t.mod, json: { title: 'Offer with a typo' } })).status, 200);
+ const ev = moderation();
+ assert.strictEqual(ev.length, n + 1);
+ valid(ev[n]);
+ assert.strictEqual(ev[n].payload.action, 'offer.edited');
+ assert.deepStrictEqual(ev[n].payload.details, { changed: ['title'] });
+ assert.ok(!JSON.stringify(ev[n]).includes('Offer with a typo'));
+ });
+
+ await check('a moderator dismissing a person\'s report: one flag.dismissed with the reporter as owner', async () => {
+ const o = await t.submit(poster, { url: 'https://s.example/reported', title: 'Reported offer', price: '3', currency: 'USD' });
+ assert.ok((await t.get(`/api/v1/offers/${o.id}/flags`, { as: reporter, json: { kind: 'expired', reason: 'Sold out' } })).status < 300);
+ const f = t.ctx.store.db.prepare('SELECT * FROM deal_flags WHERE offer_id = ?').get(o.id);
+ const n = moderation().length;
+ const r = await t.get(`/api/v1/flags/${f.id}/resolve`, { as: t.mod, json: { status: 'dismissed', resolution: 'Still in stock' } });
+ assert.strictEqual(r.status, 200, r.text);
+ const ev = moderation();
+ assert.strictEqual(ev.length, n + 1);
+ valid(ev[n]);
+ assert.strictEqual(ev[n].payload.action, 'flag.dismissed');
+ assert.deepStrictEqual(ev[n].payload.target, { type: 'flag', id: f.id, owner_subject: reporter.subject });
+ assert.strictEqual(ev[n].payload.reason, 'Still in stock');
+ assert.strictEqual(ev[n].payload.details.offer_id, o.id);
+ });
+
+ await t.close();
+ done();
+})();