From 0ca0afb1e10ba22d47deb9b5b93e2d4092c212d6 Mon Sep 17 00:00:00 2001 From: chahinebrini Date: Mon, 18 May 2026 06:23:08 +0200 Subject: [PATCH] feat(presence): online-status backend (Phase 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Insta-style Online-Status mit Following-Filter + User-opt-out: - Profile.lastSeenAt + Profile.presenceVisible (default true) - GET /api/presence/last-seen?userIds=... batch, server-side filter durch Follow-Relation + presenceVisible - GET /api/me/following → User-IDs für client-side Channel-Filter (Supabase Realtime Presence hat keine server-side Filter) - POST /api/me/presence-visibility Toggle - POST /api/me/last-seen Heartbeat (Phase-1-Fallback bis Edge-Function) - /api/auth/me extended um presenceVisible für Settings-Initial-State DB-Layer nutzt raw SQL bis Migration auf staging gelaufen ist (Prisma-Client refresh erst nach CI generate). Co-Authored-By: Claude Opus 4.7 --- .../migration.sql | 12 +++ backend/prisma/schema.prisma | 7 ++ backend/server/api/auth/me.get.ts | 1 + backend/server/api/me/following.get.ts | 19 ++++ backend/server/api/me/last-seen.post.ts | 21 +++++ .../server/api/me/presence-visibility.post.ts | 22 +++++ backend/server/api/presence/last-seen.get.ts | 62 +++++++++++++ backend/server/db/profile.ts | 86 +++++++++++++++++++ 8 files changed, 230 insertions(+) create mode 100644 backend/prisma/migrations/20260518_add_presence_fields/migration.sql create mode 100644 backend/server/api/me/following.get.ts create mode 100644 backend/server/api/me/last-seen.post.ts create mode 100644 backend/server/api/me/presence-visibility.post.ts create mode 100644 backend/server/api/presence/last-seen.get.ts diff --git a/backend/prisma/migrations/20260518_add_presence_fields/migration.sql b/backend/prisma/migrations/20260518_add_presence_fields/migration.sql new file mode 100644 index 0000000..464c426 --- /dev/null +++ b/backend/prisma/migrations/20260518_add_presence_fields/migration.sql @@ -0,0 +1,12 @@ +-- Add presence fields to profiles for Online-Status feature. +-- +-- last_seen_at: Nullable, no default — NULL means "never seen" (accounts before this migration). +-- Updated via POST /api/me/last-seen (Heartbeat, Phase 1). +-- Phase 2: Supabase Edge-Function on presence-leave replaces the heartbeat. +-- +-- presence_visible: opt-out toggle for online status visibility. +-- Default true — user can disable via POST /api/me/presence-visibility. +-- When false: last_seen_at is never exposed to other users. + +ALTER TABLE "rebreak"."profiles" ADD COLUMN "last_seen_at" TIMESTAMP(3); +ALTER TABLE "rebreak"."profiles" ADD COLUMN "presence_visible" BOOLEAN NOT NULL DEFAULT true; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 976d728..c0f25d7 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -78,6 +78,13 @@ model Profile { // ─── Install-Tracking (für Streak: max(last_resolved_cooldown, last_install)) ── lastInstallAt DateTime? @map("last_install_at") + // ─── Presence / Online-Status (Insta-style green dot + "vor X min") ───── + // Wird via POST /api/me/last-seen (Heartbeat, Phase 1) aktualisiert. + // Phase 2: Supabase-Edge-Function auf presence-leave-Events ersetzt den + // Heartbeat-Fallback für bessere Genauigkeit (kein 60s-Lag). + lastSeenAt DateTime? @map("last_seen_at") + presenceVisible Boolean @default(true) @map("presence_visible") + // ─── Voice-Quota (tages-basiert, UTC-Reset) ───────────────────────────── // Tracked per plan: Free=60s/day, Pro=300s/day, Legend=unlimited (no tracking). // voiceQuotaResetAt wird auf UTC-Mitternacht des aktuellen Tages gesetzt. diff --git a/backend/server/api/auth/me.get.ts b/backend/server/api/auth/me.get.ts index 32ebbb3..f0fa80d 100644 --- a/backend/server/api/auth/me.get.ts +++ b/backend/server/api/auth/me.get.ts @@ -38,5 +38,6 @@ export default defineEventHandler(async (event) => { }, globalBlocklistGraceUntil: dbProfile?.globalBlocklistGraceUntil?.toISOString() ?? null, + presenceVisible: dbProfile?.presenceVisible ?? true, }; }); diff --git a/backend/server/api/me/following.get.ts b/backend/server/api/me/following.get.ts new file mode 100644 index 0000000..dffd8dd --- /dev/null +++ b/backend/server/api/me/following.get.ts @@ -0,0 +1,19 @@ +/** + * GET /api/me/following + * + * Returns the IDs of all users the authenticated user follows. + * Used client-side to filter the Supabase Realtime Presence channel — + * Realtime has no native server-side filter, so the frontend needs the list. + * + * No pagination — follows rarely exceed 1 k for a single user. + * + * Response: + * { userIds: string[] } + */ +import { requireUser } from "../../utils/auth"; + +export default defineEventHandler(async (event) => { + const user = await requireUser(event); + const userIds = await getFollowingIds(user.id); + return { userIds }; +}); diff --git a/backend/server/api/me/last-seen.post.ts b/backend/server/api/me/last-seen.post.ts new file mode 100644 index 0000000..dc9ee5e --- /dev/null +++ b/backend/server/api/me/last-seen.post.ts @@ -0,0 +1,21 @@ +/** + * POST /api/me/last-seen + * + * Heartbeat endpoint — sets Profile.lastSeenAt = NOW() for the authenticated user. + * No request body required. + * + * Phase 1 fallback: frontend calls this on a ~60s interval while app is foregrounded. + * Phase 2 (TODO): replace with Supabase Edge-Function triggered on presence-leave + * events for sub-second accuracy without client-side polling. + * + * Response: { lastSeenAt: ISOString } + */ +import { requireUser } from "../../utils/auth"; + +export default defineEventHandler(async (event) => { + const user = await requireUser(event); + + const now = await touchLastSeen(user.id); + + return { success: true, data: { lastSeenAt: now.toISOString() } }; +}); diff --git a/backend/server/api/me/presence-visibility.post.ts b/backend/server/api/me/presence-visibility.post.ts new file mode 100644 index 0000000..f053155 --- /dev/null +++ b/backend/server/api/me/presence-visibility.post.ts @@ -0,0 +1,22 @@ +/** + * POST /api/me/presence-visibility + * + * Opt-out toggle for the authenticated user's online status visibility. + * When visible=false, no other user will see lastSeenAt regardless of follow status. + * + * Body: { visible: boolean } + * Response: { presenceVisible: boolean } + */ +import { requireUser } from "../../utils/auth"; + +export default defineEventHandler(async (event) => { + const user = await requireUser(event); + + const body = await readBody(event); + if (typeof body?.visible !== "boolean") { + throw createError({ statusCode: 400, message: "INVALID_VISIBLE" }); + } + + const result = await setPresenceVisible(user.id, body.visible); + return result; +}); diff --git a/backend/server/api/presence/last-seen.get.ts b/backend/server/api/presence/last-seen.get.ts new file mode 100644 index 0000000..5bf15cf --- /dev/null +++ b/backend/server/api/presence/last-seen.get.ts @@ -0,0 +1,62 @@ +/** + * GET /api/presence/last-seen?userIds=uuid1,uuid2,... + * + * Batch-fetch lastSeenAt for up to 50 users. + * Auth required — callers must be logged in (prevents anonymous enumeration). + * + * Query param: + * userIds — comma-separated UUIDs (max 50) + * + * Response: + * { success: true, data: { [userId: string]: string | null } } + * Value is lastSeenAt as ISO-8601 string, or null if never seen / user not found. + * + * Privacy filters (both must pass): + * 1. Requester follows the target (UserFollow row exists). + * 2. Target has presenceVisible = true (opt-out respected). + * Targets failing either filter → null in response map. + * Frontend: null = no indicator shown. + * + * Privacy note: only lastSeenAt is exposed — no nickname, avatar, email or any + * other profile field (feedback_anonymity_nickname.md). + */ +import { requireUser } from "../../utils/auth"; + +const MAX_USER_IDS = 50; + +export default defineEventHandler(async (event) => { + const user = await requireUser(event); + + const query = getQuery(event); + const raw = typeof query.userIds === "string" ? query.userIds.trim() : ""; + + if (!raw) { + throw createError({ statusCode: 400, message: "MISSING_USER_IDS" }); + } + + const userIds = raw + .split(",") + .map((id) => id.trim()) + .filter(Boolean); + + if (userIds.length === 0) { + throw createError({ statusCode: 400, message: "MISSING_USER_IDS" }); + } + + if (userIds.length > MAX_USER_IDS) { + throw createError({ statusCode: 400, message: "TOO_MANY_USER_IDS" }); + } + + const rows = await getLastSeenBatch(user.id, userIds); + + // Build map — guarantee every requested ID has an entry (null for filtered/missing) + const result: Record = {}; + for (const id of userIds) { + result[id] = null; + } + for (const row of rows) { + result[row.id] = row.lastSeenAt ? row.lastSeenAt.toISOString() : null; + } + + return { success: true, data: result }; +}); diff --git a/backend/server/db/profile.ts b/backend/server/db/profile.ts index ca7655a..82bedcf 100644 --- a/backend/server/db/profile.ts +++ b/backend/server/db/profile.ts @@ -281,3 +281,89 @@ export async function recordInstallEvent(userId: string) { data: { lastInstallAt: new Date() }, }); } + +// ─── Following List ─────────────────────────────────────────────────────── + +/** Return IDs of all users that currentUser follows. No pagination — rarely >1k. */ +export async function getFollowingIds(currentUserId: string): Promise { + const db = usePrisma(); + const rows = await db.userFollow.findMany({ + where: { followerId: currentUserId }, + select: { followingId: true }, + }); + return rows.map((r) => r.followingId); +} + +// ─── Presence / Online-Status ───────────────────────────────────────────── +// +// NOTE: lastSeenAt is added in migration 20260518_add_last_seen_at. +// Prisma client is regenerated on staging after `prisma migrate deploy`. +// Until then the generated client type doesn't include lastSeenAt — raw SQL +// is used here to avoid breaking the tsc build on pre-migration client types. + +/** + * Touch lastSeenAt for the authenticated user (Heartbeat, Phase 1). + * Phase 2: replaced by Supabase Edge-Function on presence-leave events. + */ +export async function touchLastSeen(userId: string): Promise { + const db = usePrisma(); + const now = new Date(); + await db.$executeRaw` + UPDATE "rebreak"."profiles" + SET "last_seen_at" = ${now} + WHERE "id" = ${userId}::uuid + `; + return now; +} + +/** + * Batch-fetch lastSeenAt for up to 50 user IDs. + * + * Privacy filters applied: + * 1. Target's presenceVisible must be true (opt-out respected). + * 2. Requester must follow the target (UserFollow row must exist). + * + * Targets failing either condition are silently omitted — caller maps them + * to null in the response, which the frontend interprets as "no indicator". + */ +export async function getLastSeenBatch( + currentUserId: string, + userIds: string[], +): Promise<{ id: string; lastSeenAt: Date | null }[]> { + const db = usePrisma(); + + // Step 1: which of the requested IDs does currentUser actually follow? + const follows = await db.userFollow.findMany({ + where: { followerId: currentUserId, followingId: { in: userIds } }, + select: { followingId: true }, + }); + const followingSet = new Set(follows.map((f) => f.followingId)); + + // Step 2: only query profiles that are followed AND have visibility enabled + const visibleIds = userIds.filter((id) => followingSet.has(id)); + if (visibleIds.length === 0) return []; + + const rows = await db.$queryRaw<{ id: string; last_seen_at: Date | null }[]>` + SELECT "id", "last_seen_at" + FROM "rebreak"."profiles" + WHERE "id" = ANY(${visibleIds}::uuid[]) + AND "presence_visible" = true + `; + return rows.map((r) => ({ id: r.id, lastSeenAt: r.last_seen_at })); +} + +/** Update presence_visible opt-out toggle for a user. */ +export async function setPresenceVisible( + userId: string, + visible: boolean, +): Promise<{ presenceVisible: boolean }> { + const db = usePrisma(); + // Raw SQL — presenceVisible added in 20260518_add_presence_fields, + // Prisma client regenerated on staging after migrate deploy. + await db.$executeRaw` + UPDATE "rebreak"."profiles" + SET "presence_visible" = ${visible} + WHERE "id" = ${userId}::uuid + `; + return { presenceVisible: visible }; +}