From c9c78d0ea6dfc0fe4803fa13e87079347af54bb4 Mon Sep 17 00:00:00 2001 From: Levente Orban Date: Wed, 15 Oct 2025 10:00:26 +0200 Subject: [PATCH 1/3] 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} From 93b0bac48ab13240a8c9951745da275e01252f6e Mon Sep 17 00:00:00 2001 From: Levente Orban Date: Sun, 26 Oct 2025 16:47:51 +0100 Subject: [PATCH 2/3] feat: invite only events --- Makefile | 109 ++++++++++-------- database/init.sql | 20 ---- src/lib/i18n/it.json | 7 +- src/lib/i18n/messages.json | 1 - src/routes/event/[id]/+page.svelte | 20 ++-- src/routes/event/[id]/edit/+page.server.ts | 3 +- src/routes/event/[id]/edit/+page.svelte | 27 +++-- .../event/[id]/invite/[token]/+page.svelte | 8 +- 8 files changed, 103 insertions(+), 92 deletions(-) diff --git a/Makefile b/Makefile index 5284be6..37879b8 100644 --- a/Makefile +++ b/Makefile @@ -1,17 +1,4 @@ -# Cactoide Makefile -# 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" +.PHONY: help build up down db-only logs db-clean prune i18n lint format migrate-up migrate-down # Database connection variables DB_HOST ?= localhost @@ -26,6 +13,21 @@ MIGRATIONS_DIR = database/migrations # Database connection string 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 migrate-up: @echo "Applying invite-only events migration..." @@ -48,47 +50,52 @@ migrate-down: exit 1; \ fi -# 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 the Docker images build: - @echo "Building application..." - npm run build + @echo "Building Docker images..." + docker compose build -# Run tests -test: - @echo "Running tests..." - npm run test +# Start all services +up: + @echo "Starting all services..." + docker compose up -d -# Install dependencies -install: - @echo "Installing dependencies..." - npm install +down: + @echo "Stopping all services..." + docker compose down -# Docker commands -docker-build: - @echo "Building Docker image..." - docker build -t cactoide . +db-clean: + @echo "Cleaning up all Docker resources..." + docker stop cactoide-db && docker rm cactoide-db && docker volume prune -f && docker network prune -f -docker-run: - @echo "Running Docker container..." - docker run -p 3000:3000 cactoide +# Start only the database +db-only: + @echo "Starting only the database..." + docker compose up -d postgres -# Database setup for development -db-setup: install db-reset migrate-up - @echo "Database setup complete!" +# Show logs from all services +logs: + @echo "Showing logs from all services..." + docker compose logs -f -# 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 + + +# Clean up everything (containers, images, volumes) +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); \ diff --git a/database/init.sql b/database/init.sql index 1ddc30e..a65afd6 100644 --- a/database/init.sql +++ b/database/init.sql @@ -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_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) --- ======================================= -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; \ No newline at end of file diff --git a/src/lib/i18n/it.json b/src/lib/i18n/it.json index cfba74f..06006f0 100644 --- a/src/lib/i18n/it.json +++ b/src/lib/i18n/it.json @@ -41,7 +41,6 @@ "numberOfGuests": "Numero di Ospiti", "addGuests": "Aggiungi ospiti", "joinEvent": "Partecipa all'Evento", - "copyLink": "Copia Link", "addToCalendar": "Aggiungi al Calendario", "close": "Chiudi", "closeModal": "Chiudi finestra", @@ -64,7 +63,6 @@ "eventNotFound": "Evento Non Trovato", "eventIsFull": "L'Evento è Pieno!", "maximumCapacityReached": "Raggiunta la capacità massima", - "eventLinkCopied": "Link dell'evento copiato negli appunti!", "rsvpAddedSuccessfully": "RSVP aggiunto con successo!", "removedRsvpSuccessfully": "RSVP rimosso con successo.", "anUnexpectedErrorOccurred": "Si è verificato un errore inaspettato.", @@ -188,8 +186,10 @@ "noAttendeesYet": "Ancora nessun partecipante", "beFirstToJoin": "Sii il primo a partecipare!", "copyLinkButton": "Copia Link", + "copyInviteLinkButton": "Copia Link Invito", "addToCalendarButton": "Aggiungi al Calendario", "eventLinkCopied": "Link dell'evento copiato negli appunti!", + "inviteLinkCopied": "Link invito copiato negli appunti!", "rsvpAddedSuccessfully": "RSVP aggiunto con successo!", "removedRsvpSuccessfully": "RSVP rimosso con successo.", "failedToAddRsvp": "Impossibile aggiungere RSVP", @@ -208,7 +208,8 @@ "viewEventAriaLabel": "Visualizza evento", "editEventAriaLabel": "Modifica evento", "deleteEventAriaLabel": "Elimina evento", - "removeRsvpAriaLabel": "Rimuovi RSVP" + "removeRsvpAriaLabel": "Rimuovi RSVP", + "inviteLinkExpiresAt": "Questo link scade quando l'evento inizia: {time}" }, "discover": { "title": "Scopri Eventi - Cactoide", diff --git a/src/lib/i18n/messages.json b/src/lib/i18n/messages.json index 6bcac7f..113d613 100644 --- a/src/lib/i18n/messages.json +++ b/src/lib/i18n/messages.json @@ -42,7 +42,6 @@ "numberOfGuests": "Number of Guests", "addGuests": "Add guest users", "joinEvent": "Join Event", - "copyLink": "Event link copied to clipboard.", "addToCalendar": "Add to Calendar", "close": "Close", "closeModal": "Close modal", diff --git a/src/routes/event/[id]/+page.svelte b/src/routes/event/[id]/+page.svelte index f05e3d1..abd1558 100644 --- a/src/routes/event/[id]/+page.svelte +++ b/src/routes/event/[id]/+page.svelte @@ -31,6 +31,7 @@ $: event = data.event; $: rsvps = data.rsvps; $: currentUserId = data.userId; + $: isEventCreator = event.user_id === currentUserId; // Create calendar event object when event data changes $: if (event && browser) { @@ -77,7 +78,7 @@ const eventId = $page.params.id || ''; const copyEventLink = () => { - if (browser) { + if (browser && isEventCreator) { const url = `${$page.url.origin}/event/${eventId}`; navigator.clipboard.writeText(url).then(() => { toastType = 'copy'; @@ -442,12 +443,17 @@
- + {#if event.visibility !== 'invite-only'} + + {/if}
- - {#if eventData.visibility === 'invite-only' && inviteToken} + + {#if eventData.visibility === 'invite-only' && inviteToken && data.event.userId === data.userId}

Invite Link

@@ -395,7 +399,7 @@ 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" > - {inviteLinkCopied ? t('common.success') : t('common.copyLink')} + {t('event.copyInviteLinkButton')}

@@ -436,3 +440,12 @@

+ + +{#if showInviteLinkToast} +
+ {t('event.inviteLinkCopied')} +
+{/if} diff --git a/src/routes/event/[id]/invite/[token]/+page.svelte b/src/routes/event/[id]/invite/[token]/+page.svelte index 8cec4a8..946f8c2 100644 --- a/src/routes/event/[id]/invite/[token]/+page.svelte +++ b/src/routes/event/[id]/invite/[token]/+page.svelte @@ -27,6 +27,7 @@ $: event = data.event; $: rsvps = data.rsvps; $: currentUserId = data.userId; + $: isEventCreator = event.user_id === currentUserId; // Create calendar event object when event data changes $: if (event && browser) { @@ -58,7 +59,7 @@ const token = $page.params.token || ''; const copyInviteLink = () => { - if (browser) { + if (browser && isEventCreator) { const url = `${$page.url.origin}/event/${eventId}/invite/${token}`; navigator.clipboard.writeText(url).then(() => { success = 'Invite link copied to clipboard!'; @@ -415,7 +416,10 @@
From 5809cb49ee74d839e8f2571df504870e85897c3b Mon Sep 17 00:00:00 2001 From: Levente Orban Date: Sun, 26 Oct 2025 17:41:44 +0100 Subject: [PATCH 3/3] fix: rvsp delet models --- .../20241220_001_add_invite_only_events.sql | 6 +- ...20_001_add_invite_only_events_rollback.sql | 6 +- package-lock.json | 57 ++++++++----------- package.json | 2 +- src/routes/event/[id]/+page.server.ts | 5 +- src/routes/event/[id]/+page.svelte | 22 ++++--- 6 files changed, 45 insertions(+), 53 deletions(-) diff --git a/database/migrations/20241220_001_add_invite_only_events.sql b/database/migrations/20241220_001_add_invite_only_events.sql index cdcb0aa..442e393 100644 --- a/database/migrations/20241220_001_add_invite_only_events.sql +++ b/database/migrations/20241220_001_add_invite_only_events.sql @@ -3,11 +3,11 @@ -- Description: Adds invite-only visibility option and invite tokens table -- Add 'invite-only' to the visibility enum -ALTER TABLE events +ALTER TABLE events DROP CONSTRAINT IF EXISTS events_visibility_check; -ALTER TABLE events -ADD CONSTRAINT events_visibility_check +ALTER TABLE events +ADD CONSTRAINT events_visibility_check CHECK (visibility IN ('public', 'private', 'invite-only')); -- Create invite_tokens table diff --git a/database/migrations/20241220_001_add_invite_only_events_rollback.sql b/database/migrations/20241220_001_add_invite_only_events_rollback.sql index 49b654b..3fb0261 100644 --- a/database/migrations/20241220_001_add_invite_only_events_rollback.sql +++ b/database/migrations/20241220_001_add_invite_only_events_rollback.sql @@ -9,9 +9,9 @@ DROP INDEX IF EXISTS idx_invite_tokens_event_id; DROP TABLE IF EXISTS invite_tokens; -- Revert visibility enum to original values -ALTER TABLE events +ALTER TABLE events DROP CONSTRAINT IF EXISTS events_visibility_check; -ALTER TABLE events -ADD CONSTRAINT events_visibility_check +ALTER TABLE events +ADD CONSTRAINT events_visibility_check CHECK (visibility IN ('public', 'private')); diff --git a/package-lock.json b/package-lock.json index a1695a2..8c70d36 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,11 +1,11 @@ { - "name": "event-cactus", + "name": "cactoide", "version": "0.1.1", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "event-cactus", + "name": "cactoide", "version": "0.1.1", "dependencies": { "@sveltejs/adapter-node": "^5.3.1", @@ -22,7 +22,7 @@ "@tailwindcss/forms": "^0.5.9", "@tailwindcss/typography": "^0.5.15", "@tailwindcss/vite": "^4.0.0", - "drizzle-kit": "^0.31.4", + "drizzle-kit": "^0.31.5", "eslint": "^9.18.0", "eslint-config-prettier": "^10.0.1", "eslint-plugin-svelte": "^3.0.0", @@ -1643,6 +1643,7 @@ "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.30.1.tgz", "integrity": "sha512-LyRpQmokZdMK4QOlGBbLX12c37IRnvC3rE6ysA4gLmBWMx5mheeEEjkZZXhtIL9Lze0BgMttaALFoROTx+kbEw==", "license": "MIT", + "peer": true, "dependencies": { "@standard-schema/spec": "^1.0.0", "@sveltejs/acorn-typescript": "^1.0.5", @@ -1675,6 +1676,7 @@ "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-6.1.2.tgz", "integrity": "sha512-7v+7OkUYelC2dhhYDAgX1qO2LcGscZ18Hi5kKzJQq7tQeXpH215dd0+J/HnX2zM5B3QKcIrTVqCGkZXAy5awYw==", "license": "MIT", + "peer": true, "dependencies": { "@sveltejs/vite-plugin-svelte-inspector": "^5.0.0", "debug": "^4.4.1", @@ -2093,17 +2095,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/node": { - "version": "24.3.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.3.0.tgz", - "integrity": "sha512-aPTXCrfwnDLj4VvXrm+UUCQjNEvJgNA8s5F1cvwQU+3KNltTOkBm1j30uNLyqqPNe7gE3KFzImYoZEfLhp4Yow==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "undici-types": "~7.10.0" - } - }, "node_modules/@types/resolve": { "version": "1.20.2", "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz", @@ -2156,6 +2147,7 @@ "integrity": "sha512-pUXGCuHnnKw6PyYq93lLRiZm3vjuslIy7tus1lIQTYVK9bL8XBgJnCWm8a0KcTtHC84Yya1Q6rtll+duSMj0dg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.39.1", "@typescript-eslint/types": "8.39.1", @@ -2373,6 +2365,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2668,9 +2661,9 @@ "license": "MIT" }, "node_modules/drizzle-kit": { - "version": "0.31.4", - "resolved": "https://registry.npmjs.org/drizzle-kit/-/drizzle-kit-0.31.4.tgz", - "integrity": "sha512-tCPWVZWZqWVx2XUsVpJRnH9Mx0ClVOf5YUHerZ5so1OKSlqww4zy1R5ksEdGRcO3tM3zj0PYN6V48TbQCL1RfA==", + "version": "0.31.5", + "resolved": "https://registry.npmjs.org/drizzle-kit/-/drizzle-kit-0.31.5.tgz", + "integrity": "sha512-+CHgPFzuoTQTt7cOYCV6MOw2w8vqEn/ap1yv4bpZOWL03u7rlVRQhUY0WYT3rHsgVTXwYQDZaSUJSQrMBUKuWg==", "dev": true, "license": "MIT", "dependencies": { @@ -2828,6 +2821,7 @@ "integrity": "sha512-CRbODhYyQx3qp7ZEwzxOk4JBqmD/seJrzPa/cGjY1VtIn5E09Oi9/dB4JwctnfZ8Q8iT7rioVv5k/FNT/uf54g==", "hasInstallScript": true, "license": "MIT", + "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -2895,6 +2889,7 @@ "integrity": "sha512-TS9bTNIryDzStCpJN93aC5VRSW3uTx9sClUn4B87pwiCaJh220otoI0X8mJKr+VcPtniMdN8GKjlwgWGUv5ZKA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", @@ -4120,6 +4115,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -4242,6 +4238,7 @@ "resolved": "https://registry.npmjs.org/postgres/-/postgres-3.4.7.tgz", "integrity": "sha512-Jtc2612XINuBjIl/QTWsV5UvE8UHuNblcO3vVADSrKsrc6RqGX6lOW1cEo3CM2v0XG4Nat8nI+YM7/f26VxXLw==", "license": "Unlicense", + "peer": true, "engines": { "node": ">=12" }, @@ -4266,6 +4263,7 @@ "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -4282,6 +4280,7 @@ "integrity": "sha512-pn1ra/0mPObzqoIQn/vUTR3ZZI6UuZ0sHqMK5x2jMLGrs53h0sXhkVuDcrlssHwIMk7FYrMjHBPoUSyyEEDlBQ==", "dev": true, "license": "MIT", + "peer": true, "peerDependencies": { "prettier": "^3.0.0", "svelte": "^3.2.0 || ^4.0.0-next.0 || ^5.0.0-next.0" @@ -4475,6 +4474,7 @@ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.46.2.tgz", "integrity": "sha512-WMmLFI+Boh6xbop+OAGo9cQ3OgX9MIg7xOQjn+pTCwOkk+FNDAeAemXkJ3HzDJrVXleLOFVa1ipuc1AmEx1Dwg==", "license": "MIT", + "peer": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -4674,6 +4674,7 @@ "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.38.1.tgz", "integrity": "sha512-fO6CLDfJYWHgfo6lQwkQU2vhCiHc2MBl6s3vEhK+sSZru17YL4R5s1v14ndRpqKAIkq8nCz6MTk1yZbESZWeyQ==", "license": "MIT", + "peer": true, "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", @@ -4766,7 +4767,8 @@ "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.12.tgz", "integrity": "sha512-DzFtxOi+7NsFf7DBtI3BJsynR+0Yp6etH+nRPTbpWnS2pZBaSksv/JGctNwSWzbFjp0vxSqknaUylseZqMDGrA==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/tapable": { "version": "2.2.2", @@ -4866,6 +4868,7 @@ "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -4903,8 +4906,7 @@ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.10.0.tgz", "integrity": "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag==", "license": "MIT", - "optional": true, - "peer": true + "optional": true }, "node_modules/uri-js": { "version": "4.4.1", @@ -4928,6 +4930,7 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.11.tgz", "integrity": "sha512-uzcxnSDVjAopEUjljkWh8EIrg6tlzrjFUfMcR1EVsRDGwf/ccef0qQPRyOrROwhrTDaApueq+ja+KLPlzR/zdg==", "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -5052,20 +5055,6 @@ "node": ">=18" } }, - "node_modules/yaml": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.1.tgz", - "integrity": "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==", - "license": "ISC", - "optional": true, - "peer": true, - "bin": { - "yaml": "bin.mjs" - }, - "engines": { - "node": ">= 14.6" - } - }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/package.json b/package.json index eacf05f..ed485f8 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,7 @@ "@tailwindcss/forms": "^0.5.9", "@tailwindcss/typography": "^0.5.15", "@tailwindcss/vite": "^4.0.0", - "drizzle-kit": "^0.31.4", + "drizzle-kit": "^0.31.5", "eslint": "^9.18.0", "eslint-config-prettier": "^10.0.1", "eslint-plugin-svelte": "^3.0.0", diff --git a/src/routes/event/[id]/+page.server.ts b/src/routes/event/[id]/+page.server.ts index f5db9c3..afb8cc2 100644 --- a/src/routes/event/[id]/+page.server.ts +++ b/src/routes/event/[id]/+page.server.ts @@ -1,9 +1,8 @@ import { database } from '$lib/database/db'; -import { events, rsvps, inviteTokens } from '$lib/database/schema'; -import { eq, asc, and } from 'drizzle-orm'; +import { events, rsvps } from '$lib/database/schema'; +import { eq, asc } 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; diff --git a/src/routes/event/[id]/+page.svelte b/src/routes/event/[id]/+page.svelte index abd1558..c764864 100644 --- a/src/routes/event/[id]/+page.svelte +++ b/src/routes/event/[id]/+page.svelte @@ -18,7 +18,7 @@ let newAttendeeName = ''; let isAddingRSVP = false; let error = ''; - let success = ''; + let success = ''; // TODO: change to boolean and refactor with 482-506 let addGuests = false; let numberOfGuests = 1; let showCalendarModal = false; @@ -50,19 +50,20 @@ success = ''; } - // TODO: ERROR - // //WHEN DELETING RSVP: THE MODAL MESSAGE IS "RSVP removed successfully." + const handleFormSuccess = () => { + if (form?.type === 'add') { + success = 'RSVP added successfully!'; + } else { + success = 'RSVP removed successfully.'; + } - // Handle form success from server - $: if (form?.success) { - success = 'RSVP added successfully!'; error = ''; newAttendeeName = ''; addGuests = false; numberOfGuests = 1; - // show and auto-hide success toast for add action - toastType = 'add'; + toastType = form?.type || 'add'; + if (browser) { if (successHideTimer) clearTimeout(successHideTimer); successHideTimer = window.setTimeout(() => { @@ -70,7 +71,10 @@ toastType = null; }, 3000); } - } + }; + + // Handle form success from server + $: if (form?.success) handleFormSuccess(); // Derive toast type from local or server form $: typeToShow = toastType ?? form?.type;