diff --git a/client/src/App.svelte b/client/src/App.svelte index 25e8eee..9c9c05b 100644 --- a/client/src/App.svelte +++ b/client/src/App.svelte @@ -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 @@ + {:else if showClubs} + + {:else} {#if user.is_admin}
+
@@ -403,6 +451,16 @@

Created: {new Date(space.created_at).toLocaleString()}

+
+ +
+ {#if actionError[space.id]}
{actionError[space.id]}
{/if} diff --git a/client/src/lib/ShareWithClubToggle.svelte b/client/src/lib/ShareWithClubToggle.svelte new file mode 100644 index 0000000..00e493e --- /dev/null +++ b/client/src/lib/ShareWithClubToggle.svelte @@ -0,0 +1,139 @@ + + +{#if hasClub} + +{/if} + + diff --git a/client/src/styles/dashboard.css b/client/src/styles/dashboard.css index 99efa4d..5baa0c2 100644 --- a/client/src/styles/dashboard.css +++ b/client/src/styles/dashboard.css @@ -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; diff --git a/src/api/clubs/clubs.route.js b/src/api/clubs/clubs.route.js new file mode 100644 index 0000000..2040e54 --- /dev/null +++ b/src/api/clubs/clubs.route.js @@ -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['YSWS–Name (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; diff --git a/src/api/index.js b/src/api/index.js index 19a3d83..0b44f4c 100644 --- a/src/api/index.js +++ b/src/api/index.js @@ -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; \ No newline at end of file diff --git a/src/api/spaces/space.route.js b/src/api/spaces/space.route.js index 31818db..c569638 100644 --- a/src/api/spaces/space.route.js +++ b/src/api/spaces/space.route.js @@ -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; diff --git a/src/middlewares/rate-limit.middleware.js b/src/middlewares/rate-limit.middleware.js index b3ce745..47307c1 100644 --- a/src/middlewares/rate-limit.middleware.js +++ b/src/middlewares/rate-limit.middleware.js @@ -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.' + }); + } +}); diff --git a/src/utils/clubs.js b/src/utils/clubs.js new file mode 100644 index 0000000..2c240f4 --- /dev/null +++ b/src/utils/clubs.js @@ -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(); +}