API: follower lists, blocking, reports, admin; drop Spotify sign-in
- Followers/following lists (paginated), 'follows you', remove a follower - Blocking severs follows both ways and hides each user from the other (profile 404s, search, follows); the blocker sees activity hidden - Reports for moderation (App Store guideline 1.2) - First account on a server becomes admin; admins see the report queue, resolve reports, and suspend accounts (signs out, blocks login, hides the profile, stops sharing). set-role script to promote/demote - users.onboardedAt drives first-run onboarding; existing accounts are backfilled as already onboarded. Empty PATCH /me no longer 500s - Remove Spotify account sign-in and polling: Spotify caps new apps at 5 accounts, so Spotify arrives via its Last.fm/ListenBrainz link or the Android reader. App-level catalog search stays for exact links - Shared: QR code generation and profile-link parsing (verified with jsQR) - Integration test timeout raised; password hashing is slow by design Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MzFywujuH9TiuXbr9vjoX8
This commit is contained in:
@@ -0,0 +1,75 @@
|
||||
/** Moderation for admins: the report queue, suspensions, and a few numbers. */
|
||||
import { and, count, desc, eq, isNotNull } from 'drizzle-orm';
|
||||
import { alias } from 'drizzle-orm/pg-core';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import type { AdminReportDTO, AdminStatsDTO } from '@musicshare/shared';
|
||||
import { db, schema } from '../db';
|
||||
import { requireAdmin } from '../lib/auth';
|
||||
import { HttpError } from '../lib/http';
|
||||
import { pauseSharing, toUserSummary } from '../services/nowPlaying';
|
||||
|
||||
export async function adminRoutes(app: FastifyInstance) {
|
||||
app.get('/admin/stats', async (req): Promise<AdminStatsDTO> => {
|
||||
requireAdmin(req);
|
||||
const [[users], [listening], [open], [suspended]] = await Promise.all([
|
||||
db.select({ n: count() }).from(schema.users),
|
||||
db.select({ n: count() }).from(schema.nowPlaying).where(eq(schema.nowPlaying.isPlaying, true)),
|
||||
db.select({ n: count() }).from(schema.reports).where(eq(schema.reports.status, 'open')),
|
||||
db.select({ n: count() }).from(schema.users).where(isNotNull(schema.users.suspendedAt)),
|
||||
]);
|
||||
return { users: users!.n, listeningNow: listening!.n, openReports: open!.n, suspended: suspended!.n };
|
||||
});
|
||||
|
||||
app.get<{ Querystring: { status?: 'open' | 'resolved' } }>('/admin/reports', async (req): Promise<AdminReportDTO[]> => {
|
||||
requireAdmin(req);
|
||||
const reporter = alias(schema.users, 'reporter');
|
||||
const reported = alias(schema.users, 'reported');
|
||||
const rows = await db
|
||||
.select({ report: schema.reports, reporter, reported })
|
||||
.from(schema.reports)
|
||||
.leftJoin(reporter, eq(reporter.id, schema.reports.reporterId))
|
||||
.innerJoin(reported, eq(reported.id, schema.reports.reportedId))
|
||||
.where(eq(schema.reports.status, req.query.status === 'resolved' ? 'resolved' : 'open'))
|
||||
.orderBy(desc(schema.reports.createdAt))
|
||||
.limit(200);
|
||||
return rows.map(({ report, reporter: by, reported: who }) => ({
|
||||
id: report.id,
|
||||
reason: report.reason,
|
||||
note: report.note,
|
||||
status: report.status,
|
||||
createdAt: report.createdAt.toISOString(),
|
||||
reporter: by ? toUserSummary(by) : null,
|
||||
reported: { ...toUserSummary(who), suspended: Boolean(who.suspendedAt) },
|
||||
}));
|
||||
});
|
||||
|
||||
app.post<{ Params: { id: string } }>('/admin/reports/:id/resolve', async (req) => {
|
||||
requireAdmin(req);
|
||||
if (!/^[0-9a-f-]{36}$/i.test(req.params.id)) throw new HttpError(404, 'Report not found');
|
||||
await db.update(schema.reports).set({ status: 'resolved' }).where(eq(schema.reports.id, req.params.id));
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
/** Suspend: signs them out everywhere, blocks login, hides their profile, stops sharing. */
|
||||
app.post<{ Params: { username: string } }>('/admin/users/:username/suspend', async (req) => {
|
||||
const admin = requireAdmin(req);
|
||||
const [user] = await db.select().from(schema.users).where(eq(schema.users.username, req.params.username.toLowerCase()));
|
||||
if (!user) throw new HttpError(404, 'User not found');
|
||||
if (user.id === admin.id) throw new HttpError(400, "You can't suspend yourself");
|
||||
if (user.role === 'admin') throw new HttpError(400, "Admins can't be suspended");
|
||||
await db.update(schema.users).set({ suspendedAt: new Date() }).where(eq(schema.users.id, user.id));
|
||||
await db.delete(schema.sessions).where(eq(schema.sessions.userId, user.id));
|
||||
await pauseSharing(user.id);
|
||||
req.log.warn({ by: admin.username, user: user.username }, 'user suspended');
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
app.delete<{ Params: { username: string } }>('/admin/users/:username/suspend', async (req) => {
|
||||
requireAdmin(req);
|
||||
await db
|
||||
.update(schema.users)
|
||||
.set({ suspendedAt: null })
|
||||
.where(and(eq(schema.users.username, req.params.username.toLowerCase()), isNotNull(schema.users.suspendedAt)));
|
||||
return { ok: true };
|
||||
});
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { hash, verify } from '@node-rs/argon2';
|
||||
import { eq, or } from 'drizzle-orm';
|
||||
import { count, eq, or, sql } from 'drizzle-orm';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { loginSchema, registerSchema } from '@musicshare/shared';
|
||||
import { db, schema } from '../db';
|
||||
@@ -18,10 +18,17 @@ export async function authRoutes(app: FastifyInstance) {
|
||||
.where(or(eq(schema.users.username, input.username), eq(schema.users.email, input.email)));
|
||||
if (taken) throw new HttpError(409, taken.email === input.email ? 'Email already registered' : 'Username taken');
|
||||
|
||||
const [user] = await db
|
||||
.insert(schema.users)
|
||||
.values({ username: input.username, email: input.email, passwordHash: await hash(input.password) })
|
||||
.returning();
|
||||
const passwordHash = await hash(input.password);
|
||||
const user = await db.transaction(async (tx) => {
|
||||
// Serialize sign-ups so two simultaneous "first" accounts can't both become admin.
|
||||
await tx.execute(sql`select pg_advisory_xact_lock(4242)`);
|
||||
const [{ n }] = (await tx.select({ n: count() }).from(schema.users)) as [{ n: number }];
|
||||
const [created] = await tx
|
||||
.insert(schema.users)
|
||||
.values({ username: input.username, email: input.email, passwordHash, role: n === 0 ? 'admin' : 'user' })
|
||||
.returning();
|
||||
return created;
|
||||
});
|
||||
const token = await createSession(user!.id, reply);
|
||||
return { user: toMeDTO(user!), token };
|
||||
});
|
||||
@@ -32,6 +39,7 @@ export async function authRoutes(app: FastifyInstance) {
|
||||
if (!user || !(await verify(user.passwordHash, input.password))) {
|
||||
throw new HttpError(401, 'Invalid email or password');
|
||||
}
|
||||
if (user.suspendedAt) throw new HttpError(403, 'This account has been suspended');
|
||||
const token = await createSession(user.id, reply);
|
||||
return { user: toMeDTO(user), token };
|
||||
});
|
||||
|
||||
@@ -4,7 +4,7 @@ import { z } from 'zod';
|
||||
import { connectUsernameSchema, jellyfinConnectSchema, type NowPlayingProvider } from '@musicshare/shared';
|
||||
import { db, schema } from '../db';
|
||||
import type { User } from '../db/schema';
|
||||
import { appleMusicConfigured, env, spotifyConfigured } from '../env';
|
||||
import { appleMusicConfigured, env } from '../env';
|
||||
import { requireUser } from '../lib/auth';
|
||||
import { encrypt, randomToken } from '../lib/crypto';
|
||||
import { entitlementsFor } from '../lib/entitlements';
|
||||
@@ -17,7 +17,6 @@ import { lastfmAuthUrl, lastfmSession, lastfmUserExists } from '../providers/las
|
||||
import { ProviderAuthError } from '../providers/types';
|
||||
import { listenbrainzUserExists } from '../providers/listenbrainz';
|
||||
import * as plex from '../providers/plex';
|
||||
import * as spotify from '../providers/spotify';
|
||||
import { toConnectionDTO, upsertConnection } from '../services/connections';
|
||||
import { unschedulePoll } from '../services/scheduler';
|
||||
|
||||
@@ -55,58 +54,6 @@ export async function connectionRoutes(app: FastifyInstance) {
|
||||
return rows.map(toConnectionDTO);
|
||||
});
|
||||
|
||||
// ---- Spotify (OAuth) ----
|
||||
|
||||
app.post('/connections/spotify/authorize', async (req) => {
|
||||
const user = requireUser(req);
|
||||
if (!spotifyConfigured) throw new HttpError(501, 'Spotify is not configured on this server');
|
||||
await assertCanConnect(user, 'spotify');
|
||||
const returnTo = resolveReturnTo(authorizeBodySchema.parse(req.body ?? {}));
|
||||
const state = randomToken(24);
|
||||
await redis.set(oauthStateKey(state), JSON.stringify({ userId: user.id, returnTo }), 'EX', 600);
|
||||
return { url: spotify.authorizeUrl(state) };
|
||||
});
|
||||
|
||||
// The callback is identified by `state`, not the session cookie: Spotify
|
||||
// requires a 127.0.0.1 redirect in dev, which won't carry localhost cookies.
|
||||
app.get<{ Querystring: { code?: string; state?: string; error?: string } }>(
|
||||
'/connections/spotify/callback',
|
||||
async (req, reply) => {
|
||||
const raw = req.query.state ? await redis.getdel(oauthStateKey(req.query.state)) : null;
|
||||
if (!raw) throw new HttpError(400, 'Invalid or expired OAuth state');
|
||||
const { userId, ...state } = JSON.parse(raw) as { userId: string; returnTo: string };
|
||||
const returnTo = new URL(state.returnTo);
|
||||
|
||||
if (req.query.error || !req.query.code) {
|
||||
returnTo.searchParams.set('error', req.query.error ?? 'spotify_denied');
|
||||
return reply.redirect(returnTo.toString());
|
||||
}
|
||||
const tok = await spotify.exchangeCode(req.query.code);
|
||||
let profile;
|
||||
try {
|
||||
profile = await spotify.getProfile(tok.access_token);
|
||||
} catch (err) {
|
||||
// Development-mode apps reject accounts not on the allowlist with 403.
|
||||
if (err instanceof HttpError && err.status === 403) {
|
||||
returnTo.searchParams.set('error', 'spotify_limited');
|
||||
return reply.redirect(returnTo.toString());
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
await upsertConnection({
|
||||
userId,
|
||||
provider: 'spotify',
|
||||
externalUserId: profile.id,
|
||||
displayName: profile.display_name,
|
||||
accessTokenEnc: encrypt(tok.access_token),
|
||||
refreshTokenEnc: tok.refresh_token ? encrypt(tok.refresh_token) : null,
|
||||
tokenExpiresAt: new Date(Date.now() + tok.expires_in * 1000),
|
||||
});
|
||||
returnTo.searchParams.set('connected', 'spotify');
|
||||
return reply.redirect(returnTo.toString());
|
||||
},
|
||||
);
|
||||
|
||||
// ---- Plex (PIN flow) ----
|
||||
|
||||
app.post('/connections/plex/authorize', async (req) => {
|
||||
|
||||
@@ -21,6 +21,8 @@ export const toMeDTO = (u: User): MeDTO => ({
|
||||
email: u.email,
|
||||
bio: u.bio,
|
||||
preferredService: u.preferredService,
|
||||
role: u.role,
|
||||
onboardedAt: u.onboardedAt?.toISOString() ?? null,
|
||||
sharingPausedUntil: isSharingPaused(u) ? u.sharingPausedUntil!.toISOString() : null,
|
||||
plan: effectivePlan(u),
|
||||
entitlements: entitlementsFor(u),
|
||||
@@ -31,8 +33,10 @@ export async function meRoutes(app: FastifyInstance) {
|
||||
|
||||
app.patch('/me', async (req) => {
|
||||
const user = requireUser(req);
|
||||
const input = updateMeSchema.parse(req.body);
|
||||
const [updated] = await db.update(schema.users).set(input).where(eq(schema.users.id, user.id)).returning();
|
||||
const { onboarded, ...input } = updateMeSchema.parse(req.body);
|
||||
const changes = { ...input, ...(onboarded && !user.onboardedAt ? { onboardedAt: new Date() } : {}) };
|
||||
if (Object.keys(changes).length === 0) return toMeDTO(user); // nothing to update
|
||||
const [updated] = await db.update(schema.users).set(changes).where(eq(schema.users.id, user.id)).returning();
|
||||
return toMeDTO(updated!);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* Blocking, removing followers, and reporting. App Store guideline 1.2 requires
|
||||
* apps with user content to let people block and report abusive users.
|
||||
*/
|
||||
import { and, desc, eq, or } from 'drizzle-orm';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { reportSchema } from '@musicshare/shared';
|
||||
import { db, schema } from '../db';
|
||||
import { requireUser } from '../lib/auth';
|
||||
import { HttpError } from '../lib/http';
|
||||
import { FOLLOWS_CHANGED_CHANNEL, redis } from '../lib/redis';
|
||||
import { toUserSummary } from '../services/nowPlaying';
|
||||
|
||||
async function findUser(username: string) {
|
||||
const [user] = await db.select().from(schema.users).where(eq(schema.users.username, username.toLowerCase()));
|
||||
if (!user) throw new HttpError(404, 'User not found');
|
||||
return user;
|
||||
}
|
||||
|
||||
/** Live websocket subscriptions are built from follows; refresh them for both people. */
|
||||
const followsChanged = (...userIds: string[]) => Promise.all(userIds.map((id) => redis.publish(FOLLOWS_CHANGED_CHANNEL, id)));
|
||||
|
||||
export async function safetyRoutes(app: FastifyInstance) {
|
||||
/** Remove someone who follows you. They aren't notified, and can follow again (block to prevent that). */
|
||||
app.delete<{ Params: { username: string } }>('/me/followers/:username', async (req) => {
|
||||
const me = requireUser(req);
|
||||
const them = await findUser(req.params.username);
|
||||
await db.delete(schema.follows).where(and(eq(schema.follows.followerId, them.id), eq(schema.follows.followeeId, me.id)));
|
||||
await followsChanged(them.id);
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
app.post<{ Params: { username: string } }>('/users/:username/block', async (req) => {
|
||||
const me = requireUser(req);
|
||||
const them = await findUser(req.params.username);
|
||||
if (them.id === me.id) throw new HttpError(400, "You can't block yourself");
|
||||
await db.transaction(async (tx) => {
|
||||
await tx.insert(schema.blocks).values({ blockerId: me.id, blockedId: them.id }).onConflictDoNothing();
|
||||
// Sever follows in both directions (pins go with them).
|
||||
await tx
|
||||
.delete(schema.follows)
|
||||
.where(
|
||||
or(
|
||||
and(eq(schema.follows.followerId, me.id), eq(schema.follows.followeeId, them.id)),
|
||||
and(eq(schema.follows.followerId, them.id), eq(schema.follows.followeeId, me.id)),
|
||||
),
|
||||
);
|
||||
});
|
||||
await followsChanged(me.id, them.id);
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
app.delete<{ Params: { username: string } }>('/users/:username/block', async (req) => {
|
||||
const me = requireUser(req);
|
||||
const them = await findUser(req.params.username);
|
||||
await db.delete(schema.blocks).where(and(eq(schema.blocks.blockerId, me.id), eq(schema.blocks.blockedId, them.id)));
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
app.get('/me/blocks', async (req) => {
|
||||
const me = requireUser(req);
|
||||
const rows = await db
|
||||
.select({ user: schema.users })
|
||||
.from(schema.blocks)
|
||||
.innerJoin(schema.users, eq(schema.users.id, schema.blocks.blockedId))
|
||||
.where(eq(schema.blocks.blockerId, me.id))
|
||||
.orderBy(desc(schema.blocks.createdAt));
|
||||
return rows.map((r) => toUserSummary(r.user));
|
||||
});
|
||||
|
||||
app.post<{ Params: { username: string } }>(
|
||||
'/users/:username/report',
|
||||
{ config: { rateLimit: { max: 10, timeWindow: '1 hour' } } },
|
||||
async (req) => {
|
||||
const me = requireUser(req);
|
||||
const them = await findUser(req.params.username);
|
||||
if (them.id === me.id) throw new HttpError(400, "You can't report yourself");
|
||||
const input = reportSchema.parse(req.body);
|
||||
await db.insert(schema.reports).values({ reporterId: me.id, reportedId: them.id, reason: input.reason, note: input.note || null });
|
||||
req.log.warn({ reported: them.username, reason: input.reason }, 'user reported');
|
||||
return { ok: true };
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
import { and, count, eq, ilike, inArray, isNotNull, or } from 'drizzle-orm';
|
||||
import { and, count, desc, eq, ilike, inArray, isNotNull, isNull, or } from 'drizzle-orm';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { sortFeed, sourceProfileUrl, type FeedItemDTO, type ProfileDTO } from '@musicshare/shared';
|
||||
import { sortFeed, sourceProfileUrl, type FeedItemDTO, type FollowListDTO, type ProfileDTO } from '@musicshare/shared';
|
||||
import { db, schema } from '../db';
|
||||
import { requireUser } from '../lib/auth';
|
||||
import { entitlementsFor } from '../lib/entitlements';
|
||||
import { HttpError } from '../lib/http';
|
||||
import { FOLLOWS_CHANGED_CHANNEL, redis } from '../lib/redis';
|
||||
import { blockStatus, hiddenUserIds } from '../services/blocks';
|
||||
import { watchUsers } from '../services/connections';
|
||||
import { getNowPlaying, getRecentPlays, toUserSummary } from '../services/nowPlaying';
|
||||
import { getTrackDTOs } from '../services/tracks';
|
||||
@@ -16,22 +17,41 @@ async function findByUsername(username: string) {
|
||||
return user;
|
||||
}
|
||||
|
||||
/** Someone who blocked you looks like they don't exist. */
|
||||
async function findVisible(username: string, viewerId: string | undefined, viewerIsAdmin = false) {
|
||||
const user = await findByUsername(username);
|
||||
if (user.suspendedAt && !viewerIsAdmin) throw new HttpError(404, 'User not found');
|
||||
const status = await blockStatus(viewerId, user.id);
|
||||
if (status.blockedByThem) throw new HttpError(404, 'User not found');
|
||||
return { user, ...status };
|
||||
}
|
||||
|
||||
const PAGE = 50;
|
||||
|
||||
export async function userRoutes(app: FastifyInstance) {
|
||||
app.get<{ Querystring: { q?: string } }>('/users/search', async (req) => {
|
||||
const q = (req.query.q ?? '').trim().replace(/[%_\\]/g, '');
|
||||
if (q.length < 2) return [];
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(schema.users)
|
||||
.where(or(ilike(schema.users.username, `${q}%`), ilike(schema.users.displayName, `%${q}%`)))
|
||||
.limit(20);
|
||||
return rows.map(toUserSummary);
|
||||
const [rows, hidden] = await Promise.all([
|
||||
db
|
||||
.select()
|
||||
.from(schema.users)
|
||||
.where(
|
||||
and(
|
||||
isNull(schema.users.suspendedAt),
|
||||
or(ilike(schema.users.username, `${q}%`), ilike(schema.users.displayName, `%${q}%`)),
|
||||
),
|
||||
)
|
||||
.limit(30),
|
||||
hiddenUserIds(req.user?.id),
|
||||
]);
|
||||
return rows.filter((u) => !hidden.has(u.id)).slice(0, 20).map(toUserSummary);
|
||||
});
|
||||
|
||||
app.get<{ Params: { username: string } }>('/users/:username', async (req): Promise<ProfileDTO> => {
|
||||
const user = await findByUsername(req.params.username);
|
||||
const viewer = req.user;
|
||||
const [[followers], [following], [follow], [featured], nowPlaying, recent, scrobblers] = await Promise.all([
|
||||
const { user, viewerBlocked } = await findVisible(req.params.username, viewer?.id, viewer?.role === 'admin');
|
||||
const [[followers], [following], [follow], [followsYou], [featured], nowPlaying, recent, scrobblers] = await Promise.all([
|
||||
db.select({ n: count() }).from(schema.follows).where(eq(schema.follows.followeeId, user.id)),
|
||||
db.select({ n: count() }).from(schema.follows).where(eq(schema.follows.followerId, user.id)),
|
||||
viewer
|
||||
@@ -40,6 +60,12 @@ export async function userRoutes(app: FastifyInstance) {
|
||||
.from(schema.follows)
|
||||
.where(and(eq(schema.follows.followerId, viewer.id), eq(schema.follows.followeeId, user.id)))
|
||||
: Promise.resolve([]),
|
||||
viewer
|
||||
? db
|
||||
.select({ x: schema.follows.followerId })
|
||||
.from(schema.follows)
|
||||
.where(and(eq(schema.follows.followerId, user.id), eq(schema.follows.followeeId, viewer.id)))
|
||||
: Promise.resolve([]),
|
||||
db.select().from(schema.featuredSongs).where(eq(schema.featuredSongs.userId, user.id)),
|
||||
getNowPlaying([user.id]),
|
||||
getRecentPlays(user.id),
|
||||
@@ -54,7 +80,7 @@ export async function userRoutes(app: FastifyInstance) {
|
||||
if (url && (c.provider === 'lastfm' || c.provider === 'listenbrainz')) attribution[c.provider] = url;
|
||||
}
|
||||
const featuredTrack = featured ? (await getTrackDTOs([featured.trackId])).get(featured.trackId) : undefined;
|
||||
await watchUsers([user.id]);
|
||||
if (!viewerBlocked) await watchUsers([user.id]);
|
||||
|
||||
return {
|
||||
user: { ...toUserSummary(user), bio: user.bio },
|
||||
@@ -63,20 +89,24 @@ export async function userRoutes(app: FastifyInstance) {
|
||||
isFollowing: Boolean(follow),
|
||||
isPinned: Boolean(follow?.pinnedAt),
|
||||
isSelf: viewer?.id === user.id,
|
||||
followsYou: Boolean(followsYou),
|
||||
isBlocked: viewerBlocked,
|
||||
// You blocked them: don't show their activity (until you unblock).
|
||||
featured:
|
||||
featured && featuredTrack
|
||||
!viewerBlocked && featured && featuredTrack
|
||||
? { track: featuredTrack, comment: featured.comment, updatedAt: featured.updatedAt.toISOString() }
|
||||
: null,
|
||||
nowPlaying: nowPlaying[0] ?? null,
|
||||
recent,
|
||||
nowPlaying: viewerBlocked ? null : (nowPlaying[0] ?? null),
|
||||
recent: viewerBlocked ? [] : recent,
|
||||
attribution,
|
||||
};
|
||||
});
|
||||
|
||||
app.post<{ Params: { username: string } }>('/users/:username/follow', async (req) => {
|
||||
const viewer = requireUser(req);
|
||||
const target = await findByUsername(req.params.username);
|
||||
const { user: target, viewerBlocked } = await findVisible(req.params.username, viewer.id);
|
||||
if (target.id === viewer.id) throw new HttpError(400, "You can't follow yourself");
|
||||
if (viewerBlocked) throw new HttpError(400, 'Unblock them first');
|
||||
await db.insert(schema.follows).values({ followerId: viewer.id, followeeId: target.id }).onConflictDoNothing();
|
||||
await redis.publish(FOLLOWS_CHANGED_CHANNEL, viewer.id);
|
||||
return { ok: true };
|
||||
@@ -124,6 +154,46 @@ export async function userRoutes(app: FastifyInstance) {
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
/** Who follows someone, or who they follow. Newest first, 50 per page. */
|
||||
for (const direction of ['followers', 'following'] as const) {
|
||||
app.get<{ Params: { username: string }; Querystring: { cursor?: string } }>(
|
||||
`/users/:username/${direction}`,
|
||||
async (req): Promise<FollowListDTO> => {
|
||||
const viewer = req.user;
|
||||
const { user } = await findVisible(req.params.username, viewer?.id);
|
||||
const offset = Math.max(0, Number.parseInt(req.query.cursor ?? '0', 10) || 0);
|
||||
const [listUser, matchCol] =
|
||||
direction === 'followers'
|
||||
? [schema.follows.followerId, schema.follows.followeeId]
|
||||
: [schema.follows.followeeId, schema.follows.followerId];
|
||||
const rows = await db
|
||||
.select({ user: schema.users })
|
||||
.from(schema.follows)
|
||||
.innerJoin(schema.users, eq(schema.users.id, listUser))
|
||||
.where(eq(matchCol, user.id))
|
||||
.orderBy(desc(schema.follows.createdAt))
|
||||
.limit(PAGE + 1)
|
||||
.offset(offset);
|
||||
const hidden = await hiddenUserIds(viewer?.id);
|
||||
const page = rows.slice(0, PAGE).map((r) => r.user);
|
||||
const viewerFollows = viewer && page.length
|
||||
? new Set(
|
||||
(
|
||||
await db
|
||||
.select({ id: schema.follows.followeeId })
|
||||
.from(schema.follows)
|
||||
.where(and(eq(schema.follows.followerId, viewer.id), inArray(schema.follows.followeeId, page.map((u) => u.id))))
|
||||
).map((r) => r.id),
|
||||
)
|
||||
: new Set<string>();
|
||||
return {
|
||||
users: page.filter((u) => !hidden.has(u.id)).map((u) => ({ ...toUserSummary(u), isFollowing: viewerFollows.has(u.id) })),
|
||||
nextCursor: rows.length > PAGE ? String(offset + PAGE) : null,
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/** Now-playing state for everyone the viewer follows, pinned friends first. */
|
||||
app.get('/feed', async (req): Promise<FeedItemDTO[]> => {
|
||||
const viewer = requireUser(req);
|
||||
|
||||
Reference in New Issue
Block a user