feat: invite only events

This commit is contained in:
Levente Orban
2025-10-26 16:47:51 +01:00
parent c9c78d0ea6
commit 93b0bac48a
8 changed files with 103 additions and 92 deletions

109
Makefile
View File

@@ -1,17 +1,4 @@
# Cactoide Makefile .PHONY: help build up down db-only logs db-clean prune i18n lint format migrate-up migrate-down
# Database and application management commands
.PHONY: help migrate-up migrate-down db-reset dev build test
# Default target
help:
@echo "Available commands:"
@echo " migrate-up - Apply invite-only events migration"
@echo " migrate-down - Rollback invite-only events migration"
@echo " db-reset - Reset database to initial state"
@echo " dev - Start development server"
@echo " build - Build the application"
@echo " test - Run tests"
# Database connection variables # Database connection variables
DB_HOST ?= localhost DB_HOST ?= localhost
@@ -26,6 +13,21 @@ MIGRATIONS_DIR = database/migrations
# Database connection string # Database connection string
DB_URL = postgresql://$(DB_USER):$(DB_PASSWORD)@$(DB_HOST):$(DB_PORT)/$(DB_NAME) DB_URL = postgresql://$(DB_USER):$(DB_PASSWORD)@$(DB_HOST):$(DB_PORT)/$(DB_NAME)
help:
@echo "Available commands:"
@echo " build - Docker build the application"
@echo " up - Start all services"
@echo " down - Stop all services"
@echo " db-only - Start only the database"
@echo " logs - Show logs from all services"
@echo " db-clean - Clean up all Docker resources"
@echo " prune - Clean up everything (containers, images, volumes)"
@echo " i18n - Validate translation files"
@echo " lint - Lint the project"
@echo " format - Format the project"
@echo " migrate-up - Apply invite-only events migration"
@echo " migrate-down - Rollback invite-only events migration"
# Apply invite-only events migration # Apply invite-only events migration
migrate-up: migrate-up:
@echo "Applying invite-only events migration..." @echo "Applying invite-only events migration..."
@@ -48,47 +50,52 @@ migrate-down:
exit 1; \ exit 1; \
fi fi
# Reset database to initial state # Build the Docker images
db-reset:
@echo "Resetting database..."
@psql "$(DB_URL)" -c "DROP SCHEMA public CASCADE; CREATE SCHEMA public; GRANT ALL ON SCHEMA public TO postgres; GRANT ALL ON SCHEMA public TO public;"
@psql "$(DB_URL)" -f database/init.sql
@echo "Database reset complete!"
# Development server
dev:
@echo "Starting development server..."
npm run dev
# Build application
build: build:
@echo "Building application..." @echo "Building Docker images..."
npm run build docker compose build
# Run tests # Start all services
test: up:
@echo "Running tests..." @echo "Starting all services..."
npm run test docker compose up -d
# Install dependencies down:
install: @echo "Stopping all services..."
@echo "Installing dependencies..." docker compose down
npm install
# Docker commands db-clean:
docker-build: @echo "Cleaning up all Docker resources..."
@echo "Building Docker image..." docker stop cactoide-db && docker rm cactoide-db && docker volume prune -f && docker network prune -f
docker build -t cactoide .
docker-run: # Start only the database
@echo "Running Docker container..." db-only:
docker run -p 3000:3000 cactoide @echo "Starting only the database..."
docker compose up -d postgres
# Database setup for development # Show logs from all services
db-setup: install db-reset migrate-up logs:
@echo "Database setup complete!" @echo "Showing logs from all services..."
docker compose logs -f
# Full development setup
setup: install db-setup
@echo "Development environment ready!" # Clean up everything (containers, images, volumes)
@echo "Run 'make dev' to start the development server" prune:
@echo "Cleaning up all Docker resources..."
docker compose down -v --rmi all
lint:
@echo "Linting the project..."
npm run lint
format:
@echo "Formatting the project..."
npm run format
#TODO: not working yet
i18n:
@echo "Validating translation files..."
@if [ -n "$(FILE)" ]; then \
./scripts/i18n-check.sh $(FILE); \

View File

@@ -42,25 +42,5 @@ CREATE INDEX IF NOT EXISTS idx_events_date ON events(date);
CREATE INDEX IF NOT EXISTS idx_events_location_type ON events(location_type); CREATE INDEX IF NOT EXISTS idx_events_location_type ON events(location_type);
CREATE INDEX IF NOT EXISTS idx_rsvps_event_id ON rsvps(event_id); CREATE INDEX IF NOT EXISTS idx_rsvps_event_id ON rsvps(event_id);
CREATE INDEX IF NOT EXISTS idx_rsvps_user_id ON rsvps(user_id); CREATE INDEX IF NOT EXISTS idx_rsvps_user_id ON rsvps(user_id);
CREATE INDEX IF NOT EXISTS idx_invite_tokens_event_id ON invite_tokens(event_id);
CREATE INDEX IF NOT EXISTS idx_invite_tokens_token ON invite_tokens(token);
CREATE INDEX IF NOT EXISTS idx_invite_tokens_expires_at ON invite_tokens(expires_at);
-- =======================================
-- Triggers (updated_at maintenance)
-- =======================================
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS update_events_updated_at ON events;
CREATE TRIGGER update_events_updated_at
BEFORE UPDATE ON events
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
COMMIT; COMMIT;

View File

@@ -41,7 +41,6 @@
"numberOfGuests": "Numero di Ospiti", "numberOfGuests": "Numero di Ospiti",
"addGuests": "Aggiungi ospiti", "addGuests": "Aggiungi ospiti",
"joinEvent": "Partecipa all'Evento", "joinEvent": "Partecipa all'Evento",
"copyLink": "Copia Link",
"addToCalendar": "Aggiungi al Calendario", "addToCalendar": "Aggiungi al Calendario",
"close": "Chiudi", "close": "Chiudi",
"closeModal": "Chiudi finestra", "closeModal": "Chiudi finestra",
@@ -64,7 +63,6 @@
"eventNotFound": "Evento Non Trovato", "eventNotFound": "Evento Non Trovato",
"eventIsFull": "L'Evento è Pieno!", "eventIsFull": "L'Evento è Pieno!",
"maximumCapacityReached": "Raggiunta la capacità massima", "maximumCapacityReached": "Raggiunta la capacità massima",
"eventLinkCopied": "Link dell'evento copiato negli appunti!",
"rsvpAddedSuccessfully": "RSVP aggiunto con successo!", "rsvpAddedSuccessfully": "RSVP aggiunto con successo!",
"removedRsvpSuccessfully": "RSVP rimosso con successo.", "removedRsvpSuccessfully": "RSVP rimosso con successo.",
"anUnexpectedErrorOccurred": "Si è verificato un errore inaspettato.", "anUnexpectedErrorOccurred": "Si è verificato un errore inaspettato.",
@@ -188,8 +186,10 @@
"noAttendeesYet": "Ancora nessun partecipante", "noAttendeesYet": "Ancora nessun partecipante",
"beFirstToJoin": "Sii il primo a partecipare!", "beFirstToJoin": "Sii il primo a partecipare!",
"copyLinkButton": "Copia Link", "copyLinkButton": "Copia Link",
"copyInviteLinkButton": "Copia Link Invito",
"addToCalendarButton": "Aggiungi al Calendario", "addToCalendarButton": "Aggiungi al Calendario",
"eventLinkCopied": "Link dell'evento copiato negli appunti!", "eventLinkCopied": "Link dell'evento copiato negli appunti!",
"inviteLinkCopied": "Link invito copiato negli appunti!",
"rsvpAddedSuccessfully": "RSVP aggiunto con successo!", "rsvpAddedSuccessfully": "RSVP aggiunto con successo!",
"removedRsvpSuccessfully": "RSVP rimosso con successo.", "removedRsvpSuccessfully": "RSVP rimosso con successo.",
"failedToAddRsvp": "Impossibile aggiungere RSVP", "failedToAddRsvp": "Impossibile aggiungere RSVP",
@@ -208,7 +208,8 @@
"viewEventAriaLabel": "Visualizza evento", "viewEventAriaLabel": "Visualizza evento",
"editEventAriaLabel": "Modifica evento", "editEventAriaLabel": "Modifica evento",
"deleteEventAriaLabel": "Elimina evento", "deleteEventAriaLabel": "Elimina evento",
"removeRsvpAriaLabel": "Rimuovi RSVP" "removeRsvpAriaLabel": "Rimuovi RSVP",
"inviteLinkExpiresAt": "Questo link scade quando l'evento inizia: {time}"
}, },
"discover": { "discover": {
"title": "Scopri Eventi - Cactoide", "title": "Scopri Eventi - Cactoide",

View File

@@ -42,7 +42,6 @@
"numberOfGuests": "Number of Guests", "numberOfGuests": "Number of Guests",
"addGuests": "Add guest users", "addGuests": "Add guest users",
"joinEvent": "Join Event", "joinEvent": "Join Event",
"copyLink": "Event link copied to clipboard.",
"addToCalendar": "Add to Calendar", "addToCalendar": "Add to Calendar",
"close": "Close", "close": "Close",
"closeModal": "Close modal", "closeModal": "Close modal",

View File

@@ -31,6 +31,7 @@
$: event = data.event; $: event = data.event;
$: rsvps = data.rsvps; $: rsvps = data.rsvps;
$: currentUserId = data.userId; $: currentUserId = data.userId;
$: isEventCreator = event.user_id === currentUserId;
// Create calendar event object when event data changes // Create calendar event object when event data changes
$: if (event && browser) { $: if (event && browser) {
@@ -77,7 +78,7 @@
const eventId = $page.params.id || ''; const eventId = $page.params.id || '';
const copyEventLink = () => { const copyEventLink = () => {
if (browser) { if (browser && isEventCreator) {
const url = `${$page.url.origin}/event/${eventId}`; const url = `${$page.url.origin}/event/${eventId}`;
navigator.clipboard.writeText(url).then(() => { navigator.clipboard.writeText(url).then(() => {
toastType = 'copy'; toastType = 'copy';
@@ -442,12 +443,17 @@
<!-- Action Buttons --> <!-- Action Buttons -->
<div class="max-w-2xl space-y-3"> <div class="max-w-2xl space-y-3">
<button {#if event.visibility !== 'invite-only'}
on:click={copyEventLink} <button
class="hover:bg-violet-400/70' w-full rounded-sm border-2 border-violet-500 bg-violet-400/20 px-4 py-3 py-4 font-bold font-medium font-semibold text-white shadow-lg transition-all duration-200 hover:scale-105" on:click={copyEventLink}
> disabled={!isEventCreator}
{t('event.copyLinkButton')} class="w-full rounded-sm border-2 px-4 py-3 py-4 font-bold font-medium font-semibold text-white shadow-lg transition-all duration-200 hover:scale-105 {isEventCreator
</button> ? 'border-violet-500 bg-violet-400/20 hover:bg-violet-400/70'
: 'cursor-not-allowed border-gray-500 bg-gray-600/20 hover:bg-gray-600/30'}"
>
{t('event.copyLinkButton')}
</button>
{/if}
<button <button
on:click={openCalendarModal} on:click={openCalendarModal}
class="hover:bg-violet-400/70' w-full rounded-sm border-2 border-violet-500 bg-violet-400/20 px-4 py-3 py-4 font-bold font-medium font-semibold text-white shadow-lg transition-all duration-200 hover:scale-105" class="hover:bg-violet-400/70' w-full rounded-sm border-2 border-violet-500 bg-violet-400/20 px-4 py-3 py-4 font-bold font-medium font-semibold text-white shadow-lg transition-all duration-200 hover:scale-105"

View File

@@ -45,7 +45,8 @@ export const load: PageServerLoad = async ({ params, cookies }) => {
return { return {
event: event[0], event: event[0],
inviteToken inviteToken,
userId
}; };
}; };

View File

@@ -23,7 +23,8 @@
let isSubmitting = false; let isSubmitting = false;
let inviteToken = data.inviteToken; let inviteToken = data.inviteToken;
let inviteLinkCopied = false; let showInviteLinkToast = false;
let toastHideTimer: number | null = null;
// Get today's date in YYYY-MM-DD format for min attribute // Get today's date in YYYY-MM-DD format for min attribute
const today = new Date().toISOString().split('T')[0]; const today = new Date().toISOString().split('T')[0];
@@ -76,9 +77,12 @@
const inviteUrl = `${window.location.origin}/event/${data.event.id}/invite/${inviteToken.token}`; const inviteUrl = `${window.location.origin}/event/${data.event.id}/invite/${inviteToken.token}`;
try { try {
await navigator.clipboard.writeText(inviteUrl); await navigator.clipboard.writeText(inviteUrl);
inviteLinkCopied = true; showInviteLinkToast = true;
setTimeout(() => {
inviteLinkCopied = false; // Auto-hide toast after 3 seconds
if (toastHideTimer) clearTimeout(toastHideTimer);
toastHideTimer = window.setTimeout(() => {
showInviteLinkToast = false;
}, 3000); }, 3000);
} catch (err) { } catch (err) {
console.error('Failed to copy invite link:', err); console.error('Failed to copy invite link:', err);
@@ -375,8 +379,8 @@
</fieldset> </fieldset>
</div> </div>
<!-- Invite Link Section (only for invite-only events) --> <!-- Invite Link Section (only for invite-only events and event creator) -->
{#if eventData.visibility === 'invite-only' && inviteToken} {#if eventData.visibility === 'invite-only' && inviteToken && data.event.userId === data.userId}
<div class="rounded-sm border border-amber-500/30 bg-amber-900/20 p-4"> <div class="rounded-sm border border-amber-500/30 bg-amber-900/20 p-4">
<div class="mb-3 flex items-center justify-between"> <div class="mb-3 flex items-center justify-between">
<h3 class="text-lg font-semibold text-amber-400">Invite Link</h3> <h3 class="text-lg font-semibold text-amber-400">Invite Link</h3>
@@ -395,7 +399,7 @@
on:click={copyInviteLink} on:click={copyInviteLink}
class="rounded-sm border border-amber-300 bg-amber-200 px-3 py-2 text-sm font-medium text-amber-900 hover:bg-amber-300" class="rounded-sm border border-amber-300 bg-amber-200 px-3 py-2 text-sm font-medium text-amber-900 hover:bg-amber-300"
> >
{inviteLinkCopied ? t('common.success') : t('common.copyLink')} {t('event.copyInviteLinkButton')}
</button> </button>
</div> </div>
<p class="text-xs text-amber-300"> <p class="text-xs text-amber-300">
@@ -436,3 +440,12 @@
</div> </div>
</div> </div>
</div> </div>
<!-- Invite Link Toast -->
{#if showInviteLinkToast}
<div
class="fixed right-4 bottom-4 z-40 w-128 rounded-sm border border-yellow-500/30 bg-yellow-900/20 p-4 text-yellow-400"
>
{t('event.inviteLinkCopied')}
</div>
{/if}

View File

@@ -27,6 +27,7 @@
$: event = data.event; $: event = data.event;
$: rsvps = data.rsvps; $: rsvps = data.rsvps;
$: currentUserId = data.userId; $: currentUserId = data.userId;
$: isEventCreator = event.user_id === currentUserId;
// Create calendar event object when event data changes // Create calendar event object when event data changes
$: if (event && browser) { $: if (event && browser) {
@@ -58,7 +59,7 @@
const token = $page.params.token || ''; const token = $page.params.token || '';
const copyInviteLink = () => { const copyInviteLink = () => {
if (browser) { if (browser && isEventCreator) {
const url = `${$page.url.origin}/event/${eventId}/invite/${token}`; const url = `${$page.url.origin}/event/${eventId}/invite/${token}`;
navigator.clipboard.writeText(url).then(() => { navigator.clipboard.writeText(url).then(() => {
success = 'Invite link copied to clipboard!'; success = 'Invite link copied to clipboard!';
@@ -415,7 +416,10 @@
<div class="max-w-2xl space-y-3"> <div class="max-w-2xl space-y-3">
<button <button
on:click={copyInviteLink} on:click={copyInviteLink}
class="hover:bg-violet-400/70' w-full rounded-sm border-2 border-violet-500 bg-violet-400/20 px-4 py-3 py-4 font-bold font-medium font-semibold text-white shadow-lg transition-all duration-200 hover:scale-105" disabled={!isEventCreator}
class="w-full rounded-sm border-2 px-4 py-3 py-4 font-bold font-medium font-semibold text-white shadow-lg transition-all duration-200 hover:scale-105 {isEventCreator
? 'border-violet-500 bg-violet-400/20 hover:bg-violet-400/70'
: 'cursor-not-allowed border-gray-500 bg-gray-600/20 hover:bg-gray-600/30'}"
> >
{t('event.copyInviteLinkButton')} {t('event.copyInviteLinkButton')}
</button> </button>