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)) ──
|
// ─── Install-Tracking (für Streak: max(last_resolved_cooldown, last_install)) ──
|
||||||
lastInstallAt DateTime? @map("last_install_at")
|
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) ─────────────────────────────
|
// ─── Voice-Quota (tages-basiert, UTC-Reset) ─────────────────────────────
|
||||||
// Tracked per plan: Free=60s/day, Pro=300s/day, Legend=unlimited (no tracking).
|
// Tracked per plan: Free=60s/day, Pro=300s/day, Legend=unlimited (no tracking).
|
||||||
// voiceQuotaResetAt wird auf UTC-Mitternacht des aktuellen Tages gesetzt.
|
// voiceQuotaResetAt wird auf UTC-Mitternacht des aktuellen Tages gesetzt.
|
||||||
|
|||||||
@ -38,5 +38,6 @@ export default defineEventHandler(async (event) => {
|
|||||||
},
|
},
|
||||||
globalBlocklistGraceUntil:
|
globalBlocklistGraceUntil:
|
||||||
dbProfile?.globalBlocklistGraceUntil?.toISOString() ?? null,
|
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() },
|
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