Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
d9f9b08
docs(B3-A): lock all-vertical onboarding, navigation and public disco…
baba-dev Nov 11, 2026
0220971
feat(B3-B): additive club vertical, setup progress and public revisio…
baba-dev Nov 11, 2026
1925552
feat(B3-C): persisted setup services, readiness guards and existing-p…
baba-dev Nov 11, 2026
54d9c77
feat(B3-D): guarded setup endpoints and required redirect after club …
baba-dev Nov 11, 2026
eff9e9f
feat(B3-E): Luxe role-aware navigation and five-step guided owner setup
baba-dev Nov 11, 2026
e938c6a
feat(B3-F): tenant-scoped public profile drafts, review snapshots and…
baba-dev Nov 11, 2026
d4dee3c
feat(B3-G): public club discovery, canonical profiles and owner publi…
baba-dev Nov 11, 2026
f32ce5f
feat(B3-H): moderated directory console, role-specific club home and …
baba-dev Nov 11, 2026
6ccc3fd
fix(B3-I): type-safe directory search, confirmed onboarding and race-…
baba-dev Nov 11, 2026
d8d52ba
test(B3-J): native PG17 setup/publication tests and formatter CI gate
baba-dev Nov 11, 2026
5629df8
style(B3): apply Prettier [b3-fmt-done]
github-actions[bot] Nov 11, 2026
fdfd925
fix(B3): match Prisma timestamp defaults and add dynamic page suspens…
baba-dev Nov 11, 2026
36202c0
style(B3): apply Prettier [b3-fmt-done]
github-actions[bot] Nov 11, 2026
fc79651
fix(B3): return redirect in cached setup router component
baba-dev Nov 11, 2026
7fb385b
fix(B3): lint cleanups and conflict-safe guided setup writes
baba-dev Nov 11, 2026
bbdbbb8
style(B3): apply Prettier [b3-fmt-done]
github-actions[bot] Nov 11, 2026
1261ef3
feat(B3): owner-editable club type and location without membership mu…
baba-dev Nov 11, 2026
2c89820
style(B3): apply Prettier [b3-fmt-done]
github-actions[bot] Nov 11, 2026
b29ecd4
feat(B3): admin-only public profile suspension and safe reinstatement
baba-dev Nov 11, 2026
13ca18f
feat(B3): expose moderator listing suspension and reinstatement actions
baba-dev Nov 11, 2026
e187381
style(B3): apply Prettier [b3-fmt-done]
github-actions[bot] Nov 11, 2026
8d116cd
fix(B3): serialize approval with suspension and hidden publication state
baba-dev Nov 11, 2026
44de137
style(B3): apply Prettier [b3-fmt-done]
github-actions[bot] Nov 11, 2026
f9a115c
fix(B3): allow hidden approved clubs to resubmit for publication review
baba-dev Nov 11, 2026
c7c0b49
fix(B3): honor currency minor units and retire temporary formatter wo…
baba-dev Nov 11, 2026
e7a79d2
style(B3): match Prettier for multi-currency plan validation
baba-dev Nov 11, 2026
f87e8d3
fix(B3): compare-and-swap public moderation against concurrent owner …
baba-dev Nov 11, 2026
7bfa552
style(B3): match established moderation guard formatting
baba-dev Nov 11, 2026
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
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,8 @@ jobs:
run: node --test tests/integration/business-schema.test.mjs
- name: Validate B2 attendance constraints and tenant isolation on PostgreSQL 17
run: node --test tests/integration/attendance-schema.test.mjs
- name: Validate B3 schema constraints and public revision safety on PostgreSQL 17
run: node --test tests/integration/b3-schema.test.mjs
- name: One-time platform administrator provisioning on isolated PostgreSQL 17
run: node --test tests/integration/platform-bootstrap.test.mjs
- name: Legacy unversioned Better Auth baseline and populated upgrade
Expand Down
19 changes: 19 additions & 0 deletions docs/B3_UNIFIED_EXPERIENCE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# B3: Unified Club Experience, multi-vertical onboarding and public discovery

Owner-approved on 11 November 2026. All verticals launch together (GYM, YOGA, DANCE, SPORTS, OTHER). New clubs must finish guided setup before operational access. Public profiles are an early-release opt-in capability, subject to platform moderation. Preserve all existing URL/API contracts and previous locked commercial/privacy decisions.

## Contract
- One Better Auth user, many club-scoped roles. Personal records are completely independent.
- Existing /clubs/[clubId] private routes remain the canonical paths, with a role-aware responsive Luxe layout; /home chooses club context.
- /clubs/new creates an IN_PROGRESS club. The owner is directed to /clubs/[clubId]/setup; the club stays ACTIVE as a tenant lifecycle status, distinct from setup readiness. Existing clubs migrate to LEGACY_ACTIVE so B1/B2 remain operable.
- Setup requires vertical, operating mode, country, currency, timezone, and at least one non-archived plan. Logo, images, public address, and public listing are optional. Owner-only progression is persisted and server-validated. A club can be operated when setup is READY or LEGACY_ACTIVE.
- Protect operational route handlers and pages; reject bypass attempts from NEW clubs (fail-closed). Setup plan creation is a narrowly scoped authenticated exception that calls the existing plan service.
- /discover/clubs and /c/[slug] expose approved public data only. A club must explicitly submit a profile; platform admin must approve. Published content remains unchanged while an edit is pending. Hidden/unlisted profiles are excluded from directory indexing.
- Public discovery does NOT join clubs, reveal member records, or process membership payments. Seven-day single-use /join#invite remains canonical.
- New schema is additive; club statuses, roles, B1 roster/plans/renewals, B2 attendance and age/region/auth feature gates are preserved.
- No new plan quota numbers, sponsored seats, clinical features, class-pack engine, marketplace or platform checkout.

## Security & release
Tenant-scoped writes: verified session, exact-origin CSRF, per-action fail-closed Redis limits, Zod parse, bound payloads, audit logging. Public reads use whitelisted projections and bounded paging. Publishing requires PLATFORM_ADMIN, no implied membership rights. Inactive/suspended clubs are not visible. Do not publish minors or Personal data.

Release gates: immutable migrations on native PG17, fresh/legacy/noop tests, unit and isolation tests, Next 16 standalone startup smoke on ARM64, 360px/390px mobile and tablet check, keyboard/a11y, QR scanner regression and production-side restore rehearsal. ClubKeep's PostgreSQL, systemd, Redis and secrets remain separate from MenuMinute. Public listing can be disabled independently from private club operations.
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
-- B3: safe additive club setup and moderated public listings
CREATE TYPE "ClubVertical" AS ENUM ('GYM','YOGA','DANCE','SPORTS','OTHER');
CREATE TYPE "ClubSetupStatus" AS ENUM ('IN_PROGRESS','READY','LEGACY_ACTIVE');
CREATE TYPE "ClubPublicationStatus" AS ENUM ('HIDDEN','PENDING','UNLISTED','LISTED','SUSPENDED');
CREATE TYPE "ClubPublicReviewStatus" AS ENUM ('PENDING','APPROVED','REJECTED');
ALTER TABLE "Club" ADD COLUMN "vertical" "ClubVertical" NOT NULL DEFAULT 'OTHER';
ALTER TABLE "Club" ADD COLUMN "setupStatus" "ClubSetupStatus" NOT NULL DEFAULT 'IN_PROGRESS';
ALTER TABLE "Club" ADD COLUMN "setupCompletedAt" TIMESTAMP(3);
-- Pre-B3 clubs retain all B1/B2 access, with incomplete setup flagged for later completion.
UPDATE "Club" SET "setupStatus"='LEGACY_ACTIVE';
CREATE TABLE "ClubSetupProgress" (
"clubId" TEXT NOT NULL PRIMARY KEY REFERENCES "Club"("id") ON DELETE RESTRICT ON UPDATE CASCADE,
"currentStep" INTEGER NOT NULL DEFAULT 0,
"version" INTEGER NOT NULL DEFAULT 0,
"typeConfirmed" BOOLEAN NOT NULL DEFAULT false,
"detailsConfirmed" BOOLEAN NOT NULL DEFAULT false,
"identityConfirmed" BOOLEAN NOT NULL DEFAULT false,
"countryCode" VARCHAR(2),
"currencyCode" VARCHAR(3),
"operatingMode" VARCHAR(16),
"city" VARCHAR(100),
"address" VARCHAR(240),
"contactEmail" VARCHAR(254),
"logoUrl" VARCHAR(512),
"updatedAt" TIMESTAMP(3) NOT NULL
);
CREATE TABLE "ClubPublicProfile" (
"clubId" TEXT NOT NULL PRIMARY KEY REFERENCES "Club"("id") ON DELETE RESTRICT ON UPDATE CASCADE,
"draft" JSONB NOT NULL DEFAULT '{}'::jsonb,
"published" JSONB,
"city" VARCHAR(100),
"status" "ClubPublicationStatus" NOT NULL DEFAULT 'HIDDEN',
"publishedAt" TIMESTAMP(3),
"updatedAt" TIMESTAMP(3) NOT NULL
);
CREATE TABLE "ClubPublicRevision" (
"id" TEXT NOT NULL PRIMARY KEY,
"clubId" TEXT NOT NULL REFERENCES "Club"("id") ON DELETE RESTRICT ON UPDATE CASCADE,
"content" JSONB NOT NULL,
"status" "ClubPublicReviewStatus" NOT NULL DEFAULT 'PENDING',
"submittedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"reviewedAt" TIMESTAMP(3),
"reviewerId" TEXT
);
CREATE INDEX "ClubPublicProfile_status_publishedAt_idx" ON "ClubPublicProfile"("status","publishedAt");
CREATE INDEX "ClubPublicProfile_status_city_publishedAt_idx" ON "ClubPublicProfile"("status","city","publishedAt");
CREATE INDEX "ClubPublicRevision_clubId_status_submittedAt_idx" ON "ClubPublicRevision"("clubId","status","submittedAt");
CREATE INDEX "ClubPublicRevision_status_submittedAt_idx" ON "ClubPublicRevision"("status","submittedAt");
CREATE UNIQUE INDEX "ClubPublicRevision_one_pending_per_club" ON "ClubPublicRevision" ("clubId") WHERE "status"='PENDING';
76 changes: 76 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,12 @@ model Club {
memberSequence Int @default(0)
// IANA zone for the club's calendar-day attendance boundary; UTC is safe until configured.
timeZone String @default("UTC")
vertical ClubVertical @default(OTHER)
setupStatus ClubSetupStatus @default(IN_PROGRESS)
setupCompletedAt DateTime?
setupProgress ClubSetupProgress?
publicProfile ClubPublicProfile?
publicRevisions ClubPublicRevision[]

memberships ClubMembership[]
invites ClubInvite[]
Expand Down Expand Up @@ -408,3 +414,73 @@ model ClubAttendance {
@@index([clubId, localDate, checkedInAt(sort: Desc)])
@@index([clubId, memberRecordId, checkedInAt(sort: Desc)])
}


enum ClubVertical {
GYM
YOGA
DANCE
SPORTS
OTHER
}
enum ClubSetupStatus {
IN_PROGRESS
READY
LEGACY_ACTIVE
}
enum ClubPublicationStatus {
HIDDEN
PENDING
UNLISTED
LISTED
SUSPENDED
}
enum ClubPublicReviewStatus {
PENDING
APPROVED
REJECTED
}

model ClubSetupProgress {
clubId String @id
currentStep Int @default(0)
version Int @default(0)
typeConfirmed Boolean @default(false)
detailsConfirmed Boolean @default(false)
identityConfirmed Boolean @default(false)
countryCode String? @db.VarChar(2)
currencyCode String? @db.VarChar(3)
operatingMode String? @db.VarChar(16)
city String? @db.VarChar(100)
address String? @db.VarChar(240)
contactEmail String? @db.VarChar(254)
logoUrl String? @db.VarChar(512)
updatedAt DateTime @updatedAt
club Club @relation(fields: [clubId], references: [id], onDelete: Restrict)
}

model ClubPublicProfile {
clubId String @id
draft Json @default("{}")
published Json?
city String? @db.VarChar(100)
status ClubPublicationStatus @default(HIDDEN)
publishedAt DateTime?
updatedAt DateTime @updatedAt
club Club @relation(fields: [clubId], references: [id], onDelete: Restrict)
@@index([status, publishedAt])
@@index([status, city, publishedAt])
}

model ClubPublicRevision {
id String @id @default(cuid())
clubId String
content Json
status ClubPublicReviewStatus @default(PENDING)
submittedAt DateTime @default(now())
reviewedAt DateTime?
reviewerId String?
club Club @relation(fields: [clubId], references: [id], onDelete: Restrict)
@@index([clubId, status, submittedAt])
@@index([status, submittedAt])
}
80 changes: 80 additions & 0 deletions src/app/admin/directory/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { Suspense } from "react";
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { requireSession } from "@/lib/required-session";
import { prisma } from "@/lib/prisma";
import { platformAdmin, validatedPublic } from "@/lib/public-directory";
import { DirectoryReview } from "@/components/admin/directory-review";
import { DirectoryVisibility } from "@/components/admin/directory-visibility";
export const metadata: Metadata = {
title: "Directory moderation · ClubKeep Admin",
robots: { index: false },
};
export default function DirectoryAdmin() {
return (
<Suspense
fallback={
<main className="min-h-screen bg-[#0b0705] p-8 text-[#fbf1e6]">Loading ClubKeep…</main>
}
>
<DirectoryAdminContent />
</Suspense>
);
}

async function DirectoryAdminContent() {
const session = await requireSession();
try {
await platformAdmin(prisma, session.user.id);
} catch {
notFound();
}
const visible = await prisma.clubPublicProfile.findMany({
where: { status: { in: ["LISTED", "UNLISTED", "SUSPENDED"] } },
select: { clubId: true, status: true, club: { select: { name: true } } },
take: 50,
orderBy: { updatedAt: "desc" },
});
const revisions = await prisma.clubPublicRevision.findMany({
where: { status: "PENDING" },
orderBy: { submittedAt: "asc" },
take: 50,
select: {
id: true,
clubId: true,
content: true,
submittedAt: true,
club: { select: { name: true } },
},
});
return (
<main className="min-h-screen bg-[#0b0705] px-5 py-10 text-[#fff0de]">
<div className="mx-auto max-w-4xl">
<Link href="/home" className="text-sm text-[#d5a877]">
← ClubKeep
</Link>
<p className="mt-10 text-xs font-semibold tracking-widest text-[#e7b871] uppercase">
Platform administration · Restricted
</p>
<h1 className="mt-3 text-4xl font-semibold">Public directory moderation</h1>
<p className="mt-3 text-sm leading-7 text-[#bfa58f]">
Approve only business-owned content suitable for publication. Approval is not a
verification badge.
</p>
<DirectoryReview
items={revisions.map((x) => ({
id: x.id,
clubId: x.clubId,
clubName: x.club.name,
content: validatedPublic(x.content),
submittedAt: x.submittedAt.toISOString(),
}))}
/>
<DirectoryVisibility
items={visible.map((x) => ({ clubId: x.clubId, status: x.status, name: x.club.name }))}
/>
</div>
</main>
);
}
36 changes: 36 additions & 0 deletions src/app/api/admin/club-profile/[clubId]/[action]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { NextRequest } from "next/server";
import { prisma } from "@/lib/prisma";
import { moderateProfileVisibility } from "@/lib/public-directory";
import {
guardOnboardingPost,
jsonPrivate,
onboardingFailure,
readOnboardingBody,
} from "@/lib/onboarding-http";
import { idInput } from "@/lib/validations/onboarding";

export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ clubId: string; action: string }> }
) {
try {
const { clubId, action } = await params;
if (!idInput.safeParse(clubId).success || !["suspend", "reinstate"].includes(action))
return jsonPrivate({ error: "Not found" }, 404);
const guard = await guardOnboardingPost(request, "b3-profile-moderation", 20, true);
if (!guard.ok) return guard.response;
const body = await readOnboardingBody(request);
if (!body || typeof body !== "object" || Object.keys(body).length !== 0)
return jsonPrivate({ error: "Invalid request" }, 400);
return jsonPrivate(
await moderateProfileVisibility(
prisma,
guard.userId!,
clubId,
action as "suspend" | "reinstate"
)
);
} catch (error) {
return onboardingFailure(error);
}
}
30 changes: 30 additions & 0 deletions src/app/api/admin/directory/[revisionId]/[decision]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { NextRequest } from "next/server";
import { prisma } from "@/lib/prisma";
import { reviewProfile } from "@/lib/public-directory";
import {
guardOnboardingPost,
jsonPrivate,
onboardingFailure,
readOnboardingBody,
} from "@/lib/onboarding-http";
import { idInput } from "@/lib/validations/onboarding";
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ revisionId: string; decision: string }> }
) {
try {
const { revisionId, decision } = await params;
if (!idInput.safeParse(revisionId).success || !["approve", "reject"].includes(decision))
return jsonPrivate({ error: "Not found" }, 404);
const guard = await guardOnboardingPost(request, "b3-profile-review", 20, true);
if (!guard.ok) return guard.response;
const body = await readOnboardingBody(request);
if (!body || typeof body !== "object" || Object.keys(body).length !== 0)
return jsonPrivate({ error: "Invalid request" }, 400);
return jsonPrivate(
await reviewProfile(prisma, guard.userId!, revisionId, decision === "approve")
);
} catch (error) {
return onboardingFailure(error);
}
}
37 changes: 37 additions & 0 deletions src/app/api/clubs/[clubId]/public-profile/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { NextRequest } from "next/server";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { readOwnerProfile, saveOwnerProfile, publicDraftInput } from "@/lib/public-directory";
import {
guardOnboardingPost,
jsonPrivate,
onboardingFailure,
readOnboardingBody,
} from "@/lib/onboarding-http";
import { idInput } from "@/lib/validations/onboarding";
type Context = { params: Promise<{ clubId: string }> };
export async function GET(request: NextRequest, { params }: Context) {
try {
const { clubId } = await params;
if (!idInput.safeParse(clubId).success) return jsonPrivate({ error: "Not found" }, 404);
const session = await auth.api.getSession({ headers: request.headers });
if (!session?.user?.emailVerified) return jsonPrivate({ error: "Sign in to continue" }, 401);
return jsonPrivate(await readOwnerProfile(prisma, session.user.id, clubId));
} catch (error) {
return onboardingFailure(error);
}
}
export async function PATCH(request: NextRequest, { params }: Context) {
try {
const { clubId } = await params;
if (!idInput.safeParse(clubId).success) return jsonPrivate({ error: "Not found" }, 404);
const guard = await guardOnboardingPost(request, "b3-profile-edit", 50, true);
if (!guard.ok) return guard.response;
const data = publicDraftInput.safeParse(await readOnboardingBody(request));
if (!data.success) return jsonPrivate({ error: "Check your public profile" }, 400);
await saveOwnerProfile(prisma, guard.userId!, clubId, data.data);
return jsonPrivate({ ok: true });
} catch (error) {
return onboardingFailure(error);
}
}
27 changes: 27 additions & 0 deletions src/app/api/clubs/[clubId]/public-profile/submit/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { NextRequest } from "next/server";
import { prisma } from "@/lib/prisma";
import { submitProfile } from "@/lib/public-directory";
import {
guardOnboardingPost,
jsonPrivate,
onboardingFailure,
readOnboardingBody,
} from "@/lib/onboarding-http";
import { idInput } from "@/lib/validations/onboarding";
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ clubId: string }> }
) {
try {
const { clubId } = await params;
if (!idInput.safeParse(clubId).success) return jsonPrivate({ error: "Not found" }, 404);
const guard = await guardOnboardingPost(request, "b3-profile-submit", 10, true);
if (!guard.ok) return guard.response;
const body = await readOnboardingBody(request);
if (!body || typeof body !== "object" || Object.keys(body).length !== 0)
return jsonPrivate({ error: "Invalid request" }, 400);
return jsonPrivate(await submitProfile(prisma, guard.userId!, clubId), 201);
} catch (error) {
return onboardingFailure(error);
}
}
Loading
Loading