Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 46 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,16 +76,30 @@ app/ # Next.js App Router
│ ├── roster/ # Roster management
│ ├── calendar/ # Event calendar views
│ ├── events/ # Event creation and details
│ ├── schedules/ # Game schedule management
│ ├── venues/ # Venue CRUD
│ ├── league/ # League management (if applicable)
│ ├── practice-planner/ # Practice session planning with rink board
│ ├── dashboard/ # Main dashboard view
│ └── admin/ # Admin-only features
├── api/ # API routes (minimal - prefer Server Actions)
│ ├── auth/[...nextauth]/ # Auth.js endpoints
│ ├── cron/ # Scheduled jobs (RSVP reminders)
│ └── invitations/ # Invitation acceptance webhooks
│ ├── cron/ # Scheduled jobs (RSVP reminders, notification batches)
│ ├── invitations/ # Invitation acceptance webhooks
│ ├── leagues/ # League API (team listing)
│ └── roster/export/ # CSV roster export (GET endpoint — file download, not a mutation)
└── docs/ # Documentation pages

components/ # React components
├── features/ # Feature-specific components (grouped by domain)
│ ├── roster/ # RosterList, PlayerCard, AddPlayerDialog, TeamOfficialCard
│ ├── dashboard/ # DashboardNav, DashboardSidebar
│ ├── events/ # EventForm, EventCard, RSVPButton
│ ├── practice-planner/ # RinkBoard, DrawingToolbar, PlayEditor, PlayLibrary
│ └── navigation/ # MobileNavigation, Breadcrumbs
├── ui/ # Generic reusable UI primitives
└── providers/ # Context providers (LeagueProvider, ThemeProvider, etc.)

lib/ # Core application logic
├── actions/ # Server Actions (primary mutation method)
│ ├── auth.ts # Signup, login (no raw queries, uses Prisma)
Expand All @@ -95,14 +109,19 @@ lib/ # Core application logic
│ ├── rsvp.ts # Attendance tracking
│ ├── invitations.ts # Email invitations
│ ├── league.ts # League management
│ ├── league-context.ts # League context resolution helpers
│ ├── team-context.ts # Team context resolution helpers
│ ├── communication.ts # Messaging system
│ ├── notifications.ts # Notification preferences
│ ├── permissions.ts # Permission checks
│ ├── admin.ts # Admin operations
│ ├── audit.ts # Audit logging
│ ├── logout.ts # Logout action
│ ├── plays.ts # Play/drill management (practice planner)
│ └── practice-sessions.ts # Practice session management
│ ├── practice-sessions.ts # Practice session management
│ ├── practice-session-queries.ts # Read-only practice session queries
│ ├── game-schedules.ts # Game schedule CRUD
│ └── venues.ts # Venue management
├── auth/ # Authentication utilities
│ ├── config.ts # Auth.js configuration
│ └── session.ts # Session helpers (requireAuth, requireTeamAdmin, etc.)
Expand All @@ -116,10 +135,29 @@ lib/ # Core application logic
│ ├── date.ts # Date formatting
│ ├── permissions.ts # Permission utilities
│ ├── security.ts # Security utilities
│ ├── sanitization.ts # Input sanitization helpers
│ ├── rate-limit.ts # Rate limiting
│ └── error-handling.ts # Error utilities
│ ├── error-handling.ts # Error utilities
│ ├── csv.ts # CSV generation primitives
│ ├── csv-export.ts # Roster/data export helpers
│ ├── league-mode.ts # League vs standalone team mode detection
│ └── data-migration.ts # Data migration utilities
└── hooks/ # React hooks for Client Components

types/ # Shared TypeScript types
├── roster.ts # Player, TeamOfficial, export types
├── events.ts # Event and RSVP types
├── practice-planner.ts # Practice session and play types
├── auth.ts # Session and auth types
└── invitations.ts # Invitation types

specs/ # SpecKit feature specifications
└── <feature-name>/ # One folder per feature
├── spec.md # Feature specification
├── plan.md # Implementation plan
├── tasks.md # Task breakdown
└── ...

prisma/
├── schema.prisma # Database schema (single source of truth)
└── migrations/ # Migration history
Expand Down Expand Up @@ -409,6 +447,10 @@ describe('Feature Name', () => {
- React components (Testing Library)
- Integration tests for critical flows

**Pre-existing test failures** (not regressions — do not attempt to fix):
- `theme-marketing.test.ts` — marketing theme variant tests
- `DragDropTeams.test.tsx` — DnD context issues

### Common Gotchas

1. **Always use Server Actions, not API routes** - API routes only for webhooks/cron
Expand Down
174 changes: 174 additions & 0 deletions __tests__/api/roster/export.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
import { describe, it, expect, vi, beforeEach } from "vitest";

// Mock auth helpers
const mockRequireUserId = vi.fn();
const mockRequireTeamAdmin = vi.fn();
vi.mock("@/lib/auth/session", () => ({
requireUserId: (...args: unknown[]) => mockRequireUserId(...args),
requireTeamAdmin: (...args: unknown[]) => mockRequireTeamAdmin(...args),
}));

// Mock Prisma
const mockPrismaTeam = { findUnique: vi.fn() };
const mockPrismaPlayer = { findMany: vi.fn() };
const mockPrismaTeamMember = { findMany: vi.fn() };
vi.mock("@/lib/db/prisma", () => ({
prisma: {
team: mockPrismaTeam,
player: mockPrismaPlayer,
teamMember: mockPrismaTeamMember,
},
}));

import { GET } from "@/app/api/roster/export/route";

const TEAM_ID = "clxxxxxxxxxxxxxxxxxxxxxxxxx";

function makeRequest(teamId?: string): Request {
const url = teamId
? `http://localhost:3000/api/roster/export?teamId=${teamId}`
: "http://localhost:3000/api/roster/export";
return new Request(url, { method: "GET" });
}

beforeEach(() => {
vi.clearAllMocks();
mockRequireUserId.mockResolvedValue("user-123");
mockRequireTeamAdmin.mockResolvedValue("user-123");
});

describe("GET /api/roster/export", () => {
it("returns 400 when teamId is missing", async () => {
const response = await GET(makeRequest());
expect(response.status).toBe(400);
});

it("returns 403 when user is not admin", async () => {
mockRequireTeamAdmin.mockRejectedValue(
new Error("Unauthorized: Only team admins can perform this action")
);

const response = await GET(makeRequest(TEAM_ID));
expect(response.status).toBe(403);
});

it("returns 401 when user is not authenticated", async () => {
mockRequireUserId.mockRejectedValue(new Error("NEXT_REDIRECT"));

const response = await GET(makeRequest(TEAM_ID));
expect(response.status).toBe(401);
});

it("returns 404 when team not found", async () => {
mockPrismaTeam.findUnique.mockResolvedValue(null);

const response = await GET(makeRequest(TEAM_ID));
expect(response.status).toBe(404);
});

it("returns valid CSV with header row only for empty roster", async () => {
mockPrismaTeam.findUnique.mockResolvedValue({ name: "Test Team" });
mockPrismaPlayer.findMany.mockResolvedValue([]);
mockPrismaTeamMember.findMany.mockResolvedValue([]);

const response = await GET(makeRequest(TEAM_ID));
expect(response.status).toBe(200);
expect(response.headers.get("Content-Type")).toBe(
"text/csv; charset=utf-8"
);

const body = await response.text();
// Should have BOM + header row only
expect(body.charCodeAt(0)).toBe(0xfeff);
const lines = body.slice(1).split("\r\n");
expect(lines).toHaveLength(1);
expect(lines[0]).toContain("Name");
expect(lines[0]).toContain("USA Hockey Member ID");
});

it("quotes player name containing comma in CSV output", async () => {
mockPrismaTeam.findUnique.mockResolvedValue({ name: "Test Team" });
mockPrismaPlayer.findMany.mockResolvedValue([
{
name: "Smith, John",
email: "john@test.com",
phone: null,
jerseyNumber: 7,
usahMemberId: null,
emergencyContact: null,
emergencyPhone: null,
},
]);
mockPrismaTeamMember.findMany.mockResolvedValue([]);

const response = await GET(makeRequest(TEAM_ID));
const body = await response.text();
// The name should be double-quoted per RFC 4180
expect(body).toContain('"Smith, John"');
});

it("escapes player name containing double-quote in CSV output", async () => {
mockPrismaTeam.findUnique.mockResolvedValue({ name: "Test Team" });
mockPrismaPlayer.findMany.mockResolvedValue([
{
name: 'O"Brien',
email: null,
phone: null,
jerseyNumber: null,
usahMemberId: null,
emergencyContact: null,
emergencyPhone: null,
},
]);
mockPrismaTeamMember.findMany.mockResolvedValue([]);

const response = await GET(makeRequest(TEAM_ID));
const body = await response.text();
// Double-quote should be escaped as ""
expect(body).toContain('"O""Brien"');
});

it("places officials before players in output", async () => {
mockPrismaTeam.findUnique.mockResolvedValue({ name: "Test Team" });
mockPrismaPlayer.findMany.mockResolvedValue([
{
name: "Alice Player",
email: null,
phone: null,
jerseyNumber: 7,
usahMemberId: null,
emergencyContact: null,
emergencyPhone: null,
},
]);
mockPrismaTeamMember.findMany.mockResolvedValue([
{
role: "ADMIN",
usahMemberId: "COACH1",
user: { name: "Coach Bob", email: "bob@test.com" },
},
]);

const response = await GET(makeRequest(TEAM_ID));
const body = await response.text();
const lines = body.slice(1).split("\r\n");
// Line 0: header, Line 1: official, Line 2: player
expect(lines[1]).toContain("Team Official");
expect(lines[1]).toContain("Coach Bob");
expect(lines[2]).toContain("Player");
expect(lines[2]).toContain("Alice Player");
});

it("sets Content-Disposition header with team name slug", async () => {
mockPrismaTeam.findUnique.mockResolvedValue({
name: "My Awesome Team!",
});
mockPrismaPlayer.findMany.mockResolvedValue([]);
mockPrismaTeamMember.findMany.mockResolvedValue([]);

const response = await GET(makeRequest(TEAM_ID));
const disposition = response.headers.get("Content-Disposition");
expect(disposition).toContain("my-awesome-team");
expect(disposition).toContain(".csv");
});
});
Loading