diff --git a/docs/plans/feedback-rewards-and-identity.md b/docs/plans/feedback-rewards-and-identity.md index c50c3ac67..f5569f9e0 100644 --- a/docs/plans/feedback-rewards-and-identity.md +++ b/docs/plans/feedback-rewards-and-identity.md @@ -1,5 +1,5 @@ --- -status: draft +status: shipped (3.A, 3.B, 3.C, 3.F live 2026-04-27 — 3.D, 3.E open) owner: till created: 2026-04-27 parent: docs/plans/feedback-hub-public.md @@ -419,19 +419,25 @@ nirgends entfernt. Drop alle Referenzen. ## Reihenfolge -1. **Phase 3.A** *(jetzt)* — Credits-Loop - - mana-credits: `/internal/credits/grant` + `grantCredits()` + transaction-type 'grant' - - mana-analytics: `grantCreditsForSubmit` + `grantShipBonus` + Founder-Whitelist + Rate-Limit - - mana-web: UI-Toast "+5 Mana" im FeedbackQuickModal + Onboarding-Wish-Confirm - - 0003 SQL-Migration: `feedback_grant_log` mini-table + transaction.type-enum-Erweiterung -2. **Phase 3.F** *(direkt danach, 2h)* — Legacy-Cleanup - - vote/unvote/toggleVote/getPublicFeedback raus - - voteCount Spalte raus - - /feedback Modul + Route + ListView raus -3. **Phase 3.B** — Loop-Closure (Status-Notify + my-wishes + Digest) -4. **Phase 3.C** — Identität (Klarname + Pixel-Avatar + Karma + Eulen-Profil) -5. **Phase 3.D** — Engagement (Trending, Compass, Quests, Match-Existing) -6. **Phase 3.E** — Smart Triggers (Frust-Detect, Voice, Companion) +1. **Phase 3.A** ✅ shipped 2026-04-27 — Credits-Loop + - dbe24acfc + eecf64c1c (server + UI), e89958e9c (port-fix) + - mana-credits transaction-type 'grant', `/internal/credits/grant` idempotent + - mana-analytics +5/+500/+25 mit Founder-Whitelist + Rate-Limit + - Reward-Chip in FeedbackQuickModal + Onboarding-Wish-Confirm +2. **Phase 3.F** ✅ shipped 2026-04-27 — Legacy-Cleanup + - eecf64c1c — vote/unvote/toggleVote shims raus, voteCount drop, /feedback Modul + Route gelöscht +3. **Phase 3.B** ✅ shipped 2026-04-27 — Loop-Closure + - 3a18a5e50 — feedback_notifications-Tabelle, Status-Notify + AdminResponse-Notify, Toast-Polling + - /profile/my-wishes mit 3 Tabs (Eigene/Unterstützt/Inbox) + - Migration 0004 +4. **Phase 3.C** ✅ shipped 2026-04-27 — Identität + - ee5bb2871 + 1b30c3655 — Pixel-Identicon-Avatar (deterministic SVG), Klarname-Toggle + mit Settings-Section, Karma-System mit Bronze/Silver/Gold/Platin-Tiers, + /community/eule/[hash] Public-Profil mit SSR + - Migration `services/mana-auth/sql/008_community_identity.sql` + - Cross-schema-JOIN auth.users in mana-analytics +5. **Phase 3.D** ⏸ offen — Engagement (Trending, Compass, Quests, Match-Existing) +6. **Phase 3.E** ⏸ offen — Smart Triggers (Frust-Detect, Voice, Companion) --- diff --git a/packages/feedback/src/avatar.test.ts b/packages/feedback/src/avatar.test.ts new file mode 100644 index 000000000..4cb6ca991 --- /dev/null +++ b/packages/feedback/src/avatar.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from 'bun:test'; +import { generateAvatarSvg, __TEST__ } from './avatar'; +import { + createDisplayHash, + generateDisplayName, +} from '../../../services/mana-analytics/src/lib/pseudonym'; + +describe('avatar', () => { + const SAMPLE_HASH = 'a3f4e1d2c5b6a798e0f1d2c3b4a5968778695a4b3c2d1e0f1a2b3c4d5e6f7a8b'; + + it('is deterministic — same hash always yields same SVG', () => { + expect(generateAvatarSvg(SAMPLE_HASH)).toBe(generateAvatarSvg(SAMPLE_HASH)); + }); + + it('produces different SVGs for different hashes', () => { + const a = generateAvatarSvg(SAMPLE_HASH); + const b = generateAvatarSvg('b3f4e1d2c5b6a798e0f1d2c3b4a5968778695a4b3c2d1e0f1a2b3c4d5e6f7a8b'); + expect(a).not.toBe(b); + }); + + it('returns a self-contained SVG with viewBox + fill', () => { + const svg = generateAvatarSvg(SAMPLE_HASH); + expect(svg.startsWith(' m[1]); + expect(colors.length).toBeGreaterThanOrEqual(2); + expect(colors[0]).not.toBe(colors[1]); + }); + + it('cells are left-mirrored (col[i] === col[GRID-1-i] for all rows in left half)', () => { + const { cells } = __TEST__.rendering(SAMPLE_HASH); + const GRID = __TEST__.GRID; + expect(cells).toHaveLength(GRID); + for (let row = 0; row < GRID; row++) { + for (let col = 0; col < 2; col++) { + expect(cells[row][col]).toBe(cells[row][GRID - 1 - col]); + } + } + }); + + it('survives short / malformed hashes via padding', () => { + expect(() => generateAvatarSvg('abc')).not.toThrow(); + expect(() => generateAvatarSvg('')).not.toThrow(); + expect(() => generateAvatarSvg('not-hex-at-all-just-random-text')).not.toThrow(); + }); + + it('integrates with the pseudonym generator (same userId → same avatar AND name)', () => { + const hash = createDisplayHash('user-42', 'fixed-secret'); + const name1 = generateDisplayName(hash); + const name2 = generateDisplayName(hash); + const svg1 = generateAvatarSvg(hash); + const svg2 = generateAvatarSvg(hash); + expect(name1).toBe(name2); + expect(svg1).toBe(svg2); + }); + + it('cell density is reasonable (not all-on, not all-off)', () => { + // Sample 50 different hashes — none should produce a totally blank + // or totally full grid (would mean the bit-extraction is broken). + let allOnCount = 0; + let allOffCount = 0; + const GRID = __TEST__.GRID; + for (let i = 0; i < 50; i++) { + const hash = createDisplayHash(`user-${i}`, 'secret'); + const { cells } = __TEST__.rendering(hash); + const total = GRID * GRID; + let on = 0; + for (const row of cells) for (const c of row) if (c) on++; + if (on === total) allOnCount++; + if (on === 0) allOffCount++; + } + expect(allOnCount).toBe(0); + expect(allOffCount).toBe(0); + }); +}); + +describe('avatar __TEST__ exports', () => { + it('exports the GRID constant', () => { + expect(__TEST__.GRID).toBe(5); + }); + + it('exports hexToBytes that handles non-hex chars', () => { + const bytes = __TEST__.hexToBytes('xx-aa-bb-cc-yy'); + expect(bytes).toEqual([0xaa, 0xbb, 0xcc]); + }); +}); diff --git a/services/mana-analytics/src/services/feedback-redact.test.ts b/services/mana-analytics/src/services/feedback-redact.test.ts new file mode 100644 index 000000000..89f43373e --- /dev/null +++ b/services/mana-analytics/src/services/feedback-redact.test.ts @@ -0,0 +1,104 @@ +/** + * Privacy-boundary tests für die `redact()`-Funktion. + * + * Kritisch: anonymous public endpoint darf NIE einen Klarnamen + * ausliefern, auch wenn der User-Account `communityShowRealName=true` + * gesetzt hat. Diese Tests sind das Sicherheitsnetz für die ›Public + * bleibt anonym‹-Garantie der Community-Surface. + */ +import { describe, expect, it } from 'bun:test'; +import { __TEST__ } from './feedback'; + +const { redact } = __TEST__; + +const baseFeedback = { + id: 'feedback-1', + userId: 'user-42', + appId: 'mana', + title: 'Test', + feedbackText: 'I would like X please', + category: 'feature' as const, + status: 'submitted' as const, + isPublic: true, + adminResponse: null, + voteCount: 0 as never, + displayHash: 'abc123def456', + displayName: 'Wachsame Eule #4528', + moduleContext: null, + parentId: null, + reactions: { '👍': 3, '❤️': 1 }, + score: 4, + deviceInfo: { ip: '1.2.3.4' } as never, + createdAt: new Date('2026-04-27T10:00:00Z'), + updatedAt: new Date('2026-04-27T10:00:00Z'), +} as Parameters[0]; + +const optedInAuthor = { + name: 'Till Schäfer', + communityShowRealName: true, + communityKarma: 47, +}; + +const optedOutAuthor = { + name: 'Till Schäfer', + communityShowRealName: false, + communityKarma: 47, +}; + +describe('redact (privacy-boundary)', () => { + it('NEVER leaks realName on the anonymous path even when author opted in', () => { + const item = redact(baseFeedback, optedInAuthor, { includeRealName: false }); + expect(item.realName).toBeUndefined(); + }); + + it('NEVER leaks realName on the auth path when author opted OUT', () => { + const item = redact(baseFeedback, optedOutAuthor, { includeRealName: true }); + expect(item.realName).toBeUndefined(); + }); + + it('exposes realName ONLY when author opted-in AND auth-path requested it', () => { + const item = redact(baseFeedback, optedInAuthor, { includeRealName: true }); + expect(item.realName).toBe('Till Schäfer'); + }); + + it('strips userId, deviceInfo, voteCount from output', () => { + const item = redact(baseFeedback, optedInAuthor, { includeRealName: true }); + expect((item as Record).userId).toBeUndefined(); + expect((item as Record).deviceInfo).toBeUndefined(); + expect((item as Record).voteCount).toBeUndefined(); + }); + + it('exposes karma always — it is public information', () => { + const item1 = redact(baseFeedback, optedInAuthor, { includeRealName: false }); + const item2 = redact(baseFeedback, optedOutAuthor, { includeRealName: false }); + expect(item1.karma).toBe(47); + expect(item2.karma).toBe(47); + }); + + it('falls back to karma=0 when author row is null (deleted user)', () => { + const item = redact(baseFeedback, null, { includeRealName: true }); + expect(item.karma).toBe(0); + expect(item.realName).toBeUndefined(); + }); + + it('exposes displayHash + displayName on every output (needed for avatar + profile-URL)', () => { + const anonymous = redact(baseFeedback, optedInAuthor, { includeRealName: false }); + const auth = redact(baseFeedback, optedInAuthor, { includeRealName: true }); + expect(anonymous.displayHash).toBe('abc123def456'); + expect(auth.displayHash).toBe('abc123def456'); + expect(anonymous.displayName).toBe('Wachsame Eule #4528'); + expect(auth.displayName).toBe('Wachsame Eule #4528'); + }); + + it('default options strip realName (defensive default)', () => { + // When no options passed, behaves like the anonymous path. + const item = redact(baseFeedback, optedInAuthor); + expect(item.realName).toBeUndefined(); + }); + + it('falls back to displayName="Anonym" when missing', () => { + const itemWithoutName = { ...baseFeedback, displayName: null }; + const item = redact(itemWithoutName as never, null, { includeRealName: false }); + expect(item.displayName).toBe('Anonym'); + }); +}); diff --git a/services/mana-analytics/src/services/feedback.ts b/services/mana-analytics/src/services/feedback.ts index dbced6ffd..d474c7528 100644 --- a/services/mana-analytics/src/services/feedback.ts +++ b/services/mana-analytics/src/services/feedback.ts @@ -775,4 +775,8 @@ function redact( return item; } -export { ALLOWED_EMOJIS, REACTION_WEIGHTS }; +export { ALLOWED_EMOJIS, REACTION_WEIGHTS, REWARD }; +// Test-only exports — used by privacy-boundary tests to verify the +// anonymous public path NEVER includes realName even when the user +// opted in. Still safe to expose since redact is a pure-function. +export const __TEST__ = { redact };