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:
parent
3c73a8b44a
commit
0ca0afb1e1
@ -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;
|
||||
@ -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.
|
||||
|
||||
@ -38,5 +38,6 @@ export default defineEventHandler(async (event) => {
|
||||
},
|
||||
globalBlocklistGraceUntil:
|
||||
dbProfile?.globalBlocklistGraceUntil?.toISOString() ?? null,
|
||||
presenceVisible: dbProfile?.presenceVisible ?? true,
|
||||
};
|
||||
});
|
||||
|
||||
19
backend/server/api/me/following.get.ts
Normal file
19
backend/server/api/me/following.get.ts
Normal 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 };
|
||||
});
|
||||
21
backend/server/api/me/last-seen.post.ts
Normal file
21
backend/server/api/me/last-seen.post.ts
Normal 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() } };
|
||||
});
|
||||
22
backend/server/api/me/presence-visibility.post.ts
Normal file
22
backend/server/api/me/presence-visibility.post.ts
Normal 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;
|
||||
});
|
||||
62
backend/server/api/presence/last-seen.get.ts
Normal file
62
backend/server/api/presence/last-seen.get.ts
Normal 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 };
|
||||
});
|
||||
@ -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 };
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user