From 10d3f2965bcec40a3a745f0bcf3d35f345cf60b8 Mon Sep 17 00:00:00 2001 From: Mehdi Asadli Date: Wed, 29 Apr 2026 11:19:47 +0400 Subject: [PATCH] feat: implement patch 19 --- apps/web/src/routes/u/$username.tsx | 307 +++++++++++++++++- draft.md | 60 ++++ packages/api/src/modules/user/router.ts | 56 ++++ packages/api/src/modules/user/service.ts | 220 +++++++++++++ .../migration.sql | 31 ++ packages/db/prisma/schema/auth.prisma | 32 ++ .../db/schemas/enums/FollowStatus.schema.ts | 5 + .../enums/UserFollowScalarFieldEnum.schema.ts | 5 + .../enums/UserScalarFieldEnum.schema.ts | 2 +- .../src/db/schemas/models/User.schema.ts | 2 + .../db/schemas/models/UserFollow.schema.ts | 12 + .../schemas/src/db/schemas/models/index.ts | 1 + packages/schemas/src/modules/user.ts | 66 ++++ packages/utils/src/app-releases.ts | 20 +- 14 files changed, 813 insertions(+), 6 deletions(-) create mode 100644 packages/db/prisma/migrations/20260430120000_patch_19_user_follow/migration.sql create mode 100644 packages/schemas/src/db/schemas/enums/FollowStatus.schema.ts create mode 100644 packages/schemas/src/db/schemas/enums/UserFollowScalarFieldEnum.schema.ts create mode 100644 packages/schemas/src/db/schemas/models/UserFollow.schema.ts diff --git a/apps/web/src/routes/u/$username.tsx b/apps/web/src/routes/u/$username.tsx index cd9d989..e9cc701 100644 --- a/apps/web/src/routes/u/$username.tsx +++ b/apps/web/src/routes/u/$username.tsx @@ -1,4 +1,9 @@ -import { useInfiniteQuery, useQuery } from "@tanstack/react-query"; +import { + useInfiniteQuery, + useMutation, + useQuery, + useQueryClient, +} from "@tanstack/react-query"; import { createFileRoute, Link, notFound } from "@tanstack/react-router"; import { Badge } from "@xamsa/ui/components/badge"; import { Button } from "@xamsa/ui/components/button"; @@ -9,6 +14,13 @@ import { CardPanel, CardTitle, } from "@xamsa/ui/components/card"; +import { + Dialog, + DialogHeader, + DialogPanel, + DialogPopup, + DialogTitle, +} from "@xamsa/ui/components/dialog"; import { Spinner } from "@xamsa/ui/components/spinner"; import { getLevelProgress } from "@xamsa/utils/levels"; import { format, parse } from "date-fns"; @@ -29,6 +41,8 @@ import { TargetIcon, TimerOffIcon, TrophyIcon, + UserMinusIcon, + UserPlusIcon, XCircleIcon, ZapIcon, } from "lucide-react"; @@ -59,6 +73,7 @@ import { isStaffRole } from "@/lib/staff"; import { orpc } from "@/utils/orpc"; const HISTORY_PAGE = 15; +const FOLLOW_LIST_PAGE = 20; export const Route = createFileRoute("/u/$username")({ component: RouteComponent, @@ -131,9 +146,18 @@ function formatPlayTimeSeconds(totalSeconds: number): string { function RouteComponent() { const { username } = Route.useParams(); - const { profile, user, isOwner } = Route.useLoaderData(); + const { profile: loaderProfile, user, isOwner } = Route.useLoaderData(); + const qc = useQueryClient(); const showStaffDashboard = isOwner && isStaffRole(user?.role); const [isLoggingOut, setIsLoggingOut] = useState(false); + const [followDialog, setFollowDialog] = useState< + null | "followers" | "following" + >(null); + + const { data: profile } = useQuery({ + ...orpc.user.findOne.queryOptions({ input: { username } }), + initialData: loaderProfile, + }); const { data: publicStats } = useQuery( orpc.user.getPublicStats.queryOptions({ input: { username } }), @@ -172,9 +196,97 @@ function RouteComponent() { }), }); + const { data: followState } = useQuery({ + ...orpc.user.getFollowState.queryOptions({ input: { username } }), + enabled: Boolean(user && !isOwner), + }); + + const { + data: followersPages, + fetchNextPage: fetchNextFollowers, + hasNextPage: hasNextFollowers, + isFetchingNextPage: isFetchingNextFollowers, + isLoading: followersLoading, + } = useInfiniteQuery({ + ...orpc.user.listFollowers.infiniteOptions({ + input: (pageParam: string | undefined) => ({ + username, + cursor: pageParam, + limit: FOLLOW_LIST_PAGE, + }), + getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined, + initialPageParam: undefined as string | undefined, + }), + enabled: followDialog === "followers", + }); + + const { + data: followingPages, + fetchNextPage: fetchNextFollowing, + hasNextPage: hasNextFollowing, + isFetchingNextPage: isFetchingNextFollowing, + isLoading: followingLoading, + } = useInfiniteQuery({ + ...orpc.user.listFollowing.infiniteOptions({ + input: (pageParam: string | undefined) => ({ + username, + cursor: pageParam, + limit: FOLLOW_LIST_PAGE, + }), + getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined, + initialPageParam: undefined as string | undefined, + }), + enabled: followDialog === "following", + }); + + const invalidateFollowRelated = () => { + void qc.invalidateQueries({ + queryKey: orpc.user.findOne.queryKey({ input: { username } }), + }); + void qc.invalidateQueries({ + queryKey: orpc.user.getFollowState.queryKey({ input: { username } }), + }); + void qc.invalidateQueries({ + queryKey: orpc.user.listFollowers.queryKey({ + input: { username, limit: FOLLOW_LIST_PAGE }, + }), + }); + void qc.invalidateQueries({ + queryKey: orpc.user.listFollowing.queryKey({ + input: { username, limit: FOLLOW_LIST_PAGE }, + }), + }); + }; + + const { mutate: followMut, isPending: isFollowPending } = useMutation({ + ...orpc.user.follow.mutationOptions(), + onSuccess() { + invalidateFollowRelated(); + toast.success("You are now following this player"); + }, + onError(error) { + toast.error(error.message || "Could not follow"); + }, + }); + + const { mutate: unfollowMut, isPending: isUnfollowPending } = useMutation({ + ...orpc.user.unfollow.mutationOptions(), + onSuccess() { + invalidateFollowRelated(); + toast.success("Unfollowed"); + }, + onError(error) { + toast.error(error.message || "Could not unfollow"); + }, + }); + const gameRows = gamesData?.pages.flatMap((p) => p.items) ?? []; const packRows = packsData?.items ?? []; const sentinelRef = useRef(null); + const followSentinelRef = useRef(null); + + const followerRows = followersPages?.pages.flatMap((p) => p.items) ?? []; + const followingRows = followingPages?.pages.flatMap((p) => p.items) ?? []; useEffect(() => { if (!sentinelRef.current) return; @@ -190,6 +302,38 @@ function RouteComponent() { observer.observe(el); return () => observer.disconnect(); }, [hasNextPage, isFetchingNextPage, fetchNextPage]); + + useEffect(() => { + if (followDialog === null) return; + const el = followSentinelRef.current; + if (!el) return; + const hasNext = + followDialog === "followers" ? hasNextFollowers : hasNextFollowing; + const isFetching = + followDialog === "followers" + ? isFetchingNextFollowers + : isFetchingNextFollowing; + const fetchNext = + followDialog === "followers" ? fetchNextFollowers : fetchNextFollowing; + const observer = new IntersectionObserver( + (entries) => { + if (entries[0]?.isIntersecting && hasNext && !isFetching) { + fetchNext(); + } + }, + { threshold: 0 }, + ); + observer.observe(el); + return () => observer.disconnect(); + }, [ + followDialog, + hasNextFollowers, + hasNextFollowing, + isFetchingNextFollowers, + isFetchingNextFollowing, + fetchNextFollowers, + fetchNextFollowing, + ]); const levelProgress = getLevelProgress(profile.xp); const xpToNext = !levelProgress.isMaxLevel && levelProgress.xpForCurrentLevel > 0 @@ -248,9 +392,31 @@ function RouteComponent() { )}

@{profile.username}

+
+ + +
- {isOwner && ( + {isOwner ? (
{showStaffDashboard ? (
- )} + ) : user ? ( +
+ {followState === undefined ? ( + + ) : followState.isFollowing ? ( + unfollowMut({ username })} + > + + Unfollow + + ) : ( + followMut({ username })} + > + + Follow + + )} +
+ ) : null} @@ -629,6 +822,112 @@ function RouteComponent() { )} + + { + if (!open) setFollowDialog(null); + }} + > + + + + {followDialog === "followers" + ? "Followers" + : followDialog === "following" + ? "Following" + : ""} + + + + {followDialog + ? (() => { + const rows = + followDialog === "followers" ? followerRows : followingRows; + const loading = + followDialog === "followers" + ? followersLoading + : followingLoading; + const fetchingNext = + followDialog === "followers" + ? isFetchingNextFollowers + : isFetchingNextFollowing; + + if (loading && rows.length === 0) { + return ( +
+ +
+ ); + } + + if (rows.length === 0) { + return ( +

+ {followDialog === "followers" + ? "No followers yet." + : "Not following anyone yet."} +

+ ); + } + + return ( + <> +
    + {rows.map((row) => { + const rowInitials = row.name + .split(" ") + .map((part) => part[0]) + .slice(0, 2) + .join("") + .toUpperCase(); + return ( +
  • + setFollowDialog(null)} + > +
    + {row.image ? ( + {row.name} + ) : ( +
    + {rowInitials} +
    + )} +
    +
    +

    + {row.name} +

    +

    + @{row.username} +

    +
    + +
  • + ); + })} +
+
+ {fetchingNext ? ( +
+ +
+ ) : null} + + ); + })() + : null} + + +
); } diff --git a/draft.md b/draft.md index 767d245..0f28296 100644 --- a/draft.md +++ b/draft.md @@ -118,6 +118,66 @@ Then introduce a new field `Game.totalHostSkippedQuestions: Int @default(0)` to ## v26.04.19 +- Introduce following system +- Add UserFollow table. + +something like this: + +```prisma +enum FollowStatus { + accepted // the following user has accepted the follow request, currently active follow +} + +// note: for now, there is only accepted status, follows are automatic +// in future, we will add other statuses like pending, rejected, etc. + +model UserFollow { + id String @id @default(uuid()) + createdAt DateTime @default(now()) @map("created_at") + + status FollowStatus @default(accepted) + + followerId String @map("follower_id") + followingId String @map("following_id") + + follower User @relation("UserFollows", fields: [followerId], references: [id], onDelete: Cascade) + following User @relation("UserFollowers", fields: [followingId], references: [id], onDelete: Cascade) + + @@unique([followerId, followingId]) + @@index([followerId]) + @@index([followingId]) + @@map("user_follow") +} +``` + +add this to User model: + +```prisma + /// follow relationships /// + totalFollowers Int @default(0) @map("total_followers") + totalFollowing Int @default(0) @map("total_following") + + /// Users this user is following (this user is the follower) + following UserFollow[] @relation("UserFollows") + /// Users following this user (this user is being followed) + followers UserFollow[] @relation("UserFollowers") + + // ...rest stays the same... +``` + +Anti-patterns to avoid: + +- do not forget self-follow prevention +- do not forget to update totalFollowers and totalFollowing when following/unfollowing + +Notes: + +- Implement database changes +- Add follow/unfollow services to api +- Add follow/unfollow buttons on user profile page (if authorized, and not self) +- Add total followers and total following counts on user profile page (when clicking on them it should open a list of followers/following people with cursor pagination) +- Currently follows have no implementation further. In future, we will add features for follows. Like home page feed, notifications, follower only packs, etc. + ### UNKNOWN VERSIONS: - Add non-host games where host is computer-controlled (AI). It will be able to, control the game flow, and validate the answers (answers will be inputted by AI, and it will be validated by AI). diff --git a/packages/api/src/modules/user/router.ts b/packages/api/src/modules/user/router.ts index 4e58204..8005527 100644 --- a/packages/api/src/modules/user/router.ts +++ b/packages/api/src/modules/user/router.ts @@ -1,7 +1,11 @@ import { FindOneProfileInputSchema, FindOneProfileOutputSchema, + FollowUserInputSchema, + FollowUserOutputSchema, GetActiveGameOutputSchema, + GetFollowStateInputSchema, + GetFollowStateOutputSchema, GetGlobalLeaderboardInputSchema, GetGlobalLeaderboardOutputSchema, GetMyStatsOutputSchema, @@ -13,6 +17,12 @@ import { GetPublicStatsOutputSchema, GetRecentGamesInputSchema, GetRecentGamesOutputSchema, + ListFollowersInputSchema, + ListFollowersOutputSchema, + ListFollowingInputSchema, + ListFollowingOutputSchema, + UnfollowUserInputSchema, + UnfollowUserOutputSchema, UpdateProfileInputSchema, UpdateProfileOutputSchema, } from "@xamsa/schemas/modules/user"; @@ -20,12 +30,17 @@ import { protectedProcedure, publicProcedure } from "../../procedures"; import { getGlobalLeaderboard } from "./global-leaderboard"; import { findOneProfile, + followUser, getActiveGame, + getFollowState, getMyStats, getPublicGameActivity, getPublicRecentGames, getPublicStats, getRecentGames, + listFollowers, + listFollowing, + unfollowUser, updateProfile, } from "./service"; @@ -72,4 +87,45 @@ export const userRouter = { async ({ input, context }) => await getRecentGames(input, context.session.user.id), ), + getFollowState: protectedProcedure + .input(GetFollowStateInputSchema) + .output(GetFollowStateOutputSchema) + .handler( + async ({ input, context }) => + await getFollowState( + input, + context.session.user.id, + context.session.user.username, + ), + ), + follow: protectedProcedure + .input(FollowUserInputSchema) + .output(FollowUserOutputSchema) + .handler( + async ({ input, context }) => + await followUser( + input, + context.session.user.id, + context.session.user.username, + ), + ), + unfollow: protectedProcedure + .input(UnfollowUserInputSchema) + .output(UnfollowUserOutputSchema) + .handler( + async ({ input, context }) => + await unfollowUser( + input, + context.session.user.id, + context.session.user.username, + ), + ), + listFollowers: publicProcedure + .input(ListFollowersInputSchema) + .output(ListFollowersOutputSchema) + .handler(async ({ input }) => await listFollowers(input)), + listFollowing: publicProcedure + .input(ListFollowingInputSchema) + .output(ListFollowingOutputSchema) + .handler(async ({ input }) => await listFollowing(input)), }; diff --git a/packages/api/src/modules/user/service.ts b/packages/api/src/modules/user/service.ts index 4f4c41c..e4226c8 100644 --- a/packages/api/src/modules/user/service.ts +++ b/packages/api/src/modules/user/service.ts @@ -3,7 +3,11 @@ import prisma from "@xamsa/db"; import type { FindOneProfileInputType, FindOneProfileOutputType, + FollowUserInputType, + FollowUserOutputType, GetActiveGameOutputType, + GetFollowStateInputType, + GetFollowStateOutputType, GetMyStatsOutputType, GetPublicGameActivityInputType, GetPublicGameActivityOutputType, @@ -13,7 +17,13 @@ import type { GetPublicStatsOutputType, GetRecentGamesInputType, GetRecentGamesOutputType, + ListFollowersInputType, + ListFollowersOutputType, + ListFollowingInputType, + ListFollowingOutputType, RecentGameRow, + UnfollowUserInputType, + UnfollowUserOutputType, UpdateProfileInputType, UpdateProfileOutputType, } from "@xamsa/schemas/modules/user"; @@ -35,6 +45,8 @@ export async function findOneProfile( elo: true, peakElo: true, lowestElo: true, + totalFollowers: true, + totalFollowing: true, }, }); @@ -332,3 +344,211 @@ export async function getPublicRecentGames( const userId = await requireUserIdByUsername(username); return getRecentGames(pagination, userId); } + +function isPrismaUniqueViolation(err: unknown): boolean { + return ( + typeof err === "object" && + err !== null && + "code" in err && + (err as { code: string }).code === "P2002" + ); +} + +export async function getFollowState( + input: GetFollowStateInputType, + followerId: string, + followerUsername: string, +): Promise { + if (input.username === followerUsername) { + return { isFollowing: false }; + } + const followingId = await requireUserIdByUsername(input.username); + const row = await prisma.userFollow.findFirst({ + where: { + followerId, + followingId, + status: "accepted", + }, + select: { id: true }, + }); + return { isFollowing: row !== null }; +} + +export async function followUser( + input: FollowUserInputType, + followerId: string, + followerUsername: string, +): Promise { + if (input.username === followerUsername) { + throw new ORPCError("BAD_REQUEST", { + message: "You cannot follow yourself", + }); + } + + const target = await prisma.user.findUnique({ + where: { username: input.username }, + select: { id: true }, + }); + if (!target) { + throw new ORPCError("NOT_FOUND", { + message: "Profile not found", + }); + } + + try { + await prisma.$transaction(async (tx) => { + const existing = await tx.userFollow.findUnique({ + where: { + followerId_followingId: { + followerId, + followingId: target.id, + }, + }, + select: { id: true }, + }); + if (existing) return; + + await tx.userFollow.create({ + data: { + followerId, + followingId: target.id, + status: "accepted", + }, + }); + await tx.user.update({ + where: { id: followerId }, + data: { totalFollowing: { increment: 1 } }, + }); + await tx.user.update({ + where: { id: target.id }, + data: { totalFollowers: { increment: 1 } }, + }); + }); + } catch (err) { + if (!isPrismaUniqueViolation(err)) throw err; + } + + return { ok: true as const }; +} + +export async function unfollowUser( + input: UnfollowUserInputType, + followerId: string, + followerUsername: string, +): Promise { + if (input.username === followerUsername) { + throw new ORPCError("BAD_REQUEST", { + message: "You cannot unfollow yourself", + }); + } + + const target = await prisma.user.findUnique({ + where: { username: input.username }, + select: { id: true }, + }); + if (!target) { + throw new ORPCError("NOT_FOUND", { + message: "Profile not found", + }); + } + + await prisma.$transaction(async (tx) => { + const deleted = await tx.userFollow.deleteMany({ + where: { + followerId, + followingId: target.id, + status: "accepted", + }, + }); + if (deleted.count === 0) return; + + await tx.user.update({ + where: { id: followerId }, + data: { totalFollowing: { decrement: 1 } }, + }); + await tx.user.update({ + where: { id: target.id }, + data: { totalFollowers: { decrement: 1 } }, + }); + }); + + return { ok: true as const }; +} + +export async function listFollowers( + input: ListFollowersInputType, +): Promise { + const limit = input.limit; + const profileId = await requireUserIdByUsername(input.username); + + const rows = await prisma.userFollow.findMany({ + where: { + followingId: profileId, + status: "accepted", + }, + orderBy: [{ createdAt: "desc" }, { id: "desc" }], + take: limit + 1, + ...(input.cursor ? { cursor: { id: input.cursor }, skip: 1 } : {}), + select: { + id: true, + follower: { + select: { username: true, name: true, image: true }, + }, + }, + }); + + const hasNext = rows.length > limit; + const pageRows = hasNext ? rows.slice(0, limit) : rows; + + const items = pageRows.map((r) => ({ + username: r.follower.username, + name: r.follower.name, + image: r.follower.image, + })); + + const nextCursor = + hasNext && pageRows.length > 0 + ? (pageRows[pageRows.length - 1]?.id ?? null) + : null; + + return { items, nextCursor }; +} + +export async function listFollowing( + input: ListFollowingInputType, +): Promise { + const limit = input.limit; + const profileId = await requireUserIdByUsername(input.username); + + const rows = await prisma.userFollow.findMany({ + where: { + followerId: profileId, + status: "accepted", + }, + orderBy: [{ createdAt: "desc" }, { id: "desc" }], + take: limit + 1, + ...(input.cursor ? { cursor: { id: input.cursor }, skip: 1 } : {}), + select: { + id: true, + following: { + select: { username: true, name: true, image: true }, + }, + }, + }); + + const hasNext = rows.length > limit; + const pageRows = hasNext ? rows.slice(0, limit) : rows; + + const items = pageRows.map((r) => ({ + username: r.following.username, + name: r.following.name, + image: r.following.image, + })); + + const nextCursor = + hasNext && pageRows.length > 0 + ? (pageRows[pageRows.length - 1]?.id ?? null) + : null; + + return { items, nextCursor }; +} diff --git a/packages/db/prisma/migrations/20260430120000_patch_19_user_follow/migration.sql b/packages/db/prisma/migrations/20260430120000_patch_19_user_follow/migration.sql new file mode 100644 index 0000000..a77fa42 --- /dev/null +++ b/packages/db/prisma/migrations/20260430120000_patch_19_user_follow/migration.sql @@ -0,0 +1,31 @@ +-- CreateEnum +CREATE TYPE "FollowStatus" AS ENUM ('accepted'); + +-- AlterTable +ALTER TABLE "user" ADD COLUMN "total_followers" INTEGER NOT NULL DEFAULT 0; +ALTER TABLE "user" ADD COLUMN "total_following" INTEGER NOT NULL DEFAULT 0; + +-- CreateTable +CREATE TABLE "user_follow" ( + "id" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "status" "FollowStatus" NOT NULL DEFAULT 'accepted', + "follower_id" TEXT NOT NULL, + "following_id" TEXT NOT NULL, + + CONSTRAINT "user_follow_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "user_follow_follower_id_following_id_key" ON "user_follow"("follower_id", "following_id"); + +-- CreateIndex +CREATE INDEX "user_follow_follower_id_idx" ON "user_follow"("follower_id"); + +-- CreateIndex +CREATE INDEX "user_follow_following_id_idx" ON "user_follow"("following_id"); + +-- AddForeignKey +ALTER TABLE "user_follow" ADD CONSTRAINT "user_follow_follower_id_fkey" FOREIGN KEY ("follower_id") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "user_follow" ADD CONSTRAINT "user_follow_following_id_fkey" FOREIGN KEY ("following_id") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/db/prisma/schema/auth.prisma b/packages/db/prisma/schema/auth.prisma index d1711db..fc1db59 100644 --- a/packages/db/prisma/schema/auth.prisma +++ b/packages/db/prisma/schema/auth.prisma @@ -4,6 +4,11 @@ enum Role { admin } +/// Follow rows are automatic for now; more statuses may be added later (e.g. pending). +enum FollowStatus { + accepted +} + model User { id String @id @default(uuid()) createdAt DateTime @default(now()) @map("created_at") @@ -54,6 +59,15 @@ model User { /// content creation /// totalPacksPublished Int @default(0) @map("total_packs_published") + /// follow relationships (denormalized counts) /// + totalFollowers Int @default(0) @map("total_followers") + totalFollowing Int @default(0) @map("total_following") + + /// Users this user is following (this user is the follower) + following UserFollow[] @relation("UserFollows") + /// Users following this user (this user is being followed) + followers UserFollow[] @relation("UserFollowers") + /// AI topic generation (daily quota, UTC day window) /// lastAiUsedAt DateTime? @map("last_ai_used_at") aiUseCount Int @default(0) @map("ai_use_count") @@ -80,6 +94,24 @@ model User { @@map("user") } +model UserFollow { + id String @id @default(uuid()) + createdAt DateTime @default(now()) @map("created_at") + + status FollowStatus @default(accepted) + + followerId String @map("follower_id") + followingId String @map("following_id") + + follower User @relation("UserFollows", fields: [followerId], references: [id], onDelete: Cascade) + following User @relation("UserFollowers", fields: [followingId], references: [id], onDelete: Cascade) + + @@unique([followerId, followingId]) + @@index([followerId]) + @@index([followingId]) + @@map("user_follow") +} + model Session { id String @id @default(uuid()) createdAt DateTime @default(now()) @map("created_at") diff --git a/packages/schemas/src/db/schemas/enums/FollowStatus.schema.ts b/packages/schemas/src/db/schemas/enums/FollowStatus.schema.ts new file mode 100644 index 0000000..4155844 --- /dev/null +++ b/packages/schemas/src/db/schemas/enums/FollowStatus.schema.ts @@ -0,0 +1,5 @@ +import * as z from 'zod'; + +export const FollowStatusSchema = z.enum(['accepted']) + +export type FollowStatus = z.infer; \ No newline at end of file diff --git a/packages/schemas/src/db/schemas/enums/UserFollowScalarFieldEnum.schema.ts b/packages/schemas/src/db/schemas/enums/UserFollowScalarFieldEnum.schema.ts new file mode 100644 index 0000000..a01e9f4 --- /dev/null +++ b/packages/schemas/src/db/schemas/enums/UserFollowScalarFieldEnum.schema.ts @@ -0,0 +1,5 @@ +import * as z from 'zod'; + +export const UserFollowScalarFieldEnumSchema = z.enum(['id', 'createdAt', 'status', 'followerId', 'followingId']) + +export type UserFollowScalarFieldEnum = z.infer; \ No newline at end of file diff --git a/packages/schemas/src/db/schemas/enums/UserScalarFieldEnum.schema.ts b/packages/schemas/src/db/schemas/enums/UserScalarFieldEnum.schema.ts index 926b33d..5940e4d 100644 --- a/packages/schemas/src/db/schemas/enums/UserScalarFieldEnum.schema.ts +++ b/packages/schemas/src/db/schemas/enums/UserScalarFieldEnum.schema.ts @@ -1,5 +1,5 @@ import * as z from 'zod'; -export const UserScalarFieldEnumSchema = z.enum(['id', 'createdAt', 'updatedAt', 'username', 'email', 'name', 'image', 'role', 'emailVerified', 'twoFactorEnabled', 'xp', 'level', 'elo', 'peakElo', 'lowestElo', 'totalGamesHosted', 'totalGamesPlayed', 'totalPointsEarned', 'totalWins', 'totalPodiums', 'totalLastPlaces', 'totalTopicsPlayed', 'totalQuestionsPlayed', 'totalCorrectAnswers', 'totalIncorrectAnswers', 'totalExpiredAnswers', 'totalFirstClicks', 'totalTimeSpentPlaying', 'totalTimeSpentHosting', 'totalPacksPublished', 'lastAiUsedAt', 'aiUseCount', 'aiUseWindowDate']) +export const UserScalarFieldEnumSchema = z.enum(['id', 'createdAt', 'updatedAt', 'username', 'email', 'name', 'image', 'role', 'emailVerified', 'twoFactorEnabled', 'xp', 'level', 'elo', 'peakElo', 'lowestElo', 'totalGamesHosted', 'totalGamesPlayed', 'totalPointsEarned', 'totalWins', 'totalPodiums', 'totalLastPlaces', 'totalTopicsPlayed', 'totalQuestionsPlayed', 'totalCorrectAnswers', 'totalIncorrectAnswers', 'totalExpiredAnswers', 'totalFirstClicks', 'totalTimeSpentPlaying', 'totalTimeSpentHosting', 'totalPacksPublished', 'totalFollowers', 'totalFollowing', 'lastAiUsedAt', 'aiUseCount', 'aiUseWindowDate']) export type UserScalarFieldEnum = z.infer; \ No newline at end of file diff --git a/packages/schemas/src/db/schemas/models/User.schema.ts b/packages/schemas/src/db/schemas/models/User.schema.ts index 045d2df..b13af94 100644 --- a/packages/schemas/src/db/schemas/models/User.schema.ts +++ b/packages/schemas/src/db/schemas/models/User.schema.ts @@ -32,6 +32,8 @@ export const UserSchema = z.object({ totalTimeSpentPlaying: z.number().int(), totalTimeSpentHosting: z.number().int(), totalPacksPublished: z.number().int(), + totalFollowers: z.number().int(), + totalFollowing: z.number().int(), lastAiUsedAt: z.coerce.date().nullish(), aiUseCount: z.number().int(), aiUseWindowDate: z.coerce.date().nullish(), diff --git a/packages/schemas/src/db/schemas/models/UserFollow.schema.ts b/packages/schemas/src/db/schemas/models/UserFollow.schema.ts new file mode 100644 index 0000000..a54396a --- /dev/null +++ b/packages/schemas/src/db/schemas/models/UserFollow.schema.ts @@ -0,0 +1,12 @@ +import * as z from 'zod'; +import { FollowStatusSchema } from '../enums/FollowStatus.schema'; + +export const UserFollowSchema = z.object({ + id: z.string(), + createdAt: z.coerce.date(), + status: FollowStatusSchema.default("accepted"), + followerId: z.string(), + followingId: z.string(), +}); + +export type UserFollowType = z.infer; diff --git a/packages/schemas/src/db/schemas/models/index.ts b/packages/schemas/src/db/schemas/models/index.ts index ebe42ef..bff3622 100644 --- a/packages/schemas/src/db/schemas/models/index.ts +++ b/packages/schemas/src/db/schemas/models/index.ts @@ -4,6 +4,7 @@ */ export { UserSchema } from './User.schema'; +export { UserFollowSchema } from './UserFollow.schema'; export { SessionSchema } from './Session.schema'; export { AccountSchema } from './Account.schema'; export { VerificationSchema } from './Verification.schema'; diff --git a/packages/schemas/src/modules/user.ts b/packages/schemas/src/modules/user.ts index 2c5ac1c..b2fef2b 100644 --- a/packages/schemas/src/modules/user.ts +++ b/packages/schemas/src/modules/user.ts @@ -16,6 +16,8 @@ export const FindOneProfileOutputSchema = UserSchema.pick({ elo: true, peakElo: true, lowestElo: true, + totalFollowers: true, + totalFollowing: true, }); export type FindOneProfileInputType = z.infer; @@ -192,6 +194,70 @@ export type GetPublicGameActivityOutputType = z.infer< typeof GetPublicGameActivityOutputSchema >; +/** + * FOLLOW — state for the signed-in user viewing a profile + */ +export const GetFollowStateInputSchema = UserSchema.pick({ + username: true, +}); + +export const GetFollowStateOutputSchema = z.object({ + isFollowing: z.boolean(), +}); + +export type GetFollowStateInputType = z.infer; +export type GetFollowStateOutputType = z.infer< + typeof GetFollowStateOutputSchema +>; + +export const FollowUserInputSchema = UserSchema.pick({ + username: true, +}); + +export const FollowUserOutputSchema = z.object({ + ok: z.literal(true), +}); + +export type FollowUserInputType = z.infer; +export type FollowUserOutputType = z.infer; + +export const UnfollowUserInputSchema = UserSchema.pick({ + username: true, +}); + +export const UnfollowUserOutputSchema = z.object({ + ok: z.literal(true), +}); + +export type UnfollowUserInputType = z.infer; +export type UnfollowUserOutputType = z.infer; + +const FollowListUserRowSchema = UserSchema.pick({ + username: true, + name: true, + image: true, +}); + +export const ListFollowersInputSchema = z.object({ + username: UserSchema.shape.username, + limit: z.number().int().min(1).max(50).default(20), + cursor: z.string().optional(), +}); + +export const ListFollowersOutputSchema = z.object({ + items: z.array(FollowListUserRowSchema), + nextCursor: z.string().nullable(), +}); + +export type ListFollowersInputType = z.infer; +export type ListFollowersOutputType = z.infer; + +export const ListFollowingInputSchema = ListFollowersInputSchema; +export const ListFollowingOutputSchema = ListFollowersOutputSchema; + +export type ListFollowingInputType = z.infer; +export type ListFollowingOutputType = z.infer; + /** * GLOBAL LEADERBOARD * diff --git a/packages/utils/src/app-releases.ts b/packages/utils/src/app-releases.ts index 4040d97..2b76912 100644 --- a/packages/utils/src/app-releases.ts +++ b/packages/utils/src/app-releases.ts @@ -7,12 +7,30 @@ import type { export type { AppRelease, AppReleasesManifest, ReleaseHighlight }; -const current = { year: 2026, month: 4, patch: 18 } as const; +const current = { year: 2026, month: 4, patch: 19 } as const; export const appReleasesManifest: AppReleasesManifest = { productName: "Xamsa", current, releases: [ + { + releasedAt: "2026-04-30", + year: 2026, + month: 4, + patch: 19, + title: + "User follow system: profile counts, follow/unfollow, follower and following lists", + highlights: [ + { + kind: "text", + text: "Public profiles show follower and following counts; signed-in visitors can follow or unfollow other players (not themselves). Denormalized totals stay in sync with the new user_follow table.", + }, + { + kind: "text", + text: "Tapping followers or following opens a paginated list of people with avatars and links to their profiles—ready for future feed, notification, and follower-only pack features.", + }, + ], + }, { releasedAt: "2026-04-29", year: 2026,