mirror of
https://github.com/System-End/spaces.git
synced 2026-04-19 16:38:24 +00:00
club integration
This commit is contained in:
parent
822c6f763e
commit
0f18d2d4be
11 changed files with 2107 additions and 7 deletions
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
725
client/src/lib/Clubs.svelte
Normal 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>
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
139
client/src/lib/ShareWithClubToggle.svelte
Normal file
139
client/src/lib/ShareWithClubToggle.svelte
Normal 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>
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
369
src/api/clubs/clubs.route.js
Normal file
369
src/api/clubs/clubs.route.js
Normal 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['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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
523
src/utils/clubs.js
Normal 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();
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue