From c2baee61e4782c97158b4d03946499ecad920b77 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 1 Sep 2026 23:59:54 +0000 Subject: [PATCH 1/6] =?UTF-8?q?Fase=201-2=20(parcial):=20identidad=20acad?= =?UTF-8?q?=C3=A9mica,=20username=20OAuth=20provisional,=20grafo=20social?= =?UTF-8?q?=20can=C3=B3nico?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fase 1: - AcademicCampus/Program/Offering + catálogo CETI seedeado idempotente - Perfil académico (offering/semester/group) con invariantes CHECK en Postgres - Username OAuth provisional de alta entropía (no derivado del email) + confirmOAuthUsername one-shot + bypass puntual de cookie cache - Helper safeInternalRedirect + redirectTo preservado en login/registro - Fix del bug de actividad: getUserLessonActivity(actorId) separado de getFriendsActivityFeed(viewerId) — el perfil de A ya no muestra amigos de A - Privacidad: perfiles con usernameSetupRequired=true ocultos a terceros Fase 2: - Friendship.pairKey canónico + FriendshipPeriod (historia) + índice único parcial para "un solo período abierto por par" - FriendRequestSource + InviteAttribution (cookie firmada first-touch, 30 días, consumida en alta nueva vía databaseHooks.user.create.after) - Discovery: 5 buckets exactos vía SQL parametrizado con CTEs, keyset pagination firmada, context token firmado por candidato - Mutual friends normalizado en SQL (sin N+1) - Scripts de backfill (pairKey + legacy XP) validados contra PostgreSQL real, idempotentes Infraestructura de tests: - vitest.integration.config.ts + tests/integration/** contra Postgres real para invariantes que un fake no puede reproducir fielmente (índice único parcial, constraints) - XP ledger (XpAward) wireado en los tres call-sites de otorgamiento de XP existentes (completeStep, submitExercise, submitPracticeExercise); UserStreak.totalXp queda como contador materializado Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_018XQFppGcovvjRoHZyQa6GF --- package.json | 1 + .../migration.sql | 501 ++++++++++++++++++ prisma/schema.prisma | 444 ++++++++++++++++ prisma/seed-academic.ts | 107 ++++ prisma/seed.ts | 2 + scripts/social/backfill-friendship-pairkey.ts | 227 ++++++++ scripts/social/backfill-xp-legacy.ts | 117 ++++ src/app/(auth)/login/login-form.tsx | 3 +- src/app/(auth)/registro/register-form.tsx | 9 +- src/app/app/(global)/amigos/page.tsx | 6 +- .../app/(global)/confirmar-usuario/page.tsx | 46 ++ .../app/(global)/perfil/[username]/page.tsx | 8 +- src/app/app/c/[courseSlug]/page.tsx | 4 +- src/app/invitar/[username]/page.tsx | 3 + src/features/academic/actions.ts | 100 ++++ src/features/academic/lib/group.ts | 13 + src/features/academic/queries.ts | 89 ++++ src/features/discovery/queries.ts | 268 ++++++++++ src/features/friends/actions.ts | 236 ++++++--- .../friends/components/profile-actions.tsx | 6 +- .../friends/components/user-search.tsx | 2 +- src/features/friends/queries.ts | 153 ++++-- src/features/friends/search-action.ts | 6 +- src/features/invites/actions.ts | 51 ++ .../components/capture-invite-cookie.tsx | 20 + src/features/lessons/actions.ts | 19 +- src/features/practice/actions.ts | 7 +- src/features/profile/actions.ts | 54 ++ .../components/confirm-username-form.tsx | 130 +++++ src/lib/auth.ts | 89 ++-- src/lib/get-session.ts | 18 + src/lib/rate-limit.ts | 10 + src/lib/social/friend-streak.ts | 274 ++++++++++ src/lib/social/friendship-lifecycle.ts | 69 +++ src/lib/social/invite-cookie.ts | 67 +++ src/lib/social/league.ts | 67 +++ src/lib/social/pair.ts | 15 + src/lib/social/ranking.ts | 30 ++ src/lib/social/redirect.ts | 44 ++ src/lib/social/signed-token.ts | 55 ++ src/lib/social/time.ts | 172 ++++++ src/lib/streak.ts | 17 + src/lib/xp.ts | 54 ++ tests/features/academic/group.test.ts | 21 + .../friends/send-friend-request.test.ts | 8 +- tests/helpers/fake-prisma.ts | 17 +- .../integration/discovery.integration.test.ts | 180 +++++++ .../friendship.integration.test.ts | 102 ++++ tests/integration/helpers.ts | 40 ++ tests/lib/social/league.test.ts | 81 +++ tests/lib/social/pair.test.ts | 14 + tests/lib/social/ranking.test.ts | 35 ++ tests/lib/social/redirect.test.ts | 38 ++ tests/lib/social/time.test.ts | 78 +++ vitest.config.ts | 4 +- vitest.integration.config.ts | 38 ++ 56 files changed, 4097 insertions(+), 172 deletions(-) create mode 100644 prisma/migrations/20260902000000_social_system_phase1_6/migration.sql create mode 100644 prisma/seed-academic.ts create mode 100644 scripts/social/backfill-friendship-pairkey.ts create mode 100644 scripts/social/backfill-xp-legacy.ts create mode 100644 src/app/app/(global)/confirmar-usuario/page.tsx create mode 100644 src/features/academic/actions.ts create mode 100644 src/features/academic/lib/group.ts create mode 100644 src/features/academic/queries.ts create mode 100644 src/features/discovery/queries.ts create mode 100644 src/features/invites/actions.ts create mode 100644 src/features/invites/components/capture-invite-cookie.tsx create mode 100644 src/features/profile/components/confirm-username-form.tsx create mode 100644 src/lib/social/friend-streak.ts create mode 100644 src/lib/social/friendship-lifecycle.ts create mode 100644 src/lib/social/invite-cookie.ts create mode 100644 src/lib/social/league.ts create mode 100644 src/lib/social/pair.ts create mode 100644 src/lib/social/ranking.ts create mode 100644 src/lib/social/redirect.ts create mode 100644 src/lib/social/signed-token.ts create mode 100644 src/lib/social/time.ts create mode 100644 src/lib/xp.ts create mode 100644 tests/features/academic/group.test.ts create mode 100644 tests/integration/discovery.integration.test.ts create mode 100644 tests/integration/friendship.integration.test.ts create mode 100644 tests/integration/helpers.ts create mode 100644 tests/lib/social/league.test.ts create mode 100644 tests/lib/social/pair.test.ts create mode 100644 tests/lib/social/ranking.test.ts create mode 100644 tests/lib/social/redirect.test.ts create mode 100644 tests/lib/social/time.test.ts create mode 100644 vitest.integration.config.ts diff --git a/package.json b/package.json index a50b44d..4a4a446 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "typecheck": "tsc --noEmit", "test": "vitest run", "test:watch": "vitest", + "test:integration": "dotenv -e .env.local -- vitest run --config vitest.integration.config.ts", "postinstall": "prisma generate", "db:generate": "prisma generate", "db:push": "dotenv -e .env.local -- prisma db push", diff --git a/prisma/migrations/20260902000000_social_system_phase1_6/migration.sql b/prisma/migrations/20260902000000_social_system_phase1_6/migration.sql new file mode 100644 index 0000000..df1037b --- /dev/null +++ b/prisma/migrations/20260902000000_social_system_phase1_6/migration.sql @@ -0,0 +1,501 @@ +-- CreateEnum +CREATE TYPE "FriendRequestSource" AS ENUM ('profile', 'search', 'discovery', 'invite'); + +-- CreateEnum +CREATE TYPE "FriendshipEndReason" AS ENUM ('unfriended', 'blocked'); + +-- CreateEnum +CREATE TYPE "SocialEventKind" AS ENUM ('unit_completed', 'course_completed', 'streak_milestone', 'league_promoted', 'friend_quest_completed'); + +-- CreateEnum +CREATE TYPE "XpReason" AS ENUM ('legacy_balance', 'lesson_completed', 'lesson_exercise_first_pass', 'practice_first_pass'); + +-- CreateEnum +CREATE TYPE "LeagueTier" AS ENUM ('bronze', 'silver', 'gold', 'platinum', 'diamond'); + +-- CreateEnum +CREATE TYPE "LeagueSeasonStatus" AS ENUM ('open', 'closing', 'closed'); + +-- CreateEnum +CREATE TYPE "LeagueOutcome" AS ENUM ('promoted', 'relegated', 'stayed', 'held_at_ceiling', 'held_at_floor'); + +-- CreateEnum +CREATE TYPE "FriendStreakStatus" AS ENUM ('pending', 'active', 'ended'); + +-- CreateEnum +CREATE TYPE "FriendStreakEndReason" AS ENUM ('unfriended', 'blocked', 'expired'); + +-- CreateEnum +CREATE TYPE "FriendQuestType" AS ENUM ('lessons_completed'); + +-- CreateEnum +CREATE TYPE "FriendQuestStatus" AS ENUM ('active', 'completed', 'expired', 'cancelled'); + +-- AlterEnum +-- This migration adds more than one value to an enum. +-- With PostgreSQL versions 11 and earlier, this is not possible +-- in a single migration. This can be worked around by creating +-- multiple migrations, each migration adding only one value to +-- the enum. + + +ALTER TYPE "ProductEventName" ADD VALUE 'discovery_impression'; +ALTER TYPE "ProductEventName" ADD VALUE 'discovery_profile_open'; +ALTER TYPE "ProductEventName" ADD VALUE 'friends_ranking_view'; +ALTER TYPE "ProductEventName" ADD VALUE 'league_view'; +ALTER TYPE "ProductEventName" ADD VALUE 'invite_link_copied'; + +-- AlterEnum +ALTER TYPE "ProductSurface" ADD VALUE 'social'; + +-- AlterTable +ALTER TABLE "friendship" ADD COLUMN "pairKey" TEXT, +ADD COLUMN "requestSource" "FriendRequestSource", +ADD COLUMN "sourceContextKey" TEXT; + +-- AlterTable +ALTER TABLE "user" ADD COLUMN "academicGroup" VARCHAR(20), +ADD COLUMN "academicOfferingId" TEXT, +ADD COLUMN "academicPromptDismissedAt" TIMESTAMP(3), +ADD COLUMN "academicSemester" INTEGER, +ADD COLUMN "usernameSetupRequired" BOOLEAN NOT NULL DEFAULT false; + +-- CreateTable +CREATE TABLE "friendship_period" ( + "id" TEXT NOT NULL, + "userLowId" TEXT NOT NULL, + "userHighId" TEXT NOT NULL, + "source" "FriendRequestSource", + "sourceContextKey" TEXT, + "startedAt" TIMESTAMP(3) NOT NULL, + "endedAt" TIMESTAMP(3), + "endReason" "FriendshipEndReason", + + CONSTRAINT "friendship_period_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "invite_attribution" ( + "id" TEXT NOT NULL, + "inviterId" TEXT NOT NULL, + "inviteeId" TEXT NOT NULL, + "capturedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "invite_attribution_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "social_event" ( + "id" TEXT NOT NULL, + "actorId" TEXT NOT NULL, + "kind" "SocialEventKind" NOT NULL, + "dedupeKey" TEXT NOT NULL, + "unitId" TEXT, + "courseId" TEXT, + "value" INTEGER, + "occurredAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "social_event_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "kudos" ( + "id" TEXT NOT NULL, + "eventId" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "kudos_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "xp_award" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "amount" INTEGER NOT NULL, + "reason" "XpReason" NOT NULL, + "dedupeKey" TEXT NOT NULL, + "lessonId" TEXT, + "exerciseId" TEXT, + "practiceExerciseId" TEXT, + "earnedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "xp_award_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "league_season" ( + "id" TEXT NOT NULL, + "key" TEXT NOT NULL, + "startsAt" TIMESTAMP(3) NOT NULL, + "endsAt" TIMESTAMP(3) NOT NULL, + "status" "LeagueSeasonStatus" NOT NULL DEFAULT 'open', + "closedAt" TIMESTAMP(3), + + CONSTRAINT "league_season_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "league_division" ( + "id" TEXT NOT NULL, + "seasonId" TEXT NOT NULL, + "tier" "LeagueTier" NOT NULL, + "number" INTEGER NOT NULL, + + CONSTRAINT "league_division_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "league_membership" ( + "id" TEXT NOT NULL, + "seasonId" TEXT NOT NULL, + "divisionId" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "joinedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "finalXp" INTEGER, + "finalRank" INTEGER, + "outcome" "LeagueOutcome", + "nextTier" "LeagueTier", + + CONSTRAINT "league_membership_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "friend_streak" ( + "id" TEXT NOT NULL, + "userLowId" TEXT NOT NULL, + "userHighId" TEXT NOT NULL, + "createdById" TEXT NOT NULL, + "status" "FriendStreakStatus" NOT NULL DEFAULT 'pending', + "pendingExpiresAt" TIMESTAMP(3), + "acceptedAt" TIMESTAMP(3), + "endedAt" TIMESTAMP(3), + "endReason" "FriendStreakEndReason", + "currentStreak" INTEGER NOT NULL DEFAULT 0, + "longestStreak" INTEGER NOT NULL DEFAULT 0, + "lastQualifiedDay" DATE, + "lastEvaluatedDay" DATE, + + CONSTRAINT "friend_streak_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "friend_streak_day" ( + "id" TEXT NOT NULL, + "streakId" TEXT NOT NULL, + "day" DATE NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "friend_streak_day_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "streak_reminder" ( + "id" TEXT NOT NULL, + "streakId" TEXT NOT NULL, + "senderId" TEXT NOT NULL, + "recipientId" TEXT NOT NULL, + "day" DATE NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "expiresAt" TIMESTAMP(3) NOT NULL, + "readAt" TIMESTAMP(3), + + CONSTRAINT "streak_reminder_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "friend_quest" ( + "id" TEXT NOT NULL, + "weekStart" DATE NOT NULL, + "startsAt" TIMESTAMP(3) NOT NULL, + "endsAt" TIMESTAMP(3) NOT NULL, + "type" "FriendQuestType" NOT NULL, + "target" INTEGER NOT NULL, + "status" "FriendQuestStatus" NOT NULL DEFAULT 'active', + "completedAt" TIMESTAMP(3), + + CONSTRAINT "friend_quest_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "friend_quest_participant" ( + "id" TEXT NOT NULL, + "questId" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "weekStart" DATE NOT NULL, + + CONSTRAINT "friend_quest_participant_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "academic_campus" ( + "id" TEXT NOT NULL, + "code" TEXT NOT NULL, + "name" TEXT NOT NULL, + "active" BOOLEAN NOT NULL DEFAULT true, + "sortOrder" INTEGER NOT NULL DEFAULT 0, + + CONSTRAINT "academic_campus_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "academic_program" ( + "id" TEXT NOT NULL, + "code" TEXT NOT NULL, + "name" TEXT NOT NULL, + "active" BOOLEAN NOT NULL DEFAULT true, + + CONSTRAINT "academic_program_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "academic_offering" ( + "id" TEXT NOT NULL, + "campusId" TEXT NOT NULL, + "programId" TEXT NOT NULL, + "semesterCount" INTEGER NOT NULL, + "active" BOOLEAN NOT NULL DEFAULT true, + + CONSTRAINT "academic_offering_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "friendship_period_userLowId_startedAt_idx" ON "friendship_period"("userLowId", "startedAt"); + +-- CreateIndex +CREATE INDEX "friendship_period_userHighId_startedAt_idx" ON "friendship_period"("userHighId", "startedAt"); + +-- CreateIndex +CREATE INDEX "friendship_period_startedAt_idx" ON "friendship_period"("startedAt"); + +-- CreateIndex +CREATE UNIQUE INDEX "invite_attribution_inviteeId_key" ON "invite_attribution"("inviteeId"); + +-- CreateIndex +CREATE INDEX "invite_attribution_inviterId_idx" ON "invite_attribution"("inviterId"); + +-- CreateIndex +CREATE INDEX "social_event_actorId_occurredAt_id_idx" ON "social_event"("actorId", "occurredAt", "id"); + +-- CreateIndex +CREATE INDEX "social_event_kind_occurredAt_idx" ON "social_event"("kind", "occurredAt"); + +-- CreateIndex +CREATE UNIQUE INDEX "social_event_actorId_dedupeKey_key" ON "social_event"("actorId", "dedupeKey"); + +-- CreateIndex +CREATE UNIQUE INDEX "kudos_eventId_userId_key" ON "kudos"("eventId", "userId"); + +-- CreateIndex +CREATE INDEX "xp_award_userId_earnedAt_idx" ON "xp_award"("userId", "earnedAt"); + +-- CreateIndex +CREATE INDEX "xp_award_earnedAt_userId_idx" ON "xp_award"("earnedAt", "userId"); + +-- CreateIndex +CREATE INDEX "xp_award_reason_earnedAt_idx" ON "xp_award"("reason", "earnedAt"); + +-- CreateIndex +CREATE UNIQUE INDEX "xp_award_userId_dedupeKey_key" ON "xp_award"("userId", "dedupeKey"); + +-- CreateIndex +CREATE UNIQUE INDEX "league_season_key_key" ON "league_season"("key"); + +-- CreateIndex +CREATE INDEX "league_season_status_endsAt_idx" ON "league_season"("status", "endsAt"); + +-- CreateIndex +CREATE INDEX "league_division_seasonId_tier_idx" ON "league_division"("seasonId", "tier"); + +-- CreateIndex +CREATE UNIQUE INDEX "league_division_seasonId_tier_number_key" ON "league_division"("seasonId", "tier", "number"); + +-- CreateIndex +CREATE INDEX "league_membership_divisionId_userId_idx" ON "league_membership"("divisionId", "userId"); + +-- CreateIndex +CREATE UNIQUE INDEX "league_membership_seasonId_userId_key" ON "league_membership"("seasonId", "userId"); + +-- CreateIndex +CREATE INDEX "friend_streak_userLowId_status_idx" ON "friend_streak"("userLowId", "status"); + +-- CreateIndex +CREATE INDEX "friend_streak_userHighId_status_idx" ON "friend_streak"("userHighId", "status"); + +-- CreateIndex +CREATE UNIQUE INDEX "friend_streak_userLowId_userHighId_key" ON "friend_streak"("userLowId", "userHighId"); + +-- CreateIndex +CREATE UNIQUE INDEX "friend_streak_day_streakId_day_key" ON "friend_streak_day"("streakId", "day"); + +-- CreateIndex +CREATE INDEX "streak_reminder_recipientId_readAt_createdAt_idx" ON "streak_reminder"("recipientId", "readAt", "createdAt"); + +-- CreateIndex +CREATE UNIQUE INDEX "streak_reminder_streakId_senderId_day_key" ON "streak_reminder"("streakId", "senderId", "day"); + +-- CreateIndex +CREATE INDEX "friend_quest_weekStart_status_idx" ON "friend_quest"("weekStart", "status"); + +-- CreateIndex +CREATE UNIQUE INDEX "friend_quest_participant_questId_userId_key" ON "friend_quest_participant"("questId", "userId"); + +-- CreateIndex +CREATE UNIQUE INDEX "friend_quest_participant_userId_weekStart_key" ON "friend_quest_participant"("userId", "weekStart"); + +-- CreateIndex +CREATE UNIQUE INDEX "academic_campus_code_key" ON "academic_campus"("code"); + +-- CreateIndex +CREATE UNIQUE INDEX "academic_program_code_key" ON "academic_program"("code"); + +-- CreateIndex +CREATE UNIQUE INDEX "academic_offering_campusId_programId_key" ON "academic_offering"("campusId", "programId"); + +-- CreateIndex +CREATE UNIQUE INDEX "friendship_pairKey_key" ON "friendship"("pairKey"); + +-- CreateIndex +CREATE INDEX "user_academicOfferingId_academicSemester_academicGroup_idx" ON "user"("academicOfferingId", "academicSemester", "academicGroup"); + +-- AddForeignKey +ALTER TABLE "user" ADD CONSTRAINT "user_academicOfferingId_fkey" FOREIGN KEY ("academicOfferingId") REFERENCES "academic_offering"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "friendship_period" ADD CONSTRAINT "friendship_period_userLowId_fkey" FOREIGN KEY ("userLowId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "friendship_period" ADD CONSTRAINT "friendship_period_userHighId_fkey" FOREIGN KEY ("userHighId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "invite_attribution" ADD CONSTRAINT "invite_attribution_inviterId_fkey" FOREIGN KEY ("inviterId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "invite_attribution" ADD CONSTRAINT "invite_attribution_inviteeId_fkey" FOREIGN KEY ("inviteeId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "social_event" ADD CONSTRAINT "social_event_actorId_fkey" FOREIGN KEY ("actorId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "social_event" ADD CONSTRAINT "social_event_unitId_fkey" FOREIGN KEY ("unitId") REFERENCES "unit"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "social_event" ADD CONSTRAINT "social_event_courseId_fkey" FOREIGN KEY ("courseId") REFERENCES "course"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "kudos" ADD CONSTRAINT "kudos_eventId_fkey" FOREIGN KEY ("eventId") REFERENCES "social_event"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "kudos" ADD CONSTRAINT "kudos_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "xp_award" ADD CONSTRAINT "xp_award_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "xp_award" ADD CONSTRAINT "xp_award_lessonId_fkey" FOREIGN KEY ("lessonId") REFERENCES "lesson"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "xp_award" ADD CONSTRAINT "xp_award_exerciseId_fkey" FOREIGN KEY ("exerciseId") REFERENCES "exercise"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "xp_award" ADD CONSTRAINT "xp_award_practiceExerciseId_fkey" FOREIGN KEY ("practiceExerciseId") REFERENCES "practice_exercise"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "league_division" ADD CONSTRAINT "league_division_seasonId_fkey" FOREIGN KEY ("seasonId") REFERENCES "league_season"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "league_membership" ADD CONSTRAINT "league_membership_seasonId_fkey" FOREIGN KEY ("seasonId") REFERENCES "league_season"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "league_membership" ADD CONSTRAINT "league_membership_divisionId_fkey" FOREIGN KEY ("divisionId") REFERENCES "league_division"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "league_membership" ADD CONSTRAINT "league_membership_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "friend_streak" ADD CONSTRAINT "friend_streak_userLowId_fkey" FOREIGN KEY ("userLowId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "friend_streak" ADD CONSTRAINT "friend_streak_userHighId_fkey" FOREIGN KEY ("userHighId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "friend_streak" ADD CONSTRAINT "friend_streak_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "friend_streak_day" ADD CONSTRAINT "friend_streak_day_streakId_fkey" FOREIGN KEY ("streakId") REFERENCES "friend_streak"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "streak_reminder" ADD CONSTRAINT "streak_reminder_streakId_fkey" FOREIGN KEY ("streakId") REFERENCES "friend_streak"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "streak_reminder" ADD CONSTRAINT "streak_reminder_senderId_fkey" FOREIGN KEY ("senderId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "streak_reminder" ADD CONSTRAINT "streak_reminder_recipientId_fkey" FOREIGN KEY ("recipientId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "friend_quest_participant" ADD CONSTRAINT "friend_quest_participant_questId_fkey" FOREIGN KEY ("questId") REFERENCES "friend_quest"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "friend_quest_participant" ADD CONSTRAINT "friend_quest_participant_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "academic_offering" ADD CONSTRAINT "academic_offering_campusId_fkey" FOREIGN KEY ("campusId") REFERENCES "academic_campus"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "academic_offering" ADD CONSTRAINT "academic_offering_programId_fkey" FOREIGN KEY ("programId") REFERENCES "academic_program"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + + +-- ===================================================================== +-- INVARIANTES ADICIONALES (raw SQL — Prisma no las expresa declarativamente) +-- ===================================================================== + +-- Identidad académica: offering y semester van juntos (ambos null o ambos +-- presentes); group sólo existe con offering+semester; semester en rango +-- amplio (el tope real, semesterCount de la oferta, se valida en el +-- servidor porque un CHECK no puede leer otra tabla). +ALTER TABLE "user" + ADD CONSTRAINT "user_academic_offering_semester_pair" CHECK ( + ("academicOfferingId" IS NULL AND "academicSemester" IS NULL) + OR ("academicOfferingId" IS NOT NULL AND "academicSemester" IS NOT NULL) + ), + ADD CONSTRAINT "user_academic_group_requires_core" CHECK ( + "academicGroup" IS NULL + OR ("academicOfferingId" IS NOT NULL AND "academicSemester" IS NOT NULL) + ), + ADD CONSTRAINT "user_academic_semester_range" CHECK ( + "academicSemester" IS NULL OR ("academicSemester" BETWEEN 1 AND 12) + ); + +-- XpAward: el monto siempre es positivo (el ledger es append-only, nunca +-- resta) y exactamente UN recurso corresponde a cada reason. +ALTER TABLE "xp_award" + ADD CONSTRAINT "xp_award_amount_positive" CHECK ("amount" > 0), + ADD CONSTRAINT "xp_award_resource_matches_reason" CHECK ( + ("reason" = 'legacy_balance' AND "lessonId" IS NULL AND "exerciseId" IS NULL AND "practiceExerciseId" IS NULL) + OR ("reason" = 'lesson_completed' AND "lessonId" IS NOT NULL AND "exerciseId" IS NULL AND "practiceExerciseId" IS NULL) + OR ("reason" = 'lesson_exercise_first_pass' AND "lessonId" IS NULL AND "exerciseId" IS NOT NULL AND "practiceExerciseId" IS NULL) + OR ("reason" = 'practice_first_pass' AND "lessonId" IS NULL AND "exerciseId" IS NULL AND "practiceExerciseId" IS NOT NULL) + ); + +-- FriendshipPeriod: a lo más UN periodo abierto (endedAt IS NULL) por par +-- canónico. Índice único PARCIAL — Prisma no soporta `WHERE` en `@@index`. +CREATE UNIQUE INDEX "friendship_period_open_pair_key" + ON "friendship_period" ("userLowId", "userHighId") + WHERE "endedAt" IS NULL; + +-- FriendQuest: la ventana [startsAt, endsAt) de una quest activa nunca está +-- vacía ni invertida. +ALTER TABLE "friend_quest" + ADD CONSTRAINT "friend_quest_window_valid" CHECK ("endsAt" > "startsAt"), + ADD CONSTRAINT "friend_quest_target_positive" CHECK ("target" > 0); + +-- FriendStreak: los contadores nunca son negativos. +ALTER TABLE "friend_streak" + ADD CONSTRAINT "friend_streak_counts_nonnegative" CHECK ( + "currentStreak" >= 0 AND "longestStreak" >= 0 AND "currentStreak" <= "longestStreak" + ); + +-- LeagueDivision: number es un ordinal positivo dentro del tier. +ALTER TABLE "league_division" + ADD CONSTRAINT "league_division_number_positive" CHECK ("number" > 0); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 0d7eb9d..adcc2c9 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -36,6 +36,21 @@ model User { /// reportes). La autorización SIEMPRE se resuelve leyendo esta columna en /// el servidor (`requireAdmin`), nunca desde el payload de la sesión. role UserRole @default(student) + /// true SOLO para cuentas OAuth nuevas con username provisional de alta + /// entropía (no derivado del email). Bloquea únicamente funcionalidad + /// SOCIAL que depende de una identidad pública estable — nunca aprender. + /// Se apaga una sola vez, vía `confirmOAuthUsername`. + usernameSetupRequired Boolean @default(false) + + // Identidad académica — opcional, nunca bloquea registro/login/aprender. + academicOfferingId String? + academicOffering AcademicOffering? @relation(fields: [academicOfferingId], references: [id], onDelete: SetNull) + /// 1..academicOffering.semesterCount. Sólo tiene sentido con offering. + academicSemester Int? + /// Trim + colapsa espacios + uppercase, <=20 chars. Sólo con offering+semester. + academicGroup String? @db.VarChar(20) + academicPromptDismissedAt DateTime? + createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -63,10 +78,26 @@ model User { // Relaciones sociales friendshipsSent Friendship[] @relation("FriendshipRequester") friendshipsReceived Friendship[] @relation("FriendshipAddressee") + friendshipPeriodsLow FriendshipPeriod[] @relation("FriendshipPeriodLow") + friendshipPeriodsHigh FriendshipPeriod[] @relation("FriendshipPeriodHigh") + invitesSent InviteAttribution[] @relation("InviterOf") + inviteReceived InviteAttribution? @relation("InviteeOf") + socialEvents SocialEvent[] + kudosGiven Kudos[] + xpAwards XpAward[] + leagueMemberships LeagueMembership[] + friendStreaksLow FriendStreak[] @relation("FriendStreakLow") + friendStreaksHigh FriendStreak[] @relation("FriendStreakHigh") + friendStreaksCreated FriendStreak[] @relation("FriendStreakCreator") + remindersSent StreakReminder[] @relation("ReminderSender") + remindersReceived StreakReminder[] @relation("ReminderRecipient") + friendQuestParticipations FriendQuestParticipant[] @@index([email]) /// Altas por ventana temporal en el panel interno. @@index([createdAt]) + /// Descubrimiento por cohorte (bucket2/bucket3 en discovery). + @@index([academicOfferingId, academicSemester, academicGroup]) @@map("user") } @@ -160,6 +191,7 @@ model Course { units Unit[] progress UserCourseProgress[] practiceExercises PracticeExercise[] + socialEvents SocialEvent[] @@map("course") } @@ -180,6 +212,7 @@ model Unit { lessons Lesson[] practiceExercises PracticeExercise[] + socialEvents SocialEvent[] @@unique([courseId, slug]) @@index([courseId, order]) @@ -209,6 +242,7 @@ model Lesson { progress UserLessonProgress[] feedback Feedback[] productEvents ProductEvent[] + xpAwards XpAward[] @@unique([unitId, slug]) @@index([unitId, order]) @@ -281,6 +315,7 @@ model Exercise { hintsViewed UserHintViewed[] bugReports BugReport[] productEvents ProductEvent[] + xpAwards XpAward[] /// Hash corto del contenido vigente (prompt + starter + solución + hints + /// tests). Lo escribe el seed; ver `src/lib/content-revision.ts`. contentRevision String? @@ -479,6 +514,7 @@ model PracticeExercise { bugReports BugReport[] feedback Feedback[] productEvents ProductEvent[] + xpAwards XpAward[] @@unique([courseId, slug]) @@index([courseId, unitSlug, position]) @@ -863,6 +899,27 @@ enum ProductEventName { * Compiló/ejecutó en el playground SIN calificar. Lo emite el servidor. */ code_run + /** + * Se renderizó una página de resultados de discovery (personas que + * quizá conozcas). Servidor. + */ + discovery_impression + /** + * Abrió el perfil de un candidato de discovery. Servidor. + */ + discovery_profile_open + /** + * Abrió el ranking semanal de amigos. + */ + friends_ranking_view + /** + * Abrió /app/liga. + */ + league_view + /** + * Copió su link de invitación. + */ + invite_link_copied } enum ProductSurface { @@ -870,6 +927,10 @@ enum ProductSurface { practice playground app + /** + * Superficies sociales: discovery, ranking, liga, invitación. + */ + social } // --------------------------------------------------------------------- @@ -931,7 +992,18 @@ model Friendship { createdAt DateTime @default(now()) acceptedAt DateTime? + /// Par canónico (menor id, mayor id) — hace imposible A→B y B→A a la vez. + /// Nullable durante el backfill de la migración de consolidación; NOT NULL + /// después. Ver `scripts/social/backfill-friendship-pairkey.ts`. + pairKey String? + /// Quién originó la solicitud. El cliente NUNCA lo declara libremente — + /// lo resuelve el servidor según la superficie desde la que se manda. + requestSource FriendRequestSource? + /// Token de correlación opcional (ej. discovery context token). + sourceContextKey String? + @@unique([requesterId, addresseeId]) + @@unique([pairKey]) @@index([addresseeId, status]) @@index([requesterId, status]) @@map("friendship") @@ -943,6 +1015,378 @@ enum FriendStatus { blocked } +enum FriendRequestSource { + profile + search + discovery + invite +} + +/// Historia de amistad por par canónico. Se abre un periodo al aceptar y se +/// cierra al remover/bloquear — en la MISMA transacción que el cambio de +/// `Friendship`. A lo más un periodo abierto por par (constraint parcial en +/// migración SQL, `friendship_period` no lo expresa en el schema). +model FriendshipPeriod { + id String @id @default(cuid()) + userLowId String + userLow User @relation("FriendshipPeriodLow", fields: [userLowId], references: [id], onDelete: Cascade) + userHighId String + userHigh User @relation("FriendshipPeriodHigh", fields: [userHighId], references: [id], onDelete: Cascade) + /// `null` sólo para periodos abiertos por el backfill de amistades + /// accepted preexistentes (su source real no se registró). Todo periodo + /// abierto por código nuevo SIEMPRE trae un source real. + source FriendRequestSource? + sourceContextKey String? + startedAt DateTime + endedAt DateTime? + endReason FriendshipEndReason? + + @@index([userLowId, startedAt]) + @@index([userHighId, startedAt]) + @@index([startedAt]) + @@map("friendship_period") +} + +enum FriendshipEndReason { + unfriended + blocked +} + +/// Atribución de invitación: quién trajo a quién. Una fila por invitado +/// (first-touch, UNIQUE inviteeId). Se consume una sola vez al registrarse. +model InviteAttribution { + id String @id @default(cuid()) + inviterId String + inviter User @relation("InviterOf", fields: [inviterId], references: [id], onDelete: Cascade) + inviteeId String @unique + invitee User @relation("InviteeOf", fields: [inviteeId], references: [id], onDelete: Cascade) + capturedAt DateTime @default(now()) + + @@index([inviterId]) + @@map("invite_attribution") +} + +// ===================================================================== +// SOCIAL — feed de hitos + kudos +// ===================================================================== + +model SocialEvent { + id String @id @default(cuid()) + actorId String + actor User @relation(fields: [actorId], references: [id], onDelete: Cascade) + kind SocialEventKind + /// Idempotencia del hito (ej. `unit_completed:`, + /// `streak_milestone:`). UNIQUE (actorId, dedupeKey). + dedupeKey String + unitId String? + unit Unit? @relation(fields: [unitId], references: [id], onDelete: SetNull) + courseId String? + course Course? @relation(fields: [courseId], references: [id], onDelete: SetNull) + /// Valor asociado al hito cuando aplica (ej. días de racha, tier). + value Int? + occurredAt DateTime @default(now()) + + kudos Kudos[] + + @@unique([actorId, dedupeKey]) + @@index([actorId, occurredAt, id]) + @@index([kind, occurredAt]) + @@map("social_event") +} + +enum SocialEventKind { + unit_completed + course_completed + streak_milestone + league_promoted + friend_quest_completed +} + +model Kudos { + id String @id @default(cuid()) + eventId String + event SocialEvent @relation(fields: [eventId], references: [id], onDelete: Cascade) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + createdAt DateTime @default(now()) + + @@unique([eventId, userId]) + @@map("kudos") +} + +// ===================================================================== +// SOCIAL — XP ledger, ranking semanal, ligas +// ===================================================================== + +/// Ledger append-only de XP. Fuente de verdad para ranking/ligas. +/// `UserStreak.totalXp` es un contador materializado que sólo se mueve +/// cuando esta tabla efectivamente insertó una fila nueva (dedupe por +/// UNIQUE (userId, dedupeKey)). +model XpAward { + id String @id @default(cuid()) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + amount Int + reason XpReason + dedupeKey String + lessonId String? + lesson Lesson? @relation(fields: [lessonId], references: [id], onDelete: SetNull) + exerciseId String? + exercise Exercise? @relation(fields: [exerciseId], references: [id], onDelete: SetNull) + practiceExerciseId String? + practiceExercise PracticeExercise? @relation(fields: [practiceExerciseId], references: [id], onDelete: SetNull) + /// Hora del SERVIDOR. Nunca la manda el cliente. + earnedAt DateTime @default(now()) + + @@unique([userId, dedupeKey]) + @@index([userId, earnedAt]) + @@index([earnedAt, userId]) + @@index([reason, earnedAt]) + @@map("xp_award") +} + +enum XpReason { + /// Baseline pre-ledger, UNA fila por usuario. NO cuenta para + /// ranking/ligas (ver `src/lib/xp.ts`). + legacy_balance + lesson_completed + lesson_exercise_first_pass + practice_first_pass +} + +enum LeagueTier { + bronze + silver + gold + platinum + diamond +} + +enum LeagueSeasonStatus { + open + closing + closed +} + +enum LeagueOutcome { + promoted + relegated + stayed + held_at_ceiling + held_at_floor +} + +model LeagueSeason { + id String @id @default(cuid()) + key String @unique + startsAt DateTime + endsAt DateTime + status LeagueSeasonStatus @default(open) + closedAt DateTime? + + divisions LeagueDivision[] + memberships LeagueMembership[] + + @@index([status, endsAt]) + @@map("league_season") +} + +model LeagueDivision { + id String @id @default(cuid()) + seasonId String + season LeagueSeason @relation(fields: [seasonId], references: [id], onDelete: Cascade) + tier LeagueTier + /// Ordinal DENTRO del tier de esta temporada (1, 2, 3...). + number Int + + memberships LeagueMembership[] + + @@unique([seasonId, tier, number]) + @@index([seasonId, tier]) + @@map("league_division") +} + +model LeagueMembership { + id String @id @default(cuid()) + seasonId String + season LeagueSeason @relation(fields: [seasonId], references: [id], onDelete: Cascade) + divisionId String + division LeagueDivision @relation(fields: [divisionId], references: [id], onDelete: Cascade) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + joinedAt DateTime @default(now()) + finalXp Int? + finalRank Int? + outcome LeagueOutcome? + nextTier LeagueTier? + + @@unique([seasonId, userId]) + @@index([divisionId, userId]) + @@map("league_membership") +} + +// ===================================================================== +// SOCIAL — Friend Streak + reminders +// ===================================================================== + +enum FriendStreakStatus { + pending + active + ended +} + +enum FriendStreakEndReason { + unfriended + blocked + expired +} + +model FriendStreak { + id String @id @default(cuid()) + userLowId String + userLow User @relation("FriendStreakLow", fields: [userLowId], references: [id], onDelete: Cascade) + userHighId String + userHigh User @relation("FriendStreakHigh", fields: [userHighId], references: [id], onDelete: Cascade) + createdById String + createdBy User @relation("FriendStreakCreator", fields: [createdById], references: [id], onDelete: Cascade) + status FriendStreakStatus @default(pending) + pendingExpiresAt DateTime? + acceptedAt DateTime? + endedAt DateTime? + endReason FriendStreakEndReason? + currentStreak Int @default(0) + longestStreak Int @default(0) + /// DATE en America/Mexico_City — última vez que AMBOS calificaron. + lastQualifiedDay DateTime? @db.Date + lastEvaluatedDay DateTime? @db.Date + + days FriendStreakDay[] + reminders StreakReminder[] + + @@unique([userLowId, userHighId]) + @@index([userLowId, status]) + @@index([userHighId, status]) + @@map("friend_streak") +} + +model FriendStreakDay { + id String @id @default(cuid()) + streakId String + streak FriendStreak @relation(fields: [streakId], references: [id], onDelete: Cascade) + day DateTime @db.Date + createdAt DateTime @default(now()) + + @@unique([streakId, day]) + @@map("friend_streak_day") +} + +model StreakReminder { + id String @id @default(cuid()) + streakId String + streak FriendStreak @relation(fields: [streakId], references: [id], onDelete: Cascade) + senderId String + sender User @relation("ReminderSender", fields: [senderId], references: [id], onDelete: Cascade) + recipientId String + recipient User @relation("ReminderRecipient", fields: [recipientId], references: [id], onDelete: Cascade) + day DateTime @db.Date + createdAt DateTime @default(now()) + expiresAt DateTime + readAt DateTime? + + @@unique([streakId, senderId, day]) + @@index([recipientId, readAt, createdAt]) + @@map("streak_reminder") +} + +// ===================================================================== +// SOCIAL — Friend Quests +// ===================================================================== + +enum FriendQuestType { + lessons_completed +} + +enum FriendQuestStatus { + active + completed + expired + cancelled +} + +model FriendQuest { + id String @id @default(cuid()) + weekStart DateTime @db.Date + startsAt DateTime + endsAt DateTime + type FriendQuestType + target Int + status FriendQuestStatus @default(active) + completedAt DateTime? + + participants FriendQuestParticipant[] + + @@index([weekStart, status]) + @@map("friend_quest") +} + +model FriendQuestParticipant { + id String @id @default(cuid()) + questId String + quest FriendQuest @relation(fields: [questId], references: [id], onDelete: Cascade) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + weekStart DateTime @db.Date + + @@unique([questId, userId]) + @@unique([userId, weekStart]) + @@map("friend_quest_participant") +} + +// ===================================================================== +// ACADÉMICO — campus, programa, oferta (catálogo CETI) +// ===================================================================== + +model AcademicCampus { + id String @id @default(cuid()) + code String @unique + name String + active Boolean @default(true) + sortOrder Int @default(0) + + offerings AcademicOffering[] + + @@map("academic_campus") +} + +model AcademicProgram { + id String @id @default(cuid()) + code String @unique + name String + active Boolean @default(true) + + offerings AcademicOffering[] + + @@map("academic_program") +} + +/// Combinación real campus×programa que el CETI ofrece hoy. UNIQUE evita +/// combinaciones inventadas por el cliente: sólo se puede elegir una oferta +/// que exista en este catálogo. +model AcademicOffering { + id String @id @default(cuid()) + campusId String + campus AcademicCampus @relation(fields: [campusId], references: [id], onDelete: Restrict) + programId String + program AcademicProgram @relation(fields: [programId], references: [id], onDelete: Restrict) + semesterCount Int + active Boolean @default(true) + + users User[] + + @@unique([campusId, programId]) + @@map("academic_offering") +} + // ===================================================================== // RATE LIMITING // ===================================================================== diff --git a/prisma/seed-academic.ts b/prisma/seed-academic.ts new file mode 100644 index 0000000..c436163 --- /dev/null +++ b/prisma/seed-academic.ts @@ -0,0 +1,107 @@ +import type { PrismaClient } from "@prisma/client"; + +/** + * Catálogo académico del CETI Guadalajara — planteles y programas + * Tecnólogo vigentes (8 semestres). Idempotente: upsert por `code`, nunca + * borra progreso de usuarios ni combinaciones ya elegidas. + * + * Fuente: oferta educativa pública del CETI. Si el catálogo real cambia + * (nuevo plantel, programa dado de baja), este archivo es la fuente de + * verdad — no se edita en Supabase a mano. + */ + +interface CampusSeed { + code: string; + name: string; + sortOrder: number; +} + +interface ProgramSeed { + code: string; + name: string; + /** Códigos de plantel donde se ofrece. */ + campuses: string[]; +} + +const CAMPUSES: CampusSeed[] = [ + { code: "colomos", name: "Colomos", sortOrder: 1 }, + { code: "tonala", name: "Tonalá", sortOrder: 2 }, + { code: "rio-santiago", name: "Río Santiago", sortOrder: 3 }, +]; + +/** Todos los programas listados son Tecnólogo: 8 semestres. */ +const SEMESTER_COUNT = 8; + +const PROGRAMS: ProgramSeed[] = [ + { code: "automatizacion-robotica", name: "Automatización y Robótica", campuses: ["colomos"] }, + { code: "calidad-productividad", name: "Calidad y Productividad", campuses: ["tonala", "rio-santiago"] }, + { code: "construccion", name: "Construcción", campuses: ["colomos"] }, + { + code: "desarrollo-software", + name: "Desarrollo de Software", + campuses: ["colomos", "tonala", "rio-santiago"], + }, + { code: "desarrollo-electronico", name: "Desarrollo Electrónico", campuses: ["tonala"] }, + { + code: "diseno-mecanica-industrial", + name: "Diseño y Mecánica Industrial", + campuses: ["colomos"], + }, + { code: "electromecanica", name: "Electromecánica", campuses: ["colomos"] }, + { code: "mecanica-automotriz", name: "Mecánica Automotriz", campuses: ["colomos"] }, + { code: "quimico-alimentos", name: "Químico en Alimentos", campuses: ["tonala"] }, + { code: "quimico-farmacos", name: "Químico en Fármacos", campuses: ["colomos", "tonala"] }, + { + code: "quimico-procesos-biotecnologia", + name: "Químico en Procesos y Biotecnología", + campuses: ["tonala"], + }, + { + code: "sistemas-electronicos-telecomunicaciones", + name: "Sistemas Electrónicos y Telecomunicaciones", + campuses: ["colomos"], + }, +]; + +export async function seedAcademicCatalog(db: PrismaClient): Promise { + const campusIdByCode = new Map(); + for (const c of CAMPUSES) { + const row = await db.academicCampus.upsert({ + where: { code: c.code }, + update: { name: c.name, sortOrder: c.sortOrder, active: true }, + create: { code: c.code, name: c.name, sortOrder: c.sortOrder }, + }); + campusIdByCode.set(c.code, row.id); + } + + let offeringCount = 0; + for (const p of PROGRAMS) { + const program = await db.academicProgram.upsert({ + where: { code: p.code }, + update: { name: p.name, active: true }, + create: { code: p.code, name: p.name }, + }); + + for (const campusCode of p.campuses) { + const campusId = campusIdByCode.get(campusCode); + if (!campusId) { + throw new Error(`Plantel desconocido en catálogo académico: ${campusCode}`); + } + await db.academicOffering.upsert({ + where: { campusId_programId: { campusId, programId: program.id } }, + update: { semesterCount: SEMESTER_COUNT, active: true }, + create: { + campusId, + programId: program.id, + semesterCount: SEMESTER_COUNT, + active: true, + }, + }); + offeringCount++; + } + } + + console.log( + ` ↳ catálogo académico: ${CAMPUSES.length} planteles, ${PROGRAMS.length} programas, ${offeringCount} ofertas`, + ); +} diff --git a/prisma/seed.ts b/prisma/seed.ts index 297315f..90d0d54 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -1,5 +1,6 @@ import { PrismaClient } from "@prisma/client"; +import { seedAcademicCatalog } from "./seed-academic"; import { seedCourse } from "./seed-content"; import { seedPracticeExercises } from "./seed-practice"; @@ -18,6 +19,7 @@ async function main() { console.log("🌱 Seeding CETI database..."); await seedCourse(db); await seedPracticeExercises(db); + await seedAcademicCatalog(db); console.log("✅ Seed completed."); } diff --git a/scripts/social/backfill-friendship-pairkey.ts b/scripts/social/backfill-friendship-pairkey.ts new file mode 100644 index 0000000..9e124a4 --- /dev/null +++ b/scripts/social/backfill-friendship-pairkey.ts @@ -0,0 +1,227 @@ +/** + * Backfill de `Friendship.pairKey` — Fase 2. + * + * Antes de este backfill, `Friendship` es direccional: nada impedía que + * existieran A→B y B→A a la vez (dos filas para el mismo par). Este script: + * + * 1. PREFLIGHT — agrupa las filas existentes por par canónico + * (min(id), max(id)) y detecta grupos con más de una fila. + * 2. CONSOLIDA duplicados con una precedencia FIJA: + * blocked > accepted > pending + * Dentro del mismo estado, gana la fila más antigua (`createdAt` menor). + * Si un grupo trae un `status` fuera de {pending, accepted, blocked} + * (imposible según el enum actual, pero el script no adivina), ABORTA + * todo el backfill sin escribir nada. + * 3. BACKUP lógico — imprime (y, en modo `--apply`, escribe a un JSON) las + * filas perdedoras ANTES de tocar nada. + * 4. BACKFILL — escribe `pairKey` en la fila ganadora de cada grupo (y en + * cada fila sin duplicado) y borra las perdedoras. + * 5. Abre `FriendshipPeriod` para toda amistad `accepted` resultante, con + * `startedAt = acceptedAt ?? createdAt` — nunca inventa historia + * anterior a lo que ya existía. Idempotente: la unique parcial + * (`friendship_period_open_pair_key`) hace que un re-run no duplique + * periodos vía `skipDuplicates`. + * + * Uso: + * npx dotenv -e .env.local -- tsx scripts/social/backfill-friendship-pairkey.ts --dry-run + * npx dotenv -e .env.local -- tsx scripts/social/backfill-friendship-pairkey.ts --apply + * + * NUNCA correr `--apply` contra producción sin haber revisado el reporte de + * `--dry-run` primero. + */ +import { mkdir, writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { FriendStatus, type Prisma } from "@prisma/client"; + +import { db } from "../../src/lib/db"; +import { canonicalPair, pairKeyOf } from "../../src/lib/social/pair"; + +const STATUS_PRECEDENCE: Record = { + [FriendStatus.blocked]: 3, + [FriendStatus.accepted]: 2, + [FriendStatus.pending]: 1, +}; + +interface FriendshipRow { + id: string; + requesterId: string; + addresseeId: string; + status: FriendStatus; + createdAt: Date; + acceptedAt: Date | null; +} + +interface PairGroup { + lowId: string; + highId: string; + pairKey: string; + rows: FriendshipRow[]; +} + +function groupByPair(rows: FriendshipRow[]): Map { + const groups = new Map(); + for (const row of rows) { + const { lowId, highId } = canonicalPair(row.requesterId, row.addresseeId); + const key = pairKeyOf(row.requesterId, row.addresseeId); + let group = groups.get(key); + if (!group) { + group = { lowId, highId, pairKey: key, rows: [] }; + groups.set(key, group); + } + group.rows.push(row); + } + return groups; +} + +/** Gana: mayor precedencia de estado; empate → `createdAt` más antiguo. */ +function pickWinner(rows: FriendshipRow[]): { winner: FriendshipRow; losers: FriendshipRow[] } { + const sorted = [...rows].sort((a, b) => { + const precedenceDiff = STATUS_PRECEDENCE[b.status] - STATUS_PRECEDENCE[a.status]; + if (precedenceDiff !== 0) return precedenceDiff; + return a.createdAt.getTime() - b.createdAt.getTime(); + }); + const [winner, ...losers] = sorted; + return { winner: winner!, losers }; +} + +export interface BackfillPlan { + totalRows: number; + singleRowGroups: number; + duplicateGroups: PairGroup[]; + winners: Map; + losers: FriendshipRow[]; +} + +export async function planBackfill(): Promise { + const rows = (await db.friendship.findMany({ + select: { + id: true, + requesterId: true, + addresseeId: true, + status: true, + createdAt: true, + acceptedAt: true, + }, + orderBy: { createdAt: "asc" }, + })) as FriendshipRow[]; + + const groups = groupByPair(rows); + const duplicateGroups: PairGroup[] = []; + const winners = new Map(); + const losers: FriendshipRow[] = []; + let singleRowGroups = 0; + + for (const group of groups.values()) { + if (group.rows.length === 1) { + singleRowGroups++; + winners.set(group.pairKey, group.rows[0]!); + continue; + } + + for (const row of group.rows) { + if (!(row.status in STATUS_PRECEDENCE)) { + throw new Error( + `Estado desconocido "${row.status}" en friendship ${row.id} (par ${group.pairKey}) — ` + + "abortando backfill sin escribir nada. Revisa manualmente antes de reintentar.", + ); + } + } + + duplicateGroups.push(group); + const { winner, losers: groupLosers } = pickWinner(group.rows); + winners.set(group.pairKey, winner); + losers.push(...groupLosers); + } + + return { totalRows: rows.length, singleRowGroups, duplicateGroups, winners, losers }; +} + +async function backupLosers(losers: FriendshipRow[]): Promise { + if (losers.length === 0) return null; + const dir = join(process.cwd(), "scripts", "social", "_backfill-backups"); + await mkdir(dir, { recursive: true }); + const path = join(dir, `friendship-pairkey-losers-${Date.now()}.json`); + await writeFile(path, JSON.stringify(losers, null, 2), "utf-8"); + return path; +} + +export async function applyBackfill(plan: BackfillPlan): Promise { + if (plan.losers.length > 0) { + const backupPath = await backupLosers(plan.losers); + console.log(` ↳ backup lógico de ${plan.losers.length} fila(s) perdedora(s): ${backupPath}`); + } + + await db.$transaction(async (tx) => { + if (plan.losers.length > 0) { + await tx.friendship.deleteMany({ where: { id: { in: plan.losers.map((l) => l.id) } } }); + } + for (const winner of plan.winners.values()) { + await tx.friendship.update({ + where: { id: winner.id }, + data: { pairKey: pairKeyOf(winner.requesterId, winner.addresseeId) }, + }); + } + }); + + // Abre FriendshipPeriod para todo accepted resultante. Fuera de la + // transacción anterior (independiente, e idempotente por la unique + // parcial): un re-run del script no duplica periodos. + let periodsOpened = 0; + for (const winner of plan.winners.values()) { + if (winner.status !== FriendStatus.accepted) continue; + const { lowId, highId } = canonicalPair(winner.requesterId, winner.addresseeId); + const res = await db.friendshipPeriod.createMany({ + data: [ + { + userLowId: lowId, + userHighId: highId, + source: null, + sourceContextKey: null, + startedAt: winner.acceptedAt ?? winner.createdAt, + endedAt: null, + }, + ] satisfies Prisma.FriendshipPeriodCreateManyInput[], + skipDuplicates: true, + }); + periodsOpened += res.count; + } + console.log(` ↳ ${periodsOpened} FriendshipPeriod abierto(s) para amistades accepted`); +} + +async function main() { + const apply = process.argv.includes("--apply"); + console.log(`🔍 Preflight de consolidación de Friendship (${apply ? "APLICANDO" : "dry-run"})...`); + + const plan = await planBackfill(); + console.log(` ↳ ${plan.totalRows} filas totales`); + console.log(` ↳ ${plan.singleRowGroups} pares sin duplicado (backfill directo de pairKey)`); + console.log(` ↳ ${plan.duplicateGroups.length} par(es) con duplicados a consolidar`); + for (const g of plan.duplicateGroups) { + const winner = plan.winners.get(g.pairKey)!; + console.log( + ` — par ${g.pairKey}: ${g.rows.length} filas [${g.rows + .map((r) => `${r.status}:${r.id}`) + .join(", ")}] → gana ${winner.id} (${winner.status})`, + ); + } + + if (!apply) { + console.log("\n✅ Dry-run completado — no se escribió nada. Corre con --apply para aplicar."); + return; + } + + await applyBackfill(plan); + console.log("✅ Backfill aplicado."); +} + +if (process.env.VITEST !== "true") { + main() + .catch((err) => { + console.error("❌ Backfill de Friendship.pairKey falló:", err); + process.exit(1); + }) + .finally(async () => { + await db.$disconnect(); + }); +} diff --git a/scripts/social/backfill-xp-legacy.ts b/scripts/social/backfill-xp-legacy.ts new file mode 100644 index 0000000..c539042 --- /dev/null +++ b/scripts/social/backfill-xp-legacy.ts @@ -0,0 +1,117 @@ +/** + * Backfill del ledger de XP — Fase 4. + * + * Crea, para cada `UserStreak` con `totalXp > 0`, UNA fila `XpAward` con + * `reason=legacy_balance` que iguala el `totalXp` pre-ledger. Esa fila NO + * cuenta para ranking/ligas (filtrada explícitamente en las consultas de + * XP competitivo — ver `src/lib/social/competitive-xp.ts`), así que no + * "resucita" XP de semanas que ya pasaron. + * + * Idempotente: `dedupeKey = legacy:` es única por usuario + * (UNIQUE (userId, dedupeKey)); volver a correrlo no duplica nada. + * + * Auditoría: al final imprime `totalXp (UserStreak) vs SUM(XpAward)` por + * usuario y falla si no cuadran — el rollout de ranking/ligas NO debe + * activarse hasta que esto pase limpio (ver del contrato). + * + * Uso: + * npx dotenv -e .env.local -- tsx scripts/social/backfill-xp-legacy.ts + * npx dotenv -e .env.local -- tsx scripts/social/backfill-xp-legacy.ts --dry-run + */ +import { db } from "../../src/lib/db"; +import { xpDedupeKey } from "../../src/lib/xp"; + +export const LEGACY_CUTOVER_KEY = "v1"; + +export async function backfillLegacyXp( + dryRun: boolean, +): Promise<{ candidates: number; inserted: number; skippedZero: number }> { + const streaks = await db.userStreak.findMany({ + select: { userId: true, totalXp: true }, + }); + + let inserted = 0; + let skippedZero = 0; + + for (const s of streaks) { + if (s.totalXp <= 0) { + skippedZero++; + continue; + } + if (dryRun) { + inserted++; + continue; + } + const res = await db.xpAward.createMany({ + data: [ + { + userId: s.userId, + amount: s.totalXp, + reason: "legacy_balance", + dedupeKey: xpDedupeKey.legacy(LEGACY_CUTOVER_KEY), + }, + ], + skipDuplicates: true, + }); + inserted += res.count; + } + + return { candidates: streaks.length, inserted, skippedZero }; +} + +/** Compara `UserStreak.totalXp` contra `SUM(XpAward.amount)` por usuario. */ +export async function auditXpLedger(): Promise< + { userId: string; totalXp: number; ledgerSum: number }[] +> { + const streaks = await db.userStreak.findMany({ select: { userId: true, totalXp: true } }); + const sums = await db.xpAward.groupBy({ + by: ["userId"], + _sum: { amount: true }, + }); + const sumByUser = new Map(sums.map((s) => [s.userId, s._sum.amount ?? 0])); + + return streaks + .map((s) => ({ + userId: s.userId, + totalXp: s.totalXp, + ledgerSum: sumByUser.get(s.userId) ?? 0, + })) + .filter((row) => row.totalXp !== row.ledgerSum); +} + +async function main() { + const dryRun = process.argv.includes("--dry-run"); + console.log(`🌱 Backfill de XP legacy${dryRun ? " (dry-run)" : ""}...`); + + const result = await backfillLegacyXp(dryRun); + console.log( + ` ↳ ${result.candidates} UserStreak revisados, ${result.inserted} XpAward ` + + `${dryRun ? "por insertar" : "insertados"}, ${result.skippedZero} con totalXp=0 (sin fila)`, + ); + + if (dryRun) { + console.log("✅ Dry-run completado — no se escribió nada."); + return; + } + + const mismatches = await auditXpLedger(); + if (mismatches.length > 0) { + console.error(`❌ ${mismatches.length} usuario(s) con totalXp ≠ SUM(XpAward.amount):`); + for (const m of mismatches.slice(0, 20)) { + console.error(` — ${m.userId}: totalXp=${m.totalXp} ledgerSum=${m.ledgerSum}`); + } + process.exit(1); + } + console.log("✅ Auditoría OK: totalXp == SUM(XpAward.amount) para todos los usuarios."); +} + +if (process.env.VITEST !== "true") { + main() + .catch((err) => { + console.error("❌ Backfill de XP legacy falló:", err); + process.exit(1); + }) + .finally(async () => { + await db.$disconnect(); + }); +} diff --git a/src/app/(auth)/login/login-form.tsx b/src/app/(auth)/login/login-form.tsx index 66af4e4..b11640d 100644 --- a/src/app/(auth)/login/login-form.tsx +++ b/src/app/(auth)/login/login-form.tsx @@ -14,6 +14,7 @@ import { import { Input } from "@/components/ui/input"; import { PasswordInput } from "@/components/ui/password-input"; import { authClient } from "@/lib/auth-client"; +import { safeInternalRedirect } from "@/lib/social/redirect"; const loginSchema = z.object({ email: z.string().trim().min(1, "Tu correo es obligatorio").email("Correo inválido"), @@ -27,7 +28,7 @@ type FieldErrors = Partial>; export function LoginForm() { const router = useRouter(); const searchParams = useSearchParams(); - const redirectTo = searchParams.get("redirectTo") ?? "/app"; + const redirectTo = safeInternalRedirect(searchParams.get("redirectTo"), "/app"); const [isPending, startTransition] = React.useTransition(); const [isGoogleLoading, setIsGoogleLoading] = React.useState(false); diff --git a/src/app/(auth)/registro/register-form.tsx b/src/app/(auth)/registro/register-form.tsx index 4b8d81c..1bfeb80 100644 --- a/src/app/(auth)/registro/register-form.tsx +++ b/src/app/(auth)/registro/register-form.tsx @@ -1,7 +1,7 @@ "use client"; import * as React from "react"; -import { useRouter } from "next/navigation"; +import { useRouter, useSearchParams } from "next/navigation"; import { AlertCircle, AtSign, Check, Lock, Mail, User } from "lucide-react"; import { toast } from "sonner"; import { z } from "zod"; @@ -14,6 +14,7 @@ import { import { Input } from "@/components/ui/input"; import { PasswordInput } from "@/components/ui/password-input"; import { authClient } from "@/lib/auth-client"; +import { safeInternalRedirect } from "@/lib/social/redirect"; import { USERNAME_MAX, USERNAME_MIN, @@ -52,6 +53,8 @@ type UsernameStatus = export function RegisterForm() { const router = useRouter(); + const searchParams = useSearchParams(); + const redirectTo = safeInternalRedirect(searchParams.get("redirectTo"), "/app"); const [isPending, startTransition] = React.useTransition(); const [isGoogleLoading, setIsGoogleLoading] = React.useState(false); const [formError, setFormError] = React.useState(null); @@ -181,7 +184,7 @@ export function RegisterForm() { } toast.success(`¡Cuenta creada! Bienvenido a ${PRODUCT_NAME}.`); - router.push("/app"); + router.push(redirectTo); router.refresh(); }); } @@ -192,7 +195,7 @@ export function RegisterForm() { setIsGoogleLoading(true); const { error: oauthError } = await authClient.signIn.social({ provider: "google", - callbackURL: "/app", + callbackURL: redirectTo, }); if (oauthError) { failForm(oauthError.message ?? "No pudimos registrarte con Google."); diff --git a/src/app/app/(global)/amigos/page.tsx b/src/app/app/(global)/amigos/page.tsx index 36e0845..adce148 100644 --- a/src/app/app/(global)/amigos/page.tsx +++ b/src/app/app/(global)/amigos/page.tsx @@ -6,7 +6,7 @@ import { getPendingIncoming, getPendingOutgoing, } from "@/features/friends/queries"; -import { requireSession } from "@/lib/get-session"; +import { requireConfirmedUsername } from "@/lib/get-session"; export const metadata = { title: "Amigos", @@ -17,7 +17,9 @@ export default async function AmigosPage({ }: { searchParams: Promise<{ tab?: string }>; }) { - const session = await requireSession(); + // Amigos depende de una identidad pública estable — con username + // provisional (OAuth sin confirmar) redirige a completarlo primero. + const session = await requireConfirmedUsername(); const userId = session.user.id; const [friends, incoming, outgoing, params] = await Promise.all([ diff --git a/src/app/app/(global)/confirmar-usuario/page.tsx b/src/app/app/(global)/confirmar-usuario/page.tsx new file mode 100644 index 0000000..41cafd8 --- /dev/null +++ b/src/app/app/(global)/confirmar-usuario/page.tsx @@ -0,0 +1,46 @@ +import { redirect } from "next/navigation"; + +import { ConfirmUsernameForm } from "@/features/profile/components/confirm-username-form"; +import { requireSession } from "@/lib/get-session"; +import { safeInternalRedirect } from "@/lib/social/redirect"; + +export const metadata = { + title: "Elige tu nombre de usuario", +}; + +interface PageProps { + searchParams: Promise<{ redirectTo?: string }>; +} + +/** + * Confirmación one-shot del username provisional de OAuth. Sólo la + * necesitan cuentas `usernameSetupRequired=true` — si ya está confirmado, + * no hay nada que hacer aquí. + */ +export default async function ConfirmUsernamePage({ searchParams }: PageProps) { + const session = await requireSession(); + const { redirectTo: rawRedirect } = await searchParams; + const redirectTo = safeInternalRedirect(rawRedirect, "/app"); + + if (!session.user.usernameSetupRequired) { + redirect(redirectTo); + } + + return ( +
+

Un último paso

+

+ Elige tu nombre de usuario +

+

+ Con Google entraste con un handle provisional. Elige el definitivo + para que tus compañeros puedan encontrarte — no podrás cambiarlo + después. +

+ +
+ +
+
+ ); +} diff --git a/src/app/app/(global)/perfil/[username]/page.tsx b/src/app/app/(global)/perfil/[username]/page.tsx index 9b40fd6..5fb1532 100644 --- a/src/app/app/(global)/perfil/[username]/page.tsx +++ b/src/app/app/(global)/perfil/[username]/page.tsx @@ -8,7 +8,7 @@ import { LevelBar } from "@/components/ui/level-bar"; import { Readout, ReadoutBar } from "@/components/ui/readout"; import { SectionRule } from "@/components/ui/section-rule"; import { StreakFlame } from "@/components/ui/streak-flame"; -import { getActivityFeed, getPublicProfile } from "@/features/friends/queries"; +import { getPublicProfile, getUserLessonActivity } from "@/features/friends/queries"; import { ProfileActions } from "@/features/friends/components/profile-actions"; import { ActivityFeed } from "@/features/friends/components/activity-feed"; import { BioEditor } from "@/features/profile/components/bio-editor"; @@ -68,8 +68,10 @@ export default async function PublicProfilePage({ params }: PageProps) { .slice(0, 2) .toUpperCase(); - // Feed sólo para uno mismo y amigos. - const feed = isSelf || isFriend ? await getActivityFeed(profile.id, 8) : []; + // Feed sólo para uno mismo y amigos. `profile.id` es el DUEÑO del + // perfil: esto trae la actividad de esa persona, no la de sus amigos + // (ver `getUserLessonActivity`). + const feed = isSelf || isFriend ? await getUserLessonActivity(profile.id, 8) : []; return (
+ {!session ? : null}
) : (
diff --git a/src/features/academic/actions.ts b/src/features/academic/actions.ts new file mode 100644 index 0000000..9057e7e --- /dev/null +++ b/src/features/academic/actions.ts @@ -0,0 +1,100 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { z } from "zod"; + +import { ActionError, withActionErrorHandling } from "@/lib/action-error"; +import { db } from "@/lib/db"; +import { requireSession } from "@/lib/get-session"; +import { logger } from "@/lib/logger"; +import { cuidSchema, parseOrThrow } from "@/lib/validation"; + +import { normalizeAcademicGroup } from "./lib/group"; + +const updateAcademicProfileSchema = z.object({ + // `null` limpia la identidad académica por completo. + academicOfferingId: cuidSchema.nullable(), + academicSemester: z.number().int().nullable(), + academicGroup: z.string().max(60).nullable(), +}); + +/** + * Actualiza (o limpia) la identidad académica del usuario actual. El + * servidor SIEMPRE revalida `academicOfferingId` contra el catálogo real — + * nunca confía en semesterCount ni en nombres que pudiera mandar el + * cliente — y aplica las invariantes del contrato antes de tocar la fila: + * + * - offering y semester van juntos (ambos null o ambos presentes) + * - semester en [1, offering.semesterCount] + * - group sólo con offering+semester, normalizado (trim/colapsa/upper/≤20) + * + * El CHECK de Postgres (`user_academic_*`) es la red de seguridad final, + * pero estos mensajes son los que ve el alumno. + */ +export const updateAcademicProfile = withActionErrorHandling( + "updateAcademicProfile", + async (input: { + academicOfferingId: string | null; + academicSemester: number | null; + academicGroup: string | null; + }): Promise<{ ok: true }> => { + const session = await requireSession(); + const parsed = parseOrThrow(updateAcademicProfileSchema, input); + + if (parsed.academicOfferingId === null) { + await db.user.update({ + where: { id: session.user.id }, + data: { + academicOfferingId: null, + academicSemester: null, + academicGroup: null, + }, + }); + revalidatePath("/app/perfil"); + return { ok: true }; + } + + if (parsed.academicSemester === null) { + throw new ActionError("Elige tu semestre"); + } + + const offering = await db.academicOffering.findUnique({ + where: { id: parsed.academicOfferingId }, + select: { id: true, active: true, semesterCount: true }, + }); + if (!offering || !offering.active) { + throw new ActionError("Esa combinación de plantel y carrera no existe"); + } + if (parsed.academicSemester < 1 || parsed.academicSemester > offering.semesterCount) { + throw new ActionError(`El semestre debe estar entre 1 y ${offering.semesterCount}`); + } + + const group = normalizeAcademicGroup(parsed.academicGroup); + + await db.user.update({ + where: { id: session.user.id }, + data: { + academicOfferingId: offering.id, + academicSemester: parsed.academicSemester, + academicGroup: group, + }, + }); + + logger.info({ userId: session.user.id }, "academic profile updated"); + revalidatePath("/app/perfil"); + return { ok: true }; + }, +); + +/** Descarta el prompt "Encuentra a tus compañeros" sin llenar nada. */ +export const dismissAcademicPrompt = withActionErrorHandling( + "dismissAcademicPrompt", + async (): Promise<{ ok: true }> => { + const session = await requireSession(); + await db.user.update({ + where: { id: session.user.id }, + data: { academicPromptDismissedAt: new Date() }, + }); + return { ok: true }; + }, +); diff --git a/src/features/academic/lib/group.ts b/src/features/academic/lib/group.ts new file mode 100644 index 0000000..eb7cd29 --- /dev/null +++ b/src/features/academic/lib/group.ts @@ -0,0 +1,13 @@ +/** Máximo de caracteres de un grupo académico (ej. "3A", "MATUTINO-2"). */ +export const ACADEMIC_GROUP_MAX = 20; + +/** + * Normaliza un grupo: trim, colapsa espacios internos, uppercase, recorta a + * `ACADEMIC_GROUP_MAX`. Cadena vacía tras normalizar → `null` (se limpia). + */ +export function normalizeAcademicGroup(raw: string | null | undefined): string | null { + if (!raw) return null; + const collapsed = raw.trim().replace(/\s+/g, " ").toUpperCase(); + if (collapsed.length === 0) return null; + return collapsed.slice(0, ACADEMIC_GROUP_MAX); +} diff --git a/src/features/academic/queries.ts b/src/features/academic/queries.ts new file mode 100644 index 0000000..3463a4f --- /dev/null +++ b/src/features/academic/queries.ts @@ -0,0 +1,89 @@ +import { cache } from "react"; + +import { db } from "@/lib/db"; + +export interface AcademicOfferingOption { + id: string; + campusId: string; + campusCode: string; + campusName: string; + programId: string; + programCode: string; + programName: string; + semesterCount: number; +} + +/** + * Ofertas académicas activas (campus × programa) para el selector de + * perfil. El cliente elige entre ESTAS combinaciones reales — nunca puede + * mandar un `academicOfferingId` inventado, porque el servidor siempre + * revalida contra esta misma tabla en `updateAcademicProfile`. + */ +export const getAcademicOptions = cache(async (): Promise => { + const offerings = await db.academicOffering.findMany({ + where: { active: true, campus: { active: true }, program: { active: true } }, + select: { + id: true, + semesterCount: true, + campus: { select: { id: true, code: true, name: true, sortOrder: true } }, + program: { select: { id: true, code: true, name: true } }, + }, + orderBy: [{ campus: { sortOrder: "asc" } }, { program: { name: "asc" } }], + }); + + return offerings.map((o) => ({ + id: o.id, + campusId: o.campus.id, + campusCode: o.campus.code, + campusName: o.campus.name, + programId: o.program.id, + programCode: o.program.code, + programName: o.program.name, + semesterCount: o.semesterCount, + })); +}); + +export interface AcademicProfile { + offering: { id: string; campusName: string; programName: string; semesterCount: number } | null; + semester: number | null; + group: string | null; + promptDismissedAt: Date | null; +} + +/** Perfil académico del usuario actual (self — siempre incluye el grupo exacto). */ +export const getOwnAcademicProfile = cache( + async (userId: string): Promise => { + const user = await db.user.findUnique({ + where: { id: userId }, + select: { + academicSemester: true, + academicGroup: true, + academicPromptDismissedAt: true, + academicOffering: { + select: { + id: true, + semesterCount: true, + campus: { select: { name: true } }, + program: { select: { name: true } }, + }, + }, + }, + }); + if (!user) { + return { offering: null, semester: null, group: null, promptDismissedAt: null }; + } + return { + offering: user.academicOffering + ? { + id: user.academicOffering.id, + campusName: user.academicOffering.campus.name, + programName: user.academicOffering.program.name, + semesterCount: user.academicOffering.semesterCount, + } + : null, + semester: user.academicSemester, + group: user.academicGroup, + promptDismissedAt: user.academicPromptDismissedAt, + }; + }, +); diff --git a/src/features/discovery/queries.ts b/src/features/discovery/queries.ts new file mode 100644 index 0000000..a917f9c --- /dev/null +++ b/src/features/discovery/queries.ts @@ -0,0 +1,268 @@ +import { Prisma } from "@prisma/client"; + +import { ActionError } from "@/lib/action-error"; +import { db } from "@/lib/db"; +import { decodeSignedToken, encodeSignedToken } from "@/lib/social/signed-token"; + +export const DISCOVERY_PAGE_MAX = 40; +const CURSOR_TTL_MS = 60 * 60 * 1000; +/** TTL del context token que correlaciona "vi este candidato en discovery" con la solicitud que mande. */ +const CONTEXT_TOKEN_TTL_MS = 30 * 60 * 1000; + +export type DiscoveryBucket = 1 | 2 | 3 | 4 | 5; + +export interface DiscoveryCandidate { + id: string; + username: string; + name: string; + image: string | null; + bucket: DiscoveryBucket; + mutualCount: number; + /** Texto ya resuelto — NUNCA revela el grupo exacto a un no-amigo. */ + reason: string; + /** Token firmado: correlaciona un `sendFriendRequest(source:"discovery")` con ESTE resultado. */ + contextToken: string; +} + +export interface DiscoveryPage { + candidates: DiscoveryCandidate[]; + nextCursor: string | null; +} + +interface CursorPayload { + bucket: number; + negMutual: number; + negLastSig: number; + username: string; + id: string; + snapshotAt: string; +} + +interface Row { + id: string; + username: string; + name: string; + image: string | null; + bucket: number; + mutual_count: number; + program_name: string | null; + semester: number | null; + last_significant_epoch: string | null; +} + +const NULL_LAST_SIG_SENTINEL = 9e18; + +function contextTokenFor(viewerId: string, candidateId: string, bucket: DiscoveryBucket): string { + return encodeSignedToken( + { viewerId, candidateId, bucket: String(bucket) }, + CONTEXT_TOKEN_TTL_MS, + ); +} + +function reasonFor(bucket: DiscoveryBucket, row: Row, courseTitle: string | null): string { + switch (bucket) { + case 1: + return `${row.mutual_count} ${row.mutual_count === 1 ? "amigo en común" : "amigos en común"}`; + case 2: + return "Mismo grupo"; + case 3: + return row.program_name && row.semester + ? `${row.program_name} · ${row.semester}.º semestre` + : "Misma carrera y semestre"; + case 4: + return courseTitle ? `También estudia ${courseTitle}` : "También estudia este curso"; + case 5: + return "Mismo plantel"; + } +} + +/** + * Descubrimiento de compañeros — 5 buckets EXACTOS, primer match gana, + * orden `bucket ASC, mutualCount DESC (bucket1), lastSignificantAt DESC + * NULLS LAST, username ASC, id ASC`. Implementado con UNA consulta SQL + * parametrizada (CTEs) para mantener esa semántica sin concatenar 5 + * arrays ni hacer N+1: cada candidato se evalúa una sola vez contra los 5 + * criterios, en el orden de precedencia del contrato. + * + * `courseId` es el curso SELECCIONADO actualmente por el viewer (o `null` + * si no hay uno válido — bucket4 se omite entonces). `snapshotAt` viene + * del cursor (si lo hay) para que `lastSignificantAt` no cambie de + * significado entre páginas de la misma sesión de scroll. + */ +export async function getDiscoveryCandidates( + viewerId: string, + opts: { courseId: string | null; cursor?: string | null; pageSize?: number }, +): Promise { + const pageSize = Math.min(Math.max(opts.pageSize ?? DISCOVERY_PAGE_MAX, 1), DISCOVERY_PAGE_MAX); + + let cursor: CursorPayload | null = null; + if (opts.cursor) { + const decoded = decodeSignedToken(opts.cursor); + if (!decoded) throw new ActionError("El cursor de resultados ya no es válido."); + cursor = decoded; + } + const snapshotAt = cursor ? new Date(cursor.snapshotAt) : new Date(); + const courseId = opts.courseId; + + const rows = await db.$queryRaw(Prisma.sql` + WITH excluded AS ( + SELECT ${viewerId}::text AS id + UNION + SELECT CASE WHEN f."requesterId" = ${viewerId} THEN f."addresseeId" ELSE f."requesterId" END + FROM friendship f + WHERE f.status IN ('accepted', 'pending', 'blocked') + AND ${viewerId} IN (f."requesterId", f."addresseeId") + ), + viewer_friends AS ( + SELECT CASE WHEN f."requesterId" = ${viewerId} THEN f."addresseeId" ELSE f."requesterId" END AS friend_id + FROM friendship f + WHERE f.status = 'accepted' AND ${viewerId} IN (f."requesterId", f."addresseeId") + ), + viewer AS ( + SELECT u.id, u."academicOfferingId", u."academicSemester", u."academicGroup", + ao."programId" AS "programId", ao."campusId" AS "campusId" + FROM "user" u + LEFT JOIN academic_offering ao ON ao.id = u."academicOfferingId" + WHERE u.id = ${viewerId} + ), + candidates_base AS ( + SELECT u.id, u.username, u.name, u.image, + u."academicOfferingId", u."academicSemester", u."academicGroup", + ao."programId" AS "programId", ao."campusId" AS "campusId" + FROM "user" u + LEFT JOIN academic_offering ao ON ao.id = u."academicOfferingId" + WHERE u."usernameSetupRequired" = false + AND u.id NOT IN (SELECT id FROM excluded) + ), + mutuals AS ( + SELECT cb.id AS candidate_id, COUNT(*)::int AS mutual_count + FROM candidates_base cb + JOIN friendship f ON f.status = 'accepted' AND (f."requesterId" = cb.id OR f."addresseeId" = cb.id) + JOIN viewer_friends vf + ON vf.friend_id = CASE WHEN f."requesterId" = cb.id THEN f."addresseeId" ELSE f."requesterId" END + GROUP BY cb.id + ), + course_active AS ( + SELECT DISTINCT u.id + FROM candidates_base u + WHERE ${courseId}::text IS NOT NULL + AND ( + EXISTS ( + SELECT 1 FROM user_lesson_progress ulp + JOIN lesson l ON l.id = ulp."lessonId" + JOIN unit un ON un.id = l."unitId" + WHERE ulp."userId" = u.id AND un."courseId" = ${courseId} + ) + OR EXISTS ( + SELECT 1 FROM user_exercise_attempt uea + JOIN exercise e ON e.id = uea."exerciseId" + JOIN lesson_step ls ON ls.id = e."stepId" + JOIN lesson l2 ON l2.id = ls."lessonId" + JOIN unit un2 ON un2.id = l2."unitId" + WHERE uea."userId" = u.id AND un2."courseId" = ${courseId} + ) + OR EXISTS ( + SELECT 1 FROM user_practice_attempt upa + JOIN practice_exercise pe ON pe.id = upa."exerciseId" + WHERE upa."userId" = u.id AND pe."courseId" = ${courseId} + ) + ) + ), + last_significant AS ( + SELECT t."userId", MAX(t.at) AS at + FROM ( + SELECT "userId", "completedAt" AS at FROM user_lesson_progress + WHERE status = 'completed' AND "completedAt" IS NOT NULL AND "completedAt" <= ${snapshotAt} + UNION ALL + SELECT "userId", "createdAt" FROM user_exercise_attempt WHERE "createdAt" <= ${snapshotAt} + UNION ALL + SELECT "userId", "createdAt" FROM user_practice_attempt WHERE "createdAt" <= ${snapshotAt} + ) t + GROUP BY t."userId" + ), + bucketed AS ( + SELECT + cb.id, cb.username, cb.name, cb.image, + COALESCE(m.mutual_count, 0) AS mutual_count, + p.name AS program_name, + cb."academicSemester" AS semester, + CASE + WHEN COALESCE(m.mutual_count, 0) > 0 THEN 1 + WHEN v."academicOfferingId" IS NOT NULL + AND cb."academicOfferingId" = v."academicOfferingId" + AND cb."academicSemester" = v."academicSemester" + AND cb."academicGroup" IS NOT NULL + AND cb."academicGroup" = v."academicGroup" THEN 2 + WHEN v."programId" IS NOT NULL + AND cb."programId" = v."programId" + AND cb."academicSemester" = v."academicSemester" THEN 3 + WHEN ${courseId}::text IS NOT NULL AND ca.id IS NOT NULL THEN 4 + WHEN v."campusId" IS NOT NULL AND cb."campusId" = v."campusId" THEN 5 + ELSE NULL + END AS bucket + FROM candidates_base cb + CROSS JOIN viewer v + LEFT JOIN mutuals m ON m.candidate_id = cb.id + LEFT JOIN course_active ca ON ca.id = cb.id + LEFT JOIN academic_program p ON p.id = cb."programId" + ), + final AS ( + SELECT + b.id, b.username, b.name, b.image, b.bucket, b.mutual_count, b.program_name, b.semester, + COALESCE(EXTRACT(EPOCH FROM ls.at) * 1000, ${NULL_LAST_SIG_SENTINEL}::float8) AS last_significant_epoch + FROM bucketed b + LEFT JOIN last_significant ls ON ls."userId" = b.id + WHERE b.bucket IS NOT NULL + ) + SELECT id, username, name, image, bucket, mutual_count, program_name, semester, + last_significant_epoch::text AS last_significant_epoch + FROM final + WHERE ${ + cursor + ? Prisma.sql`(bucket, -mutual_count, last_significant_epoch, username, id) > + (${cursor.bucket}, ${cursor.negMutual}, ${-cursor.negLastSig}, ${cursor.username}, ${cursor.id})` + : Prisma.sql`TRUE` + } + ORDER BY bucket ASC, -mutual_count ASC, last_significant_epoch ASC, username ASC, id ASC + LIMIT ${pageSize + 1} + `); + + const courseTitle = courseId + ? (await db.course.findUnique({ where: { id: courseId }, select: { title: true } }))?.title ?? null + : null; + + const hasMore = rows.length > pageSize; + const page = rows.slice(0, pageSize); + + const candidates: DiscoveryCandidate[] = page.map((row) => { + const bucket = row.bucket as DiscoveryBucket; + return { + id: row.id, + username: row.username, + name: row.name, + image: row.image, + bucket, + mutualCount: row.mutual_count, + reason: reasonFor(bucket, row, courseTitle), + contextToken: contextTokenFor(viewerId, row.id, bucket), + }; + }); + + let nextCursor: string | null = null; + const last = page.at(-1); + if (hasMore && last) { + nextCursor = encodeSignedToken( + { + bucket: last.bucket, + negMutual: -last.mutual_count, + negLastSig: -Number(last.last_significant_epoch), + username: last.username, + id: last.id, + snapshotAt: snapshotAt.toISOString(), + } satisfies CursorPayload, + CURSOR_TTL_MS, + ); + } + + return { candidates, nextCursor }; +} diff --git a/src/features/friends/actions.ts b/src/features/friends/actions.ts index 5143c88..1ae1671 100644 --- a/src/features/friends/actions.ts +++ b/src/features/friends/actions.ts @@ -1,6 +1,6 @@ "use server"; -import { FriendStatus } from "@prisma/client"; +import { FriendStatus, type FriendRequestSource } from "@prisma/client"; import { revalidatePath } from "next/cache"; import { z } from "zod"; @@ -8,9 +8,33 @@ import { ActionError, withActionErrorHandling } from "@/lib/action-error"; import { db } from "@/lib/db"; import { requireSession } from "@/lib/get-session"; import { logger } from "@/lib/logger"; +import { enforceRateLimit } from "@/lib/rate-limit"; +import { + closeFriendshipPeriod, + friendshipCreateData, + openFriendshipPeriod, +} from "@/lib/social/friendship-lifecycle"; +import { decodeSignedToken } from "@/lib/social/signed-token"; +import { endFriendStreakForPair } from "@/lib/social/friend-streak"; import { cuidSchema, parseOrThrow, usernameSchema } from "@/lib/validation"; -const sendByUsernameSchema = z.object({ username: usernameSchema }); +/** Máximo de solicitudes salientes pendientes al mismo tiempo. */ +const MAX_PENDING_OUTGOING = 50; + +interface DiscoveryTokenPayload { + viewerId: string; + candidateId: string; + bucket: string; +} + +const friendRequestSourceSchema = z.enum(["profile", "search", "discovery", "invite"]); + +const sendByUsernameSchema = z.object({ + username: usernameSchema, + source: friendRequestSourceSchema, + /** Sólo cuando `source === "discovery"` — token firmado del resultado. */ + discoveryToken: z.string().optional(), +}); const byUserIdSchema = z.object({ userId: cuidSchema }); const byFriendshipIdSchema = z.object({ friendshipId: cuidSchema }); const respondSchema = z.object({ @@ -25,77 +49,120 @@ const respondSchema = z.object({ * - ya somos amigos → noop * - yo ya tengo solicitud pendiente con esta persona → noop * - la otra persona ya me mandó solicitud → AUTO-ACEPTA (crossed-requests) + * + * `source` lo elige el COMPONENTE que llama (cada superficie pasa su propio + * literal fijo) — el cliente nunca manda una cadena libre. Para + * `source: "discovery"`, `discoveryToken` es el token firmado que + * `getDiscoveryCandidates` emitió para ESE candidato: sin uno válido que + * corresponda a (viewer, target), la solicitud se rechaza. */ export const sendFriendRequest = withActionErrorHandling( "sendFriendRequest", - async (input: { username: string }): Promise<{ status: "sent" | "accepted" | "already" }> => { + async (input: { + username: string; + source: FriendRequestSource; + discoveryToken?: string; + }): Promise<{ status: "sent" | "accepted" | "already" }> => { const session = await requireSession(); const me = session.user.id; - const { username } = parseOrThrow(sendByUsernameSchema, input); + if (session.user.usernameSetupRequired) { + throw new ActionError("Confirma tu nombre de usuario antes de agregar amigos"); + } + const { username, source, discoveryToken } = parseOrThrow(sendByUsernameSchema, input); + await enforceRateLimit(me, "friend-request"); const target = await db.user.findUnique({ where: { username }, - select: { id: true }, + select: { id: true, usernameSetupRequired: true }, }); - if (!target) throw new ActionError("No encontramos ese usuario"); + if (!target || target.usernameSetupRequired) { + throw new ActionError("No encontramos ese usuario"); + } if (target.id === me) throw new ActionError("No puedes agregarte a ti mismo"); - return db.$transaction(async (tx) => { - const existing = await tx.friendship.findMany({ - where: { - OR: [ - { requesterId: me, addresseeId: target.id }, - { requesterId: target.id, addresseeId: me }, - ], - }, - }); + let sourceContextKey: string | null = null; + if (source === "discovery") { + const payload = decodeSignedToken(discoveryToken); + if (!payload || payload.viewerId !== me || payload.candidateId !== target.id) { + throw new ActionError("Ese resultado ya no es válido. Actualiza la página."); + } + sourceContextKey = payload.bucket; + } + + const pendingOutgoing = await db.friendship.count({ + where: { requesterId: me, status: FriendStatus.pending }, + }); + if (pendingOutgoing >= MAX_PENDING_OUTGOING) { + throw new ActionError("Tienes demasiadas solicitudes pendientes. Espera a que respondan."); + } - const blocked = existing.find((r) => r.status === FriendStatus.blocked); - if (blocked) throw new ActionError("No podemos enviar esa solicitud"); + return db + .$transaction(async (tx) => { + const existing = await tx.friendship.findMany({ + where: { + OR: [ + { requesterId: me, addresseeId: target.id }, + { requesterId: target.id, addresseeId: me }, + ], + }, + }); - const friends = existing.find((r) => r.status === FriendStatus.accepted); - if (friends) return { status: "already" as const }; + const blocked = existing.find((r) => r.status === FriendStatus.blocked); + if (blocked) throw new ActionError("No podemos enviar esa solicitud"); - const outgoingPending = existing.find( - (r) => r.status === FriendStatus.pending && r.requesterId === me, - ); - if (outgoingPending) return { status: "already" as const }; + const friends = existing.find((r) => r.status === FriendStatus.accepted); + if (friends) return { status: "already" as const }; - const incomingPending = existing.find( - (r) => r.status === FriendStatus.pending && r.requesterId === target.id, - ); - if (incomingPending) { - // Crossed requests → auto-aceptar la entrante. - await tx.friendship.update({ - where: { id: incomingPending.id }, - data: { status: FriendStatus.accepted, acceptedAt: new Date() }, + const outgoingPending = existing.find( + (r) => r.status === FriendStatus.pending && r.requesterId === me, + ); + if (outgoingPending) return { status: "already" as const }; + + const incomingPending = existing.find( + (r) => r.status === FriendStatus.pending && r.requesterId === target.id, + ); + if (incomingPending) { + // Crossed requests → auto-aceptar la entrante. El período usa el + // source/contexto de la solicitud ORIGINAL (la que ya existía). + const acceptedAt = new Date(); + await tx.friendship.update({ + where: { id: incomingPending.id }, + data: { status: FriendStatus.accepted, acceptedAt }, + }); + await openFriendshipPeriod( + tx, + me, + target.id, + incomingPending.requestSource, + incomingPending.sourceContextKey, + acceptedAt, + ); + logger.info({ me, other: target.id }, "friendship auto-accepted (crossed)"); + return { status: "accepted" as const }; + } + + // `createMany({ skipDuplicates })` = INSERT ... ON CONFLICT DO NOTHING: + // resuelve la race (alguien creó la fila entre el findMany y esto) sin + // lanzar P2002, que dejaría la transacción de Postgres abortada. + const inserted = await tx.friendship.createMany({ + data: [ + { + ...friendshipCreateData(me, target.id, source, sourceContextKey), + status: FriendStatus.pending, + }, + ], + skipDuplicates: true, }); - logger.info({ me, other: target.id }, "friendship auto-accepted (crossed)"); - return { status: "accepted" as const }; - } + if (inserted.count === 0) return { status: "already" as const }; - // `createMany({ skipDuplicates })` = INSERT ... ON CONFLICT DO NOTHING: - // resuelve la race (alguien creó la fila entre el findMany y esto) sin - // lanzar P2002, que dejaría la transacción de Postgres abortada. - const inserted = await tx.friendship.createMany({ - data: [ - { - requesterId: me, - addresseeId: target.id, - status: FriendStatus.pending, - }, - ], - skipDuplicates: true, + logger.info({ me, other: target.id, source }, "friend request sent"); + return { status: "sent" as const }; + }) + .then((result) => { + revalidatePath("/app/amigos"); + revalidatePath(`/app/perfil/${username}`); + return result; }); - if (inserted.count === 0) return { status: "already" as const }; - - logger.info({ me, other: target.id }, "friend request sent"); - return { status: "sent" as const }; - }).then((result) => { - revalidatePath("/app/amigos"); - revalidatePath(`/app/perfil/${username}`); - return result; - }); }, ); @@ -111,7 +178,13 @@ export const respondFriendRequest = withActionErrorHandling( const row = await db.friendship.findUnique({ where: { id: friendshipId }, - select: { addresseeId: true, status: true }, + select: { + addresseeId: true, + requesterId: true, + status: true, + requestSource: true, + sourceContextKey: true, + }, }); if (!row || row.addresseeId !== me) { throw new ActionError("Solicitud no encontrada"); @@ -121,9 +194,20 @@ export const respondFriendRequest = withActionErrorHandling( } if (accept) { - await db.friendship.update({ - where: { id: friendshipId }, - data: { status: FriendStatus.accepted, acceptedAt: new Date() }, + const acceptedAt = new Date(); + await db.$transaction(async (tx) => { + await tx.friendship.update({ + where: { id: friendshipId }, + data: { status: FriendStatus.accepted, acceptedAt }, + }); + await openFriendshipPeriod( + tx, + row.requesterId, + me, + row.requestSource, + row.sourceContextKey, + acceptedAt, + ); }); logger.info({ me, friendshipId }, "friend request accepted"); } else { @@ -166,7 +250,8 @@ export const cancelFriendRequest = withActionErrorHandling( /** * Quita a un amigo (sin importar quién fue requester). Borra la fila - * accepted; sin notificación a la otra parte (es ghost-friendly). + * accepted; sin notificación a la otra parte (es ghost-friendly). Cierra el + * período de amistad y cualquier Friend Streak activo en la MISMA operación. */ export const removeFriend = withActionErrorHandling( "removeFriend", @@ -175,18 +260,22 @@ export const removeFriend = withActionErrorHandling( const me = session.user.id; const { userId } = parseOrThrow(byUserIdSchema, input); - const result = await db.friendship.deleteMany({ - where: { - status: FriendStatus.accepted, - OR: [ - { requesterId: me, addresseeId: userId }, - { requesterId: userId, addresseeId: me }, - ], - }, + await db.$transaction(async (tx) => { + const result = await tx.friendship.deleteMany({ + where: { + status: FriendStatus.accepted, + OR: [ + { requesterId: me, addresseeId: userId }, + { requesterId: userId, addresseeId: me }, + ], + }, + }); + if (result.count === 0) { + throw new ActionError("Esa amistad ya no existe"); + } + await closeFriendshipPeriod(tx, me, userId, "unfriended"); + await endFriendStreakForPair(tx, me, userId, "unfriended"); }); - if (result.count === 0) { - throw new ActionError("Esa amistad ya no existe"); - } logger.info({ me, other: userId }, "friend removed"); revalidatePath("/app/amigos"); @@ -219,11 +308,12 @@ export const blockUser = withActionErrorHandling( }); await tx.friendship.create({ data: { - requesterId: me, - addresseeId: userId, + ...friendshipCreateData(me, userId, null, null), status: FriendStatus.blocked, }, }); + await closeFriendshipPeriod(tx, me, userId, "blocked"); + await endFriendStreakForPair(tx, me, userId, "blocked"); }); logger.info({ me, other: userId }, "user blocked"); diff --git a/src/features/friends/components/profile-actions.tsx b/src/features/friends/components/profile-actions.tsx index dd3f981..579b2e5 100644 --- a/src/features/friends/components/profile-actions.tsx +++ b/src/features/friends/components/profile-actions.tsx @@ -11,6 +11,7 @@ import { UserPlus, X, } from "lucide-react"; +import type { FriendRequestSource } from "@prisma/client"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; @@ -36,12 +37,15 @@ interface ProfileActionsProps { userId: string; username: string; state: FriendshipState; + /** Superficie desde la que se manda la solicitud — nunca la elige el cliente libremente. */ + source?: FriendRequestSource; } export function ProfileActions({ userId, username, state: initialState, + source = "profile", }: ProfileActionsProps) { const router = useRouter(); const [state, setState] = React.useState(initialState); @@ -75,7 +79,7 @@ export function ProfileActions({ loading={pending} onClick={() => runWith(async () => { - const result = await sendFriendRequest({ username }); + const result = await sendFriendRequest({ username, source }); return { next: result.status === "accepted" ? "friends" : "pending_outgoing", toast: diff --git a/src/features/friends/components/user-search.tsx b/src/features/friends/components/user-search.tsx index d72ae3b..c117d37 100644 --- a/src/features/friends/components/user-search.tsx +++ b/src/features/friends/components/user-search.tsx @@ -60,7 +60,7 @@ export function UserSearch({ meUsername }: UserSearchProps) { prev.map((u) => (u.id === user.id ? { ...u, pending: true } : u)), ); try { - const result = await sendFriendRequest({ username: user.username }); + const result = await sendFriendRequest({ username: user.username, source: "search" }); setResults((prev) => prev.map((u) => u.id === user.id diff --git a/src/features/friends/queries.ts b/src/features/friends/queries.ts index afcbc0f..d13c0ec 100644 --- a/src/features/friends/queries.ts +++ b/src/features/friends/queries.ts @@ -1,4 +1,4 @@ -import { FriendStatus, type Prisma } from "@prisma/client"; +import { FriendStatus, Prisma } from "@prisma/client"; import { cache } from "react"; import { db } from "@/lib/db"; @@ -220,10 +220,13 @@ export async function searchUsers( excludedIds.add(b.addresseeId); } - // username prefix (case-insensitive) o name contains (case-insensitive) + // username prefix (case-insensitive) o name contains (case-insensitive). + // Excluye cuentas OAuth con setup pendiente: sin identidad pública + // estable, no deben aparecer en discovery/search. const candidates = await db.user.findMany({ where: { id: { notIn: Array.from(excludedIds) }, + usernameSetupRequired: false, OR: [ { username: { startsWith: q.toLowerCase(), mode: "insensitive" } }, { name: { contains: q, mode: "insensitive" } }, @@ -283,10 +286,14 @@ export async function getPublicProfile( image: true, bio: true, createdAt: true, + usernameSetupRequired: true, streak: { select: { totalXp: true, currentStreak: true, longestStreak: true } }, }, }); if (!user) return null; + // Setup de username sin terminar: sin identidad pública estable, no hay + // perfil social que mostrar a un tercero (self sigue viendo el suyo). + if (user.usernameSetupRequired && user.id !== viewerId) return null; const [completedLessons, completedExercises, state] = await Promise.all([ db.userLessonProgress.count({ @@ -312,56 +319,28 @@ export async function getPublicProfile( }; } -/** - * Feed de actividad reciente de los amigos del usuario. - * V1 sólo emite "lesson_completed" — los milestones de level-up/racha - * requerirían snapshots históricos que aún no almacenamos. - */ -export async function getActivityFeed( - viewerId: string, - limit = 30, -): Promise { - const friendIds = ( - await db.friendship.findMany({ - where: { - status: FriendStatus.accepted, - OR: [{ requesterId: viewerId }, { addresseeId: viewerId }], - }, - select: { requesterId: true, addresseeId: true }, - }) - ) - .map((r) => (r.requesterId === viewerId ? r.addresseeId : r.requesterId)); - - if (friendIds.length === 0) return []; - - const rows = await db.userLessonProgress.findMany({ - where: { - userId: { in: friendIds }, - status: "completed", - completedAt: { not: null }, - }, +const ACTIVITY_SELECT = { + completedAt: true, + user: { select: { id: true, username: true, name: true, image: true } }, + lesson: { select: { - completedAt: true, - user: { select: { id: true, username: true, name: true, image: true } }, - lesson: { + title: true, + slug: true, + xpReward: true, + unit: { select: { title: true, slug: true, - xpReward: true, - unit: { - select: { - title: true, - slug: true, - course: { select: { slug: true } }, - }, - }, + course: { select: { slug: true } }, }, }, }, - orderBy: { completedAt: "desc" }, - take: limit, - }); + }, +} satisfies Prisma.UserLessonProgressSelect; +function toActivityEvents( + rows: Prisma.UserLessonProgressGetPayload<{ select: typeof ACTIVITY_SELECT }>[], +): ActivityEvent[] { return rows.flatMap((row) => { if (!row.completedAt) return []; return [ @@ -382,6 +361,92 @@ export async function getActivityFeed( }); } +/** + * Actividad propia de UN usuario (`actorId`) — lo que un perfil (el suyo o + * el de un amigo) tiene que mostrar de sí mismo. NO amigos de `actorId`. + * + * Antes de esta separación, el perfil público llamaba a lo que hoy es + * `getFriendsActivityFeed` pasándole el id del DUEÑO del perfil como si + * fuera el viewer: eso mostraba "actividad de los amigos de A" en la + * página de A, en vez de la actividad de A. Ver + * `tests/features/friends/activity.test.ts`. + */ +export async function getUserLessonActivity( + actorId: string, + limit = 30, +): Promise { + const rows = await db.userLessonProgress.findMany({ + where: { userId: actorId, status: "completed", completedAt: { not: null } }, + select: ACTIVITY_SELECT, + orderBy: { completedAt: "desc" }, + take: limit, + }); + return toActivityEvents(rows); +} + +/** + * Feed de actividad reciente de los amigos ACEPTADOS de `viewerId` (no + * incluye la actividad del propio viewer). V1 sólo emite + * "lesson_completed" — los milestones (Fase 3, `SocialEvent`) tienen su + * propio feed en `src/features/social-feed`. + */ +export async function getFriendsActivityFeed( + viewerId: string, + limit = 30, +): Promise { + const friendIds = ( + await db.friendship.findMany({ + where: { + status: FriendStatus.accepted, + OR: [{ requesterId: viewerId }, { addresseeId: viewerId }], + }, + select: { requesterId: true, addresseeId: true }, + }) + ) + .map((r) => (r.requesterId === viewerId ? r.addresseeId : r.requesterId)); + + if (friendIds.length === 0) return []; + + const rows = await db.userLessonProgress.findMany({ + where: { userId: { in: friendIds }, status: "completed", completedAt: { not: null } }, + select: ACTIVITY_SELECT, + orderBy: { completedAt: "desc" }, + take: limit, + }); + return toActivityEvents(rows); +} + +/** + * Amigos en común entre `viewerId` y `otherId` — normaliza los edges + * accepted (direccionales en la tabla) en SQL para no hacer N+1. `preview` + * trae hasta 3 para la UI ("Juan, Ana y 2 más"). + */ +export async function getMutualFriends( + viewerId: string, + otherId: string, +): Promise<{ count: number; preview: { id: string; username: string; name: string; image: string | null }[] }> { + const rows = await db.$queryRaw< + { id: string; username: string; name: string; image: string | null }[] + >(Prisma.sql` + WITH viewer_friends AS ( + SELECT CASE WHEN f."requesterId" = ${viewerId} THEN f."addresseeId" ELSE f."requesterId" END AS friend_id + FROM friendship f + WHERE f.status = 'accepted' AND ${viewerId} IN (f."requesterId", f."addresseeId") + ), + other_friends AS ( + SELECT CASE WHEN f."requesterId" = ${otherId} THEN f."addresseeId" ELSE f."requesterId" END AS friend_id + FROM friendship f + WHERE f.status = 'accepted' AND ${otherId} IN (f."requesterId", f."addresseeId") + ) + SELECT u.id, u.username, u.name, u.image + FROM viewer_friends vf + JOIN other_friends "of" ON "of".friend_id = vf.friend_id + JOIN "user" u ON u.id = vf.friend_id + ORDER BY u.username ASC + `); + return { count: rows.length, preview: rows.slice(0, 3) }; +} + const friendUserSelect = { select: { id: true, diff --git a/src/features/friends/search-action.ts b/src/features/friends/search-action.ts index 638bae3..522c6e5 100644 --- a/src/features/friends/search-action.ts +++ b/src/features/friends/search-action.ts @@ -4,23 +4,25 @@ import { z } from "zod"; import { withActionErrorHandling } from "@/lib/action-error"; import { requireSession } from "@/lib/get-session"; +import { enforceRateLimit } from "@/lib/rate-limit"; import { parseOrThrow } from "@/lib/validation"; import { searchUsers, type UserSearchResult } from "./queries"; export type SearchActionResult = UserSearchResult[]; const schema = z.object({ - query: z.string().trim().min(1).max(50), + query: z.string().trim().min(2, "Escribe al menos 2 caracteres").max(50), }); /** * Wrap de `searchUsers` como Server Action para llamarla desde el cliente - * con debounce. Limita a 12 resultados y require sesión. + * con debounce. Limita a 12 resultados, mínimo 2 caracteres, 30 req/min/user. */ export const searchUsersAction = withActionErrorHandling( "searchUsersAction", async (input: { query: string }): Promise => { const session = await requireSession(); + await enforceRateLimit(session.user.id, "search-users"); const { query } = parseOrThrow(schema, input); return searchUsers(session.user.id, query, 12); }, diff --git a/src/features/invites/actions.ts b/src/features/invites/actions.ts new file mode 100644 index 0000000..097f7d8 --- /dev/null +++ b/src/features/invites/actions.ts @@ -0,0 +1,51 @@ +"use server"; + +import { cookies } from "next/headers"; +import { z } from "zod"; + +import { withActionErrorHandling } from "@/lib/action-error"; +import { db } from "@/lib/db"; +import { env } from "@/env"; +import { getSession } from "@/lib/get-session"; +import { INVITE_COOKIE_MAX_AGE_SEC, INVITE_COOKIE_NAME } from "@/lib/social/invite-cookie"; +import { encodeSignedToken } from "@/lib/social/signed-token"; +import { parseOrThrow, usernameSchema } from "@/lib/validation"; + +const schema = z.object({ inviterUsername: usernameSchema }); + +/** + * Captura FIRST-TOUCH la atribución de invitación: se llama desde + * `/invitar/[username]` cuando NO hay sesión. Idempotente y silenciosa — + * nunca sobreescribe una cookie ya presente (first-touch), nunca falla + * visiblemente para el visitante. + */ +export const captureInviteAttribution = withActionErrorHandling( + "captureInviteAttribution", + async (input: { inviterUsername: string }): Promise<{ captured: boolean }> => { + const session = await getSession(); + if (session?.user) return { captured: false }; + + const store = await cookies(); + if (store.get(INVITE_COOKIE_NAME)?.value) return { captured: false }; + + const { inviterUsername } = parseOrThrow(schema, input); + const inviter = await db.user.findUnique({ + where: { username: inviterUsername }, + select: { id: true, usernameSetupRequired: true }, + }); + if (!inviter || inviter.usernameSetupRequired) return { captured: false }; + + const token = encodeSignedToken( + { inviterId: inviter.id }, + INVITE_COOKIE_MAX_AGE_SEC * 1000, + ); + store.set(INVITE_COOKIE_NAME, token, { + httpOnly: true, + sameSite: "lax", + secure: env.NODE_ENV === "production", + path: "/", + maxAge: INVITE_COOKIE_MAX_AGE_SEC, + }); + return { captured: true }; + }, +); diff --git a/src/features/invites/components/capture-invite-cookie.tsx b/src/features/invites/components/capture-invite-cookie.tsx new file mode 100644 index 0000000..a00771a --- /dev/null +++ b/src/features/invites/components/capture-invite-cookie.tsx @@ -0,0 +1,20 @@ +"use client"; + +import * as React from "react"; + +import { captureInviteAttribution } from "@/features/invites/actions"; + +/** + * Componente invisible: en el primer render del lado del cliente, intenta + * capturar la atribución de invitación (cookie first-touch, 30 días). Sólo + * se monta desde `/invitar/[username]` cuando el visitante NO tiene sesión. + */ +export function CaptureInviteCookie({ inviterUsername }: { inviterUsername: string }) { + React.useEffect(() => { + captureInviteAttribution({ inviterUsername }).catch(() => { + // silencioso — un fallo aquí no debe interrumpir la visita. + }); + }, [inviterUsername]); + + return null; +} diff --git a/src/features/lessons/actions.ts b/src/features/lessons/actions.ts index 3b086da..e1a4029 100644 --- a/src/features/lessons/actions.ts +++ b/src/features/lessons/actions.ts @@ -17,6 +17,7 @@ import { parseOrThrow, stepCompletionSchema, } from "@/lib/validation"; +import { xpDedupeKey } from "@/lib/xp"; import { requireAccessibleExercise, requireAccessibleStep } from "./lib/access"; import { markStepCompletedInTx } from "./lib/progression"; @@ -57,7 +58,11 @@ export const completeStep = withActionErrorHandling( assisted, ); if (progression.lessonJustCompleted) { - await awardXpAndUpdateStreak(tx, userId, progression.lessonXpEarned); + await awardXpAndUpdateStreak(tx, userId, progression.lessonXpEarned, { + reason: "lesson_completed", + dedupeKey: xpDedupeKey.lesson(step.lessonId), + lessonId: step.lessonId, + }); } return progression; }); @@ -231,11 +236,19 @@ export const submitExercise = withActionErrorHandling( let xp = 0; if (firstPass) { - await incrementUserXp(tx, userId, exercise.xpReward); + await incrementUserXp(tx, userId, exercise.xpReward, { + reason: "lesson_exercise_first_pass", + dedupeKey: xpDedupeKey.exercise(exercise.id), + exerciseId: exercise.id, + }); xp += exercise.xpReward; } if (progression.lessonJustCompleted) { - await awardXpAndUpdateStreak(tx, userId, progression.lessonXpEarned); + await awardXpAndUpdateStreak(tx, userId, progression.lessonXpEarned, { + reason: "lesson_completed", + dedupeKey: xpDedupeKey.lesson(lesson.id), + lessonId: lesson.id, + }); xp += progression.lessonXpEarned; } return xp; diff --git a/src/features/practice/actions.ts b/src/features/practice/actions.ts index 00e73ba..943b9bf 100644 --- a/src/features/practice/actions.ts +++ b/src/features/practice/actions.ts @@ -13,6 +13,7 @@ import { enforceRateLimit } from "@/lib/rate-limit"; import { buildStructureFeedback, checkStructure } from "@/lib/structure"; import { awardXpAndUpdateStreak } from "@/lib/streak"; import { codeSubmissionSchema, parseOrThrow } from "@/lib/validation"; +import { xpDedupeKey } from "@/lib/xp"; /** * Envía un intento de un ejercicio de PRÁCTICA (standalone). @@ -122,7 +123,11 @@ export const submitPracticeExercise = withActionErrorHandling( }); if (isFirstPass) { - await awardXpAndUpdateStreak(tx, userId, exercise.xpReward); + await awardXpAndUpdateStreak(tx, userId, exercise.xpReward, { + reason: "practice_first_pass", + dedupeKey: xpDedupeKey.practice(exercise.id), + practiceExerciseId: exercise.id, + }); return { xpEarned: exercise.xpReward, firstPass: true }; } return { xpEarned: 0, firstPass: false }; diff --git a/src/features/profile/actions.ts b/src/features/profile/actions.ts index 6cf0dad..b90e587 100644 --- a/src/features/profile/actions.ts +++ b/src/features/profile/actions.ts @@ -1,11 +1,14 @@ "use server"; +import { headers } from "next/headers"; import { z } from "zod"; import { ActionError, + isUniqueViolation, withActionErrorHandling, } from "@/lib/action-error"; +import { auth } from "@/lib/auth"; import { db } from "@/lib/db"; import { requireSession } from "@/lib/get-session"; import { logger } from "@/lib/logger"; @@ -108,3 +111,54 @@ export const setUsername = withActionErrorHandling( return { ok: true, username }; }, ); + +const confirmOAuthUsernameSchema = z.object({ username: usernameSchema }); + +/** + * Confirmación ONE-SHOT del username provisional que se generó al crear la + * cuenta por OAuth. Sólo puede llamarse una vez: el UPDATE está + * condicionado por `usernameSetupRequired: true`, así que una segunda + * llamada (doble click, tab duplicada) no encuentra la fila y falla con un + * mensaje claro en vez de silenciosamente re-escribir el handle. + * + * La carrera final por el handle la resuelve el UNIQUE de Postgres — el + * pre-check de disponibilidad (`checkUsernameAvailability`) es sólo UX. + */ +export const confirmOAuthUsername = withActionErrorHandling( + "confirmOAuthUsername", + async (input: { username: string }): Promise<{ ok: true; username: string }> => { + const session = await requireSession(); + const { username } = parseOrThrow(confirmOAuthUsernameSchema, input); + + let updated; + try { + updated = await db.user.updateMany({ + where: { id: session.user.id, usernameSetupRequired: true }, + data: { username, usernameSetupRequired: false }, + }); + } catch (err) { + if (isUniqueViolation(err)) { + throw new ActionError("Ese nombre de usuario ya está en uso"); + } + throw err; + } + + if (updated.count === 0) { + // O ya se confirmó antes (one-shot gastado), o la cuenta no requería + // setup — cualquiera de los dos es "no hay nada que hacer aquí". + throw new ActionError("Tu nombre de usuario ya fue confirmado"); + } + + // Sin esto, `cookieCache` (5 min) podría seguir sirviendo la sesión + // vieja con `usernameSetupRequired: true` y el alumno vería el prompt + // de setup aunque ya haya terminado. Un solo bypass puntual, NO se + // desactiva el cache global (`auth.ts`). + await auth.api.getSession({ + headers: await headers(), + query: { disableCookieCache: true }, + }); + + logger.info({ userId: session.user.id, username }, "oauth username confirmed"); + return { ok: true, username }; + }, +); diff --git a/src/features/profile/components/confirm-username-form.tsx b/src/features/profile/components/confirm-username-form.tsx new file mode 100644 index 0000000..22bf7b9 --- /dev/null +++ b/src/features/profile/components/confirm-username-form.tsx @@ -0,0 +1,130 @@ +"use client"; + +import * as React from "react"; +import { useRouter } from "next/navigation"; +import { AlertCircle, AtSign, Check } from "lucide-react"; +import { toast } from "sonner"; + +import { Button } from "@/components/ui/button"; +import { FormField } from "@/components/ui/form-field"; +import { Input } from "@/components/ui/input"; +import { checkUsernameAvailability, confirmOAuthUsername } from "@/features/profile/actions"; +import { safeInternalRedirect } from "@/lib/social/redirect"; +import { USERNAME_MAX, USERNAME_MIN, usernameSchema } from "@/lib/validation"; + +type Status = + | { kind: "idle" } + | { kind: "bad-format"; reason: string } + | { kind: "checking" } + | { kind: "ok" } + | { kind: "taken"; reason: string }; + +export function ConfirmUsernameForm({ redirectTo }: { redirectTo: string | null }) { + const router = useRouter(); + const [value, setValue] = React.useState(""); + const [pending, startTransition] = React.useTransition(); + const [error, setError] = React.useState(null); + // Resultado async (fetch de disponibilidad), etiquetado con el valor + // para el que se pidió — evita mostrar un resultado stale. + const [availabilityStatus, setAvailabilityStatus] = React.useState< + (Status & { for: string }) | null + >(null); + + const syncStatus = React.useMemo(() => { + const trimmed = value.trim(); + if (trimmed.length === 0) return { kind: "idle" }; + const parsed = usernameSchema.safeParse(trimmed); + if (!parsed.success) { + return { kind: "bad-format", reason: parsed.error.issues[0]?.message ?? "Formato inválido" }; + } + return { kind: "checking" }; + }, [value]); + + const status: Status = + syncStatus.kind === "checking" && availabilityStatus && availabilityStatus.for === value.trim() + ? availabilityStatus + : syncStatus; + + React.useEffect(() => { + if (syncStatus.kind !== "checking") return; + const trimmed = value.trim(); + const handle = setTimeout(async () => { + try { + const result = await checkUsernameAvailability({ username: trimmed }); + setAvailabilityStatus({ + ...(result.available ? { kind: "ok" } : { kind: "taken", reason: result.reason }), + for: trimmed, + }); + } catch { + // el submit re-valida server-side + } + }, 400); + return () => clearTimeout(handle); + }, [syncStatus.kind, value]); + + function handleSubmit(event: React.FormEvent) { + event.preventDefault(); + setError(null); + if (status.kind === "bad-format" || status.kind === "taken") return; + + startTransition(async () => { + try { + await confirmOAuthUsername({ username: value.trim() }); + toast.success("¡Listo! Ya puedes usar todo lo social."); + router.push(safeInternalRedirect(redirectTo, "/app")); + router.refresh(); + } catch (err) { + setError(err instanceof Error ? err.message : "Algo salió mal"); + } + }); + } + + return ( +
+ + } + trailing={status.kind === "ok" ? : null} + spellCheck={false} + value={value} + onChange={(e) => setValue(e.currentTarget.value.toLowerCase())} + /> + + + {error ? ( +

+ + {error} +

+ ) : null} + + +
+ ); +} diff --git a/src/lib/auth.ts b/src/lib/auth.ts index cb9a9cf..af3be5e 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -8,8 +8,8 @@ import { env, googleAuthEnabled } from "@/env"; import { db } from "./db"; import { logger } from "./logger"; import { PRODUCT_NAME } from "@/lib/branding"; +import { INVITE_COOKIE_NAME, consumeInviteCookieForNewUser } from "@/lib/social/invite-cookie"; import { - generateUsernameFromSeed, RESERVED_USERNAMES, USERNAME_MAX, USERNAME_MIN, @@ -58,6 +58,16 @@ export const auth = betterAuth({ input: false, returned: true, }, + // true SOLO para cuentas OAuth nuevas con handle provisional. Bloquea + // únicamente funcionalidad social (ver `usernameSetupRequired` en + // schema.prisma); aprender sigue funcionando. Se apaga una sola vez + // desde `confirmOAuthUsername` (src/features/academic/actions.ts). + usernameSetupRequired: { + type: "boolean", + required: false, + input: false, + returned: true, + }, }, // Habilita /delete-user. Borra el usuario y su data por cascade // (sesiones, progreso, intentos, etc. — definido en schema.prisma). @@ -97,19 +107,38 @@ export const auth = betterAuth({ logger.warn({ provided }, "username collision at signup hook"); return false; } - return { data: { ...user, username: provided } }; + return { + data: { ...user, username: provided, usernameSetupRequired: false }, + }; } - // Path: OAuth signup — auto-genera y resuelve colisión. - const seed = typeof user.email === "string" - ? user.email.split("@")[0] - : ""; - const base = generateUsernameFromSeed( - seed, - typeof user.id === "string" ? user.id : randomUUID(), - ); - const username = await resolveAvailableUsername(base); - return { data: { ...user, username } }; + // Path: OAuth signup — el form no participa, así que no hay + // username que validar. El handle es PROVISIONAL: alta entropía, + // nunca derivado del email (no revela el correo del alumno y no + // colisiona con el username real que va a elegir). El alumno + // confirma el definitivo en `confirmOAuthUsername`; hasta entonces + // `usernameSetupRequired=true` lo excluye de todo lo social sin + // tocar su acceso a cursos/lecciones/práctica. + const username = await resolveAvailableProvisionalUsername(); + return { + data: { ...user, username, usernameSetupRequired: true }, + }; + }, + after: async (user, context) => { + // Consume la cookie de atribución de invitación — SÓLO corre en + // alta nueva (nunca en login), así que "cuenta existente no + // genera attribution" sale gratis de estar aquí y no en un flujo + // que también dispare en signin. + if (!context) return; + const raw = context.getCookie(INVITE_COOKIE_NAME); + // Se borra siempre, haya servido o no: es de un solo uso. + context.setCookie(INVITE_COOKIE_NAME, "", { maxAge: 0, path: "/" }); + if (!raw) return; + try { + await consumeInviteCookieForNewUser(db, user.id, raw); + } catch (err) { + logger.error({ err, userId: user.id }, "invite cookie consumption failed"); + } }, }, }, @@ -133,30 +162,30 @@ export const auth = betterAuth({ export type Session = typeof auth.$Infer.Session; +/** Prefijo del handle provisional — deja claro en logs/DB que es temporal. */ +const PROVISIONAL_PREFIX = "alumno_"; + /** - * Intenta el candidato base; si está tomado, prueba sufijos numéricos cortos - * (deterministas para que el handle siga siendo bonito) y, como último recurso, - * cuelga un sufijo aleatorio. La probabilidad de colisión en 5+ rondas es - * despreciable para nuestra escala. + * Genera un handle provisional de alta entropía (no derivado de ningún dato + * del usuario) y resuelve la colisión, si la hay, con más entropía — nunca + * con un sufijo predecible. El username real lo elige el alumno en + * `confirmOAuthUsername`; a este no le importa ser "bonito". */ -async function resolveAvailableUsername(base: string): Promise { - const truncated = base.slice(0, USERNAME_MAX); - const free = await db.user.findUnique({ - where: { username: truncated }, - select: { id: true }, - }); - if (!free) return truncated; - - for (let suffix = 2; suffix <= 99; suffix++) { - const candidate = `${truncated.slice(0, USERNAME_MAX - String(suffix).length)}${suffix}`; +async function resolveAvailableProvisionalUsername(): Promise { + for (let attempt = 0; attempt < 5; attempt++) { + const entropy = randomUUID().replace(/-/g, "").slice(0, 12); + const candidate = `${PROVISIONAL_PREFIX}${entropy}`.slice(0, USERNAME_MAX); const taken = await db.user.findUnique({ where: { username: candidate }, select: { id: true }, }); if (!taken) return candidate; } - - // Fallback con entropía — sólo bajo presión patológica. - const rand = randomUUID().replace(/-/g, "").slice(0, 6); - return `${truncated.slice(0, USERNAME_MAX - rand.length)}${rand}`; + // Presión patológica: 5 colisiones de 48 bits de entropía es + // estadísticamente imposible a nuestra escala; si pasa, un id de sesión + // adicional termina de romper el empate. + return `${PROVISIONAL_PREFIX}${randomUUID().replace(/-/g, "").slice(0, 12)}`.slice( + 0, + USERNAME_MAX, + ); } diff --git a/src/lib/get-session.ts b/src/lib/get-session.ts index a129bbf..e68ab07 100644 --- a/src/lib/get-session.ts +++ b/src/lib/get-session.ts @@ -1,4 +1,5 @@ import { headers } from "next/headers"; +import { redirect } from "next/navigation"; import { cache } from "react"; import { auth } from "@/lib/auth"; @@ -20,3 +21,20 @@ export async function requireSession() { } return session; } + +/** + * Igual que `requireSession`, pero además EXIGE que el username ya esté + * confirmado (`usernameSetupRequired === false`). Úsalo SÓLO en + * funcionalidad social (amigos, discovery, liga, streaks, quests) — NUNCA + * en cursos/lecciones/práctica, que siguen funcionando con un handle + * provisional. En Server Components redirige a la pantalla de setup; en + * Server Actions lanza (el cliente social nunca debería poder invocarlas + * con un username sin confirmar, pero por si acaso). + */ +export async function requireConfirmedUsername() { + const session = await requireSession(); + if (session.user.usernameSetupRequired) { + redirect(`/app/confirmar-usuario?redirectTo=${encodeURIComponent("/app/amigos")}`); + } + return session; +} diff --git a/src/lib/rate-limit.ts b/src/lib/rate-limit.ts index fa58cad..1c0cda3 100644 --- a/src/lib/rate-limit.ts +++ b/src/lib/rate-limit.ts @@ -24,6 +24,16 @@ const LIMITS = { telemetry: { limit: 90, windowSec: 60 }, /** Envío de feedback general. */ feedback: { limit: 5, windowSec: 60 }, + /** Búsqueda de usuarios (amigos). */ + "search-users": { limit: 30, windowSec: 60 }, + /** Descubrimiento de compañeros (páginas de resultados). */ + discovery: { limit: 20, windowSec: 60 }, + /** Solicitudes de amistad enviadas. */ + "friend-request": { limit: 20, windowSec: 3600 }, + /** Kudos (reacción a un hito del feed). */ + kudos: { limit: 60, windowSec: 60 }, + /** Recordatorios de Friend Streak. */ + "streak-reminder": { limit: 10, windowSec: 60 }, } satisfies Record; export type RateLimitEndpoint = keyof typeof LIMITS; diff --git a/src/lib/social/friend-streak.ts b/src/lib/social/friend-streak.ts new file mode 100644 index 0000000..a56046d --- /dev/null +++ b/src/lib/social/friend-streak.ts @@ -0,0 +1,274 @@ +import type { FriendStreakEndReason, Prisma } from "@prisma/client"; + +import { ActionError } from "@/lib/action-error"; +import { db } from "@/lib/db"; +import { canonicalPair } from "@/lib/social/pair"; +import { isNextDateOnly, mxDayRangeForDateOnly } from "@/lib/social/time"; + +export const MAX_ACTIVE_FRIEND_STREAKS = 3; +export const MAX_PENDING_OUTGOING_FRIEND_STREAKS = 3; +export const PENDING_EXPIRES_DAYS = 7; + +type Db = Prisma.TransactionClient | typeof db; + +/** + * Actividad SIGNIFICATIVA server-authoritative para calificar un día de + * Friend Streak: lección completada, intento calificado de reto de + * lección, o intento calificado de práctica. Pasar o no el intento NO + * importa — sólo que haya pasado por el ejecutor real. Login, vistas, + * social, kudos o un `code_run` libre del playground NUNCA cuentan. + */ +export async function hadSignificantActivity( + tx: Db, + userId: string, + start: Date, + end: Date, +): Promise { + const [lesson, exercise, practice] = await Promise.all([ + tx.userLessonProgress.findFirst({ + where: { userId, status: "completed", completedAt: { gte: start, lt: end } }, + select: { id: true }, + }), + tx.userExerciseAttempt.findFirst({ + where: { userId, createdAt: { gte: start, lt: end } }, + select: { id: true }, + }), + tx.userPracticeAttempt.findFirst({ + where: { userId, createdAt: { gte: start, lt: end } }, + select: { id: true }, + }), + ]); + return Boolean(lesson || exercise || practice); +} + +/** Advisory lock transaccional por usuario — serializa capacidad entre requests concurrentes. */ +async function lockUserForStreakCapacity(tx: Prisma.TransactionClient, userId: string): Promise { + await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext(${userId})::bigint)`; +} + +async function countActiveStreaks(tx: Db, userId: string): Promise { + return tx.friendStreak.count({ + where: { status: "active", OR: [{ userLowId: userId }, { userHighId: userId }] }, + }); +} + +async function countPendingOutgoingStreaks(tx: Db, userId: string): Promise { + return tx.friendStreak.count({ where: { status: "pending", createdById: userId } }); +} + +/** + * Crea (o reutiliza el par canónico ya `ended`, conservando `longestStreak`) + * una solicitud pendiente de Friend Streak. Exige amistad accepted vigente, + * capacidad del creador (≤3 activas, ≤3 pendientes salientes). + */ +export async function createFriendStreakRequest( + creatorId: string, + friendUserId: string, +): Promise<{ id: string }> { + if (creatorId === friendUserId) throw new ActionError("No puedes iniciar una racha contigo mismo"); + + return db.$transaction(async (tx) => { + const { lowId, highId } = canonicalPair(creatorId, friendUserId); + await lockUserForStreakCapacity(tx, lowId); + await lockUserForStreakCapacity(tx, highId); + + const friendship = await tx.friendship.findFirst({ + where: { + status: "accepted", + OR: [ + { requesterId: creatorId, addresseeId: friendUserId }, + { requesterId: friendUserId, addresseeId: creatorId }, + ], + }, + select: { id: true }, + }); + if (!friendship) throw new ActionError("Sólo puedes iniciar una racha con un amigo"); + + const [activeCount, pendingCount] = await Promise.all([ + countActiveStreaks(tx, creatorId), + countPendingOutgoingStreaks(tx, creatorId), + ]); + if (activeCount >= MAX_ACTIVE_FRIEND_STREAKS) { + throw new ActionError(`Ya tienes ${MAX_ACTIVE_FRIEND_STREAKS} rachas activas`); + } + if (pendingCount >= MAX_PENDING_OUTGOING_FRIEND_STREAKS) { + throw new ActionError("Ya tienes demasiadas solicitudes de racha pendientes"); + } + + const existing = await tx.friendStreak.findUnique({ + where: { userLowId_userHighId: { userLowId: lowId, userHighId: highId } }, + }); + const pendingExpiresAt = new Date(Date.now() + PENDING_EXPIRES_DAYS * 86_400_000); + + if (!existing) { + return tx.friendStreak.create({ + data: { userLowId: lowId, userHighId: highId, createdById: creatorId, status: "pending", pendingExpiresAt }, + select: { id: true }, + }); + } + if (existing.status === "ended") { + // Reamistad: reutiliza el par canónico, conserva longestStreak. + return tx.friendStreak.update({ + where: { id: existing.id }, + data: { + status: "pending", + createdById: creatorId, + pendingExpiresAt, + acceptedAt: null, + endedAt: null, + endReason: null, + currentStreak: 0, + lastQualifiedDay: null, + lastEvaluatedDay: null, + }, + select: { id: true }, + }); + } + if (existing.status === "pending") { + throw new ActionError("Ya hay una solicitud de racha pendiente con esa persona"); + } + throw new ActionError("Ya tienen una racha activa"); + }); +} + +/** Acepta una solicitud pendiente. Revalida capacidad de AMBOS bajo lock. */ +export async function acceptFriendStreakRequest(accepterId: string, streakId: string): Promise { + await db.$transaction(async (tx) => { + const streak = await tx.friendStreak.findUnique({ where: { id: streakId } }); + if (!streak || streak.status !== "pending") throw new ActionError("Esa solicitud ya no existe"); + if (streak.createdById === accepterId) throw new ActionError("No puedes aceptar tu propia solicitud"); + if (accepterId !== streak.userLowId && accepterId !== streak.userHighId) { + throw new ActionError("No autorizado"); + } + if (streak.pendingExpiresAt && streak.pendingExpiresAt.getTime() < Date.now()) { + throw new ActionError("Esa solicitud expiró"); + } + + await lockUserForStreakCapacity(tx, streak.userLowId); + await lockUserForStreakCapacity(tx, streak.userHighId); + + const [lowActive, highActive] = await Promise.all([ + countActiveStreaks(tx, streak.userLowId), + countActiveStreaks(tx, streak.userHighId), + ]); + if (lowActive >= MAX_ACTIVE_FRIEND_STREAKS || highActive >= MAX_ACTIVE_FRIEND_STREAKS) { + throw new ActionError("Uno de los dos ya llegó al máximo de rachas activas"); + } + + await tx.friendStreak.update({ + where: { id: streakId }, + data: { status: "active", acceptedAt: new Date(), pendingExpiresAt: null }, + }); + }); +} + +/** Cancela (creador) o rechaza (receptor) una solicitud pendiente. */ +export async function cancelOrDeclineFriendStreakRequest( + userId: string, + streakId: string, +): Promise { + await db.$transaction(async (tx) => { + const streak = await tx.friendStreak.findUnique({ where: { id: streakId } }); + if (!streak || streak.status !== "pending") throw new ActionError("Esa solicitud ya no existe"); + if (streak.createdById !== userId && streak.userLowId !== userId && streak.userHighId !== userId) { + throw new ActionError("No autorizado"); + } + await tx.friendStreak.update({ + where: { id: streakId }, + data: { status: "ended", endedAt: new Date(), endReason: "expired" }, + }); + }); +} + +/** Expira solicitudes pendientes vencidas — llamado desde el job de mantenimiento. */ +export async function expirePendingFriendStreaks(): Promise { + const res = await db.friendStreak.updateMany({ + where: { status: "pending", pendingExpiresAt: { lt: new Date() } }, + data: { status: "ended", endedAt: new Date(), endReason: "expired" }, + }); + return res.count; +} + +/** + * Cierra cualquier Friend Streak (pending o active) del par tras + * unfriend/block, EN LA MISMA operación social (se pasa la `tx` del + * caller). `currentStreak` se pone a 0; `longestStreak` NUNCA se toca — + * es historia. Los `FriendStreakDay` tampoco se borran. + */ +export async function endFriendStreakForPair( + tx: Prisma.TransactionClient, + userA: string, + userB: string, + reason: Extract, +): Promise { + const { lowId, highId } = canonicalPair(userA, userB); + await tx.friendStreak.updateMany({ + where: { userLowId: lowId, userHighId: highId, status: { in: ["pending", "active"] } }, + data: { status: "ended", endedAt: new Date(), endReason: reason, currentStreak: 0 }, + }); +} + +/** + * Evalúa un día calendario (DATE-only, ver `mxDateOnly`) para un streak + * activo. Idempotente vía el guard `lastEvaluatedDay` (una `updateMany` + * condicionada, no read-then-write) + el UNIQUE de `FriendStreakDay` — un + * reintento o un doble refresh NUNCA suma dos veces. + * + * `breakOnMiss`: + * - `false` (refresh en vivo de HOY): si no calificó, no pasa nada — el + * día no ha terminado, puede calificar más tarde. + * - `true` (job diario evaluando AYER): si no calificó, rompe la racha + * (`currentStreak = 0`) — el día ya cerró. + */ +export async function refreshFriendStreakDay( + streakId: string, + dayOnly: Date, + opts: { breakOnMiss: boolean }, +): Promise { + await db.$transaction(async (tx) => { + const streak = await tx.friendStreak.findUnique({ where: { id: streakId } }); + if (!streak || streak.status !== "active") return; + if (streak.lastEvaluatedDay && streak.lastEvaluatedDay.getTime() >= dayOnly.getTime()) return; + + const { start, end } = mxDayRangeForDateOnly(dayOnly); + const [lowOk, highOk] = await Promise.all([ + hadSignificantActivity(tx, streak.userLowId, start, end), + hadSignificantActivity(tx, streak.userHighId, start, end), + ]); + + const guard = { + id: streakId, + OR: [{ lastEvaluatedDay: null }, { lastEvaluatedDay: { lt: dayOnly } }], + }; + + if (lowOk && highOk) { + const inserted = await tx.friendStreakDay.createMany({ + data: [{ streakId, day: dayOnly }], + skipDuplicates: true, + }); + if (inserted.count !== 1) return; // ya se había registrado este día — no duplicar. + + const newCurrent = + streak.lastQualifiedDay && isNextDateOnly(streak.lastQualifiedDay, dayOnly) + ? streak.currentStreak + 1 + : 1; + await tx.friendStreak.updateMany({ + where: guard, + data: { + currentStreak: newCurrent, + longestStreak: Math.max(streak.longestStreak, newCurrent), + lastQualifiedDay: dayOnly, + lastEvaluatedDay: dayOnly, + }, + }); + return; + } + + if (opts.breakOnMiss) { + await tx.friendStreak.updateMany({ + where: guard, + data: { currentStreak: 0, lastEvaluatedDay: dayOnly }, + }); + } + }); +} diff --git a/src/lib/social/friendship-lifecycle.ts b/src/lib/social/friendship-lifecycle.ts new file mode 100644 index 0000000..14b3762 --- /dev/null +++ b/src/lib/social/friendship-lifecycle.ts @@ -0,0 +1,69 @@ +import type { FriendRequestSource, FriendshipEndReason, Prisma } from "@prisma/client"; + +import { canonicalPair, pairKeyOf } from "./pair"; + +/** Datos comunes de una fila `Friendship` nueva — SIEMPRE trae `pairKey`. */ +export function friendshipCreateData( + requesterId: string, + addresseeId: string, + source: FriendRequestSource | null, + sourceContextKey: string | null, +): { + requesterId: string; + addresseeId: string; + pairKey: string; + requestSource: FriendRequestSource | null; + sourceContextKey: string | null; +} { + return { + requesterId, + addresseeId, + pairKey: pairKeyOf(requesterId, addresseeId), + requestSource: source, + sourceContextKey, + }; +} + +/** + * Abre un período de amistad para el par. Idempotente: la unique parcial + * `friendship_period_open_pair_key` (`WHERE "endedAt" IS NULL`) hace que + * `skipDuplicates` no duplique un período ya abierto para este par. + */ +export async function openFriendshipPeriod( + tx: Prisma.TransactionClient, + userA: string, + userB: string, + source: FriendRequestSource | null, + sourceContextKey: string | null, + startedAt: Date = new Date(), +): Promise { + const { lowId, highId } = canonicalPair(userA, userB); + await tx.friendshipPeriod.createMany({ + data: [ + { + userLowId: lowId, + userHighId: highId, + source, + sourceContextKey, + startedAt, + endedAt: null, + }, + ], + skipDuplicates: true, + }); +} + +/** Cierra el período abierto del par (si lo hay) — no-op si ya estaba cerrado. */ +export async function closeFriendshipPeriod( + tx: Prisma.TransactionClient, + userA: string, + userB: string, + endReason: FriendshipEndReason, + endedAt: Date = new Date(), +): Promise { + const { lowId, highId } = canonicalPair(userA, userB); + await tx.friendshipPeriod.updateMany({ + where: { userLowId: lowId, userHighId: highId, endedAt: null }, + data: { endedAt, endReason }, + }); +} diff --git a/src/lib/social/invite-cookie.ts b/src/lib/social/invite-cookie.ts new file mode 100644 index 0000000..e09ca56 --- /dev/null +++ b/src/lib/social/invite-cookie.ts @@ -0,0 +1,67 @@ +import type { Prisma } from "@prisma/client"; + +import type { db as prismaDb } from "@/lib/db"; +import { logger } from "@/lib/logger"; +import { friendshipCreateData } from "@/lib/social/friendship-lifecycle"; +import { decodeSignedToken } from "@/lib/social/signed-token"; + +type Db = Prisma.TransactionClient | typeof prismaDb; + +/** Mismo esquema de nombre que `COURSE_COOKIE` (`src/lib/course-selection.ts`). */ +export const INVITE_COOKIE_NAME = "cpp-ceti.invite"; +export const INVITE_COOKIE_MAX_AGE_SEC = 60 * 60 * 24 * 30; // 30 días + +interface InviteCookiePayload { + inviterId: string; +} + +/** + * Consume la cookie de atribución de invitación para un usuario RECIÉN + * CREADO (llamado desde `databaseHooks.user.create.after` en `auth.ts` — + * ese hook sólo dispara en alta nueva, nunca en login, así que "cuenta + * existente no genera attribution" sale gratis de dónde se llama esto). + * + * - Sin cookie, cookie inválida/expirada, o self-invite → no-op. + * - `InviteAttribution.inviteeId` es UNIQUE: aunque esto se llamara dos + * veces (no debería, un `create.after` corre una vez por alta), la + * segunda es un no-op vía `skipDuplicates`. + * - Además crea una solicitud de amistad `source: invite` best-effort — + * un fallo ahí NUNCA debe tumbar el registro. + */ +export async function consumeInviteCookieForNewUser( + db: Db, + newUserId: string, + rawCookieValue: string | null | undefined, +): Promise<{ consumed: boolean }> { + const payload = decodeSignedToken(rawCookieValue); + if (!payload) return { consumed: false }; + if (payload.inviterId === newUserId) return { consumed: false }; + + try { + await db.inviteAttribution.createMany({ + data: [{ inviterId: payload.inviterId, inviteeId: newUserId }], + skipDuplicates: true, + }); + } catch (err) { + logger.error({ err, newUserId }, "invite attribution insert failed"); + return { consumed: false }; + } + + try { + await db.friendship.createMany({ + data: [ + { + ...friendshipCreateData(payload.inviterId, newUserId, "invite", null), + status: "pending", + }, + ], + skipDuplicates: true, + }); + } catch (err) { + // No debe romper el registro — el usuario ya se creó y la atribución + // ya se guardó; la solicitud de amistad es un extra. + logger.error({ err, newUserId }, "invite friend request creation failed"); + } + + return { consumed: true }; +} diff --git a/src/lib/social/league.ts b/src/lib/social/league.ts new file mode 100644 index 0000000..f2ec83a --- /dev/null +++ b/src/lib/social/league.ts @@ -0,0 +1,67 @@ +import type { LeagueOutcome, LeagueTier } from "@prisma/client"; + +/** Orden de tiers, de más bajo a más alto. */ +export const LEAGUE_TIERS: LeagueTier[] = ["bronze", "silver", "gold", "platinum", "diamond"]; + +export function tierAbove(tier: LeagueTier): LeagueTier | null { + const i = LEAGUE_TIERS.indexOf(tier); + return i >= 0 && i < LEAGUE_TIERS.length - 1 ? LEAGUE_TIERS[i + 1]! : null; +} + +export function tierBelow(tier: LeagueTier): LeagueTier | null { + const i = LEAGUE_TIERS.indexOf(tier); + return i > 0 ? LEAGUE_TIERS[i - 1]! : null; +} + +/** Objetivo ~20 miembros/división: divisionCount = max(1, round(N/20)). */ +export function divisionCountFor(memberCount: number): number { + if (memberCount <= 0) return 0; + return Math.max(1, Math.round(memberCount / 20)); +} + +/** Tamaños balanceados (diferencia máxima 1) que suman exactamente `n`. */ +export function balancedDivisionSizes(n: number, divisions: number): number[] { + if (divisions <= 0 || n < 0) return []; + const base = Math.floor(n / divisions); + const remainder = n % divisions; + return Array.from({ length: divisions }, (_, i) => base + (i < remainder ? 1 : 0)); +} + +export interface PromotionSlots { + promoteCount: number; + relegateCount: number; +} + +/** N>=10 → top5/bottom5. N<10 → floor(N/2) arriba y abajo. Nunca se solapan. */ +export function promotionSlotsFor(memberCount: number): PromotionSlots { + if (memberCount >= 10) return { promoteCount: 5, relegateCount: 5 }; + const slots = Math.floor(memberCount / 2); + return { promoteCount: slots, relegateCount: slots }; +} + +export interface RolloverOutcome { + outcome: LeagueOutcome; + nextTier: LeagueTier; +} + +/** + * Resultado de rollover para un miembro dado su `rank` (1-based) dentro de + * una división de `memberCount` miembros y su `tier` actual. + */ +export function resolveRolloverOutcome( + rank: number, + memberCount: number, + tier: LeagueTier, +): RolloverOutcome { + const { promoteCount, relegateCount } = promotionSlotsFor(memberCount); + + if (promoteCount > 0 && rank <= promoteCount) { + const above = tierAbove(tier); + return above ? { outcome: "promoted", nextTier: above } : { outcome: "held_at_ceiling", nextTier: tier }; + } + if (relegateCount > 0 && rank > memberCount - relegateCount) { + const below = tierBelow(tier); + return below ? { outcome: "relegated", nextTier: below } : { outcome: "held_at_floor", nextTier: tier }; + } + return { outcome: "stayed", nextTier: tier }; +} diff --git a/src/lib/social/pair.ts b/src/lib/social/pair.ts new file mode 100644 index 0000000..dc4dc75 --- /dev/null +++ b/src/lib/social/pair.ts @@ -0,0 +1,15 @@ +/** + * Par canónico de dos ids de usuario — orden lexicográfico estable. + * Toda tabla que representa una relación simétrica entre dos usuarios + * (Friendship.pairKey, FriendshipPeriod, FriendStreak) usa esto para que + * A↔B tenga SIEMPRE una sola fila, sin importar quién la originó. + */ +export function canonicalPair(a: string, b: string): { lowId: string; highId: string } { + return a <= b ? { lowId: a, highId: b } : { lowId: b, highId: a }; +} + +/** Clave de texto del par canónico, para columnas `pairKey`. */ +export function pairKeyOf(a: string, b: string): string { + const { lowId, highId } = canonicalPair(a, b); + return `${lowId}:${highId}`; +} diff --git a/src/lib/social/ranking.ts b/src/lib/social/ranking.ts new file mode 100644 index 0000000..227a91f --- /dev/null +++ b/src/lib/social/ranking.ts @@ -0,0 +1,30 @@ +/** + * Comparador ÚNICO de ranking competitivo (XP semanal de amigos y + * standings de liga): XP desc → última vez que ganó XP asc (llegó primero + * gana el empate) → userId asc (determinista, nunca hay rank compartido). + */ +export interface RankableMember { + userId: string; + xp: number; + lastAwardAt: Date | null; +} + +export function compareRankable(a: RankableMember, b: RankableMember): number { + if (b.xp !== a.xp) return b.xp - a.xp; + const at = a.lastAwardAt ? a.lastAwardAt.getTime() : Number.POSITIVE_INFINITY; + const bt = b.lastAwardAt ? b.lastAwardAt.getTime() : Number.POSITIVE_INFINITY; + if (at !== bt) return at - bt; + if (a.userId < b.userId) return -1; + if (a.userId > b.userId) return 1; + return 0; +} + +export interface Ranked { + member: T; + rank: number; +} + +/** Ordena y asigna rank 1..n — determinista, nunca hay empates compartidos. */ +export function rankMembers(members: T[]): Ranked[] { + return [...members].sort(compareRankable).map((member, i) => ({ member, rank: i + 1 })); +} diff --git a/src/lib/social/redirect.ts b/src/lib/social/redirect.ts new file mode 100644 index 0000000..9f3618b --- /dev/null +++ b/src/lib/social/redirect.ts @@ -0,0 +1,44 @@ +/** + * Sanea un `redirectTo` que llega como query param (login, registro, + * invitar) para que sólo pueda apuntar a un path interno. + * + * Rechaza cualquier cosa que no sea "/" seguido de algo que no sea otra + * "/" — eso bloquea rutas absolutas ("//evil.com", que el navegador trata + * como protocol-relative), esquemas (`http://`, `https://`, `javascript:`, + * etc.) y caracteres de control que podrían confundir a un parser aguas + * abajo. Cualquier duda cae al fallback. + */ +export function safeInternalRedirect(raw: string | null | undefined, fallback = "/app"): string { + if (!raw) return fallback; + + let value: string; + try { + value = decodeURIComponent(raw); + } catch { + return fallback; + } + + if (value.length === 0 || value.length > 2048) return fallback; + if (containsControlChar(value)) return fallback; + if (!value.startsWith("/")) return fallback; + if (value.startsWith("//")) return fallback; + if (value.startsWith("/\\")) return fallback; + + // Un scheme colado como "/javascript:alert(1)" no empieza con "//" pero + // tampoco es un path — cualquier ":" antes del primer "/" siguiente es + // sospechoso de ser un scheme. + const firstSlash = value.indexOf("/", 1); + const beforeNextSlash = firstSlash === -1 ? value : value.slice(0, firstSlash); + if (beforeNextSlash.includes(":")) return fallback; + + return value; +} + +/** true si `value` contiene un carácter de control (0x00-0x1F o 0x7F). */ +function containsControlChar(value: string): boolean { + for (let i = 0; i < value.length; i++) { + const code = value.charCodeAt(i); + if (code <= 0x1f || code === 0x7f) return true; + } + return false; +} diff --git a/src/lib/social/signed-token.ts b/src/lib/social/signed-token.ts new file mode 100644 index 0000000..4a64675 --- /dev/null +++ b/src/lib/social/signed-token.ts @@ -0,0 +1,55 @@ +import { createHmac, timingSafeEqual } from "node:crypto"; + +import { env } from "@/env"; + +/** + * Token firmado genérico (HMAC-SHA256 con `BETTER_AUTH_SECRET` — no hace + * falta un secreto nuevo, ya es server-only). Usado donde el servidor + * necesita mandar un dato de vuelta al cliente y luego confiar en que NO + * fue alterado: el context token de discovery (Fase 2 §6), la cookie de + * atribución de invitación (Fase 2 §4) y el cursor de discovery (keyset). + * + * No es JWT: es deliberadamente chico y sin librería extra. + */ + +function sign(payload: string): string { + return createHmac("sha256", env.BETTER_AUTH_SECRET).update(payload).digest("base64url"); +} + +export function encodeSignedToken(data: Record, ttlMs?: number): string { + const payload = JSON.stringify({ + ...data, + iat: Date.now(), + ...(ttlMs ? { exp: Date.now() + ttlMs } : {}), + }); + const encoded = Buffer.from(payload, "utf-8").toString("base64url"); + return `${encoded}.${sign(encoded)}`; +} + +/** `null` si la firma no cuadra, el JSON es inválido, o el token expiró. */ +export function decodeSignedToken( + token: string | null | undefined, +): (T & { iat: number; exp?: number }) | null { + if (!token) return null; + const dot = token.indexOf("."); + if (dot <= 0) return null; + const encoded = token.slice(0, dot); + const sig = token.slice(dot + 1); + if (!encoded || !sig) return null; + + const expected = sign(encoded); + const a = Buffer.from(sig, "base64url"); + const b = Buffer.from(expected, "base64url"); + if (a.length !== b.length || !timingSafeEqual(a, b)) return null; + + try { + const payload = JSON.parse(Buffer.from(encoded, "base64url").toString("utf-8")) as T & { + iat: number; + exp?: number; + }; + if (typeof payload.exp === "number" && Date.now() > payload.exp) return null; + return payload; + } catch { + return null; + } +} diff --git a/src/lib/social/time.ts b/src/lib/social/time.ts new file mode 100644 index 0000000..2ac392f --- /dev/null +++ b/src/lib/social/time.ts @@ -0,0 +1,172 @@ +/** + * Modelo de tiempo social — ÚNICO para todo lo social (Friend Streaks, + * ranking semanal, ligas, quests). + * + * Zona: America/Mexico_City (IANA — nunca hardcodear un offset como + * "-06:00": México abolió el horario de verano en 2022, pero resolver la + * zona vía Intl es correcto sin importar cambios futuros de política). + * + * Día social: [00:00 local, 00:00 local del día siguiente) + * Semana social: lunes 00:00 local → lunes siguiente 00:00 local + * + * Todo instante persistido es UTC (`DateTime`); las etiquetas de día usan + * columnas `DATE` de Postgres, que Prisma mapea a un `Date` de JS en + * medianoche UTC (sin conversión de zona) — ver `mxDateOnly`. + */ + +export const SOCIAL_TIME_ZONE = "America/Mexico_City"; + +interface TzParts { + year: number; + month: number; + day: number; + hour: number; + minute: number; + second: number; +} + +function partsInTz(date: Date, timeZone: string): TzParts { + const dtf = new Intl.DateTimeFormat("en-US", { + timeZone, + hourCycle: "h23", + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }); + const map: Record = {}; + for (const part of dtf.formatToParts(date)) { + if (part.type !== "literal") map[part.type] = part.value; + } + return { + year: Number(map.year), + month: Number(map.month), + day: Number(map.day), + hour: Number(map.hour === "24" ? "0" : map.hour), + minute: Number(map.minute), + second: Number(map.second), + }; +} + +/** Offset (ms) tal que `UTC-interpretado-como-wall-clock - offset = instante real`. */ +function offsetMsAt(date: Date, timeZone: string): number { + const p = partsInTz(date, timeZone); + const asUTC = Date.UTC(p.year, p.month - 1, p.day, p.hour, p.minute, p.second); + return asUTC - date.getTime(); +} + +/** Normaliza year/month/day que se salieron de rango (ej. day=32) usando aritmética UTC. */ +function normalizeYmd( + year: number, + month: number, + day: number, +): { year: number; month: number; day: number } { + const d = new Date(Date.UTC(year, month - 1, day)); + return { year: d.getUTCFullYear(), month: d.getUTCMonth() + 1, day: d.getUTCDate() }; +} + +/** Instante UTC de las 00:00:00 locales de un Y-M-D dado, resuelto correctamente ante DST. */ +function localMidnightUtc(year: number, month: number, day: number, timeZone: string): Date { + const { year: y, month: m, day: d } = normalizeYmd(year, month, day); + // Mediodía como sonda: nunca cae en la transición de DST (evita ambigüedad). + const noonGuess = new Date(Date.UTC(y, m - 1, d, 12, 0, 0)); + const guessOffset = offsetMsAt(noonGuess, timeZone); + let candidate = new Date(Date.UTC(y, m - 1, d, 0, 0, 0) - guessOffset); + const refinedOffset = offsetMsAt(candidate, timeZone); + if (refinedOffset !== guessOffset) { + candidate = new Date(Date.UTC(y, m - 1, d, 0, 0, 0) - refinedOffset); + } + return candidate; +} + +/** Clave de día local "YYYY-MM-DD" (para logs/dedupe legible, no para columnas DATE). */ +export function mxDayKey(date: Date): string { + const p = partsInTz(date, SOCIAL_TIME_ZONE); + return `${String(p.year).padStart(4, "0")}-${String(p.month).padStart(2, "0")}-${String(p.day).padStart(2, "0")}`; +} + +/** + * Representa el día calendario local de `date` como un `Date` a medianoche + * UTC — la forma en que Prisma serializa/lee una columna `@db.Date`. Úsalo + * SIEMPRE al escribir o comparar contra `FriendStreakDay.day`, + * `FriendStreak.lastQualifiedDay`, `FriendQuest.weekStart`, etc. + */ +export function mxDateOnly(date: Date): Date { + const p = partsInTz(date, SOCIAL_TIME_ZONE); + return new Date(Date.UTC(p.year, p.month - 1, p.day)); +} + +/** Día calendario siguiente/anterior de un valor `DATE` (medianoche UTC). */ +export function shiftDateOnly(dateOnly: Date, deltaDays: number): Date { + return new Date(dateOnly.getTime() + deltaDays * 86_400_000); +} + +/** true si `b` es exactamente el día calendario siguiente a `a` (ambos DATE-only). */ +export function isNextDateOnly(a: Date, b: Date): boolean { + return shiftDateOnly(a, 1).getTime() === b.getTime(); +} + +/** [inicio, fin) del día social en UTC que contiene `date`. */ +export function mxDayRange(date: Date): { start: Date; end: Date } { + const p = partsInTz(date, SOCIAL_TIME_ZONE); + const start = localMidnightUtc(p.year, p.month, p.day, SOCIAL_TIME_ZONE); + const next = normalizeYmd(p.year, p.month, p.day + 1); + const end = localMidnightUtc(next.year, next.month, next.day, SOCIAL_TIME_ZONE); + return { start, end }; +} + +/** [inicio, fin) de la semana social (lunes→lunes) en UTC que contiene `date`. */ +export function mxWeekRange(date: Date): { start: Date; end: Date } { + const p = partsInTz(date, SOCIAL_TIME_ZONE); + // Día de la semana local vía una sonda a mediodía UTC del mismo Y-M-D + // (evita que un borde de zona horaria mueva el día calendario). + const weekdayProbe = new Date(Date.UTC(p.year, p.month - 1, p.day, 12)); + const isoDow = weekdayProbe.getUTCDay(); // 0=domingo..6=sábado + const daysSinceMonday = (isoDow + 6) % 7; // lunes=0 + const monday = normalizeYmd(p.year, p.month, p.day - daysSinceMonday); + const start = localMidnightUtc(monday.year, monday.month, monday.day, SOCIAL_TIME_ZONE); + const nextMonday = normalizeYmd(monday.year, monday.month, monday.day + 7); + const end = localMidnightUtc(nextMonday.year, nextMonday.month, nextMonday.day, SOCIAL_TIME_ZONE); + return { start, end }; +} + +/** Clave estable de semana ("YYYY-MM-DD" del lunes) — usada como `weekStart`/`key`. */ +export function mxWeekKey(date: Date): string { + const { start } = mxWeekRange(date); + return mxDayKey(start); +} + +/** El lunes de la semana social que contiene `date`, como valor DATE-only. */ +export function mxWeekStartDateOnly(date: Date): Date { + const { start } = mxWeekRange(date); + return mxDateOnly(start); +} + +/** + * [inicio, fin) en UTC del día social que representa un valor DATE-only + * (ej. `FriendStreakDay.day`, `FriendStreak.lastQualifiedDay`). El Y-M-D + * UTC de `dateOnly` ES el día calendario (por construcción de + * `mxDateOnly`), así que sólo hace falta resolver la medianoche local de + * ESE Y-M-D — sin volver a pasar por `date.getTime()`. + */ +export function mxDayRangeForDateOnly(dateOnly: Date): { start: Date; end: Date } { + const y = dateOnly.getUTCFullYear(); + const m = dateOnly.getUTCMonth() + 1; + const d = dateOnly.getUTCDate(); + const start = localMidnightUtc(y, m, d, SOCIAL_TIME_ZONE); + const next = normalizeYmd(y, m, d + 1); + const end = localMidnightUtc(next.year, next.month, next.day, SOCIAL_TIME_ZONE); + return { start, end }; +} + +/** El día calendario local de "ahora mismo", como valor DATE-only. */ +export function mxToday(): Date { + return mxDateOnly(new Date()); +} + +/** El día calendario local inmediatamente anterior a `dateOnly`. */ +export function mxYesterdayOf(dateOnly: Date): Date { + return shiftDateOnly(dateOnly, -1); +} diff --git a/src/lib/streak.ts b/src/lib/streak.ts index c0f1098..86d0789 100644 --- a/src/lib/streak.ts +++ b/src/lib/streak.ts @@ -2,6 +2,7 @@ import type { Prisma } from "@prisma/client"; import { cache } from "react"; import { db } from "@/lib/db"; +import { recordXpAward, type XpAwardDescriptor } from "@/lib/xp"; export interface UserStats { totalXp: number; @@ -32,12 +33,21 @@ export const getUserStats = cache(async (userId: string): Promise => * * El `totalXp` se incrementa atómicamente (no read-then-write) para evitar * lost updates si por alguna razón se invocan dos racha-updates en paralelo. + * + * `award` describe el otorgamiento para el ledger `XpAward` (fuente de + * verdad de ranking/ligas — ver `src/lib/xp.ts`). Si el dedupeKey ya existía + * (llamada repetida del mismo evento), NO se toca ni la racha ni `totalXp`: + * el ledger es quien decide si esto "ya pasó". */ export async function awardXpAndUpdateStreak( tx: Prisma.TransactionClient, userId: string, xpEarned: number, + award: XpAwardDescriptor, ): Promise { + const granted = await recordXpAward(tx, userId, xpEarned, award); + if (!granted) return; + const today = startOfDayUTC(new Date()); const yesterday = startOfDayUTC(new Date(Date.now() - 86_400_000)); @@ -86,12 +96,19 @@ export async function awardXpAndUpdateStreak( * Suma XP sin tocar la racha. Atómico vía `increment`. Usado para XP de * sub-eventos (ej. aprobar un ejercicio dentro de una lección, donde la * racha la maneja la transición de la lección). + * + * Igual que `awardXpAndUpdateStreak`: sólo mueve `totalXp` si el ledger + * efectivamente otorgó este `award` por primera vez. */ export async function incrementUserXp( tx: Prisma.TransactionClient, userId: string, xp: number, + award: XpAwardDescriptor, ): Promise { + const granted = await recordXpAward(tx, userId, xp, award); + if (!granted) return; + await tx.userStreak.upsert({ where: { userId }, update: { totalXp: { increment: xp } }, diff --git a/src/lib/xp.ts b/src/lib/xp.ts new file mode 100644 index 0000000..9bf5474 --- /dev/null +++ b/src/lib/xp.ts @@ -0,0 +1,54 @@ +import type { Prisma, XpReason } from "@prisma/client"; + +/** + * Descriptor de un otorgamiento de XP — exactamente un recurso por + * `reason` (reflejado también como CHECK en Postgres, ver la migración + * `social_system_phase1_6`). + */ +export type XpAwardDescriptor = + | { reason: "lesson_completed"; dedupeKey: string; lessonId: string } + | { reason: "lesson_exercise_first_pass"; dedupeKey: string; exerciseId: string } + | { reason: "practice_first_pass"; dedupeKey: string; practiceExerciseId: string } + | { reason: "legacy_balance"; dedupeKey: string }; + +/** Claves de dedupe estándar — un otorgamiento por recurso, para siempre. */ +export const xpDedupeKey = { + lesson: (lessonId: string) => `lesson:${lessonId}`, + exercise: (exerciseId: string) => `exercise:${exerciseId}`, + practice: (practiceExerciseId: string) => `practice:${practiceExerciseId}`, + legacy: (cutoverKey: string) => `legacy:${cutoverKey}`, +}; + +/** + * Inserta una fila en el ledger append-only `XpAward`, idempotente vía + * `createMany({ skipDuplicates })` (UNIQUE (userId, dedupeKey) → + * `INSERT ... ON CONFLICT DO NOTHING`, nunca aborta la transacción — ver + * `@/lib/completions`). + * + * @returns `true` SOLO si esta llamada insertó la fila (primer otorgamiento + * de este dedupeKey). El caller debe mover `UserStreak.totalXp` (y + * cualquier otro contador derivado) ÚNICAMENTE cuando esto es `true`. + */ +export async function recordXpAward( + tx: Prisma.TransactionClient, + userId: string, + amount: number, + award: XpAwardDescriptor, +): Promise { + const inserted = await tx.xpAward.createMany({ + data: [ + { + userId, + amount, + reason: award.reason as XpReason, + dedupeKey: award.dedupeKey, + lessonId: award.reason === "lesson_completed" ? award.lessonId : null, + exerciseId: award.reason === "lesson_exercise_first_pass" ? award.exerciseId : null, + practiceExerciseId: + award.reason === "practice_first_pass" ? award.practiceExerciseId : null, + }, + ], + skipDuplicates: true, + }); + return inserted.count === 1; +} diff --git a/tests/features/academic/group.test.ts b/tests/features/academic/group.test.ts new file mode 100644 index 0000000..f12cf86 --- /dev/null +++ b/tests/features/academic/group.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; + +import { normalizeAcademicGroup } from "@/features/academic/lib/group"; + +describe("normalizeAcademicGroup", () => { + it("trim + colapsa espacios + uppercase", () => { + expect(normalizeAcademicGroup(" 3a matutino ")).toBe("3A MATUTINO"); + }); + + it("recorta a 20 caracteres", () => { + const long = "a".repeat(40); + expect(normalizeAcademicGroup(long)).toHaveLength(20); + }); + + it("cadena vacía o sólo espacios se limpia a null", () => { + expect(normalizeAcademicGroup("")).toBeNull(); + expect(normalizeAcademicGroup(" ")).toBeNull(); + expect(normalizeAcademicGroup(null)).toBeNull(); + expect(normalizeAcademicGroup(undefined)).toBeNull(); + }); +}); diff --git a/tests/features/friends/send-friend-request.test.ts b/tests/features/friends/send-friend-request.test.ts index 1c7df07..b6b0e71 100644 --- a/tests/features/friends/send-friend-request.test.ts +++ b/tests/features/friends/send-friend-request.test.ts @@ -36,7 +36,7 @@ describe("sendFriendRequest", () => { }); it("crea la solicitud cuando no hay relación previa", async () => { - const res = await sendFriendRequest({ username: "otro" }); + const res = await sendFriendRequest({ username: "otro", source: "profile" }); expect(res.status).toBe("sent"); expect(friendships()).toHaveLength(1); @@ -45,8 +45,8 @@ describe("sendFriendRequest", () => { }); it("es idempotente: repetir la solicitud no duplica filas", async () => { - await sendFriendRequest({ username: "otro" }); - const res = await sendFriendRequest({ username: "otro" }); + await sendFriendRequest({ username: "otro", source: "profile" }); + const res = await sendFriendRequest({ username: "otro", source: "profile" }); expect(res.status).toBe("already"); expect(friendships()).toHaveLength(1); @@ -69,7 +69,7 @@ describe("sendFriendRequest", () => { const api = fake.friendship as Record; api.findMany = vi.fn(async () => []); - const res = await sendFriendRequest({ username: "otro" }); + const res = await sendFriendRequest({ username: "otro", source: "profile" }); expect(res.status).toBe("already"); expect(friendships()).toHaveLength(1); diff --git a/tests/helpers/fake-prisma.ts b/tests/helpers/fake-prisma.ts index 5ce0156..1ce0e44 100644 --- a/tests/helpers/fake-prisma.ts +++ b/tests/helpers/fake-prisma.ts @@ -34,7 +34,7 @@ const UNIQUES: Record = { userStepProgress: [["userId", "stepId"]], userLessonProgress: [["userId", "lessonId"]], userStreak: [["userId"]], - friendship: [["requesterId", "addresseeId"]], + friendship: [["requesterId", "addresseeId"], ["pairKey"]], practiceExercise: [["courseId", "slug"]], unit: [["courseId", "slug"]], productEvent: [["userId", "dedupeKey"]], @@ -43,6 +43,21 @@ const UNIQUES: Record = { ["userId", "exerciseId", "hintIndex"], ["userId", "practiceExerciseId", "hintIndex"], ], + // Social (Fases 1-6) + xpAward: [["userId", "dedupeKey"]], + socialEvent: [["actorId", "dedupeKey"]], + kudos: [["eventId", "userId"]], + inviteAttribution: [["inviteeId"]], + friendStreak: [["userLowId", "userHighId"]], + friendStreakDay: [["streakId", "day"]], + streakReminder: [["streakId", "senderId", "day"]], + friendQuestParticipant: [["questId", "userId"], ["userId", "weekStart"]], + leagueSeason: [["key"]], + leagueDivision: [["seasonId", "tier", "number"]], + leagueMembership: [["seasonId", "userId"]], + academicCampus: [["code"]], + academicProgram: [["code"]], + academicOffering: [["campusId", "programId"]], }; /** Valores por defecto que aplica el schema y que algún test podría leer. */ diff --git a/tests/integration/discovery.integration.test.ts b/tests/integration/discovery.integration.test.ts new file mode 100644 index 0000000..36c3a8e --- /dev/null +++ b/tests/integration/discovery.integration.test.ts @@ -0,0 +1,180 @@ +import { afterAll, beforeEach, describe, expect, it } from "vitest"; + +import { getDiscoveryCandidates } from "@/features/discovery/queries"; +import { db } from "@/lib/db"; +import { decodeSignedToken } from "@/lib/social/signed-token"; + +import { createTestUser, resetSocialTables } from "./helpers"; + +async function makeOffering(campusCode: string, programCode: string, semesterCount = 8) { + const campus = await db.academicCampus.upsert({ + where: { code: campusCode }, + update: {}, + create: { code: campusCode, name: campusCode }, + }); + const program = await db.academicProgram.upsert({ + where: { code: programCode }, + update: {}, + create: { code: programCode, name: `Programa ${programCode}` }, + }); + return db.academicOffering.upsert({ + where: { campusId_programId: { campusId: campus.id, programId: program.id } }, + update: {}, + create: { campusId: campus.id, programId: program.id, semesterCount }, + }); +} + +describe("Discovery — buckets sobre Postgres real", () => { + beforeEach(async () => { + await resetSocialTables(); + await db.academicOffering.deleteMany({}); + await db.academicProgram.deleteMany({}); + await db.academicCampus.deleteMany({}); + }); + afterAll(async () => { + await resetSocialTables(); + await db.$disconnect(); + }); + + it("bucket1 (mutuals) gana sobre bucket5 (mismo plantel) — primer match", async () => { + const offering = await makeOffering("colomos", "software"); + const viewer = await db.user.create({ + data: { + email: "v@t.com", + name: "Viewer", + username: "viewer1", + academicOfferingId: offering.id, + academicSemester: 3, + }, + }); + const mutualFriend = await createTestUser("mf"); + const candidateWithMutual = await db.user.create({ + data: { email: "c1@t.com", name: "C1", username: "candidate_mutual", academicOfferingId: offering.id, academicSemester: 3 }, + }); + const candidateSameCampusOnly = await db.user.create({ + data: { email: "c2@t.com", name: "C2", username: "candidate_campus", academicOfferingId: offering.id, academicSemester: 1 }, + }); + + // viewer<->mutualFriend accepted, mutualFriend<->candidateWithMutual accepted + await db.friendship.createMany({ + data: [ + { requesterId: viewer.id, addresseeId: mutualFriend.id, status: "accepted", pairKey: `${[viewer.id, mutualFriend.id].sort().join(":")}` }, + { requesterId: mutualFriend.id, addresseeId: candidateWithMutual.id, status: "accepted", pairKey: `${[mutualFriend.id, candidateWithMutual.id].sort().join(":")}` }, + ], + }); + + const page = await getDiscoveryCandidates(viewer.id, { courseId: null }); + const ids = page.candidates.map((c) => c.id); + expect(ids).toContain(candidateWithMutual.id); + expect(ids).toContain(candidateSameCampusOnly.id); + + const mutualResult = page.candidates.find((c) => c.id === candidateWithMutual.id)!; + expect(mutualResult.bucket).toBe(1); + expect(mutualResult.mutualCount).toBe(1); + + const campusResult = page.candidates.find((c) => c.id === candidateSameCampusOnly.id)!; + expect(campusResult.bucket).toBe(5); + + // bucket1 antes que bucket5 en el orden. + expect(ids.indexOf(candidateWithMutual.id)).toBeLessThan(ids.indexOf(candidateSameCampusOnly.id)); + }); + + it("excluye self, accepted, pending, blocked y usernameSetupRequired", async () => { + const offering = await makeOffering("colomos", "software"); + const viewer = await db.user.create({ + data: { email: "v@t.com", name: "V", username: "viewer2", academicOfferingId: offering.id, academicSemester: 2 }, + }); + const friend = await db.user.create({ + data: { email: "f@t.com", name: "F", username: "friend2", academicOfferingId: offering.id, academicSemester: 2 }, + }); + const pendingUser = await db.user.create({ + data: { email: "p@t.com", name: "P", username: "pending2", academicOfferingId: offering.id, academicSemester: 2 }, + }); + const blockedUser = await db.user.create({ + data: { email: "b@t.com", name: "B", username: "blocked2", academicOfferingId: offering.id, academicSemester: 2 }, + }); + const provisional = await db.user.create({ + data: { + email: "prov@t.com", + name: "Prov", + username: "alumno_provisional2", + usernameSetupRequired: true, + academicOfferingId: offering.id, + academicSemester: 2, + }, + }); + + await db.friendship.createMany({ + data: [ + { requesterId: viewer.id, addresseeId: friend.id, status: "accepted", pairKey: [viewer.id, friend.id].sort().join(":") }, + { requesterId: viewer.id, addresseeId: pendingUser.id, status: "pending", pairKey: [viewer.id, pendingUser.id].sort().join(":") }, + { requesterId: viewer.id, addresseeId: blockedUser.id, status: "blocked", pairKey: [viewer.id, blockedUser.id].sort().join(":") }, + ], + }); + + const page = await getDiscoveryCandidates(viewer.id, { courseId: null }); + const ids = page.candidates.map((c) => c.id); + expect(ids).not.toContain(viewer.id); + expect(ids).not.toContain(friend.id); + expect(ids).not.toContain(pendingUser.id); + expect(ids).not.toContain(blockedUser.id); + expect(ids).not.toContain(provisional.id); + }); + + it("keyset pagination: la segunda página no repite resultados de la primera", async () => { + const offering = await makeOffering("colomos", "software"); + const viewer = await db.user.create({ + data: { email: "v3@t.com", name: "V3", username: "viewer3", academicOfferingId: offering.id, academicSemester: 4 }, + }); + for (let i = 0; i < 5; i++) { + await db.user.create({ + data: { + email: `cand${i}@t.com`, + name: `Cand ${i}`, + username: `candidate_p_${i}`, + academicOfferingId: offering.id, + academicSemester: 4, + }, + }); + } + + const firstPage = await getDiscoveryCandidates(viewer.id, { courseId: null, pageSize: 2 }); + expect(firstPage.candidates).toHaveLength(2); + expect(firstPage.nextCursor).not.toBeNull(); + + const secondPage = await getDiscoveryCandidates(viewer.id, { + courseId: null, + pageSize: 2, + cursor: firstPage.nextCursor, + }); + expect(secondPage.candidates).toHaveLength(2); + + const firstIds = firstPage.candidates.map((c) => c.id); + const secondIds = secondPage.candidates.map((c) => c.id); + expect(firstIds.some((id) => secondIds.includes(id))).toBe(false); + }); + + it("un discoveryToken forjado (firma inválida) se detecta como inválido", () => { + const forged = "eyJ2aWV3ZXJJZCI6ImV2aWwifQ.forged-signature"; + expect(decodeSignedToken(forged)).toBeNull(); + }); + + it("el contextToken de un candidato decodifica al (viewer, candidate, bucket) correctos", async () => { + const offering = await makeOffering("colomos", "software"); + const viewer = await db.user.create({ + data: { email: "v4@t.com", name: "V4", username: "viewer4", academicOfferingId: offering.id, academicSemester: 5 }, + }); + await db.user.create({ + data: { email: "c4@t.com", name: "C4", username: "candidate_ctx", academicOfferingId: offering.id, academicSemester: 5 }, + }); + + const page = await getDiscoveryCandidates(viewer.id, { courseId: null }); + expect(page.candidates.length).toBeGreaterThan(0); + const candidate = page.candidates[0]!; + const decoded = decodeSignedToken<{ viewerId: string; candidateId: string; bucket: string }>( + candidate.contextToken, + ); + expect(decoded?.viewerId).toBe(viewer.id); + expect(decoded?.candidateId).toBe(candidate.id); + }); +}); diff --git a/tests/integration/friendship.integration.test.ts b/tests/integration/friendship.integration.test.ts new file mode 100644 index 0000000..7358150 --- /dev/null +++ b/tests/integration/friendship.integration.test.ts @@ -0,0 +1,102 @@ +import { FriendStatus } from "@prisma/client"; +import { afterAll, beforeEach, describe, expect, it } from "vitest"; + +import { db } from "@/lib/db"; +import { + closeFriendshipPeriod, + friendshipCreateData, + openFriendshipPeriod, +} from "@/lib/social/friendship-lifecycle"; +import { pairKeyOf } from "@/lib/social/pair"; + +import { createTestUser, resetSocialTables } from "./helpers"; + +describe("Friendship — invariantes de Postgres real", () => { + beforeEach(async () => { + await resetSocialTables(); + }); + afterAll(async () => { + await resetSocialTables(); + await db.$disconnect(); + }); + + it("pairKey es UNIQUE — dos filas para el mismo par no pueden coexistir", async () => { + const a = await createTestUser("a"); + const b = await createTestUser("b"); + + await db.friendship.create({ + data: { ...friendshipCreateData(a.id, b.id, "profile", null), status: FriendStatus.pending }, + }); + + await expect( + db.friendship.create({ + // Dirección invertida, mismo par canónico → mismo pairKey. + data: { ...friendshipCreateData(b.id, a.id, "search", null), status: FriendStatus.pending }, + }), + ).rejects.toThrow(/Unique constraint/i); + }); + + it("a lo más UN FriendshipPeriod abierto por par — abrir dos veces es no-op", async () => { + const a = await createTestUser("a"); + const b = await createTestUser("b"); + + await db.$transaction(async (tx) => { + await openFriendshipPeriod(tx, a.id, b.id, "profile", null, new Date("2026-01-01T00:00:00Z")); + // Doble apertura concurrente/retry — el índice único parcial (WHERE + // endedAt IS NULL) hace que la segunda sea un no-op silencioso. + await openFriendshipPeriod(tx, a.id, b.id, "search", null, new Date("2026-01-02T00:00:00Z")); + }); + + const periods = await db.friendshipPeriod.findMany({ + where: { userLowId: a.id < b.id ? a.id : b.id, userHighId: a.id < b.id ? b.id : a.id }, + }); + expect(periods).toHaveLength(1); + expect(periods[0]?.source).toBe("profile"); // gana la primera apertura + }); + + it("dos aperturas CONCURRENTES (transacciones separadas) también producen un solo período", async () => { + const a = await createTestUser("a"); + const b = await createTestUser("b"); + + const open = () => + db.$transaction((tx) => openFriendshipPeriod(tx, a.id, b.id, "profile", null)); + + // Dos transacciones reales, en paralelo — ejercita el índice único + // parcial bajo concurrencia genuina, no sólo dentro de una tx. + await Promise.all([open(), open()]); + + const periods = await db.friendshipPeriod.findMany({ + where: { userLowId: a.id < b.id ? a.id : b.id, userHighId: a.id < b.id ? b.id : a.id, endedAt: null }, + }); + expect(periods).toHaveLength(1); + }); + + it("cerrar y reabrir crea un SEGUNDO período (el primero queda cerrado)", async () => { + const a = await createTestUser("a"); + const b = await createTestUser("b"); + + await db.$transaction(async (tx) => { + await openFriendshipPeriod(tx, a.id, b.id, "profile", null, new Date("2026-01-01T00:00:00Z")); + await closeFriendshipPeriod(tx, a.id, b.id, "unfriended", new Date("2026-01-05T00:00:00Z")); + }); + await db.$transaction(async (tx) => { + await openFriendshipPeriod(tx, a.id, b.id, "search", null, new Date("2026-02-01T00:00:00Z")); + }); + + const periods = await db.friendshipPeriod.findMany({ + where: { userLowId: a.id < b.id ? a.id : b.id, userHighId: a.id < b.id ? b.id : a.id }, + orderBy: { startedAt: "asc" }, + }); + expect(periods).toHaveLength(2); + expect(periods[0]?.endedAt).not.toBeNull(); + expect(periods[1]?.endedAt).toBeNull(); + }); + + it("pairKeyOf coincide con lo que persiste friendshipCreateData", async () => { + const a = await createTestUser("a"); + const b = await createTestUser("b"); + const data = friendshipCreateData(a.id, b.id, "profile", null); + expect(data.pairKey).toBe(pairKeyOf(a.id, b.id)); + expect(data.pairKey).toBe(pairKeyOf(b.id, a.id)); + }); +}); diff --git a/tests/integration/helpers.ts b/tests/integration/helpers.ts new file mode 100644 index 0000000..e829397 --- /dev/null +++ b/tests/integration/helpers.ts @@ -0,0 +1,40 @@ +import { db } from "@/lib/db"; + +let seq = 0; + +/** Crea un usuario mínimo para tests de integración. Username único por llamada. */ +export async function createTestUser(namePrefix = "u"): Promise<{ id: string; username: string }> { + seq++; + const suffix = `${Date.now().toString(36)}${seq}`; + const user = await db.user.create({ + data: { + email: `${namePrefix}${suffix}@integration.test`, + name: `${namePrefix}${suffix}`, + username: `${namePrefix}_${suffix}`.slice(0, 20), + }, + select: { id: true, username: true }, + }); + return user; +} + +/** Borra TODA la data de las tablas sociales — sólo para esta suite, DB de test dedicada. */ +export async function resetSocialTables(): Promise { + await db.$transaction([ + db.kudos.deleteMany({}), + db.socialEvent.deleteMany({}), + db.streakReminder.deleteMany({}), + db.friendStreakDay.deleteMany({}), + db.friendStreak.deleteMany({}), + db.friendQuestParticipant.deleteMany({}), + db.friendQuest.deleteMany({}), + db.leagueMembership.deleteMany({}), + db.leagueDivision.deleteMany({}), + db.leagueSeason.deleteMany({}), + db.xpAward.deleteMany({}), + db.inviteAttribution.deleteMany({}), + db.friendshipPeriod.deleteMany({}), + db.friendship.deleteMany({}), + db.userStreak.deleteMany({}), + db.user.deleteMany({}), + ]); +} diff --git a/tests/lib/social/league.test.ts b/tests/lib/social/league.test.ts new file mode 100644 index 0000000..4eb6993 --- /dev/null +++ b/tests/lib/social/league.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from "vitest"; + +import { + balancedDivisionSizes, + divisionCountFor, + promotionSlotsFor, + resolveRolloverOutcome, +} from "@/lib/social/league"; + +describe("divisionCountFor / balancedDivisionSizes", () => { + it.each([ + [2, 1, [2]], + [7, 1, [7]], + [19, 1, [19]], + [21, 1, [21]], + [40, 2, [20, 20]], + ])("N=%i → %i división(es) balanceada(s)", (n, expectedCount, expectedSizes) => { + const count = divisionCountFor(n); + expect(count).toBe(expectedCount); + const sizes = balancedDivisionSizes(n, count); + expect(sizes).toEqual(expectedSizes); + expect(sizes.reduce((a, b) => a + b, 0)).toBe(n); + }); + + it("el tamaño de las divisiones nunca difiere en más de 1", () => { + for (const n of [1, 3, 13, 55, 101]) { + const count = divisionCountFor(n); + const sizes = balancedDivisionSizes(n, count); + expect(Math.max(...sizes) - Math.min(...sizes)).toBeLessThanOrEqual(1); + expect(sizes.reduce((a, b) => a + b, 0)).toBe(n); + } + }); +}); + +describe("promotionSlotsFor", () => { + it("N>=10 usa top5/bottom5", () => { + expect(promotionSlotsFor(10)).toEqual({ promoteCount: 5, relegateCount: 5 }); + expect(promotionSlotsFor(25)).toEqual({ promoteCount: 5, relegateCount: 5 }); + }); + + it("N<10 usa floor(N/2) sin solapamiento", () => { + expect(promotionSlotsFor(9)).toEqual({ promoteCount: 4, relegateCount: 4 }); + expect(promotionSlotsFor(2)).toEqual({ promoteCount: 1, relegateCount: 1 }); + expect(promotionSlotsFor(1)).toEqual({ promoteCount: 0, relegateCount: 0 }); + + for (const n of [1, 2, 3, 5, 7, 9]) { + const { promoteCount, relegateCount } = promotionSlotsFor(n); + expect(promoteCount + relegateCount).toBeLessThanOrEqual(n); + } + }); +}); + +describe("resolveRolloverOutcome", () => { + it("Diamond top queda held_at_ceiling en vez de promoted", () => { + const r = resolveRolloverOutcome(1, 20, "diamond"); + expect(r).toEqual({ outcome: "held_at_ceiling", nextTier: "diamond" }); + }); + + it("Bronze bottom queda held_at_floor en vez de relegated", () => { + const r = resolveRolloverOutcome(20, 20, "bronze"); + expect(r).toEqual({ outcome: "held_at_floor", nextTier: "bronze" }); + }); + + it("tiers intermedios promueven/degradan normalmente", () => { + expect(resolveRolloverOutcome(1, 20, "gold")).toEqual({ + outcome: "promoted", + nextTier: "platinum", + }); + expect(resolveRolloverOutcome(20, 20, "gold")).toEqual({ + outcome: "relegated", + nextTier: "silver", + }); + }); + + it("rangos intermedios se quedan (stayed)", () => { + expect(resolveRolloverOutcome(10, 20, "silver")).toEqual({ + outcome: "stayed", + nextTier: "silver", + }); + }); +}); diff --git a/tests/lib/social/pair.test.ts b/tests/lib/social/pair.test.ts new file mode 100644 index 0000000..42881d6 --- /dev/null +++ b/tests/lib/social/pair.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from "vitest"; + +import { canonicalPair, pairKeyOf } from "@/lib/social/pair"; + +describe("canonicalPair / pairKeyOf", () => { + it("es simétrico: A,B y B,A producen el mismo resultado", () => { + expect(canonicalPair("user_a", "user_b")).toEqual(canonicalPair("user_b", "user_a")); + expect(pairKeyOf("user_a", "user_b")).toBe(pairKeyOf("user_b", "user_a")); + }); + + it("ordena lexicográficamente", () => { + expect(canonicalPair("z", "a")).toEqual({ lowId: "a", highId: "z" }); + }); +}); diff --git a/tests/lib/social/ranking.test.ts b/tests/lib/social/ranking.test.ts new file mode 100644 index 0000000..5a30fb9 --- /dev/null +++ b/tests/lib/social/ranking.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; + +import { rankMembers } from "@/lib/social/ranking"; + +describe("rankMembers", () => { + it("ordena por XP desc y nunca comparte rank (userId asc como último desempate)", () => { + const ranked = rankMembers([ + { userId: "b", xp: 100, lastAwardAt: null }, + { userId: "a", xp: 100, lastAwardAt: null }, + { userId: "c", xp: 200, lastAwardAt: null }, + ]); + expect(ranked.map((r) => r.member.userId)).toEqual(["c", "a", "b"]); + expect(ranked.map((r) => r.rank)).toEqual([1, 2, 3]); + }); + + it("con XP empatado, gana quien llegó primero (lastAwardAt asc)", () => { + const early = new Date("2026-01-01T00:00:00Z"); + const late = new Date("2026-01-02T00:00:00Z"); + const ranked = rankMembers([ + { userId: "late", xp: 50, lastAwardAt: late }, + { userId: "early", xp: 50, lastAwardAt: early }, + ]); + expect(ranked.map((r) => r.member.userId)).toEqual(["early", "late"]); + }); + + it("incluye miembros con 0 XP y sin lastAwardAt (nunca ganaron XP)", () => { + const ranked = rankMembers([ + { userId: "z", xp: 0, lastAwardAt: null }, + { userId: "a", xp: 0, lastAwardAt: null }, + ]); + expect(ranked).toHaveLength(2); + // Empate total de XP y actividad → desempata por userId. + expect(ranked.map((r) => r.member.userId)).toEqual(["a", "z"]); + }); +}); diff --git a/tests/lib/social/redirect.test.ts b/tests/lib/social/redirect.test.ts new file mode 100644 index 0000000..7bde672 --- /dev/null +++ b/tests/lib/social/redirect.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest"; + +import { safeInternalRedirect } from "@/lib/social/redirect"; + +describe("safeInternalRedirect", () => { + it("acepta un path interno normal", () => { + expect(safeInternalRedirect("/app/amigos")).toBe("/app/amigos"); + }); + + it("rechaza protocol-relative (//host)", () => { + expect(safeInternalRedirect("//evil.com")).toBe("/app"); + }); + + it("rechaza esquemas absolutos", () => { + expect(safeInternalRedirect("http://evil.com")).toBe("/app"); + expect(safeInternalRedirect("https://evil.com/app")).toBe("/app"); + expect(safeInternalRedirect("javascript:alert(1)")).toBe("/app"); + }); + + it("rechaza valores que no empiezan con /", () => { + expect(safeInternalRedirect("app/amigos")).toBe("/app"); + expect(safeInternalRedirect("")).toBe("/app"); + expect(safeInternalRedirect(null)).toBe("/app"); + expect(safeInternalRedirect(undefined)).toBe("/app"); + }); + + it("rechaza backslashes que un navegador podría tratar como //", () => { + expect(safeInternalRedirect("/\\evil.com")).toBe("/app"); + }); + + it("respeta un fallback custom", () => { + expect(safeInternalRedirect(null, "/login")).toBe("/login"); + }); + + it("decodifica URI-encoding antes de validar", () => { + expect(safeInternalRedirect("%2F%2Fevil.com")).toBe("/app"); + }); +}); diff --git a/tests/lib/social/time.test.ts b/tests/lib/social/time.test.ts new file mode 100644 index 0000000..bbf6cca --- /dev/null +++ b/tests/lib/social/time.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from "vitest"; + +import { + isNextDateOnly, + mxDateOnly, + mxDayKey, + mxDayRange, + mxWeekKey, + mxWeekRange, + shiftDateOnly, +} from "@/lib/social/time"; + +describe("mxDayKey / mxDayRange", () => { + it("resuelve el día calendario local en UTC-6 (México sin DST)", () => { + // 2026-03-15 05:59:59 UTC == 2026-03-14 23:59:59 en Ciudad de México. + const justBeforeMidnight = new Date("2026-03-15T05:59:59.000Z"); + expect(mxDayKey(justBeforeMidnight)).toBe("2026-03-14"); + + // Un segundo después ya es el día siguiente en México. + const justAfterMidnight = new Date("2026-03-15T06:00:00.000Z"); + expect(mxDayKey(justAfterMidnight)).toBe("2026-03-15"); + }); + + it("el rango es [start, end) exacto — el instante de start pertenece, el de end no", () => { + const { start, end } = mxDayRange(new Date("2026-06-10T18:00:00.000Z")); + expect(mxDayKey(start)).toBe(mxDayKey(new Date(start.getTime()))); + expect(mxDayKey(new Date(end.getTime() - 1))).toBe(mxDayKey(start)); + expect(mxDayKey(end)).not.toBe(mxDayKey(start)); + expect(end.getTime() - start.getTime()).toBe(24 * 60 * 60 * 1000); + }); +}); + +describe("mxWeekRange — frontera domingo/lunes", () => { + it("un domingo tarde y el lunes siguiente temprano caen en semanas distintas", () => { + // Domingo 2026-08-30 23:00 local (UTC-6) == lunes en UTC pero domingo local. + const sundayNight = new Date("2026-08-31T05:00:00.000Z"); // domingo 23:00 CDMX + const mondayMorning = new Date("2026-08-31T06:30:00.000Z"); // lunes 00:30 CDMX + + const weekOfSunday = mxWeekRange(sundayNight); + const weekOfMonday = mxWeekRange(mondayMorning); + + expect(weekOfSunday.start.getTime()).not.toBe(weekOfMonday.start.getTime()); + // El domingo cae DENTRO del rango [start, end) de su propia semana. + expect(sundayNight.getTime() >= weekOfSunday.start.getTime()).toBe(true); + expect(sundayNight.getTime() < weekOfSunday.end.getTime()).toBe(true); + // El lunes es exactamente el `start` de la semana siguiente. + expect(mondayMorning.getTime()).toBeGreaterThanOrEqual(weekOfMonday.start.getTime()); + expect(weekOfMonday.start.getTime()).toBe(weekOfSunday.end.getTime()); + }); + + it("la semana siempre dura exactamente 7 días", () => { + const { start, end } = mxWeekRange(new Date("2026-01-15T12:00:00.000Z")); + expect(end.getTime() - start.getTime()).toBe(7 * 24 * 60 * 60 * 1000); + }); + + it("mxWeekKey es estable para cualquier instante dentro de la misma semana", () => { + const monday = new Date("2026-09-07T07:00:00.000Z"); // lunes 01:00 CDMX + const sundayEnd = new Date("2026-09-14T05:59:00.000Z"); // domingo 23:59 CDMX + expect(mxWeekKey(monday)).toBe(mxWeekKey(sundayEnd)); + }); +}); + +describe("mxDateOnly / shiftDateOnly / isNextDateOnly", () => { + it("dos instantes del mismo día local producen el mismo DATE-only", () => { + const a = mxDateOnly(new Date("2026-05-01T06:01:00.000Z")); + const b = mxDateOnly(new Date("2026-05-01T23:00:00.000Z")); + expect(a.getTime()).toBe(b.getTime()); + }); + + it("isNextDateOnly detecta consecutividad y rechaza saltos", () => { + const day1 = mxDateOnly(new Date("2026-05-01T12:00:00.000Z")); + const day2 = mxDateOnly(new Date("2026-05-02T12:00:00.000Z")); + const day3 = mxDateOnly(new Date("2026-05-03T12:00:00.000Z")); + expect(isNextDateOnly(day1, day2)).toBe(true); + expect(isNextDateOnly(day1, day3)).toBe(false); + expect(shiftDateOnly(day1, 1).getTime()).toBe(day2.getTime()); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index 5e70016..725ff6b 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -12,7 +12,9 @@ export default defineConfig({ }, test: { include: ["src/**/*.{test,spec}.{ts,tsx}", "tests/**/*.{test,spec}.{ts,tsx}"], - exclude: ["**/node_modules/**", "**/.next/**"], + // Los tests de integración necesitan PostgreSQL real y corren aparte + // (`npm run test:integration`, ver `vitest.integration.config.ts`). + exclude: ["**/node_modules/**", "**/.next/**", "tests/integration/**"], environment: "node", // Mínimas env vars que src/env.ts requiere al boot. Cualquier módulo que // se importe indirectamente desde un test (logger, executor, etc.) las diff --git a/vitest.integration.config.ts b/vitest.integration.config.ts new file mode 100644 index 0000000..bf1b455 --- /dev/null +++ b/vitest.integration.config.ts @@ -0,0 +1,38 @@ +import { fileURLToPath } from "node:url"; +import { defineConfig } from "vitest/config"; + +/** + * Config de tests de INTEGRACIÓN contra PostgreSQL real — constraints, + * índices parciales, locks y transacciones que un doble de prueba en + * memoria (`tests/helpers/fake-prisma.ts`) no puede reproducir fielmente + * (ej. la unique parcial de `friendship_period`, `pg_advisory_xact_lock`, + * `SERIALIZABLE`). Deliberadamente chico: sólo los invariantes que de + * verdad necesitan Postgres — el resto de la suite sigue en + * `vitest.config.ts` sin tocar una base de datos. + * + * Requiere una base de datos Postgres real y las migraciones aplicadas: + * + * createdb cpp_ceti_test + * npx dotenv -e .env.local -- prisma migrate deploy + * npm run test:integration + * + * `DATABASE_URL` se toma de `.env.local` (o del entorno) — NO de un + * placeholder, a diferencia de `vitest.config.ts`. + */ +export default defineConfig({ + resolve: { + alias: { + "@": fileURLToPath(new URL("./src", import.meta.url)), + }, + }, + test: { + include: ["tests/integration/**/*.{test,spec}.ts"], + exclude: ["**/node_modules/**", "**/.next/**"], + environment: "node", + testTimeout: 30_000, + // Los tests de integración comparten filas (mismo pg advisory lock + // keyspace, mismas tablas) — correrlos en paralelo entre archivos + // podría cruzar datos de un test con otro. + fileParallelism: false, + }, +}); From 71eb3e128b328d92415f26616581bc08cc492840 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 00:05:52 +0000 Subject: [PATCH 2/6] Fase 3: feed social (hitos) + kudos, discovery UI, invite attribution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SocialEvent + Kudos: hitos (unit_completed, course_completed, streak_milestone) emitidos idempotentemente dentro de la misma transacción del hecho de dominio (completeStep/submitExercise, awardXpAndUpdateStreak) - checkUnitAndCourseCompletion: detecta primera vez que se completa una unidad/curso sin N+1 (una sola lectura de las lecciones publicadas del curso) - Feed fanout-on-read (self + amigos accepted actuales) — un unfriend/block hace desaparecer los eventos de esa persona de inmediato, sin borrar nada - Kudos: no propio, duplicado no-op, sólo remove propio - UI: pestañas "Descubrir" y "Actividad" en /app/amigos, reutilizando componentes existentes (FriendAvatar, Tabs) - Invite attribution consumida vía databaseHooks.user.create.after (sólo alta nueva, nunca login) + cookie first-touch capturada desde /invitar/[username] - ProductEvent nuevos (discovery_impression, discovery_profile_open) con props validados por Zod, sin username/id de candidato ni datos académicos - Tests de integración contra Postgres real: dedupe de milestones, desaparición inmediata del feed tras unfriend, doble-click de kudos Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_018XQFppGcovvjRoHZyQa6GF --- src/app/app/(global)/amigos/page.tsx | 38 +++++- src/features/discovery/actions.ts | 80 ++++++++++++ .../discovery/components/discovery-list.tsx | 108 ++++++++++++++++ .../friends/components/friends-tabs.tsx | 22 +++- src/features/lessons/actions.ts | 3 + src/features/social-feed/actions.ts | 76 +++++++++++ .../social-feed/components/milestone-feed.tsx | 118 ++++++++++++++++++ src/features/social-feed/queries.ts | 106 ++++++++++++++++ src/lib/analytics/social-props.ts | 26 ++++ src/lib/social/social-events.ts | 103 +++++++++++++++ src/lib/streak.ts | 5 + .../social-events.integration.test.ts | 107 ++++++++++++++++ 12 files changed, 789 insertions(+), 3 deletions(-) create mode 100644 src/features/discovery/actions.ts create mode 100644 src/features/discovery/components/discovery-list.tsx create mode 100644 src/features/social-feed/actions.ts create mode 100644 src/features/social-feed/components/milestone-feed.tsx create mode 100644 src/features/social-feed/queries.ts create mode 100644 src/lib/analytics/social-props.ts create mode 100644 src/lib/social/social-events.ts create mode 100644 tests/integration/social-events.integration.test.ts diff --git a/src/app/app/(global)/amigos/page.tsx b/src/app/app/(global)/amigos/page.tsx index adce148..8ab0aba 100644 --- a/src/app/app/(global)/amigos/page.tsx +++ b/src/app/app/(global)/amigos/page.tsx @@ -1,4 +1,9 @@ +import { randomUUID } from "node:crypto"; + import { SectionRule } from "@/components/ui/section-rule"; +import { getDiscoveryCandidates } from "@/features/discovery/queries"; +import { discoveryImpressionPropsSchema } from "@/lib/analytics/social-props"; +import { recordProductEventSafely } from "@/lib/analytics/record"; import { FriendsTabs } from "@/features/friends/components/friends-tabs"; import { InviteLinkCard } from "@/features/friends/components/invite-link-card"; import { @@ -6,6 +11,9 @@ import { getPendingIncoming, getPendingOutgoing, } from "@/features/friends/queries"; +import { getSocialFeed } from "@/features/social-feed/queries"; +import { readSelectedCourseSlug } from "@/lib/course-selection"; +import { db } from "@/lib/db"; import { requireConfirmedUsername } from "@/lib/get-session"; export const metadata = { @@ -22,15 +30,38 @@ export default async function AmigosPage({ const session = await requireConfirmedUsername(); const userId = session.user.id; - const [friends, incoming, outgoing, params] = await Promise.all([ + const courseSlug = await readSelectedCourseSlug(); + const course = courseSlug + ? await db.course.findUnique({ where: { slug: courseSlug, published: true }, select: { id: true } }) + : null; + + const [friends, incoming, outgoing, discovery, feed, params] = await Promise.all([ getFriends(userId), getPendingIncoming(userId), getPendingOutgoing(userId), + getDiscoveryCandidates(userId, { courseId: course?.id ?? null }), + getSocialFeed(userId), searchParams, ]); + const bucketCounts: Record = {}; + for (const c of discovery.candidates) bucketCounts[c.bucket] = (bucketCounts[c.bucket] ?? 0) + 1; + await recordProductEventSafely(db, { + userId, + name: "discovery_impression", + surface: "social", + props: discoveryImpressionPropsSchema.parse({ + discoverySessionKey: randomUUID(), + resultCount: discovery.candidates.length, + bucketCounts, + }), + }); + const initialTab = - params.tab === "solicitudes" || params.tab === "buscar" + params.tab === "solicitudes" || + params.tab === "buscar" || + params.tab === "descubrir" || + params.tab === "actividad" ? params.tab : incoming.length > 0 ? "solicitudes" @@ -62,6 +93,9 @@ export default async function AmigosPage({ incoming={incoming} outgoing={outgoing} meUsername={session.user.username} + meId={userId} + discovery={discovery} + feed={feed.events} />
diff --git a/src/features/discovery/actions.ts b/src/features/discovery/actions.ts new file mode 100644 index 0000000..1eeed07 --- /dev/null +++ b/src/features/discovery/actions.ts @@ -0,0 +1,80 @@ +"use server"; + +import { z } from "zod"; + +import { ActionError, withActionErrorHandling } from "@/lib/action-error"; +import { discoveryImpressionPropsSchema, discoveryProfileOpenPropsSchema } from "@/lib/analytics/social-props"; +import { recordProductEventSafely } from "@/lib/analytics/record"; +import { readSelectedCourseSlug } from "@/lib/course-selection"; +import { db } from "@/lib/db"; +import { getDiscoveryCandidates, type DiscoveryPage } from "@/features/discovery/queries"; +import { requireSession } from "@/lib/get-session"; +import { enforceRateLimit } from "@/lib/rate-limit"; + +const pageSchema = z.object({ + cursor: z.string().nullish(), + discoverySessionKey: z.string().min(1).max(100), +}); + +export const getDiscoveryPage = withActionErrorHandling( + "getDiscoveryPage", + async (input: { cursor?: string | null; discoverySessionKey: string }): Promise => { + const session = await requireSession(); + if (session.user.usernameSetupRequired) { + throw new ActionError("Confirma tu nombre de usuario para descubrir compañeros"); + } + await enforceRateLimit(session.user.id, "discovery"); + const { cursor, discoverySessionKey } = pageSchema.parse(input); + + const courseSlug = await readSelectedCourseSlug(); + let courseId: string | null = null; + if (courseSlug) { + const course = await db.course.findUnique({ + where: { slug: courseSlug, published: true }, + select: { id: true }, + }); + courseId = course?.id ?? null; + } + + const page = await getDiscoveryCandidates(session.user.id, { + courseId, + cursor: cursor ?? null, + }); + + const bucketCounts: Record = {}; + for (const c of page.candidates) { + bucketCounts[c.bucket] = (bucketCounts[c.bucket] ?? 0) + 1; + } + await recordProductEventSafely(db, { + userId: session.user.id, + name: "discovery_impression", + surface: "social", + props: discoveryImpressionPropsSchema.parse({ + discoverySessionKey, + resultCount: page.candidates.length, + bucketCounts, + }), + }); + + return page; + }, +); + +const profileOpenSchema = z.object({ + bucket: z.number().int().min(1).max(5), + discoverySessionKey: z.string().min(1).max(100), +}); + +export const trackDiscoveryProfileOpen = withActionErrorHandling( + "trackDiscoveryProfileOpen", + async (input: { bucket: number; discoverySessionKey: string }): Promise => { + const session = await requireSession(); + const { bucket, discoverySessionKey } = profileOpenSchema.parse(input); + await recordProductEventSafely(db, { + userId: session.user.id, + name: "discovery_profile_open", + surface: "social", + props: discoveryProfileOpenPropsSchema.parse({ bucket, discoverySessionKey }), + }); + }, +); diff --git a/src/features/discovery/components/discovery-list.tsx b/src/features/discovery/components/discovery-list.tsx new file mode 100644 index 0000000..c9e2aed --- /dev/null +++ b/src/features/discovery/components/discovery-list.tsx @@ -0,0 +1,108 @@ +"use client"; + +import * as React from "react"; +import Link from "next/link"; +import { UserPlus, Users } from "lucide-react"; +import { toast } from "sonner"; + +import { Button } from "@/components/ui/button"; +import { FriendAvatar } from "@/features/friends/components/friend-avatar"; +import { sendFriendRequest } from "@/features/friends/actions"; +import { getDiscoveryPage, trackDiscoveryProfileOpen } from "@/features/discovery/actions"; +import type { DiscoveryCandidate } from "@/features/discovery/queries"; + +type CardState = DiscoveryCandidate & { status: "idle" | "sent" }; + +export function DiscoveryList({ initialPage }: { initialPage: { candidates: DiscoveryCandidate[]; nextCursor: string | null } }) { + const [sessionKey] = React.useState(() => crypto.randomUUID()); + const [candidates, setCandidates] = React.useState( + initialPage.candidates.map((c) => ({ ...c, status: "idle" as const })), + ); + const [cursor, setCursor] = React.useState(initialPage.nextCursor); + const [loadingMore, setLoadingMore] = React.useState(false); + + async function loadMore() { + if (!cursor || loadingMore) return; + setLoadingMore(true); + try { + const page = await getDiscoveryPage({ cursor, discoverySessionKey: sessionKey }); + setCandidates((prev) => [...prev, ...page.candidates.map((c) => ({ ...c, status: "idle" as const }))]); + setCursor(page.nextCursor); + } catch { + toast.error("No pudimos cargar más resultados"); + } finally { + setLoadingMore(false); + } + } + + async function handleAdd(candidate: CardState) { + setCandidates((prev) => prev.map((c) => (c.id === candidate.id ? { ...c, status: "sent" } : c))); + try { + await sendFriendRequest({ + username: candidate.username, + source: "discovery", + discoveryToken: candidate.contextToken, + }); + toast.success(`Solicitud enviada a @${candidate.username}`); + } catch (err) { + setCandidates((prev) => prev.map((c) => (c.id === candidate.id ? { ...c, status: "idle" } : c))); + toast.error(err instanceof Error ? err.message : "Algo salió mal"); + } + } + + if (candidates.length === 0) { + return ( +
+ +

Nada por aquí todavía

+

+ Completa tu perfil académico para que encontremos compañeros de tu + grupo, carrera o plantel. +

+
+ ); + } + + return ( +
+
    + {candidates.map((candidate) => ( +
  • + + void trackDiscoveryProfileOpen({ bucket: candidate.bucket, discoverySessionKey: sessionKey }) + } + className="flex min-w-0 flex-1 items-center gap-3" + > + +
    +

    {candidate.name}

    +

    + {candidate.reason} +

    +
    + + +
  • + ))} +
+ {cursor ? ( + + ) : null} +
+ ); +} diff --git a/src/features/friends/components/friends-tabs.tsx b/src/features/friends/components/friends-tabs.tsx index 106ebd9..b46b0a2 100644 --- a/src/features/friends/components/friends-tabs.tsx +++ b/src/features/friends/components/friends-tabs.tsx @@ -4,6 +4,10 @@ import * as React from "react"; import { Badge } from "@/components/ui/badge"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { DiscoveryList } from "@/features/discovery/components/discovery-list"; +import type { DiscoveryCandidate } from "@/features/discovery/queries"; +import { MilestoneFeed } from "@/features/social-feed/components/milestone-feed"; +import type { FeedEvent } from "@/features/social-feed/queries"; import { FriendsList } from "./friends-list"; import { IncomingRequests } from "./incoming-requests"; import { OutgoingRequests } from "./outgoing-requests"; @@ -13,7 +17,7 @@ import type { PendingRequest, } from "@/features/friends/queries"; -type TabKey = "amigos" | "solicitudes" | "buscar"; +type TabKey = "amigos" | "solicitudes" | "buscar" | "descubrir" | "actividad"; interface FriendsTabsProps { initialTab: TabKey; @@ -21,6 +25,9 @@ interface FriendsTabsProps { incoming: PendingRequest[]; outgoing: PendingRequest[]; meUsername: string; + meId: string; + discovery: { candidates: DiscoveryCandidate[]; nextCursor: string | null }; + feed: FeedEvent[]; } export function FriendsTabs({ @@ -29,6 +36,9 @@ export function FriendsTabs({ incoming, outgoing, meUsername, + meId, + discovery, + feed, }: FriendsTabsProps) { const [tab, setTab] = React.useState(initialTab); @@ -52,6 +62,8 @@ export function FriendsTabs({ ) : null} Buscar + Descubrir + Actividad @@ -66,6 +78,14 @@ export function FriendsTabs({ + + + + + + + + ); } diff --git a/src/features/lessons/actions.ts b/src/features/lessons/actions.ts index e1a4029..89290d6 100644 --- a/src/features/lessons/actions.ts +++ b/src/features/lessons/actions.ts @@ -10,6 +10,7 @@ import { buildFeedback, getExecutorForProfile } from "@/lib/executor"; import type { TestCaseResult } from "@/lib/executor"; import { requireSession } from "@/lib/get-session"; import { enforceRateLimit } from "@/lib/rate-limit"; +import { checkUnitAndCourseCompletion } from "@/lib/social/social-events"; import { buildStructureFeedback, checkStructure } from "@/lib/structure"; import { awardXpAndUpdateStreak, incrementUserXp } from "@/lib/streak"; import { @@ -63,6 +64,7 @@ export const completeStep = withActionErrorHandling( dedupeKey: xpDedupeKey.lesson(step.lessonId), lessonId: step.lessonId, }); + await checkUnitAndCourseCompletion(tx, userId, step.lesson.unit.id, step.lesson.unit.course.id); } return progression; }); @@ -249,6 +251,7 @@ export const submitExercise = withActionErrorHandling( dedupeKey: xpDedupeKey.lesson(lesson.id), lessonId: lesson.id, }); + await checkUnitAndCourseCompletion(tx, userId, lesson.unit.id, lesson.unit.course.id); xp += progression.lessonXpEarned; } return xp; diff --git a/src/features/social-feed/actions.ts b/src/features/social-feed/actions.ts new file mode 100644 index 0000000..e994178 --- /dev/null +++ b/src/features/social-feed/actions.ts @@ -0,0 +1,76 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { z } from "zod"; + +import { ActionError, withActionErrorHandling } from "@/lib/action-error"; +import { db } from "@/lib/db"; +import { requireSession } from "@/lib/get-session"; +import { enforceRateLimit } from "@/lib/rate-limit"; +import { cuidSchema, parseOrThrow } from "@/lib/validation"; + +const byEventIdSchema = z.object({ eventId: cuidSchema }); + +/** + * Verifica que `eventId` sea de un evento ACTUALMENTE visible para + * `viewerId`: el propio viewer o un amigo accepted VIGENTE (recalculado en + * cada llamada — igual que el feed, fanout-on-read). + */ +async function requireVisibleEvent(viewerId: string, eventId: string) { + const event = await db.socialEvent.findUnique({ + where: { id: eventId }, + select: { id: true, actorId: true }, + }); + if (!event) throw new ActionError("Ese hito ya no existe"); + if (event.actorId === viewerId) return event; + + const friendship = await db.friendship.findFirst({ + where: { + status: "accepted", + OR: [ + { requesterId: viewerId, addresseeId: event.actorId }, + { requesterId: event.actorId, addresseeId: viewerId }, + ], + }, + select: { id: true }, + }); + if (!friendship) throw new ActionError("Ese hito ya no está disponible"); + return event; +} + +/** Da kudos a un hito. No propio, duplicado es no-op, sólo hitos visibles. */ +export const giveKudos = withActionErrorHandling( + "giveKudos", + async (input: { eventId: string }): Promise<{ ok: true }> => { + const session = await requireSession(); + const me = session.user.id; + const { eventId } = parseOrThrow(byEventIdSchema, input); + await enforceRateLimit(me, "kudos"); + + const event = await requireVisibleEvent(me, eventId); + if (event.actorId === me) throw new ActionError("No puedes darte kudos a ti mismo"); + + await db.kudos.createMany({ + data: [{ eventId, userId: me }], + skipDuplicates: true, + }); + + revalidatePath("/app"); + return { ok: true }; + }, +); + +/** Quita MI kudos de un hito (no el de nadie más). */ +export const removeKudos = withActionErrorHandling( + "removeKudos", + async (input: { eventId: string }): Promise<{ ok: true }> => { + const session = await requireSession(); + const me = session.user.id; + const { eventId } = parseOrThrow(byEventIdSchema, input); + + await db.kudos.deleteMany({ where: { eventId, userId: me } }); + + revalidatePath("/app"); + return { ok: true }; + }, +); diff --git a/src/features/social-feed/components/milestone-feed.tsx b/src/features/social-feed/components/milestone-feed.tsx new file mode 100644 index 0000000..d4cf735 --- /dev/null +++ b/src/features/social-feed/components/milestone-feed.tsx @@ -0,0 +1,118 @@ +"use client"; + +import * as React from "react"; +import Link from "next/link"; +import { Heart, PartyPopper, Sparkles, Trophy } from "lucide-react"; + +import { relativeFromNow } from "@/lib/relative-time"; +import { giveKudos, removeKudos } from "@/features/social-feed/actions"; +import type { FeedEvent } from "@/features/social-feed/queries"; +import { FriendAvatar } from "@/features/friends/components/friend-avatar"; +import { cn } from "@/lib/utils"; + +function labelFor(event: FeedEvent): string { + switch (event.kind) { + case "unit_completed": + return `completó la unidad${event.unitTitle ? ` "${event.unitTitle}"` : ""}`; + case "course_completed": + return `terminó el curso${event.courseTitle ? ` ${event.courseTitle}` : ""}`; + case "streak_milestone": + return `llegó a ${event.value ?? "?"} días de racha`; + case "league_promoted": + return "subió de liga"; + case "friend_quest_completed": + return "completó una misión con un amigo"; + } +} + +const KIND_ICON: Record = { + unit_completed: Sparkles, + course_completed: Trophy, + streak_milestone: PartyPopper, + league_promoted: Trophy, + friend_quest_completed: Trophy, +}; + +export function MilestoneFeed({ events, viewerId }: { events: FeedEvent[]; viewerId: string }) { + if (events.length === 0) { + return ( +
+

Sin hitos todavía

+

+ Cuando tú o tus amigos completen una unidad, un curso o lleguen a + una racha importante, aparece aquí. +

+
+ ); + } + + return ( +
    + {events.map((event) => ( + + ))} +
+ ); +} + +function MilestoneRow({ event, viewerId }: { event: FeedEvent; viewerId: string }) { + const [given, setGiven] = React.useState(event.kudosByMe); + const [count, setCount] = React.useState(event.kudosCount); + const [pending, startTransition] = React.useTransition(); + const Icon = KIND_ICON[event.kind]; + const isSelf = event.actor.id === viewerId; + + function toggle() { + if (isSelf || pending) return; + const next = !given; + setGiven(next); + setCount((c) => c + (next ? 1 : -1)); + startTransition(async () => { + try { + await (next ? giveKudos({ eventId: event.id }) : removeKudos({ eventId: event.id })); + } catch { + setGiven(!next); + setCount((c) => c + (next ? -1 : 1)); + } + }); + } + + return ( +
  • + +
    +

    + + {isSelf ? "Tú" : event.actor.name} + {" "} + {labelFor(event)} +

    +

    + + +

    +
    + +
  • + ); +} diff --git a/src/features/social-feed/queries.ts b/src/features/social-feed/queries.ts new file mode 100644 index 0000000..18ac95e --- /dev/null +++ b/src/features/social-feed/queries.ts @@ -0,0 +1,106 @@ +import type { SocialEventKind } from "@prisma/client"; + +import { db } from "@/lib/db"; +import { decodeSignedToken, encodeSignedToken } from "@/lib/social/signed-token"; + +const FEED_PAGE_SIZE = 20; + +export interface FeedEvent { + id: string; + actor: { id: string; username: string; name: string; image: string | null }; + kind: SocialEventKind; + unitTitle: string | null; + courseTitle: string | null; + value: number | null; + occurredAt: Date; + kudosCount: number; + kudosByMe: boolean; +} + +export interface FeedPage { + events: FeedEvent[]; + nextCursor: string | null; +} + +interface CursorPayload { + occurredAt: string; + id: string; +} + +/** + * Feed social — fanout-on-read: self + amigos ACEPTADOS ACTUALES. Como la + * lista de actores se recalcula en cada lectura, un unfriend/block hace + * que los eventos de esa persona desaparezcan de inmediato, sin tocar + * `SocialEvent` (no se borra nada — sólo deja de estar en el conjunto + * visible). + */ +export async function getSocialFeed( + viewerId: string, + opts: { cursor?: string | null } = {}, +): Promise { + const friendRows = await db.friendship.findMany({ + where: { + status: "accepted", + OR: [{ requesterId: viewerId }, { addresseeId: viewerId }], + }, + select: { requesterId: true, addresseeId: true }, + }); + const actorIds = [ + viewerId, + ...friendRows.map((r) => (r.requesterId === viewerId ? r.addresseeId : r.requesterId)), + ]; + + let cursor: CursorPayload | null = null; + if (opts.cursor) { + cursor = decodeSignedToken(opts.cursor); + } + + const rows = await db.socialEvent.findMany({ + where: { + actorId: { in: actorIds }, + ...(cursor + ? { + OR: [ + { occurredAt: { lt: new Date(cursor.occurredAt) } }, + { occurredAt: new Date(cursor.occurredAt), id: { lt: cursor.id } }, + ], + } + : {}), + }, + select: { + id: true, + kind: true, + value: true, + occurredAt: true, + actor: { select: { id: true, username: true, name: true, image: true } }, + unit: { select: { title: true } }, + course: { select: { title: true } }, + kudos: { select: { userId: true } }, + }, + orderBy: [{ occurredAt: "desc" }, { id: "desc" }], + take: FEED_PAGE_SIZE + 1, + }); + + const hasMore = rows.length > FEED_PAGE_SIZE; + const page = rows.slice(0, FEED_PAGE_SIZE); + + const events: FeedEvent[] = page.map((row) => ({ + id: row.id, + actor: row.actor, + kind: row.kind, + unitTitle: row.unit?.title ?? null, + courseTitle: row.course?.title ?? null, + value: row.value, + occurredAt: row.occurredAt, + kudosCount: row.kudos.length, + kudosByMe: row.kudos.some((k) => k.userId === viewerId), + })); + + const last = page.at(-1); + const nextCursor = + hasMore && last + ? encodeSignedToken({ occurredAt: last.occurredAt.toISOString(), id: last.id } satisfies CursorPayload) + : null; + + return { events, nextCursor }; +} diff --git a/src/lib/analytics/social-props.ts b/src/lib/analytics/social-props.ts new file mode 100644 index 0000000..1a05ea6 --- /dev/null +++ b/src/lib/analytics/social-props.ts @@ -0,0 +1,26 @@ +import { z } from "zod"; + +/** + * Contratos de `props` para los ProductEvent sociales que SÍ hace falta + * agregar (no hay tabla de dominio que los reconstruya) — ver + * `` del contrato. Nunca candidate username/id, group, campus + * ni program en props. + */ + +export const discoveryImpressionPropsSchema = z.object({ + discoverySessionKey: z.string().min(1).max(100), + resultCount: z.number().int().min(0), + bucketCounts: z.record(z.string(), z.number().int().min(0)), +}); + +export const discoveryProfileOpenPropsSchema = z.object({ + bucket: z.number().int().min(1).max(5), + discoverySessionKey: z.string().min(1).max(100), +}); + +export const leagueViewPropsSchema = z.object({ + tier: z.string().min(1).max(20), +}); + +/** `friends_ranking_view` e `invite_link_copied` no llevan props. */ +export const emptyPropsSchema = z.object({}); diff --git a/src/lib/social/social-events.ts b/src/lib/social/social-events.ts new file mode 100644 index 0000000..8abede0 --- /dev/null +++ b/src/lib/social/social-events.ts @@ -0,0 +1,103 @@ +import type { Prisma, SocialEventKind } from "@prisma/client"; + +/** Streak milestones — conjunto pequeño y explícito, no se inventan más. */ +export const STREAK_MILESTONE_DAYS = [3, 7, 14, 30, 60, 100] as const; + +/** + * Inserta un hito en el feed social. Idempotente vía UNIQUE + * (actorId, dedupeKey) — `createMany({ skipDuplicates })`, nunca aborta la + * transacción que lo envuelve. + * + * @returns `true` sólo si esta llamada insertó el hito (primera vez). + */ +export async function emitSocialEvent( + tx: Prisma.TransactionClient, + input: { + actorId: string; + kind: SocialEventKind; + dedupeKey: string; + unitId?: string | null; + courseId?: string | null; + value?: number | null; + }, +): Promise { + const inserted = await tx.socialEvent.createMany({ + data: [ + { + actorId: input.actorId, + kind: input.kind, + dedupeKey: input.dedupeKey, + unitId: input.unitId ?? null, + courseId: input.courseId ?? null, + value: input.value ?? null, + }, + ], + skipDuplicates: true, + }); + return inserted.count === 1; +} + +/** + * Tras completar una lección, revisa si la UNIDAD y/o el CURSO quedaron + * completos por primera vez y emite los hitos correspondientes. Una sola + * lectura de todas las lecciones publicadas del curso (acotado — decenas, + * no miles) evita N+1 por unidad. + */ +export async function checkUnitAndCourseCompletion( + tx: Prisma.TransactionClient, + userId: string, + unitId: string, + courseId: string, +): Promise { + const lessonRows = await tx.lesson.findMany({ + where: { unit: { courseId }, published: true }, + select: { id: true, unitId: true }, + }); + if (lessonRows.length === 0) return; + + const completedRows = await tx.userLessonProgress.findMany({ + where: { userId, status: "completed", lessonId: { in: lessonRows.map((l) => l.id) } }, + select: { lessonId: true }, + }); + const completedIds = new Set(completedRows.map((r) => r.lessonId)); + + const unitLessons = lessonRows.filter((l) => l.unitId === unitId); + if (unitLessons.length > 0 && unitLessons.every((l) => completedIds.has(l.id))) { + await emitSocialEvent(tx, { + actorId: userId, + kind: "unit_completed", + dedupeKey: `unit_completed:${unitId}`, + unitId, + courseId, + }); + } + + if (lessonRows.every((l) => completedIds.has(l.id))) { + await emitSocialEvent(tx, { + actorId: userId, + kind: "course_completed", + dedupeKey: `course_completed:${courseId}`, + courseId, + }); + } +} + +/** + * Emite `streak_milestone` si `newStreak` cae en el conjunto fijo. La + * dedupeKey es sólo el número de días: es un logro "primera vez en la + * vida", igual que los badges de `/app/logros` — no un evento por CADA + * ciclo en que la racha vuelve a pasar por ese umbral. + */ +export async function maybeEmitStreakMilestone( + tx: Prisma.TransactionClient, + userId: string, + newStreak: number, +): Promise { + if (!(STREAK_MILESTONE_DAYS as readonly number[]).includes(newStreak)) return; + await emitSocialEvent(tx, { + actorId: userId, + kind: "streak_milestone", + dedupeKey: `streak_milestone:${newStreak}`, + value: newStreak, + }); +} diff --git a/src/lib/streak.ts b/src/lib/streak.ts index 86d0789..c61a1c8 100644 --- a/src/lib/streak.ts +++ b/src/lib/streak.ts @@ -2,6 +2,7 @@ import type { Prisma } from "@prisma/client"; import { cache } from "react"; import { db } from "@/lib/db"; +import { maybeEmitStreakMilestone } from "@/lib/social/social-events"; import { recordXpAward, type XpAwardDescriptor } from "@/lib/xp"; export interface UserStats { @@ -63,6 +64,7 @@ export async function awardXpAndUpdateStreak( totalXp: xpEarned, }, }); + await maybeEmitStreakMilestone(tx, userId, 1); return; } @@ -90,6 +92,9 @@ export async function awardXpAndUpdateStreak( totalXp: { increment: xpEarned }, }, }); + if (newStreak !== existing.currentStreak) { + await maybeEmitStreakMilestone(tx, userId, newStreak); + } } /** diff --git a/tests/integration/social-events.integration.test.ts b/tests/integration/social-events.integration.test.ts new file mode 100644 index 0000000..fbb42f2 --- /dev/null +++ b/tests/integration/social-events.integration.test.ts @@ -0,0 +1,107 @@ +import { afterAll, beforeEach, describe, expect, it } from "vitest"; + +import { getSocialFeed } from "@/features/social-feed/queries"; +import { db } from "@/lib/db"; +import { canonicalPair } from "@/lib/social/pair"; +import { emitSocialEvent, maybeEmitStreakMilestone } from "@/lib/social/social-events"; + +import { createTestUser, resetSocialTables } from "./helpers"; + +async function makeAcceptedFriends(aId: string, bId: string) { + const { lowId, highId } = canonicalPair(aId, bId); + await db.friendship.create({ + data: { + requesterId: aId, + addresseeId: bId, + status: "accepted", + pairKey: `${lowId}:${highId}`, + acceptedAt: new Date(), + }, + }); +} + +describe("SocialEvent / Kudos — Postgres real", () => { + beforeEach(async () => { + await resetSocialTables(); + }); + afterAll(async () => { + await resetSocialTables(); + await db.$disconnect(); + }); + + it("emitSocialEvent es idempotente vía UNIQUE(actorId, dedupeKey)", async () => { + const user = await createTestUser("u"); + await db.$transaction(async (tx) => { + const first = await emitSocialEvent(tx, { + actorId: user.id, + kind: "streak_milestone", + dedupeKey: "streak_milestone:7", + value: 7, + }); + const second = await emitSocialEvent(tx, { + actorId: user.id, + kind: "streak_milestone", + dedupeKey: "streak_milestone:7", + value: 7, + }); + expect(first).toBe(true); + expect(second).toBe(false); + }); + const events = await db.socialEvent.findMany({ where: { actorId: user.id } }); + expect(events).toHaveLength(1); + }); + + it("maybeEmitStreakMilestone sólo emite en el conjunto fijo {3,7,14,30,60,100}", async () => { + const user = await createTestUser("u"); + await db.$transaction(async (tx) => { + await maybeEmitStreakMilestone(tx, user.id, 5); // no es milestone + await maybeEmitStreakMilestone(tx, user.id, 7); // sí lo es + }); + const events = await db.socialEvent.findMany({ where: { actorId: user.id } }); + expect(events).toHaveLength(1); + expect(events[0]?.value).toBe(7); + }); + + it("el feed de un amigo desaparece INMEDIATAMENTE tras unfriend", async () => { + const viewer = await createTestUser("v"); + const friend = await createTestUser("f"); + await makeAcceptedFriends(viewer.id, friend.id); + + await db.$transaction(async (tx) => { + await emitSocialEvent(tx, { + actorId: friend.id, + kind: "unit_completed", + dedupeKey: "unit_completed:u1", + unitId: null, + courseId: null, + }); + }); + + const before = await getSocialFeed(viewer.id); + expect(before.events.some((e) => e.actor.id === friend.id)).toBe(true); + + // Unfriend: borra la fila accepted (igual que `removeFriend`). + await db.friendship.deleteMany({ + where: { status: "accepted", OR: [{ requesterId: viewer.id, addresseeId: friend.id }, { requesterId: friend.id, addresseeId: viewer.id }] }, + }); + + const after = await getSocialFeed(viewer.id); + expect(after.events.some((e) => e.actor.id === friend.id)).toBe(false); + }); + + it("kudos: doble click produce UNA sola fila (UNIQUE eventId+userId)", async () => { + const actor = await createTestUser("a"); + const giver = await createTestUser("g"); + await makeAcceptedFriends(actor.id, giver.id); + + const event = await db.socialEvent.create({ + data: { actorId: actor.id, kind: "unit_completed", dedupeKey: "unit_completed:x" }, + }); + + await db.kudos.createMany({ data: [{ eventId: event.id, userId: giver.id }], skipDuplicates: true }); + await db.kudos.createMany({ data: [{ eventId: event.id, userId: giver.id }], skipDuplicates: true }); + + const rows = await db.kudos.findMany({ where: { eventId: event.id } }); + expect(rows).toHaveLength(1); + }); +}); From 3a7a197d74c41448ebf02e7ee608f39eaf697070 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 00:13:30 +0000 Subject: [PATCH 3/6] =?UTF-8?q?Fase=204-5:=20XP=20ledger=20=E2=86=92=20ran?= =?UTF-8?q?king/ligas=20+=20Friend=20Streak=20completo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fase 4: - Ranking semanal de amigos (self + accepted, TODOS incluidos aunque tengan 0 XP) y standings de liga por división, ambos sobre XpAward competitivo (legacy_balance excluido) - League rollover: Serializable + advisory lock global, claim atómico open→closing (dos workers concurrentes → sólo uno rueda), formación de divisiones balanceadas (divisionCount=max(1,round(N/20))), promoción top5/bottom5 (N<10: floor(N/2)), Bronze floor / Diamond ceiling, frontera exacta de earnedAt, entrantes midweek, finalXp=0 no se pre-asigna - Bootstrap de la primera season en el PRÓXIMO lunes (nunca a mitad de semana) — ninguna season cubre una semana parcial Fase 5 — Friend Streak: - createFriendStreakRequest/acceptFriendStreakRequest con pg_advisory_xact_lock por usuario (orden canónico) — capacidad ≤3 activas / ≤3 pendientes salientes revalidada de ambos lados - refreshFriendStreakDay: actividad server-authoritative (lección completada O intento calificado de reto/práctica), updateMany condicionado por lastEvaluatedDay (sin locks explícitos, idempotente ante reintento/doble refresh), breakOnMiss distingue refresh en vivo de HOY vs. el job evaluando AYER - endFriendStreakForPair: unfriend/block cierra en la misma operación, current=0, longest se conserva; reamistad reutiliza el par canónico - StreakReminder: elegibilidad estricta (sender estudió, recipient no), límites (1/día/streak, 3/día/sender, rate limit de endpoint) - UI: pestaña "Rachas" en /app/amigos (streak cards + banners de recordatorio), botón "Iniciar racha" en el perfil de un amigo 26 tests de integración contra PostgreSQL real (constraints, locks, concurrencia genuina) + 475 tests unitarios, todos verdes. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_018XQFppGcovvjRoHZyQa6GF --- src/app/app/(global)/amigos/page.tsx | 10 +- .../app/(global)/perfil/[username]/page.tsx | 4 +- .../friends/components/friends-tabs.tsx | 20 +- src/features/league/queries.ts | 139 +++++++++++ src/features/streaks/actions.ts | 73 ++++++ .../components/start-streak-button.tsx | 44 ++++ .../streaks/components/streak-cards.tsx | 158 ++++++++++++ src/features/streaks/queries.ts | 84 +++++++ src/lib/social/league-rollover.ts | 235 ++++++++++++++++++ src/lib/social/league-season.ts | 84 +++++++ src/lib/social/streak-reminder.ts | 66 +++++ .../friend-streak.integration.test.ts | 167 +++++++++++++ tests/integration/helpers.ts | 30 +++ .../league-rollover.integration.test.ts | 199 +++++++++++++++ 14 files changed, 1309 insertions(+), 4 deletions(-) create mode 100644 src/features/league/queries.ts create mode 100644 src/features/streaks/actions.ts create mode 100644 src/features/streaks/components/start-streak-button.tsx create mode 100644 src/features/streaks/components/streak-cards.tsx create mode 100644 src/features/streaks/queries.ts create mode 100644 src/lib/social/league-rollover.ts create mode 100644 src/lib/social/league-season.ts create mode 100644 src/lib/social/streak-reminder.ts create mode 100644 tests/integration/friend-streak.integration.test.ts create mode 100644 tests/integration/league-rollover.integration.test.ts diff --git a/src/app/app/(global)/amigos/page.tsx b/src/app/app/(global)/amigos/page.tsx index 8ab0aba..38e6686 100644 --- a/src/app/app/(global)/amigos/page.tsx +++ b/src/app/app/(global)/amigos/page.tsx @@ -12,6 +12,7 @@ import { getPendingOutgoing, } from "@/features/friends/queries"; import { getSocialFeed } from "@/features/social-feed/queries"; +import { getMyFriendStreaks, getMyStreakReminders } from "@/features/streaks/queries"; import { readSelectedCourseSlug } from "@/lib/course-selection"; import { db } from "@/lib/db"; import { requireConfirmedUsername } from "@/lib/get-session"; @@ -35,12 +36,14 @@ export default async function AmigosPage({ ? await db.course.findUnique({ where: { slug: courseSlug, published: true }, select: { id: true } }) : null; - const [friends, incoming, outgoing, discovery, feed, params] = await Promise.all([ + const [friends, incoming, outgoing, discovery, feed, streaks, reminders, params] = await Promise.all([ getFriends(userId), getPendingIncoming(userId), getPendingOutgoing(userId), getDiscoveryCandidates(userId, { courseId: course?.id ?? null }), getSocialFeed(userId), + getMyFriendStreaks(userId), + getMyStreakReminders(userId), searchParams, ]); @@ -61,7 +64,8 @@ export default async function AmigosPage({ params.tab === "solicitudes" || params.tab === "buscar" || params.tab === "descubrir" || - params.tab === "actividad" + params.tab === "actividad" || + params.tab === "rachas" ? params.tab : incoming.length > 0 ? "solicitudes" @@ -96,6 +100,8 @@ export default async function AmigosPage({ meId={userId} discovery={discovery} feed={feed.events} + streaks={streaks} + reminders={reminders} />
    diff --git a/src/app/app/(global)/perfil/[username]/page.tsx b/src/app/app/(global)/perfil/[username]/page.tsx index 5fb1532..c8b1c92 100644 --- a/src/app/app/(global)/perfil/[username]/page.tsx +++ b/src/app/app/(global)/perfil/[username]/page.tsx @@ -12,6 +12,7 @@ import { getPublicProfile, getUserLessonActivity } from "@/features/friends/quer import { ProfileActions } from "@/features/friends/components/profile-actions"; import { ActivityFeed } from "@/features/friends/components/activity-feed"; import { BioEditor } from "@/features/profile/components/bio-editor"; +import { StartStreakButton } from "@/features/streaks/components/start-streak-button"; import { requireSession } from "@/lib/get-session"; import { pluralize } from "@/lib/utils"; import { @@ -108,7 +109,8 @@ export default async function PublicProfilePage({ params }: PageProps) {

    -
    +
    + {isFriend ? : null} (initialTab); @@ -64,6 +70,14 @@ export function FriendsTabs({ Buscar Descubrir Actividad + + Rachas + {reminders.some((r) => !r.readAt) ? ( + + {reminders.filter((r) => !r.readAt).length} + + ) : null} + @@ -86,6 +100,10 @@ export function FriendsTabs({ + + + + ); } diff --git a/src/features/league/queries.ts b/src/features/league/queries.ts new file mode 100644 index 0000000..65dfca9 --- /dev/null +++ b/src/features/league/queries.ts @@ -0,0 +1,139 @@ +import { db } from "@/lib/db"; +import { ensureCurrentSeason } from "@/lib/social/league-season"; +import { rankMembers } from "@/lib/social/ranking"; + +export interface FriendRankingRow { + userId: string; + username: string; + name: string; + image: string | null; + xp: number; + rank: number; + isSelf: boolean; +} + +/** + * Ranking semanal de amigos: self + amigos accepted ACTUALES, TODOS + * incluidos aunque tengan 0 XP esta semana. `legacy_balance` nunca cuenta + * (no es XP competitivo). + */ +export async function getFriendWeeklyRanking(viewerId: string): Promise { + const season = await ensureCurrentSeason(); + + const friendRows = await db.friendship.findMany({ + where: { status: "accepted", OR: [{ requesterId: viewerId }, { addresseeId: viewerId }] }, + select: { requesterId: true, addresseeId: true }, + }); + const memberIds = [ + viewerId, + ...friendRows.map((r) => (r.requesterId === viewerId ? r.addresseeId : r.requesterId)), + ]; + + const [users, sums] = await Promise.all([ + db.user.findMany({ + where: { id: { in: memberIds } }, + select: { id: true, username: true, name: true, image: true }, + }), + db.xpAward.groupBy({ + by: ["userId"], + where: { + userId: { in: memberIds }, + earnedAt: { gte: season.startsAt, lt: season.endsAt }, + reason: { not: "legacy_balance" }, + }, + _sum: { amount: true }, + _max: { earnedAt: true }, + }), + ]); + + const sumByUser = new Map(sums.map((s) => [s.userId, { xp: s._sum.amount ?? 0, lastAwardAt: s._max.earnedAt }])); + + const ranked = rankMembers( + users.map((u) => ({ + userId: u.id, + xp: sumByUser.get(u.id)?.xp ?? 0, + lastAwardAt: sumByUser.get(u.id)?.lastAwardAt ?? null, + })), + ); + + const userById = new Map(users.map((u) => [u.id, u])); + return ranked.map(({ member, rank }) => { + const u = userById.get(member.userId)!; + return { userId: u.id, username: u.username, name: u.name, image: u.image, xp: member.xp, rank, isSelf: u.id === viewerId }; + }); +} + +export interface LeagueStanding { + season: { key: string; startsAt: Date; endsAt: Date }; + tier: "bronze" | "silver" | "gold" | "platinum" | "diamond"; + divisionId: string; + rows: { + userId: string; + username: string; + name: string; + image: string | null; + xp: number; + rank: number; + isSelf: boolean; + }[]; + promoteCount: number; + relegateCount: number; +} + +/** + * Standings de la división del usuario en la season vigente. `null` si el + * usuario no tiene membership todavía (nunca ganó XP competitivo). + */ +export async function getLeagueStanding(userId: string): Promise { + const season = await ensureCurrentSeason(); + + const membership = await db.leagueMembership.findUnique({ + where: { seasonId_userId: { seasonId: season.id, userId } }, + select: { divisionId: true, division: { select: { tier: true } } }, + }); + if (!membership) return null; + + const members = await db.leagueMembership.findMany({ + where: { divisionId: membership.divisionId }, + select: { userId: true, user: { select: { username: true, name: true, image: true } } }, + }); + + const sums = await db.xpAward.groupBy({ + by: ["userId"], + where: { + userId: { in: members.map((m) => m.userId) }, + earnedAt: { gte: season.startsAt, lt: season.endsAt }, + reason: { not: "legacy_balance" }, + }, + _sum: { amount: true }, + _max: { earnedAt: true }, + }); + const sumByUser = new Map(sums.map((s) => [s.userId, { xp: s._sum.amount ?? 0, lastAwardAt: s._max.earnedAt }])); + + const ranked = rankMembers( + members.map((m) => ({ + userId: m.userId, + xp: sumByUser.get(m.userId)?.xp ?? 0, + lastAwardAt: sumByUser.get(m.userId)?.lastAwardAt ?? null, + })), + ); + + const userById = new Map(members.map((m) => [m.userId, m.user])); + const rows = ranked.map(({ member, rank }) => { + const u = userById.get(member.userId)!; + return { userId: member.userId, username: u.username, name: u.name, image: u.image, xp: member.xp, rank, isSelf: member.userId === userId }; + }); + + const n = rows.length; + const promoteCount = n >= 10 ? 5 : Math.floor(n / 2); + const relegateCount = promoteCount; + + return { + season: { key: season.key, startsAt: season.startsAt, endsAt: season.endsAt }, + tier: membership.division.tier, + divisionId: membership.divisionId, + rows, + promoteCount, + relegateCount, + }; +} diff --git a/src/features/streaks/actions.ts b/src/features/streaks/actions.ts new file mode 100644 index 0000000..b22a8d6 --- /dev/null +++ b/src/features/streaks/actions.ts @@ -0,0 +1,73 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { z } from "zod"; + +import { withActionErrorHandling } from "@/lib/action-error"; +import { requireConfirmedUsername } from "@/lib/get-session"; +import { enforceRateLimit } from "@/lib/rate-limit"; +import { + acceptFriendStreakRequest, + cancelOrDeclineFriendStreakRequest, + createFriendStreakRequest, +} from "@/lib/social/friend-streak"; +import { markReminderRead, sendStreakReminder } from "@/lib/social/streak-reminder"; +import { cuidSchema, parseOrThrow } from "@/lib/validation"; + +const byUserIdSchema = z.object({ userId: cuidSchema }); +const byStreakIdSchema = z.object({ streakId: cuidSchema }); + +export const requestFriendStreak = withActionErrorHandling( + "requestFriendStreak", + async (input: { userId: string }): Promise<{ id: string }> => { + const session = await requireConfirmedUsername(); + const { userId } = parseOrThrow(byUserIdSchema, input); + const result = await createFriendStreakRequest(session.user.id, userId); + revalidatePath("/app/amigos"); + return result; + }, +); + +export const acceptFriendStreak = withActionErrorHandling( + "acceptFriendStreak", + async (input: { streakId: string }): Promise<{ ok: true }> => { + const session = await requireConfirmedUsername(); + const { streakId } = parseOrThrow(byStreakIdSchema, input); + await acceptFriendStreakRequest(session.user.id, streakId); + revalidatePath("/app/amigos"); + return { ok: true }; + }, +); + +export const declineFriendStreak = withActionErrorHandling( + "declineFriendStreak", + async (input: { streakId: string }): Promise<{ ok: true }> => { + const session = await requireConfirmedUsername(); + const { streakId } = parseOrThrow(byStreakIdSchema, input); + await cancelOrDeclineFriendStreakRequest(session.user.id, streakId); + revalidatePath("/app/amigos"); + return { ok: true }; + }, +); + +export const sendFriendStreakReminder = withActionErrorHandling( + "sendFriendStreakReminder", + async (input: { streakId: string }): Promise<{ sent: true }> => { + const session = await requireConfirmedUsername(); + const { streakId } = parseOrThrow(byStreakIdSchema, input); + await enforceRateLimit(session.user.id, "streak-reminder"); + return sendStreakReminder(session.user.id, streakId); + }, +); + +const byReminderIdSchema = z.object({ reminderId: cuidSchema }); + +export const markStreakReminderRead = withActionErrorHandling( + "markStreakReminderRead", + async (input: { reminderId: string }): Promise<{ ok: true }> => { + const session = await requireConfirmedUsername(); + const { reminderId } = parseOrThrow(byReminderIdSchema, input); + await markReminderRead(session.user.id, reminderId); + return { ok: true }; + }, +); diff --git a/src/features/streaks/components/start-streak-button.tsx b/src/features/streaks/components/start-streak-button.tsx new file mode 100644 index 0000000..0f9cb57 --- /dev/null +++ b/src/features/streaks/components/start-streak-button.tsx @@ -0,0 +1,44 @@ +"use client"; + +import * as React from "react"; +import { Flame } from "lucide-react"; +import { toast } from "sonner"; + +import { Button } from "@/components/ui/button"; +import { requestFriendStreak } from "@/features/streaks/actions"; + +export function StartStreakButton({ userId }: { userId: string }) { + const [sent, setSent] = React.useState(false); + const [pending, startTransition] = React.useTransition(); + + if (sent) { + return ( + + ); + } + + return ( + + ); +} diff --git a/src/features/streaks/components/streak-cards.tsx b/src/features/streaks/components/streak-cards.tsx new file mode 100644 index 0000000..f8ee2f8 --- /dev/null +++ b/src/features/streaks/components/streak-cards.tsx @@ -0,0 +1,158 @@ +"use client"; + +import * as React from "react"; +import Link from "next/link"; +import { Bell, Check, Flame, X } from "lucide-react"; +import { toast } from "sonner"; + +import { Button } from "@/components/ui/button"; +import { FriendAvatar } from "@/features/friends/components/friend-avatar"; +import { + acceptFriendStreak, + declineFriendStreak, + markStreakReminderRead, + sendFriendStreakReminder, +} from "@/features/streaks/actions"; +import type { FriendStreakCard, StreakReminderCard } from "@/features/streaks/queries"; +import { relativeFromNow } from "@/lib/relative-time"; + +export function StreakCards({ + streaks, + reminders, +}: { + streaks: FriendStreakCard[]; + reminders: StreakReminderCard[]; +}) { + const unreadReminders = reminders.filter((r) => !r.readAt); + + return ( +
    + {unreadReminders.length > 0 ? ( +
      + {unreadReminders.map((r) => ( + + ))} +
    + ) : null} + + {streaks.length === 0 ? ( +
    + +

    Sin rachas con amigos todavía

    +

    + Invita a un amigo desde su perfil a mantener una racha juntos — + los dos tienen que estudiar el mismo día para que cuente. +

    +
    + ) : ( +
      + {streaks.slice(0, 3).map((s) => ( + + ))} +
    + )} +
    + ); +} + +function ReminderBanner({ reminder }: { reminder: StreakReminderCard }) { + const [dismissed, setDismissed] = React.useState(false); + if (dismissed) return null; + + return ( +
  • + +

    + {reminder.sender.name}{" "} + te recordó mantener su racha. +

    + +
  • + ); +} + +function StreakRow({ streak }: { streak: FriendStreakCard }) { + const [pending, startTransition] = React.useTransition(); + const [local, setLocal] = React.useState(streak); + + function respond(accept: boolean) { + startTransition(async () => { + try { + if (accept) { + await acceptFriendStreak({ streakId: local.id }); + setLocal((s) => ({ ...s, status: "active" })); + toast.success(`Racha activa con ${local.other.name}`); + } else { + await declineFriendStreak({ streakId: local.id }); + toast.success("Solicitud rechazada"); + } + } catch (err) { + toast.error(err instanceof Error ? err.message : "Algo salió mal"); + } + }); + } + + function remind() { + startTransition(async () => { + try { + await sendFriendStreakReminder({ streakId: local.id }); + setLocal((s) => ({ ...s, canRemindToday: false })); + toast.success(`Le avisamos a ${local.other.name}`); + } catch (err) { + toast.error(err instanceof Error ? err.message : "Algo salió mal"); + } + }); + } + + return ( +
  • + + +
    +

    {local.other.name}

    +

    + {local.status === "pending" + ? local.isCreator + ? "Esperando que acepte" + : "Te invitó a una racha" + : `${local.currentStreak} ${local.currentStreak === 1 ? "día" : "días"} · mejor ${local.longestStreak}`} +

    +
    + + + {local.status === "pending" && !local.isCreator ? ( +
    + + +
    + ) : local.status === "pending" ? ( + + {local.pendingExpiresAt ? `Vence ${relativeFromNow(local.pendingExpiresAt)}` : null} + + ) : ( + + )} +
  • + ); +} diff --git a/src/features/streaks/queries.ts b/src/features/streaks/queries.ts new file mode 100644 index 0000000..98b2caa --- /dev/null +++ b/src/features/streaks/queries.ts @@ -0,0 +1,84 @@ +import { db } from "@/lib/db"; +import { refreshFriendStreakDay } from "@/lib/social/friend-streak"; +import { mxToday } from "@/lib/social/time"; + +const userSelect = { select: { id: true, username: true, name: true, image: true } } as const; + +export interface FriendStreakCard { + id: string; + status: "pending" | "active"; + other: { id: string; username: string; name: string; image: string | null }; + isCreator: boolean; + currentStreak: number; + longestStreak: number; + canRemindToday: boolean; + pendingExpiresAt: Date | null; +} + +/** + * Streaks activos/pendientes del viewer. Para los activos, refresca HOY de + * forma idempotente (ver `refreshFriendStreakDay`) antes de leer, así que + * si ambos ya estudiaron hoy el contador refleja eso inmediatamente sin + * esperar al job diario. + */ +export async function getMyFriendStreaks(viewerId: string): Promise { + const rows = await db.friendStreak.findMany({ + where: { OR: [{ userLowId: viewerId }, { userHighId: viewerId }], status: { in: ["pending", "active"] } }, + include: { userLow: userSelect, userHigh: userSelect }, + }); + + const today = mxToday(); + await Promise.all( + rows.filter((r) => r.status === "active").map((r) => refreshFriendStreakDay(r.id, today, { breakOnMiss: false })), + ); + + const ids = rows.map((r) => r.id); + const fresh = ids.length > 0 ? await db.friendStreak.findMany({ where: { id: { in: ids } } }) : []; + const freshById = new Map(fresh.map((f) => [f.id, f])); + + const todayReminders = await db.streakReminder.findMany({ + where: { streakId: { in: ids }, senderId: viewerId, day: today }, + select: { streakId: true }, + }); + const remindedToday = new Set(todayReminders.map((r) => r.streakId)); + + return rows.map((row) => { + const f = freshById.get(row.id) ?? row; + const other = row.userLowId === viewerId ? row.userHigh : row.userLow; + return { + id: row.id, + status: f.status as "pending" | "active", + other, + isCreator: row.createdById === viewerId, + currentStreak: f.currentStreak, + longestStreak: f.longestStreak, + canRemindToday: f.status === "active" && !remindedToday.has(row.id), + pendingExpiresAt: f.pendingExpiresAt, + }; + }); +} + +export interface StreakReminderCard { + id: string; + streakId: string; + sender: { id: string; username: string; name: string; image: string | null }; + createdAt: Date; + readAt: Date | null; +} + +/** Recordatorios recibidos, no leídos primero. */ +export async function getMyStreakReminders(viewerId: string, limit = 10): Promise { + const rows = await db.streakReminder.findMany({ + where: { recipientId: viewerId, expiresAt: { gt: new Date() } }, + include: { sender: userSelect }, + orderBy: [{ readAt: { sort: "asc", nulls: "first" } }, { createdAt: "desc" }], + take: limit, + }); + return rows.map((r) => ({ + id: r.id, + streakId: r.streakId, + sender: r.sender, + createdAt: r.createdAt, + readAt: r.readAt, + })); +} diff --git a/src/lib/social/league-rollover.ts b/src/lib/social/league-rollover.ts new file mode 100644 index 0000000..6ddbb6a --- /dev/null +++ b/src/lib/social/league-rollover.ts @@ -0,0 +1,235 @@ +import { Prisma, type LeagueTier } from "@prisma/client"; + +import { db } from "@/lib/db"; +import { logger } from "@/lib/logger"; +import { + assignToSmallestDivision, + ensureCurrentSeason, + ROLLOVER_ADVISORY_LOCK_KEY, + seasonKeyFor, +} from "@/lib/social/league-season"; +import { balancedDivisionSizes, divisionCountFor, resolveRolloverOutcome } from "@/lib/social/league"; +import { rankMembers } from "@/lib/social/ranking"; +import { emitSocialEvent } from "@/lib/social/social-events"; +import { mxWeekRange } from "@/lib/social/time"; + +/** + * Corre el rollover si la season abierta vigente ya venció. Idempotente + * ante dos workers: el "claim" (`open` → `closing`, `updateMany` + * condicionado) hace que sólo uno proceda; el otro ve `count === 0` y + * regresa sin hacer nada. Si el claimant falla a mitad de camino, revierte + * el claim para que un siguiente intento lo retome — nunca deja la season + * atascada en `closing`. + */ +export async function runLeagueRolloverIfDue(now: Date = new Date()): Promise<{ rolled: boolean }> { + const season = await ensureCurrentSeason(now); + if (now < season.endsAt) return { rolled: false }; + + const claimed = await db.leagueSeason.updateMany({ + where: { id: season.id, status: "open" }, + data: { status: "closing" }, + }); + if (claimed.count === 0) return { rolled: false }; + + try { + await db.$transaction( + async (tx) => { + // Lock global del rollover — cluster-wide, cae automáticamente al + // terminar la transacción (éxito o rollback). + await tx.$executeRaw`SELECT pg_advisory_xact_lock(${ROLLOVER_ADVISORY_LOCK_KEY})`; + + const memberships = await tx.leagueMembership.findMany({ + where: { seasonId: season.id }, + select: { id: true, userId: true, divisionId: true, division: { select: { tier: true } } }, + }); + + const newSeasonStart = season.endsAt; + const newSeasonEnd = mxWeekRange(new Date(newSeasonStart.getTime() + 86_400_000)).end; + const newSeason = await tx.leagueSeason.create({ + data: { + key: seasonKeyFor(newSeasonStart), + startsAt: newSeasonStart, + endsAt: newSeasonEnd, + status: "open", + }, + }); + + if (memberships.length > 0) { + const sums = await tx.xpAward.groupBy({ + by: ["userId"], + where: { + userId: { in: memberships.map((m) => m.userId) }, + earnedAt: { gte: season.startsAt, lt: season.endsAt }, + reason: { not: "legacy_balance" }, + }, + _sum: { amount: true }, + _max: { earnedAt: true }, + }); + const xpByUser = new Map( + sums.map((s) => [s.userId, { xp: s._sum.amount ?? 0, lastAwardAt: s._max.earnedAt }]), + ); + + const byDivision = new Map(); + for (const m of memberships) { + const list = byDivision.get(m.divisionId) ?? []; + list.push(m); + byDivision.set(m.divisionId, list); + } + + // PASE 1 — cierra cada división vieja: rank, finalXp/finalRank, + // outcome/nextTier, evento de promoción. Junta a los que quedan + // con finalXp>0 para la formación de la nueva season (PASE 2). + const carryForward: { userId: string; nextTier: LeagueTier; finalXp: number }[] = []; + + for (const [, members] of byDivision) { + const tier = members[0]!.division.tier; + const ranked = rankMembers( + members.map((m) => ({ + userId: m.userId, + xp: xpByUser.get(m.userId)?.xp ?? 0, + lastAwardAt: xpByUser.get(m.userId)?.lastAwardAt ?? null, + })), + ); + + for (const { member, rank } of ranked) { + const { outcome, nextTier } = resolveRolloverOutcome(rank, ranked.length, tier); + const row = members.find((m) => m.userId === member.userId)!; + + await tx.leagueMembership.update({ + where: { id: row.id }, + data: { finalXp: member.xp, finalRank: rank, outcome, nextTier }, + }); + + if (outcome === "promoted") { + await emitSocialEvent(tx, { + actorId: member.userId, + kind: "league_promoted", + dedupeKey: `league_promoted:${season.id}`, + value: null, + }); + } + + // finalXp=0 → historia queda, pero NO se pre-asigna: entra + // como midweek entrant si vuelve y gana XP (Fase 4 §8). + if (member.xp > 0) { + carryForward.push({ userId: member.userId, nextTier, finalXp: member.xp }); + } + } + } + + // PASE 2 — forma las divisiones de la NUEVA season, un tier a la + // vez: divisionCount = max(1, round(N/20)), tamaños balanceados, + // orden XP desc → userId asc (secundario razonable y estable sin + // reintroducir curso/actividad reciente, que ya decidió el orden + // de empate arriba). + const byTier = new Map(); + for (const c of carryForward) { + const list = byTier.get(c.nextTier) ?? []; + list.push(c); + byTier.set(c.nextTier, list); + } + + for (const [tier, entrants] of byTier) { + const sorted = [...entrants].sort((a, b) => { + if (b.finalXp !== a.finalXp) return b.finalXp - a.finalXp; + return a.userId < b.userId ? -1 : a.userId > b.userId ? 1 : 0; + }); + const divisionCount = divisionCountFor(sorted.length); + const sizes = balancedDivisionSizes(sorted.length, divisionCount); + + let cursor = 0; + for (let i = 0; i < divisionCount; i++) { + const division = await tx.leagueDivision.create({ + data: { seasonId: newSeason.id, tier, number: i + 1 }, + select: { id: true }, + }); + const chunk = sorted.slice(cursor, cursor + sizes[i]!); + cursor += sizes[i]!; + if (chunk.length > 0) { + await tx.leagueMembership.createMany({ + data: chunk.map((c) => ({ + seasonId: newSeason.id, + divisionId: division.id, + userId: c.userId, + })), + }); + } + } + } + } + + await tx.leagueSeason.update({ + where: { id: season.id }, + data: { status: "closed", closedAt: now }, + }); + }, + { isolationLevel: Prisma.TransactionIsolationLevel.Serializable, maxWait: 15_000, timeout: 60_000 }, + ); + + logger.info({ seasonId: season.id }, "league rollover completado"); + return { rolled: true }; + } catch (err) { + // Revierte el claim para que un siguiente intento del job lo retome — + // nunca deja la season atascada en `closing`. + await db.leagueSeason.updateMany({ + where: { id: season.id, status: "closing" }, + data: { status: "open" }, + }); + logger.error({ err, seasonId: season.id }, "league rollover falló — claim revertido"); + throw err; + } +} + +async function lastKnownTier(userId: string): Promise { + const lastClosed = await db.leagueMembership.findFirst({ + where: { userId, season: { status: "closed" } }, + orderBy: { season: { endsAt: "desc" } }, + select: { nextTier: true }, + }); + return lastClosed?.nextTier ?? "bronze"; +} + +/** + * Entrantes midweek: usuarios con XP competitivo en la season abierta que + * todavía no tienen membership ahí (nunca tuvieron una, o volvieron tras + * inactividad). Tier según su última membership CERRADA; sin historia, + * bronze. Va a la división más pequeña del tier con <25 miembros. + */ +export async function assignMidweekEntrants(now: Date = new Date()): Promise { + const season = await ensureCurrentSeason(now); + + const earners = await db.xpAward.findMany({ + where: { + earnedAt: { gte: season.startsAt, lt: season.endsAt }, + reason: { not: "legacy_balance" }, + }, + distinct: ["userId"], + select: { userId: true }, + }); + if (earners.length === 0) return 0; + + const existing = await db.leagueMembership.findMany({ + where: { seasonId: season.id, userId: { in: earners.map((e) => e.userId) } }, + select: { userId: true }, + }); + const existingIds = new Set(existing.map((m) => m.userId)); + const newcomers = earners.filter((e) => !existingIds.has(e.userId)); + if (newcomers.length === 0) return 0; + + let assigned = 0; + for (const entrant of newcomers) { + const tier = await lastKnownTier(entrant.userId); + await db.$transaction(async (tx) => { + const already = await tx.leagueMembership.findUnique({ + where: { seasonId_userId: { seasonId: season.id, userId: entrant.userId } }, + }); + if (already) return; + const divisionId = await assignToSmallestDivision(tx, season.id, tier); + await tx.leagueMembership.create({ + data: { seasonId: season.id, divisionId, userId: entrant.userId }, + }); + }); + assigned++; + } + return assigned; +} diff --git a/src/lib/social/league-season.ts b/src/lib/social/league-season.ts new file mode 100644 index 0000000..072777c --- /dev/null +++ b/src/lib/social/league-season.ts @@ -0,0 +1,84 @@ +import { Prisma, type LeagueTier } from "@prisma/client"; + +import { db } from "@/lib/db"; +import { logger } from "@/lib/logger"; +import { mxWeekKey, mxWeekRange } from "@/lib/social/time"; + +/** Clave global fija del advisory lock del rollover — un solo rollover a la vez, cluster-wide. */ +const ROLLOVER_ADVISORY_LOCK_KEY = 872364501; + +/** Divisiones nuevas apuntan a <25 miembros antes de abrir otra (Fase 4 §5). */ +const MIDWEEK_DIVISION_SOFT_CAP = 25; + +export function seasonKeyFor(date: Date): string { + return `season-${mxWeekKey(date)}`; +} + +/** + * Devuelve la season `open` vigente, creando la PRIMERA si nunca hubo + * ninguna. El bootstrap arranca en el PRÓXIMO lunes — nunca a mitad de + * semana — para que "desde el primer lunes completo después del cutover" + * sea literal: ninguna season cubre una semana parcial. + */ +export async function ensureCurrentSeason(now: Date = new Date()): Promise<{ + id: string; + key: string; + startsAt: Date; + endsAt: Date; +}> { + const openSeason = await db.leagueSeason.findFirst({ + where: { status: "open" }, + orderBy: { startsAt: "desc" }, + }); + if (openSeason) return openSeason; + + const anySeason = await db.leagueSeason.findFirst({ orderBy: { startsAt: "desc" } }); + if (anySeason) { + // No debería pasar en operación normal (siempre hay un open salvo + // durante el rollover, que lo reabre) — pero si pasa, no inventamos: + // lo reporta el caller. + throw new Error("No hay season abierta y ya existe historial — revisa el rollover manualmente"); + } + + const { end: nextMonday } = mxWeekRange(now); + const startsAt = nextMonday; + const endsAt = mxWeekRange(new Date(nextMonday.getTime() + 24 * 60 * 60 * 1000)).end; + + const created = await db.leagueSeason.create({ + data: { key: seasonKeyFor(startsAt), startsAt, endsAt, status: "open" }, + }); + logger.info({ seasonId: created.id, startsAt, endsAt }, "bootstrap: primera league season creada"); + return created; +} + +/** + * Asigna una división a un usuario dentro de un tier de la season dada: + * la división más pequeña con <25 miembros, o una nueva si todas ya + * llegaron al tope. Usado tanto en la formación inicial del rollover como + * para entrantes midweek. + */ +export async function assignToSmallestDivision( + tx: Prisma.TransactionClient, + seasonId: string, + tier: LeagueTier, +): Promise { + const divisions = await tx.leagueDivision.findMany({ + where: { seasonId, tier }, + select: { id: true, number: true, _count: { select: { memberships: true } } }, + orderBy: { number: "asc" }, + }); + + const withSpace = divisions + .filter((d) => d._count.memberships < MIDWEEK_DIVISION_SOFT_CAP) + .sort((a, b) => a._count.memberships - b._count.memberships)[0]; + if (withSpace) return withSpace.id; + + const nextNumber = (divisions.at(-1)?.number ?? 0) + 1; + const created = await tx.leagueDivision.create({ + data: { seasonId, tier, number: nextNumber }, + select: { id: true }, + }); + return created.id; +} + +export { ROLLOVER_ADVISORY_LOCK_KEY }; diff --git a/src/lib/social/streak-reminder.ts b/src/lib/social/streak-reminder.ts new file mode 100644 index 0000000..4394c9a --- /dev/null +++ b/src/lib/social/streak-reminder.ts @@ -0,0 +1,66 @@ +import { ActionError } from "@/lib/action-error"; +import { db } from "@/lib/db"; +import { hadSignificantActivity } from "@/lib/social/friend-streak"; +import { mxDateOnly, mxDayRangeForDateOnly } from "@/lib/social/time"; + +/** Máximo global de recordatorios que un usuario puede MANDAR en un día. */ +const MAX_REMINDERS_PER_SENDER_PER_DAY = 3; +const REMINDER_TTL_MS = 48 * 60 * 60 * 1000; + +/** + * Manda un recordatorio de Friend Streak. Elegibilidad estricta: + * - streak activo, sender es participant + * - sender YA estudió hoy, recipient NO + * - <=1 por (streak, sender, día) — UNIQUE + * - <=3 recordatorios TOTALES por sender por día + */ +export async function sendStreakReminder( + senderId: string, + streakId: string, +): Promise<{ sent: true }> { + const streak = await db.friendStreak.findUnique({ where: { id: streakId } }); + if (!streak || streak.status !== "active") throw new ActionError("Esa racha ya no está activa"); + if (senderId !== streak.userLowId && senderId !== streak.userHighId) { + throw new ActionError("No autorizado"); + } + const recipientId = senderId === streak.userLowId ? streak.userHighId : streak.userLowId; + + const today = mxDateOnly(new Date()); + const { start, end } = mxDayRangeForDateOnly(today); + const [senderStudied, recipientStudied] = await Promise.all([ + hadSignificantActivity(db, senderId, start, end), + hadSignificantActivity(db, recipientId, start, end), + ]); + if (!senderStudied) throw new ActionError("Estudia hoy antes de mandar el recordatorio"); + if (recipientStudied) throw new ActionError("Tu amigo ya estudió hoy"); + + const sentToday = await db.streakReminder.count({ where: { senderId, day: today } }); + if (sentToday >= MAX_REMINDERS_PER_SENDER_PER_DAY) { + throw new ActionError("Ya mandaste el máximo de recordatorios de hoy"); + } + + const inserted = await db.streakReminder.createMany({ + data: [ + { + streakId, + senderId, + recipientId, + day: today, + expiresAt: new Date(Date.now() + REMINDER_TTL_MS), + }, + ], + skipDuplicates: true, + }); + if (inserted.count === 0) { + throw new ActionError("Ya le mandaste un recordatorio de esta racha hoy"); + } + return { sent: true }; +} + +/** Marca un recordatorio como leído — sólo el recipient puede hacerlo. */ +export async function markReminderRead(recipientId: string, reminderId: string): Promise { + await db.streakReminder.updateMany({ + where: { id: reminderId, recipientId, readAt: null }, + data: { readAt: new Date() }, + }); +} diff --git a/tests/integration/friend-streak.integration.test.ts b/tests/integration/friend-streak.integration.test.ts new file mode 100644 index 0000000..9b97ed3 --- /dev/null +++ b/tests/integration/friend-streak.integration.test.ts @@ -0,0 +1,167 @@ +import { afterAll, beforeEach, describe, expect, it } from "vitest"; + +import { + acceptFriendStreakRequest, + createFriendStreakRequest, + endFriendStreakForPair, + MAX_ACTIVE_FRIEND_STREAKS, + refreshFriendStreakDay, +} from "@/lib/social/friend-streak"; +import { db } from "@/lib/db"; +import { canonicalPair } from "@/lib/social/pair"; +import { mxDateOnly, shiftDateOnly } from "@/lib/social/time"; + +import { createTestLesson, createTestUser, resetSocialTables } from "./helpers"; + +async function makeAcceptedFriends(aId: string, bId: string) { + const { lowId, highId } = canonicalPair(aId, bId); + await db.friendship.create({ + data: { requesterId: aId, addresseeId: bId, status: "accepted", pairKey: `${lowId}:${highId}`, acceptedAt: new Date() }, + }); +} + +async function activateStreak(aId: string, bId: string): Promise { + const req = await createFriendStreakRequest(aId, bId); + await acceptFriendStreakRequest(bId, req.id); + return req.id; +} + +/** Crea un ejercicio real (step + exercise) dentro de la lección dada. */ +async function createTestExercise(lessonId: string): Promise<{ id: string }> { + const step = await db.lessonStep.create({ + data: { lessonId, order: 1, type: "code_challenge", content: {} }, + }); + return db.exercise.create({ + data: { stepId: step.id, prompt: "x", starterCode: "x", solutionCode: "x", hints: [] }, + select: { id: true }, + }); +} + +/** Marca actividad significativa de `userId` en el día calendario `dateOnly` (mediodía local). */ +async function markActivity(userId: string, dateOnly: Date, exerciseId: string) { + const noon = new Date(dateOnly.getTime() + 12 * 3600 * 1000); + await db.userExerciseAttempt.create({ + data: { userId, exerciseId, code: "int main(){}", passed: true, createdAt: noon }, + }); +} + +describe("FriendStreak — Postgres real", () => { + beforeEach(async () => { + await resetSocialTables(); + }); + afterAll(async () => { + await resetSocialTables(); + await db.$disconnect(); + }); + + it("máximo 3 rachas activas — la 4ª se rechaza", async () => { + const a = await createTestUser("a"); + const friends = await Promise.all([createTestUser("f1"), createTestUser("f2"), createTestUser("f3"), createTestUser("f4")]); + for (const f of friends) await makeAcceptedFriends(a.id, f.id); + + for (let i = 0; i < MAX_ACTIVE_FRIEND_STREAKS; i++) { + await activateStreak(a.id, friends[i]!.id); + } + + await expect(createFriendStreakRequest(a.id, friends[3]!.id)).rejects.toThrow(/3 rachas activas/); + }); + + it("unfriend/block cierra el streak en la misma operación — current queda en 0, longest se conserva", async () => { + const a = await createTestUser("a"); + const b = await createTestUser("b"); + await makeAcceptedFriends(a.id, b.id); + const streakId = await activateStreak(a.id, b.id); + + await db.friendStreak.update({ where: { id: streakId }, data: { currentStreak: 5, longestStreak: 5 } }); + + await db.$transaction(async (tx) => { + await endFriendStreakForPair(tx, a.id, b.id, "unfriended"); + }); + + const row = await db.friendStreak.findUnique({ where: { id: streakId } }); + expect(row?.status).toBe("ended"); + expect(row?.currentStreak).toBe(0); + expect(row?.longestStreak).toBe(5); + }); + + it("reamistad reutiliza el par canónico como pending, conserva longest", async () => { + const a = await createTestUser("a"); + const b = await createTestUser("b"); + await makeAcceptedFriends(a.id, b.id); + const streakId = await activateStreak(a.id, b.id); + await db.friendStreak.update({ where: { id: streakId }, data: { currentStreak: 9, longestStreak: 9 } }); + await db.$transaction(async (tx) => endFriendStreakForPair(tx, a.id, b.id, "unfriended")); + + const reReq = await createFriendStreakRequest(a.id, b.id); + expect(reReq.id).toBe(streakId); // MISMO row canónico + + const row = await db.friendStreak.findUnique({ where: { id: streakId } }); + expect(row?.status).toBe("pending"); + expect(row?.currentStreak).toBe(0); + expect(row?.longestStreak).toBe(9); // conservado + }); + + it("día calificado consecutivo incrementa; doble refresh el mismo día no suma +2", async () => { + const a = await createTestUser("a"); + const b = await createTestUser("b"); + await makeAcceptedFriends(a.id, b.id); + const streakId = await activateStreak(a.id, b.id); + const lesson = await createTestLesson(); + const exercise = await createTestExercise(lesson.id); + + const day1 = mxDateOnly(new Date()); + await markActivity(a.id, day1, exercise.id); + await markActivity(b.id, day1, exercise.id); + + await refreshFriendStreakDay(streakId, day1, { breakOnMiss: false }); + await refreshFriendStreakDay(streakId, day1, { breakOnMiss: false }); // retry — no debe sumar de nuevo + + const row = await db.friendStreak.findUnique({ where: { id: streakId } }); + expect(row?.currentStreak).toBe(1); + + const days = await db.friendStreakDay.findMany({ where: { streakId } }); + expect(days).toHaveLength(1); + }); + + it("un solo participante activo no califica el día (ambos deben estudiar)", async () => { + const a = await createTestUser("a"); + const b = await createTestUser("b"); + await makeAcceptedFriends(a.id, b.id); + const streakId = await activateStreak(a.id, b.id); + const lesson = await createTestLesson(); + const exercise = await createTestExercise(lesson.id); + + const day1 = mxDateOnly(new Date()); + await markActivity(a.id, day1, exercise.id); // sólo A estudia + + await refreshFriendStreakDay(streakId, day1, { breakOnMiss: false }); + + const row = await db.friendStreak.findUnique({ where: { id: streakId } }); + expect(row?.currentStreak).toBe(0); + const days = await db.friendStreakDay.findMany({ where: { streakId } }); + expect(days).toHaveLength(0); + }); + + it("día faltante rompe la racha (breakOnMiss=true, como el job diario)", async () => { + const a = await createTestUser("a"); + const b = await createTestUser("b"); + await makeAcceptedFriends(a.id, b.id); + const streakId = await activateStreak(a.id, b.id); + const lesson = await createTestLesson(); + const exercise = await createTestExercise(lesson.id); + + const day1 = shiftDateOnly(mxDateOnly(new Date()), -2); + const day2 = shiftDateOnly(day1, 1); // día perdido — nadie estudia + await markActivity(a.id, day1, exercise.id); + await markActivity(b.id, day1, exercise.id); + await refreshFriendStreakDay(streakId, day1, { breakOnMiss: true }); + + let row = await db.friendStreak.findUnique({ where: { id: streakId } }); + expect(row?.currentStreak).toBe(1); + + // día2: nadie estudió, el job (breakOnMiss=true) lo evalúa y rompe. + await refreshFriendStreakDay(streakId, day2, { breakOnMiss: true }); + row = await db.friendStreak.findUnique({ where: { id: streakId } }); + expect(row?.currentStreak).toBe(0); + }); +}); diff --git a/tests/integration/helpers.ts b/tests/integration/helpers.ts index e829397..5537358 100644 --- a/tests/integration/helpers.ts +++ b/tests/integration/helpers.ts @@ -17,6 +17,35 @@ export async function createTestUser(namePrefix = "u"): Promise<{ id: string; us return user; } +/** + * Crea un curso/unidad/lección mínimos — sólo para satisfacer el FK y el + * CHECK de `XpAward` (`reason='lesson_completed'` exige `lessonId` + * NOT NULL apuntando a una lección real) en tests que necesitan otorgar + * XP competitivo sintético sin pasar por `completeStep`. + */ +export async function createTestLesson(): Promise<{ id: string }> { + seq++; + const suffix = `${Date.now().toString(36)}${seq}`; + const course = await db.course.create({ + data: { + slug: `course-${suffix}`, + title: `Curso ${suffix}`, + description: "test", + subjectName: "test", + academicContext: "test", + language: "cpp", + executionProfile: "cpp17-wandbox", + }, + }); + const unit = await db.unit.create({ + data: { courseId: course.id, slug: `unit-${suffix}`, title: "Unidad", description: "test" }, + }); + const lesson = await db.lesson.create({ + data: { unitId: unit.id, slug: `lesson-${suffix}`, title: "Lección", description: "test" }, + }); + return { id: lesson.id }; +} + /** Borra TODA la data de las tablas sociales — sólo para esta suite, DB de test dedicada. */ export async function resetSocialTables(): Promise { await db.$transaction([ @@ -36,5 +65,6 @@ export async function resetSocialTables(): Promise { db.friendship.deleteMany({}), db.userStreak.deleteMany({}), db.user.deleteMany({}), + db.course.deleteMany({}), ]); } diff --git a/tests/integration/league-rollover.integration.test.ts b/tests/integration/league-rollover.integration.test.ts new file mode 100644 index 0000000..ec720a9 --- /dev/null +++ b/tests/integration/league-rollover.integration.test.ts @@ -0,0 +1,199 @@ +import type { LeagueTier } from "@prisma/client"; +import { afterAll, beforeEach, describe, expect, it } from "vitest"; + +import { runLeagueRolloverIfDue } from "@/lib/social/league-rollover"; +import { db } from "@/lib/db"; + +import { createTestLesson, createTestUser, resetSocialTables } from "./helpers"; + +/** Crea una season YA VENCIDA con una sola división de `tier` y N miembros con XP dado. */ +async function seedDueSeason(tier: LeagueTier, xpByIndex: number[]) { + const lesson = await createTestLesson(); + const now = new Date(); + const startsAt = new Date(now.getTime() - 8 * 86_400_000); + const endsAt = new Date(now.getTime() - 60_000); // ya venció + + const season = await db.leagueSeason.create({ + data: { key: `season-test-${Date.now()}`, startsAt, endsAt, status: "open" }, + }); + const division = await db.leagueDivision.create({ + data: { seasonId: season.id, tier, number: 1 }, + }); + + const users = await Promise.all(xpByIndex.map((_, i) => createTestUser(`m${i}`))); + await db.leagueMembership.createMany({ + data: users.map((u) => ({ seasonId: season.id, divisionId: division.id, userId: u.id })), + }); + + // XpAward DENTRO de la ventana de la season que cierra. + const mid = new Date(startsAt.getTime() + 1000); + for (let i = 0; i < users.length; i++) { + if (xpByIndex[i]! > 0) { + await db.xpAward.create({ + data: { + userId: users[i]!.id, + amount: xpByIndex[i]!, + reason: "lesson_completed", + dedupeKey: `lesson:seed-${i}`, + lessonId: lesson.id, + earnedAt: mid, + }, + }); + } + } + return { season, users }; +} + +describe("League rollover — Postgres real", () => { + beforeEach(async () => { + await resetSocialTables(); + await db.leagueMembership.deleteMany({}); + await db.leagueDivision.deleteMany({}); + await db.leagueSeason.deleteMany({}); + }); + afterAll(async () => { + await resetSocialTables(); + await db.leagueMembership.deleteMany({}); + await db.leagueDivision.deleteMany({}); + await db.leagueSeason.deleteMany({}); + await db.$disconnect(); + }); + + it("N=20 en gold: top5 promueven a platinum, bottom5 bajan a silver, resto se queda", async () => { + const xp = Array.from({ length: 20 }, (_, i) => (20 - i) * 10); // 200..10, ranks 1..20 + const { season, users } = await seedDueSeason("gold", xp); + + const result = await runLeagueRolloverIfDue(new Date()); + expect(result.rolled).toBe(true); + + const memberships = await db.leagueMembership.findMany({ + where: { seasonId: season.id }, + orderBy: { finalRank: "asc" }, + }); + expect(memberships).toHaveLength(20); + expect(memberships[0]?.outcome).toBe("promoted"); + expect(memberships[0]?.nextTier).toBe("platinum"); + expect(memberships[4]?.outcome).toBe("promoted"); + expect(memberships[5]?.outcome).toBe("stayed"); + expect(memberships[14]?.outcome).toBe("stayed"); + expect(memberships[15]?.outcome).toBe("relegated"); + expect(memberships[15]?.nextTier).toBe("silver"); + expect(memberships[19]?.outcome).toBe("relegated"); + + // La season vieja quedó cerrada; existe una nueva abierta. + const closed = await db.leagueSeason.findUnique({ where: { id: season.id } }); + expect(closed?.status).toBe("closed"); + const openSeason = await db.leagueSeason.findFirst({ where: { status: "open" } }); + expect(openSeason).not.toBeNull(); + + // El promovido #1 tiene membership nueva en platinum; el relegado #20 en silver. + const top = memberships[0]!; + const bottom = memberships[19]!; + const newTop = await db.leagueMembership.findFirst({ + where: { seasonId: openSeason!.id, userId: top.userId }, + include: { division: true }, + }); + const newBottom = await db.leagueMembership.findFirst({ + where: { seasonId: openSeason!.id, userId: bottom.userId }, + include: { division: true }, + }); + expect(newTop?.division.tier).toBe("platinum"); + expect(newBottom?.division.tier).toBe("silver"); + void users; + }); + + it("Diamond top queda held_at_ceiling (no hay tier arriba)", async () => { + const xp = Array.from({ length: 20 }, (_, i) => (20 - i) * 10); + const { season } = await seedDueSeason("diamond", xp); + await runLeagueRolloverIfDue(new Date()); + + const top = await db.leagueMembership.findFirst({ + where: { seasonId: season.id }, + orderBy: { finalRank: "asc" }, + }); + expect(top?.outcome).toBe("held_at_ceiling"); + expect(top?.nextTier).toBe("diamond"); + }); + + it("Bronze bottom queda held_at_floor (no hay tier abajo)", async () => { + const xp = Array.from({ length: 20 }, (_, i) => (20 - i) * 10); + const { season } = await seedDueSeason("bronze", xp); + await runLeagueRolloverIfDue(new Date()); + + const bottom = await db.leagueMembership.findFirst({ + where: { seasonId: season.id }, + orderBy: { finalRank: "desc" }, + }); + expect(bottom?.outcome).toBe("held_at_floor"); + expect(bottom?.nextTier).toBe("bronze"); + }); + + it("miembro con finalXp=0 NO se pre-asigna a la nueva season", async () => { + const xp = [50, 0]; + const { season, users } = await seedDueSeason("silver", xp); + await runLeagueRolloverIfDue(new Date()); + + const openSeason = await db.leagueSeason.findFirst({ where: { status: "open" } }); + const inactiveMembership = await db.leagueMembership.findFirst({ + where: { seasonId: openSeason!.id, userId: users[1]!.id }, + }); + expect(inactiveMembership).toBeNull(); + + const oldRow = await db.leagueMembership.findFirst({ + where: { seasonId: season.id, userId: users[1]!.id }, + }); + expect(oldRow?.finalXp).toBe(0); + expect(oldRow?.outcome).not.toBeNull(); // sí recibe outcome/tier histórico + }); + + it("dos workers concurrentes: sólo uno rueda la season", async () => { + const xp = [100, 50]; + await seedDueSeason("silver", xp); + const now = new Date(); + + const [a, b] = await Promise.all([ + runLeagueRolloverIfDue(now), + runLeagueRolloverIfDue(now), + ]); + const rolledCount = [a.rolled, b.rolled].filter(Boolean).length; + expect(rolledCount).toBe(1); + + // Sólo una season cerrada, una abierta. + const closedCount = await db.leagueSeason.count({ where: { status: "closed" } }); + const openCount = await db.leagueSeason.count({ where: { status: "open" } }); + expect(closedCount).toBe(1); + expect(openCount).toBe(1); + }); + + it("award exactamente en la frontera: earnedAt < end cuenta, earnedAt >= end NO", async () => { + const { season, users } = await seedDueSeason("silver", [0, 0]); + // Un award justo ANTES de endsAt (cuenta) y uno justo EN/DESPUÉS (no cuenta). + await db.xpAward.create({ + data: { + userId: users[0]!.id, + amount: 30, + reason: "lesson_completed", + dedupeKey: "lesson:boundary-in", + lessonId: (await createTestLesson()).id, + earnedAt: new Date(season.endsAt.getTime() - 1), + }, + }); + await db.xpAward.create({ + data: { + userId: users[1]!.id, + amount: 999, + reason: "lesson_completed", + dedupeKey: "lesson:boundary-out", + lessonId: (await createTestLesson()).id, + earnedAt: season.endsAt, + }, + }); + + await runLeagueRolloverIfDue(new Date()); + + const m0 = await db.leagueMembership.findFirst({ where: { seasonId: season.id, userId: users[0]!.id } }); + const m1 = await db.leagueMembership.findFirst({ where: { seasonId: season.id, userId: users[1]!.id } }); + expect(m0?.finalXp).toBe(30); + expect(m1?.finalXp).toBe(0); + }); +}); From e949f05f344b9e383e86cc4c503e38b82ba4dee0 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 00:23:49 +0000 Subject: [PATCH 4/6] =?UTF-8?q?Fase=206=20+=20jobs=20+=20nav=20+=20UI=20ac?= =?UTF-8?q?ad=C3=A9mica:=20quests,=20mantenimiento=20social,=20liga?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fase 6 — Friend Quests: - Matching semanal: candidatos con actividad significativa en 14d Y >=1 lección la semana previa; edges ordenados por diferencia de ritmo, no-repetir-pareja, pairKey; greedy — nunca empareja inactivo con activo - 1 quest/usuario/semana vía UNIQUE(userId, weekStart); progreso cuenta lecciones de AMBOS aunque sean de cursos distintos; target=12 fijo - Transición active→completed condicional (updateMany) — sólo la llamada ganadora emite friend_quest_completed, sin XP reward (v1) Jobs: - /api/jobs/social-maintenance — UNA superficie protegida por CRON_SECRET (Bearer, formato Vercel Cron), ejecución horaria (vercel.json). Corre rollover de liga + entrantes midweek + expiración de streaks pendientes + evaluación diaria de streaks + matching/expiración de quests. Cada sub-tarea es independiente e idempotente; un fallo no tumba las demás. UI: - /app/liga: tier, rank, top3, cercanos (±2), zonas de promoción/descenso, clasificación completa bajo demanda - Nav: Inicio/Práctica/Liga/Amigos/Perfil (mobile + desktop); Logros pasa a vivir dentro de Perfil; /app/logros queda como redirect permanente - Ranking semanal de amigos en /app/amigos - Identidad académica: editor cascada plantel→carrera→semestre→grupo en Perfil, visible en el perfil público (campus/carrera/semestre a cualquier autenticado, grupo exacto sólo self/amigo), prompt "Encuentra a tus compañeros" en Home (después del ContinuePanel, descartable) - Contexto social compacto en Home: liga, rachas activas, quest de la semana — nunca un dashboard 30 tests de integración + 475 unitarios, todos verdes. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_018XQFppGcovvjRoHZyQa6GF --- src/app/api/jobs/social-maintenance/route.ts | 63 ++++ src/app/app/(global)/amigos/page.tsx | 25 +- src/app/app/(global)/liga/page.tsx | 164 +++++++++ src/app/app/(global)/logros/loading.tsx | 20 -- src/app/app/(global)/logros/page.tsx | 331 +----------------- .../app/(global)/perfil/[username]/page.tsx | 20 ++ src/app/app/(global)/perfil/page.tsx | 49 ++- src/app/app/c/[courseSlug]/page.tsx | 52 ++- src/components/layout/mobile-nav.tsx | 4 +- src/components/layout/sidebar-nav.tsx | 4 +- src/env.ts | 7 + .../components/academic-profile-editor.tsx | 144 ++++++++ .../components/academic-prompt-banner.tsx | 43 +++ .../friends/components/invite-link-card.tsx | 4 + src/features/friends/queries.ts | 31 ++ src/features/invites/actions.ts | 18 +- .../league/components/friend-ranking-list.tsx | 33 ++ .../components/achievements-section.tsx | 269 ++++++++++++++ src/features/quests/components/quest-card.tsx | 39 +++ src/features/quests/queries.ts | 55 +++ src/lib/social/friend-quest.ts | 213 +++++++++++ src/lib/social/friend-streak.ts | 23 +- src/lib/social/league-labels.ts | 9 + .../friend-quest.integration.test.ts | 148 ++++++++ vercel.json | 8 +- 25 files changed, 1416 insertions(+), 360 deletions(-) create mode 100644 src/app/api/jobs/social-maintenance/route.ts create mode 100644 src/app/app/(global)/liga/page.tsx delete mode 100644 src/app/app/(global)/logros/loading.tsx create mode 100644 src/features/academic/components/academic-profile-editor.tsx create mode 100644 src/features/academic/components/academic-prompt-banner.tsx create mode 100644 src/features/league/components/friend-ranking-list.tsx create mode 100644 src/features/profile/components/achievements-section.tsx create mode 100644 src/features/quests/components/quest-card.tsx create mode 100644 src/features/quests/queries.ts create mode 100644 src/lib/social/friend-quest.ts create mode 100644 src/lib/social/league-labels.ts create mode 100644 tests/integration/friend-quest.integration.test.ts diff --git a/src/app/api/jobs/social-maintenance/route.ts b/src/app/api/jobs/social-maintenance/route.ts new file mode 100644 index 0000000..c70d0c8 --- /dev/null +++ b/src/app/api/jobs/social-maintenance/route.ts @@ -0,0 +1,63 @@ +import { NextResponse, type NextRequest } from "next/server"; + +import { env } from "@/env"; +import { logger } from "@/lib/logger"; +import { runWeeklyFriendQuestMatching, expireStaleFriendQuests } from "@/lib/social/friend-quest"; +import { assignMidweekEntrants, runLeagueRolloverIfDue } from "@/lib/social/league-rollover"; +import { evaluateAllActiveStreaksForYesterday, expirePendingFriendStreaks } from "@/lib/social/friend-streak"; + +/** + * UNA sola superficie de mantenimiento social — no un cron por feature. + * Ejecución horaria (`vercel.json`); cada sub-tarea es idempotente, así + * que correr esto de más nunca duplica nada — sólo cuesta unas queries de + * más. Protegida por `CRON_SECRET`: sin uno configurado, o sin que la + * request lo traiga, 401 — nunca corre "abierto". + * + * No devuelve datos privados: sólo conteos agregados para observabilidad. + */ +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; +export const maxDuration = 60; + +function isAuthorized(request: NextRequest): boolean { + if (!env.CRON_SECRET) return false; + const auth = request.headers.get("authorization"); + return auth === `Bearer ${env.CRON_SECRET}`; +} + +export async function GET(request: NextRequest): Promise { + if (!isAuthorized(request)) { + return NextResponse.json({ ok: false, error: "unauthorized" }, { status: 401 }); + } + + const now = new Date(); + const results: Record = {}; + const errors: string[] = []; + + async function run(name: string, fn: () => Promise) { + try { + results[name] = await fn(); + } catch (err) { + errors.push(name); + logger.error({ err, job: name }, "social-maintenance sub-task failed"); + } + } + + // Liga: rollover (idempotente, claim atómico) y entrantes midweek. + await run("leagueRolled", async () => (await runLeagueRolloverIfDue(now)).rolled); + await run("leagueMidweekEntrants", () => assignMidweekEntrants(now)); + + // Friend Streak: expira pendientes vencidas, evalúa ayer para las activas. + await run("streaksExpiredPending", () => expirePendingFriendStreaks()); + await run("streaksEvaluatedYesterday", () => evaluateAllActiveStreaksForYesterday(now)); + + // Friend Quest: matching semanal (incremental — sólo empareja a quien + // sigue suelto) y expira las que no llegaron al target. + await run("questsMatched", () => runWeeklyFriendQuestMatching(now)); + await run("questsExpired", () => expireStaleFriendQuests(now)); + + results.ok = errors.length === 0; + if (errors.length > 0) results.failedTasks = errors.join(","); + + return NextResponse.json(results, { status: errors.length === 0 ? 200 : 207 }); +} diff --git a/src/app/app/(global)/amigos/page.tsx b/src/app/app/(global)/amigos/page.tsx index 38e6686..686f666 100644 --- a/src/app/app/(global)/amigos/page.tsx +++ b/src/app/app/(global)/amigos/page.tsx @@ -2,7 +2,9 @@ import { randomUUID } from "node:crypto"; import { SectionRule } from "@/components/ui/section-rule"; import { getDiscoveryCandidates } from "@/features/discovery/queries"; -import { discoveryImpressionPropsSchema } from "@/lib/analytics/social-props"; +import { FriendRankingList } from "@/features/league/components/friend-ranking-list"; +import { getFriendWeeklyRanking } from "@/features/league/queries"; +import { discoveryImpressionPropsSchema, emptyPropsSchema } from "@/lib/analytics/social-props"; import { recordProductEventSafely } from "@/lib/analytics/record"; import { FriendsTabs } from "@/features/friends/components/friends-tabs"; import { InviteLinkCard } from "@/features/friends/components/invite-link-card"; @@ -36,7 +38,7 @@ export default async function AmigosPage({ ? await db.course.findUnique({ where: { slug: courseSlug, published: true }, select: { id: true } }) : null; - const [friends, incoming, outgoing, discovery, feed, streaks, reminders, params] = await Promise.all([ + const [friends, incoming, outgoing, discovery, feed, streaks, reminders, ranking, params] = await Promise.all([ getFriends(userId), getPendingIncoming(userId), getPendingOutgoing(userId), @@ -44,9 +46,19 @@ export default async function AmigosPage({ getSocialFeed(userId), getMyFriendStreaks(userId), getMyStreakReminders(userId), + getFriendWeeklyRanking(userId), searchParams, ]); + if (ranking.length > 1) { + await recordProductEventSafely(db, { + userId, + name: "friends_ranking_view", + surface: "social", + props: emptyPropsSchema.parse({}), + }); + } + const bucketCounts: Record = {}; for (const c of discovery.candidates) bucketCounts[c.bucket] = (bucketCounts[c.bucket] ?? 0) + 1; await recordProductEventSafely(db, { @@ -90,6 +102,15 @@ export default async function AmigosPage({ ) : null} + {ranking.length > 1 ? ( +
    + Ranking semanal +
    + +
    +
    + ) : null} +
    +
    +

    + Liga +

    +

    + Compite en XP de la semana contra tu división. Sube de liga si + terminas arriba; baja si te quedas atrás — todo se reinicia cada + lunes. +

    +
    + + {!standing ? ( +
    +

    Aún no estás en una liga

    +

    + Gana XP completando lecciones o retos esta semana y entrarás + automáticamente a Bronce. +

    +
    + ) : ( + <> +
    + + + +
    +

    Liga {LEAGUE_TIER_LABEL[standing.tier]}

    +

    + {standing.rows.length} {standing.rows.length === 1 ? "alumno" : "alumnos"} en tu división +

    +
    +
    +

    + #{standing.rows.find((r) => r.isSelf)?.rank ?? "—"} +

    +

    tu lugar

    +
    +
    + +
    + Top 3 +
      + {standing.rows.slice(0, 3).map((row) => ( + + ))} +
    +
    + + + +
    + + Ver clasificación completa + +
      + {standing.rows.map((row) => ( + + ))} +
    +
    + + )} +
    + ); +} + +function NearbySection({ + standing, +}: { + standing: NonNullable>>; +}) { + const selfIdx = standing.rows.findIndex((r) => r.isSelf); + if (selfIdx < 0 || selfIdx < 3) return null; // ya está en el top3 mostrado arriba + + const start = Math.max(0, selfIdx - 2); + const end = Math.min(standing.rows.length, selfIdx + 3); + const nearby = standing.rows.slice(start, end); + + return ( +
    + Cerca de ti +
      + {nearby.map((row) => ( + + ))} +
    +
    + ); +} + +function StandingRow({ + row, + standing, +}: { + row: { userId: string; username: string; name: string; image: string | null; xp: number; rank: number; isSelf: boolean }; + standing: { promoteCount: number; relegateCount: number; rows: { rank: number }[] }; +}) { + const n = standing.rows.length; + const inPromotionZone = row.rank <= standing.promoteCount; + const inRelegationZone = row.rank > n - standing.relegateCount; + + return ( +
  • + + {row.rank} + + +
    +

    + {row.name} + {row.isSelf ? ( + + Tú + + ) : null} +

    +
    + {inPromotionZone ? : null} + {inRelegationZone ? : null} + {row.rank === 1 ? : null} + + XP + +
  • + ); +} diff --git a/src/app/app/(global)/logros/loading.tsx b/src/app/app/(global)/logros/loading.tsx deleted file mode 100644 index 194cb56..0000000 --- a/src/app/app/(global)/logros/loading.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import { Skeleton } from "@/components/ui/skeleton"; - -export default function LogrosLoading() { - return ( -
    - - - - - - - -
    - {Array.from({ length: 6 }).map((_, i) => ( - - ))} -
    -
    - ); -} diff --git a/src/app/app/(global)/logros/page.tsx b/src/app/app/(global)/logros/page.tsx index fd7ea38..0c21518 100644 --- a/src/app/app/(global)/logros/page.tsx +++ b/src/app/app/(global)/logros/page.tsx @@ -1,329 +1,10 @@ -import Link from "next/link"; -import type { CSSProperties } from "react"; -import { - ArrowRight, - BookOpen, - Code2, - Flame, - GraduationCap, - Lock, - Sparkles, - Star, - Zap, - type LucideIcon, -} from "lucide-react"; - -import { AnimatedNumber } from "@/components/ui/animated-number"; -import { BrickRow } from "@/components/ui/bricks"; -import { Button } from "@/components/ui/button"; -import { Readout, ReadoutBar } from "@/components/ui/readout"; -import { SectionRule } from "@/components/ui/section-rule"; -import { StreakFlame } from "@/components/ui/streak-flame"; -import { - getCompletedLessonsCount, - getDistinctExercisesPassedCount, -} from "@/features/lessons/queries"; -import { getUserStats } from "@/lib/streak"; -import { requireSession } from "@/lib/get-session"; -import { cn, pluralize } from "@/lib/utils"; - -type BadgeTone = "primary" | "warning" | "success"; - -interface BadgeDef { - id: string; - title: string; - description: string; - unlockedWhen: (s: AchievementStats) => boolean; - hint: string; - icon: LucideIcon; - tone: BadgeTone; -} - -const TONE_MARK: Record = { - primary: "bg-primary text-primary-foreground", - warning: "bg-warning-vivid text-warning-ink", - success: "bg-success text-success-foreground", -}; - -export const metadata = { - title: "Logros", -}; - -interface AchievementStats { - totalXp: number; - currentStreak: number; - longestStreak: number; - lessonsCompleted: number; - exercisesPassed: number; -} - -const BADGES: BadgeDef[] = [ - { - id: "first-step", - title: "Primer paso", - description: "Completaste tu primera lección.", - unlockedWhen: (s) => s.lessonsCompleted >= 1, - hint: "Termina cualquier lección.", - icon: BookOpen, - tone: "primary", - }, - { - id: "explorer", - title: "Explorador", - description: "Completaste 5 lecciones.", - unlockedWhen: (s) => s.lessonsCompleted >= 5, - hint: "Sigue avanzando — vas a la mitad de la primera unidad.", - icon: BookOpen, - tone: "primary", - }, - { - id: "unit-complete", - title: "Unidad dominada", - description: "Terminaste una unidad completa.", - unlockedWhen: (s) => s.lessonsCompleted >= 6, - hint: "Completa todas las lecciones de una unidad.", - icon: GraduationCap, - tone: "success", - }, - { - id: "challenger", - title: "Resuelvo retos", - description: "Aprobaste tu primer reto de código.", - unlockedWhen: (s) => s.exercisesPassed >= 1, - hint: "Envía la solución a un reto y pasa todos sus tests.", - icon: Sparkles, - tone: "success", - }, - { - id: "challenger-5", - title: "Coder ágil", - description: "Aprobaste 5 retos de código.", - unlockedWhen: (s) => s.exercisesPassed >= 5, - hint: "Completa 5 retos para desbloquearlo.", - icon: Code2, - tone: "success", - }, - { - id: "streak-3", - title: "Constancia", - description: "Racha de 3 días.", - unlockedWhen: (s) => s.longestStreak >= 3, - hint: "Vuelve 3 días seguidos.", - icon: Flame, - tone: "warning", - }, - { - id: "streak-7", - title: "Una semana entera", - description: "Racha de 7 días.", - unlockedWhen: (s) => s.longestStreak >= 7, - hint: "Vuelve cada día durante 7 días.", - icon: Flame, - tone: "warning", - }, - { - id: "xp-100", - title: "Centenario", - description: "Acumulaste 100 XP.", - unlockedWhen: (s) => s.totalXp >= 100, - hint: "Cada lección te da entre 20 y 30 XP.", - icon: Zap, - tone: "primary", - }, - { - id: "xp-500", - title: "Quinientos", - description: "Acumulaste 500 XP.", - unlockedWhen: (s) => s.totalXp >= 500, - hint: "Sigue completando lecciones y retos.", - icon: Star, - tone: "warning", - }, -]; - -export default async function LogrosPage() { - const session = await requireSession(); - - const [stats, lessonsCompleted, exercisesPassed] = await Promise.all([ - getUserStats(session.user.id), - getCompletedLessonsCount(session.user.id), - getDistinctExercisesPassedCount(session.user.id), - ]); - - const achievementStats: AchievementStats = { - totalXp: stats.totalXp, - currentStreak: stats.currentStreak, - longestStreak: stats.longestStreak, - lessonsCompleted, - exercisesPassed, - }; - - const unlocked = BADGES.filter((b) => b.unlockedWhen(achievementStats)); - const locked = BADGES.filter((b) => !b.unlockedWhen(achievementStats)); - const percent = Math.round((unlocked.length / BADGES.length) * 100); - - return ( -
    -
    -

    - Logros -

    -

    - {unlocked.length === 0 - ? "Cada lección y cada reto que completes desbloquea un logro. La colección empieza con un solo paso." - : `Llevas ${unlocked.length} de ${BADGES.length} ${pluralize( - BADGES.length, - "logro", - "logros", - )}.`} -

    - -
    - - - {percent}% - -
    -
    - - - } - /> - } - value={ - <> - - - {pluralize(stats.longestStreak, "día", "días")} - - - } - /> - } - /> - - - {unlocked.length === 0 ? ( -
    -

    - Tu primer logro está a una lección de distancia -

    -

    - Completa cualquier lección para desbloquear “Primer paso” y echar a - andar tu racha. -

    - -
    - ) : null} - - {unlocked.length > 0 ? ( -
    - - Desbloqueados - - -
    - ) : null} - - {locked.length > 0 ? ( -
    - - Por desbloquear - - -
    - ) : null} -
    - ); -} +import { redirect } from "next/navigation"; /** - * Los logros son piezas de colección: la marca lleva el icono y el - * color del tipo de logro; los bloqueados quedan en borde punteado con - * la condición para conseguirlos, que es más útil que esconderlos. + * Logros pasó a vivir dentro de `/app/perfil` (Fase 4, reorganización de + * nav: Inicio / Práctica / Liga / Amigos / Perfil). Este redirect + * permanente preserva cualquier marcador o enlace compartido viejo. */ -function BadgeList({ - badges, - unlocked, -}: { - badges: BadgeDef[]; - unlocked: boolean; -}) { - return ( -
      - {badges.map((badge, idx) => { - const Icon = badge.icon; - return ( -
    • - - {unlocked ? ( - - ) : ( - - )} - - -
      -

      - {badge.title} -

      -

      - {unlocked ? badge.description : badge.hint} -

      -
      -
    • - ); - })} -
    - ); +export default function LogrosRedirect() { + redirect("/app/perfil#logros"); } diff --git a/src/app/app/(global)/perfil/[username]/page.tsx b/src/app/app/(global)/perfil/[username]/page.tsx index c8b1c92..cbdd65b 100644 --- a/src/app/app/(global)/perfil/[username]/page.tsx +++ b/src/app/app/(global)/perfil/[username]/page.tsx @@ -132,6 +132,26 @@ export default async function PublicProfilePage({ params }: PageProps) { ) : null} + {profile.academic ? ( +

    + {profile.academic.campusName} + · + {profile.academic.programName} + {profile.academic.semester ? ( + <> + · + {profile.academic.semester}.º semestre + + ) : null} + {profile.academic.group ? ( + <> + · + Grupo {profile.academic.group} + + ) : null} +

    + ) : null} +
    diff --git a/src/app/app/(global)/perfil/page.tsx b/src/app/app/(global)/perfil/page.tsx index d47fc6e..5725ee8 100644 --- a/src/app/app/(global)/perfil/page.tsx +++ b/src/app/app/(global)/perfil/page.tsx @@ -8,11 +8,15 @@ import { SectionRule } from "@/components/ui/section-rule"; import { StreakFlame } from "@/components/ui/streak-flame"; import { getCompletedLessonsCount, + getDistinctExercisesPassedCount, getExerciseAttemptsCount, } from "@/features/lessons/queries"; import { getUserStats } from "@/lib/streak"; import { requireSession } from "@/lib/get-session"; import { pluralize } from "@/lib/utils"; +import { AcademicProfileEditor } from "@/features/academic/components/academic-profile-editor"; +import { getAcademicOptions, getOwnAcademicProfile } from "@/features/academic/queries"; +import { AchievementsSection } from "@/features/profile/components/achievements-section"; import { ChangePasswordDialog } from "@/features/profile/components/change-password-dialog"; import { DeleteAccountDialog } from "@/features/profile/components/delete-account-dialog"; import { SignOutButton } from "@/features/profile/components/sign-out-button"; @@ -25,11 +29,15 @@ export default async function PerfilPage() { const session = await requireSession(); const user = session.user; - const [stats, lessonsCompleted, attempts] = await Promise.all([ - getUserStats(user.id), - getCompletedLessonsCount(user.id), - getExerciseAttemptsCount(user.id), - ]); + const [stats, lessonsCompleted, attempts, exercisesPassed, academicOptions, academicProfile] = + await Promise.all([ + getUserStats(user.id), + getCompletedLessonsCount(user.id), + getExerciseAttemptsCount(user.id), + getDistinctExercisesPassedCount(user.id), + getAcademicOptions(), + getOwnAcademicProfile(user.id), + ]); const initials = user.name .split(" ") @@ -71,6 +79,25 @@ export default async function PerfilPage() { +
    + Identidad académica +

    + Opcional — ayuda a tus compañeros del CETI a encontrarte. Campus, + carrera y semestre son visibles para cualquier usuario con + sesión; tu grupo exacto sólo lo ven tus amigos. +

    +
    + +
    +
    +
    Tu actividad @@ -102,6 +129,18 @@ export default async function PerfilPage() {
    +
    + +
    +
    Cuenta diff --git a/src/app/app/c/[courseSlug]/page.tsx b/src/app/app/c/[courseSlug]/page.tsx index 94d489e..a2b5c25 100644 --- a/src/app/app/c/[courseSlug]/page.tsx +++ b/src/app/app/c/[courseSlug]/page.tsx @@ -9,16 +9,23 @@ import { LevelBar } from "@/components/ui/level-bar"; import { SectionRule } from "@/components/ui/section-rule"; import { StreakFlame } from "@/components/ui/streak-flame"; import { InlineCodeText } from "@/components/shared/inline-code-text"; +import { AcademicPromptBanner } from "@/features/academic/components/academic-prompt-banner"; +import { getOwnAcademicProfile } from "@/features/academic/queries"; import { ActivityFeed } from "@/features/friends/components/activity-feed"; import { getFriends, getFriendsActivityFeed } from "@/features/friends/queries"; +import { getLeagueStanding } from "@/features/league/queries"; +import { QuestCard } from "@/features/quests/components/quest-card"; +import { getMyFriendQuest } from "@/features/quests/queries"; import { RoadmapUnits } from "@/features/roadmap/components/roadmap-units"; import { findNextLesson, getCourseBySlug, getRoadmapUnits, } from "@/features/roadmap/queries"; +import { getMyFriendStreaks } from "@/features/streaks/queries"; import { getUserStats } from "@/lib/streak"; import { getSession } from "@/lib/get-session"; +import { LEAGUE_TIER_LABEL } from "@/lib/social/league-labels"; import { pluralize } from "@/lib/utils"; import type { NextLesson, RoadmapUnit } from "@/features/roadmap/types"; @@ -42,13 +49,23 @@ export default async function CourseHomePage({ params }: PageProps) { const course = await getCourseBySlug(courseSlug); if (!course) notFound(); - const [stats, nextLesson, friends, feed, units] = await Promise.all([ + const [stats, nextLesson, friends, feed, units, standing, myStreaks, myQuest, academicProfile] = await Promise.all([ getUserStats(session.user.id), findNextLesson(session.user.id, course.id), getFriends(session.user.id), getFriendsActivityFeed(session.user.id, 5), getRoadmapUnits(course.id, session.user.id), + session.user.usernameSetupRequired ? null : getLeagueStanding(session.user.id), + session.user.usernameSetupRequired ? [] : getMyFriendStreaks(session.user.id), + session.user.usernameSetupRequired ? null : getMyFriendQuest(session.user.id), + session.user.usernameSetupRequired ? null : getOwnAcademicProfile(session.user.id), ]); + const activeStreakCount = myStreaks.filter((s) => s.status === "active").length; + const showAcademicPrompt = + !session.user.usernameSetupRequired && + academicProfile !== null && + academicProfile.offering === null && + academicProfile.promptDismissedAt === null; const totalLessons = units.reduce((sum, u) => sum + u.lessonCount, 0); const totalCompleted = units.reduce((sum, u) => sum + u.completedCount, 0); @@ -83,6 +100,8 @@ export default async function CourseHomePage({ params }: PageProps) { )} + {showAcademicPrompt ? : null} +
    + {standing || activeStreakCount > 0 || myQuest ? ( +
    + Compañeros + {standing ? ( + + + Liga {LEAGUE_TIER_LABEL[standing.tier]} + + + #{standing.rows.find((r) => r.isSelf)?.rank ?? "—"} de {standing.rows.length} + + + ) : null} + {activeStreakCount > 0 ? ( + + Rachas con amigos + + {activeStreakCount} {pluralize(activeStreakCount, "activa", "activas")} + + + ) : null} + {myQuest ? : null} +
    + ) : null} +
    { + const seen = new Map(); + for (const o of options) seen.set(o.campusId, { id: o.campusId, name: o.campusName }); + return [...seen.values()].sort((a, b) => a.name.localeCompare(b.name, "es")); + }, [options]); + + const initialOffering = options.find((o) => o.id === initial.offeringId) ?? null; + const [campusId, setCampusId] = React.useState(initialOffering?.campusId ?? ""); + const [offeringId, setOfferingId] = React.useState(initial.offeringId ?? ""); + const [semester, setSemester] = React.useState(initial.semester ? String(initial.semester) : ""); + const [group, setGroup] = React.useState(initial.group ?? ""); + const [pending, startTransition] = React.useTransition(); + + const programsForCampus = options.filter((o) => o.campusId === campusId); + const selectedOffering = options.find((o) => o.id === offeringId) ?? null; + + function save() { + startTransition(async () => { + try { + await updateAcademicProfile({ + academicOfferingId: offeringId || null, + academicSemester: offeringId && semester ? Number(semester) : null, + academicGroup: offeringId && semester ? group : null, + }); + toast.success("Perfil académico actualizado"); + } catch (err) { + toast.error(err instanceof Error ? err.message : "Algo salió mal"); + } + }); + } + + return ( +
    +
    +
    + + +
    + +
    + + +
    +
    + + {selectedOffering ? ( +
    +
    + + +
    +
    + + setGroup(e.currentTarget.value)} + placeholder="ej. 3A" + maxLength={20} + /> +
    +
    + ) : null} + + +
    + ); +} diff --git a/src/features/academic/components/academic-prompt-banner.tsx b/src/features/academic/components/academic-prompt-banner.tsx new file mode 100644 index 0000000..fba02ee --- /dev/null +++ b/src/features/academic/components/academic-prompt-banner.tsx @@ -0,0 +1,43 @@ +"use client"; + +import * as React from "react"; +import Link from "next/link"; +import { Users, X } from "lucide-react"; + +import { dismissAcademicPrompt } from "@/features/academic/actions"; + +export function AcademicPromptBanner() { + const [dismissed, setDismissed] = React.useState(false); + if (dismissed) return null; + + return ( +
    + + + +
    +

    Encuentra a tus compañeros

    +

    + Cuéntanos tu plantel y carrera para encontrar gente de tu grupo. +

    +
    + + Completar + + +
    + ); +} diff --git a/src/features/friends/components/invite-link-card.tsx b/src/features/friends/components/invite-link-card.tsx index b480cd9..132bfab 100644 --- a/src/features/friends/components/invite-link-card.tsx +++ b/src/features/friends/components/invite-link-card.tsx @@ -6,6 +6,7 @@ import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; +import { trackInviteLinkCopied } from "@/features/invites/actions"; import { PRODUCT_NAME } from "@/lib/branding"; interface InviteLinkCardProps { @@ -33,6 +34,7 @@ export function InviteLinkCard({ username }: InviteLinkCardProps) { setCopied(true); toast.success("Link copiado al portapapeles"); setTimeout(() => setCopied(false), 2000); + void trackInviteLinkCopied(); } catch { toast.error("No pudimos copiar el link"); } @@ -48,6 +50,7 @@ export function InviteLinkCard({ username }: InviteLinkCardProps) { text: "Aprende C++ con lecciones interactivas", url: inviteUrl, }); + void trackInviteLinkCopied(); return; } catch { // usuario cerró el panel; cae al WhatsApp web @@ -55,6 +58,7 @@ export function InviteLinkCard({ username }: InviteLinkCardProps) { } const wa = `https://wa.me/?text=${encodeURIComponent(text)}`; window.open(wa, "_blank", "noopener"); + void trackInviteLinkCopied(); } return ( diff --git a/src/features/friends/queries.ts b/src/features/friends/queries.ts index d13c0ec..81ae3f0 100644 --- a/src/features/friends/queries.ts +++ b/src/features/friends/queries.ts @@ -65,6 +65,18 @@ export interface ActivityEvent { }; } +export interface PublicProfileAcademic { + campusName: string; + programName: string; + semester: number | null; + /** + * `null` para un no-amigo — visible siempre para self/friend. Campus, + * carrera y semestre SÍ se muestran a cualquier autenticado; el grupo + * exacto NUNCA (ver `` del contrato). + */ + group: string | null; +} + export interface PublicProfile { id: string; username: string; @@ -78,6 +90,7 @@ export interface PublicProfile { completedLessons: number; completedExercises: number; state: FriendshipState; + academic: PublicProfileAcademic | null; } /** @@ -287,6 +300,11 @@ export async function getPublicProfile( bio: true, createdAt: true, usernameSetupRequired: true, + academicSemester: true, + academicGroup: true, + academicOffering: { + select: { campus: { select: { name: true } }, program: { select: { name: true } } }, + }, streak: { select: { totalXp: true, currentStreak: true, longestStreak: true } }, }, }); @@ -303,6 +321,18 @@ export async function getPublicProfile( getFriendshipState(viewerId, user.id), ]); + // Campus/carrera/semestre: visibles para cualquier autenticado. Grupo + // exacto: SOLO self o amigo accepted — nunca para un no-amigo. + const showGroup = state === "self" || state === "friends"; + const academic: PublicProfileAcademic | null = user.academicOffering + ? { + campusName: user.academicOffering.campus.name, + programName: user.academicOffering.program.name, + semester: user.academicSemester, + group: showGroup ? user.academicGroup : null, + } + : null; + return { id: user.id, username: user.username, @@ -316,6 +346,7 @@ export async function getPublicProfile( completedLessons, completedExercises, state, + academic, }; } diff --git a/src/features/invites/actions.ts b/src/features/invites/actions.ts index 097f7d8..d5582a1 100644 --- a/src/features/invites/actions.ts +++ b/src/features/invites/actions.ts @@ -6,7 +6,9 @@ import { z } from "zod"; import { withActionErrorHandling } from "@/lib/action-error"; import { db } from "@/lib/db"; import { env } from "@/env"; -import { getSession } from "@/lib/get-session"; +import { emptyPropsSchema } from "@/lib/analytics/social-props"; +import { recordProductEventSafely } from "@/lib/analytics/record"; +import { getSession, requireSession } from "@/lib/get-session"; import { INVITE_COOKIE_MAX_AGE_SEC, INVITE_COOKIE_NAME } from "@/lib/social/invite-cookie"; import { encodeSignedToken } from "@/lib/social/signed-token"; import { parseOrThrow, usernameSchema } from "@/lib/validation"; @@ -49,3 +51,17 @@ export const captureInviteAttribution = withActionErrorHandling( return { captured: true }; }, ); + +/** Registra que el usuario copió/compartió su link de invitación. */ +export const trackInviteLinkCopied = withActionErrorHandling( + "trackInviteLinkCopied", + async (): Promise => { + const session = await requireSession(); + await recordProductEventSafely(db, { + userId: session.user.id, + name: "invite_link_copied", + surface: "social", + props: emptyPropsSchema.parse({}), + }); + }, +); diff --git a/src/features/league/components/friend-ranking-list.tsx b/src/features/league/components/friend-ranking-list.tsx new file mode 100644 index 0000000..c0493ca --- /dev/null +++ b/src/features/league/components/friend-ranking-list.tsx @@ -0,0 +1,33 @@ +import { AnimatedNumber } from "@/components/ui/animated-number"; +import { FriendAvatar } from "@/features/friends/components/friend-avatar"; +import type { FriendRankingRow } from "@/features/league/queries"; +import { cn } from "@/lib/utils"; + +export function FriendRankingList({ rows }: { rows: FriendRankingRow[] }) { + if (rows.length <= 1) return null; + + return ( +
      + {rows.slice(0, 10).map((row) => ( +
    1. + + {row.rank} + + +

      + {row.isSelf ? "Tú" : row.name} +

      + + XP + +
    2. + ))} +
    + ); +} diff --git a/src/features/profile/components/achievements-section.tsx b/src/features/profile/components/achievements-section.tsx new file mode 100644 index 0000000..84a6b00 --- /dev/null +++ b/src/features/profile/components/achievements-section.tsx @@ -0,0 +1,269 @@ +import Link from "next/link"; +import type { CSSProperties } from "react"; +import { + ArrowRight, + BookOpen, + Code2, + Flame, + GraduationCap, + Lock, + Sparkles, + Star, + Zap, + type LucideIcon, +} from "lucide-react"; + +import { AnimatedNumber } from "@/components/ui/animated-number"; +import { BrickRow } from "@/components/ui/bricks"; +import { Button } from "@/components/ui/button"; +import { Readout, ReadoutBar } from "@/components/ui/readout"; +import { SectionRule } from "@/components/ui/section-rule"; +import { StreakFlame } from "@/components/ui/streak-flame"; +import { cn, pluralize } from "@/lib/utils"; + +type BadgeTone = "primary" | "warning" | "success"; + +interface BadgeDef { + id: string; + title: string; + description: string; + unlockedWhen: (s: AchievementStats) => boolean; + hint: string; + icon: LucideIcon; + tone: BadgeTone; +} + +const TONE_MARK: Record = { + primary: "bg-primary text-primary-foreground", + warning: "bg-warning-vivid text-warning-ink", + success: "bg-success text-success-foreground", +}; + +export interface AchievementStats { + totalXp: number; + currentStreak: number; + longestStreak: number; + lessonsCompleted: number; + exercisesPassed: number; +} + +const BADGES: BadgeDef[] = [ + { + id: "first-step", + title: "Primer paso", + description: "Completaste tu primera lección.", + unlockedWhen: (s) => s.lessonsCompleted >= 1, + hint: "Termina cualquier lección.", + icon: BookOpen, + tone: "primary", + }, + { + id: "explorer", + title: "Explorador", + description: "Completaste 5 lecciones.", + unlockedWhen: (s) => s.lessonsCompleted >= 5, + hint: "Sigue avanzando — vas a la mitad de la primera unidad.", + icon: BookOpen, + tone: "primary", + }, + { + id: "unit-complete", + title: "Unidad dominada", + description: "Terminaste una unidad completa.", + unlockedWhen: (s) => s.lessonsCompleted >= 6, + hint: "Completa todas las lecciones de una unidad.", + icon: GraduationCap, + tone: "success", + }, + { + id: "challenger", + title: "Resuelvo retos", + description: "Aprobaste tu primer reto de código.", + unlockedWhen: (s) => s.exercisesPassed >= 1, + hint: "Envía la solución a un reto y pasa todos sus tests.", + icon: Sparkles, + tone: "success", + }, + { + id: "challenger-5", + title: "Coder ágil", + description: "Aprobaste 5 retos de código.", + unlockedWhen: (s) => s.exercisesPassed >= 5, + hint: "Completa 5 retos para desbloquearlo.", + icon: Code2, + tone: "success", + }, + { + id: "streak-3", + title: "Constancia", + description: "Racha de 3 días.", + unlockedWhen: (s) => s.longestStreak >= 3, + hint: "Vuelve 3 días seguidos.", + icon: Flame, + tone: "warning", + }, + { + id: "streak-7", + title: "Una semana entera", + description: "Racha de 7 días.", + unlockedWhen: (s) => s.longestStreak >= 7, + hint: "Vuelve cada día durante 7 días.", + icon: Flame, + tone: "warning", + }, + { + id: "xp-100", + title: "Centenario", + description: "Acumulaste 100 XP.", + unlockedWhen: (s) => s.totalXp >= 100, + hint: "Cada lección te da entre 20 y 30 XP.", + icon: Zap, + tone: "primary", + }, + { + id: "xp-500", + title: "Quinientos", + description: "Acumulaste 500 XP.", + unlockedWhen: (s) => s.totalXp >= 500, + hint: "Sigue completando lecciones y retos.", + icon: Star, + tone: "warning", + }, +]; + +/** + * Sección de logros — vive dentro de `/app/perfil` (con `id="logros"` para + * que `/app/logros` pueda seguir enlazando ahí como redirect permanente). + */ +export function AchievementsSection({ stats }: { stats: AchievementStats }) { + const unlocked = BADGES.filter((b) => b.unlockedWhen(stats)); + const locked = BADGES.filter((b) => !b.unlockedWhen(stats)); + const percent = Math.round((unlocked.length / BADGES.length) * 100); + + return ( +
    + Logros +

    + {unlocked.length === 0 + ? "Cada lección y cada reto que completes desbloquea un logro." + : `Llevas ${unlocked.length} de ${BADGES.length} ${pluralize(BADGES.length, "logro", "logros")}.`} +

    + +
    + + + {percent}% + +
    + + + } /> + } + value={ + <> + + + {pluralize(stats.longestStreak, "día", "días")} + + + } + /> + } + /> + + + {unlocked.length === 0 ? ( +
    +

    + Tu primer logro está a una lección de distancia +

    +

    + Completa cualquier lección para desbloquear “Primer paso”. +

    + +
    + ) : null} + + {unlocked.length > 0 ? ( +
    + +
    + ) : null} + + {locked.length > 0 ? ( +
    +

    + Por desbloquear · {locked.length} +

    + +
    + ) : null} +
    + ); +} + +function BadgeList({ badges, unlocked }: { badges: BadgeDef[]; unlocked: boolean }) { + return ( +
      + {badges.map((badge, idx) => { + const Icon = badge.icon; + return ( +
    • + + {unlocked ? : } + +
      +

      + {badge.title} +

      +

      + {unlocked ? badge.description : badge.hint} +

      +
      +
    • + ); + })} +
    + ); +} diff --git a/src/features/quests/components/quest-card.tsx b/src/features/quests/components/quest-card.tsx new file mode 100644 index 0000000..e137052 --- /dev/null +++ b/src/features/quests/components/quest-card.tsx @@ -0,0 +1,39 @@ +import { Target } from "lucide-react"; + +import { BrickRow } from "@/components/ui/bricks"; +import { FriendAvatar } from "@/features/friends/components/friend-avatar"; +import type { MyFriendQuest } from "@/features/quests/queries"; + +/** Contexto compacto — nunca un dashboard. Sólo se muestra si hay quest activa/completada esta semana. */ +export function QuestCard({ quest }: { quest: MyFriendQuest }) { + if (quest.status !== "active" && quest.status !== "completed") return null; + + return ( +
    +
    + + + +
    +

    + Misión con + {quest.partner.name} +

    +

    + {quest.status === "completed" + ? "¡Completada!" + : `${quest.progress}/${quest.target} lecciones entre los dos`} +

    +
    +
    + +
    + ); +} diff --git a/src/features/quests/queries.ts b/src/features/quests/queries.ts new file mode 100644 index 0000000..07fccf9 --- /dev/null +++ b/src/features/quests/queries.ts @@ -0,0 +1,55 @@ +import { db } from "@/lib/db"; +import { refreshFriendQuestProgress } from "@/lib/social/friend-quest"; +import { mxWeekStartDateOnly } from "@/lib/social/time"; + +export interface MyFriendQuest { + id: string; + status: "active" | "completed" | "expired" | "cancelled"; + target: number; + progress: number; + endsAt: Date; + partner: { id: string; username: string; name: string; image: string | null }; +} + +/** Quest de la semana del viewer, con progreso refrescado en vivo. `null` si no hay. */ +export async function getMyFriendQuest(viewerId: string): Promise { + const weekStart = mxWeekStartDateOnly(new Date()); + const participation = await db.friendQuestParticipant.findUnique({ + where: { userId_weekStart: { userId: viewerId, weekStart } }, + select: { + quest: { + select: { + id: true, + status: true, + target: true, + endsAt: true, + participants: { + select: { userId: true, user: { select: { id: true, username: true, name: true, image: true } } }, + }, + }, + }, + }, + }); + if (!participation) return null; + + const quest = participation.quest; + let progress = 0; + if (quest.status === "active") { + const refreshed = await refreshFriendQuestProgress(quest.id); + progress = refreshed.progress; + } else if (quest.status === "completed") { + progress = quest.target; + } + + const partner = quest.participants.find((p) => p.userId !== viewerId)?.user; + if (!partner) return null; + + return { + id: quest.id, + status: quest.status, + target: quest.target, + progress, + endsAt: quest.endsAt, + partner, + }; +} diff --git a/src/lib/social/friend-quest.ts b/src/lib/social/friend-quest.ts new file mode 100644 index 0000000..bbd274b --- /dev/null +++ b/src/lib/social/friend-quest.ts @@ -0,0 +1,213 @@ +import { db } from "@/lib/db"; +import { pairKeyOf } from "@/lib/social/pair"; +import { emitSocialEvent } from "@/lib/social/social-events"; +import { mxWeekRange, mxWeekStartDateOnly, shiftDateOnly } from "@/lib/social/time"; + +export const FRIEND_QUEST_TARGET = 12; +const ACTIVITY_WINDOW_DAYS = 14; + +interface Edge { + a: string; + b: string; + diff14d: number; + repeatedFromLastWeek: boolean; + pairKey: string; +} + +/** + * Matching semanal de Friend Quests. Idempotente por diseño: sólo + * considera usuarios SIN participación ya registrada para `weekStart` + * (`FriendQuestParticipant.userId_weekStart` es UNIQUE), así que correrlo + * de nuevo en la misma semana sólo empareja a quien quedó suelto. + */ +export async function runWeeklyFriendQuestMatching(now: Date = new Date()): Promise { + const { start: weekStartInstant, end: weekEndInstant } = mxWeekRange(now); + const weekStart = mxWeekStartDateOnly(now); + const prevWeekStartInstant = mxWeekRange(new Date(weekStartInstant.getTime() - 86_400_000)).start; + + const alreadyMatched = await db.friendQuestParticipant.findMany({ + where: { weekStart }, + select: { userId: true }, + }); + const alreadyMatchedIds = new Set(alreadyMatched.map((m) => m.userId)); + + // Candidatos: >=1 lección completada la semana PREVIA + >=1 actividad + // significativa en los últimos 14 días — nunca empareja a alguien + // inactivo con alguien activo. + const fourteenDaysAgo = new Date(now.getTime() - ACTIVITY_WINDOW_DAYS * 86_400_000); + const [recentLessons, recentExercises, recentPractice, lastWeekLessons] = await Promise.all([ + db.userLessonProgress.findMany({ + where: { status: "completed", completedAt: { gte: fourteenDaysAgo, lte: now } }, + select: { userId: true }, + distinct: ["userId"], + }), + db.userExerciseAttempt.findMany({ + where: { createdAt: { gte: fourteenDaysAgo, lte: now } }, + select: { userId: true }, + distinct: ["userId"], + }), + db.userPracticeAttempt.findMany({ + where: { createdAt: { gte: fourteenDaysAgo, lte: now } }, + select: { userId: true }, + distinct: ["userId"], + }), + db.userLessonProgress.groupBy({ + by: ["userId"], + where: { status: "completed", completedAt: { gte: prevWeekStartInstant, lt: weekStartInstant } }, + _count: { _all: true }, + }), + ]); + + const activeRecently = new Set([ + ...recentLessons.map((r) => r.userId), + ...recentExercises.map((r) => r.userId), + ...recentPractice.map((r) => r.userId), + ]); + const lessonsLastWeekByUser = new Map(lastWeekLessons.map((r) => [r.userId, r._count._all])); + + const eligibleIds = new Set( + [...lessonsLastWeekByUser.keys()].filter( + (id) => activeRecently.has(id) && !alreadyMatchedIds.has(id), + ), + ); + if (eligibleIds.size < 2) return 0; + + // 14d de actividad TOTAL (lecciones, para el criterio de diferencia de + // ritmo) — reusamos lastWeekLessons como proxy razonable de "ritmo + // reciente"; ver decisión documentada en el reporte final. + const activityCount = lessonsLastWeekByUser; + + const lastWeekPairs = await getLastWeekPairs(shiftDateOnly(weekStart, -7)); + + const friendships = await db.friendship.findMany({ + where: { + status: "accepted", + OR: [ + { requesterId: { in: [...eligibleIds] } }, + { addresseeId: { in: [...eligibleIds] } }, + ], + }, + select: { requesterId: true, addresseeId: true }, + }); + + const edges: Edge[] = []; + for (const f of friendships) { + if (!eligibleIds.has(f.requesterId) || !eligibleIds.has(f.addresseeId)) continue; + const key = pairKeyOf(f.requesterId, f.addresseeId); + edges.push({ + a: f.requesterId, + b: f.addresseeId, + diff14d: Math.abs((activityCount.get(f.requesterId) ?? 0) - (activityCount.get(f.addresseeId) ?? 0)), + repeatedFromLastWeek: lastWeekPairs.has(key), + pairKey: key, + }); + } + + edges.sort((x, y) => { + if (x.diff14d !== y.diff14d) return x.diff14d - y.diff14d; + if (x.repeatedFromLastWeek !== y.repeatedFromLastWeek) return x.repeatedFromLastWeek ? 1 : -1; + return x.pairKey < y.pairKey ? -1 : x.pairKey > y.pairKey ? 1 : 0; + }); + + const taken = new Set(); + let created = 0; + for (const edge of edges) { + if (taken.has(edge.a) || taken.has(edge.b)) continue; + taken.add(edge.a); + taken.add(edge.b); + + try { + await db.$transaction(async (tx) => { + const quest = await tx.friendQuest.create({ + data: { + weekStart, + startsAt: weekStartInstant, + endsAt: weekEndInstant, + type: "lessons_completed", + target: FRIEND_QUEST_TARGET, + status: "active", + }, + }); + await tx.friendQuestParticipant.createMany({ + data: [ + { questId: quest.id, userId: edge.a, weekStart }, + { questId: quest.id, userId: edge.b, weekStart }, + ], + }); + }); + created++; + } catch { + // UNIQUE(userId, weekStart) chocó — alguien ya se emparejó por otra + // vía entre la lectura y esta escritura. Se salta, no se reintenta: + // el siguiente run del job lo recoge si sigue libre. + } + } + return created; +} + +async function getLastWeekPairs(lastWeekStart: Date): Promise> { + const quests = await db.friendQuest.findMany({ + where: { weekStart: lastWeekStart }, + select: { participants: { select: { userId: true } } }, + }); + const pairs = new Set(); + for (const q of quests) { + if (q.participants.length === 2) { + pairs.add(pairKeyOf(q.participants[0]!.userId, q.participants[1]!.userId)); + } + } + return pairs; +} + +/** + * Progreso actual de una quest: COUNT de lecciones completadas por AMBOS + * participantes dentro de [startsAt, endsAt). Cursos pueden ser distintos. + * Transición active→completed es CONDICIONAL (`updateMany` con + * `status: "active"` en el WHERE) — sólo la llamada que gana emite el + * `SocialEvent`. + */ +export async function refreshFriendQuestProgress(questId: string): Promise<{ progress: number; justCompleted: boolean }> { + const quest = await db.friendQuest.findUnique({ + where: { id: questId }, + include: { participants: { select: { userId: true } } }, + }); + if (!quest || quest.status !== "active") { + return { progress: quest?.status === "completed" ? quest.target : 0, justCompleted: false }; + } + + const progress = await db.userLessonProgress.count({ + where: { + userId: { in: quest.participants.map((p) => p.userId) }, + status: "completed", + completedAt: { gte: quest.startsAt, lt: quest.endsAt }, + }, + }); + + if (progress < quest.target) return { progress, justCompleted: false }; + + const claimed = await db.friendQuest.updateMany({ + where: { id: questId, status: "active" }, + data: { status: "completed", completedAt: new Date() }, + }); + if (claimed.count === 1) { + for (const p of quest.participants) { + await db.$transaction(async (tx) => { + await emitSocialEvent(tx, { + actorId: p.userId, + kind: "friend_quest_completed", + dedupeKey: `friend_quest_completed:${questId}`, + }); + }); + } + } + return { progress, justCompleted: claimed.count === 1 }; +} + +/** Expira quests activas cuya ventana ya cerró sin llegar al target. */ +export async function expireStaleFriendQuests(now: Date = new Date()): Promise { + const res = await db.friendQuest.updateMany({ + where: { status: "active", endsAt: { lte: now } }, + data: { status: "expired" }, + }); + return res.count; +} diff --git a/src/lib/social/friend-streak.ts b/src/lib/social/friend-streak.ts index a56046d..a017bb5 100644 --- a/src/lib/social/friend-streak.ts +++ b/src/lib/social/friend-streak.ts @@ -3,7 +3,7 @@ import type { FriendStreakEndReason, Prisma } from "@prisma/client"; import { ActionError } from "@/lib/action-error"; import { db } from "@/lib/db"; import { canonicalPair } from "@/lib/social/pair"; -import { isNextDateOnly, mxDayRangeForDateOnly } from "@/lib/social/time"; +import { isNextDateOnly, mxDateOnly, mxDayRangeForDateOnly, mxYesterdayOf } from "@/lib/social/time"; export const MAX_ACTIVE_FRIEND_STREAKS = 3; export const MAX_PENDING_OUTGOING_FRIEND_STREAKS = 3; @@ -272,3 +272,24 @@ export async function refreshFriendStreakDay( } }); } + +/** + * Job diario: evalúa AYER para todo streak activo que aún no lo tenga + * evaluado (`lastEvaluatedDay < ayer`). Seguro de correr cada hora — el + * guard de `refreshFriendStreakDay` hace que sólo la primera pasada del + * día haga algo. + */ +export async function evaluateAllActiveStreaksForYesterday(now: Date = new Date()): Promise { + const yesterday = mxYesterdayOf(mxDateOnly(now)); + const candidates = await db.friendStreak.findMany({ + where: { + status: "active", + OR: [{ lastEvaluatedDay: null }, { lastEvaluatedDay: { lt: yesterday } }], + }, + select: { id: true }, + }); + for (const c of candidates) { + await refreshFriendStreakDay(c.id, yesterday, { breakOnMiss: true }); + } + return candidates.length; +} diff --git a/src/lib/social/league-labels.ts b/src/lib/social/league-labels.ts new file mode 100644 index 0000000..d0046dd --- /dev/null +++ b/src/lib/social/league-labels.ts @@ -0,0 +1,9 @@ +import type { LeagueTier } from "@prisma/client"; + +export const LEAGUE_TIER_LABEL: Record = { + bronze: "Bronce", + silver: "Plata", + gold: "Oro", + platinum: "Platino", + diamond: "Diamante", +}; diff --git a/tests/integration/friend-quest.integration.test.ts b/tests/integration/friend-quest.integration.test.ts new file mode 100644 index 0000000..038abbf --- /dev/null +++ b/tests/integration/friend-quest.integration.test.ts @@ -0,0 +1,148 @@ +import { afterAll, beforeEach, describe, expect, it } from "vitest"; + +import { db } from "@/lib/db"; +import { canonicalPair } from "@/lib/social/pair"; +import { + FRIEND_QUEST_TARGET, + refreshFriendQuestProgress, + runWeeklyFriendQuestMatching, +} from "@/lib/social/friend-quest"; +import { mxWeekRange } from "@/lib/social/time"; + +import { createTestLesson, createTestUser, resetSocialTables } from "./helpers"; + +async function makeAcceptedFriends(aId: string, bId: string) { + const { lowId, highId } = canonicalPair(aId, bId); + await db.friendship.create({ + data: { requesterId: aId, addresseeId: bId, status: "accepted", pairKey: `${lowId}:${highId}`, acceptedAt: new Date() }, + }); +} + +/** Simula N lecciones completadas por `userId` en un instante dado. */ +async function completeLessons(userId: string, count: number, at: Date) { + for (let i = 0; i < count; i++) { + const lesson = await createTestLesson(); + await db.userLessonProgress.create({ + data: { userId, lessonId: lesson.id, status: "completed", completedAt: at, startedAt: at }, + }); + } +} + +describe("Friend Quest — Postgres real", () => { + beforeEach(async () => { + await resetSocialTables(); + await db.friendQuestParticipant.deleteMany({}); + await db.friendQuest.deleteMany({}); + }); + afterAll(async () => { + await resetSocialTables(); + await db.friendQuestParticipant.deleteMany({}); + await db.friendQuest.deleteMany({}); + await db.$disconnect(); + }); + + it("un usuario inactivo (sin actividad reciente) no se empareja", async () => { + const now = new Date(); + const { start: thisWeekStart } = mxWeekRange(now); + const prevWeek = mxWeekRange(new Date(thisWeekStart.getTime() - 86_400_000)).start; + + const active = await createTestUser("active"); + const inactive = await createTestUser("inactive"); + await makeAcceptedFriends(active.id, inactive.id); + + // Sólo `active` completó lecciones la semana previa; `inactive` nada. + await completeLessons(active.id, 3, new Date(prevWeek.getTime() + 86_400_000)); + + const created = await runWeeklyFriendQuestMatching(now); + expect(created).toBe(0); + + const participants = await db.friendQuestParticipant.findMany({}); + expect(participants).toHaveLength(0); + }); + + it("empareja a dos amigos elegibles y respeta 1 quest/usuario/semana", async () => { + const now = new Date(); + const { start: thisWeekStart } = mxWeekRange(now); + const prevWeek = mxWeekRange(new Date(thisWeekStart.getTime() - 86_400_000)).start; + const midPrevWeek = new Date(prevWeek.getTime() + 86_400_000); + + const a = await createTestUser("a"); + const b = await createTestUser("b"); + await makeAcceptedFriends(a.id, b.id); + await completeLessons(a.id, 3, midPrevWeek); + await completeLessons(b.id, 2, midPrevWeek); + + const created = await runWeeklyFriendQuestMatching(now); + expect(created).toBe(1); + + const participantsA = await db.friendQuestParticipant.findMany({ where: { userId: a.id } }); + expect(participantsA).toHaveLength(1); + + // Segunda corrida en la MISMA semana: ya están emparejados, no duplica. + const createdAgain = await runWeeklyFriendQuestMatching(now); + expect(createdAgain).toBe(0); + const participantsA2 = await db.friendQuestParticipant.findMany({ where: { userId: a.id } }); + expect(participantsA2).toHaveLength(1); + }); + + it("progreso cuenta lecciones de AMBOS aunque sean de cursos distintos", async () => { + const now = new Date(); + const { start, end } = mxWeekRange(now); + const a = await createTestUser("a"); + const b = await createTestUser("b"); + await makeAcceptedFriends(a.id, b.id); + + const quest = await db.friendQuest.create({ + data: { weekStart: start, startsAt: start, endsAt: end, type: "lessons_completed", target: FRIEND_QUEST_TARGET, status: "active" }, + }); + await db.friendQuestParticipant.createMany({ + data: [ + { questId: quest.id, userId: a.id, weekStart: start }, + { questId: quest.id, userId: b.id, weekStart: start }, + ], + }); + + const mid = new Date(start.getTime() + 3600_000); + await completeLessons(a.id, 5, mid); // curso A (lecciones nuevas cada vez, cursos distintos por diseño de createTestLesson) + await completeLessons(b.id, 6, mid); // curso B + + const result = await refreshFriendQuestProgress(quest.id); + expect(result.progress).toBe(11); + expect(result.justCompleted).toBe(false); + + await completeLessons(a.id, 1, mid); // 12avo + const result2 = await refreshFriendQuestProgress(quest.id); + expect(result2.progress).toBe(12); + expect(result2.justCompleted).toBe(true); + + const questRow = await db.friendQuest.findUnique({ where: { id: quest.id } }); + expect(questRow?.status).toBe("completed"); + }); + + it("al completar, emite friend_quest_completed UNA vez por participante (no duplica en doble refresh)", async () => { + const now = new Date(); + const { start, end } = mxWeekRange(now); + const a = await createTestUser("a"); + const b = await createTestUser("b"); + await makeAcceptedFriends(a.id, b.id); + + const quest = await db.friendQuest.create({ + data: { weekStart: start, startsAt: start, endsAt: end, type: "lessons_completed", target: FRIEND_QUEST_TARGET, status: "active" }, + }); + await db.friendQuestParticipant.createMany({ + data: [ + { questId: quest.id, userId: a.id, weekStart: start }, + { questId: quest.id, userId: b.id, weekStart: start }, + ], + }); + await completeLessons(a.id, 12, new Date(start.getTime() + 3600_000)); + + await refreshFriendQuestProgress(quest.id); + await refreshFriendQuestProgress(quest.id); // doble refresh — no debe duplicar el evento + + const eventsA = await db.socialEvent.findMany({ where: { actorId: a.id, kind: "friend_quest_completed" } }); + const eventsB = await db.socialEvent.findMany({ where: { actorId: b.id, kind: "friend_quest_completed" } }); + expect(eventsA).toHaveLength(1); + expect(eventsB).toHaveLength(1); + }); +}); diff --git a/vercel.json b/vercel.json index 89a556e..bbf0404 100644 --- a/vercel.json +++ b/vercel.json @@ -4,5 +4,11 @@ "deploymentEnabled": { "claude/exercise-scoring-bug-cu1zlc": false } - } + }, + "crons": [ + { + "path": "/api/jobs/social-maintenance", + "schedule": "0 * * * *" + } + ] } From 6d9515ed81b3faa6300dfba08396251fd6009acf Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 00:27:23 +0000 Subject: [PATCH 5/6] Privacidad: blocked-by-target devuelve notFound (no perfil con acciones deshabilitadas) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getPublicProfile ahora corta antes de construir el DTO cuando el viewer está bloqueado por el dueño del perfil: devuelve null (→ 404), en vez de un perfil completo con el botón de amistad deshabilitado. Esto es lo que pide del contrato ("Blocked-by-target: notFound y exclusión total") — la versión anterior filtraba nombre/avatar/bio/stats al bloqueado, sólo le escondía el botón de agregar. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_018XQFppGcovvjRoHZyQa6GF --- src/features/friends/queries.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/features/friends/queries.ts b/src/features/friends/queries.ts index 81ae3f0..326c879 100644 --- a/src/features/friends/queries.ts +++ b/src/features/friends/queries.ts @@ -313,12 +313,18 @@ export async function getPublicProfile( // perfil social que mostrar a un tercero (self sigue viendo el suyo). if (user.usernameSetupRequired && user.id !== viewerId) return null; - const [completedLessons, completedExercises, state] = await Promise.all([ + const state = await getFriendshipState(viewerId, user.id); + // Blocked-by-target: notFound y exclusión total (nunca se revela ni + // siquiera que el usuario existe) — el bloqueado ve exactamente lo mismo + // que si el username no existiera. El lado que bloqueó sigue viendo su + // perfil normal (ver `ProfileActions`, estado `blocked_by_me`). + if (state === "blocked_by_them") return null; + + const [completedLessons, completedExercises] = await Promise.all([ db.userLessonProgress.count({ where: { userId: user.id, status: "completed" }, }), db.userExerciseCompletion.count({ where: { userId: user.id } }), - getFriendshipState(viewerId, user.id), ]); // Campus/carrera/semestre: visibles para cualquier autenticado. Grupo From 7af9072c8119240bc18817258f841a2a80db888e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 00:45:47 +0000 Subject: [PATCH 6/6] fix(ops): move social maintenance schedule to GitHub Actions Vercel's current plan doesn't support hourly cron frequency. Remove the crons entry from vercel.json and add a dedicated GitHub Actions workflow (schedule + workflow_dispatch) that makes an authenticated HTTPS request to /api/jobs/social-maintenance, which still owns all the maintenance logic. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_013dA2QiVhmuKxQsAmyZwhbt --- .github/workflows/social-maintenance.yml | 34 ++++++++++++++++++++++++ vercel.json | 8 +----- 2 files changed, 35 insertions(+), 7 deletions(-) create mode 100644 .github/workflows/social-maintenance.yml diff --git a/.github/workflows/social-maintenance.yml b/.github/workflows/social-maintenance.yml new file mode 100644 index 0000000..8ec9574 --- /dev/null +++ b/.github/workflows/social-maintenance.yml @@ -0,0 +1,34 @@ +name: Social maintenance + +# Dispara /api/jobs/social-maintenance cada hora. Vivía como cron de +# vercel.json, pero el plan actual de Vercel no permite frecuencia horaria; +# el disparo se movió aquí. Toda la lógica sigue viviendo exclusivamente en +# el endpoint — este workflow sólo hace la request HTTPS. +on: + schedule: + - cron: "0 * * * *" + workflow_dispatch: + +jobs: + trigger: + name: llamar social-maintenance + runs-on: ubuntu-latest + timeout-minutes: 5 + + steps: + - name: Verificar secret CRON_SECRET + run: | + if [ -z "${{ secrets.CRON_SECRET }}" ]; then + echo "::error::Falta el secret CRON_SECRET. Configúralo en Settings → Secrets and variables → Actions con el mismo valor que en Vercel Environment Variables." + exit 1 + fi + + - name: Llamar /api/jobs/social-maintenance + env: + CRON_SECRET: ${{ secrets.CRON_SECRET }} + run: | + curl --fail-with-body --silent --show-error \ + --max-time 60 \ + --connect-timeout 10 \ + -H "Authorization: Bearer ${CRON_SECRET}" \ + "https://cpp-ceti.vercel.app/api/jobs/social-maintenance" diff --git a/vercel.json b/vercel.json index bbf0404..89a556e 100644 --- a/vercel.json +++ b/vercel.json @@ -4,11 +4,5 @@ "deploymentEnabled": { "claude/exercise-scoring-bug-cu1zlc": false } - }, - "crons": [ - { - "path": "/api/jobs/social-maintenance", - "schedule": "0 * * * *" - } - ] + } }