feat(feedback): rename community-identity columns + settings-section

Letzter "community"-Rest aus dem Feedback-Hub räumt sich auf — DB-Spalten,
Settings-Search-Index, Section-Name und i18n-Keys einheitlich auf
"feedback":

- DB: auth.users.community_show_real_name → feedback_show_real_name,
  community_karma → feedback_karma. Migration unter
  services/mana-auth/sql/009_rename_community_to_feedback.sql (manuell
  via psql, in Drizzle-Schema beider Services nachgezogen).
- mana-auth/me.ts: PATCH /api/v1/me/profile akzeptiert jetzt
  feedbackShowRealName und gibt es im Response zurück.
- mana-analytics: feedback.ts liest authUsers.feedbackShowRealName /
  feedbackKarma, redact() + Karma-Increment + Tests entsprechend.
- Frontend: CommunitySection.svelte → FeedbackIdentitySection.svelte
  (Datei umbenannt, Property-Namen + Toast-Texte aktualisiert,
  HeartHalf-Icon, "Feedback-Identität" als Title).
- searchIndex.ts: CategoryId 'community' → 'feedback', anchor
  'community-identity' → 'feedback-identity'.
- i18n (5 locales): settings.categories.community → .feedback,
  settings.search.community_* → feedback_*. Labels DE/EN/FR/IT/ES
  jeweils auf "Feedback" + "im Feedback-Feed" angepasst.

38/38 Integration-Tests grün, validate:i18n-parity sauber, svelte-check 0.

BREAKING (intern, nicht live): Frontend, das gegen die alten Spalten- /
Property-Namen aus dem PATCH-Response geht, fällt jetzt um. Kein
Production-Risiko da Hub noch nicht öffentlich.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-28 17:09:58 +02:00
parent 6f83fba66a
commit 941df57f77
16 changed files with 544 additions and 209 deletions

View file

@ -1,5 +1,5 @@
/**
* Read-only cross-schema view of auth.users for the public-community
* Read-only cross-schema view of auth.users for the public-feedback
* hub. mana-auth owns the table; we JOIN it from mana-analytics to
* enrich feed responses with the post-author's real-name opt-in and
* karma score. We never INSERT/UPDATE/DELETE here that's
@ -17,6 +17,6 @@ const authSchema = pgSchema('auth');
export const authUsers = authSchema.table('users', {
id: text('id').primaryKey(),
name: text('name').notNull(),
communityShowRealName: boolean('community_show_real_name').default(false).notNull(),
communityKarma: integer('community_karma').default(0).notNull(),
feedbackShowRealName: boolean('feedback_show_real_name').default(false).notNull(),
feedbackKarma: integer('feedback_karma').default(0).notNull(),
});

View file

@ -1,7 +1,7 @@
/**
* Integration tests for the cross-schema karma flow.
*
* Karma lives on auth.users.community_karma; mana-analytics increments
* Karma lives on auth.users.feedback_karma; mana-analytics increments
* it inside toggleReaction. Tests verify the SQL path, the self-react
* skip, and the floor-at-zero clamp.
*/

View file

@ -2,7 +2,7 @@
* Privacy-boundary tests für die `redact()`-Funktion.
*
* Kritisch: anonymous public endpoint darf NIE einen Klarnamen
* ausliefern, auch wenn der User-Account `communityShowRealName=true`
* ausliefern, auch wenn der User-Account `feedbackShowRealName=true`
* gesetzt hat. Diese Tests sind das Sicherheitsnetz für die Public
* bleibt anonym-Garantie der Community-Surface.
*/
@ -35,14 +35,14 @@ const baseFeedback = {
const optedInAuthor = {
name: 'Till Schäfer',
communityShowRealName: true,
communityKarma: 47,
feedbackShowRealName: true,
feedbackKarma: 47,
};
const optedOutAuthor = {
name: 'Till Schäfer',
communityShowRealName: false,
communityKarma: 47,
feedbackShowRealName: false,
feedbackKarma: 47,
};
describe('redact (privacy-boundary)', () => {

View file

@ -78,7 +78,7 @@ export type PublicFeedbackItem = {
/** Author's community karma (public, drives tier-badge). */
karma: number;
/** Real name, only present when:
* - the post-author opted in via communityShowRealName=true, AND
* - the post-author opted in via feedbackShowRealName=true, AND
* - the response is going to an authenticated caller (the
* anonymous /public endpoint always strips this).
*/
@ -88,8 +88,8 @@ export type PublicFeedbackItem = {
type FeedbackRow = typeof userFeedback.$inferSelect;
type AuthUserRow = {
name: string;
communityShowRealName: boolean;
communityKarma: number;
feedbackShowRealName: boolean;
feedbackKarma: number;
} | null;
export class FeedbackService {
@ -301,7 +301,7 @@ export class FeedbackService {
const items = rows.map((r) => redact(r.feedback, r.author, { includeRealName: false }));
const displayName = items[0]?.displayName ?? null;
const karma = rows[0]?.author?.communityKarma ?? 0;
const karma = rows[0]?.author?.feedbackKarma ?? 0;
return { displayHash, displayName, karma, items };
}
@ -327,8 +327,8 @@ export class FeedbackService {
private authorSelection() {
return {
name: authUsers.name,
communityShowRealName: authUsers.communityShowRealName,
communityKarma: authUsers.communityKarma,
feedbackShowRealName: authUsers.feedbackShowRealName,
feedbackKarma: authUsers.feedbackKarma,
};
}
@ -442,7 +442,7 @@ export class FeedbackService {
await this.db
.update(authUsers)
.set({
communityKarma: sql`GREATEST(${authUsers.communityKarma} + ${delta}, 0)`,
feedbackKarma: sql`GREATEST(${authUsers.feedbackKarma} + ${delta}, 0)`,
})
.where(eq(authUsers.id, item.authorId));
}
@ -767,9 +767,9 @@ function redact(
adminResponse: row.adminResponse,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
karma: author?.communityKarma ?? 0,
karma: author?.feedbackKarma ?? 0,
};
if (includeReal && author?.communityShowRealName && author.name) {
if (includeReal && author?.feedbackShowRealName && author.name) {
item.realName = author.name;
}
return item;

View file

@ -47,7 +47,7 @@ let seededIds = new Set<string>();
*/
export async function seedUser(
db: TestDb,
overrides: Partial<{ name: string; communityShowRealName: boolean; communityKarma: number }> = {}
overrides: Partial<{ name: string; feedbackShowRealName: boolean; feedbackKarma: number }> = {}
): Promise<SeededUser> {
const id = `test-${randomUUID()}`;
const email = `${id}@test.local`;
@ -57,13 +57,13 @@ export async function seedUser(
// model only declares the columns mana-analytics READS — auth.users
// has additional NOT NULL columns (email, etc.) we'd otherwise miss.
await db.execute(sql`
INSERT INTO auth.users (id, email, name, community_show_real_name, community_karma)
INSERT INTO auth.users (id, email, name, feedback_show_real_name, feedback_karma)
VALUES (
${id},
${email},
${name},
${overrides.communityShowRealName ?? false},
${overrides.communityKarma ?? 0}
${overrides.feedbackShowRealName ?? false},
${overrides.feedbackKarma ?? 0}
)
ON CONFLICT (id) DO NOTHING
`);
@ -71,10 +71,10 @@ export async function seedUser(
return { id, email, name };
}
/** Read auth.users.community_karma for a test user. */
/** Read auth.users.feedback_karma for a test user. */
export async function getKarma(db: TestDb, userId: string): Promise<number> {
const [row] = await db
.select({ karma: authUsers.communityKarma })
.select({ karma: authUsers.feedbackKarma })
.from(authUsers)
.where(eq(authUsers.id, userId))
.limit(1);

View file

@ -0,0 +1,17 @@
-- 009_rename_community_to_feedback.sql
-- Renames the two identity-opt-in columns on auth.users to match the
-- "feedback" brand the public hub now carries. Was originally added
-- in 008_community_identity.sql.
--
-- Apply with:
-- psql "$DATABASE_URL" -f sql/009_rename_community_to_feedback.sql
BEGIN;
ALTER TABLE auth.users
RENAME COLUMN community_show_real_name TO feedback_show_real_name;
ALTER TABLE auth.users
RENAME COLUMN community_karma TO feedback_karma;
COMMIT;

View file

@ -53,14 +53,14 @@ export const users = authSchema.table('users', {
// → Look → Templates). The flow is skippable, but even a skip sets
// this timestamp so we don't re-prompt. See docs/plans/onboarding-flow.md.
onboardingCompletedAt: timestamp('onboarding_completed_at', { withTimezone: true }),
// Community-Hub identity opt-ins (Phase 3.C of feedback-rewards-and-identity).
// Public-feedback identity opt-ins (Phase 3.C of feedback-rewards-and-identity).
// Off by default — users stay anonymous as their tier-pseudonym ("Wachsame
// Eule #4528"). Opt-in shows the real `name` next to the pseudonym in the
// auth-required community feed only; the public-mirror NEVER exposes it.
communityShowRealName: boolean('community_show_real_name').default(false).notNull(),
// auth-required feedback feed only; the public-mirror NEVER exposes it.
feedbackShowRealName: boolean('feedback_show_real_name').default(false).notNull(),
// Karma += 1 per reaction received from another user, decremented on unreact.
// Drives the public Bronze/Silver/Gold/Platinum-Eulen tier badge.
communityKarma: integer('community_karma').default(0).notNull(),
feedbackKarma: integer('feedback_karma').default(0).notNull(),
});
// Sessions table (Better Auth schema)

View file

@ -72,13 +72,13 @@ export function createMeRoutes(userDataService: UserDataService, db: Database) {
const body = (await c.req.json().catch(() => ({}))) as {
name?: unknown;
image?: unknown;
communityShowRealName?: unknown;
feedbackShowRealName?: unknown;
};
const patch: {
name?: string;
image?: string;
communityShowRealName?: boolean;
feedbackShowRealName?: boolean;
updatedAt: Date;
} = {
updatedAt: new Date(),
@ -93,11 +93,11 @@ export function createMeRoutes(userDataService: UserDataService, db: Database) {
if (typeof body.image === 'string') {
patch.image = body.image;
}
if (typeof body.communityShowRealName === 'boolean') {
patch.communityShowRealName = body.communityShowRealName;
if (typeof body.feedbackShowRealName === 'boolean') {
patch.feedbackShowRealName = body.feedbackShowRealName;
}
if (!('name' in patch) && !('image' in patch) && !('communityShowRealName' in patch)) {
if (!('name' in patch) && !('image' in patch) && !('feedbackShowRealName' in patch)) {
return c.json({ error: 'no fields to update' }, 400);
}
@ -109,14 +109,14 @@ export function createMeRoutes(userDataService: UserDataService, db: Database) {
id: users.id,
name: users.name,
image: users.image,
communityShowRealName: users.communityShowRealName,
feedbackShowRealName: users.feedbackShowRealName,
});
if (!updated) return c.json({ error: 'User not found' }, 404);
return c.json({
name: updated.name,
image: updated.image,
communityShowRealName: updated.communityShowRealName,
feedbackShowRealName: updated.feedbackShowRealName,
});
})
);