SquadPact
Scheduling, RSVP, chat, and payments app for adult soccer teams. It pulls schedules, rosters, and results from the GSSL and Rats league sites, so managers stop copying them by hand. 30 Prisma models, 114 API handlers, and one codebase for web, iOS, and Android.
Highlights
- Scrapes GSSL and Rats league sites to auto-fill schedules, opponents, rosters, and results. Managers review a diff instead of copying data by hand.
- 30-model Prisma schema covering leagues, seasons, teams, roster memberships, events, RSVPs, team and direct chat, payments, and a player marketplace.
- 114 API route handlers across 81 route files. Business logic lives in a service layer, so routes and pages stay thin.
- A scheduled job moves each team through commitment, recruiting, payment collection, and active phases as deadlines pass.
- Push notifications carry HMAC-signed action tokens, so a player can RSVP straight from the notification without opening the app.
- Composite-key upserts (`eventId_userId`) enforce one RSVP per user per event in the database.
- One TypeScript codebase ships to web (Next.js on Vercel), iOS, and Android via Capacitor. Built under Veltarium Software LLC.
Stack
Problem
Volunteer managers of adult soccer teams in the GSSL and Rats leagues spend hours every week on admin. Game times, opponent info, and roster changes all live on the league websites, but those sites are read-only. So each week a manager opens the league site, copies the schedule into a group chat, texts the roster to ask who's coming, chases the people who don't answer, and posts the final lineup. The data already exists. The managers are copying it to their team by hand.
Approach
Treat the league sites as the source of truth and build a scraper per league (GSSL, Rats) that turns a public team page into a structured schedule and roster. Every league module has the same interface, so adding a league means adding a module:
League scraper contract: one module per league, same shape23 linestypescript
export interface LeagueScraper {
leagueKey: "gssl" | "rats";
fetchSchedule(teamUrl: string): Promise<ScrapedEvent[]>;
fetchRoster(teamUrl: string): Promise<ScrapedPlayer[]>;
}
export interface ScrapedEvent {
leagueEventId: string; // stable id we dedupe on
kickoffAt: Date;
opponentName: string;
location: string;
}
// Manager-triggered sync: scrape, diff against what's in Postgres,
// show the manager the diff, apply on confirmation. No silent writes.
export async function syncTeam(team: Team, scraper: LeagueScraper) {
const [events, roster] = await Promise.all([
scraper.fetchSchedule(team.leagueUrl),
scraper.fetchRoster(team.leagueUrl),
]);
const diff = await computeDiff(team.id, events, roster);
return { diff, apply: () => persistDiff(team.id, diff) };
}Scrapers run on a schedule and when a manager taps sync. A league-site event becomes an Event row in Postgres, an opponent becomes a Team, and every roster member gets a default RSVP. The database enforces one RSVP per user per event: a compound unique index on (eventId, userId) plus a composite-key upsert means clicking "Going" twice writes the same row, and switching between "Going" and "Maybe" updates that row.
One-RSVP-per-user-per-event: composite-key upsert23 linestypescript
// prisma/schema.prisma
// model RSVP {
// id String @id @default(cuid())
// eventId String
// userId String
// status RsvpStatus
// updatedAt DateTime @updatedAt
// event Event @relation(fields: [eventId], references: [id])
// user User @relation(fields: [userId], references: [id])
// @@unique([eventId, userId])
// }
export async function setRsvp(
eventId: string,
userId: string,
status: RsvpStatus,
) {
return prisma.rSVP.upsert({
where: { eventId_userId: { eventId, userId } }, // composite key
create: { eventId, userId, status },
update: { status },
});
}Managers see changes as a diff ("here's what changed since the last sync, confirm"), so an hour of weekly copy-paste becomes one review. The same Next.js build is wrapped in Capacitor for iOS and Android, so the scraper and domain logic exist once for web and mobile.
Why these choices
- League scrapers (GSSL, Rats)
- Turn a public team URL into a structured schedule and roster. They run on a schedule plus a manager's sync button. One module per league, so adding a league means adding a module.
- Next.js (App Router)
- Hosts the web UI, server components, API routes, and the scraper jobs in one project. No separate backend to deploy or version.
- Capacitor
- Wraps the same Next.js build for iOS and Android. Scraper, auth, and domain logic are written once and reused across web, iOS, and Android.
- Prisma + PostgreSQL
- Stores the synced league data. Composite-key upserts (e.g. `eventId_userId`) enforce one RSVP per user per event in the database.
- Clerk
- Same auth flow across the web app and the Capacitor wrappers; webhook-driven user sync into Prisma on first sign-in.
- Firebase Cloud Messaging
- One push pipeline for iOS, Android, and web. Action buttons carry signed tokens, so an RSVP from a notification works without a session.
- Stripe + payment deep links
- Two money flows, kept apart. Players pay their league-fee share to the manager through generated Venmo, Cash App, Zelle, or PayPal links, so SquadPact never holds league money. Only the manager's per-player platform fee runs through Stripe.
- Neon (prod) / Docker Postgres (dev)
- The same Postgres engine in both environments, so dev and production behave alike. Neon's hosted tier needs no setup.