feat(presence): online-status backend (Phase 1)

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 <noreply@anthropic.com>
This commit is contained in:
chahinebrini 2026-05-18 06:23:08 +02:00
parent 3c73a8b44a
commit 0ca0afb1e1
8 changed files with 230 additions and 0 deletions

View File

@ -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;

View File

@ -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.

View File

@ -38,5 +38,6 @@ export default defineEventHandler(async (event) => {
},
globalBlocklistGraceUntil:
dbProfile?.globalBlocklistGraceUntil?.toISOString() ?? null,
presenceVisible: dbProfile?.presenceVisible ?? true,
};
});

View File

@ -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 };
});

View File

@ -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() } };
});

View File

@ -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;
});

View File

@ -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<string, string | null> = {};
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 };
});

View File

@ -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<string[]> {
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<Date> {
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 };
}