chahinebrini 2e409efaf0 feat(onboarding/android + backend/lyra-i18n): platform-dispatch + post-catalog scaffold
Android-Onboarding (Platform.OS dispatch in ProtectionSlide):
- Neue Phasen für Android: preexplain_vpn → preexplain_a11y → a11y_pending
- AppState-Listener: nach Settings-Rückkehr auto-poll isAccessibilityEnabled
  → wenn live, armTamperLock + finish (kein Fokus-Klick nötig)
- onboardingAssets: 8 neue Mappings (android_vpn + android_a11y × 4 Locales)
- Screenshots: vpn-permission + a11y-rebreak-row pro Locale
- Locale-Keys: protection_url_android, protection_lock_android, cta_open_a11y,
  cta_check_a11y, dialog_button_vpn_ok, dialog_button_a11y_toggle, tap_marker_hint_*

Lyra-Post i18n Phase 1 (Scaffold, feature-flag OFF by default):
- schema.prisma: CommunityPost.i18nKey String? (nullable)
- migration 20260517_add_lyra_post_i18n_key: ALTER TABLE ADD COLUMN i18n_key
  (NICHT auto-deployed — `prisma migrate deploy` als separater Step)
- server/lib/lyraPostCatalog.ts: 15 Templates skelettiert + pickRandomTemplate
- cron/lyra-post: USE_TEMPLATE_CATALOG=true Branch → speichert i18nKey;
  default false → LLM-Path unverändert (zero-risk-deployment)
- community.createPost: optionaler i18nKey-Parameter
- posts.get: i18nKey in API-Response
- PostCard: 3-Zeilen-Branch — i18nKey ? t('lyra_posts.'+id) : content
- stores/community: i18nKey?: string|null im Interface
- de.json: lyra_posts-Block mit 15 IDs + DE-Texten

Single-Banner-Verhalten auf Android verifiziert:
lockedIn=urlFilter && appDeletionLock funktioniert weiter — auf Android
alias appDeletionLock ← tamperLock; onboarding arms tamperLock, also
nach onboarding-done direkt ProtectionLockedCard sichtbar.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-17 23:48:25 +02:00

439 lines
12 KiB
TypeScript

import { usePrisma } from "../utils/prisma";
// ─── Posts ────────────────────────────────────────────────────────────────────
export async function getPosts(
category: string,
page: number,
limit: number,
currentUserId: string | null,
filterUserId?: string | null,
) {
const db = usePrisma();
const offset = (page - 1) * limit;
const where: any = { isModerated: false };
if (category !== "all") {
if (category === "games") {
where.category = { in: ["game_share", "challenge"] };
} else {
where.category = category;
}
}
if (filterUserId) {
where.userId = filterUserId;
}
const posts = await db.communityPost.findMany({
where,
orderBy: { createdAt: "desc" },
skip: offset,
take: limit,
include: {
author: {
select: {
id: true,
username: true,
nickname: true,
avatar: true,
plan: true,
},
},
repostOf: {
include: {
author: {
select: {
id: true,
username: true,
nickname: true,
avatar: true,
plan: true,
},
},
},
},
},
});
// Batch: UserScore (tier) für alle Autoren laden
const authorUserIds = [
...new Set(
posts
.flatMap((p) => [p.userId, p.repostOf?.userId])
.filter((id): id is string => !!id),
),
];
let userScores: Record<string, string> = {};
if (authorUserIds.length > 0) {
const scores = await db.userScore.findMany({
where: { userId: { in: authorUserIds } },
select: { userId: true, tier: true },
});
for (const s of scores) userScores[s.userId] = s.tier;
}
// Eigene Likes laden wenn eingeloggt
let userLikes: Record<string, "like" | "dislike"> = {};
if (currentUserId && posts.length > 0) {
const postIds = posts.map((p) => p.id);
const likes = await db.postLike.findMany({
where: { userId: currentUserId, postId: { in: postIds } },
select: { postId: true, type: true },
});
for (const l of likes) {
userLikes[l.postId] = l.type as "like" | "dislike";
}
}
// Challenge-Status für Challenge-Posts laden
const challengeIds = posts
.map((p) => (p as any).challengeId)
.filter((id): id is string => !!id);
let challengeStatuses: Record<
string,
{
status: string;
opponentId: string | null;
opponentName: string | null;
gameType: string | null;
isLive: boolean;
}
> = {};
if (challengeIds.length > 0) {
const challenges = await db.gameChallenge.findMany({
where: { id: { in: challengeIds } },
select: {
id: true,
status: true,
opponentId: true,
opponentName: true,
gameType: true,
isLive: true,
},
});
for (const c of challenges) {
challengeStatuses[c.id] = {
status: c.status,
opponentId: c.opponentId,
opponentName: (c as any).opponentName ?? null,
gameType: (c as any).gameType ?? null,
isLive: (c as any).isLive ?? false,
};
}
}
// Domain-Submission-Daten für domain_vote Posts laden
const domainVotePostIds = posts
.filter((p) => p.category === "domain_vote")
.map((p) => p.id);
type SubEntry = {
id: string;
domain: string;
yesVotes: number;
noVotes: number;
status: string;
reviewedAt: Date | null;
};
let domainSubmissions: Record<string, SubEntry> = {};
let userDomainVotes: Record<string, "yes" | "no"> = {};
let submissionVoters: Record<
string,
{
yes: { id: string; nickname: string; avatar: string | null }[];
no: { id: string; nickname: string; avatar: string | null }[];
}
> = {};
if (domainVotePostIds.length > 0) {
const subs = await db.domainSubmission.findMany({
where: { postId: { in: domainVotePostIds } },
select: {
id: true,
postId: true,
domain: true,
yesVotes: true,
noVotes: true,
status: true,
reviewedAt: true,
},
});
for (const s of subs) {
if (s.postId)
domainSubmissions[s.postId] = {
id: s.id,
domain: s.domain,
yesVotes: s.yesVotes,
noVotes: s.noVotes,
status: s.status,
reviewedAt: s.reviewedAt ?? null,
};
}
if (currentUserId && subs.length > 0) {
const votes = await db.domainVote.findMany({
where: {
userId: currentUserId,
submissionId: { in: subs.map((s) => s.id) },
},
select: { submissionId: true, vote: true },
});
const subIdToPostId = Object.fromEntries(
Object.entries(domainSubmissions).map(([pid, s]) => [s.id, pid]),
);
for (const v of votes) {
const pid = subIdToPostId[v.submissionId];
if (pid) userDomainVotes[pid] = v.vote as "yes" | "no";
}
}
// Batch: DomainVote voters for domain submissions
const submissionIds = Object.values(domainSubmissions)
.map((s) => s.id)
.filter(Boolean);
if (submissionIds.length > 0) {
const votes = await db.domainVote.findMany({
where: { submissionId: { in: submissionIds } },
select: { submissionId: true, vote: true, userId: true },
});
const voterIds = [...new Set(votes.map((v) => v.userId))];
let voterProfiles: Record<
string,
{ nickname: string | null; avatar: string | null }
> = {};
if (voterIds.length > 0) {
const profiles = await db.profile.findMany({
where: { id: { in: voterIds } },
select: { id: true, nickname: true, avatar: true },
});
for (const p of profiles)
voterProfiles[p.id] = { nickname: p.nickname, avatar: p.avatar };
}
for (const v of votes) {
if (!submissionVoters[v.submissionId])
submissionVoters[v.submissionId] = { yes: [], no: [] };
const profile = voterProfiles[v.userId];
const voter = {
id: v.userId,
nickname: profile?.nickname ?? "Nutzer",
avatar: profile?.avatar ?? null,
};
if (v.vote === "yes") submissionVoters[v.submissionId].yes.push(voter);
else submissionVoters[v.submissionId].no.push(voter);
}
}
}
return {
posts,
userLikes,
challengeStatuses,
domainSubmissions,
userDomainVotes,
userScores,
submissionVoters,
};
}
export async function getPostById(postId: string) {
const db = usePrisma();
return db.communityPost.findUnique({
where: { id: postId },
include: {
author: {
select: {
id: true,
username: true,
nickname: true,
avatar: true,
plan: true,
},
},
},
});
}
export async function createPost(
userId: string,
category: string,
content: string,
imageUrl?: string,
gameName?: string | null,
i18nKey?: string | null,
) {
const db = usePrisma();
return db.communityPost.create({
data: {
userId,
category,
content,
imageUrl: imageUrl || null,
gameName: gameName ?? null,
i18nKey: i18nKey ?? null,
isAnonymous: false,
isModerated: false,
},
include: {
author: {
select: {
id: true,
username: true,
nickname: true,
avatar: true,
plan: true,
},
},
},
});
}
export async function deleteUserPosts(userId: string) {
const db = usePrisma();
return db.communityPost.deleteMany({ where: { userId } });
}
// ─── Likes ───────────────────────────────────────────────────────────────────
export async function getPostLike(userId: string, postId: string) {
const db = usePrisma();
return db.postLike.findUnique({
where: { userId_postId: { userId, postId } },
select: { type: true },
});
}
export async function setPostLike(
userId: string,
postId: string,
type: "like" | "dislike",
) {
const db = usePrisma();
return db.postLike.upsert({
where: { userId_postId: { userId, postId } },
create: { userId, postId, type },
update: { type },
});
}
export async function deletePostLike(userId: string, postId: string) {
const db = usePrisma();
return db.postLike.delete({
where: { userId_postId: { userId, postId } },
});
}
export async function countPostLikes(postId: string) {
const db = usePrisma();
const counts = await db.postLike.groupBy({
by: ["type"],
where: { postId },
_count: { type: true },
});
const likes = counts.find((c) => c.type === "like")?._count.type ?? 0;
const dislikes = counts.find((c) => c.type === "dislike")?._count.type ?? 0;
return { likes, dislikes };
}
export async function syncPostLikeCounts(
postId: string,
likes: number,
dislikes: number,
) {
const db = usePrisma();
return db.communityPost.update({
where: { id: postId },
data: { likesCount: likes, dislikesCount: dislikes },
});
}
// ─── Comments (replies) ──────────────────────────────────────────────────────
export async function getCommentsByPost(
postId: string,
currentUserId: string | null,
) {
const db = usePrisma();
const comments = await db.communityReply.findMany({
where: { postId },
orderBy: { createdAt: "asc" },
take: 200,
include: {
author: {
select: { id: true, username: true, nickname: true, avatar: true },
},
},
});
let userLikes = new Set<string>();
if (currentUserId && comments.length > 0) {
const commentIds = comments.map((c) => c.id);
const likes = await db.commentLike.findMany({
where: { userId: currentUserId, commentId: { in: commentIds } },
select: { commentId: true },
});
for (const l of likes) {
userLikes.add(l.commentId);
}
}
return { comments, userLikes };
}
export async function createComment(
userId: string,
postId: string,
content: string,
parentReplyId: string | null,
) {
const db = usePrisma();
const [reply] = await Promise.all([
db.communityReply.create({
data: { userId, postId, content, parentReplyId, isAnonymous: false },
select: {
id: true,
content: true,
createdAt: true,
likesCount: true,
parentReplyId: true,
},
}),
db.communityPost.update({
where: { id: postId },
data: { commentsCount: { increment: 1 } },
}),
]);
return reply;
}
// ─── Comment Likes ────────────────────────────────────────────────────────────
export async function getCommentLike(userId: string, commentId: string) {
const db = usePrisma();
return db.commentLike.findUnique({
where: { userId_commentId: { userId, commentId } },
});
}
export async function createCommentLike(userId: string, commentId: string) {
const db = usePrisma();
return db.commentLike.create({ data: { userId, commentId } });
}
export async function deleteCommentLike(userId: string, commentId: string) {
const db = usePrisma();
return db.commentLike.delete({
where: { userId_commentId: { userId, commentId } },
});
}
export async function getCommentLikeCount(commentId: string) {
const db = usePrisma();
return db.commentLike.count({ where: { commentId } });
}
export async function syncCommentLikeCount(commentId: string, count: number) {
const db = usePrisma();
return db.communityReply.update({
where: { id: commentId },
data: { likesCount: count },
});
}