NBA Feed
팀 뉴스와 경기 정보를 제공, 점수는 직접 확인할 때까지 숨겨져 있습니다
아직 감지된 업데이트가 없습니다. GitHub 릴리스가 나오면 여기에 보입니다.
Manage your events' attendance.
저장소 README에서 가져옴
README 전체를 불러오는 중입니다. 아래는 저장된 발췌입니다.
An app for managing sports teams and groups. The coach owns structured state
WhatsApp is bad at — rosters, RSVPs, event capacity/waitlists, payment tracking —
and broadcasts to the group's existing WhatsApp chat with one tap. See
CLAUDE.md for the product vision and design decisions, and
BRAINSTORM.md for how we got here.
• Next.js (App Router) + TypeScript, as a PWA
• Postgres via Prisma
• Auth.js (NextAuth v5) — magic-link email (logged to console in dev)
• Tailwind CSS v4 for styling, lucide-react for icons
• Vitest for unit tests
• Hosting: Railway (app + Postgres) — live at
whosin.teamREADME 전문 보기 ↗An app for managing sports teams and groups. The coach owns structured state
WhatsApp is bad at — rosters, RSVPs, event capacity/waitlists, payment tracking —
and broadcasts to the group's existing WhatsApp chat with one tap. See
CLAUDE.md for the product vision and design decisions, and
BRAINSTORM.md for how we got here.
Layered so logic stays testable and the native-app door stays open (a future native client could call the same API). Keep a clean boundary — don't bury business logic in server-rendered pages.
src/
app/ routes: pages (UI) + route handlers + thin server actions
components/ reusable UI (PlayerForm, EventForm, WhatsAppComposer, BottomNav, ...)
server/ backend: coach/group resolution + domain services
services/ the real logic (roster.ts, events.ts)
lib/ shared, pure: db client, messages.ts, status.ts, squad.ts, ui.ts
The rule: route handlers / server actions stay thin and delegate to
server/services; pure formatting/validation lives in lib.
npm install
cp .env.example .env # fill in DATABASE_URL, AUTH_SECRET, SEED_COACH_EMAIL
npm run db:migrate # apply migrations to your local Postgres
npm run db:seed # demo coach + 12-player roster
npm run dev # http://localhost:3000
Log in: enter the address you set as SEED_COACH_EMAIL — the demo roster
belongs to that coach — then copy the magic link printed in the terminal
(dev doesn't send real email yet).
| Script | What it does |
|---|---|
npm run dev | Start the dev server |
npm run build | Production build |
npm run db:migrate | Create + apply a migration from schema changes (local dev) |
npm run db:seed | Reset to a known demo roster (destructive — wipes event statuses) |
npm run db:studio | Prisma Studio |
npm run lint | ESLint — code quality |
npm run format | Prettier — auto-format all files |
npm run format:check | Prettier — check formatting without writing |
npm test | Run the unit test suite once |
npm run test:watch | Run unit tests in watch mode |
npm run test:coverage | Run unit tests + print a coverage report (HTML in coverage/) |
npm run test:e2e | Run Playwright end-to-end tests (auto-starts the dev server) |
ESLint (code quality) and Prettier (formatting) are complementary, not
overlapping: ESLint catches bugs and bad patterns (unused vars, <a> for
internal links, hook deps), Prettier owns layout (indentation, quotes, width).
eslint-config-prettier disables ESLint's formatting rules so the two never
fight. Run npm run format before committing; npm run lint enforces quality.
Our layered design concentrates the valuable logic in pure, easily-tested places. We invest where bugs actually live, in order of value-per-effort:
| Layer | Test type | Status |
|---|---|---|
lib/ pure functions (message formatting, status labels, validation) | Unit (Vitest) — no DB, no browser | ✅ in place |
server/services (createEvent seeding, ensureEntries, status/squad writes) | Integration (Vitest + a test Postgres) | ⏳ planned |
Client components (WhatsAppComposer, DateTimeInput) | Component (Vitest + React Testing Library) | ⏳ later |
| Full flows (create event → mark In → share) | E2E (Playwright) | ✅ in place |
| Auth (redirect unauthenticated, login, session) | E2E (Playwright) | ✅ in place |
| Roster page (add player modal, delete confirmation) | E2E (Playwright) | ✅ in place |
| Events page (tabs, pagination, create modal, FAB) | E2E (Playwright) | ✅ in place |
| Navigation (logo links, pencil icon hrefs) | E2E (Playwright) | ✅ in place |
lib/messages.ts builds what gets broadcast to real
people; it's critical and trivial to test (no mocking). Most real bugs so
far have been in formatting/state, not infrastructure.vitest.setup.ts pins TZ=UTC so date formatting in
tests is stable across machines/CI.We rely on automated coverage rather than a hand-maintained tally (which
drifts). npm run test:coverage prints a per-file table and writes an HTML
report to coverage/ (gitignored). Today src/lib is well covered and the
other layers read as gaps — accurate, since their tests are still planned.
Infra glue (lib/db.ts, layouts, route handlers) is excluded so the numbers
reflect real logic. Per-directory thresholds can be added in
vitest.config.ts once the suite stabilizes.
E2E specs live in e2e/. npm run test:e2e auto-starts the dev server and runs
against it, so a local Postgres with a synced schema is required. A global setup
(e2e/global-setup.ts) provisions an isolated E2E coach + group + roster
(separate from dev/seed data, reset each run) and injects a DB-backed session via
storageState, so tests start authenticated without driving the magic-link UI
(that flow is better tested in its own targeted spec later). Reports land in
playwright-report/ (gitignored).
Never hard-code dates in an E2E spec. Use the helpers in e2e/dates.ts
(daysFromNow, dateValue, dateTimeValue, nextYear) so every date is
relative to the run. A hard-coded date is a time bomb: it passes until the
calendar catches up, then fails permanently, and the failure looks like a
broken feature rather than a stale fixture. This already happened once —
recurring-events.spec.ts pinned Sep 2026 and started reporting "expected 4,
received 2" once two of its occurrences aged onto the Past tab. Note also that
specs share one database within a run (one worker, serial), so an event a
spec creates is visible to later specs — which is why the created event must
stay in the future, or events-page.spec.ts's empty-Past-tab assertion fails.
Co-locate unit tests as *.test.ts next to the code (src/lib/messages.test.ts);
put E2E specs in e2e/*.spec.ts. Run:
npm test # unit, once
npm run test:watch # unit, watch mode
npm run test:coverage # unit + coverage report
npm run test:e2e # end-to-end (starts the dev server)
Everything below is what stands between the current local-only app and real users at whosin.team. Ordered roughly by priority. 🔴 = blocker (no one can use it without this), 🟡 = needed for a credible launch, ⚪ = nice-to-have.
npm run build → npm start). Set the start command if needed.Two projects in one Railway account (whosin + the partner's project). Using one account is simpler than juggling two logins — the only risk is deploying to the wrong project, which is low-stakes and obvious (the project name is right there). GitHub and Claude don't enter into it.
- Separate Workspaces keep ownership/billing clean between whosin and the partner's project while sharing one login. Switch projects from the dropdown.
- GitHub doesn't pick the project — the Railway dashboard you're in does. Create the whosin service from the whosin project/workspace.
- Scope the Railway GitHub App to "Only select repositories" → just the whosin repo when authorizing, so a service can't accidentally pull the wrong repo.
- Prefer the dashboard over the global
railwayCLI. If you use the CLI or CI, use a project-scopedRAILWAY_TOKEN(it can't touch any other project) and runrailway status/railway whoamibefore acting.- Claude has no Railway connection — it only touches Railway if you ask it to run a
railwayCLI command, so dashboard-only work keeps it out entirely.
Provision Railway Postgres; copy its DATABASE_URL into the app
service's env (Railway can inject it automatically). Wired via Railway's
variable reference (${{Postgres.DATABASE_URL}}), the internal URL.
Decide on migrations: took the documented shortcut — ran prisma db push directly against the prod DB for the first deploy. Now resolved:
generated a baseline migration
(prisma/migrations/20260619214557_baseline) from a diff against an
empty schema, then marked it applied (not re-run, since the schema
already matched) on local, staging, and production via
prisma migrate resolve --applied 20260619214557_baseline. npm start
now runs prisma migrate deploy before next start, so future schema
changes ship as committed migrations applied automatically on deploy.
Local dev now uses npm run db:migrate (prisma migrate dev) instead
of db:push.
Do NOT run the demo seed against production. It's destructive and
creates fake players. The real coach signs in and builds their own group.
prisma/seed.mjs now refuses to run against any non-local
DATABASE_URL (checks the hostname, not a hardcoded URL) unless
ALLOW_REMOTE_SEED=true is explicitly set — keeps the door open to
seed staging on purpose, while blocking accidental runs against prod.
Enable WAL archiving on the Railway Postgres service (Postgres service → Backups tab → enable continuous archiving). Scheduled backups and point-in-time restore require the Pro plan (not available on Hobby), but archiving itself is free and runs regardless — enabled now so a restore is possible later if you ever upgrade.
Rotating the Postgres password (done 2026-09-16, after the live
credential leaked into a chat transcript). Railway has no "change
password" control — the Postgres service's Danger zone only offers
deleting the whole service — and the rotation is two halves, both
required: run ALTER USER postgres WITH PASSWORD '…'; over
railway connect Postgres (or psql via ), then
update on the Postgres service, because Railway
composes / from it and changing the
database alone leaves both stale. Keep those two as
templates rather than literals so one edit
propagates; the app's stays
and needs only a redeploy. Generate the password —
it is embedded in a URL, and break parsing in a way Prisma
reports as , indistinguishable from a
wrong password. Two traps that each cost time here: prints
on success, and a leaves it on a
continuation prompt where the statement silently never runs;
and is the better command anyway, since it prompts
instead of leaving the new secret in shell history.
Magic-link login now sends real email in production and has been confirmed working end-to-end (email arrives, link signs in).
contact.whosin.team (not the root domain) and added its SPF/DKIM/DMARC
records at the registrar.sendVerificationRequest in src/auth.ts —
ended up calling Resend's HTTP API directly (fetch), not SMTP:
Railway blocks outbound SMTP ports, which surfaced as connection
timeouts until the switch. Configured via RESEND_API_KEY + EMAIL_FROM.
Console fallback still used for local dev (when RESEND_API_KEY is unset).AUTH_SECRET (do not reuse the dev one).AUTH_URL=https://whosin.team in production env.whosin.team.@ — it blocks the CNAME
from resolving and needs to be deleted.whosin.team loads over HTTPS).contact. subdomain).[email protected] forwarding — the apex was a CNAME
to Railway (ak04l2iq.up.railway.app), and a CNAME must be the only
record at its hostname (RFC 1034), so no MX could exist at
whosin.team and Namecheap's email forwarding was unavailable (its
UI reports this as the misleading "Your domain is using other email
service"). Asking the zone for MX literally returned the CNAME. Fixed
by switching the @ record from CNAME to ALIAS, same target —
ALIAS answers as an A record while still tracking Railway's hostname,
which frees the apex to hold MX. Then Mail Settings → Email
Forwarding, with [email protected] → personal Gmail, to back the
invite-request link on /login (src/lib/access.ts). Verified end to
end: apex answers A, eforward1-5 MX live, SPF added, site still
loads, Railway domain still validated, test email delivered. Note
fphelp.app solves the same problem with a hardcoded A record to a
Railway IP — it works, but pins infrastructure we don't control; ALIAS
is the safer form of the same trick.contact.whosin.team inbound, send.contact.whosin.team bounce
feedback). SPF and DKIM survived, so sending is unaffected for both
apps and Resend still reports the domain verified. Nothing receives on
contact.whosin.team, so this was accepted rather than reverted:
switching back to Custom MX would drop eforward1-5 and kill the new
alias. See the Cloudflare item in step 8 for the way out. Resend re-checked, found no MX, flipped
to unverified, and magic-link sign-in started
failing in production with a 403 — weeks after the DNS change that
caused it.Set on the Railway app service:
DATABASE_URL (prod Postgres, via variable reference)AUTH_SECRET (fresh, strong)AUTH_URL=https://whosin.teamEMAIL_FROM + RESEND_API_KEY (HTTP API key, not SMTP creds —
.env.example reflects this)ALLOWED_EMAILS — comma-separated sign-in allowlist. Required: with
it unset in production every sign-in is refused, by design. Sign-up is
invite-only in v1, which also keeps /login from being an open email
relay that strangers can use to burn the Resend quota and the sending
domain's reputation. Set on production (3 addresses), confirmed
2026-09-16. If it ever looks missing, check the service before assuming
it was lost: Railway variables are scoped per service, per
environment, so it lives on the whosin app service, not on Postgres.metadata.title, the home page heading, and
public/manifest.webmanifest (name, short_name). theme_color
still uses the placeholder slate — revisit once a brand color is
picked alongside the logo.public/icon-192.png and public/icon-512.png which don't exist yet;
also add a favicon and an apple-touch-icon. (Tools for the logo:
Recraft or Midjourney for the mark — Recraft can export clean SVG;
Looka/Canva for a quick wordmark; or just a typographic wordmark in
a nice font. Generate the icon sizes with a favicon generator like
realfavicongenerator.net.)The primary user is a phone-first coach — test on a real phone, not just the laptop:
src/lib/messages.ts.ngrok) — public-wifi note: a tunnel avoids same-network
issues./privacy and /terms
pages, linked from the sign-in page. Covers what's collected (coach
email for magic-link auth; player name/phone/email/notes entered by the
coach), that it's not sold/shared beyond the hosting (Railway) and email
(Resend) providers, and that deletion requests go to
[email protected] and are handled manually for now. Terms include a
no-affiliation-with-WhatsApp disclaimer per the coexistence model in
CLAUDE.md. Not formal legal review — revisit with a lawyer before any
paid tier.src/app/error.tsx (client
error boundary with a "Try again" button) and src/app/not-found.tsx,
matching the existing minimal page style.src/auth.ts now enforces a
60-second per-email cooldown in sendVerificationRequest (backed by
isRateLimited in src/lib/rateLimit.ts, unit-tested) before sending
another magic-link email. In-memory, so it resets on redeploy and only
holds for a single instance — revisit with a shared store (e.g. Redis)
if the app ever scales beyond one Railway instance..github/workflows/ci.yml: runs format check,
lint, typecheck, and unit tests on every PR into main. Deliberately
skips E2E for now (needs a Postgres service + more setup) — fine while
it's a single-developer repo; add it if/when others start contributing
or E2E coverage grows.@sentry/nextjs: src/instrumentation.ts
(server/edge) + src/instrumentation-client.ts (browser), both reading
NEXT_PUBLIC_SENTRY_DSN (not secret, so one var works for both sides).
src/app/error.tsx and the new src/app/global-error.tsx (catches
errors in the root layout itself) report via Sentry.captureException.
next.config.ts wraps the config with withSentryConfig so production
builds upload source maps (needs SENTRY_AUTH_TOKEN, scope
project:releases, set on Railway) and strip them from the shipped
client bundle. Set NEXT_PUBLIC_SENTRY_DSN and on
the Railway staging + production app services to activate; both unset
locally is fine (errors just won't report and the build skips source
map upload in dev).DATABASE_PUBLIC_URLPOSTGRES_PASSWORDDATABASE_URLDATABASE_PUBLIC_URL${{POSTGRES_PASSWORD}}DATABASE_URL${{Postgres.DATABASE_URL}}@ : / # %P1000: Authentication failedpsqlALTER ROLErailway-#\password postgres Staging environment, reachable from a phone. Set up via Railway's
Duplicate Environment on the production environment, which cloned
the app + Postgres services into a new staging environment with the
DATABASE_URL reference automatically rewired to the staging Postgres
(confirmed it resolves to the staging DB, not prod). Then: generated a
fresh AUTH_SECRET, generated a public domain
(whosin-staging.up.railway.app) and set AUTH_URL to it, kept the
same RESEND_API_KEY / EMAIL_FROM (same verified sending domain),
and pushed the schema with prisma db push using the staging
Postgres's public connection string (DATABASE_URL_PUBLIC-style
URL — the internal postgres.railway.internal host only resolves
inside Railway's network, not from a laptop). Verified end-to-end on a
phone: page loads, magic-link login works. Local Postgres remains fine
for everyday schema iteration; this environment covers the cases local
can't (phone testing, rehearsing against a deployed build).
Staging and production deploy from different branches. Both
environments originally watched main (an artifact of Duplicate
Environment), so a single push deployed straight to production with no
rehearsal step. Now: staging watches main, production watches a
dedicated production branch, with Wait for CI enabled on the
production service so a red build can't ship. The environments already
had separate DATABASE_URL, AUTH_SECRET, and AUTH_URL values from
the duplication, so only the deploy trigger changed.
Shipping is therefore a git operation, which keeps the release history
in the repo rather than in Railway's dashboard:
```bash
# always start here: local refs go stale fast, and a stale main is
# why a promote silently reports "Everything up-to-date"
git checkout main && git pull
# what is on main but not yet in production?
git log --oneline origin/production..main
# ship main to production (fast-forward)
git push origin main:production
# roll production back to a known-good commit
git push -f origin <sha>:production
```
There is deliberately **no local `production` branch** — it only exists
on the remote, so it can't drift or be committed to by accident. Always
refer to it as `origin/production`; plain `production` will fail with
"unknown revision".
⚠️ **Rolling back reverts code, not data.** `npm start` runs
`prisma migrate deploy`, and Prisma has no automatic down-migration. If
a bad deploy applied a *destructive* migration (dropped a column,
narrowed a type), force-pushing the old commit leaves production
running old code against the new schema — frequently worse than the bug
you're backing out. Rollback is only safe while migrations are additive.
See the open item below.
send.contactcontact.whosin.teamSENTRY_AUTH_TOKENproduction
branch, a bad release can be rolled back with
git push -f origin <sha>:production — but that reverts code only.
npm start runs prisma migrate deploy and Prisma has no automatic
down-migration, so a destructive migration (dropped column, narrowed
type, renamed field) survives the rollback and leaves old code running
against a new schema. The deploy split we just made is what raises the
stakes here: production can now lag staging by several commits, so a
single promote may apply several migrations at once. Worth deciding:
(a) an expand/contract convention so migrations are always additive and
reversible within a release, (b) a review step that flags destructive
SQL in prisma/migrations before promoting, and (c) whether the Hobby
plan's lack of point-in-time restore is acceptable once real coaches
have data in there — WAL archiving is on, but restore needs Pro. Get to
this before the first column drop or rename, not after.contact.whosin.team to unverified,
and /login started returning 500 with
Resend API error 403: domain is not verified. Sending had run on
SPF+DKIM alone for weeks after the MX rows were deleted, which is why
nothing looked wrong until it broke. Namecheap allows exactly one mail
mode per zone (Email Forwarding or Custom MX), so restoring Resend's
MX there would have killed [email protected] — Cloudflare has no
zone-wide mail mode, MX are ordinary per-hostname records, and CNAME
flattening keeps the apex on Railway, so forwarding and Resend coexist.
Registrar stays Namecheap; only the nameservers change. theapps.app is
a separate zone and was unaffected. The zone, recreated in Cloudflare:
apex whosin.team CNAME → <id>.up.railway.app (DNS only);
_dmarc TXT v=DMARC1; p=none;; send.contact MX
10 feedback-smtp.us-east-1.amazonses.com; send.contact TXT
v=spf1 include:amazonses.com ~all; and resend._domainkey.contact TXT
holding the p=MIGf… key. Four traps, all avoidable next time:
Cloudflare's importer cannot enumerate a zone — it guesses common
hostnames, so it found only the apex CNAME and _dmarc and silently
missed all three Resend records, which must be added by hand before
activation; the apex must stay grey-cloud / DNS only, because
Railway issues its own certificate and a proxied origin breaks renewal
(Cloudflare's "not fully protected" warning is an upsell, ignore it);
Resend's "Enable Receiving" MX is not needed — nothing receives on
contact.whosin.team, and its record belongs on that subdomain, never
the apex, where it would collide with Email Routing; and switching
Namecheap to Custom DNS wipes every record in the Advanced DNS tab,
so inventory the zone first
(Resolve-DnsName -Server dns1.registrar-servers.com) — there is no
rollback afterwards. Copy DKIM from Resend's dashboard, never retype it.
Outcome: the zone now does what Namecheap structurally could not —
apex forwarding and Resend's subdomain records coexisting. Cloudflare
Email Routing serves access@ and [email protected] — apex MX
route1/2/3.mx.cloudflare.net, plus Cloudflare's own SPF include at the
apex — both delivering; Resend reports Sending verified and magic-link
sign-in works on production. Note
Email Routing can only be configured after the zone goes active, so
mail to both addresses bounces from the nameserver switch until it is —
keep that window short. Resend's inbound half showed as pending and was
disabled: nothing receives on contact.whosin.team, and sending
never depended on it. Verified from outside afterwards: apex answers
Railway with no cf-ray header (confirming grey-cloud), and the DKIM
key is byte-identical to the Namecheap original.next.config.ts now sets them on every route:
Strict-Transport-Security (1 year, subdomains, deliberately no
preload — that submits the domain to a browser-baked list that is slow
to reverse), X-Frame-Options: DENY (the delete-player and
cancel-event actions are one-click form posts, so clickjacking was the
one plausible attack), X-Content-Type-Options, Referrer-Policy, and
Permissions-Policy. CSP ships as Content-Security-Policy-Report-Only
on purpose: a CSP mistake breaks the app silently for real users, and
report-only surfaces violations without blocking. Verified against a
production build (next build + next start) driving /login, /,
/events, /roster, /privacy, /terms, the create-event modal and
an event detail page: zero violations. Two notes before promoting it
to the enforcing header: (a) 'unsafe-inline' in script-src is
required, since Next injects inline bootstrap/hydration scripts and the
nonce alternative needs middleware this app deliberately doesn't have;
(b) connect-src wildcards *.sentry.io because the DSN host is org-
and region-specific and NEXT_PUBLIC_SENTRY_DSN is unset locally, so
Sentry's real egress was never exercised in testing — check staging's
console for a report-only violation on the ingest host before
enforcing, or error reporting dies quietly. When promoting, rename the
header key and add back upgrade-insecure-requests (browsers ignore it
in report-only and warn on every page load, so it was removed).
poweredByHeader: false also drops Next's x-powered-by response
header, which advertised the framework and version to anyone curious.src/app/opengraph-image.tsx renders a 1200×630 preview card from
public/logo.png at build time, with openGraph/twitter metadata and a
metadataBase in src/app/layout.tsx so shared links resolve it
absolutely. src/app/robots.ts allows only the pages a signed-out visitor
can actually see (/, /login, /privacy, /terms) and disallows the
rest, which would only redirect a crawler to /login; /check-email is
additionally noindex. /login now carries a standing invite-only line
under the button, so a visitor arriving from the showcase site learns why
they can't sign up instead of hitting a dead end. Deleted the unused
2.1 MB public/logo_original.png, which was being served publicly.https://whosin.team so you hear about
an outage instead of a coach telling you.