diff --git a/.env.example b/.env.example index 0302e10..069b597 100644 --- a/.env.example +++ b/.env.example @@ -13,4 +13,8 @@ S3_ACCESS_KEY_ID=minio S3_SECRET_ACCESS_KEY=minio-password S3_AVATARS_BUCKET=seen-avatars S3_PUBLIC_BASE_URL=http://localhost:3000 +# Salt for hashing contact identifiers. MUST equal the client's +# EXPO_PUBLIC_CONTACT_HASH_SALT, or contact matching silently finds nothing. +CONTACT_HASH_SALT=seen-contact-v1 EXPO_PUBLIC_API_URL=http://localhost:3000 +EXPO_PUBLIC_CONTACT_HASH_SALT=seen-contact-v1 diff --git a/apps/api/src/env.ts b/apps/api/src/env.ts index b010762..079b26e 100644 --- a/apps/api/src/env.ts +++ b/apps/api/src/env.ts @@ -1,3 +1,5 @@ +import { DEFAULT_CONTACT_HASH_SALT } from "@seen/shared"; + function optionalEnv(name: string) { const value = process.env[name]; return value && value.trim().length > 0 ? value : undefined; @@ -36,4 +38,7 @@ export const env = { s3SecretAccessKey: requiredEnv("S3_SECRET_ACCESS_KEY", "minio-password"), s3AvatarsBucket: requiredEnv("S3_AVATARS_BUCKET", "seen-avatars"), s3PublicBaseUrl: requiredEnv("S3_PUBLIC_BASE_URL", "http://localhost:3000"), + // Must match the client's EXPO_PUBLIC_CONTACT_HASH_SALT so device-computed + // contact hashes line up with server-stored ones. + contactHashSalt: requiredEnv("CONTACT_HASH_SALT", DEFAULT_CONTACT_HASH_SALT), }; diff --git a/apps/api/src/lib/contact-hash.ts b/apps/api/src/lib/contact-hash.ts new file mode 100644 index 0000000..7493457 --- /dev/null +++ b/apps/api/src/lib/contact-hash.ts @@ -0,0 +1,19 @@ +import { createHash } from "node:crypto"; + +import { + buildContactHashPayload, + normalizeContactValue, + type ContactIdentifierKind, +} from "@seen/shared"; + +import { env } from "../env"; + +// Hash a contact identifier the same way the mobile client does (salted SHA-256 +// hex over the normalized value). Returns null when the value can't be normalized. +export function hashContactValue(kind: ContactIdentifierKind, value: string): string | null { + const normalized = normalizeContactValue(kind, value); + if (!normalized) return null; + return createHash("sha256") + .update(buildContactHashPayload(env.contactHashSalt, kind, normalized)) + .digest("hex"); +} diff --git a/apps/api/src/lib/rate-limit.ts b/apps/api/src/lib/rate-limit.ts new file mode 100644 index 0000000..ae3b94f --- /dev/null +++ b/apps/api/src/lib/rate-limit.ts @@ -0,0 +1,52 @@ +import { HttpError } from "./http-error"; +import { redis } from "./redis"; + +type RateLimitOptions = { + key: string; + max: number; + windowSeconds: number; + message: string; + code: string; +}; + +type MemoryBucket = { + count: number; + resetAt: number; +}; + +const memoryBuckets = new Map(); + +function assertMemoryRateLimit({ key, max, windowSeconds, message, code }: RateLimitOptions) { + const now = Date.now(); + const existing = memoryBuckets.get(key); + const bucket = + existing && existing.resetAt > now + ? existing + : { count: 0, resetAt: now + windowSeconds * 1000 }; + + bucket.count += 1; + memoryBuckets.set(key, bucket); + + if (bucket.count > max) { + throw new HttpError(429, message, code); + } +} + +export async function assertRateLimit(options: RateLimitOptions): Promise { + if (!redis) { + assertMemoryRateLimit(options); + return; + } + + const key = `rate:${options.key}`; + try { + const count = await redis.incr(key); + if (count === 1) await redis.expire(key, options.windowSeconds); + if (count > options.max) { + throw new HttpError(429, options.message, options.code); + } + } catch (error) { + if (error instanceof HttpError) throw error; + assertMemoryRateLimit(options); + } +} diff --git a/apps/api/src/modules/profiles/model.ts b/apps/api/src/modules/profiles/model.ts index 949f58c..4a789c2 100644 --- a/apps/api/src/modules/profiles/model.ts +++ b/apps/api/src/modules/profiles/model.ts @@ -2,11 +2,23 @@ import { Elysia, t } from "elysia"; const mediaType = t.Union([t.Literal("movie"), t.Literal("tv")]); +const followPolicy = t.Union([t.Literal("open"), t.Literal("approval_required")]); +const profileVisibility = t.Union([t.Literal("public"), t.Literal("followers")]); +const watchlistVisibility = t.Union([ + t.Literal("private"), + t.Literal("followers"), + t.Literal("public"), +]); + const profile = t.Object({ id: t.String(), full_name: t.String(), username: t.String(), avatar_path: t.Nullable(t.String()), + follow_policy: followPolicy, + profile_visibility: profileVisibility, + default_watchlist_visibility: watchlistVisibility, + contact_discovery_enabled: t.Boolean(), created_at: t.String(), updated_at: t.String(), }); @@ -35,6 +47,12 @@ export const ProfileModel = new Elysia({ name: "Profile.Model" }).model({ username: t.String({ minLength: 3, maxLength: 20, pattern: "^[a-z0-9_.]+$" }), avatarPath: t.Optional(t.Nullable(t.String())), }), + "profile.PrivacyBody": t.Object({ + followPolicy: t.Optional(followPolicy), + profileVisibility: t.Optional(profileVisibility), + defaultWatchlistVisibility: t.Optional(watchlistVisibility), + contactDiscoveryEnabled: t.Optional(t.Boolean()), + }), "profile.ActivityQuery": t.Object({ limit: t.Optional(t.Numeric({ minimum: 1, maximum: 50 })), offset: t.Optional(t.Numeric({ minimum: 0 })), diff --git a/apps/api/src/modules/profiles/mutations/index.ts b/apps/api/src/modules/profiles/mutations/index.ts index f952441..439da69 100644 --- a/apps/api/src/modules/profiles/mutations/index.ts +++ b/apps/api/src/modules/profiles/mutations/index.ts @@ -1,3 +1,4 @@ export * from "./update-my-profile"; +export * from "./update-my-privacy"; export * from "./upload-avatar"; export * from "./delete-avatar"; diff --git a/apps/api/src/modules/profiles/mutations/update-my-privacy.ts b/apps/api/src/modules/profiles/mutations/update-my-privacy.ts new file mode 100644 index 0000000..63f7483 --- /dev/null +++ b/apps/api/src/modules/profiles/mutations/update-my-privacy.ts @@ -0,0 +1,91 @@ +import { db } from "@seen/db"; +import { profileContactIdentifiers, profiles } from "@seen/db/schema"; +import { and, eq, ne } from "@seen/db/orm"; + +import { hashContactValue } from "../../../lib/contact-hash"; +import { HttpError } from "../../../lib/http-error"; +import { toApiRow } from "../../../lib/rows"; +import { getOrCreateMyProfile } from "../queries/get-or-create-my-profile"; + +type AuthUser = { + id: string; + email?: string | null; + emailVerified?: boolean | null; + name?: string | null; + image?: string | null; + userMetadata?: Record | null; +}; + +type PrivacyInput = { + followPolicy?: "open" | "approval_required"; + profileVisibility?: "public" | "followers"; + defaultWatchlistVisibility?: "private" | "followers" | "public"; + contactDiscoveryEnabled?: boolean; +}; + +// Reconcile the user's stored contact identifiers with their discovery setting. +// When discovery is on we store the salted hash of their verified email (and only +// that hash); when off we remove every identifier so they're no longer matchable. +async function syncContactIdentifiers(user: AuthUser, discoveryEnabled: boolean) { + if (!discoveryEnabled) { + await db.delete(profileContactIdentifiers).where(eq(profileContactIdentifiers.userId, user.id)); + return; + } + + const emailHash = user.emailVerified && user.email ? hashContactValue("email", user.email) : null; + if (!emailHash) { + // Nothing verifiable to store yet; clear any stale rows. + await db.delete(profileContactIdentifiers).where(eq(profileContactIdentifiers.userId, user.id)); + return; + } + + await db + .insert(profileContactIdentifiers) + .values({ userId: user.id, kind: "email", hash: emailHash }) + .onConflictDoNothing({ + target: [ + profileContactIdentifiers.userId, + profileContactIdentifiers.kind, + profileContactIdentifiers.hash, + ], + }); + // Drop any previously stored email hash that no longer matches (e.g. email changed). + await db + .delete(profileContactIdentifiers) + .where( + and( + eq(profileContactIdentifiers.userId, user.id), + eq(profileContactIdentifiers.kind, "email"), + ne(profileContactIdentifiers.hash, emailHash), + ), + ); +} + +export async function updateMyPrivacy(user: AuthUser, input: PrivacyInput) { + // Guarantee the profile row exists before patching it. + await getOrCreateMyProfile(user); + + const patch: Partial = {}; + if (input.followPolicy !== undefined) patch.followPolicy = input.followPolicy; + if (input.profileVisibility !== undefined) patch.profileVisibility = input.profileVisibility; + if (input.defaultWatchlistVisibility !== undefined) { + patch.defaultWatchlistVisibility = input.defaultWatchlistVisibility; + } + if (input.contactDiscoveryEnabled !== undefined) { + patch.contactDiscoveryEnabled = input.contactDiscoveryEnabled; + } + + if (Object.keys(patch).length > 0) { + patch.updatedAt = new Date(); + await db.update(profiles).set(patch).where(eq(profiles.id, user.id)); + } + + const [row] = await db.select().from(profiles).where(eq(profiles.id, user.id)).limit(1); + if (!row) throw new HttpError(404, "Profile not found.", "profile-not-found"); + + if (input.contactDiscoveryEnabled !== undefined) { + await syncContactIdentifiers(user, row.contactDiscoveryEnabled); + } + + return toApiRow(row); +} diff --git a/apps/api/src/modules/profiles/router.ts b/apps/api/src/modules/profiles/router.ts index 2da1643..7cfa6fd 100644 --- a/apps/api/src/modules/profiles/router.ts +++ b/apps/api/src/modules/profiles/router.ts @@ -3,7 +3,7 @@ import { Elysia } from "elysia"; import { authGuard } from "../../auth-plugin"; import { ProfileModel } from "./model"; import { getAvatar, getMyProfileActivity, getOrCreateMyProfile } from "./queries"; -import { deleteAvatar, updateMyProfile, uploadAvatar } from "./mutations"; +import { deleteAvatar, updateMyPrivacy, updateMyProfile, uploadAvatar } from "./mutations"; export const profileController = new Elysia({ name: "Profile.Controller", @@ -24,6 +24,13 @@ export const profileController = new Elysia({ 200: "profile.Profile", }, }) + .patch("/me/privacy", ({ user, body }) => updateMyPrivacy(user, body), { + auth: true, + body: "profile.PrivacyBody", + response: { + 200: "profile.Profile", + }, + }) .get( "/me/activity", ({ user, query }) => getMyProfileActivity(user.id, query.limit, query.offset), diff --git a/apps/api/src/modules/recommendations/model.ts b/apps/api/src/modules/recommendations/model.ts index 5094c02..726db1b 100644 --- a/apps/api/src/modules/recommendations/model.ts +++ b/apps/api/src/modules/recommendations/model.ts @@ -31,6 +31,10 @@ const availableEntry = t.Composite([ t.Object({ providers: t.Array(providerRef), isShort: t.Boolean(), + // How many followed profiles reviewed/rated/watchlisted this title, and a + // short human reason. 0 / null when no followed profile has engaged with it. + friendSignalCount: t.Number(), + friendReason: t.Nullable(t.String()), }), ]); diff --git a/apps/api/src/modules/recommendations/queries/available-feed.ts b/apps/api/src/modules/recommendations/queries/available-feed.ts index 63f7792..e8ebcee 100644 --- a/apps/api/src/modules/recommendations/queries/available-feed.ts +++ b/apps/api/src/modules/recommendations/queries/available-feed.ts @@ -13,6 +13,7 @@ import { normalizeSummary, trending } from "../../tmdb/client"; import { getMediaDetail } from "../../tmdb/queries/media-detail"; import type { MediaFilter, TmdbMovieSummary } from "../../tmdb"; import type { AvailableEntryDto } from "../model"; +import { computeFriendSignals, getFolloweeIds } from "./friend-signal"; // Cap on how many cache-cold titles we warm per request, so a fresh feed // populates itself over a few loads without fanning out to TMDB unbounded. @@ -227,8 +228,28 @@ export async function getAvailableFeed( ...normalizeSummary(candidate.summary, candidate.summary.media_type), providers: matching, isShort: passesShortFilter(candidate), + friendSignalCount: 0, + friendReason: null, }); } - return entries.sort((a, b) => (b.popularity ?? 0) - (a.popularity ?? 0)); + // Annotate each surviving entry with how many followed profiles engaged with it, + // then float social matches above equal non-social ones. + const followeeIds = await getFolloweeIds(userId); + const signals = await computeFriendSignals( + followeeIds, + entries.map((entry) => ({ id: entry.id, media_type: entry.media_type })), + ); + for (const entry of entries) { + const signal = signals.get(`${entry.media_type}:${entry.id}`); + if (signal) { + entry.friendSignalCount = signal.count; + entry.friendReason = signal.reason; + } + } + + return entries.sort( + (a, b) => + b.friendSignalCount - a.friendSignalCount || (b.popularity ?? 0) - (a.popularity ?? 0), + ); } diff --git a/apps/api/src/modules/recommendations/queries/friend-signal.ts b/apps/api/src/modules/recommendations/queries/friend-signal.ts new file mode 100644 index 0000000..5df2ed4 --- /dev/null +++ b/apps/api/src/modules/recommendations/queries/friend-signal.ts @@ -0,0 +1,95 @@ +import { db } from "@seen/db"; +import { profiles, reviews, watchlist } from "@seen/db/schema"; +import { and, inArray } from "@seen/db/orm"; + +import type { MediaType } from "../../tmdb"; + +// Reuse the social module's follow lookup rather than duplicating the query. +export { getFolloweeIds } from "../../social/activity"; + +type CandidateRef = { id: number; media_type: MediaType }; +export type SignalAction = "review" | "watchlist"; + +export type FriendSignal = { count: number; reason: string | null }; + +export function buildReason(entries: { username: string; action: SignalAction }[]): string | null { + const primary = entries.find((entry) => entry.action === "review") ?? entries[0]; + if (!primary) return null; + if (entries.length === 1) { + return primary.action === "review" + ? `@${primary.username} reviewed this` + : `@${primary.username} added this to their watchlist`; + } + const others = entries.length - 1; + return `@${primary.username} and ${others} other${others > 1 ? "s" : ""}`; +} + +// For each candidate, how many of the user's followees engaged with it (reviewed +// or kept a non-private watchlist entry) plus a short reason. As a follower the +// viewer can see followers/public content, so only `private` watchlist rows are +// excluded — never a leak of hidden activity. +export async function computeFriendSignals( + followeeIds: string[], + candidates: CandidateRef[], +): Promise> { + const result = new Map(); + if (followeeIds.length === 0 || candidates.length === 0) return result; + + const tmdbIds = [...new Set(candidates.map((candidate) => candidate.id))]; + const candidateKeys = new Set( + candidates.map((candidate) => `${candidate.media_type}:${candidate.id}`), + ); + + const [reviewRows, watchRows] = await Promise.all([ + db + .select({ userId: reviews.userId, tmdbId: reviews.tmdbId, mediaType: reviews.mediaType }) + .from(reviews) + .where(and(inArray(reviews.userId, followeeIds), inArray(reviews.tmdbId, tmdbIds))), + db + .select({ + userId: watchlist.userId, + tmdbId: watchlist.tmdbId, + mediaType: watchlist.mediaType, + }) + .from(watchlist) + .where( + and( + inArray(watchlist.userId, followeeIds), + inArray(watchlist.tmdbId, tmdbIds), + inArray(watchlist.visibility, ["followers", "public"]), + ), + ), + ]); + + // key -> (followeeId -> action). A review outweighs a watchlist entry. + const byKey = new Map>(); + const record = (key: string, userId: string, action: SignalAction) => { + if (!candidateKeys.has(key)) return; + let users = byKey.get(key); + if (!users) { + users = new Map(); + byKey.set(key, users); + } + if (users.get(userId) !== "review") users.set(userId, action); + }; + for (const row of reviewRows) record(`${row.mediaType}:${row.tmdbId}`, row.userId, "review"); + for (const row of watchRows) record(`${row.mediaType}:${row.tmdbId}`, row.userId, "watchlist"); + + const userIds = new Set(); + for (const users of byKey.values()) for (const id of users.keys()) userIds.add(id); + const nameRows = userIds.size + ? await db + .select({ id: profiles.id, username: profiles.username }) + .from(profiles) + .where(inArray(profiles.id, [...userIds])) + : []; + const usernames = new Map(nameRows.map((row) => [row.id, row.username])); + + for (const [key, users] of byKey) { + const entries = [...users.entries()] + .map(([id, action]) => ({ username: usernames.get(id) ?? "someone", action })) + .sort((left, right) => left.username.localeCompare(right.username)); + result.set(key, { count: entries.length, reason: buildReason(entries) }); + } + return result; +} diff --git a/apps/api/src/modules/router.ts b/apps/api/src/modules/router.ts index 77a3152..5dd5b4b 100644 --- a/apps/api/src/modules/router.ts +++ b/apps/api/src/modules/router.ts @@ -12,6 +12,7 @@ import { preferencesController } from "./preferences"; import { profileController } from "./profiles"; import { recommendationsController } from "./recommendations"; import { reviewController } from "./reviews"; +import { socialController } from "./social"; import { tmdbController } from "./tmdb"; import { watchlistController } from "./watchlist"; import { whatsNewController } from "./whats-new"; @@ -31,5 +32,6 @@ export const apiRouter = new Elysia({ name: "api.router" }) .use(platformsController) .use(preferencesController) .use(recommendationsController) + .use(socialController) .use(analyticsController) .use(whatsNewController); diff --git a/apps/api/src/modules/social/activity.ts b/apps/api/src/modules/social/activity.ts new file mode 100644 index 0000000..e55f624 --- /dev/null +++ b/apps/api/src/modules/social/activity.ts @@ -0,0 +1,147 @@ +import { db } from "@seen/db"; +import { episodeReviews, follows, movies, profiles, reviews } from "@seen/db/schema"; +import { desc, eq, inArray } from "@seen/db/orm"; + +import { toApiRow } from "../../lib/rows"; +import { getViewerState, getViewerStates, normalizePagination, toProfileCard } from "./shared"; + +type MediaType = "movie" | "tv"; + +async function getMoviesFor(keys: { tmdbId: number; mediaType: MediaType }[]) { + const ids = [...new Set(keys.map((key) => key.tmdbId))]; + const allowed = new Set(keys.map((key) => `${key.tmdbId}:${key.mediaType}`)); + if (!ids.length) return new Map(); + const rows = await db.select().from(movies).where(inArray(movies.tmdbId, ids)); + return new Map( + rows + .filter((movie) => allowed.has(`${movie.tmdbId}:${movie.mediaType}`)) + .map((movie) => [`${movie.tmdbId}:${movie.mediaType}`, movie]), + ); +} + +// Author cards for the activity feed, resolved relative to the viewer so each +// row carries follow state for its author. +async function getAuthorCards(viewerId: string, authorIds: string[]) { + const ids = [...new Set(authorIds)]; + if (!ids.length) return new Map>(); + const [rows, states] = await Promise.all([ + db.select().from(profiles).where(inArray(profiles.id, ids)), + getViewerStates(viewerId, ids), + ]); + const map = new Map>(); + for (const row of rows) { + map.set(row.id, toProfileCard(row, viewerId, getViewerState(states, row.id))); + } + return map; +} + +function mediaSubtitle(movie: typeof movies.$inferSelect | undefined, mediaType: MediaType) { + const label = mediaType === "tv" ? "Series" : "Movie"; + const year = movie?.releaseDate?.slice(0, 4); + return year ? `${label} - ${year}` : label; +} + +// Merge review + episode-review activity for a set of authors into one feed, +// newest first, with the author card attached to every item. +export async function buildActivityFeed( + viewerId: string, + authorIds: string[], + limit: number, + offset: number, +) { + const { pageSize, offset: from } = normalizePagination(limit, offset); + const window = from + pageSize; + const authors = [...new Set(authorIds)]; + if (authors.length === 0) return []; + + const [reviewRows, episodeRows] = await Promise.all([ + db + .select() + .from(reviews) + .where(inArray(reviews.userId, authors)) + .orderBy(desc(reviews.createdAt)) + .limit(window), + db + .select() + .from(episodeReviews) + .where(inArray(episodeReviews.userId, authors)) + .orderBy(desc(episodeReviews.createdAt)) + .limit(window), + ]); + + const [movieMap, authorCards] = await Promise.all([ + getMoviesFor([ + ...reviewRows.map((review) => ({ + tmdbId: review.tmdbId, + mediaType: review.mediaType as MediaType, + })), + ...episodeRows.map((episode) => ({ tmdbId: episode.seriesTmdbId, mediaType: "tv" as const })), + ]), + getAuthorCards(viewerId, [ + ...reviewRows.map((review) => review.userId), + ...episodeRows.map((episode) => episode.userId), + ]), + ]); + + const mediaItems = reviewRows + .filter((review) => authorCards.has(review.userId)) + .map((review) => { + const mediaType = review.mediaType as MediaType; + const movie = movieMap.get(`${review.tmdbId}:${mediaType}`); + return toApiRow({ + id: review.id, + kind: "media" as const, + createdAt: review.createdAt, + rating: review.rating, + reviewTitle: review.title, + comment: review.comment, + mediaTitle: movie?.title ?? (mediaType === "tv" ? "Series" : "Movie"), + mediaSubtitle: mediaSubtitle(movie, mediaType), + posterPath: movie?.posterPath ?? null, + mediaType, + tmdbId: review.tmdbId, + seasonNumber: null, + episodeNumber: null, + episodeTmdbId: null, + author: authorCards.get(review.userId), + }); + }); + + const episodeItems = episodeRows + .filter((episode) => authorCards.has(episode.userId)) + .map((episode) => { + const series = movieMap.get(`${episode.seriesTmdbId}:tv`); + return toApiRow({ + id: episode.id, + kind: "episode" as const, + createdAt: episode.createdAt, + rating: episode.rating, + reviewTitle: episode.title, + comment: episode.comment, + mediaTitle: series?.title ?? "Series", + mediaSubtitle: `Season ${episode.seasonNumber} - Episode ${episode.episodeNumber}`, + posterPath: series?.posterPath ?? null, + mediaType: "tv" as const, + tmdbId: episode.seriesTmdbId, + seasonNumber: episode.seasonNumber, + episodeNumber: episode.episodeNumber, + episodeTmdbId: episode.episodeTmdbId, + author: authorCards.get(episode.userId), + }); + }); + + return [...mediaItems, ...episodeItems] + .sort( + (left, right) => new Date(right.created_at).getTime() - new Date(left.created_at).getTime(), + ) + .slice(from, from + pageSize); +} + +// The ids of everyone `userId` follows. +export async function getFolloweeIds(userId: string): Promise { + const rows = await db + .select({ id: follows.followeeId }) + .from(follows) + .where(eq(follows.followerId, userId)); + return rows.map((row) => row.id); +} diff --git a/apps/api/src/modules/social/index.ts b/apps/api/src/modules/social/index.ts new file mode 100644 index 0000000..7778cad --- /dev/null +++ b/apps/api/src/modules/social/index.ts @@ -0,0 +1,2 @@ +export { socialController } from "./router"; +export { socialModels } from "./model"; diff --git a/apps/api/src/modules/social/model.ts b/apps/api/src/modules/social/model.ts new file mode 100644 index 0000000..c60e183 --- /dev/null +++ b/apps/api/src/modules/social/model.ts @@ -0,0 +1,130 @@ +import { Elysia, t } from "elysia"; + +const mediaType = t.Union([t.Literal("movie"), t.Literal("tv")]); +const requestStatus = t.Union([t.Literal("none"), t.Literal("pending"), t.Literal("rejected")]); + +const profileCard = t.Object({ + id: t.String(), + username: t.String(), + full_name: t.String(), + avatar_path: t.Nullable(t.String()), + is_me: t.Boolean(), + is_following: t.Boolean(), + follows_me: t.Boolean(), + request_status: requestStatus, +}); + +const profile = t.Composite([ + profileCard, + t.Object({ + follow_policy: t.Union([t.Literal("open"), t.Literal("approval_required")]), + profile_visibility: t.Union([t.Literal("public"), t.Literal("followers")]), + followers_count: t.Number(), + following_count: t.Number(), + // True when the viewer may not see this profile's detail (activity/watchlist). + locked: t.Boolean(), + }), +]); + +const mediaSummary = t.Object({ + id: t.Number(), + media_type: mediaType, + title: t.Optional(t.String()), + original_title: t.Optional(t.String()), + overview: t.Optional(t.String()), + release_date: t.Optional(t.String()), + runtime: t.Optional(t.Nullable(t.Number())), + poster_path: t.Optional(t.Nullable(t.String())), + backdrop_path: t.Optional(t.Nullable(t.String())), + vote_average: t.Optional(t.Number()), + vote_count: t.Optional(t.Number()), + popularity: t.Optional(t.Number()), + genre_ids: t.Optional(t.Array(t.Number())), +}); + +const activityItem = t.Object({ + id: t.String(), + kind: t.Union([t.Literal("media"), t.Literal("episode")]), + created_at: t.String(), + rating: t.Nullable(t.Number()), + review_title: t.Nullable(t.String()), + comment: t.Nullable(t.String()), + media_title: t.String(), + media_subtitle: t.String(), + poster_path: t.Nullable(t.String()), + media_type: mediaType, + tmdb_id: t.Number(), + season_number: t.Optional(t.Nullable(t.Number())), + episode_number: t.Optional(t.Nullable(t.Number())), + episode_tmdb_id: t.Optional(t.Nullable(t.Number())), + author: profileCard, +}); + +const watchlistItem = t.Object({ + id: t.String(), + tmdb_id: t.Number(), + media_type: mediaType, + added_at: t.String(), + visibility: t.Union([t.Literal("private"), t.Literal("followers"), t.Literal("public")]), + media: mediaSummary, +}); + +const followRequest = t.Object({ + id: t.String(), + created_at: t.String(), + status: t.Union([t.Literal("pending"), t.Literal("approved"), t.Literal("rejected")]), + requester: profileCard, +}); + +const pageQuery = t.Object({ + limit: t.Optional(t.Numeric({ minimum: 1, maximum: 50 })), + offset: t.Optional(t.Numeric({ minimum: 0 })), +}); + +export const SocialModel = new Elysia({ name: "Social.Model" }).model({ + "social.ProfileCard": profileCard, + "social.Profile": profile, + "social.ProfileList": t.Array(profileCard), + "social.SearchQuery": t.Object({ + q: t.String({ minLength: 1, maxLength: 60 }), + limit: t.Optional(t.Numeric({ minimum: 1, maximum: 50 })), + offset: t.Optional(t.Numeric({ minimum: 0 })), + }), + "social.PageQuery": pageQuery, + "social.ActivityList": t.Array(activityItem), + "social.WatchlistPage": t.Object({ + items: t.Array(watchlistItem), + count: t.Number(), + }), + "social.FollowResult": t.Object({ + state: t.Union([t.Literal("following"), t.Literal("requested")]), + profile, + }), + "social.UnfollowResult": t.Object({ + profile, + }), + "social.RequestList": t.Array(followRequest), + "social.ApproveAllResponse": t.Object({ + approved: t.Number(), + }), + "social.OkResponse": t.Object({ + ok: t.Boolean(), + }), + "social.ContactMatchList": t.Array( + t.Object({ + profile: profileCard, + matched_hashes: t.Array(t.String()), + }), + ), + "social.ContactsMatchBody": t.Object({ + identifiers: t.Array( + t.Object({ + kind: t.Union([t.Literal("email"), t.Literal("phone")]), + hash: t.String({ minLength: 16, maxLength: 128 }), + }), + { maxItems: 2000 }, + ), + }), +}); + +export const socialModels = SocialModel.models; diff --git a/apps/api/src/modules/social/mutations/approve-all-requests.ts b/apps/api/src/modules/social/mutations/approve-all-requests.ts new file mode 100644 index 0000000..8ff8261 --- /dev/null +++ b/apps/api/src/modules/social/mutations/approve-all-requests.ts @@ -0,0 +1,33 @@ +import { db } from "@seen/db"; +import { follows, followRequests } from "@seen/db/schema"; +import { and, eq, inArray } from "@seen/db/orm"; + +// Approve every pending follow request addressed to the viewer in one shot. +export async function approveAllFollowRequests(viewerId: string) { + return db.transaction(async (tx) => { + const pending = await tx + .select({ id: followRequests.id, requesterId: followRequests.requesterId }) + .from(followRequests) + .where(and(eq(followRequests.targetId, viewerId), eq(followRequests.status, "pending"))); + + if (pending.length === 0) return { approved: 0 }; + + await tx + .insert(follows) + .values(pending.map((request) => ({ followerId: request.requesterId, followeeId: viewerId }))) + .onConflictDoNothing({ target: [follows.followerId, follows.followeeId] }); + + const updated = await tx + .update(followRequests) + .set({ status: "approved", updatedAt: new Date() }) + .where( + inArray( + followRequests.id, + pending.map((request) => request.id), + ), + ) + .returning({ id: followRequests.id }); + + return { approved: updated.length }; + }); +} diff --git a/apps/api/src/modules/social/mutations/approve-request.ts b/apps/api/src/modules/social/mutations/approve-request.ts new file mode 100644 index 0000000..ade5a57 --- /dev/null +++ b/apps/api/src/modules/social/mutations/approve-request.ts @@ -0,0 +1,37 @@ +import { db } from "@seen/db"; +import { follows, followRequests } from "@seen/db/schema"; +import { and, eq } from "@seen/db/orm"; + +import { HttpError } from "../../../lib/http-error"; + +// Approve a pending follow request addressed to the viewer: create the follow edge +// and mark the request approved. +export async function approveFollowRequest(viewerId: string, requestId: string) { + const [request] = await db + .select() + .from(followRequests) + .where( + and( + eq(followRequests.id, requestId), + eq(followRequests.targetId, viewerId), + eq(followRequests.status, "pending"), + ), + ) + .limit(1); + + if (!request) { + throw new HttpError(404, "Request not found.", "request-not-found"); + } + + await db + .insert(follows) + .values({ followerId: request.requesterId, followeeId: viewerId }) + .onConflictDoNothing({ target: [follows.followerId, follows.followeeId] }); + + await db + .update(followRequests) + .set({ status: "approved", updatedAt: new Date() }) + .where(eq(followRequests.id, requestId)); + + return { ok: true }; +} diff --git a/apps/api/src/modules/social/mutations/follow-profile.ts b/apps/api/src/modules/social/mutations/follow-profile.ts new file mode 100644 index 0000000..e7dde3c --- /dev/null +++ b/apps/api/src/modules/social/mutations/follow-profile.ts @@ -0,0 +1,45 @@ +import { db } from "@seen/db"; +import { follows, followRequests } from "@seen/db/schema"; +import { and, eq } from "@seen/db/orm"; + +import { HttpError } from "../../../lib/http-error"; +import { buildProfileDetail, loadProfileRow } from "../shared"; + +// Follow a profile. `open` profiles create the follow immediately; `approval_required` +// profiles create (or re-open) a pending follow request instead. +export async function followProfile(viewerId: string, profileId: string) { + if (viewerId === profileId) { + throw new HttpError(400, "You can't follow yourself.", "follow-self"); + } + const row = await loadProfileRow(profileId); + + const [alreadyFollowing] = await db + .select({ id: follows.id }) + .from(follows) + .where(and(eq(follows.followerId, viewerId), eq(follows.followeeId, profileId))) + .limit(1); + + if (alreadyFollowing || row.followPolicy === "open") { + await db + .insert(follows) + .values({ followerId: viewerId, followeeId: profileId }) + .onConflictDoNothing({ target: [follows.followerId, follows.followeeId] }); + // A previously pending request is now moot. + await db + .delete(followRequests) + .where(and(eq(followRequests.requesterId, viewerId), eq(followRequests.targetId, profileId))); + return { state: "following" as const, profile: await buildProfileDetail(viewerId, row) }; + } + + const now = new Date(); + + await db + .insert(followRequests) + .values({ requesterId: viewerId, targetId: profileId, status: "pending" }) + .onConflictDoUpdate({ + target: [followRequests.requesterId, followRequests.targetId], + set: { status: "pending", createdAt: now, updatedAt: now }, + }); + + return { state: "requested" as const, profile: await buildProfileDetail(viewerId, row) }; +} diff --git a/apps/api/src/modules/social/mutations/index.ts b/apps/api/src/modules/social/mutations/index.ts new file mode 100644 index 0000000..40058b2 --- /dev/null +++ b/apps/api/src/modules/social/mutations/index.ts @@ -0,0 +1,6 @@ +export { followProfile } from "./follow-profile"; +export { unfollowProfile } from "./unfollow-profile"; +export { approveFollowRequest } from "./approve-request"; +export { rejectFollowRequest } from "./reject-request"; +export { approveAllFollowRequests } from "./approve-all-requests"; +export { matchContacts } from "./match-contacts"; diff --git a/apps/api/src/modules/social/mutations/match-contacts.ts b/apps/api/src/modules/social/mutations/match-contacts.ts new file mode 100644 index 0000000..09a5ebf --- /dev/null +++ b/apps/api/src/modules/social/mutations/match-contacts.ts @@ -0,0 +1,75 @@ +import { db } from "@seen/db"; +import { profileContactIdentifiers, profiles } from "@seen/db/schema"; +import { and, eq, inArray, ne, or } from "@seen/db/orm"; + +import { assertRateLimit } from "../../../lib/rate-limit"; +import { buildProfileCards, type ProfileRow } from "../shared"; + +type Identifier = { kind: "email" | "phone"; hash: string }; + +const MATCH_CONTACTS_LIMIT = 30; +const MATCH_CONTACTS_WINDOW_SECONDS = 60 * 60; + +// Match a batch of hashed contact identifiers against discoverable profiles. Only +// profiles that opted into contact discovery are returned, and the viewer is +// excluded. We receive (and return) only hashes — never plaintext contacts. Each +// result echoes the hashes that matched so the client can re-join the profile to +// the on-device contact name. +export async function matchContacts(viewerId: string, identifiers: Identifier[]) { + await assertRateLimit({ + key: `contacts-match:${viewerId}`, + max: MATCH_CONTACTS_LIMIT, + windowSeconds: MATCH_CONTACTS_WINDOW_SECONDS, + message: "Too many contact matching attempts. Please try again later.", + code: "contacts-match-rate-limited", + }); + + const emailHashes = [ + ...new Set(identifiers.filter((id) => id.kind === "email").map((id) => id.hash)), + ]; + const phoneHashes = [ + ...new Set(identifiers.filter((id) => id.kind === "phone").map((id) => id.hash)), + ]; + if (emailHashes.length === 0 && phoneHashes.length === 0) return []; + + const hashMatch = or( + emailHashes.length + ? and( + eq(profileContactIdentifiers.kind, "email"), + inArray(profileContactIdentifiers.hash, emailHashes), + ) + : undefined, + phoneHashes.length + ? and( + eq(profileContactIdentifiers.kind, "phone"), + inArray(profileContactIdentifiers.hash, phoneHashes), + ) + : undefined, + ); + + const rows = await db + .select({ profile: profiles, hash: profileContactIdentifiers.hash }) + .from(profileContactIdentifiers) + .innerJoin(profiles, eq(profiles.id, profileContactIdentifiers.userId)) + .where(and(eq(profiles.contactDiscoveryEnabled, true), ne(profiles.id, viewerId), hashMatch)); + + const byProfile = new Map }>(); + for (const row of rows) { + let entry = byProfile.get(row.profile.id); + if (!entry) { + entry = { row: row.profile, hashes: new Set() }; + byProfile.set(row.profile.id, entry); + } + entry.hashes.add(row.hash); + } + + const cards = await buildProfileCards( + viewerId, + [...byProfile.values()].map((entry) => entry.row), + ); + + return cards.map((card) => ({ + profile: card, + matched_hashes: [...(byProfile.get(card.id)?.hashes ?? [])], + })); +} diff --git a/apps/api/src/modules/social/mutations/reject-request.ts b/apps/api/src/modules/social/mutations/reject-request.ts new file mode 100644 index 0000000..fafd177 --- /dev/null +++ b/apps/api/src/modules/social/mutations/reject-request.ts @@ -0,0 +1,28 @@ +import { db } from "@seen/db"; +import { followRequests } from "@seen/db/schema"; +import { and, eq } from "@seen/db/orm"; + +import { HttpError } from "../../../lib/http-error"; + +// Reject a pending follow request addressed to the viewer. The row is kept as +// `rejected` (not deleted) so a re-request flips the same row, but no follow edge +// is created. +export async function rejectFollowRequest(viewerId: string, requestId: string) { + const result = await db + .update(followRequests) + .set({ status: "rejected", updatedAt: new Date() }) + .where( + and( + eq(followRequests.id, requestId), + eq(followRequests.targetId, viewerId), + eq(followRequests.status, "pending"), + ), + ) + .returning({ id: followRequests.id }); + + if (result.length === 0) { + throw new HttpError(404, "Request not found.", "request-not-found"); + } + + return { ok: true }; +} diff --git a/apps/api/src/modules/social/mutations/unfollow-profile.ts b/apps/api/src/modules/social/mutations/unfollow-profile.ts new file mode 100644 index 0000000..7f8de2f --- /dev/null +++ b/apps/api/src/modules/social/mutations/unfollow-profile.ts @@ -0,0 +1,22 @@ +import { db } from "@seen/db"; +import { follows, followRequests } from "@seen/db/schema"; +import { and, eq } from "@seen/db/orm"; + +import { buildProfileDetail, loadProfileRow } from "../shared"; + +// Unfollow a profile, and cancel any outstanding follow request to it. Idempotent: +// unfollowing someone you don't follow simply returns the current profile state. +export async function unfollowProfile(viewerId: string, profileId: string) { + const row = await loadProfileRow(profileId); + + await Promise.all([ + db + .delete(follows) + .where(and(eq(follows.followerId, viewerId), eq(follows.followeeId, profileId))), + db + .delete(followRequests) + .where(and(eq(followRequests.requesterId, viewerId), eq(followRequests.targetId, profileId))), + ]); + + return { profile: await buildProfileDetail(viewerId, row) }; +} diff --git a/apps/api/src/modules/social/queries/get-followers.ts b/apps/api/src/modules/social/queries/get-followers.ts new file mode 100644 index 0000000..fc07452 --- /dev/null +++ b/apps/api/src/modules/social/queries/get-followers.ts @@ -0,0 +1,5 @@ +import { listProfileConnections } from "../shared"; + +export async function getFollowers(viewerId: string, profileId: string, limit = 20, offset = 0) { + return listProfileConnections(viewerId, profileId, "followers", limit, offset); +} diff --git a/apps/api/src/modules/social/queries/get-following-activity.ts b/apps/api/src/modules/social/queries/get-following-activity.ts new file mode 100644 index 0000000..56dedcc --- /dev/null +++ b/apps/api/src/modules/social/queries/get-following-activity.ts @@ -0,0 +1,10 @@ +import { buildActivityFeed, getFolloweeIds } from "../activity"; + +// The viewer's home-style feed: recent activity from everyone they follow. As a +// follower the viewer can always see followee `followers`-visible content, so no +// extra per-author visibility filtering is needed here. +export async function getFollowingActivity(viewerId: string, limit = 12, offset = 0) { + const followeeIds = await getFolloweeIds(viewerId); + if (followeeIds.length === 0) return []; + return buildActivityFeed(viewerId, followeeIds, limit, offset); +} diff --git a/apps/api/src/modules/social/queries/get-following.ts b/apps/api/src/modules/social/queries/get-following.ts new file mode 100644 index 0000000..f0f2d27 --- /dev/null +++ b/apps/api/src/modules/social/queries/get-following.ts @@ -0,0 +1,5 @@ +import { listProfileConnections } from "../shared"; + +export async function getFollowing(viewerId: string, profileId: string, limit = 20, offset = 0) { + return listProfileConnections(viewerId, profileId, "following", limit, offset); +} diff --git a/apps/api/src/modules/social/queries/get-incoming-requests.ts b/apps/api/src/modules/social/queries/get-incoming-requests.ts new file mode 100644 index 0000000..5062246 --- /dev/null +++ b/apps/api/src/modules/social/queries/get-incoming-requests.ts @@ -0,0 +1,32 @@ +import { db } from "@seen/db"; +import { followRequests, profiles } from "@seen/db/schema"; +import { and, desc, eq } from "@seen/db/orm"; + +import { getViewerState, getViewerStates, normalizePagination, toProfileCard } from "../shared"; + +// Pending follow requests addressed to the viewer, newest first, each with the +// requester's profile card. +export async function getIncomingRequests(viewerId: string, limit = 30, offset = 0) { + const { pageSize, offset: from } = normalizePagination(limit, offset); + + const rows = await db + .select({ request: followRequests, requester: profiles }) + .from(followRequests) + .innerJoin(profiles, eq(profiles.id, followRequests.requesterId)) + .where(and(eq(followRequests.targetId, viewerId), eq(followRequests.status, "pending"))) + .orderBy(desc(followRequests.createdAt)) + .limit(pageSize) + .offset(from); + + const states = await getViewerStates( + viewerId, + rows.map((entry) => entry.requester.id), + ); + + return rows.map((entry) => ({ + id: entry.request.id, + created_at: entry.request.createdAt.toISOString(), + status: entry.request.status as "pending" | "approved" | "rejected", + requester: toProfileCard(entry.requester, viewerId, getViewerState(states, entry.requester.id)), + })); +} diff --git a/apps/api/src/modules/social/queries/get-profile-activity.ts b/apps/api/src/modules/social/queries/get-profile-activity.ts new file mode 100644 index 0000000..304440d --- /dev/null +++ b/apps/api/src/modules/social/queries/get-profile-activity.ts @@ -0,0 +1,12 @@ +import { buildActivityFeed } from "../activity"; +import { loadViewableProfile } from "../shared"; + +export async function getSocialProfileActivity( + viewerId: string, + profileId: string, + limit = 12, + offset = 0, +) { + await loadViewableProfile(viewerId, profileId); + return buildActivityFeed(viewerId, [profileId], limit, offset); +} diff --git a/apps/api/src/modules/social/queries/get-profile-watchlist.ts b/apps/api/src/modules/social/queries/get-profile-watchlist.ts new file mode 100644 index 0000000..0cfe8e0 --- /dev/null +++ b/apps/api/src/modules/social/queries/get-profile-watchlist.ts @@ -0,0 +1,63 @@ +import { db } from "@seen/db"; +import { movies, watchlist } from "@seen/db/schema"; +import { and, count, desc, eq, inArray } from "@seen/db/orm"; + +import { toMediaSummary } from "../../watchlist/shared"; +import { canViewWatchlistVisibility, loadViewableProfile, normalizePagination } from "../shared"; + +// Which visibility values the viewer is allowed to see, pushed into the query so +// hidden rows never leave the database and the count matches what's returned. +function allowedVisibilities(viewerId: string, ownerId: string, isFollowing: boolean): string[] { + if (viewerId === ownerId) return ["private", "followers", "public"]; + return isFollowing ? ["followers", "public"] : ["public"]; +} + +export async function getSocialProfileWatchlist( + viewerId: string, + profileId: string, + limit = 20, + offset = 0, +) { + const { state } = await loadViewableProfile(viewerId, profileId); + + const { pageSize, offset: from } = normalizePagination(limit, offset); + const visibilities = allowedVisibilities(viewerId, profileId, state.isFollowing); + + const where = and(eq(watchlist.userId, profileId), inArray(watchlist.visibility, visibilities)); + const mediaJoin = and( + eq(watchlist.tmdbId, movies.tmdbId), + eq(watchlist.mediaType, movies.mediaType), + ); + + const [rows, total] = await Promise.all([ + db + .select({ watchlist, media: movies }) + .from(watchlist) + .innerJoin(movies, mediaJoin) + .where(where) + .orderBy(desc(watchlist.addedAt)) + .limit(pageSize) + .offset(from), + db.select({ value: count() }).from(watchlist).innerJoin(movies, mediaJoin).where(where), + ]); + + const items = rows + .filter((entry) => + canViewWatchlistVisibility( + entry.watchlist.visibility, + viewerId, + profileId, + state.isFollowing, + ), + ) + .map((entry) => ({ + id: entry.watchlist.id, + tmdb_id: entry.watchlist.tmdbId, + media_type: entry.watchlist.mediaType as "movie" | "tv", + added_at: entry.watchlist.addedAt.toISOString(), + visibility: entry.watchlist.visibility as "private" | "followers" | "public", + media: toMediaSummary(entry.media), + })); + + return { items, count: total[0]?.value ?? 0 }; +} diff --git a/apps/api/src/modules/social/queries/get-profile.ts b/apps/api/src/modules/social/queries/get-profile.ts new file mode 100644 index 0000000..a5e88ab --- /dev/null +++ b/apps/api/src/modules/social/queries/get-profile.ts @@ -0,0 +1,6 @@ +import { buildProfileDetail, loadProfileRow } from "../shared"; + +export async function getSocialProfile(viewerId: string, profileId: string) { + const row = await loadProfileRow(profileId); + return buildProfileDetail(viewerId, row); +} diff --git a/apps/api/src/modules/social/queries/index.ts b/apps/api/src/modules/social/queries/index.ts new file mode 100644 index 0000000..4e57481 --- /dev/null +++ b/apps/api/src/modules/social/queries/index.ts @@ -0,0 +1,8 @@ +export { getSocialProfile } from "./get-profile"; +export { searchProfiles } from "./search-profiles"; +export { getSocialProfileActivity } from "./get-profile-activity"; +export { getSocialProfileWatchlist } from "./get-profile-watchlist"; +export { getFollowers } from "./get-followers"; +export { getFollowing } from "./get-following"; +export { getFollowingActivity } from "./get-following-activity"; +export { getIncomingRequests } from "./get-incoming-requests"; diff --git a/apps/api/src/modules/social/queries/search-profiles.ts b/apps/api/src/modules/social/queries/search-profiles.ts new file mode 100644 index 0000000..b405329 --- /dev/null +++ b/apps/api/src/modules/social/queries/search-profiles.ts @@ -0,0 +1,33 @@ +import { db } from "@seen/db"; +import { profiles } from "@seen/db/schema"; +import { and, asc, ilike, ne, or } from "@seen/db/orm"; + +import { buildProfileCards, normalizePagination } from "../shared"; + +// Escape LIKE/ILIKE wildcards so a user typing `%` or `_` searches for the literal +// character instead of matching every (or any) profile. +function escapeLike(value: string): string { + return value.replace(/[\\%_]/g, (char) => `\\${char}`); +} + +export async function searchProfiles(viewerId: string, term: string, limit = 20, offset = 0) { + const { pageSize, offset: from } = normalizePagination(limit, offset); + const query = term.trim(); + if (!query) return []; + + const pattern = `%${escapeLike(query)}%`; + const rows = await db + .select() + .from(profiles) + .where( + and( + ne(profiles.id, viewerId), + or(ilike(profiles.username, pattern), ilike(profiles.fullName, pattern)), + ), + ) + .orderBy(asc(profiles.username)) + .limit(pageSize) + .offset(from); + + return buildProfileCards(viewerId, rows); +} diff --git a/apps/api/src/modules/social/router.ts b/apps/api/src/modules/social/router.ts new file mode 100644 index 0000000..7ab2252 --- /dev/null +++ b/apps/api/src/modules/social/router.ts @@ -0,0 +1,132 @@ +import { Elysia } from "elysia"; + +import { authGuard } from "../../auth-plugin"; +import { SocialModel } from "./model"; +import { + getFollowers, + getFollowing, + getFollowingActivity, + getIncomingRequests, + getSocialProfile, + getSocialProfileActivity, + getSocialProfileWatchlist, + searchProfiles, +} from "./queries"; +import { + approveAllFollowRequests, + approveFollowRequest, + followProfile, + matchContacts, + rejectFollowRequest, + unfollowProfile, +} from "./mutations"; + +export const socialController = new Elysia({ + name: "Social.Controller", + prefix: "/social", +}) + .use(authGuard) + .use(SocialModel) + // Static routes are declared before `/profiles/:profileId` so they win the match. + .get( + "/profiles/search", + ({ user, query }) => searchProfiles(user.id, query.q, query.limit, query.offset), + { + auth: true, + query: "social.SearchQuery", + response: { 200: "social.ProfileList" }, + }, + ) + .get("/activity", ({ user, query }) => getFollowingActivity(user.id, query.limit, query.offset), { + auth: true, + query: "social.PageQuery", + response: { 200: "social.ActivityList" }, + }) + .get("/requests", ({ user, query }) => getIncomingRequests(user.id, query.limit, query.offset), { + auth: true, + query: "social.PageQuery", + response: { 200: "social.RequestList" }, + }) + .post("/requests/approve-all", ({ user }) => approveAllFollowRequests(user.id), { + auth: true, + response: { 200: "social.ApproveAllResponse" }, + }) + .post( + "/requests/:requestId/approve", + ({ user, params }) => approveFollowRequest(user.id, params.requestId), + { + auth: true, + response: { 200: "social.OkResponse" }, + }, + ) + .post( + "/requests/:requestId/reject", + ({ user, params }) => rejectFollowRequest(user.id, params.requestId), + { + auth: true, + response: { 200: "social.OkResponse" }, + }, + ) + .post("/contacts/match", ({ user, body }) => matchContacts(user.id, body.identifiers), { + auth: true, + body: "social.ContactsMatchBody", + response: { 200: "social.ContactMatchList" }, + }) + .get("/profiles/:profileId", ({ user, params }) => getSocialProfile(user.id, params.profileId), { + auth: true, + response: { 200: "social.Profile" }, + }) + .get( + "/profiles/:profileId/activity", + ({ user, params, query }) => + getSocialProfileActivity(user.id, params.profileId, query.limit, query.offset), + { + auth: true, + query: "social.PageQuery", + response: { 200: "social.ActivityList" }, + }, + ) + .get( + "/profiles/:profileId/watchlist", + ({ user, params, query }) => + getSocialProfileWatchlist(user.id, params.profileId, query.limit, query.offset), + { + auth: true, + query: "social.PageQuery", + response: { 200: "social.WatchlistPage" }, + }, + ) + .get( + "/profiles/:profileId/followers", + ({ user, params, query }) => getFollowers(user.id, params.profileId, query.limit, query.offset), + { + auth: true, + query: "social.PageQuery", + response: { 200: "social.ProfileList" }, + }, + ) + .get( + "/profiles/:profileId/following", + ({ user, params, query }) => getFollowing(user.id, params.profileId, query.limit, query.offset), + { + auth: true, + query: "social.PageQuery", + response: { 200: "social.ProfileList" }, + }, + ) + .post( + "/profiles/:profileId/follow", + ({ user, params }) => followProfile(user.id, params.profileId), + { + auth: true, + response: { 200: "social.FollowResult" }, + }, + ) + .delete( + "/profiles/:profileId/follow", + ({ user, params }) => unfollowProfile(user.id, params.profileId), + { + auth: true, + response: { 200: "social.UnfollowResult" }, + }, + ); diff --git a/apps/api/src/modules/social/shared.ts b/apps/api/src/modules/social/shared.ts new file mode 100644 index 0000000..45183a0 --- /dev/null +++ b/apps/api/src/modules/social/shared.ts @@ -0,0 +1,201 @@ +import { db } from "@seen/db"; +import { follows, followRequests, profiles } from "@seen/db/schema"; +import { and, count, desc, eq, inArray } from "@seen/db/orm"; + +import { HttpError } from "../../lib/http-error"; + +export type ProfileRow = typeof profiles.$inferSelect; + +export type RequestStatus = "none" | "pending" | "rejected"; + +export type ViewerState = { + isFollowing: boolean; + followsMe: boolean; + requestStatus: RequestStatus; +}; + +function defaultViewerState(): ViewerState { + return { isFollowing: false, followsMe: false, requestStatus: "none" }; +} + +export function getViewerState(states: Map, profileId: string): ViewerState { + return states.get(profileId) ?? defaultViewerState(); +} + +export function normalizePagination(limit: number, offset: number, max = 50) { + return { + pageSize: Math.max(1, Math.min(max, limit)), + offset: Math.max(0, offset), + }; +} + +// The viewer's relationship to each of `targetIds`, resolved in three batched +// reads regardless of how many targets are passed. +export async function getViewerStates( + viewerId: string, + targetIds: string[], +): Promise> { + const states = new Map(); + const ids = [...new Set(targetIds)].filter((id) => id !== viewerId); + for (const id of targetIds) { + states.set(id, defaultViewerState()); + } + if (ids.length === 0) return states; + + const [followingRows, followerRows, requestRows] = await Promise.all([ + db + .select({ id: follows.followeeId }) + .from(follows) + .where(and(eq(follows.followerId, viewerId), inArray(follows.followeeId, ids))), + db + .select({ id: follows.followerId }) + .from(follows) + .where(and(eq(follows.followeeId, viewerId), inArray(follows.followerId, ids))), + db + .select({ id: followRequests.targetId, status: followRequests.status }) + .from(followRequests) + .where(and(eq(followRequests.requesterId, viewerId), inArray(followRequests.targetId, ids))), + ]); + + for (const row of followingRows) { + const state = states.get(row.id); + if (state) state.isFollowing = true; + } + for (const row of followerRows) { + const state = states.get(row.id); + if (state) state.followsMe = true; + } + for (const row of requestRows) { + const state = states.get(row.id); + if (state && (row.status === "pending" || row.status === "rejected")) { + state.requestStatus = row.status; + } + } + return states; +} + +export async function loadProfileRow(profileId: string): Promise { + const [row] = await db.select().from(profiles).where(eq(profiles.id, profileId)).limit(1); + if (!row) throw new HttpError(404, "Profile not found.", "profile-not-found"); + return row; +} + +// Whether `viewerId` may see the profile's detail (activity, reviews). Owner and +// `public` profiles are always visible; `followers` profiles need a live follow. +export function canViewProfileDetail( + viewerId: string, + row: ProfileRow, + state: ViewerState, +): boolean { + if (viewerId === row.id) return true; + if (row.profileVisibility === "public") return true; + return state.isFollowing; +} + +export function assertCanViewProfileDetail( + viewerId: string, + row: ProfileRow, + state: ViewerState, +): void { + if (!canViewProfileDetail(viewerId, row, state)) { + throw new HttpError(403, "This profile is private.", "profile-locked"); + } +} + +export async function loadViewableProfile(viewerId: string, profileId: string) { + const row = await loadProfileRow(profileId); + const states = await getViewerStates(viewerId, [profileId]); + const state = getViewerState(states, profileId); + assertCanViewProfileDetail(viewerId, row, state); + return { row, state }; +} + +export async function listProfileConnections( + viewerId: string, + profileId: string, + kind: "followers" | "following", + limit = 20, + offset = 0, +) { + await loadViewableProfile(viewerId, profileId); + + const { pageSize, offset: from } = normalizePagination(limit, offset); + const joinedProfileId = kind === "followers" ? follows.followerId : follows.followeeId; + const targetProfileId = kind === "followers" ? follows.followeeId : follows.followerId; + + const rows = await db + .select({ profile: profiles }) + .from(follows) + .innerJoin(profiles, eq(profiles.id, joinedProfileId)) + .where(eq(targetProfileId, profileId)) + .orderBy(desc(follows.createdAt)) + .limit(pageSize) + .offset(from); + + return buildProfileCards( + viewerId, + rows.map((entry) => entry.profile), + ); +} + +// Watchlist rows carry their own visibility on top of the profile's. Owner sees +// everything; others see `public` always and `followers` only when following. +export function canViewWatchlistVisibility( + visibility: string, + viewerId: string, + ownerId: string, + isFollowing: boolean, +): boolean { + if (viewerId === ownerId) return true; + if (visibility === "public") return true; + if (visibility === "followers") return isFollowing; + return false; +} + +export async function getFollowCounts( + profileId: string, +): Promise<{ followers: number; following: number }> { + const [followers, following] = await Promise.all([ + db.select({ value: count() }).from(follows).where(eq(follows.followeeId, profileId)), + db.select({ value: count() }).from(follows).where(eq(follows.followerId, profileId)), + ]); + return { + followers: followers[0]?.value ?? 0, + following: following[0]?.value ?? 0, + }; +} + +export function toProfileCard(row: ProfileRow, viewerId: string, state: ViewerState) { + return { + id: row.id, + username: row.username, + full_name: row.fullName, + avatar_path: row.avatarPath, + is_me: row.id === viewerId, + is_following: state.isFollowing, + follows_me: state.followsMe, + request_status: state.requestStatus, + }; +} + +export async function buildProfileDetail(viewerId: string, row: ProfileRow) { + const states = await getViewerStates(viewerId, [row.id]); + const state = getViewerState(states, row.id); + const counts = await getFollowCounts(row.id); + return { + ...toProfileCard(row, viewerId, state), + follow_policy: row.followPolicy as "open" | "approval_required", + profile_visibility: row.profileVisibility as "public" | "followers", + followers_count: counts.followers, + following_count: counts.following, + locked: !canViewProfileDetail(viewerId, row, state), + }; +} + +export async function buildProfileCards(viewerId: string, rows: ProfileRow[]) { + const states = await getViewerStates( + viewerId, + rows.map((row) => row.id), + ); + return rows.map((row) => toProfileCard(row, viewerId, getViewerState(states, row.id))); +} diff --git a/apps/api/src/modules/watchlist/model.ts b/apps/api/src/modules/watchlist/model.ts index c04b100..71e0635 100644 --- a/apps/api/src/modules/watchlist/model.ts +++ b/apps/api/src/modules/watchlist/model.ts @@ -18,13 +18,15 @@ const summary = t.Object({ genre_ids: t.Optional(t.Array(t.Number())), }); +const visibility = t.Union([t.Literal("private"), t.Literal("followers"), t.Literal("public")]); + const item = t.Object({ id: t.String(), user_id: t.String(), tmdb_id: t.Number(), media_type: mediaType, added_at: t.String(), - visibility: t.Literal("private"), + visibility, }); const itemWithMedia = t.Composite([ diff --git a/apps/api/src/modules/watchlist/mutations/add.ts b/apps/api/src/modules/watchlist/mutations/add.ts index e123396..a61890d 100644 --- a/apps/api/src/modules/watchlist/mutations/add.ts +++ b/apps/api/src/modules/watchlist/mutations/add.ts @@ -1,20 +1,33 @@ import { db } from "@seen/db"; -import { watchlist } from "@seen/db/schema"; +import { profiles, watchlist } from "@seen/db/schema"; +import { eq } from "@seen/db/orm"; import { HttpError } from "../../../lib/http-error"; import { getMediaDetail } from "../../tmdb"; import type { WatchlistInput } from "../shared"; import { toWatchlistItem, watchlistMediaWhere } from "../shared"; +async function defaultWatchlistVisibility(userId: string) { + const [row] = await db + .select({ visibility: profiles.defaultWatchlistVisibility }) + .from(profiles) + .where(eq(profiles.id, userId)) + .limit(1); + return row?.visibility ?? "private"; +} + export async function addToWatchlist(userId: string, input: WatchlistInput) { await getMediaDetail(input.media_type, input.tmdb_id); + const visibility = await defaultWatchlistVisibility(userId); + const [inserted] = await db .insert(watchlist) .values({ userId, tmdbId: input.tmdb_id, mediaType: input.media_type, + visibility, }) .onConflictDoNothing({ target: [watchlist.userId, watchlist.tmdbId, watchlist.mediaType], diff --git a/apps/api/src/modules/watchlist/shared.ts b/apps/api/src/modules/watchlist/shared.ts index b71fe57..105347f 100644 --- a/apps/api/src/modules/watchlist/shared.ts +++ b/apps/api/src/modules/watchlist/shared.ts @@ -8,6 +8,8 @@ export type WatchlistInput = { media_type: MediaType; }; +export type WatchlistVisibility = "private" | "followers" | "public"; + export function watchlistMediaWhere(userId: string, tmdbId: number, mediaType: MediaType) { return and( eq(watchlist.userId, userId), @@ -41,7 +43,7 @@ export function toWatchlistItem(row: typeof watchlist.$inferSelect) { tmdb_id: row.tmdbId, media_type: row.mediaType as MediaType, added_at: row.addedAt.toISOString(), - visibility: "private" as const, + visibility: row.visibility as WatchlistVisibility, }; } diff --git a/apps/mobile/app.json b/apps/mobile/app.json index fc91ab9..aa3d5fd 100644 --- a/apps/mobile/app.json +++ b/apps/mobile/app.json @@ -53,6 +53,12 @@ ], "expo-secure-store", "expo-sqlite", + [ + "expo-contacts", + { + "contactsPermission": "Seen uses your contacts to help you find friends who are already on Seen. Your contacts are matched privately and never stored in plain text." + } + ], [ "expo-localization", { diff --git a/apps/mobile/package.json b/apps/mobile/package.json index e165364..76e81a3 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -17,6 +17,7 @@ "expo-apple-authentication": "~56.0.4", "expo-blur": "~56.0.3", "expo-constants": "~56.0.16", + "expo-contacts": "~56.0.8", "expo-crypto": "~56.0.4", "expo-dev-client": "~56.0.18", "expo-device": "~56.0.4", diff --git a/apps/mobile/src/app/(tabs)/profile/_layout.tsx b/apps/mobile/src/app/(tabs)/profile/_layout.tsx index 24da451..def1738 100644 --- a/apps/mobile/src/app/(tabs)/profile/_layout.tsx +++ b/apps/mobile/src/app/(tabs)/profile/_layout.tsx @@ -49,6 +49,46 @@ export default function ProfileLayout() { contentStyle: { backgroundColor: theme.backgroundElement }, }} /> + + + + + ; +} diff --git a/apps/mobile/src/app/(tabs)/profile/social/[profileId].tsx b/apps/mobile/src/app/(tabs)/profile/social/[profileId].tsx new file mode 100644 index 0000000..a6985b9 --- /dev/null +++ b/apps/mobile/src/app/(tabs)/profile/social/[profileId].tsx @@ -0,0 +1,5 @@ +import { SocialProfile } from "@/components/screens/social/social-profile"; + +export default function SocialProfileRoute() { + return ; +} diff --git a/apps/mobile/src/app/(tabs)/profile/social/connections.tsx b/apps/mobile/src/app/(tabs)/profile/social/connections.tsx new file mode 100644 index 0000000..906dd26 --- /dev/null +++ b/apps/mobile/src/app/(tabs)/profile/social/connections.tsx @@ -0,0 +1,5 @@ +import { Connections } from "@/components/screens/social/connections"; + +export default function ConnectionsRoute() { + return ; +} diff --git a/apps/mobile/src/app/(tabs)/profile/social/requests.tsx b/apps/mobile/src/app/(tabs)/profile/social/requests.tsx new file mode 100644 index 0000000..b4c2d1d --- /dev/null +++ b/apps/mobile/src/app/(tabs)/profile/social/requests.tsx @@ -0,0 +1,5 @@ +import { FollowRequests } from "@/components/screens/social/follow-requests"; + +export default function FollowRequestsRoute() { + return ; +} diff --git a/apps/mobile/src/app/(tabs)/profile/social/search.tsx b/apps/mobile/src/app/(tabs)/profile/social/search.tsx new file mode 100644 index 0000000..de36374 --- /dev/null +++ b/apps/mobile/src/app/(tabs)/profile/social/search.tsx @@ -0,0 +1,5 @@ +import { FindFriends } from "@/components/screens/social/find-friends"; + +export default function FindFriendsRoute() { + return ; +} diff --git a/apps/mobile/src/components/screens/profile/edit-profile-form.tsx b/apps/mobile/src/components/screens/profile/edit-profile-form.tsx index ea4d248..3a12201 100644 --- a/apps/mobile/src/components/screens/profile/edit-profile-form.tsx +++ b/apps/mobile/src/components/screens/profile/edit-profile-form.tsx @@ -33,8 +33,9 @@ import { type Profile, } from "@/services/profiles"; +import { ProfileAvatar } from "@/components/ui/profile-avatar"; + import { EditSheetScaffold } from "./edit-sheet-scaffold"; -import { ProfileAvatar } from "./profile-avatar"; function profileErrorMessage( error: unknown, diff --git a/apps/mobile/src/components/screens/profile/index.tsx b/apps/mobile/src/components/screens/profile/index.tsx index b9f5757..fe52492 100644 --- a/apps/mobile/src/components/screens/profile/index.tsx +++ b/apps/mobile/src/components/screens/profile/index.tsx @@ -6,21 +6,31 @@ import type { NativeScrollEvent, NativeSyntheticEvent } from "react-native"; import { ActivityIndicator, Pressable, ScrollView, StyleSheet, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { GlassButton } from "@/components/ui/button"; +import { Button, GlassButton } from "@/components/ui/button"; import { EmptyState } from "@/components/ui/empty-state"; +import { ProfileAvatar } from "@/components/ui/profile-avatar"; import { Text } from "@/components/ui/text"; import { BottomTabInset } from "@/constants/theme"; import { BORDER_RADIUS, LAYOUT, OPACITY, SPACING } from "@/constants/design-tokens"; import { useProfileActivity } from "@/hooks/profiles/use-profile-activity"; import { useMyProfile } from "@/hooks/profiles/use-my-profile"; import { useAccentColor } from "@/hooks/use-accent-color"; +import { useAuthContext } from "@/hooks/use-auth-context"; +import { useFollowRequests } from "@/hooks/social/use-follow-requests"; +import { useSocialProfile } from "@/hooks/social/use-social-profile"; import { useTheme } from "@/hooks/use-theme"; -import { hapticTap } from "@/lib/haptics"; +import { hapticSelection, hapticTap } from "@/lib/haptics"; +import { + connectionsHref, + findFriendsHref, + followRequestsHref, + privacyHref, + type ConnectionsKind, +} from "@/lib/navigation"; import { profileAvatarUrl } from "@/services/profiles"; import { ActivityRow } from "./activity-row"; import { FavoritesSection } from "./favorites-section"; -import { ProfileAvatar } from "./profile-avatar"; export function ProfileScreen() { const { t } = useTranslation(); @@ -28,16 +38,23 @@ export function ProfileScreen() { const theme = useTheme(); const insets = useSafeAreaInsets(); const { accentHex } = useAccentColor(); + const { user } = useAuthContext(); const profile = useMyProfile(); const activity = useProfileActivity(); + const social = useSocialProfile(user?.id); + const requests = useFollowRequests(); const refetchProfile = profile.refetch; const refetchActivity = activity.refetch; + const refetchSocial = social.refetch; + const refetchRequests = requests.refetch; useFocusEffect( useCallback(() => { refetchProfile(); refetchActivity(); - }, [refetchActivity, refetchProfile]), + refetchSocial(); + refetchRequests(); + }, [refetchActivity, refetchProfile, refetchSocial, refetchRequests]), ); const avatarUri = profileAvatarUrl(profile.data); @@ -55,6 +72,33 @@ export function ProfileScreen() { router.push("/profile/settings"); }, [router]); + const openFindFriends = useCallback(() => { + hapticTap(); + router.push(findFriendsHref()); + }, [router]); + + const openRequests = useCallback(() => { + hapticTap(); + router.push(followRequestsHref()); + }, [router]); + + const openPrivacy = useCallback(() => { + hapticTap(); + router.push(privacyHref()); + }, [router]); + + const openConnections = useCallback( + (kind: ConnectionsKind) => { + if (!social.data) return; + hapticSelection(); + const title = kind === "followers" ? t("social.followersTitle") : t("social.followingTitle"); + router.push(connectionsHref(social.data.id, kind, title)); + }, + [router, social.data, t], + ); + + const pendingRequests = requests.data.length; + const loadMore = activity.loadMore; const handleScroll = useCallback( (event: NativeSyntheticEvent) => { @@ -68,6 +112,9 @@ export function ProfileScreen() { return ( <> + + {t("social.findFriends")} + {t("account.settings")} @@ -105,7 +152,54 @@ export function ProfileScreen() { ) : null} - + {social.data ? ( + + openConnections("followers")}> + + {String(social.data.followers_count)} + + + {t("social.followers")} + + + openConnections("following")}> + + {String(social.data.following_count)} + + + {t("social.followingTitle")} + + + + ) : null} + + + + + +