diff --git a/package-lock.json b/package-lock.json index ece1bfa9..9eddfb56 100644 --- a/package-lock.json +++ b/package-lock.json @@ -986,6 +986,22 @@ "integrity": "sha512-Bz1zLGEqBQ0BVkqt1OgMxdBOE3BdUWUd7Ly9Ecr/aUwkA8AV1w1XzBMe4xblmJHnB1XXNlPH4SraXCvO+q0Mig==", "license": "MIT" }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/android-arm": { "version": "0.27.7", "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", @@ -12754,9 +12770,6 @@ "@esbuild/win32-x64": "0.27.7" } }, - "node_modules/esbuild/node_modules/@esbuild/aix-ppc64": { - "optional": true - }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", diff --git a/server/src/controllers/cohorts.controller.ts b/server/src/controllers/cohorts.controller.ts new file mode 100644 index 00000000..4764a7b3 --- /dev/null +++ b/server/src/controllers/cohorts.controller.ts @@ -0,0 +1,313 @@ +import { type Response } from "express" +import { pool } from "../db/index" +import { logger } from "../lib/logger" +import { type AuthRequest } from "../middleware/auth.middleware" + +const log = logger.child({ module: "cohorts" }) + +/** + * Create a new study cohort for a course. + * The creator is automatically added as the first member. + */ +export const createCohort = async ( + req: AuthRequest, + res: Response, +): Promise => { + try { + const walletAddress = req.walletAddress + if (!walletAddress) { + res.status(401).json({ error: "Unauthorized" }) + return + } + + const { name, course_slug, start_date, max_members } = req.body + + const courseResult = await pool.query( + `SELECT slug FROM courses WHERE slug = $1 LIMIT 1`, + [course_slug], + ) + if (courseResult.rows.length === 0) { + res.status(404).json({ error: "Course not found" }) + return + } + + const client = await pool.connect() + try { + await client.query("BEGIN") + + const cohortResult = await client.query( + `INSERT INTO cohorts (name, course_slug, start_date, max_members, created_by) + VALUES ($1, $2, $3, $4, $5) + RETURNING id, name, course_slug, start_date, max_members, created_by, created_at`, + [name, course_slug, start_date, max_members ?? 8, walletAddress], + ) + const cohort = cohortResult.rows[0] + + await client.query( + `INSERT INTO cohort_members (cohort_id, learner_addr) VALUES ($1, $2)`, + [cohort.id, walletAddress], + ) + + await client.query("COMMIT") + + res.status(201).json({ ...cohort, member_count: 1 }) + } catch (error) { + await client.query("ROLLBACK") + throw error + } finally { + client.release() + } + } catch (error) { + log.error({ err: error }, "Error creating cohort") + res.status(500).json({ error: "Failed to create cohort" }) + } +} + +/** + * List cohorts, optionally filtered by course slug. + * Includes current member count so clients can show joinable capacity. + */ +export const listCohorts = async ( + req: AuthRequest, + res: Response, +): Promise => { + try { + const { course } = req.query + + const params: string[] = [] + let whereClause = "" + if (course && typeof course === "string") { + params.push(course) + whereClause = "WHERE c.course_slug = $1" + } + + const result = await pool.query( + `SELECT c.id, c.name, c.course_slug, c.start_date, c.max_members, + c.created_by, c.created_at, + COUNT(cm.learner_addr)::int AS member_count + FROM cohorts c + LEFT JOIN cohort_members cm ON cm.cohort_id = c.id + ${whereClause} + GROUP BY c.id + ORDER BY c.start_date ASC, c.created_at ASC`, + params, + ) + + res.status(200).json({ data: result.rows }) + } catch (error) { + log.error({ err: error }, "Error listing cohorts") + res.status(500).json({ error: "Failed to list cohorts" }) + } +} + +/** + * Join a cohort. Capacity-checked inside a transaction (the cohort row is + * locked to serialize concurrent joins). Idempotent: joining a cohort you + * are already a member of succeeds without duplicating membership. + */ +export const joinCohort = async ( + req: AuthRequest, + res: Response, +): Promise => { + try { + const walletAddress = req.walletAddress + if (!walletAddress) { + res.status(401).json({ error: "Unauthorized" }) + return + } + + const cohortId = parseInt(req.params.id, 10) + + const client = await pool.connect() + try { + await client.query("BEGIN") + + const cohortResult = await client.query( + `SELECT id, max_members FROM cohorts WHERE id = $1 FOR UPDATE`, + [cohortId], + ) + if (cohortResult.rows.length === 0) { + await client.query("ROLLBACK") + res.status(404).json({ error: "Cohort not found" }) + return + } + const cohort = cohortResult.rows[0] + + const memberResult = await client.query( + `SELECT learner_addr FROM cohort_members WHERE cohort_id = $1`, + [cohortId], + ) + const members = memberResult.rows as Array<{ learner_addr: string }> + + if (members.some((m) => m.learner_addr === walletAddress)) { + await client.query("ROLLBACK") + res.status(200).json({ + joined: true, + already_member: true, + member_count: members.length, + }) + return + } + + if (members.length >= cohort.max_members) { + await client.query("ROLLBACK") + res.status(409).json({ error: "Cohort is full" }) + return + } + + await client.query( + `INSERT INTO cohort_members (cohort_id, learner_addr) + VALUES ($1, $2) + ON CONFLICT (cohort_id, learner_addr) DO NOTHING`, + [cohortId, walletAddress], + ) + + await client.query("COMMIT") + + res.status(200).json({ + joined: true, + already_member: false, + member_count: members.length + 1, + }) + } catch (error) { + await client.query("ROLLBACK") + throw error + } finally { + client.release() + } + } catch (error) { + log.error({ err: error }, "Error joining cohort") + res.status(500).json({ error: "Failed to join cohort" }) + } +} + +/** + * Leave a cohort. Idempotent: leaving a cohort you are not a member of + * succeeds as a no-op. + */ +export const leaveCohort = async ( + req: AuthRequest, + res: Response, +): Promise => { + try { + const walletAddress = req.walletAddress + if (!walletAddress) { + res.status(401).json({ error: "Unauthorized" }) + return + } + + const cohortId = parseInt(req.params.id, 10) + + const cohortResult = await pool.query( + `SELECT id FROM cohorts WHERE id = $1`, + [cohortId], + ) + if (cohortResult.rows.length === 0) { + res.status(404).json({ error: "Cohort not found" }) + return + } + + const result = await pool.query( + `DELETE FROM cohort_members WHERE cohort_id = $1 AND learner_addr = $2`, + [cohortId, walletAddress], + ) + + res.status(200).json({ + left: true, + was_member: (result.rowCount ?? 0) > 0, + }) + } catch (error) { + log.error({ err: error }, "Error leaving cohort") + res.status(500).json({ error: "Failed to leave cohort" }) + } +} + +/** + * Cohort detail: members with per-member approved-milestone progress + * (joined against milestone_reports) plus a group completion percentage. + * Members are ordered by milestones completed, so the payload doubles as + * the group leaderboard. + */ +export const getCohortDetail = async ( + req: AuthRequest, + res: Response, +): Promise => { + try { + const cohortId = parseInt(req.params.id, 10) + + const cohortResult = await pool.query( + `SELECT id, name, course_slug, start_date, max_members, created_by, created_at + FROM cohorts WHERE id = $1`, + [cohortId], + ) + if (cohortResult.rows.length === 0) { + res.status(404).json({ error: "Cohort not found" }) + return + } + const cohort = cohortResult.rows[0] + + const totalResult = await pool.query( + `SELECT + (SELECT COUNT(*)::int FROM milestones m + INNER JOIN courses c ON c.id = m.course_id + WHERE c.slug = $1) AS milestone_count, + (SELECT COUNT(*)::int FROM lessons l + INNER JOIN courses c ON c.id = l.course_id + WHERE c.slug = $1) AS lesson_count`, + [cohort.course_slug], + ) + const totals = totalResult.rows[0] ?? {} + // Some courses only track progress at the lesson level; fall back so + // the completion percentage stays meaningful for them. + const totalMilestones = + Number(totals.milestone_count) > 0 + ? Number(totals.milestone_count) + : Number(totals.lesson_count ?? 0) + + const membersResult = await pool.query( + `SELECT cm.learner_addr, cm.joined_at, + COUNT(mr.id) FILTER (WHERE mr.status = 'approved')::int AS milestones_completed + FROM cohort_members cm + LEFT JOIN milestone_reports mr + ON mr.scholar_address = cm.learner_addr + AND mr.course_id = $2 + WHERE cm.cohort_id = $1 + GROUP BY cm.learner_addr, cm.joined_at + ORDER BY milestones_completed DESC, cm.joined_at ASC`, + [cohortId, cohort.course_slug], + ) + + const members = membersResult.rows.map( + (row: { + learner_addr: string + joined_at: string + milestones_completed: number + }) => ({ + learner_addr: row.learner_addr, + joined_at: row.joined_at, + milestones_completed: Number(row.milestones_completed), + total_milestones: totalMilestones, + }), + ) + + const completedSum = members.reduce( + (sum, m) => sum + m.milestones_completed, + 0, + ) + const groupCompletionPct = + members.length > 0 && totalMilestones > 0 + ? Math.round((completedSum / (members.length * totalMilestones)) * 100) + : 0 + + res.status(200).json({ + ...cohort, + member_count: members.length, + total_milestones: totalMilestones, + group_completion_pct: groupCompletionPct, + members, + }) + } catch (error) { + log.error({ err: error }, "Error fetching cohort detail") + res.status(500).json({ error: "Failed to fetch cohort" }) + } +} diff --git a/server/src/db/migrations/025_study_cohorts.sql b/server/src/db/migrations/025_study_cohorts.sql new file mode 100644 index 00000000..b32740a6 --- /dev/null +++ b/server/src/db/migrations/025_study_cohorts.sql @@ -0,0 +1,25 @@ +-- ============================================================ +-- Migration 025: Study cohorts (squads) +-- Small groups of learners enrolled in the same track who share +-- a group progress view, discussion thread, and leaderboard. +-- ============================================================ + +CREATE TABLE IF NOT EXISTS cohorts ( + id SERIAL PRIMARY KEY, + name TEXT NOT NULL, + course_slug TEXT NOT NULL, + start_date DATE NOT NULL, + max_members INTEGER NOT NULL DEFAULT 8 CHECK (max_members > 0), + created_by TEXT NOT NULL, + created_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS cohort_members ( + cohort_id INTEGER NOT NULL REFERENCES cohorts(id) ON DELETE CASCADE, + learner_addr TEXT NOT NULL, + joined_at TIMESTAMPTZ DEFAULT NOW(), + PRIMARY KEY (cohort_id, learner_addr) +); + +CREATE INDEX IF NOT EXISTS idx_cohorts_course_slug ON cohorts (course_slug); +CREATE INDEX IF NOT EXISTS idx_cohort_members_learner ON cohort_members (learner_addr); diff --git a/server/src/db/migrations/025_study_cohorts.undo.sql b/server/src/db/migrations/025_study_cohorts.undo.sql new file mode 100644 index 00000000..01e066fa --- /dev/null +++ b/server/src/db/migrations/025_study_cohorts.undo.sql @@ -0,0 +1,6 @@ +-- ============================================================ +-- Undo Migration 025: Study cohorts (squads) +-- ============================================================ + +DROP TABLE IF EXISTS cohort_members; +DROP TABLE IF EXISTS cohorts; diff --git a/server/src/index.ts b/server/src/index.ts index 20a2a624..74c8009b 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -36,6 +36,7 @@ import { adminProviderKeysRouter } from "./routes/admin-provider-keys.routes" import { adminRouter } from "./routes/admin.routes" import { antiSybilRouter } from "./routes/anti-sybil.routes" import { createAuthRouter } from "./routes/auth.routes" +import { createCohortsRouter } from "./routes/cohorts.routes" import { createCommentsRouter } from "./routes/comments.routes" import { communityRouter } from "./routes/community.routes" import { coursesRouter } from "./routes/courses.routes" @@ -269,6 +270,7 @@ app.use("/api/auth", createAuthRouter(authService, jwtService)) app.use("/api", createMeRouter(jwtService)) app.use("/api", coursesRouter) app.use("/api", createEnrollmentsRouter(jwtService)) +app.use("/api", createCohortsRouter(jwtService)) app.use("/api", createScholarsRouter(jwtService)) app.use("/api", scholarshipsRouter) app.use("/api", mentorshipRouter) diff --git a/server/src/lib/zod-schemas.ts b/server/src/lib/zod-schemas.ts index f68f1f84..09249392 100644 --- a/server/src/lib/zod-schemas.ts +++ b/server/src/lib/zod-schemas.ts @@ -292,6 +292,37 @@ export const enrollmentBodySchema = z }) .strict() +export const createCohortBodySchema = z + .object({ + name: requiredString("name", 100), + course_slug: requiredString("course_slug", 100), + start_date: requiredString("start_date", 10).regex( + /^\d{4}-\d{2}-\d{2}$/, + "start_date must be in YYYY-MM-DD format", + ), + max_members: z + .number({ invalid_type_error: "max_members must be a number" }) + .int("max_members must be an integer") + .min(2, "max_members must be at least 2") + .max(100, "max_members must be 100 or fewer") + .optional(), + }) + .strict() + +export const cohortIdParamSchema = z + .object({ + id: z + .string({ required_error: "id is required" }) + .regex(/^\d+$/, "id must be a positive integer"), + }) + .strict() + +export const listCohortsQuerySchema = z + .object({ + course: optionalTrimmedString("course", 100), + }) + .strict() + export const bookmarkBodySchema = z .object({ course_id: requiredString("course_id", 100), diff --git a/server/src/routes/cohorts.routes.ts b/server/src/routes/cohorts.routes.ts new file mode 100644 index 00000000..b33bc935 --- /dev/null +++ b/server/src/routes/cohorts.routes.ts @@ -0,0 +1,169 @@ +import { Router } from "express" + +import { + createCohort, + getCohortDetail, + joinCohort, + leaveCohort, + listCohorts, +} from "../controllers/cohorts.controller" +import * as schemas from "../lib/zod-schemas" +import { createRequireAuth } from "../middleware/auth.middleware" +import { validate } from "../middleware/validation.middleware" +import { type JwtService } from "../services/jwt.service" + +export function createCohortsRouter(jwtService: JwtService): Router { + const router = Router() + const requireAuth = createRequireAuth(jwtService) + + /** + * @openapi + * /api/cohorts: + * post: + * tags: [Cohorts] + * summary: Create a study cohort (squad) for a course + * security: + * - bearerAuth: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: [name, course_slug, start_date] + * properties: + * name: + * type: string + * course_slug: + * type: string + * start_date: + * type: string + * format: date + * max_members: + * type: integer + * default: 8 + * responses: + * 201: + * description: Cohort created (creator auto-joined) + * 400: + * description: Validation error + * 401: + * description: Unauthorized + * 404: + * description: Course not found + */ + router.post( + "/cohorts", + requireAuth, + validate({ body: schemas.createCohortBodySchema }), + createCohort, + ) + + /** + * @openapi + * /api/cohorts: + * get: + * tags: [Cohorts] + * summary: List cohorts, optionally filtered by course + * parameters: + * - in: query + * name: course + * schema: + * type: string + * description: Course slug to filter by + * responses: + * 200: + * description: Cohorts with member counts + */ + router.get( + "/cohorts", + validate({ query: schemas.listCohortsQuerySchema }), + listCohorts, + ) + + /** + * @openapi + * /api/cohorts/{id}: + * get: + * tags: [Cohorts] + * summary: Cohort detail with per-member milestone progress and group completion + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: integer + * responses: + * 200: + * description: Cohort detail + * 404: + * description: Cohort not found + */ + router.get( + "/cohorts/:id", + validate({ params: schemas.cohortIdParamSchema }), + getCohortDetail, + ) + + /** + * @openapi + * /api/cohorts/{id}/join: + * post: + * tags: [Cohorts] + * summary: Join a cohort (capacity-checked, idempotent) + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: integer + * responses: + * 200: + * description: Joined (or already a member) + * 401: + * description: Unauthorized + * 404: + * description: Cohort not found + * 409: + * description: Cohort is full + */ + router.post( + "/cohorts/:id/join", + requireAuth, + validate({ params: schemas.cohortIdParamSchema }), + joinCohort, + ) + + /** + * @openapi + * /api/cohorts/{id}/leave: + * post: + * tags: [Cohorts] + * summary: Leave a cohort (idempotent) + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: integer + * responses: + * 200: + * description: Left the cohort (no-op if not a member) + * 401: + * description: Unauthorized + * 404: + * description: Cohort not found + */ + router.post( + "/cohorts/:id/leave", + requireAuth, + validate({ params: schemas.cohortIdParamSchema }), + leaveCohort, + ) + + return router +} diff --git a/server/src/tests/cohorts-api.test.ts b/server/src/tests/cohorts-api.test.ts new file mode 100644 index 00000000..a2d357c6 --- /dev/null +++ b/server/src/tests/cohorts-api.test.ts @@ -0,0 +1,413 @@ +jest.mock("../db/index", () => ({ + pool: { + query: jest.fn().mockResolvedValue({ rows: [], rowCount: 0 }), + connect: jest.fn(), + }, +})) + +import express from "express" +import request from "supertest" +import { pool } from "../db/index" +import { errorHandler } from "../middleware/error.middleware" +import { createCohortsRouter } from "../routes/cohorts.routes" +import { type JwtService } from "../services/jwt.service" + +const mockedQuery = pool.query as jest.Mock +const mockedConnect = pool.connect as jest.Mock + +const mockJwtService: JwtService = { + signWalletToken: () => "mock-token", + signRefreshToken: () => "mock-refresh-token", + issueTokenPair: () => ({ + accessToken: "mock-token", + refreshToken: "mock-refresh-token", + }), + verifyWalletToken: async () => ({ sub: "mock-address", jti: "mock-jti" }), + verifyRefreshToken: async () => ({ sub: "mock-address", jti: "mock-jti" }), + rotateRefreshToken: async () => ({ + accessToken: "mock-token", + refreshToken: "mock-refresh-token", + sub: "mock-address", + }), + revokeToken: async () => {}, +} + +function buildApp() { + const app = express() + app.use(express.json()) + app.use("/api", createCohortsRouter(mockJwtService)) + app.use(errorHandler) + return app +} + +function mockClient() { + const client = { + query: jest.fn().mockResolvedValue({ rows: [], rowCount: 0 }), + release: jest.fn(), + } + mockedConnect.mockResolvedValue(client) + return client +} + +const auth = { Authorization: "Bearer mock-token" } + +beforeEach(() => { + mockedQuery.mockReset() + mockedQuery.mockResolvedValue({ rows: [], rowCount: 0 }) + mockedConnect.mockReset() +}) + +describe("POST /api/cohorts", () => { + it("returns 401 without auth", async () => { + const res = await request(buildApp()).post("/api/cohorts").send({ + name: "Squad A", + course_slug: "stellar-basics", + start_date: "2026-08-01", + }) + + expect(res.status).toBe(401) + }) + + it("returns 400 when required fields are missing", async () => { + const res = await request(buildApp()) + .post("/api/cohorts") + .set(auth) + .send({ name: "Squad A" }) + + expect(res.status).toBe(400) + }) + + it("returns 400 for an invalid start_date", async () => { + const res = await request(buildApp()).post("/api/cohorts").set(auth).send({ + name: "Squad A", + course_slug: "stellar-basics", + start_date: "next tuesday", + }) + + expect(res.status).toBe(400) + }) + + it("returns 404 when the course does not exist", async () => { + mockedQuery.mockResolvedValueOnce({ rows: [], rowCount: 0 }) + + const res = await request(buildApp()).post("/api/cohorts").set(auth).send({ + name: "Squad A", + course_slug: "no-such-course", + start_date: "2026-08-01", + }) + + expect(res.status).toBe(404) + expect(res.body.error).toBe("Course not found") + }) + + it("creates a cohort and auto-joins the creator", async () => { + mockedQuery.mockResolvedValueOnce({ + rows: [{ slug: "stellar-basics" }], + rowCount: 1, + }) + const client = mockClient() + client.query.mockImplementation((sql: string) => { + if (sql.includes("INSERT INTO cohorts")) { + return Promise.resolve({ + rows: [ + { + id: 7, + name: "Squad A", + course_slug: "stellar-basics", + start_date: "2026-08-01", + max_members: 8, + created_by: "mock-address", + created_at: "2026-07-17T00:00:00Z", + }, + ], + rowCount: 1, + }) + } + return Promise.resolve({ rows: [], rowCount: 0 }) + }) + + const res = await request(buildApp()).post("/api/cohorts").set(auth).send({ + name: "Squad A", + course_slug: "stellar-basics", + start_date: "2026-08-01", + }) + + expect(res.status).toBe(201) + expect(res.body.id).toBe(7) + expect(res.body.member_count).toBe(1) + + const memberInsert = client.query.mock.calls.find(([sql]) => + String(sql).includes("INSERT INTO cohort_members"), + ) + expect(memberInsert).toBeDefined() + expect(memberInsert?.[1]).toEqual([7, "mock-address"]) + expect(client.query).toHaveBeenCalledWith("COMMIT") + }) +}) + +describe("GET /api/cohorts", () => { + it("lists cohorts for a course with member counts", async () => { + mockedQuery.mockResolvedValueOnce({ + rows: [ + { + id: 7, + name: "Squad A", + course_slug: "stellar-basics", + start_date: "2026-08-01", + max_members: 8, + created_by: "GABC", + created_at: "2026-07-17T00:00:00Z", + member_count: 3, + }, + ], + rowCount: 1, + }) + + const res = await request(buildApp()).get( + "/api/cohorts?course=stellar-basics", + ) + + expect(res.status).toBe(200) + expect(res.body.data).toHaveLength(1) + expect(res.body.data[0].member_count).toBe(3) + expect(mockedQuery.mock.calls[0][1]).toEqual(["stellar-basics"]) + }) +}) + +describe("POST /api/cohorts/:id/join", () => { + it("returns 404 for an unknown cohort", async () => { + mockClient() + + const res = await request(buildApp()).post("/api/cohorts/99/join").set(auth) + + expect(res.status).toBe(404) + }) + + it("rejects joining a full cohort with 409", async () => { + const client = mockClient() + client.query.mockImplementation((sql: string) => { + if (String(sql).includes("FROM cohorts")) { + return Promise.resolve({ + rows: [{ id: 7, max_members: 2 }], + rowCount: 1, + }) + } + if (String(sql).includes("FROM cohort_members")) { + return Promise.resolve({ + rows: [{ learner_addr: "GAAA" }, { learner_addr: "GBBB" }], + rowCount: 2, + }) + } + return Promise.resolve({ rows: [], rowCount: 0 }) + }) + + const res = await request(buildApp()).post("/api/cohorts/7/join").set(auth) + + expect(res.status).toBe(409) + expect(res.body.error).toBe("Cohort is full") + + const memberInsert = client.query.mock.calls.find(([sql]) => + String(sql).includes("INSERT INTO cohort_members"), + ) + expect(memberInsert).toBeUndefined() + }) + + it("joins successfully when there is capacity", async () => { + const client = mockClient() + client.query.mockImplementation((sql: string) => { + if (String(sql).includes("FROM cohorts")) { + return Promise.resolve({ + rows: [{ id: 7, max_members: 8 }], + rowCount: 1, + }) + } + if (String(sql).includes("FROM cohort_members")) { + return Promise.resolve({ + rows: [{ learner_addr: "GAAA" }], + rowCount: 1, + }) + } + return Promise.resolve({ rows: [], rowCount: 0 }) + }) + + const res = await request(buildApp()).post("/api/cohorts/7/join").set(auth) + + expect(res.status).toBe(200) + expect(res.body).toMatchObject({ + joined: true, + already_member: false, + member_count: 2, + }) + expect(client.query).toHaveBeenCalledWith("COMMIT") + }) + + it("is idempotent when already a member", async () => { + const client = mockClient() + client.query.mockImplementation((sql: string) => { + if (String(sql).includes("FROM cohorts")) { + return Promise.resolve({ + rows: [{ id: 7, max_members: 2 }], + rowCount: 1, + }) + } + if (String(sql).includes("FROM cohort_members")) { + // Cohort is full, but the caller is one of the members + return Promise.resolve({ + rows: [{ learner_addr: "mock-address" }, { learner_addr: "GBBB" }], + rowCount: 2, + }) + } + return Promise.resolve({ rows: [], rowCount: 0 }) + }) + + const res = await request(buildApp()).post("/api/cohorts/7/join").set(auth) + + expect(res.status).toBe(200) + expect(res.body).toMatchObject({ joined: true, already_member: true }) + + const memberInsert = client.query.mock.calls.find(([sql]) => + String(sql).includes("INSERT INTO cohort_members"), + ) + expect(memberInsert).toBeUndefined() + }) +}) + +describe("POST /api/cohorts/:id/leave", () => { + it("returns 404 for an unknown cohort", async () => { + mockedQuery.mockResolvedValueOnce({ rows: [], rowCount: 0 }) + + const res = await request(buildApp()) + .post("/api/cohorts/99/leave") + .set(auth) + + expect(res.status).toBe(404) + }) + + it("removes membership when the caller is a member", async () => { + mockedQuery + .mockResolvedValueOnce({ rows: [{ id: 7 }], rowCount: 1 }) + .mockResolvedValueOnce({ rows: [], rowCount: 1 }) + + const res = await request(buildApp()).post("/api/cohorts/7/leave").set(auth) + + expect(res.status).toBe(200) + expect(res.body).toMatchObject({ left: true, was_member: true }) + }) + + it("is idempotent when the caller is not a member", async () => { + mockedQuery + .mockResolvedValueOnce({ rows: [{ id: 7 }], rowCount: 1 }) + .mockResolvedValueOnce({ rows: [], rowCount: 0 }) + + const res = await request(buildApp()).post("/api/cohorts/7/leave").set(auth) + + expect(res.status).toBe(200) + expect(res.body).toMatchObject({ left: true, was_member: false }) + }) +}) + +describe("GET /api/cohorts/:id", () => { + it("returns 404 for an unknown cohort", async () => { + mockedQuery.mockResolvedValueOnce({ rows: [], rowCount: 0 }) + + const res = await request(buildApp()).get("/api/cohorts/99") + + expect(res.status).toBe(404) + }) + + it("returns members with progress and aggregates group completion", async () => { + mockedQuery + // 1. cohort lookup + .mockResolvedValueOnce({ + rows: [ + { + id: 7, + name: "Squad A", + course_slug: "stellar-basics", + start_date: "2026-08-01", + max_members: 8, + created_by: "GAAA", + created_at: "2026-07-17T00:00:00Z", + }, + ], + rowCount: 1, + }) + // 2. course totals: 5 milestones + .mockResolvedValueOnce({ + rows: [{ milestone_count: 5, lesson_count: 10 }], + rowCount: 1, + }) + // 3. members with per-member approved milestone counts + .mockResolvedValueOnce({ + rows: [ + { + learner_addr: "GAAA", + joined_at: "2026-07-17T00:00:00Z", + milestones_completed: 5, + }, + { + learner_addr: "GBBB", + joined_at: "2026-07-17T01:00:00Z", + milestones_completed: 3, + }, + { + learner_addr: "GCCC", + joined_at: "2026-07-17T02:00:00Z", + milestones_completed: 0, + }, + ], + rowCount: 3, + }) + + const res = await request(buildApp()).get("/api/cohorts/7") + + expect(res.status).toBe(200) + expect(res.body.member_count).toBe(3) + expect(res.body.total_milestones).toBe(5) + // (5 + 3 + 0) / (3 members * 5 milestones) = 53.33% -> 53 + expect(res.body.group_completion_pct).toBe(53) + expect(res.body.members[0]).toMatchObject({ + learner_addr: "GAAA", + milestones_completed: 5, + total_milestones: 5, + }) + }) + + it("falls back to lesson count when a course has no milestone rows", async () => { + mockedQuery + .mockResolvedValueOnce({ + rows: [ + { + id: 7, + name: "Squad A", + course_slug: "stellar-basics", + start_date: "2026-08-01", + max_members: 8, + created_by: "GAAA", + created_at: "2026-07-17T00:00:00Z", + }, + ], + rowCount: 1, + }) + .mockResolvedValueOnce({ + rows: [{ milestone_count: 0, lesson_count: 4 }], + rowCount: 1, + }) + .mockResolvedValueOnce({ + rows: [ + { + learner_addr: "GAAA", + joined_at: "2026-07-17T00:00:00Z", + milestones_completed: 2, + }, + ], + rowCount: 1, + }) + + const res = await request(buildApp()).get("/api/cohorts/7") + + expect(res.status).toBe(200) + expect(res.body.total_milestones).toBe(4) + expect(res.body.group_completion_pct).toBe(50) + }) +}) diff --git a/src/components/cohorts/CohortDetailView.test.tsx b/src/components/cohorts/CohortDetailView.test.tsx new file mode 100644 index 00000000..1f1e773c --- /dev/null +++ b/src/components/cohorts/CohortDetailView.test.tsx @@ -0,0 +1,154 @@ +import { render, screen } from "@testing-library/react" +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest" +import { type CohortDetail } from "../../hooks/useCohorts" +import CohortDetailView from "./CohortDetailView" + +beforeAll(() => { + window.matchMedia = vi.fn().mockImplementation((query: string) => ({ + matches: false, + media: query, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + })) as unknown as typeof window.matchMedia +}) + +const mockUseCohortDetail = vi.fn() +const mockJoinMutate = vi.fn() +const mockLeaveMutate = vi.fn() + +vi.mock("../../hooks/useCohorts", () => ({ + useCohortDetail: (id: number | null) => mockUseCohortDetail(id) as unknown, + useJoinCohort: () => ({ + mutate: mockJoinMutate, + isPending: false, + error: null, + }), + useLeaveCohort: () => ({ + mutate: mockLeaveMutate, + isPending: false, + error: null, + }), +})) + +vi.mock("../../hooks/useWallet", () => ({ + useWallet: () => ({ address: "GMEMBER" }), +})) + +vi.mock("../AddressDisplay", () => ({ + AddressDisplay: ({ address }: { address?: string | null }) => ( + {address} + ), +})) + +vi.mock("../CommentSection", () => ({ + default: ({ proposalId }: { proposalId: string }) => ( +
{proposalId}
+ ), +})) + +const baseCohort: CohortDetail = { + id: 7, + name: "Night Owls", + course_slug: "stellar-basics", + start_date: "2026-08-01", + max_members: 3, + created_by: "GMEMBER", + created_at: "2026-07-17T00:00:00Z", + member_count: 2, + total_milestones: 5, + group_completion_pct: 53, + members: [ + { + learner_addr: "GLEADER", + joined_at: "2026-07-17T00:00:00Z", + milestones_completed: 5, + total_milestones: 5, + }, + { + learner_addr: "GMEMBER", + joined_at: "2026-07-17T01:00:00Z", + milestones_completed: 3, + total_milestones: 5, + }, + ], +} + +function mockDetail(overrides: Partial = {}) { + mockUseCohortDetail.mockReturnValue({ + data: { ...baseCohort, ...overrides }, + isLoading: false, + error: null, + refetch: vi.fn(), + }) +} + +beforeEach(() => { + vi.clearAllMocks() +}) + +describe("CohortDetailView", () => { + it("renders the group completion ring and member roster", () => { + mockDetail() + + render( {}} />) + + expect(screen.getByText("Night Owls")).toBeInTheDocument() + expect( + screen.getByRole("img", { name: "Group completion: 53%" }), + ).toBeInTheDocument() + expect(screen.getByText("2/3 members")).toBeInTheDocument() + expect(screen.getByText("GLEADER")).toBeInTheDocument() + expect(screen.getByText("GMEMBER")).toBeInTheDocument() + }) + + it("marks the current wallet in the leaderboard and shows leave action for members", () => { + mockDetail() + + render( {}} />) + + expect(screen.getByText("You")).toBeInTheDocument() + expect( + screen.getByRole("button", { name: "Leave Squad" }), + ).toBeInTheDocument() + }) + + it("scopes the discussion thread to the cohort for members", () => { + mockDetail() + + render( {}} />) + + expect(screen.getByTestId("comment-section")).toHaveTextContent("cohort-7") + }) + + it("disables joining when the cohort is full and hides discussion for non-members", () => { + mockDetail({ + member_count: 3, + members: [ + { + learner_addr: "GAAA", + joined_at: "2026-07-17T00:00:00Z", + milestones_completed: 1, + total_milestones: 5, + }, + { + learner_addr: "GBBB", + joined_at: "2026-07-17T01:00:00Z", + milestones_completed: 1, + total_milestones: 5, + }, + { + learner_addr: "GCCC", + joined_at: "2026-07-17T02:00:00Z", + milestones_completed: 0, + total_milestones: 5, + }, + ], + }) + + render( {}} />) + + const fullButton = screen.getByRole("button", { name: "Squad Full" }) + expect(fullButton).toBeDisabled() + expect(screen.queryByTestId("comment-section")).not.toBeInTheDocument() + }) +}) diff --git a/src/components/cohorts/CohortDetailView.tsx b/src/components/cohorts/CohortDetailView.tsx new file mode 100644 index 00000000..0a9dc135 --- /dev/null +++ b/src/components/cohorts/CohortDetailView.tsx @@ -0,0 +1,232 @@ +import { ArrowLeft, Crown, Users } from "lucide-react" +import React from "react" +import { + useCohortDetail, + useJoinCohort, + useLeaveCohort, +} from "../../hooks/useCohorts" +import { useWallet } from "../../hooks/useWallet" +import { AddressDisplay } from "../AddressDisplay" +import CommentSection from "../CommentSection" +import CourseProgressBar from "../CourseProgressBar" +import { ErrorState } from "../states/errorState" + +interface CohortDetailViewProps { + cohortId: number + onBack: () => void +} + +const RING_RADIUS = 52 +const RING_CIRCUMFERENCE = 2 * Math.PI * RING_RADIUS + +function GroupCompletionRing({ pct }: { pct: number }) { + const clamped = Math.max(0, Math.min(100, pct)) + const offset = RING_CIRCUMFERENCE * (1 - clamped / 100) + + return ( +
+ + + = 100 ? "#34d399" : "#22d3ee"} + strokeWidth="10" + strokeLinecap="round" + strokeDasharray={RING_CIRCUMFERENCE} + strokeDashoffset={offset} + style={{ transition: "stroke-dashoffset 0.7s ease" }} + /> + +
+ + {clamped}% + + + Group + +
+
+ ) +} + +export const CohortDetailView: React.FC = ({ + cohortId, + onBack, +}) => { + const { address } = useWallet() + const { data: cohort, isLoading, error, refetch } = useCohortDetail(cohortId) + const joinCohort = useJoinCohort() + const leaveCohort = useLeaveCohort() + + if (isLoading) { + return ( +
+
+
+
+
+ ) + } + + if (error || !cohort) { + return ( + void refetch()} + /> + ) + } + + const isMember = Boolean( + address && cohort.members.some((m) => m.learner_addr === address), + ) + const isFull = cohort.member_count >= cohort.max_members + const actionPending = joinCohort.isPending || leaveCohort.isPending + + return ( +
+ + +
+
+ +
+

+ {cohort.name} +

+

+

+

+ Starts{" "} + {new Date(cohort.start_date).toLocaleDateString(undefined, { + year: "numeric", + month: "long", + day: "numeric", + })} +

+
+
+ {isMember ? ( + + ) : ( + + )} +
+
+ {(joinCohort.error || leaveCohort.error) && ( +

+ {joinCohort.error instanceof Error + ? joinCohort.error.message + : leaveCohort.error instanceof Error + ? leaveCohort.error.message + : "Something went wrong"} +

+ )} +
+ +
+

+ Leaderboard +

+
    + {cohort.members.map((member, index) => ( +
  1. + + {index === 0 ? ( + + ) : ( + index + 1 + )} + +
    +
    + + {member.learner_addr === address && ( + + You + + )} +
    + +
    +
  2. + ))} +
+
+ + {isMember && ( +
+

+ Squad Discussion +

+ +
+ )} +
+ ) +} + +export default CohortDetailView diff --git a/src/components/cohorts/SquadsPanel.tsx b/src/components/cohorts/SquadsPanel.tsx new file mode 100644 index 00000000..ac9d07d6 --- /dev/null +++ b/src/components/cohorts/SquadsPanel.tsx @@ -0,0 +1,261 @@ +import { Users } from "lucide-react" +import React, { useState } from "react" +import { + useCohorts, + useCreateCohort, + useJoinCohort, + type CohortSummary, +} from "../../hooks/useCohorts" +import { useWallet } from "../../hooks/useWallet" +import { EmptyState } from "../states/emptyState" +import { ErrorState } from "../states/errorState" +import CohortDetailView from "./CohortDetailView" + +interface SquadsPanelProps { + courseSlug: string +} + +const DEFAULT_MAX_MEMBERS = 8 + +function CreateSquadForm({ + courseSlug, + onCreated, +}: { + courseSlug: string + onCreated: (cohortId: number) => void +}) { + const createCohort = useCreateCohort() + const [name, setName] = useState("") + const [startDate, setStartDate] = useState("") + const [maxMembers, setMaxMembers] = useState(DEFAULT_MAX_MEMBERS) + + const handleSubmit = (event: React.FormEvent) => { + event.preventDefault() + if (!name.trim() || !startDate) return + createCohort.mutate( + { + name: name.trim(), + course_slug: courseSlug, + start_date: startDate, + max_members: maxMembers, + }, + { + onSuccess: (cohort) => { + setName("") + setStartDate("") + setMaxMembers(DEFAULT_MAX_MEMBERS) + onCreated(cohort.id) + }, + }, + ) + } + + const inputCls = + "w-full px-4 py-2.5 rounded-xl bg-white/[0.04] border border-white/10 text-white text-sm placeholder:text-white/30 focus:outline-none focus:border-brand-cyan/50" + + return ( +
+

+ Start a New Squad +

+
+ + + +
+ {createCohort.error && ( +

+ {createCohort.error instanceof Error + ? createCohort.error.message + : "Failed to create squad"} +

+ )} + +
+ ) +} + +function SquadCard({ + cohort, + onOpen, + onJoin, + isMemberActionPending, + canJoin, +}: { + cohort: CohortSummary + onOpen: () => void + onJoin: () => void + isMemberActionPending: boolean + canJoin: boolean +}) { + const isFull = cohort.member_count >= cohort.max_members + + return ( +
+
+
+

+ {cohort.name} +

+

+ Starts{" "} + {new Date(cohort.start_date).toLocaleDateString(undefined, { + month: "short", + day: "numeric", + year: "numeric", + })} +

+
+ + +
+
+ + +
+
+ ) +} + +export const SquadsPanel: React.FC = ({ courseSlug }) => { + const { address } = useWallet() + const { data: cohorts, isLoading, error, refetch } = useCohorts(courseSlug) + const joinCohort = useJoinCohort() + const [selectedCohortId, setSelectedCohortId] = useState(null) + + if (selectedCohortId != null) { + return ( + setSelectedCohortId(null)} + /> + ) + } + + return ( +
+ + + {joinCohort.error && ( +

+ {joinCohort.error instanceof Error + ? joinCohort.error.message + : "Failed to join squad"} +

+ )} + + {isLoading ? ( +
+ {[1, 2].map((i) => ( +
+ ))} +
+ ) : error ? ( + void refetch()} + /> + ) : !cohorts || cohorts.length === 0 ? ( + + ) : ( +
+ {cohorts.map((cohort) => ( + setSelectedCohortId(cohort.id)} + onJoin={() => + joinCohort.mutate(cohort.id, { + onSuccess: () => setSelectedCohortId(cohort.id), + }) + } + /> + ))} +
+ )} +
+ ) +} + +export default SquadsPanel diff --git a/src/hooks/useCohorts.ts b/src/hooks/useCohorts.ts new file mode 100644 index 00000000..01f65df6 --- /dev/null +++ b/src/hooks/useCohorts.ts @@ -0,0 +1,104 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" +import { apiFetchJson } from "../lib/api" + +export interface CohortSummary { + id: number + name: string + course_slug: string + start_date: string + max_members: number + created_by: string + created_at: string + member_count: number +} + +export interface CohortMember { + learner_addr: string + joined_at: string + milestones_completed: number + total_milestones: number +} + +export interface CohortDetail extends CohortSummary { + total_milestones: number + group_completion_pct: number + members: CohortMember[] +} + +export interface CreateCohortInput { + name: string + course_slug: string + start_date: string + max_members?: number +} + +export function useCohorts(courseSlug?: string) { + return useQuery({ + queryKey: ["cohorts", courseSlug], + queryFn: async () => { + const params = courseSlug + ? `?course=${encodeURIComponent(courseSlug)}` + : "" + const response = await apiFetchJson<{ data: CohortSummary[] }>( + `/api/cohorts${params}`, + ) + return response.data ?? [] + }, + }) +} + +export function useCohortDetail(cohortId: number | null) { + return useQuery({ + queryKey: ["cohort", cohortId], + queryFn: () => apiFetchJson(`/api/cohorts/${cohortId}`), + enabled: cohortId != null, + }) +} + +function useInvalidateCohorts() { + const queryClient = useQueryClient() + return (cohortId?: number) => { + void queryClient.invalidateQueries({ queryKey: ["cohorts"] }) + if (cohortId != null) { + void queryClient.invalidateQueries({ queryKey: ["cohort", cohortId] }) + } + } +} + +export function useCreateCohort() { + const invalidate = useInvalidateCohorts() + return useMutation({ + mutationFn: (input: CreateCohortInput) => + apiFetchJson("/api/cohorts", { + method: "POST", + auth: true, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(input), + }), + onSuccess: () => invalidate(), + }) +} + +export function useJoinCohort() { + const invalidate = useInvalidateCohorts() + return useMutation({ + mutationFn: (cohortId: number) => + apiFetchJson<{ joined: boolean; member_count: number }>( + `/api/cohorts/${cohortId}/join`, + { method: "POST", auth: true }, + ), + onSuccess: (_data, cohortId) => invalidate(cohortId), + }) +} + +export function useLeaveCohort() { + const invalidate = useInvalidateCohorts() + return useMutation({ + mutationFn: (cohortId: number) => + apiFetchJson<{ left: boolean }>(`/api/cohorts/${cohortId}/leave`, { + method: "POST", + auth: true, + }), + onSuccess: (_data, cohortId) => invalidate(cohortId), + }) +} diff --git a/src/pages/LessonView.tsx b/src/pages/LessonView.tsx index e4e8d52e..b13172f1 100644 --- a/src/pages/LessonView.tsx +++ b/src/pages/LessonView.tsx @@ -1,6 +1,7 @@ import { Button } from "@stellar/design-system" import React, { useEffect, useMemo, useState } from "react" import { Link, useParams } from "react-router-dom" +import SquadsPanel from "../components/cohorts/SquadsPanel" import CourseReviewsPanel from "../components/CourseReviewsPanel" import { CourseForum } from "../components/forum/CourseForum" import LessonContent from "../components/LessonContent" @@ -266,7 +267,11 @@ const LessonView: React.FC = () => {

- {currentTab === "forum" ? "Community Forum" : lesson.title} + {currentTab === "forum" + ? "Community Forum" + : currentTab === "squads" + ? "Study Squads" + : lesson.title}

@@ -334,6 +339,16 @@ const LessonView: React.FC = () => { > Forum +
@@ -409,6 +424,8 @@ const LessonView: React.FC = () => {
+ ) : currentTab === "squads" ? ( + ) : ( { /> )} - {lesson?.isMilestone && !isLoadingCourse && !isLoadingContent && ( -
- -
- )} - {course && currentTab !== "forum" && ( + {lesson?.isMilestone && + currentTab !== "squads" && + !isLoadingCourse && + !isLoadingContent && ( +
+ +
+ )} + {course && currentTab !== "forum" && currentTab !== "squads" && (