mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:21:09 +02:00
feat(mana-analytics): pseudonym + reactions + public feed + admin
Macht mana-analytics zur Backend-Heimat des Public-Community-Hubs. - Pseudonym: createDisplayHash(userId, secret) + generateDisplayName() produzieren deterministisch "Wachsame Eule #4528" pro User. 100 Adjektive × 80 Tieren × 10000 Suffixe → ~80M Kombinationen. 7 unit tests, 0 PII im Output. - Schema-Erweiterung user_feedback: display_hash, display_name, module_context, parent_id (1-level Threading), reactions jsonb (cached emoji→count), score (cached weighted sort). - feedback_votes ersetzt durch feedback_reactions (Slack-Pattern: unique(feedback_id, user_id, emoji), pro User mehrere Emojis möglich). - Service: createFeedback stempelt display_hash + display_name. Neue Methoden getPublicFeed (redacted), getReplies, toggleReaction (rebuilt reactions+score). Admin-Methoden adminListAll/adminUpdate founder-tier-gated im Route-Layer. - Routes: /api/v1/public/feedback/* — anonymous reads (kein Auth, kein userId/displayHash/deviceInfo im Output) /api/v1/feedback/* — auth-required Submit/React/Manage, plus :id/replies, :id/react, /admin - Config: neuer FEEDBACK_PSEUDONYM_SECRET-Env-Var seedet die Hashes; Rotation re-keyt nur künftige Pseudonyme, alte Records bleiben stabil. Migration 0002_public-community-foundation.sql idempotent, lokal + prod (mana-server) eingespielt. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
fd11481d94
commit
8b0a943e71
9 changed files with 820 additions and 70 deletions
|
|
@ -0,0 +1,51 @@
|
|||
-- 0002_public-community-foundation.sql
|
||||
--
|
||||
-- Phase 2.1 von docs/plans/feedback-hub-public.md.
|
||||
--
|
||||
-- Macht user_feedback bereit für die Public-Community-Surface:
|
||||
-- Pseudonym (display_hash + display_name), Modul-Kontext, 1-Level-Reply-
|
||||
-- Threading, Slack-Style-Reactions (statt simpler Vote-Counter), Cached-
|
||||
-- Score für Sort.
|
||||
--
|
||||
-- feedback_votes wird durch feedback_reactions ersetzt (0 Rows in Prod
|
||||
-- + lokal vor diesem Commit verifiziert, deshalb destruktiver DROP/CREATE
|
||||
-- statt Rename+Add-Column).
|
||||
--
|
||||
-- Apply manuell:
|
||||
-- psql "$DATABASE_URL" -f services/mana-analytics/drizzle/0002_public-community-foundation.sql
|
||||
--
|
||||
-- Idempotent via IF EXISTS / IF NOT EXISTS.
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- 1. Pseudonym + Module-Context + Threading + Reactions auf user_feedback
|
||||
ALTER TABLE feedback.user_feedback
|
||||
ADD COLUMN IF NOT EXISTS display_hash text,
|
||||
ADD COLUMN IF NOT EXISTS display_name text,
|
||||
ADD COLUMN IF NOT EXISTS module_context text,
|
||||
ADD COLUMN IF NOT EXISTS parent_id uuid REFERENCES feedback.user_feedback(id) ON DELETE SET NULL,
|
||||
ADD COLUMN IF NOT EXISTS reactions jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
ADD COLUMN IF NOT EXISTS score integer NOT NULL DEFAULT 0;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS feedback_display_hash_idx ON feedback.user_feedback(display_hash);
|
||||
CREATE INDEX IF NOT EXISTS feedback_module_context_idx ON feedback.user_feedback(module_context);
|
||||
CREATE INDEX IF NOT EXISTS feedback_parent_id_idx ON feedback.user_feedback(parent_id);
|
||||
CREATE INDEX IF NOT EXISTS feedback_score_idx ON feedback.user_feedback(score DESC);
|
||||
|
||||
-- 2. feedback_votes durch feedback_reactions ersetzen
|
||||
DROP TABLE IF EXISTS feedback.feedback_votes;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS feedback.feedback_reactions (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
feedback_id uuid NOT NULL REFERENCES feedback.user_feedback(id) ON DELETE CASCADE,
|
||||
user_id text NOT NULL,
|
||||
emoji text NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS feedback_reactions_unique
|
||||
ON feedback.feedback_reactions(feedback_id, user_id, emoji);
|
||||
CREATE INDEX IF NOT EXISTS feedback_reactions_feedback_idx
|
||||
ON feedback.feedback_reactions(feedback_id);
|
||||
|
||||
COMMIT;
|
||||
|
|
@ -4,6 +4,12 @@ export interface Config {
|
|||
manaAuthUrl: string;
|
||||
manaLlmUrl: string;
|
||||
serviceKey: string;
|
||||
/**
|
||||
* Secret seeded into the per-user display-hash for the public-community
|
||||
* pseudonym ("Wachsame Eule #4528"). Rotating this re-keys all future
|
||||
* pseudonyms — existing rows keep the old hash/name.
|
||||
*/
|
||||
pseudonymSecret: string;
|
||||
cors: { origins: string[] };
|
||||
}
|
||||
|
||||
|
|
@ -11,13 +17,11 @@ export function loadConfig(): Config {
|
|||
const env = (key: string, fallback?: string) => process.env[key] || fallback || '';
|
||||
return {
|
||||
port: parseInt(env('PORT', '3064'), 10),
|
||||
databaseUrl: env(
|
||||
'DATABASE_URL',
|
||||
'postgresql://mana:devpassword@localhost:5432/mana_platform'
|
||||
),
|
||||
databaseUrl: env('DATABASE_URL', 'postgresql://mana:devpassword@localhost:5432/mana_platform'),
|
||||
manaAuthUrl: env('MANA_AUTH_URL', 'http://localhost:3001'),
|
||||
manaLlmUrl: env('MANA_LLM_URL', 'http://localhost:3025'),
|
||||
serviceKey: env('MANA_SERVICE_KEY', 'dev-service-key'),
|
||||
pseudonymSecret: env('FEEDBACK_PSEUDONYM_SECRET', 'dev-pseudonym-secret'),
|
||||
cors: { origins: env('CORS_ORIGINS', 'http://localhost:5173').split(',') },
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import {
|
|||
index,
|
||||
unique,
|
||||
pgEnum,
|
||||
type AnyPgColumn,
|
||||
} from 'drizzle-orm/pg-core';
|
||||
|
||||
export const feedbackSchema = pgSchema('feedback');
|
||||
|
|
@ -49,6 +50,26 @@ export const userFeedback = feedbackSchema.table(
|
|||
isPublic: boolean('is_public').default(true).notNull(),
|
||||
adminResponse: text('admin_response'),
|
||||
voteCount: integer('vote_count').default(0).notNull(),
|
||||
// Public-community fields (Phase 2.1):
|
||||
// `display_hash` = SHA256(userId + serviceKey), never exposed.
|
||||
// `display_name` = deterministic Tier-pseudonym derived from hash.
|
||||
// Server stamps both on insert; clients receive only display_name.
|
||||
displayHash: text('display_hash'),
|
||||
displayName: text('display_name'),
|
||||
// `module_context` is set by inline FeedbackHook submissions so the
|
||||
// public feed can filter / badge by module ('todo', 'notes', …).
|
||||
moduleContext: text('module_context'),
|
||||
// `parent_id` enables 1-level reply threading on feedback items.
|
||||
parentId: uuid('parent_id').references((): AnyPgColumn => userFeedback.id, {
|
||||
onDelete: 'set null',
|
||||
}),
|
||||
// Cached per-emoji counter map, e.g. {"👍": 12, "❤️": 4, "🚀": 2}.
|
||||
// Source of truth lives in `feedback_reactions`; this column is
|
||||
// recomputed on every react/unreact for cheap reads.
|
||||
reactions: jsonb('reactions').default({}).notNull(),
|
||||
// Cached sort score (weighted reactions sum). Sort the public feed
|
||||
// on this column instead of recomputing per-row from `reactions`.
|
||||
score: integer('score').default(0).notNull(),
|
||||
deviceInfo: jsonb('device_info'),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
|
|
@ -57,23 +78,32 @@ export const userFeedback = feedbackSchema.table(
|
|||
userIdIdx: index('feedback_user_id_idx').on(table.userId),
|
||||
appIdIdx: index('feedback_app_id_idx').on(table.appId),
|
||||
statusIdx: index('feedback_status_idx').on(table.status),
|
||||
displayHashIdx: index('feedback_display_hash_idx').on(table.displayHash),
|
||||
moduleContextIdx: index('feedback_module_context_idx').on(table.moduleContext),
|
||||
parentIdIdx: index('feedback_parent_id_idx').on(table.parentId),
|
||||
scoreIdx: index('feedback_score_idx').on(table.score),
|
||||
})
|
||||
);
|
||||
|
||||
export const feedbackVotes = feedbackSchema.table(
|
||||
'feedback_votes',
|
||||
// Reactions table: one row per (item, user, emoji). Slack-pattern:
|
||||
// a user can stack multiple emojis on the same item. Aggregated counts
|
||||
// are mirrored into `user_feedback.reactions` for cheap reads.
|
||||
export const feedbackReactions = feedbackSchema.table(
|
||||
'feedback_reactions',
|
||||
{
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
feedbackId: uuid('feedback_id')
|
||||
.notNull()
|
||||
.references(() => userFeedback.id, { onDelete: 'cascade' }),
|
||||
userId: text('user_id').notNull(),
|
||||
emoji: text('emoji').notNull(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
},
|
||||
(table) => ({
|
||||
feedbackUserUnique: unique('feedback_votes_unique').on(table.feedbackId, table.userId),
|
||||
uniq: unique('feedback_reactions_unique').on(table.feedbackId, table.userId, table.emoji),
|
||||
feedbackIdx: index('feedback_reactions_feedback_idx').on(table.feedbackId),
|
||||
})
|
||||
);
|
||||
|
||||
export type Feedback = typeof userFeedback.$inferSelect;
|
||||
export type FeedbackVote = typeof feedbackVotes.$inferSelect;
|
||||
export type FeedbackReaction = typeof feedbackReactions.$inferSelect;
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
/**
|
||||
* mana-analytics — Feedback and analytics service
|
||||
* mana-analytics — Public-Community Feedback Hub
|
||||
*
|
||||
* Hono + Bun runtime. Extracted from mana-auth.
|
||||
* Handles: user feedback, voting, AI-powered title generation.
|
||||
* Hono + Bun runtime. Routes:
|
||||
* /api/v1/feedback/* — auth-required (jwtAuth via JWKS)
|
||||
* /api/v1/public/feedback/* — read-only, no auth, redacted output
|
||||
*/
|
||||
|
||||
import { Hono } from 'hono';
|
||||
|
|
@ -14,11 +15,12 @@ import { jwtAuth } from './middleware/jwt-auth';
|
|||
import { FeedbackService } from './services/feedback';
|
||||
import { healthRoutes } from './routes/health';
|
||||
import { createFeedbackRoutes } from './routes/feedback';
|
||||
import { createPublicFeedbackRoutes } from './routes/public';
|
||||
|
||||
const config = loadConfig();
|
||||
const db = getDb(config.databaseUrl);
|
||||
|
||||
const feedbackService = new FeedbackService(db, config.manaLlmUrl);
|
||||
const feedbackService = new FeedbackService(db, config.manaLlmUrl, config.pseudonymSecret);
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
|
|
@ -27,6 +29,10 @@ app.use('*', cors({ origin: config.cors.origins, credentials: true }));
|
|||
|
||||
app.route('/health', healthRoutes);
|
||||
|
||||
// Public surface: anonymous reads, no JWT required.
|
||||
app.route('/api/v1/public/feedback', createPublicFeedbackRoutes(feedbackService));
|
||||
|
||||
// Authenticated surface: submit, react, manage own items, admin.
|
||||
app.use('/api/v1/feedback/*', jwtAuth(config.manaAuthUrl));
|
||||
app.route('/api/v1/feedback', createFeedbackRoutes(feedbackService));
|
||||
|
||||
|
|
|
|||
49
services/mana-analytics/src/lib/pseudonym.test.ts
Normal file
49
services/mana-analytics/src/lib/pseudonym.test.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import { describe, expect, it } from 'bun:test';
|
||||
import { createDisplayHash, generateDisplayName, __TEST__ } from './pseudonym';
|
||||
|
||||
describe('pseudonym', () => {
|
||||
it('createDisplayHash is deterministic per (userId, secret)', () => {
|
||||
const a = createDisplayHash('user-123', 'secret');
|
||||
const b = createDisplayHash('user-123', 'secret');
|
||||
expect(a).toBe(b);
|
||||
});
|
||||
|
||||
it('createDisplayHash varies per userId', () => {
|
||||
const a = createDisplayHash('user-1', 'secret');
|
||||
const b = createDisplayHash('user-2', 'secret');
|
||||
expect(a).not.toBe(b);
|
||||
});
|
||||
|
||||
it('createDisplayHash varies per secret (rotation safety)', () => {
|
||||
const a = createDisplayHash('user-1', 'secret-old');
|
||||
const b = createDisplayHash('user-1', 'secret-new');
|
||||
expect(a).not.toBe(b);
|
||||
});
|
||||
|
||||
it('generateDisplayName is deterministic per hash', () => {
|
||||
const hash = createDisplayHash('user-123', 'secret');
|
||||
expect(generateDisplayName(hash)).toBe(generateDisplayName(hash));
|
||||
});
|
||||
|
||||
it('generateDisplayName format is "{Adj} {Tier} #{4-digit}"', () => {
|
||||
const hash = createDisplayHash('user-1', 'secret');
|
||||
const name = generateDisplayName(hash);
|
||||
expect(name).toMatch(/^[A-ZÄÖÜ][a-zäöüß]+ [A-ZÄÖÜ][a-zäöüß]+ #\d{4}$/);
|
||||
});
|
||||
|
||||
it('uses adjectives + animals from the curated lists', () => {
|
||||
const hash = createDisplayHash('user-1', 'secret');
|
||||
const [adj, animal] = generateDisplayName(hash).split(' ');
|
||||
expect(__TEST__.ADJECTIVES).toContain(adj);
|
||||
expect(__TEST__.ANIMALS).toContain(animal);
|
||||
});
|
||||
|
||||
it('produces varied names across many users (no obvious clustering)', () => {
|
||||
const names = new Set<string>();
|
||||
for (let i = 0; i < 1000; i++) {
|
||||
names.add(generateDisplayName(createDisplayHash(`user-${i}`, 'secret')));
|
||||
}
|
||||
// 1000 users → expect ~1000 unique pseudonyms (collisions allowed but rare).
|
||||
expect(names.size).toBeGreaterThan(950);
|
||||
});
|
||||
});
|
||||
241
services/mana-analytics/src/lib/pseudonym.ts
Normal file
241
services/mana-analytics/src/lib/pseudonym.ts
Normal file
|
|
@ -0,0 +1,241 @@
|
|||
/**
|
||||
* Pseudonym generator for the public-community surface.
|
||||
*
|
||||
* Same hash-input always yields the same display_name → users see "their"
|
||||
* pseudonym consistently across submissions, but real userId is never
|
||||
* exposed.
|
||||
*
|
||||
* Generation:
|
||||
* "{Adjektiv} {Tier} #{HashSuffix}"
|
||||
* e.g. "Wachsame Eule #4528", "Heimliche Otter #0091"
|
||||
*
|
||||
* Naming space: 100 adjectives × 80 animals × 10000 numeric suffixes
|
||||
* = 80 million combinations. Collision risk irrelevant in practice.
|
||||
*
|
||||
* The 4-digit suffix is the last 4 hex characters of the hash interpreted
|
||||
* as decimal (modulo 10000). Adjective and animal indices are derived
|
||||
* from disjoint hash slices so they vary independently.
|
||||
*/
|
||||
|
||||
import { createHash } from 'crypto';
|
||||
|
||||
const ADJECTIVES = [
|
||||
'Wachsame',
|
||||
'Heimliche',
|
||||
'Stille',
|
||||
'Kühne',
|
||||
'Sanfte',
|
||||
'Wilde',
|
||||
'Listige',
|
||||
'Weise',
|
||||
'Mutige',
|
||||
'Träumerische',
|
||||
'Neugierige',
|
||||
'Treue',
|
||||
'Verspielte',
|
||||
'Bedächtige',
|
||||
'Flinke',
|
||||
'Helle',
|
||||
'Dunkle',
|
||||
'Goldene',
|
||||
'Silberne',
|
||||
'Funkelnde',
|
||||
'Glühende',
|
||||
'Kühle',
|
||||
'Warme',
|
||||
'Singende',
|
||||
'Tanzende',
|
||||
'Schweigende',
|
||||
'Lauschende',
|
||||
'Suchende',
|
||||
'Findende',
|
||||
'Wandernde',
|
||||
'Schwebende',
|
||||
'Tauchende',
|
||||
'Träumende',
|
||||
'Wache',
|
||||
'Frohe',
|
||||
'Ernste',
|
||||
'Leise',
|
||||
'Laute',
|
||||
'Sanfte',
|
||||
'Schnelle',
|
||||
'Langsame',
|
||||
'Geheime',
|
||||
'Offene',
|
||||
'Versteckte',
|
||||
'Sichtbare',
|
||||
'Strahlende',
|
||||
'Glänzende',
|
||||
'Matte',
|
||||
'Helle',
|
||||
'Schimmernde',
|
||||
'Flackernde',
|
||||
'Stetige',
|
||||
'Bewegte',
|
||||
'Ruhige',
|
||||
'Stürmische',
|
||||
'Friedliche',
|
||||
'Kämpferische',
|
||||
'Freundliche',
|
||||
'Misstrauische',
|
||||
'Vertrauende',
|
||||
'Hoffende',
|
||||
'Zweifelnde',
|
||||
'Glaubende',
|
||||
'Fragende',
|
||||
'Antwortende',
|
||||
'Lernende',
|
||||
'Lehrende',
|
||||
'Wachsende',
|
||||
'Reife',
|
||||
'Junge',
|
||||
'Alte',
|
||||
'Zeitlose',
|
||||
'Frische',
|
||||
'Müde',
|
||||
'Wache',
|
||||
'Schlafende',
|
||||
'Erwachende',
|
||||
'Träumende',
|
||||
'Erinnernde',
|
||||
'Vergessende',
|
||||
'Zählende',
|
||||
'Sammelnde',
|
||||
'Verschenkende',
|
||||
'Bewahrende',
|
||||
'Suchende',
|
||||
'Wartende',
|
||||
'Eilende',
|
||||
'Bleibende',
|
||||
'Reisende',
|
||||
'Heimkehrende',
|
||||
'Aufbrechende',
|
||||
'Ankommende',
|
||||
'Lauschende',
|
||||
'Singende',
|
||||
'Pfeifende',
|
||||
'Summende',
|
||||
'Knurrende',
|
||||
'Schnurrende',
|
||||
'Lachende',
|
||||
'Weinende',
|
||||
'Schmunzelnde',
|
||||
'Staunende',
|
||||
];
|
||||
|
||||
const ANIMALS = [
|
||||
'Eule',
|
||||
'Otter',
|
||||
'Fuchs',
|
||||
'Wolf',
|
||||
'Bär',
|
||||
'Luchs',
|
||||
'Adler',
|
||||
'Reiher',
|
||||
'Kranich',
|
||||
'Schwalbe',
|
||||
'Lerche',
|
||||
'Amsel',
|
||||
'Specht',
|
||||
'Falke',
|
||||
'Habicht',
|
||||
'Sperber',
|
||||
'Krähe',
|
||||
'Rabe',
|
||||
'Elster',
|
||||
'Häher',
|
||||
'Star',
|
||||
'Drossel',
|
||||
'Meise',
|
||||
'Fink',
|
||||
'Sperling',
|
||||
'Schmetterling',
|
||||
'Libelle',
|
||||
'Hirsch',
|
||||
'Reh',
|
||||
'Gämse',
|
||||
'Steinbock',
|
||||
'Murmeltier',
|
||||
'Eichhörnchen',
|
||||
'Iltis',
|
||||
'Marder',
|
||||
'Dachs',
|
||||
'Igel',
|
||||
'Wiesel',
|
||||
'Hermelin',
|
||||
'Salamander',
|
||||
'Molch',
|
||||
'Frosch',
|
||||
'Kröte',
|
||||
'Eidechse',
|
||||
'Schlange',
|
||||
'Schildkröte',
|
||||
'Karpfen',
|
||||
'Hecht',
|
||||
'Forelle',
|
||||
'Lachs',
|
||||
'Stör',
|
||||
'Aal',
|
||||
'Krebs',
|
||||
'Schnecke',
|
||||
'Spinne',
|
||||
'Käfer',
|
||||
'Hummel',
|
||||
'Biene',
|
||||
'Wespe',
|
||||
'Ameise',
|
||||
'Grashüpfer',
|
||||
'Grille',
|
||||
'Heuschrecke',
|
||||
'Marienkäfer',
|
||||
'Hirschkäfer',
|
||||
'Robbe',
|
||||
'Seehund',
|
||||
'Delfin',
|
||||
'Wal',
|
||||
'Hai',
|
||||
'Möwe',
|
||||
'Albatros',
|
||||
'Pelikan',
|
||||
'Kormoran',
|
||||
'Pinguin',
|
||||
'Schwan',
|
||||
'Gans',
|
||||
'Ente',
|
||||
'Storch',
|
||||
'Ibis',
|
||||
];
|
||||
|
||||
function hashToBigInt(hash: string): bigint {
|
||||
// Strip non-hex chars, take first 16 hex chars (64-bit slice).
|
||||
const slice = hash.replace(/[^0-9a-f]/gi, '').slice(0, 16);
|
||||
return BigInt('0x' + slice);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deterministically derive a display name from a display-hash.
|
||||
*
|
||||
* @param displayHash hex string from createDisplayHash().
|
||||
* @returns "{Adjektiv} {Tier} #{NNNN}" — stable across calls for same hash.
|
||||
*/
|
||||
export function generateDisplayName(displayHash: string): string {
|
||||
const big = hashToBigInt(displayHash);
|
||||
const adj = ADJECTIVES[Number(big % BigInt(ADJECTIVES.length))];
|
||||
const animal = ANIMALS[Number((big / BigInt(ADJECTIVES.length)) % BigInt(ANIMALS.length))];
|
||||
const suffix = Number((big / BigInt(ADJECTIVES.length * ANIMALS.length)) % 10000n)
|
||||
.toString()
|
||||
.padStart(4, '0');
|
||||
return `${adj} ${animal} #${suffix}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive a non-reversible display hash for a given userId.
|
||||
* Same userId + same secret always produces the same hash.
|
||||
*/
|
||||
export function createDisplayHash(userId: string, secret: string): string {
|
||||
return createHash('sha256').update(`${userId}:${secret}`).digest('hex');
|
||||
}
|
||||
|
||||
// Exported for tests.
|
||||
export const __TEST__ = { ADJECTIVES, ANIMALS };
|
||||
|
|
@ -1,34 +1,123 @@
|
|||
/**
|
||||
* Authenticated feedback routes — mounted under /api/v1/feedback.
|
||||
*
|
||||
* All routes here require a valid Bearer token (jwtAuth middleware
|
||||
* applied at the app level in index.ts). Public-read endpoints live
|
||||
* separately under /api/v1/public/feedback (see ./public.ts).
|
||||
*/
|
||||
|
||||
import { Hono } from 'hono';
|
||||
import type { FeedbackService } from '../services/feedback';
|
||||
import { ALLOWED_EMOJIS } from '../services/feedback';
|
||||
import type { AuthUser } from '../middleware/jwt-auth';
|
||||
import { BadRequestError, ForbiddenError } from '../lib/errors';
|
||||
|
||||
const FOUNDER_ROLES = new Set(['founder', 'admin']);
|
||||
|
||||
export function createFeedbackRoutes(feedbackService: FeedbackService) {
|
||||
return new Hono<{ Variables: { user: AuthUser } }>()
|
||||
.post('/', async (c) => {
|
||||
const user = c.get('user');
|
||||
const body = await c.req.json();
|
||||
return c.json(await feedbackService.createFeedback(user.userId, body), 201);
|
||||
})
|
||||
.get('/public', async (c) => {
|
||||
const appId = c.req.query('appId');
|
||||
const limit = parseInt(c.req.query('limit') || '50', 10);
|
||||
const offset = parseInt(c.req.query('offset') || '0', 10);
|
||||
return c.json(await feedbackService.getPublicFeedback(appId, limit, offset));
|
||||
})
|
||||
.get('/me', async (c) => {
|
||||
const user = c.get('user');
|
||||
return c.json(await feedbackService.getMyFeedback(user.userId));
|
||||
})
|
||||
.post('/:id/vote', async (c) => {
|
||||
const user = c.get('user');
|
||||
return c.json(await feedbackService.vote(c.req.param('id'), user.userId));
|
||||
})
|
||||
.delete('/:id/vote', async (c) => {
|
||||
const user = c.get('user');
|
||||
return c.json(await feedbackService.unvote(c.req.param('id'), user.userId));
|
||||
})
|
||||
.delete('/:id', async (c) => {
|
||||
const user = c.get('user');
|
||||
return c.json(await feedbackService.deleteFeedback(c.req.param('id'), user.userId));
|
||||
const r = new Hono<{ Variables: { user: AuthUser } }>();
|
||||
|
||||
// ── User-facing ───────────────────────────────────────────────────
|
||||
|
||||
r.post('/', async (c) => {
|
||||
const user = c.get('user');
|
||||
const body = await c.req.json();
|
||||
const item = await feedbackService.createFeedback(user.userId, body);
|
||||
return c.json(item, 201);
|
||||
});
|
||||
|
||||
/** Auth-required public feed — same as /public/feed but additionally
|
||||
* enriches each item with the requesting user's reaction state. */
|
||||
r.get('/public', async (c) => {
|
||||
const user = c.get('user');
|
||||
const appId = c.req.query('appId') || undefined;
|
||||
const moduleContext = c.req.query('moduleContext') || undefined;
|
||||
const category = c.req.query('category') || undefined;
|
||||
const status = c.req.query('status') || undefined;
|
||||
const limit = parseInt(c.req.query('limit') || '50', 10);
|
||||
const offset = parseInt(c.req.query('offset') || '0', 10);
|
||||
|
||||
const items = await feedbackService.getPublicFeed({
|
||||
appId,
|
||||
moduleContext,
|
||||
category,
|
||||
status,
|
||||
limit,
|
||||
offset,
|
||||
});
|
||||
|
||||
const enriched = await Promise.all(
|
||||
items.map(async (item) => ({
|
||||
...item,
|
||||
myReactions: await feedbackService.getMyReactionsFor(item.id, user.userId),
|
||||
}))
|
||||
);
|
||||
|
||||
return c.json({ items: enriched });
|
||||
});
|
||||
|
||||
r.get('/me', async (c) => {
|
||||
const user = c.get('user');
|
||||
return c.json(await feedbackService.getMyFeedback(user.userId));
|
||||
});
|
||||
|
||||
r.get('/:id/replies', async (c) => {
|
||||
return c.json(await feedbackService.getReplies(c.req.param('id')));
|
||||
});
|
||||
|
||||
// ── Reactions ─────────────────────────────────────────────────────
|
||||
|
||||
r.post('/:id/react', async (c) => {
|
||||
const user = c.get('user');
|
||||
const { emoji } = await c.req.json<{ emoji: string }>();
|
||||
if (!emoji || !ALLOWED_EMOJIS.includes(emoji)) {
|
||||
throw new BadRequestError(`emoji must be one of: ${ALLOWED_EMOJIS.join(' ')}`);
|
||||
}
|
||||
const result = await feedbackService.toggleReaction(c.req.param('id'), user.userId, emoji);
|
||||
return c.json(result);
|
||||
});
|
||||
|
||||
r.delete('/:id', async (c) => {
|
||||
const user = c.get('user');
|
||||
return c.json(await feedbackService.deleteFeedback(c.req.param('id'), user.userId));
|
||||
});
|
||||
|
||||
// ── Admin (founder/admin role) ────────────────────────────────────
|
||||
|
||||
r.get('/admin', async (c) => {
|
||||
const user = c.get('user');
|
||||
if (!FOUNDER_ROLES.has(user.role)) throw new ForbiddenError('Founder/admin role required');
|
||||
|
||||
const appId = c.req.query('appId') || undefined;
|
||||
const category = c.req.query('category') || undefined;
|
||||
const status = c.req.query('status') || undefined;
|
||||
const moduleContext = c.req.query('moduleContext') || undefined;
|
||||
const limit = parseInt(c.req.query('limit') || '100', 10);
|
||||
const offset = parseInt(c.req.query('offset') || '0', 10);
|
||||
|
||||
const items = await feedbackService.adminListAll({
|
||||
appId,
|
||||
category,
|
||||
status,
|
||||
moduleContext,
|
||||
limit,
|
||||
offset,
|
||||
});
|
||||
return c.json({ items });
|
||||
});
|
||||
|
||||
r.patch('/admin/:id', async (c) => {
|
||||
const user = c.get('user');
|
||||
if (!FOUNDER_ROLES.has(user.role)) throw new ForbiddenError('Founder/admin role required');
|
||||
|
||||
const patch = await c.req.json<{
|
||||
status?: string;
|
||||
adminResponse?: string;
|
||||
isPublic?: boolean;
|
||||
}>();
|
||||
const updated = await feedbackService.adminUpdate(c.req.param('id'), patch);
|
||||
return c.json(updated);
|
||||
});
|
||||
|
||||
return r;
|
||||
}
|
||||
|
|
|
|||
44
services/mana-analytics/src/routes/public.ts
Normal file
44
services/mana-analytics/src/routes/public.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
/**
|
||||
* Public, anonymous feedback routes — mounted under /api/v1/public/feedback.
|
||||
*
|
||||
* Distinct from /api/v1/feedback/* because the jwtAuth middleware is
|
||||
* scoped to that prefix only — anything under /api/v1/public/* skips
|
||||
* auth entirely. Output is fully redacted (display_name only, no
|
||||
* userId / displayHash / deviceInfo); reaction state is not enriched
|
||||
* because there is no caller identity.
|
||||
*/
|
||||
|
||||
import { Hono } from 'hono';
|
||||
import type { FeedbackService } from '../services/feedback';
|
||||
|
||||
export function createPublicFeedbackRoutes(feedbackService: FeedbackService) {
|
||||
const r = new Hono();
|
||||
|
||||
r.get('/feed', async (c) => {
|
||||
const appId = c.req.query('appId') || undefined;
|
||||
const moduleContext = c.req.query('moduleContext') || undefined;
|
||||
const category = c.req.query('category') || undefined;
|
||||
const status = c.req.query('status') || undefined;
|
||||
const limit = Math.min(parseInt(c.req.query('limit') || '50', 10), 200);
|
||||
const offset = parseInt(c.req.query('offset') || '0', 10);
|
||||
|
||||
const items = await feedbackService.getPublicFeed({
|
||||
appId,
|
||||
moduleContext,
|
||||
category,
|
||||
status,
|
||||
limit,
|
||||
offset,
|
||||
});
|
||||
return c.json({ items });
|
||||
});
|
||||
|
||||
r.get('/:id', async (c) => {
|
||||
const item = await feedbackService.getPublicItem(c.req.param('id'));
|
||||
if (!item) return c.json({ error: 'Not found' }, 404);
|
||||
const replies = await feedbackService.getReplies(item.id);
|
||||
return c.json({ item, replies });
|
||||
});
|
||||
|
||||
return r;
|
||||
}
|
||||
|
|
@ -1,18 +1,64 @@
|
|||
/**
|
||||
* Feedback Service — User feedback CRUD with voting
|
||||
* Feedback Service — Public-Community-Hub backend.
|
||||
*
|
||||
* Public reads are anonymous: callers receive the persistent pseudonym
|
||||
* (display_name) but never the underlying userId. Writes (create / react /
|
||||
* delete) require auth at the route layer; the service trusts the
|
||||
* userId argument.
|
||||
*
|
||||
* Reactions follow the Slack pattern: a user can stack multiple emojis
|
||||
* on the same item. Aggregated counts are mirrored to
|
||||
* `user_feedback.reactions` so the public feed sorts on a cached score
|
||||
* column.
|
||||
*/
|
||||
|
||||
import { eq, and, desc, sql } from 'drizzle-orm';
|
||||
import { userFeedback, feedbackVotes } from '../db/schema/feedback';
|
||||
import { eq, and, desc, sql, isNull } from 'drizzle-orm';
|
||||
import { userFeedback, feedbackReactions } from '../db/schema/feedback';
|
||||
import type { Database } from '../db/connection';
|
||||
import { NotFoundError } from '../lib/errors';
|
||||
import { NotFoundError, BadRequestError } from '../lib/errors';
|
||||
import { createDisplayHash, generateDisplayName } from '../lib/pseudonym';
|
||||
|
||||
/**
|
||||
* Allowed reaction emojis with sort-score weights.
|
||||
* Add a new emoji here to make it submittable.
|
||||
*/
|
||||
const REACTION_WEIGHTS: Record<string, number> = {
|
||||
'👍': 1,
|
||||
'❤️': 1,
|
||||
'🚀': 2,
|
||||
'🤔': 0,
|
||||
'🎉': 1,
|
||||
};
|
||||
|
||||
const ALLOWED_EMOJIS = Object.keys(REACTION_WEIGHTS);
|
||||
|
||||
export type PublicFeedbackItem = {
|
||||
id: string;
|
||||
appId: string;
|
||||
title: string | null;
|
||||
feedbackText: string;
|
||||
category: string;
|
||||
status: string;
|
||||
moduleContext: string | null;
|
||||
parentId: string | null;
|
||||
displayName: string;
|
||||
reactions: Record<string, number>;
|
||||
score: number;
|
||||
adminResponse: string | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
};
|
||||
|
||||
export class FeedbackService {
|
||||
constructor(
|
||||
private db: Database,
|
||||
private llmUrl: string
|
||||
private llmUrl: string,
|
||||
/** Secret used to derive non-reversible per-user display hashes. */
|
||||
private pseudonymSecret: string
|
||||
) {}
|
||||
|
||||
// ── Submission ────────────────────────────────────────────────────
|
||||
|
||||
async createFeedback(
|
||||
userId: string,
|
||||
data: {
|
||||
|
|
@ -21,13 +67,16 @@ export class FeedbackService {
|
|||
category?: string;
|
||||
title?: string;
|
||||
isPublic?: boolean;
|
||||
moduleContext?: string;
|
||||
parentId?: string;
|
||||
deviceInfo?: Record<string, unknown>;
|
||||
}
|
||||
) {
|
||||
let title = data.title;
|
||||
|
||||
// Auto-generate title via LLM if not provided
|
||||
if (!title && this.llmUrl) {
|
||||
// Auto-title via mana-llm only for top-level items; replies inherit
|
||||
// context from parent and don't need their own title.
|
||||
if (!title && !data.parentId && this.llmUrl) {
|
||||
try {
|
||||
title = await this.generateTitle(data.feedbackText);
|
||||
} catch {
|
||||
|
|
@ -35,6 +84,9 @@ export class FeedbackService {
|
|||
}
|
||||
}
|
||||
|
||||
const displayHash = createDisplayHash(userId, this.pseudonymSecret);
|
||||
const displayName = generateDisplayName(displayHash);
|
||||
|
||||
const [feedback] = await this.db
|
||||
.insert(userFeedback)
|
||||
.values({
|
||||
|
|
@ -43,10 +95,11 @@ export class FeedbackService {
|
|||
title: title || data.feedbackText.slice(0, 80),
|
||||
feedbackText: data.feedbackText,
|
||||
category: (data.category as any) || 'other',
|
||||
// Honor explicit isPublic from caller; otherwise let the column
|
||||
// default (true) apply. Private intake categories like
|
||||
// 'onboarding-wish' should pass `false`.
|
||||
...(typeof data.isPublic === 'boolean' ? { isPublic: data.isPublic } : {}),
|
||||
moduleContext: data.moduleContext ?? null,
|
||||
parentId: data.parentId ?? null,
|
||||
displayHash,
|
||||
displayName,
|
||||
deviceInfo: data.deviceInfo,
|
||||
})
|
||||
.returning();
|
||||
|
|
@ -54,18 +107,65 @@ export class FeedbackService {
|
|||
return feedback;
|
||||
}
|
||||
|
||||
async getPublicFeedback(appId?: string, limit = 50, offset = 0) {
|
||||
let query = this.db
|
||||
// ── Public reads (no auth) ────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Public feed: top-level items only (parent_id IS NULL), is_public=true.
|
||||
* Sorted by cached score desc, then recency. Output is redacted —
|
||||
* userId / displayHash / deviceInfo never leave the service.
|
||||
*/
|
||||
async getPublicFeed(
|
||||
opts: {
|
||||
appId?: string;
|
||||
moduleContext?: string;
|
||||
category?: string;
|
||||
status?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
} = {}
|
||||
): Promise<PublicFeedbackItem[]> {
|
||||
const { appId, moduleContext, category, status, limit = 50, offset = 0 } = opts;
|
||||
|
||||
const conditions = [eq(userFeedback.isPublic, true), isNull(userFeedback.parentId)];
|
||||
if (appId) conditions.push(eq(userFeedback.appId, appId));
|
||||
if (moduleContext) conditions.push(eq(userFeedback.moduleContext, moduleContext));
|
||||
if (category) conditions.push(eq(userFeedback.category, category as any));
|
||||
if (status) conditions.push(eq(userFeedback.status, status as any));
|
||||
|
||||
const rows = await this.db
|
||||
.select()
|
||||
.from(userFeedback)
|
||||
.where(eq(userFeedback.isPublic, true))
|
||||
.orderBy(desc(userFeedback.voteCount))
|
||||
.where(and(...conditions))
|
||||
.orderBy(desc(userFeedback.score), desc(userFeedback.createdAt))
|
||||
.limit(limit)
|
||||
.offset(offset);
|
||||
|
||||
return query;
|
||||
return rows.map(redact);
|
||||
}
|
||||
|
||||
/** Replies for a single parent item (1-level threading). */
|
||||
async getReplies(parentId: string): Promise<PublicFeedbackItem[]> {
|
||||
const rows = await this.db
|
||||
.select()
|
||||
.from(userFeedback)
|
||||
.where(and(eq(userFeedback.parentId, parentId), eq(userFeedback.isPublic, true)))
|
||||
.orderBy(userFeedback.createdAt);
|
||||
return rows.map(redact);
|
||||
}
|
||||
|
||||
/** Single public item by id. Returns null if not found / not public. */
|
||||
async getPublicItem(id: string): Promise<PublicFeedbackItem | null> {
|
||||
const [row] = await this.db
|
||||
.select()
|
||||
.from(userFeedback)
|
||||
.where(and(eq(userFeedback.id, id), eq(userFeedback.isPublic, true)))
|
||||
.limit(1);
|
||||
return row ? redact(row) : null;
|
||||
}
|
||||
|
||||
// ── Authenticated reads ───────────────────────────────────────────
|
||||
|
||||
/** Items the user has authored (across all isPublic states). */
|
||||
async getMyFeedback(userId: string) {
|
||||
return this.db
|
||||
.select()
|
||||
|
|
@ -74,30 +174,96 @@ export class FeedbackService {
|
|||
.orderBy(desc(userFeedback.createdAt));
|
||||
}
|
||||
|
||||
async vote(feedbackId: string, userId: string) {
|
||||
await this.db.insert(feedbackVotes).values({ feedbackId, userId }).onConflictDoNothing();
|
||||
await this.db
|
||||
.update(userFeedback)
|
||||
.set({ voteCount: sql`${userFeedback.voteCount} + 1` })
|
||||
.where(eq(userFeedback.id, feedbackId));
|
||||
return { success: true };
|
||||
/** Map of emoji → boolean for the requesting user on a feedback item. */
|
||||
async getMyReactionsFor(feedbackId: string, userId: string): Promise<string[]> {
|
||||
const rows = await this.db
|
||||
.select({ emoji: feedbackReactions.emoji })
|
||||
.from(feedbackReactions)
|
||||
.where(
|
||||
and(eq(feedbackReactions.feedbackId, feedbackId), eq(feedbackReactions.userId, userId))
|
||||
);
|
||||
return rows.map((r) => r.emoji);
|
||||
}
|
||||
|
||||
async unvote(feedbackId: string, userId: string) {
|
||||
const result = await this.db
|
||||
.delete(feedbackVotes)
|
||||
.where(and(eq(feedbackVotes.feedbackId, feedbackId), eq(feedbackVotes.userId, userId)))
|
||||
// ── Reactions ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Toggle a single emoji reaction for (feedbackId, userId).
|
||||
* Returns the updated reaction-counter map and score.
|
||||
*/
|
||||
async toggleReaction(
|
||||
feedbackId: string,
|
||||
userId: string,
|
||||
emoji: string
|
||||
): Promise<{ reactions: Record<string, number>; score: number; userHasReacted: boolean }> {
|
||||
if (!ALLOWED_EMOJIS.includes(emoji)) {
|
||||
throw new BadRequestError(`Unsupported emoji: ${emoji}`);
|
||||
}
|
||||
|
||||
// Ensure target item exists.
|
||||
const [item] = await this.db
|
||||
.select({ id: userFeedback.id })
|
||||
.from(userFeedback)
|
||||
.where(eq(userFeedback.id, feedbackId))
|
||||
.limit(1);
|
||||
if (!item) throw new NotFoundError('Feedback not found');
|
||||
|
||||
// Try to insert (react). If conflicting → user already reacted, so unreact.
|
||||
const inserted = await this.db
|
||||
.insert(feedbackReactions)
|
||||
.values({ feedbackId, userId, emoji })
|
||||
.onConflictDoNothing()
|
||||
.returning();
|
||||
|
||||
if (result.length > 0) {
|
||||
let userHasReacted: boolean;
|
||||
if (inserted.length === 0) {
|
||||
// Already reacted → remove the row (unreact).
|
||||
await this.db
|
||||
.update(userFeedback)
|
||||
.set({ voteCount: sql`GREATEST(${userFeedback.voteCount} - 1, 0)` })
|
||||
.where(eq(userFeedback.id, feedbackId));
|
||||
.delete(feedbackReactions)
|
||||
.where(
|
||||
and(
|
||||
eq(feedbackReactions.feedbackId, feedbackId),
|
||||
eq(feedbackReactions.userId, userId),
|
||||
eq(feedbackReactions.emoji, emoji)
|
||||
)
|
||||
);
|
||||
userHasReacted = false;
|
||||
} else {
|
||||
userHasReacted = true;
|
||||
}
|
||||
return { success: true };
|
||||
|
||||
// Recompute aggregated reactions + score for this item.
|
||||
const aggregated = await this.recomputeReactions(feedbackId);
|
||||
return { ...aggregated, userHasReacted };
|
||||
}
|
||||
|
||||
/** Recomputes user_feedback.reactions + score from feedback_reactions. */
|
||||
private async recomputeReactions(
|
||||
feedbackId: string
|
||||
): Promise<{ reactions: Record<string, number>; score: number }> {
|
||||
const rows = await this.db
|
||||
.select({ emoji: feedbackReactions.emoji, count: sql<number>`count(*)::int` })
|
||||
.from(feedbackReactions)
|
||||
.where(eq(feedbackReactions.feedbackId, feedbackId))
|
||||
.groupBy(feedbackReactions.emoji);
|
||||
|
||||
const reactions: Record<string, number> = {};
|
||||
let score = 0;
|
||||
for (const row of rows) {
|
||||
reactions[row.emoji] = row.count;
|
||||
score += (REACTION_WEIGHTS[row.emoji] ?? 0) * row.count;
|
||||
}
|
||||
|
||||
await this.db
|
||||
.update(userFeedback)
|
||||
.set({ reactions, score, updatedAt: new Date() })
|
||||
.where(eq(userFeedback.id, feedbackId));
|
||||
|
||||
return { reactions, score };
|
||||
}
|
||||
|
||||
// ── Mutations ─────────────────────────────────────────────────────
|
||||
|
||||
async deleteFeedback(feedbackId: string, userId: string) {
|
||||
const result = await this.db
|
||||
.delete(userFeedback)
|
||||
|
|
@ -107,6 +273,54 @@ export class FeedbackService {
|
|||
return { success: true };
|
||||
}
|
||||
|
||||
// ── Admin (founder-tier-gated at route layer) ─────────────────────
|
||||
|
||||
async adminListAll(
|
||||
opts: {
|
||||
appId?: string;
|
||||
category?: string;
|
||||
status?: string;
|
||||
moduleContext?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
} = {}
|
||||
) {
|
||||
const { appId, category, status, moduleContext, limit = 100, offset = 0 } = opts;
|
||||
const conditions = [];
|
||||
if (appId) conditions.push(eq(userFeedback.appId, appId));
|
||||
if (category) conditions.push(eq(userFeedback.category, category as any));
|
||||
if (status) conditions.push(eq(userFeedback.status, status as any));
|
||||
if (moduleContext) conditions.push(eq(userFeedback.moduleContext, moduleContext));
|
||||
|
||||
return this.db
|
||||
.select()
|
||||
.from(userFeedback)
|
||||
.where(conditions.length ? and(...conditions) : undefined)
|
||||
.orderBy(desc(userFeedback.createdAt))
|
||||
.limit(limit)
|
||||
.offset(offset);
|
||||
}
|
||||
|
||||
async adminUpdate(
|
||||
feedbackId: string,
|
||||
patch: { status?: string; adminResponse?: string; isPublic?: boolean }
|
||||
) {
|
||||
const update: Record<string, unknown> = { updatedAt: new Date() };
|
||||
if (patch.status !== undefined) update.status = patch.status;
|
||||
if (patch.adminResponse !== undefined) update.adminResponse = patch.adminResponse;
|
||||
if (patch.isPublic !== undefined) update.isPublic = patch.isPublic;
|
||||
|
||||
const [row] = await this.db
|
||||
.update(userFeedback)
|
||||
.set(update)
|
||||
.where(eq(userFeedback.id, feedbackId))
|
||||
.returning();
|
||||
if (!row) throw new NotFoundError('Feedback not found');
|
||||
return row;
|
||||
}
|
||||
|
||||
// ── LLM helpers ───────────────────────────────────────────────────
|
||||
|
||||
private async generateTitle(text: string): Promise<string> {
|
||||
const res = await fetch(`${this.llmUrl}/api/v1/chat/completions`, {
|
||||
method: 'POST',
|
||||
|
|
@ -129,3 +343,25 @@ export class FeedbackService {
|
|||
return data.choices?.[0]?.message?.content?.trim() || text.slice(0, 80);
|
||||
}
|
||||
}
|
||||
|
||||
/** Strips userId / displayHash / deviceInfo from a row. */
|
||||
function redact(row: typeof userFeedback.$inferSelect): PublicFeedbackItem {
|
||||
return {
|
||||
id: row.id,
|
||||
appId: row.appId,
|
||||
title: row.title,
|
||||
feedbackText: row.feedbackText,
|
||||
category: row.category,
|
||||
status: row.status,
|
||||
moduleContext: row.moduleContext,
|
||||
parentId: row.parentId,
|
||||
displayName: row.displayName ?? 'Anonym',
|
||||
reactions: (row.reactions as Record<string, number>) ?? {},
|
||||
score: row.score,
|
||||
adminResponse: row.adminResponse,
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
export { ALLOWED_EMOJIS, REACTION_WEIGHTS };
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue