Skip to main content

Club Memberships & Admin Roles

The access control system manages club memberships with role-based permissions at both club and tenant levels.

Architecture

┌─────────────────┐     ┌──────────────────┐     ┌─────────────────┐
│ JWT Token │────▶│ Permission │────▶│ Membership │
│ (userId) │ │ Guard │ │ Lookup │
└─────────────────┘ └──────────────────┘ └─────────────────┘
│ │
▼ ▼
┌──────────────────┐ ┌─────────────────┐
│ Role-Permission │ │ Cache (60s) │
│ Mapping │ │ │
└──────────────────┘ └─────────────────┘


┌──────────────────┐
│ Allow/Deny │
└──────────────────┘

Sources:

  • apps/teetime/teetime-backend/src/admin/club-memberships.service.ts
  • libs/access-control/src/lib/club-permission.guard.ts

Club Roles

RoleDescriptionUse Case
CLUB_ADMINFull club administrationClub managers, owners
PRO_SHOP_STAFFBooking and payment operationsPro shop staff
COACHLesson managementTeaching professionals
PLAYERBasic member accessClub members

Tenant Roles

Higher-level roles for multi-club operations:

RoleCapabilities
OWNERFull tenant access, billing, feature toggles
ADMINOps manager: tee-sheet, pricing, calendars, reports
PROSHOPBooking/modify/cancel, payments, check-in
STARTERCheck-in, view tee-sheet, no-shows
FINANCEPayouts, settlements, refunds, exports
REGISTEREDSelf-booking, wallet, receipts

Permissions

Club-Level Permissions

PermissionCLUB_ADMINPRO_SHOP_STAFFCOACHPLAYER
TEE_SHEET_VIEW
TEE_SHEET_EDIT
REFUND_PROCESS
LESSON_MANAGE

Tenant-Level Permissions

CategoryPermissions
Tenant/Opstenant.update, feature.toggle, staff.invite, env.config
Tee Sheetteesheet.view, teesheet.create, teesheet.update, teesheet.lock
Bookingbooking.create, booking.modify, booking.cancel, booking.checkin
Pricingrates.view, rates.update, promo.manage, policy.manage
Membersmember.view, member.update, member.notes
Paymentspayment.charge, payment.refund, payout.view, export.journal
Reportsreport.view, report.download

Membership Model

interface Membership {
id: string;
userId: string;
clubId: string;
role: ClubRole;
scope?: JsonValue; // Fine-grained access
providerRole?: string; // External system role
active: boolean; // Enable/disable
}

Constraints:

  • Unique on [userId, clubId, role] — allows multiple roles per user per club
  • Indexed on [clubId, role] and [userId, active] for performance

API Endpoints

List Memberships

GET /admin/clubs/:clubId/memberships

// Response
[
{
"id": "mem-123",
"userId": "user-456",
"role": "CLUB_ADMIN",
"active": true
},
{
"id": "mem-789",
"userId": "user-abc",
"role": "PRO_SHOP_STAFF",
"active": true
}
]

Guards: JwtAuthGuard, AudienceGuard(teetime-admin), ClubPermissionGuard(TEE_SHEET_VIEW, CLUB_ADMIN)

Create/Update Membership

POST /admin/clubs/:clubId/memberships

// Request
{
"userId": "user-456",
"role": "PRO_SHOP_STAFF",
"active": true
}

// Response
{
"id": "mem-new",
"userId": "user-456",
"role": "PRO_SHOP_STAFF",
"active": true
}

Guards: JwtAuthGuard, AudienceGuard(teetime-admin), ClubPermissionGuard(TEE_SHEET_EDIT, CLUB_ADMIN)

Permission Guard

@Injectable()
export class ClubPermissionGuard implements CanActivate {
constructor(
private readonly required: Permission,
private readonly options: ClubPermissionGuardOptions
) {}

async canActivate(context: ExecutionContext): Promise<boolean> {
// 1. Extract userId from JWT
const userId = extractUserId(request);

// 2. Resolve clubId (from params, body, or courseId lookup)
const clubId = await resolveClubId(request, options);

// 3. Get user's roles (cached 60s)
const roles = await getUserClubRoles(userId, clubId);

// 4. Check role requirement if specified
if (options.requireRoles && !roles.some(r => options.requireRoles.includes(r))) {
return false;
}

// 5. Check permission for roles
return roles.some(role => hasPermission(role, required));
}
}

Usage:

@UseGuards(ClubPermissionGuard.with(Permission.TEE_SHEET_EDIT, { requireRoles: ['CLUB_ADMIN'] }))
@Post()
async createMembership() { ... }

Invitation System

Invitation Model

interface Invitation {
id: string;
token: string; // Unique invite token
userId: string; // Target user
tenantId: string;
requiredScopes: string[]; // Scopes to grant
expiresAt: Date;
usedAt?: Date; // When accepted
declinedAt?: Date; // When rejected
}

Invitation Lifecycle

┌─────────────┐     ┌─────────────┐     ┌─────────────┐
│ CREATE │────▶│ PENDING │────▶│ ACCEPTED │
│ (send link) │ │ (awaiting) │ │ (usedAt) │
└─────────────┘ └─────────────┘ └─────────────┘


┌─────────────┐
│ DECLINED │
│ (declinedAt)│
└─────────────┘

Invitation Methods

// Create invitation with expiration
createInvitation(userId, tenantId, scopes, expiresAt): Promise<Invitation>

// Validate token and check expiration
findInvitationByToken(token): Promise<Invitation | null>
isExpired(invitation): boolean

// Accept invitation - grants membership
markInvitationAsUsed(id): Promise<void>

// Reject or admin cancel
markInvitationAsDeclined(id): Promise<void>
markInvitationAsCanceled(id): Promise<void>

Role Caching

User roles are cached for performance:

// Get roles with 60-second TTL
const roles = await getUserClubRoles(userId, clubId);

// Invalidate on membership change
invalidateUserClubRolesCache(userId, clubId);

Cache Key: club:roles:${userId}:${clubId}

Membership Lifecycle

1. Invite User

// Admin creates invitation
const invite = await invitationsService.createInvitation(
userId,
tenantId,
['booking.create', 'teesheet.view'],
addDays(new Date(), 7) // 7-day expiration
);

// Send email with invite link
await sendInviteEmail(user.email, invite.token);

2. Accept Invitation

// User clicks invite link
const invite = await invitationsService.findInvitationByToken(token);

if (invitationsService.isExpired(invite)) {
throw new BadRequestException('Invitation expired');
}

// Mark as used and create membership
await invitationsService.markInvitationAsUsed(invite.id);
await membershipsService.upsert(clubId, userId, role, true);

3. Revoke Access

// Disable membership (soft revoke)
await membershipsService.upsert(clubId, userId, role, false);

// Cache automatically invalidated

4. Re-enable Access

// Re-activate membership
await membershipsService.upsert(clubId, userId, role, true);

Club ID Resolution

The permission guard resolves club ID from multiple sources:

  1. Request Params: req.params.clubId
  2. Request Body: req.body.clubId
  3. Query String: req.query.clubId
  4. Course Lookup: Derive from courseId → club relationship
const options: ClubPermissionGuardOptions = {
clubIdParam: 'clubId', // Default param name
requireRoles: ['CLUB_ADMIN'] // Optional role requirement
};

Security Features

FeatureDescription
JWT AuthenticationAll endpoints require valid JWT
Audience ValidationMust have teetime-admin scope
Role-Based AccessSpecific roles required for operations
Permission MappingGranular permissions per role
Cache InvalidationImmediate effect on role changes
Audit LoggingAll invitation state changes logged
Soft DeleteInvitations support soft delete
ExpirationInvitations auto-expire

Data Model (Prisma)

model Membership {
id String @id @default(uuid()) @db.Uuid
userId String
clubId String @db.Uuid
role ClubRole
scope Json?
providerRole String?
active Boolean @default(true)

club Club @relation(fields: [clubId], references: [id])

@@unique([userId, clubId, role])
@@index([clubId, role])
@@index([userId, active])
}

enum ClubRole {
CLUB_ADMIN
PRO_SHOP_STAFF
COACH
PLAYER
}

Troubleshooting

Permission Denied

  1. Verify user has active membership for club
  2. Check role has required permission
  3. Verify cache is not stale (wait 60s or invalidate)
  4. Check JWT has correct audience

Invitation Not Working

  1. Check invitation not expired
  2. Verify invitation not already used/declined
  3. Ensure token is correctly URL-encoded
  4. Check user ID matches invitation target

Role Changes Not Taking Effect

  1. Cache invalidates automatically on upsert
  2. If using external cache, verify invalidation
  3. Wait up to 60 seconds for TTL expiry