From c9c78d0ea6dfc0fe4803fa13e87079347af54bb4 Mon Sep 17 00:00:00 2001 From: Levente Orban Date: Wed, 15 Oct 2025 10:00:26 +0200 Subject: [PATCH] feat(tmp): invite link feature --- Makefile | 135 +++-- database/init.sql | 3 + .../20241220_001_add_invite_only_events.sql | 25 + ...20_001_add_invite_only_events_rollback.sql | 17 + src/lib/database/schema.ts | 38 +- src/lib/i18n/messages.json | 21 +- src/lib/inviteTokenHelpers.ts | 33 ++ src/lib/types.ts | 10 +- src/routes/create/+page.server.ts | 24 +- src/routes/create/+page.svelte | 20 +- src/routes/discover/+page.server.ts | 6 +- src/routes/discover/+page.svelte | 10 + src/routes/event/[id]/+page.server.ts | 20 +- src/routes/event/[id]/+page.svelte | 219 ++++---- src/routes/event/[id]/edit/+page.server.ts | 25 +- src/routes/event/[id]/edit/+page.svelte | 66 ++- .../event/[id]/invite/[token]/+page.server.ts | 223 +++++++++ .../event/[id]/invite/[token]/+page.svelte | 468 ++++++++++++++++++ 18 files changed, 1199 insertions(+), 164 deletions(-) create mode 100644 database/migrations/20241220_001_add_invite_only_events.sql create mode 100644 database/migrations/20241220_001_add_invite_only_events_rollback.sql create mode 100644 src/lib/inviteTokenHelpers.ts create mode 100644 src/routes/event/[id]/invite/[token]/+page.server.ts create mode 100644 src/routes/event/[id]/invite/[token]/+page.svelte diff --git a/Makefile b/Makefile index a9d2d58..5284be6 100644 --- a/Makefile +++ b/Makefile @@ -1,67 +1,94 @@ -.PHONY: help build up db-only logs db-clean prune i18n lint format +# Cactoide Makefile +# Database and application management commands + +.PHONY: help migrate-up migrate-down db-reset dev build test # Default target help: - @echo "Cactoide Commands" - @echo "" - @echo "Main commands:" - @echo " make build - Build the Docker images" - @echo " make up - Start all services (database + app)" - @echo "" - @echo "Individual services:" - @echo " make db-only - Start only the database" - @echo "" - @echo "Utility commands:" - @echo " make logs - Show logs from all services" - @echo " make db-clean - Stop & remove database container" - @echo " make prune - Remove all containers, images, and volumes" - @echo " make i18n - Validate translation files against messages.json" - @echo " make help - Show this help message" + @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" -# Build the Docker images -build: - @echo "Building Docker images..." - docker compose build +# Database connection variables +DB_HOST ?= localhost +DB_PORT ?= 5432 +DB_NAME ?= cactoide_database +DB_USER ?= cactoide +DB_PASSWORD ?= cactoide_password -# Start all services -up: - @echo "Starting all services..." - docker compose up -d +# Migration variables +MIGRATIONS_DIR = database/migrations -# Start only the database -db-only: - @echo "Starting only the database..." - docker compose up -d postgres +# Database connection string +DB_URL = postgresql://$(DB_USER):$(DB_PASSWORD)@$(DB_HOST):$(DB_PORT)/$(DB_NAME) -# Show logs from all services -logs: - @echo "Showing logs from all services..." - docker compose logs -f - -db-clean: - @echo "Cleaning up all Docker resources..." - docker stop cactoide-db && docker rm cactoide-db && docker volume prune -f && docker network prune -f - -# Clean up everything (containers, images, volumes) -prune: - @echo "Cleaning up all Docker resources..." - docker compose down -v --rmi all - -# Validate translation files -i18n: - @echo "Validating translation files..." - @if [ -n "$(FILE)" ]; then \ - ./scripts/i18n-check.sh $(FILE); \ +# Apply invite-only events migration +migrate-up: + @echo "Applying invite-only events migration..." + @if [ -f "$(MIGRATIONS_DIR)/20241220_001_add_invite_only_events.sql" ]; then \ + psql "$(DB_URL)" -f "$(MIGRATIONS_DIR)/20241220_001_add_invite_only_events.sql" && \ + echo "Migration applied successfully!"; \ else \ - ./scripts/i18n-check.sh; \ + echo "Migration file not found: $(MIGRATIONS_DIR)/20241220_001_add_invite_only_events.sql"; \ + exit 1; \ fi -lint: - @echo "Linting the project..." - npm run lint +# Rollback invite-only events migration +migrate-down: + @echo "Rolling back invite-only events migration..." + @if [ -f "$(MIGRATIONS_DIR)/20241220_001_add_invite_only_events_rollback.sql" ]; then \ + psql "$(DB_URL)" -f "$(MIGRATIONS_DIR)/20241220_001_add_invite_only_events_rollback.sql" && \ + echo "Migration rolled back successfully!"; \ + else \ + echo "Rollback file not found: $(MIGRATIONS_DIR)/20241220_001_add_invite_only_events_rollback.sql"; \ + exit 1; \ + fi -format: - @echo "Formatting the project..." - npm run format +# Reset database to initial state +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: + @echo "Building application..." + npm run build + +# Run tests +test: + @echo "Running tests..." + npm run test + +# Install dependencies +install: + @echo "Installing dependencies..." + npm install + +# Docker commands +docker-build: + @echo "Building Docker image..." + docker build -t cactoide . + +docker-run: + @echo "Running Docker container..." + docker run -p 3000:3000 cactoide + +# Database setup for development +db-setup: install db-reset migrate-up + @echo "Database setup complete!" + +# Full development setup +setup: install db-setup + @echo "Development environment ready!" + @echo "Run 'make dev' to start the development server" \ No newline at end of file diff --git a/database/init.sql b/database/init.sql index fa47a98..1ddc30e 100644 --- a/database/init.sql +++ b/database/init.sql @@ -42,6 +42,9 @@ 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_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_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) diff --git a/database/migrations/20241220_001_add_invite_only_events.sql b/database/migrations/20241220_001_add_invite_only_events.sql new file mode 100644 index 0000000..cdcb0aa --- /dev/null +++ b/database/migrations/20241220_001_add_invite_only_events.sql @@ -0,0 +1,25 @@ +-- Migration: Add invite-only events feature +-- Created: 2024-12-20 +-- Description: Adds invite-only visibility option and invite tokens table + +-- Add 'invite-only' to the visibility enum +ALTER TABLE events +DROP CONSTRAINT IF EXISTS events_visibility_check; + +ALTER TABLE events +ADD CONSTRAINT events_visibility_check +CHECK (visibility IN ('public', 'private', 'invite-only')); + +-- Create invite_tokens table +CREATE TABLE IF NOT EXISTS invite_tokens ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + event_id VARCHAR(8) NOT NULL REFERENCES events(id) ON DELETE CASCADE, + token VARCHAR(32) NOT NULL UNIQUE, + expires_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ DEFAULT NOW() +); + +-- Create indexes for invite_tokens table +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); diff --git a/database/migrations/20241220_001_add_invite_only_events_rollback.sql b/database/migrations/20241220_001_add_invite_only_events_rollback.sql new file mode 100644 index 0000000..49b654b --- /dev/null +++ b/database/migrations/20241220_001_add_invite_only_events_rollback.sql @@ -0,0 +1,17 @@ +-- Rollback Migration: Remove invite-only events feature +-- Created: 2024-12-20 +-- Description: Removes invite-only visibility option and invite tokens table + +-- Drop invite_tokens table and its indexes +DROP INDEX IF EXISTS idx_invite_tokens_expires_at; +DROP INDEX IF EXISTS idx_invite_tokens_token; +DROP INDEX IF EXISTS idx_invite_tokens_event_id; +DROP TABLE IF EXISTS invite_tokens; + +-- Revert visibility enum to original values +ALTER TABLE events +DROP CONSTRAINT IF EXISTS events_visibility_check; + +ALTER TABLE events +ADD CONSTRAINT events_visibility_check +CHECK (visibility IN ('public', 'private')); diff --git a/src/lib/database/schema.ts b/src/lib/database/schema.ts index c5d0d21..29c254d 100644 --- a/src/lib/database/schema.ts +++ b/src/lib/database/schema.ts @@ -16,7 +16,7 @@ import type { InferInsertModel, InferSelectModel } from 'drizzle-orm'; // --- Enums (matching the SQL CHECK constraints) export const eventTypeEnum = pgEnum('event_type', ['limited', 'unlimited']); -export const visibilityEnum = pgEnum('visibility', ['public', 'private']); +export const visibilityEnum = pgEnum('visibility', ['public', 'private', 'invite-only']); export const locationTypeEnum = pgEnum('location_type', ['none', 'text', 'maps']); // --- Events table @@ -71,11 +71,31 @@ export const rsvps = pgTable( }) ); +// --- Invite Tokens table +export const inviteTokens = pgTable( + 'invite_tokens', + { + id: uuid('id').defaultRandom().primaryKey(), + eventId: varchar('event_id', { length: 8 }) + .notNull() + .references(() => events.id, { onDelete: 'cascade' }), + token: varchar('token', { length: 32 }).notNull().unique(), + expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow() + }, + (t) => ({ + idxInviteTokensEventId: index('idx_invite_tokens_event_id').on(t.eventId), + idxInviteTokensToken: index('idx_invite_tokens_token').on(t.token), + idxInviteTokensExpiresAt: index('idx_invite_tokens_expires_at').on(t.expiresAt) + }) +); + // --- Relations (optional but handy for type safety) import { relations } from 'drizzle-orm'; export const eventsRelations = relations(events, ({ many }) => ({ - rsvps: many(rsvps) + rsvps: many(rsvps), + inviteTokens: many(inviteTokens) })); export const rsvpsRelations = relations(rsvps, ({ one }) => ({ @@ -85,16 +105,30 @@ export const rsvpsRelations = relations(rsvps, ({ one }) => ({ }) })); +export const inviteTokensRelations = relations(inviteTokens, ({ one }) => ({ + event: one(events, { + fields: [inviteTokens.eventId], + references: [events.id] + }) +})); + // --- Inferred types for use in the application export type Event = InferSelectModel; export type NewEvent = InferInsertModel; export type Rsvp = InferSelectModel; export type NewRsvp = InferInsertModel; +export type InviteToken = InferSelectModel; +export type NewInviteToken = InferInsertModel; // --- Additional utility types export type EventWithRsvps = Event & { rsvps: Rsvp[]; }; +export type EventWithInviteTokens = Event & { + inviteTokens: InviteToken[]; +}; + export type CreateEventData = Omit; export type CreateRsvpData = Omit; +export type CreateInviteTokenData = Omit; diff --git a/src/lib/i18n/messages.json b/src/lib/i18n/messages.json index d4e0f3e..6bcac7f 100644 --- a/src/lib/i18n/messages.json +++ b/src/lib/i18n/messages.json @@ -27,6 +27,7 @@ "visibility": "Visibility", "public": "Public", "private": "Private", + "inviteOnly": "Invite Only", "limited": "Limited", "unlimited": "Unlimited", "capacity": "Capacity", @@ -41,7 +42,7 @@ "numberOfGuests": "Number of Guests", "addGuests": "Add guest users", "joinEvent": "Join Event", - "copyLink": "Copy Link", + "copyLink": "Event link copied to clipboard.", "addToCalendar": "Add to Calendar", "close": "Close", "closeModal": "Close modal", @@ -64,9 +65,11 @@ "eventNotFound": "Event Not Found", "eventIsFull": "Event is Full!", "maximumCapacityReached": "Maximum capacity reached", - "eventLinkCopied": "Event link copied to clipboard!", "rsvpAddedSuccessfully": "RSVP added successfully!", "removedRsvpSuccessfully": "Removed RSVP successfully.", + "inviteRequiredToDetails": "This event requires an invite link to see the details.", + "invalidInviteToken": "Invalid invite token", + "inviteTokenExpired": "Invite token has expired", "anUnexpectedErrorOccurred": "An unexpected error occurred.", "somethingWentWrong": "Something went wrong. Please try again.", "failedToAddRsvp": "Failed to add RSVP", @@ -160,8 +163,10 @@ "visibilityLabel": "Visibility", "publicOption": "🌍 Public", "privateOption": "🔒 Private", + "inviteOnlyOption": "🚧 Invite Only", "publicDescription": "Public events are visible to everyone and can be discovered by others.", "privateDescription": "Private events are only visible to you and people you share the link with.", + "inviteOnlyDescription": "Event is public but requires a special invite link to attend.", "creatingEvent": "Creating Event...", "createEventButton": "Create Event" }, @@ -188,9 +193,14 @@ "noAttendeesYet": "No attendees yet", "beFirstToJoin": "Be the first to join!", "copyLinkButton": "Copy Link", + "copyInviteLinkButton": "Copy Invite Link", + "inviteOnlyBadge": "Invite Only", + "inviteOnlyBannerTitle": "Invite Only Event", + "inviteOnlyBannerSubtitle": "You're viewing this event through a special invite link", "addToCalendarButton": "Add to Calendar", - "eventLinkCopied": "Event link copied to clipboard!", - "rsvpAddedSuccessfully": "RSVP added successfully!", + "eventLinkCopied": "Event link copied to clipboard.", + "inviteLinkCopied": "Invite link copied to clipboard.", + "rsvpAddedSuccessfully": "RSVP added successfully.", "removedRsvpSuccessfully": "Removed RSVP successfully.", "failedToAddRsvp": "Failed to add RSVP", "failedToRemoveRsvp": "Failed to remove RSVP", @@ -208,7 +218,8 @@ "viewEventAriaLabel": "View event", "editEventAriaLabel": "Edit event", "deleteEventAriaLabel": "Delete event", - "removeRsvpAriaLabel": "Remove RSVP" + "removeRsvpAriaLabel": "Remove RSVP", + "inviteLinkExpiresAt": "This link expires when the event starts: {time}" }, "discover": { "title": "Discover Events - Cactoide", diff --git a/src/lib/inviteTokenHelpers.ts b/src/lib/inviteTokenHelpers.ts new file mode 100644 index 0000000..b29f772 --- /dev/null +++ b/src/lib/inviteTokenHelpers.ts @@ -0,0 +1,33 @@ +import { randomBytes } from 'crypto'; + +/** + * Generates a secure random token for invite links + * @param length - Length of the token (default: 32) + * @returns A random hex string + */ +export function generateInviteToken(length: number = 32): string { + return randomBytes(length / 2).toString('hex'); +} + +/** + * Calculates the expiration time for an invite token + * The token expires when the event starts + * @param eventDate - The event date in YYYY-MM-DD format + * @param eventTime - The event time in HH:MM:SS format + * @returns ISO string of the expiration time + */ +export function calculateTokenExpiration(eventDate: string, eventTime: string): string { + const eventDateTime = new Date(`${eventDate}T${eventTime}`); + return eventDateTime.toISOString(); +} + +/** + * Checks if an invite token is still valid + * @param expiresAt - The expiration time as ISO string + * @returns true if token is still valid, false otherwise + */ +export function isTokenValid(expiresAt: string): boolean { + const now = new Date(); + const expiration = new Date(expiresAt); + return now < expiration; +} diff --git a/src/lib/types.ts b/src/lib/types.ts index 8b0379d..ae1fe33 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -1,5 +1,5 @@ export type EventType = 'limited' | 'unlimited'; -export type EventVisibility = 'public' | 'private'; +export type EventVisibility = 'public' | 'private' | 'invite-only'; export type ActionType = 'add' | 'remove'; export type LocationType = 'none' | 'text' | 'maps'; @@ -62,3 +62,11 @@ export interface DatabaseRSVP { user_id: string; created_at: string; } + +export interface InviteToken { + id: string; + event_id: string; + token: string; + expires_at: string; + created_at: string; +} diff --git a/src/routes/create/+page.server.ts b/src/routes/create/+page.server.ts index f34b14f..657bb58 100644 --- a/src/routes/create/+page.server.ts +++ b/src/routes/create/+page.server.ts @@ -1,7 +1,8 @@ import { database } from '$lib/database/db'; -import { events } from '$lib/database/schema'; +import { events, inviteTokens } from '$lib/database/schema'; import { fail, redirect } from '@sveltejs/kit'; import type { Actions } from './$types'; +import { generateInviteToken, calculateTokenExpiration } from '$lib/inviteTokenHelpers.js'; // Generate a random URL-friendly ID function generateEventId(): string { @@ -25,7 +26,7 @@ export const actions: Actions = { const locationUrl = formData.get('location_url') as string; const type = formData.get('type') as 'limited' | 'unlimited'; const attendeeLimit = formData.get('attendee_limit') as string; - const visibility = formData.get('visibility') as 'public' | 'private'; + const visibility = formData.get('visibility') as 'public' | 'private' | 'invite-only'; const userId = cookies.get('cactoideUserId'); // Validation @@ -98,6 +99,7 @@ export const actions: Actions = { const eventId = generateEventId(); + // Create the event await database .insert(events) .values({ @@ -118,6 +120,24 @@ export const actions: Actions = { throw error; }); + // Generate invite token for invite-only events + if (visibility === 'invite-only') { + const token = generateInviteToken(); + const expiresAt = calculateTokenExpiration(date, time); + + await database + .insert(inviteTokens) + .values({ + eventId: eventId, + token: token, + expiresAt: new Date(expiresAt) + }) + .catch((error) => { + console.error('Error creating invite token', error); + throw error; + }); + } + throw redirect(303, `/event/${eventId}`); } }; diff --git a/src/routes/create/+page.svelte b/src/routes/create/+page.svelte index ba5b7e3..9bfbd92 100644 --- a/src/routes/create/+page.svelte +++ b/src/routes/create/+page.svelte @@ -15,7 +15,7 @@ location_url: '', type: 'unlimited', attendee_limit: undefined, - visibility: 'public' + visibility: 'public' as 'public' | 'private' | 'invite-only' }; let errors: Record = {}; @@ -317,13 +317,13 @@ {t('create.visibilityLabel')} {t('common.required')} -
+
+

{eventData.visibility === 'public' ? t('create.publicDescription') - : t('create.privateDescription')} + : eventData.visibility === 'private' + ? t('create.privateDescription') + : 'Event is public but requires a special invite link to attend'}

diff --git a/src/routes/discover/+page.server.ts b/src/routes/discover/+page.server.ts index 852d6cc..cec8aea 100644 --- a/src/routes/discover/+page.server.ts +++ b/src/routes/discover/+page.server.ts @@ -1,15 +1,15 @@ import { database } from '$lib/database/db'; -import { eq, desc } from 'drizzle-orm'; +import { desc, inArray } from 'drizzle-orm'; import type { PageServerLoad } from './$types'; import { events } from '$lib/database/schema'; export const load: PageServerLoad = async () => { try { - // Fetch all public events ordered by creation date (newest first) + // Fetch all non-private events (public and invite-only) ordered by creation date (newest first) const publicEvents = await database .select() .from(events) - .where(eq(events.visibility, 'public')) + .where(inArray(events.visibility, ['public', 'invite-only'])) .orderBy(desc(events.createdAt)); // Transform the database events to match the expected Event interface diff --git a/src/routes/discover/+page.svelte b/src/routes/discover/+page.svelte index 1d59ba8..ed18c45 100644 --- a/src/routes/discover/+page.svelte +++ b/src/routes/discover/+page.svelte @@ -324,6 +324,16 @@ {event.type === 'limited' ? t('common.limited') : t('common.unlimited')} +
+ + {event.visibility === 'public' ? t('common.public') : t('common.inviteOnly')} + +
diff --git a/src/routes/event/[id]/+page.server.ts b/src/routes/event/[id]/+page.server.ts index 5068ffd..f5db9c3 100644 --- a/src/routes/event/[id]/+page.server.ts +++ b/src/routes/event/[id]/+page.server.ts @@ -1,8 +1,9 @@ import { database } from '$lib/database/db'; -import { events, rsvps } from '$lib/database/schema'; -import { eq, asc } from 'drizzle-orm'; +import { events, rsvps, inviteTokens } from '$lib/database/schema'; +import { eq, asc, and } from 'drizzle-orm'; import { error, fail } from '@sveltejs/kit'; import type { PageServerLoad, Actions } from './$types'; +import { isTokenValid } from '$lib/inviteTokenHelpers.js'; export const load: PageServerLoad = async ({ params, cookies }) => { const eventId = params.id; @@ -25,6 +26,16 @@ export const load: PageServerLoad = async ({ params, cookies }) => { const event = eventData[0]; const eventRsvps = rsvpData; + // Check if this is an invite-only event + if (event.visibility === 'invite-only') { + // For invite-only events, check if user is the event creator + const userId = cookies.get('cactoideUserId'); + if (event.userId !== userId) { + // User is not the creator, redirect to a message about needing invite + throw error(403, 'This event requires an invite link to view'); + } + } + // Transform the data to match the expected interface const transformedEvent = { id: event.id, @@ -85,6 +96,11 @@ export const actions: Actions = { return fail(404, { error: 'Event not found' }); } + // Check if this is an invite-only event + if (eventData.visibility === 'invite-only') { + return fail(403, { error: 'This event requires an invite link to RSVP' }); + } + // Get current RSVPs const currentRSVPs = await database.select().from(rsvps).where(eq(rsvps.eventId, eventId)); diff --git a/src/routes/event/[id]/+page.svelte b/src/routes/event/[id]/+page.svelte index 272537f..f05e3d1 100644 --- a/src/routes/event/[id]/+page.svelte +++ b/src/routes/event/[id]/+page.svelte @@ -10,7 +10,8 @@ import { t } from '$lib/i18n/i18n.js'; export let data: { event: Event; rsvps: RSVP[]; userId: string }; - export let form; + type FormDataLocal = { success?: boolean; error?: string; type?: 'add' | 'remove' | 'copy' }; + export let form: FormDataLocal | undefined; let event: Event; let rsvps: RSVP[] = []; @@ -22,6 +23,9 @@ let numberOfGuests = 1; let showCalendarModal = false; let calendarEvent: CalendarEvent; + let toastType: 'add' | 'remove' | 'copy' | null = null; + let typeToShow: 'add' | 'remove' | 'copy' | undefined; + let successHideTimer: number | null = null; // Use server-side data $: event = data.event; @@ -45,6 +49,9 @@ success = ''; } + // TODO: ERROR + // //WHEN DELETING RSVP: THE MODAL MESSAGE IS "RSVP removed successfully." + // Handle form success from server $: if (form?.success) { success = 'RSVP added successfully!'; @@ -52,17 +59,33 @@ newAttendeeName = ''; addGuests = false; numberOfGuests = 1; + + // show and auto-hide success toast for add action + toastType = 'add'; + if (browser) { + if (successHideTimer) clearTimeout(successHideTimer); + successHideTimer = window.setTimeout(() => { + success = ''; + toastType = null; + }, 3000); + } } + // Derive toast type from local or server form + $: typeToShow = toastType ?? form?.type; + const eventId = $page.params.id || ''; const copyEventLink = () => { if (browser) { const url = `${$page.url.origin}/event/${eventId}`; navigator.clipboard.writeText(url).then(() => { - success = 'Event link copied to clipboard!'; + toastType = 'copy'; + success = t('event.eventLinkCopied'); + setTimeout(() => { success = ''; + toastType = null; }, 3000); }); } @@ -71,6 +94,7 @@ const clearMessages = () => { error = ''; success = ''; + toastType = null; }; // Calendar modal functions @@ -208,7 +232,13 @@

{t('event.joinThisEvent')}

- {#if event.type === 'limited' && event.attendee_limit && rsvps.length >= event.attendee_limit} + {#if event.visibility === 'invite-only'} +
+
🎫
+

{t('event.inviteOnlyBannerTitle')}

+

{t('common.inviteRequiredToDetails')}

+
+ {:else if event.type === 'limited' && event.attendee_limit && rsvps.length >= event.attendee_limit}
🚫

{t('event.eventIsFull')}

@@ -316,92 +346,99 @@
-
-
-

{t('event.attendeesTitle')}

- {rsvps.length} -
- - {#if rsvps.length === 0} -
-

{t('event.noAttendeesYet')}

-

{t('event.beFirstToJoin')}

+ {#if event.visibility !== 'invite-only'} +
+
+

{t('event.attendeesTitle')}

+ {rsvps.length}
- {:else} -
- {#each rsvps as attendee, i (i)} -
-
-
- {attendee.name.charAt(0).toUpperCase()} -
-
-

+

{t('event.noAttendeesYet')}

+

{t('event.beFirstToJoin')}

+
+ {:else} +
+ {#each rsvps as attendee, i (i)} +
+
+
- {attendee.name} -

-

- {(() => { - const date = new Date(attendee.created_at); - const year = date.getFullYear(); - const month = String(date.getMonth() + 1).padStart(2, '0'); - const day = String(date.getDate()).padStart(2, '0'); - const hours = String(date.getHours()).padStart(2, '0'); - const minutes = String(date.getMinutes()).padStart(2, '0'); - return `${year}/${month}/${day} ${hours}:${minutes}`; - })()} -

+ {attendee.name.charAt(0).toUpperCase()} +
+
+

+ {attendee.name} +

+

+ {(() => { + const date = new Date(attendee.created_at); + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + const hours = String(date.getHours()).padStart(2, '0'); + const minutes = String(date.getMinutes()).padStart(2, '0'); + return `${year}/${month}/${day} ${hours}:${minutes}`; + })()} +

+
+ + {#if attendee.user_id === currentUserId} +
{ + clearMessages(); + return async ({ result, update }) => { + if (result.type === 'failure') { + error = String(result.data?.error || 'Failed to remove RSVP'); + } + update(); + }; + }} + style="display: inline;" + > + + +
+ {/if}
- - {#if attendee.user_id === currentUserId} -
{ - clearMessages(); - return async ({ result, update }) => { - if (result.type === 'failure') { - error = String(result.data?.error || 'Failed to remove RSVP'); - } - update(); - }; - }} - style="display: inline;" - > - - -
- {/if} -
- {/each} -
- {/if} -
+ {/each} +
+ {/if} +
+ {/if}
@@ -436,18 +473,26 @@ {#if success} - {#if form?.type === 'add'} + {#if typeToShow === 'add'}
{success}
- {:else if form?.type === 'remove'} + {:else if typeToShow === 'remove'}
{t('event.removedRsvpSuccessfully')}
+ {:else if typeToShow === 'copy'} +
+ {t('event.eventLinkCopied')} +
+ {:else} + {/if} {/if} diff --git a/src/routes/event/[id]/edit/+page.server.ts b/src/routes/event/[id]/edit/+page.server.ts index 69b2d81..ba9eeb6 100644 --- a/src/routes/event/[id]/edit/+page.server.ts +++ b/src/routes/event/[id]/edit/+page.server.ts @@ -1,5 +1,5 @@ import { database } from '$lib/database/db'; -import { events } from '$lib/database/schema'; +import { events, inviteTokens } from '$lib/database/schema'; import { eq, and } from 'drizzle-orm'; import { fail, redirect } from '@sveltejs/kit'; import type { Actions, PageServerLoad } from './$types'; @@ -23,8 +23,29 @@ export const load: PageServerLoad = async ({ params, cookies }) => { throw redirect(303, '/event'); } + // Fetch invite token if this is an invite-only event + let inviteToken = null; + if (event[0].visibility === 'invite-only') { + const tokenData = await database + .select() + .from(inviteTokens) + .where(eq(inviteTokens.eventId, eventId)) + .limit(1); + + if (tokenData.length > 0) { + inviteToken = { + id: tokenData[0].id, + event_id: tokenData[0].eventId, + token: tokenData[0].token, + expires_at: tokenData[0].expiresAt.toISOString(), + created_at: tokenData[0].createdAt?.toISOString() || new Date().toISOString() + }; + } + } + return { - event: event[0] + event: event[0], + inviteToken }; }; diff --git a/src/routes/event/[id]/edit/+page.svelte b/src/routes/event/[id]/edit/+page.svelte index 0bdd782..39986a4 100644 --- a/src/routes/event/[id]/edit/+page.svelte +++ b/src/routes/event/[id]/edit/+page.svelte @@ -21,6 +21,9 @@ let errors: Record = {}; let isSubmitting = false; + let inviteToken = data.inviteToken; + + let inviteLinkCopied = false; // Get today's date in YYYY-MM-DD format for min attribute const today = new Date().toISOString().split('T')[0]; @@ -67,6 +70,21 @@ const handleCancel = () => { goto(`/event/${data.event.id}`); }; + + const copyInviteLink = async () => { + if (inviteToken) { + const inviteUrl = `${window.location.origin}/event/${data.event.id}/invite/${inviteToken.token}`; + try { + await navigator.clipboard.writeText(inviteUrl); + inviteLinkCopied = true; + setTimeout(() => { + inviteLinkCopied = false; + }, 3000); + } catch (err) { + console.error('Failed to copy invite link:', err); + } + } + }; @@ -315,7 +333,7 @@ {t('common.visibility')} {t('common.required')} -
+

{eventData.visibility === 'public' ? t('create.publicDescription') - : t('create.privateDescription')} + : eventData.visibility === 'private' + ? t('create.privateDescription') + : t('create.inviteOnlyDescription')}

+ + {#if eventData.visibility === 'invite-only' && inviteToken} +
+
+

Invite Link

+
+ +
+
+ + +
+

+ {t('event.inviteLinkExpiresAt', { + time: new Date(inviteToken.expires_at).toLocaleString() + })} +

+
+
+ {/if} +
+
+
+ {:else if event} +
+ +
+
+
🎫
+
+

{t('event.inviteOnlyBannerTitle')}

+

{t('event.inviteOnlyBannerSubtitle')}

+
+
+
+ + +
+

+ {event.name} +

+ +
+ +
+
+ + + +
+
+

+ {formatDate(event.date)} + - + {formatTime(event.time)} +

+
+
+ + +
+
+ + + + +
+
+ {#if event.location_type === 'none'} +

N/A

+ {:else if event.location_type === 'maps' && event.location_url} + + {t('create.locationMapsOption')} + + {:else} +

{event.location}

+ {/if} +
+
+ + +
+
+ + {event.type === 'limited' ? t('common.limited') : t('common.unlimited')} + + + {t('event.inviteOnlyBadge')} + +
+ + {#if event.type === 'limited' && event.attendee_limit} +
+

{t('common.capacity')}

+

+ {rsvps.length}/{event.attendee_limit} +

+
+ {/if} +
+
+
+ + +
+

{t('event.joinThisEvent')}

+ + {#if event.type === 'limited' && event.attendee_limit && rsvps.length >= event.attendee_limit} +
+
🚫
+

{t('event.eventIsFull')}

+

{t('event.maximumCapacityReached')}

+
+ {:else} +
{ + isAddingRSVP = true; + clearMessages(); + return async ({ result, update }) => { + isAddingRSVP = false; + if (result.type === 'failure') { + error = String(result.data?.error || 'Failed to add RSVP'); + } + update(); + }; + }} + class="space-y-4" + > + +
+ + +
+ + +
+ + +
+ + + {#if addGuests} +
+ + +

+ {t('event.guestsWillBeAddedAs', { + name: newAttendeeName || t('common.yourNamePlaceholder') + })} +

+
+ {/if} + + +
+ {/if} +
+ + +
+
+

{t('event.attendeesTitle')}

+ {rsvps.length} +
+ + {#if rsvps.length === 0} +
+

{t('event.noAttendeesYet')}

+

{t('event.beFirstToJoin')}

+
+ {:else} +
+ {#each rsvps as attendee, i (i)} +
+
+
+ {attendee.name.charAt(0).toUpperCase()} +
+
+

+ {attendee.name} +

+

+ {(() => { + const date = new Date(attendee.created_at); + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + const hours = String(date.getHours()).padStart(2, '0'); + const minutes = String(date.getMinutes()).padStart(2, '0'); + return `${year}/${month}/${day} ${hours}:${minutes}`; + })()} +

+
+
+ + {#if attendee.user_id === currentUserId} +
{ + clearMessages(); + return async ({ result, update }) => { + if (result.type === 'failure') { + error = String(result.data?.error || 'Failed to remove RSVP'); + } + update(); + }; + }} + style="display: inline;" + > + + +
+ {/if} +
+ {/each} +
+ {/if} +
+ + +
+ + +
+
+ {/if} +
+
+ + +{#if calendarEvent && browser} + +{/if} + + +{#if success} + {#if form?.type === 'add'} +
+ {success} +
+ {:else if form?.type === 'remove'} +
+ {t('event.removedRsvpSuccessfully')} +
+ {/if} +{/if} + +{#if error} +
+ {error} +
+{/if}