club integration

This commit is contained in:
Charmunks 2025-12-09 14:12:05 -05:00
parent 822c6f763e
commit 0f18d2d4be
11 changed files with 2107 additions and 7 deletions

View file

@ -5,6 +5,7 @@
import AdminPanel from './lib/AdminPanel.svelte';
import ThemeSwitcher from './lib/ThemeSwitcher.svelte';
import Settings from './lib/Settings.svelte';
import Clubs from './lib/Clubs.svelte';
import { API_BASE } from './config.js';
import { applyTheme, currentTheme } from './stores/theme.js';
import { get } from 'svelte/store';
@ -15,6 +16,7 @@
let spaces = [];
let showAdminPanel = false;
let showSettings = false;
let showClubs = false;
onMount(() => {
applyTheme(get(currentTheme));
@ -120,6 +122,11 @@
<button on:click={() => showSettings = false}>Back to Dashboard</button>
</div>
<Settings {user} authorization={user.authorization} on:update={handleUserUpdate} />
{:else if showClubs}
<div class="nav-header">
<button on:click={() => showClubs = false}>Back to Dashboard</button>
</div>
<Clubs authorization={user.authorization} {user} />
{:else}
{#if user.is_admin}
<div class="admin-link">
@ -132,6 +139,7 @@
username={user.username}
on:signout={handleSignOut}
on:settings={() => showSettings = true}
on:clubs={() => showClubs = true}
/>
{/if}
{:else}

View file

@ -1,4 +1,4 @@
export const API_BASE = 'https://t0080w08wcockgs44ws8w880.b.selfhosted.hackclub.com/api/v1';
export const API_BASE = 'http://localhost:2593/api/v1';
export const ERROR_MESSAGES = {
NETWORK_ERROR: 'Network error. Please try again.',

725
client/src/lib/Clubs.svelte Normal file
View file

@ -0,0 +1,725 @@
<script>
import { createEventDispatcher, onMount } from 'svelte';
import { API_BASE } from '../config.js';
export let authorization = '';
export let user = null;
const dispatch = createEventDispatcher();
let clubData = null;
let ships = [];
let sharedSpaces = [];
let loading = true;
let linkLoading = false;
let shipsLoading = false;
let sharedLoading = false;
let error = '';
let message = '';
let activeTab = 'info';
onMount(() => {
loadClubData();
});
async function loadClubData() {
loading = true;
error = '';
try {
const response = await fetch(`${API_BASE}/clubs/me`, {
headers: {
'Authorization': authorization
}
});
const data = await response.json();
if (response.ok && data.success) {
clubData = data.data.club;
if (clubData) {
loadShips();
if (clubData.role === 'leader') {
loadSharedSpaces();
}
}
} else {
error = data.message || 'Failed to load club data';
}
} catch (err) {
error = 'Network error. Please try again.';
console.error(err);
} finally {
loading = false;
}
}
async function loadShips() {
shipsLoading = true;
try {
const response = await fetch(`${API_BASE}/clubs/me/ships`, {
headers: {
'Authorization': authorization
}
});
const data = await response.json();
if (response.ok && data.success) {
ships = data.data.ships || [];
}
} catch (err) {
console.error('Failed to load ships:', err);
} finally {
shipsLoading = false;
}
}
async function loadSharedSpaces() {
sharedLoading = true;
try {
const response = await fetch(`${API_BASE}/spaces/shared-with-me`, {
headers: {
'Authorization': authorization
}
});
const data = await response.json();
if (response.ok && data.success) {
sharedSpaces = data.data.spaces || [];
}
} catch (err) {
console.error('Failed to load shared spaces:', err);
} finally {
sharedLoading = false;
}
}
async function linkClub() {
linkLoading = true;
error = '';
message = '';
try {
const response = await fetch(`${API_BASE}/clubs/link`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': authorization
}
});
const data = await response.json();
if (response.ok && data.success) {
message = 'Successfully linked to your club!';
await loadClubData();
} else {
error = data.message || 'Failed to link club';
}
} catch (err) {
error = 'Network error. Please try again.';
console.error(err);
} finally {
linkLoading = false;
}
}
async function unlinkClub() {
if (!confirm('Are you sure you want to unlink from your club?')) {
return;
}
linkLoading = true;
error = '';
message = '';
try {
const response = await fetch(`${API_BASE}/clubs/unlink`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': authorization
}
});
const data = await response.json();
if (response.ok && data.success) {
message = 'Successfully unlinked from club';
clubData = null;
ships = [];
sharedSpaces = [];
} else {
error = data.message || 'Failed to unlink club';
}
} catch (err) {
error = 'Network error. Please try again.';
console.error(err);
} finally {
linkLoading = false;
}
}
function getRoleBadgeClass(role) {
return role === 'leader' ? 'role-leader' : 'role-member';
}
function formatDate(dateString) {
if (!dateString) return 'Unknown';
return new Date(dateString).toLocaleDateString();
}
</script>
<div class="clubs-container">
<h2>My Club</h2>
{#if loading}
<div class="loading">Loading club information...</div>
{:else if !clubData}
<div class="no-club">
<div class="no-club-icon">🏫</div>
<h3>No Club Linked</h3>
<p>Link your Hack Club to see your club information and share spaces with your club leaders.</p>
<p class="info-text">Link using your registered email as a club leader or member.</p>
{#if error}
<div class="error">{error}</div>
{/if}
{#if message}
<div class="success">{message}</div>
{/if}
<button class="btn-primary" on:click={linkClub} disabled={linkLoading}>
{linkLoading ? 'Linking...' : 'Link My Club'}
</button>
</div>
{:else}
<div class="club-tabs">
<button
class="tab-btn"
class:active={activeTab === 'info'}
on:click={() => activeTab = 'info'}
>
Club Info
</button>
<button
class="tab-btn"
class:active={activeTab === 'ships'}
on:click={() => activeTab = 'ships'}
>
Ships ({ships.length})
</button>
{#if clubData.role === 'leader'}
<button
class="tab-btn"
class:active={activeTab === 'shared'}
on:click={() => activeTab = 'shared'}
>
Shared Spaces ({sharedSpaces.length})
</button>
{/if}
</div>
{#if error}
<div class="error">{error}</div>
{/if}
{#if message}
<div class="success">{message}</div>
{/if}
{#if activeTab === 'info'}
<div class="club-info-card">
<div class="club-header">
<h3>{clubData.displayName || clubData.name}</h3>
<span class="role-badge {getRoleBadgeClass(clubData.role)}">
{clubData.role}
</span>
</div>
<div class="club-details">
{#if clubData.country}
<div class="detail-row">
<span class="label">Country:</span>
<span class="value">{clubData.country}</span>
</div>
{/if}
{#if clubData.metadata}
{#if clubData.metadata.status}
<div class="detail-row">
<span class="label">Status:</span>
<span class="value status-badge">{clubData.metadata.status}</span>
</div>
{/if}
{#if clubData.metadata.level}
<div class="detail-row">
<span class="label">Level:</span>
<span class="value">{clubData.metadata.level}</span>
</div>
{/if}
{#if clubData.metadata.attendees}
<div class="detail-row">
<span class="label">Est. Attendees:</span>
<span class="value">{clubData.metadata.attendees}</span>
</div>
{/if}
{#if clubData.metadata.meetingDays}
<div class="detail-row">
<span class="label">Meeting Days:</span>
<span class="value">{clubData.metadata.meetingDays}</span>
</div>
{/if}
{/if}
<div class="detail-row">
<span class="label">Last Verified:</span>
<span class="value">{formatDate(clubData.lastVerifiedAt)}</span>
</div>
</div>
<div class="club-actions">
<button class="btn-secondary" on:click={loadClubData} disabled={loading}>
Refresh
</button>
<button class="btn-danger" on:click={unlinkClub} disabled={linkLoading}>
{linkLoading ? 'Unlinking...' : 'Unlink Club'}
</button>
</div>
</div>
{:else if activeTab === 'ships'}
<div class="ships-section">
{#if shipsLoading}
<div class="loading">Loading ships...</div>
{:else if ships.length === 0}
<div class="empty-state">
<p>No ships found for your club yet.</p>
</div>
{:else}
<div class="ships-grid">
{#each ships as ship}
<div class="ship-card">
<div class="ship-name">{ship.workshop || 'Unnamed Ship'}</div>
{#if ship.rating}
<div class="ship-rating">{ship.rating}</div>
{/if}
{#if ship.codeUrl}
<a href={ship.codeUrl} target="_blank" rel="noopener noreferrer" class="ship-link">
View Code →
</a>
{/if}
{#if ship.ysws && ship.ysws.length > 0}
<div class="ship-ysws">
{#each ship.ysws as ysws}
<span class="ysws-badge">{ysws}</span>
{/each}
</div>
{/if}
</div>
{/each}
</div>
{/if}
</div>
{:else if activeTab === 'shared'}
<div class="shared-section">
{#if sharedLoading}
<div class="loading">Loading shared spaces...</div>
{:else if sharedSpaces.length === 0}
<div class="empty-state">
<p>No spaces have been shared with you yet.</p>
<p class="hint">Club members can share their spaces with you from the Dashboard.</p>
</div>
{:else}
<div class="shared-spaces-grid">
{#each sharedSpaces as space}
<div class="shared-space-card">
<div class="space-header">
<span class="space-type">{space.type}</span>
<span class="space-status" class:running={space.running}>
{space.running ? 'Running' : 'Stopped'}
</span>
</div>
<div class="space-owner">
Shared by: <strong>{space.owner.username}</strong>
</div>
<div class="space-meta">
<span>Shared: {formatDate(space.sharedAt)}</span>
</div>
{#if space.running && space.accessUrl}
<a href={space.accessUrl} target="_blank" rel="noopener noreferrer" class="btn-primary space-open-btn">
Open Space
</a>
{:else}
<div class="space-stopped-hint">Space must be running to access</div>
{/if}
</div>
{/each}
</div>
{/if}
</div>
{/if}
{/if}
</div>
<style>
.clubs-container {
max-width: 900px;
margin: 0 auto;
padding: 20px;
}
h2 {
margin-bottom: 20px;
color: var(--text);
}
.loading {
text-align: center;
padding: 40px;
color: var(--muted);
}
.no-club {
text-align: center;
padding: 60px 20px;
background: var(--snow);
border-radius: 12px;
border: 1px solid var(--smoke);
}
.no-club-icon {
font-size: 64px;
margin-bottom: 20px;
}
.no-club h3 {
margin-bottom: 10px;
color: var(--text);
}
.no-club p {
color: var(--muted);
margin-bottom: 10px;
}
.info-text {
font-size: 14px;
font-style: italic;
}
.club-tabs {
display: flex;
gap: 4px;
margin-bottom: 20px;
background: var(--snow);
padding: 4px;
border-radius: 8px;
border: 1px solid var(--smoke);
}
.tab-btn {
flex: 1;
padding: 10px 16px;
border: none;
background: transparent;
color: var(--muted);
cursor: pointer;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
transition: all 0.2s;
}
.tab-btn:hover {
background: var(--smoke);
color: var(--text);
}
.tab-btn.active {
background: var(--blue);
color: var(--white);
}
.club-info-card {
background: var(--snow);
border: 1px solid var(--smoke);
border-radius: 12px;
padding: 24px;
}
.club-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
padding-bottom: 16px;
border-bottom: 1px solid var(--smoke);
}
.club-header h3 {
margin: 0;
color: var(--text);
font-size: 24px;
}
.role-badge {
padding: 6px 16px;
border-radius: 20px;
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
}
.role-leader {
background: rgba(51, 214, 166, 0.15);
color: var(--green);
}
.role-member {
background: rgba(90, 95, 255, 0.15);
color: var(--blue);
}
.club-details {
display: grid;
gap: 12px;
margin-bottom: 24px;
}
.detail-row {
display: flex;
gap: 12px;
}
.detail-row .label {
font-weight: 500;
color: var(--text);
min-width: 120px;
}
.detail-row .value {
color: var(--muted);
}
.status-badge {
padding: 2px 8px;
background: rgba(246, 173, 85, 0.15);
color: var(--orange);
border-radius: 4px;
font-size: 12px;
}
.club-actions {
display: flex;
gap: 12px;
}
.btn-primary {
background: var(--blue);
color: var(--white);
border: none;
padding: 10px 20px;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
font-weight: 500;
transition: background 0.2s;
}
.btn-primary:hover {
background: var(--cyan);
}
.btn-primary:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.btn-secondary {
background: var(--slate);
color: var(--white);
border: none;
padding: 10px 20px;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
transition: background 0.2s;
}
.btn-secondary:hover {
background: var(--muted);
}
.btn-danger {
background: transparent;
color: var(--red);
border: 1px solid var(--red);
padding: 10px 20px;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
transition: all 0.2s;
}
.btn-danger:hover {
background: rgba(236, 55, 80, 0.1);
}
.error {
color: var(--red);
margin-bottom: 15px;
padding: 10px;
background: rgba(236, 55, 80, 0.1);
border-radius: 4px;
}
.success {
color: var(--green);
margin-bottom: 15px;
padding: 10px;
background: rgba(51, 214, 166, 0.1);
border-radius: 4px;
}
.ships-section, .shared-section {
background: var(--snow);
border: 1px solid var(--smoke);
border-radius: 12px;
padding: 24px;
}
.empty-state {
text-align: center;
padding: 40px;
color: var(--muted);
}
.empty-state .hint {
font-size: 14px;
font-style: italic;
}
.ships-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 16px;
}
.ship-card {
background: var(--white);
border: 1px solid var(--smoke);
border-radius: 8px;
padding: 16px;
}
.ship-name {
font-weight: 600;
color: var(--text);
margin-bottom: 8px;
}
.ship-rating {
color: var(--orange);
font-size: 14px;
margin-bottom: 8px;
}
.ship-link {
color: var(--blue);
text-decoration: none;
font-size: 14px;
}
.ship-link:hover {
text-decoration: underline;
}
.ship-ysws {
margin-top: 12px;
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.ysws-badge {
background: rgba(90, 95, 255, 0.1);
color: var(--blue);
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
}
.shared-spaces-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 16px;
}
.shared-space-card {
background: var(--white);
border: 1px solid var(--smoke);
border-radius: 8px;
padding: 16px;
}
.space-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.space-type {
font-weight: 600;
color: var(--text);
}
.space-status {
padding: 4px 10px;
border-radius: 12px;
font-size: 12px;
background: rgba(156, 163, 175, 0.2);
color: var(--muted);
}
.space-status.running {
background: rgba(51, 214, 166, 0.15);
color: var(--green);
}
.space-owner {
color: var(--muted);
font-size: 14px;
margin-bottom: 8px;
}
.space-owner strong {
color: var(--text);
}
.space-meta {
color: var(--muted);
font-size: 12px;
margin-bottom: 12px;
}
.space-open-btn {
display: block;
text-align: center;
text-decoration: none;
width: 100%;
box-sizing: border-box;
}
.space-stopped-hint {
text-align: center;
color: var(--muted);
font-size: 13px;
padding: 8px;
background: var(--snow);
border-radius: 4px;
}
</style>

View file

@ -5,6 +5,7 @@
import { currentTheme } from '../stores/theme.js';
import { themes } from '../themes.js';
import FlagIcon from '../assets/flag.svg?raw';
import ShareWithClubToggle from './ShareWithClubToggle.svelte';
export let spaces = [];
export let authorization = '';
@ -21,6 +22,48 @@
let actionError = {};
let dropdownOpen = false;
let showPassword = false;
let clubData = null;
let spaceShareStatus = {};
onMount(() => {
loadClubData();
});
async function loadClubData() {
try {
const response = await fetch(`${API_BASE}/clubs/me`, {
headers: {
'Authorization': authorization
}
});
const data = await response.json();
if (response.ok && data.success && data.data.club) {
clubData = data.data.club;
loadSpaceShareStatuses();
}
} catch (err) {
console.error('Failed to load club data:', err);
}
}
async function loadSpaceShareStatuses() {
for (const space of spaces) {
try {
const response = await fetch(`${API_BASE}/spaces/${space.id}/share/status`, {
headers: {
'Authorization': authorization
}
});
const data = await response.json();
if (response.ok && data.success) {
spaceShareStatus[space.id] = data.data;
spaceShareStatus = spaceShareStatus;
}
} catch (err) {
console.error('Failed to load share status:', err);
}
}
}
const spaceTypes = [
{ value: 'code-server', label: 'VS Code Server', description: 'Web-based code editor' },
@ -255,6 +298,10 @@
function handleSettings() {
dispatch('settings');
}
function handleClubs() {
dispatch('clubs');
}
</script>
<div class="dashboard">
@ -269,6 +316,7 @@
</div>
</div>
<div class="header-actions">
<button class="clubs-button" on:click={handleClubs}>My Club</button>
<button class="settings-button" on:click={handleSettings}>Settings</button>
<button class="signout-button" on:click={handleSignOut}>Sign Out</button>
</div>
@ -403,6 +451,16 @@
<p><strong>Created:</strong> {new Date(space.created_at).toLocaleString()}</p>
</div>
<div class="space-share-section">
<ShareWithClubToggle
spaceId={space.id}
{authorization}
hasClub={!!clubData}
initialShared={spaceShareStatus[space.id]?.shared || false}
clubName={clubData?.displayName || clubData?.name || ''}
/>
</div>
{#if actionError[space.id]}
<div class="error-message small">{actionError[space.id]}</div>
{/if}

View file

@ -0,0 +1,139 @@
<script>
import { API_BASE } from '../config.js';
export let spaceId;
export let authorization;
export let hasClub = false;
export let initialShared = false;
export let clubName = '';
let shared = initialShared;
let loading = false;
let error = '';
async function toggleShare() {
if (!hasClub) return;
loading = true;
error = '';
const newShareState = !shared;
try {
const response = await fetch(`${API_BASE}/spaces/${spaceId}/share/club`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': authorization
},
body: JSON.stringify({ share: newShareState })
});
const data = await response.json();
if (response.ok && data.success) {
shared = newShareState;
if (data.data?.clubName) {
clubName = data.data.clubName;
}
} else {
error = data.message || 'Failed to update sharing';
}
} catch (err) {
error = 'Network error';
console.error(err);
} finally {
loading = false;
}
}
</script>
{#if hasClub}
<div class="share-toggle-container">
<button
class="share-toggle"
class:shared={shared}
class:loading={loading}
on:click={toggleShare}
disabled={loading}
title={shared ? `Shared with ${clubName || 'your club'} leaders` : 'Share with club leaders'}
>
{#if loading}
<span class="spinner"></span>
{:else if shared}
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 256 256">
<path d="M230.91,172A8,8,0,0,1,228,182.91l-96,56a8,8,0,0,1-8.06,0l-96-56A8,8,0,0,1,36,169.09l92,53.65,92-53.65A8,8,0,0,1,230.91,172ZM220,121.09l-92,53.65L36,121.09A8,8,0,0,0,28,134.91l96,56a8,8,0,0,0,8.06,0l96-56A8,8,0,1,0,220,121.09ZM24,80a8,8,0,0,1,4-6.91l96-56a8,8,0,0,1,8.06,0l96,56a8,8,0,0,1,0,13.82l-96,56a8,8,0,0,1-8.06,0l-96-56A8,8,0,0,1,24,80Zm23.88,0L128,126.74,208.12,80,128,33.26Z"></path>
</svg>
Shared
{:else}
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 256 256">
<path d="M229.66,109.66l-48,48a8,8,0,0,1-11.32-11.32L204.69,112H165a88,88,0,0,0-85.23,66,8,8,0,0,1-15.5-4A103.94,103.94,0,0,1,165,96h39.71L170.34,61.66a8,8,0,0,1,11.32-11.32l48,48A8,8,0,0,1,229.66,109.66ZM192,208H40V88a8,8,0,0,0-16,0V208a16,16,0,0,0,16,16H192a8,8,0,0,0,0-16Z"></path>
</svg>
Share
{/if}
</button>
{#if error}
<span class="share-error" title={error}>⚠</span>
{/if}
</div>
{/if}
<style>
.share-toggle-container {
display: inline-flex;
align-items: center;
gap: 4px;
}
.share-toggle {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
border: 1px solid var(--smoke);
background: var(--white);
color: var(--muted);
border-radius: 6px;
cursor: pointer;
font-size: 12px;
font-weight: 500;
transition: all 0.2s;
}
.share-toggle:hover {
border-color: var(--blue);
color: var(--blue);
}
.share-toggle.shared {
background: rgba(51, 214, 166, 0.1);
border-color: var(--green);
color: var(--green);
}
.share-toggle.loading {
opacity: 0.6;
cursor: wait;
}
.share-toggle:disabled {
cursor: not-allowed;
}
.spinner {
width: 12px;
height: 12px;
border: 2px solid currentColor;
border-top-color: transparent;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.share-error {
color: var(--red);
cursor: help;
}
</style>

View file

@ -91,6 +91,30 @@
transform: scale(1);
}
.clubs-button {
padding: 12px 24px;
background: transparent;
border: 2px solid var(--green);
border-radius: 99999px;
color: var(--green);
font-size: 16px;
font-weight: bold;
font-family: Phantom Sans, sans-serif;
cursor: pointer;
transition: transform 0.125s ease-in-out, box-shadow 0.125s ease-in-out;
-webkit-tap-highlight-color: transparent;
}
.clubs-button:hover {
transform: scale(1.0625);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.125);
background: rgba(51, 214, 166, 0.1);
}
.clubs-button:active {
transform: scale(1);
}
.header-actions {
display: flex;
gap: 16px;
@ -468,6 +492,12 @@
text-decoration: underline;
}
.space-share-section {
margin-bottom: 12px;
padding-top: 8px;
border-top: 1px solid var(--smoke);
}
.space-actions {
display: flex;
gap: 8px;

View file

@ -0,0 +1,369 @@
import express from 'express';
import { getUser } from '../../utils/user.js';
import {
checkClubAffiliation,
getOrCreateClubByName,
linkUserToClub,
getUserPrimaryMembership,
refreshMembershipIfStale,
getClubShips,
getClubInfo,
getClubLevel,
getClubStatus,
getClubMembers,
unlinkUserFromClub
} from '../../utils/clubs.js';
import { clubsLimiter } from '../../middlewares/rate-limit.middleware.js';
const router = express.Router();
router.get('/', (req, res) => {
res.status(200).json({
message: 'Clubs API Route'
});
});
router.post('/link', clubsLimiter, async (req, res) => {
try {
const authorization = req.headers.authorization;
if (!authorization) {
return res.status(401).json({
success: false,
message: 'Unauthorized'
});
}
const user = await getUser(authorization);
if (!user) {
return res.status(401).json({
success: false,
message: 'Invalid authorization token'
});
}
const existingMembership = await getUserPrimaryMembership(user.id);
if (existingMembership) {
return res.status(400).json({
success: false,
message: 'You are already linked to a club. Unlink first to link to a different club.'
});
}
const { affiliated, role, clubName } = await checkClubAffiliation(user.email);
if (!affiliated || !clubName) {
return res.status(404).json({
success: false,
message: 'No club found for your account. Make sure you are registered as a leader or member with your email.'
});
}
const club = await getOrCreateClubByName(clubName);
if (!club) {
return res.status(500).json({
success: false,
message: 'Failed to create club record'
});
}
const source = role === 'leader' ? 'leader_email' : 'member_lookup';
const membership = await linkUserToClub(user.id, club.id, role, source);
res.status(200).json({
success: true,
message: `Successfully linked to club as ${role}`,
data: {
clubName: club.club_name,
displayName: club.display_name,
role: membership.role,
linkedAt: membership.created_at
}
});
} catch (error) {
console.error('Error in /link route:', error);
res.status(500).json({
success: false,
message: 'Failed to link club',
error: process.env.NODE_ENV === 'development' ? error.message : undefined
});
}
});
router.post('/unlink', clubsLimiter, async (req, res) => {
try {
const authorization = req.headers.authorization;
if (!authorization) {
return res.status(401).json({
success: false,
message: 'Unauthorized'
});
}
const user = await getUser(authorization);
if (!user) {
return res.status(401).json({
success: false,
message: 'Invalid authorization token'
});
}
const membership = await getUserPrimaryMembership(user.id);
if (!membership) {
return res.status(400).json({
success: false,
message: 'You are not linked to any club'
});
}
await unlinkUserFromClub(user.id, membership.club_id);
res.status(200).json({
success: true,
message: 'Successfully unlinked from club'
});
} catch (error) {
console.error('Error in /unlink route:', error);
res.status(500).json({
success: false,
message: 'Failed to unlink club',
error: process.env.NODE_ENV === 'development' ? error.message : undefined
});
}
});
router.get('/me', clubsLimiter, async (req, res) => {
try {
const authorization = req.headers.authorization;
if (!authorization) {
return res.status(401).json({
success: false,
message: 'Unauthorized'
});
}
const user = await getUser(authorization);
if (!user) {
return res.status(401).json({
success: false,
message: 'Invalid authorization token'
});
}
const membership = await refreshMembershipIfStale(user.id, 24);
if (!membership) {
return res.status(200).json({
success: true,
data: {
club: null,
message: 'No club linked to your account'
}
});
}
let metadata = membership.metadata;
if (typeof metadata === 'string') {
try {
metadata = JSON.parse(metadata);
} catch (e) {
metadata = null;
}
}
res.status(200).json({
success: true,
data: {
club: {
name: membership.club_name,
displayName: membership.display_name || membership.club_name,
country: membership.country,
role: membership.role,
isPrimary: membership.is_primary,
lastVerifiedAt: membership.last_verified_at,
metadata: metadata ? {
attendees: metadata['Est. # of Attendees'],
meetingDays: metadata.call_meeting_days,
meetingLength: metadata.call_meeting_length,
status: metadata.club_status,
level: metadata.level
} : null
}
}
});
} catch (error) {
console.error('Error in /me route:', error);
res.status(500).json({
success: false,
message: 'Failed to get club info',
error: process.env.NODE_ENV === 'development' ? error.message : undefined
});
}
});
router.get('/me/details', clubsLimiter, async (req, res) => {
try {
const authorization = req.headers.authorization;
if (!authorization) {
return res.status(401).json({
success: false,
message: 'Unauthorized'
});
}
const user = await getUser(authorization);
if (!user) {
return res.status(401).json({
success: false,
message: 'Invalid authorization token'
});
}
const membership = await getUserPrimaryMembership(user.id);
if (!membership) {
return res.status(404).json({
success: false,
message: 'No club linked to your account'
});
}
const [info, level, status] = await Promise.all([
getClubInfo(membership.club_name),
getClubLevel(membership.club_name),
getClubStatus(membership.club_name)
]);
res.status(200).json({
success: true,
data: {
clubName: membership.club_name,
displayName: membership.display_name,
role: membership.role,
info: info ? {
attendees: info['Est. # of Attendees'],
meetingDays: info.call_meeting_days,
meetingLength: info.call_meeting_length,
country: info.venue_address_country
} : null,
level,
status
}
});
} catch (error) {
console.error('Error in /me/details route:', error);
res.status(500).json({
success: false,
message: 'Failed to get club details',
error: process.env.NODE_ENV === 'development' ? error.message : undefined
});
}
});
router.get('/me/ships', clubsLimiter, async (req, res) => {
try {
const authorization = req.headers.authorization;
if (!authorization) {
return res.status(401).json({
success: false,
message: 'Unauthorized'
});
}
const user = await getUser(authorization);
if (!user) {
return res.status(401).json({
success: false,
message: 'Invalid authorization token'
});
}
const membership = await getUserPrimaryMembership(user.id);
if (!membership) {
return res.status(404).json({
success: false,
message: 'No club linked to your account'
});
}
const ships = await getClubShips(membership.club_name);
res.status(200).json({
success: true,
data: {
clubName: membership.club_name,
ships: ships.map(ship => ({
workshop: ship.workshop,
rating: ship.Rating,
codeUrl: ship.code_url,
ysws: ship['YSWSName (from Unified YSWS Database)']
}))
}
});
} catch (error) {
console.error('Error in /me/ships route:', error);
res.status(500).json({
success: false,
message: 'Failed to get club ships',
error: process.env.NODE_ENV === 'development' ? error.message : undefined
});
}
});
router.get('/me/members', clubsLimiter, async (req, res) => {
try {
const authorization = req.headers.authorization;
if (!authorization) {
return res.status(401).json({
success: false,
message: 'Unauthorized'
});
}
const user = await getUser(authorization);
if (!user) {
return res.status(401).json({
success: false,
message: 'Invalid authorization token'
});
}
const membership = await getUserPrimaryMembership(user.id);
if (!membership) {
return res.status(404).json({
success: false,
message: 'No club linked to your account'
});
}
if (membership.role !== 'leader') {
return res.status(403).json({
success: false,
message: 'Only club leaders can view member list'
});
}
const rawMembers = await getClubMembers(membership.club_name);
res.status(200).json({
success: true,
data: {
clubName: membership.club_name,
members: rawMembers
}
});
} catch (error) {
console.error('Error in /me/members route:', error);
res.status(500).json({
success: false,
message: 'Failed to get club members',
error: process.env.NODE_ENV === 'development' ? error.message : undefined
});
}
});
export default router;

View file

@ -3,6 +3,7 @@ import spaces from './spaces/space.route.js';
import users from './users/users.route.js';
import admin from './admin/admin.route.js';
import oauth from './oauth/oauth.route.js';
import clubs from './clubs/clubs.route.js';
const router = express.Router();
@ -16,5 +17,6 @@ router.use('/spaces/', spaces);
router.use('/users/', users);
router.use('/admin/', admin);
router.use('/oauth/', oauth);
router.use('/clubs/', clubs);
export default router;

View file

@ -7,11 +7,21 @@ import {
getUserSpaces,
deleteSpace
} from "../../utils/spaces.js";
import { containerOpsLimiter } from "../../middlewares/rate-limit.middleware.js";
import { getUser } from "../../utils/user.js";
import {
getUserPrimaryMembership,
shareSpaceWithClub,
revokeSpaceClubShare,
getSpaceShareStatus,
getSpacesSharedWithLeader,
getLeaderMemberships
} from "../../utils/clubs.js";
import pg from "../../utils/db.js";
import { containerOpsLimiter, spaceShareLimiter } from "../../middlewares/rate-limit.middleware.js";
const router = express.Router();
router.post("/create", /* containerOpsLimiter, */ async (req, res) => {
router.post("/create", containerOpsLimiter, async (req, res) => {
const { password, type } = req.body;
const authorization = req.headers.authorization;
@ -31,7 +41,7 @@ router.post("/create", /* containerOpsLimiter, */ async (req, res) => {
}
});
router.post("/start/:spaceId", /* containerOpsLimiter, */ async (req, res) => {
router.post("/start/:spaceId", containerOpsLimiter, async (req, res) => {
const { spaceId } = req.params;
const authorization = req.headers.authorization;
@ -44,7 +54,7 @@ router.post("/start/:spaceId", /* containerOpsLimiter, */ async (req, res) => {
}
});
router.post("/stop/:spaceId", /* containerOpsLimiter, */ async (req, res) => {
router.post("/stop/:spaceId", containerOpsLimiter, async (req, res) => {
const { spaceId } = req.params;
const authorization = req.headers.authorization;
@ -57,7 +67,7 @@ router.post("/stop/:spaceId", /* containerOpsLimiter, */ async (req, res) => {
}
});
router.get("/status/:spaceId", async (req, res) => {
router.get("/status/:spaceId", containerOpsLimiter, async (req, res) => {
const { spaceId } = req.params;
const authorization = req.headers.authorization;
@ -86,7 +96,7 @@ router.get("/list", async (req, res) => {
}
});
router.delete("/delete/:spaceId", async (req, res) => {
router.delete("/delete/:spaceId", containerOpsLimiter, async (req, res) => {
const { spaceId } = req.params;
const authorization = req.headers.authorization;
@ -99,4 +109,206 @@ router.delete("/delete/:spaceId", async (req, res) => {
}
});
router.post("/:spaceId/share/club", spaceShareLimiter, async (req, res) => {
const { spaceId } = req.params;
const { share } = req.body;
const authorization = req.headers.authorization;
try {
if (!authorization) {
return res.status(401).json({
success: false,
message: 'Unauthorized'
});
}
const user = await getUser(authorization);
if (!user) {
return res.status(401).json({
success: false,
message: 'Invalid authorization token'
});
}
const space = await pg('spaces')
.where('id', spaceId)
.where('user_id', user.id)
.first();
if (!space) {
return res.status(404).json({
success: false,
message: 'Space not found or not owned by you'
});
}
const membership = await getUserPrimaryMembership(user.id);
if (!membership) {
return res.status(400).json({
success: false,
message: 'You must be linked to a club to share spaces'
});
}
if (share === true) {
await shareSpaceWithClub(space.id, membership.club_id, user.id);
res.status(200).json({
success: true,
message: 'Space shared with your club leaders',
data: {
spaceId: space.id,
clubName: membership.club_name,
shared: true
}
});
} else if (share === false) {
await revokeSpaceClubShare(space.id, membership.club_id);
res.status(200).json({
success: true,
message: 'Space sharing revoked',
data: {
spaceId: space.id,
shared: false
}
});
} else {
return res.status(400).json({
success: false,
message: 'Invalid share value. Must be true or false.'
});
}
} catch (err) {
console.error('Error in share/club route:', err);
res.status(500).json({
success: false,
message: 'Failed to update share status',
error: process.env.NODE_ENV === 'development' ? err.message : undefined
});
}
});
router.get("/:spaceId/share/status", async (req, res) => {
const { spaceId } = req.params;
const authorization = req.headers.authorization;
try {
if (!authorization) {
return res.status(401).json({
success: false,
message: 'Unauthorized'
});
}
const user = await getUser(authorization);
if (!user) {
return res.status(401).json({
success: false,
message: 'Invalid authorization token'
});
}
const space = await pg('spaces')
.where('id', spaceId)
.where('user_id', user.id)
.first();
if (!space) {
return res.status(404).json({
success: false,
message: 'Space not found or not owned by you'
});
}
const shareStatus = await getSpaceShareStatus(space.id);
res.status(200).json({
success: true,
data: {
spaceId: space.id,
shared: !!shareStatus,
clubName: shareStatus?.club_name || null,
sharedAt: shareStatus?.created_at || null
}
});
} catch (err) {
console.error('Error in share/status route:', err);
res.status(500).json({
success: false,
message: 'Failed to get share status',
error: process.env.NODE_ENV === 'development' ? err.message : undefined
});
}
});
router.get("/shared-with-me", async (req, res) => {
const authorization = req.headers.authorization;
try {
if (!authorization) {
return res.status(401).json({
success: false,
message: 'Unauthorized'
});
}
const user = await getUser(authorization);
if (!user) {
return res.status(401).json({
success: false,
message: 'Invalid authorization token'
});
}
const leaderMemberships = await getLeaderMemberships(user.id);
if (leaderMemberships.length === 0) {
return res.status(200).json({
success: true,
data: {
isLeader: false,
spaces: [],
message: 'You are not a leader of any club'
}
});
}
const sharedSpaces = await getSpacesSharedWithLeader(user.id);
res.status(200).json({
success: true,
data: {
isLeader: true,
clubs: leaderMemberships.map(m => ({
name: m.club_name,
displayName: m.display_name
})),
spaces: sharedSpaces.map(space => ({
id: space.space_id,
type: space.type,
description: space.description,
accessUrl: space.access_url,
running: space.running,
createdAt: space.created_at,
owner: {
id: space.owner_id,
username: space.owner_username
},
club: {
name: space.club_name,
displayName: space.club_display_name
},
permission: space.permission,
sharedAt: space.shared_at
}))
}
});
} catch (err) {
console.error('Error in shared-with-me route:', err);
res.status(500).json({
success: false,
message: 'Failed to get shared spaces',
error: process.env.NODE_ENV === 'development' ? err.message : undefined
});
}
});
export default router;

View file

@ -48,3 +48,37 @@ export const containerOpsLimiter = rateLimit({
});
}
});
export const clubsLimiter = rateLimit({
windowMs: 10 * 60 * 1000,
max: 30,
standardHeaders: true,
legacyHeaders: false,
message: {
success: false,
message: 'Too many club API requests. Please try again in 10 minutes.'
},
handler: (req, res) => {
res.status(429).json({
success: false,
message: 'Too many club API requests. Please try again in 10 minutes.'
});
}
});
export const spaceShareLimiter = rateLimit({
windowMs: 60 * 1000,
max: 10,
standardHeaders: true,
legacyHeaders: false,
message: {
success: false,
message: 'Too many share requests. Please try again in 1 minute.'
},
handler: (req, res) => {
res.status(429).json({
success: false,
message: 'Too many share requests. Please try again in 1 minute.'
});
}
});

523
src/utils/clubs.js Normal file
View file

@ -0,0 +1,523 @@
import pg from './db.js';
const CLUBS_API_KEY = process.env.HACKCLUB_CLUBS_API_KEY;
const CLUBS_API_BASE = 'https://clubapi.hackclub.com';
const clubInfoCache = new Map();
const CACHE_TTL_MS = 5 * 60 * 1000;
async function callClubsAPI(path, params = {}, requireAuth = true) {
const url = new URL(`${CLUBS_API_BASE}${path}`);
Object.entries(params).forEach(([k, v]) => {
if (v !== undefined && v !== null) {
url.searchParams.set(k, v);
}
});
const headers = {};
if (requireAuth) {
if (!CLUBS_API_KEY) {
throw new Error('Clubs API key not configured');
}
headers['Authorization'] = CLUBS_API_KEY;
}
const resp = await fetch(url.toString(), { headers });
if (!resp.ok) {
if (resp.status === 404) {
return null;
}
throw new Error(`Clubs API error: ${resp.status}`);
}
const contentType = resp.headers.get('content-type');
if (!contentType || !contentType.includes('application/json')) {
const text = (await resp.text()).trim();
if (!text || text.toLowerCase().includes('not found')) {
return null;
}
return { _plainText: text };
}
return resp.json();
}
export async function getOrCreateClubByName(clubName) {
if (!clubName) {
return null;
}
let club = await pg('clubs')
.where('club_name', clubName)
.first();
if (club) {
return club;
}
try {
const clubInfo = await callClubsAPI('/club', { name: clubName }, false);
const [newClub] = await pg('clubs')
.insert({
club_name: clubName,
display_name: clubInfo?.club_name || clubName,
country: clubInfo?.venue_address_country,
metadata: clubInfo ? JSON.stringify(clubInfo) : null,
last_synced_at: new Date()
})
.onConflict('club_name')
.merge(['display_name', 'country', 'metadata', 'last_synced_at'])
.returning('*');
return newClub;
} catch (error) {
console.error('Error creating club:', error);
const [newClub] = await pg('clubs')
.insert({
club_name: clubName,
last_synced_at: new Date()
})
.onConflict('club_name')
.ignore()
.returning('*');
return newClub || await pg('clubs').where('club_name', clubName).first();
}
}
export async function checkLeaderStatus(email) {
if (!email) {
return { isLeader: false, clubName: null };
}
try {
const result = await callClubsAPI('/leader', { email }, true);
if (result?.club_name) {
return { isLeader: true, clubName: result.club_name };
}
if (result?.leader === true) {
return { isLeader: true, clubName: null };
}
return { isLeader: false, clubName: null };
} catch (error) {
console.error('Error checking leader status:', error);
return { isLeader: false, clubName: null };
}
}
export async function checkMemberStatus(email) {
if (!email) {
return { isMember: false, clubName: null };
}
try {
const result = await callClubsAPI('/member/email', { email }, true);
if (!result) {
return { isMember: false, clubName: null };
}
if (result._plainText) {
return { isMember: true, clubName: result._plainText };
}
if (result.club_name) {
return { isMember: true, clubName: result.club_name };
}
return { isMember: false, clubName: null };
} catch (error) {
console.error('Error checking member status:', error);
return { isMember: false, clubName: null };
}
}
export async function checkClubAffiliation(email) {
if (!email) {
return { affiliated: false, role: null, clubName: null };
}
const { isLeader, clubName: leaderClubName } = await checkLeaderStatus(email);
if (isLeader && leaderClubName) {
return { affiliated: true, role: 'leader', clubName: leaderClubName };
}
const { isMember, clubName: memberClubName } = await checkMemberStatus(email);
if (isMember && memberClubName) {
return { affiliated: true, role: 'member', clubName: memberClubName };
}
return { affiliated: false, role: null, clubName: null };
}
/**
* Links a user to a club with a specific role.
*
* SECURITY: This function must ONLY be called with:
* - userId derived from an authenticated token (via getUser())
* - clubId derived from a trusted database/API lookup (never from client input)
* - role and source verified server-side
*
* DO NOT expose this function directly to client-supplied parameters.
* Misuse could allow users to assign themselves as leaders of arbitrary clubs.
*/
export async function linkUserToClub(userId, clubId, role, source) {
if (!userId || !clubId || !role) {
throw new Error('User ID, club ID, and role are required');
}
const validRoles = ['leader', 'member'];
if (!validRoles.includes(role)) {
throw new Error('Invalid role');
}
const validSources = ['leader_email', 'member_lookup'];
if (!validSources.includes(source)) {
throw new Error('Invalid source');
}
const existingMembership = await pg('user_club_memberships')
.where({ user_id: userId, club_id: clubId })
.first();
if (existingMembership) {
const [updated] = await pg('user_club_memberships')
.where({ user_id: userId, club_id: clubId })
.update({
role,
source,
last_verified_at: new Date(),
updated_at: new Date()
})
.returning('*');
return updated;
}
await pg('user_club_memberships')
.where({ user_id: userId, is_primary: true })
.update({ is_primary: false });
const [membership] = await pg('user_club_memberships')
.insert({
user_id: userId,
club_id: clubId,
role,
source,
is_primary: true,
last_verified_at: new Date()
})
.returning('*');
return membership;
}
export async function getUserPrimaryMembership(userId) {
if (!userId) {
return null;
}
const membership = await pg('user_club_memberships')
.join('clubs', 'user_club_memberships.club_id', 'clubs.id')
.where('user_club_memberships.user_id', userId)
.where('user_club_memberships.is_primary', true)
.select(
'user_club_memberships.*',
'clubs.club_name',
'clubs.display_name',
'clubs.country',
'clubs.metadata'
)
.first();
return membership;
}
export async function getUserMemberships(userId) {
if (!userId) {
return [];
}
const memberships = await pg('user_club_memberships')
.join('clubs', 'user_club_memberships.club_id', 'clubs.id')
.where('user_club_memberships.user_id', userId)
.select(
'user_club_memberships.*',
'clubs.club_name',
'clubs.display_name',
'clubs.country',
'clubs.metadata'
);
return memberships;
}
export async function getLeaderMemberships(userId) {
if (!userId) {
return [];
}
const memberships = await pg('user_club_memberships')
.join('clubs', 'user_club_memberships.club_id', 'clubs.id')
.where('user_club_memberships.user_id', userId)
.where('user_club_memberships.role', 'leader')
.select(
'user_club_memberships.*',
'clubs.club_name',
'clubs.display_name',
'clubs.country',
'clubs.metadata'
);
return memberships;
}
export async function getClubShips(clubName) {
if (!clubName) {
return [];
}
const cacheKey = `ships:${clubName}`;
const cached = clubInfoCache.get(cacheKey);
if (cached && Date.now() - cached.timestamp < CACHE_TTL_MS) {
return cached.data;
}
try {
const ships = await callClubsAPI('/ships', { club_name: clubName }, false);
clubInfoCache.set(cacheKey, {
data: ships || [],
timestamp: Date.now()
});
return ships || [];
} catch (error) {
console.error('Error fetching club ships:', error);
return cached?.data || [];
}
}
export async function getClubInfo(clubName) {
if (!clubName) {
return null;
}
const cacheKey = `info:${clubName}`;
const cached = clubInfoCache.get(cacheKey);
if (cached && Date.now() - cached.timestamp < CACHE_TTL_MS) {
return cached.data;
}
try {
const info = await callClubsAPI('/club', { name: clubName }, true);
if (info) {
clubInfoCache.set(cacheKey, {
data: info,
timestamp: Date.now()
});
}
return info;
} catch (error) {
console.error('Error fetching club info:', error);
return cached?.data || null;
}
}
export async function getClubLevel(clubName) {
if (!clubName) {
return null;
}
try {
const result = await callClubsAPI('/level', { club_name: clubName }, false);
return result?.level || null;
} catch (error) {
console.error('Error fetching club level:', error);
return null;
}
}
export async function getClubStatus(clubName) {
if (!clubName) {
return null;
}
try {
const result = await callClubsAPI('/status', { club_name: clubName }, false);
return result?.status || null;
} catch (error) {
console.error('Error fetching club status:', error);
return null;
}
}
export async function getClubMembers(clubName) {
if (!clubName) {
return null;
}
try {
const result = await callClubsAPI('/members', { club_name: clubName }, true);
return result?.members || null;
} catch (error) {
console.error('Error fetching club members:', error);
return null;
}
}
export async function refreshMembershipIfStale(userId, maxAgeHours = 24) {
const membership = await getUserPrimaryMembership(userId);
if (!membership) {
return null;
}
const maxAgeMs = maxAgeHours * 60 * 60 * 1000;
const lastVerified = new Date(membership.last_verified_at);
if (Date.now() - lastVerified.getTime() < maxAgeMs) {
return membership;
}
const user = await pg('users').where('id', userId).first();
if (!user) {
return membership;
}
const { isLeader, clubName } = await checkLeaderStatus(user.email);
if (!isLeader || !clubName) {
await pg('user_club_memberships')
.where('id', membership.id)
.update({
role: 'member',
last_verified_at: new Date(),
updated_at: new Date()
});
return getUserPrimaryMembership(userId);
}
if (clubName !== membership.club_name) {
const newClub = await getOrCreateClubByName(clubName);
if (newClub) {
await linkUserToClub(userId, newClub.id, 'leader', 'leader_email');
}
return getUserPrimaryMembership(userId);
}
await pg('user_club_memberships')
.where('id', membership.id)
.update({
last_verified_at: new Date(),
updated_at: new Date()
});
return getUserPrimaryMembership(userId);
}
export async function shareSpaceWithClub(spaceId, clubId, userId) {
if (!spaceId || !clubId || !userId) {
throw new Error('Space ID, club ID, and user ID are required');
}
const existing = await pg('space_club_shares')
.where({ space_id: spaceId, club_id: clubId })
.whereNull('revoked_at')
.first();
if (existing) {
return existing;
}
const [share] = await pg('space_club_shares')
.insert({
space_id: spaceId,
club_id: clubId,
shared_by_user_id: userId,
permission: 'read'
})
.returning('*');
return share;
}
export async function revokeSpaceClubShare(spaceId, clubId) {
if (!spaceId || !clubId) {
throw new Error('Space ID and club ID are required');
}
await pg('space_club_shares')
.where({ space_id: spaceId, club_id: clubId })
.whereNull('revoked_at')
.update({ revoked_at: new Date() });
}
export async function getSpaceShareStatus(spaceId) {
if (!spaceId) {
return null;
}
const share = await pg('space_club_shares')
.join('clubs', 'space_club_shares.club_id', 'clubs.id')
.where('space_club_shares.space_id', spaceId)
.whereNull('space_club_shares.revoked_at')
.select('space_club_shares.*', 'clubs.club_name', 'clubs.display_name')
.first();
return share;
}
export async function getSpacesSharedWithLeader(userId) {
if (!userId) {
return [];
}
const leaderMemberships = await getLeaderMemberships(userId);
if (leaderMemberships.length === 0) {
return [];
}
const clubIds = leaderMemberships.map(m => m.club_id);
const sharedSpaces = await pg('space_club_shares')
.join('spaces', 'space_club_shares.space_id', 'spaces.id')
.join('users', 'spaces.user_id', 'users.id')
.join('clubs', 'space_club_shares.club_id', 'clubs.id')
.whereIn('space_club_shares.club_id', clubIds)
.whereNull('space_club_shares.revoked_at')
.select(
'spaces.id as space_id',
'spaces.type',
'spaces.description',
'spaces.access_url',
'spaces.running',
'spaces.created_at',
'users.id as owner_id',
'users.username as owner_username',
'clubs.club_name',
'clubs.display_name as club_display_name',
'space_club_shares.permission',
'space_club_shares.created_at as shared_at'
);
return sharedSpaces;
}
export async function unlinkUserFromClub(userId, clubId) {
if (!userId || !clubId) {
throw new Error('User ID and club ID are required');
}
await pg('user_club_memberships')
.where({ user_id: userId, club_id: clubId })
.delete();
}