diff --git a/Makefile b/Makefile index a9d2d58..37879b8 100644 --- a/Makefile +++ b/Makefile @@ -1,22 +1,54 @@ -.PHONY: help build up db-only logs db-clean prune i18n lint format +.PHONY: help build up down db-only logs db-clean prune i18n lint format migrate-up migrate-down + +# Database connection variables +DB_HOST ?= localhost +DB_PORT ?= 5432 +DB_NAME ?= cactoide_database +DB_USER ?= cactoide +DB_PASSWORD ?= cactoide_password + +# Migration variables +MIGRATIONS_DIR = database/migrations + +# Database connection string +DB_URL = postgresql://$(DB_USER):$(DB_PASSWORD)@$(DB_HOST):$(DB_PORT)/$(DB_NAME) -# 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 " 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..." + @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 \ + echo "Migration file not found: $(MIGRATIONS_DIR)/20241220_001_add_invite_only_events.sql"; \ + exit 1; \ + fi + +# 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 # Build the Docker images build: @@ -28,6 +60,14 @@ up: @echo "Starting all services..." docker compose up -d +down: + @echo "Stopping all services..." + docker compose down + +db-clean: + @echo "Cleaning up all Docker resources..." + docker stop cactoide-db && docker rm cactoide-db && docker volume prune -f && docker network prune -f + # Start only the database db-only: @echo "Starting only the database..." @@ -38,23 +78,13 @@ 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); \ - else \ - ./scripts/i18n-check.sh; \ - fi lint: @echo "Linting the project..." @@ -64,4 +94,8 @@ 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 fa47a98..a65afd6 100644 --- a/database/init.sql +++ b/database/init.sql @@ -43,21 +43,4 @@ 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); --- ======================================= --- 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/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..442e393 --- /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..3fb0261 --- /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/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/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/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 d4e0f3e..113d613 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,6 @@ "numberOfGuests": "Number of Guests", "addGuests": "Add guest users", "joinEvent": "Join Event", - "copyLink": "Copy Link", "addToCalendar": "Add to Calendar", "close": "Close", "closeModal": "Close modal", @@ -64,9 +64,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 +162,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 +192,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 +217,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..afb8cc2 100644 --- a/src/routes/event/[id]/+page.server.ts +++ b/src/routes/event/[id]/+page.server.ts @@ -25,6 +25,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 +95,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..c764864 100644 --- a/src/routes/event/[id]/+page.svelte +++ b/src/routes/event/[id]/+page.svelte @@ -10,23 +10,28 @@ 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[] = []; 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; 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; $: rsvps = data.rsvps; $: currentUserId = data.userId; + $: isEventCreator = event.user_id === currentUserId; // Create calendar event object when event data changes $: if (event && browser) { @@ -45,24 +50,47 @@ success = ''; } - // Handle form success from server - $: if (form?.success) { - success = 'RSVP added successfully!'; + const handleFormSuccess = () => { + if (form?.type === 'add') { + success = 'RSVP added successfully!'; + } else { + success = 'RSVP removed successfully.'; + } + error = ''; newAttendeeName = ''; addGuests = false; numberOfGuests = 1; - } + + toastType = form?.type || 'add'; + + if (browser) { + if (successHideTimer) clearTimeout(successHideTimer); + successHideTimer = window.setTimeout(() => { + success = ''; + toastType = null; + }, 3000); + } + }; + + // Handle form success from server + $: if (form?.success) handleFormSuccess(); + + // Derive toast type from local or server form + $: typeToShow = toastType ?? form?.type; const eventId = $page.params.id || ''; const copyEventLink = () => { - if (browser) { + if (browser && isEventCreator) { 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 +99,7 @@ const clearMessages = () => { error = ''; success = ''; + toastType = null; }; // Calendar modal functions @@ -208,7 +237,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,101 +351,113 @@
-
-
-

{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}
- + {#if event.visibility !== 'invite-only'} + + {/if}

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

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

Invite Link

+
+ +
+
+ + +
+

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

+
+
+ {/if} +
+ + +{#if showInviteLinkToast} +
+ {t('event.inviteLinkCopied')} +
+{/if} diff --git a/src/routes/event/[id]/invite/[token]/+page.server.ts b/src/routes/event/[id]/invite/[token]/+page.server.ts new file mode 100644 index 0000000..b42ccd3 --- /dev/null +++ b/src/routes/event/[id]/invite/[token]/+page.server.ts @@ -0,0 +1,223 @@ +import { database } from '$lib/database/db'; +import { events, rsvps, inviteTokens } from '$lib/database/schema'; +import { eq, and, 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; + const token = params.token; + + if (!eventId || !token) { + throw error(404, 'Event or token not found'); + } + + try { + // Fetch event, RSVPs, and invite token in parallel + const [eventData, rsvpData, tokenData] = await Promise.all([ + database.select().from(events).where(eq(events.id, eventId)).limit(1), + database.select().from(rsvps).where(eq(rsvps.eventId, eventId)).orderBy(asc(rsvps.createdAt)), + database + .select() + .from(inviteTokens) + .where(and(eq(inviteTokens.eventId, eventId), eq(inviteTokens.token, token))) + .limit(1) + ]); + + if (!eventData[0]) { + throw error(404, 'Event not found'); + } + + if (!tokenData[0]) { + throw error(404, 'Invalid invite token'); + } + + const event = eventData[0]; + const eventRsvps = rsvpData; + const inviteToken = tokenData[0]; + + // Check if token is still valid + if (!isTokenValid(inviteToken.expiresAt.toISOString())) { + throw error(410, 'Invite token has expired'); + } + + // Check if event is invite-only + if (event.visibility !== 'invite-only') { + throw error(403, 'This event does not require an invite'); + } + + // Transform the data to match the expected interface + const transformedEvent = { + id: event.id, + name: event.name, + date: event.date, + time: event.time, + location: event.location, + location_type: event.locationType, + location_url: event.locationUrl, + type: event.type, + attendee_limit: event.attendeeLimit, + visibility: event.visibility, + user_id: event.userId, + created_at: event.createdAt?.toISOString() || new Date().toISOString(), + updated_at: event.updatedAt?.toISOString() || new Date().toISOString() + }; + + const transformedRsvps = eventRsvps.map((rsvp) => ({ + id: rsvp.id, + event_id: rsvp.eventId, + name: rsvp.name, + user_id: rsvp.userId, + created_at: rsvp.createdAt?.toISOString() || new Date().toISOString() + })); + + const userId = cookies.get('cactoideUserId'); + + return { + event: transformedEvent, + rsvps: transformedRsvps, + userId: userId, + inviteToken: { + id: inviteToken.id, + event_id: inviteToken.eventId, + token: inviteToken.token, + expires_at: inviteToken.expiresAt.toISOString(), + created_at: inviteToken.createdAt?.toISOString() || new Date().toISOString() + } + }; + } catch (err) { + if (err instanceof Response) throw err; // This is the 404/410/403 error + + console.error('Error loading invite-only event:', err); + throw error(500, 'Failed to load event'); + } +}; + +export const actions: Actions = { + addRSVP: async ({ request, params, cookies }) => { + const eventId = params.id; + const token = params.token; + const formData = await request.formData(); + + const name = formData.get('newAttendeeName') as string; + const numberOfGuests = parseInt(formData.get('numberOfGuests') as string) || 0; + const userId = cookies.get('cactoideUserId'); + + if (!name?.trim() || !userId) { + return fail(400, { error: 'Name and user ID are required' }); + } + + try { + // Verify the invite token is still valid + const [tokenData] = await database + .select() + .from(inviteTokens) + .where(and(eq(inviteTokens.eventId, eventId), eq(inviteTokens.token, token))) + .limit(1); + + if (!tokenData || !isTokenValid(tokenData.expiresAt.toISOString())) { + return fail(403, { error: 'Invalid or expired invite token' }); + } + + // Check if event exists and get its details + const [eventData] = await database.select().from(events).where(eq(events.id, eventId)); + if (!eventData) { + return fail(404, { error: 'Event not found' }); + } + + // Get current RSVPs + const currentRSVPs = await database.select().from(rsvps).where(eq(rsvps.eventId, eventId)); + + // Calculate total attendees (including guests) + const totalAttendees = currentRSVPs.length + numberOfGuests; + + // Check if event is full (for limited type events) + if (eventData.type === 'limited' && eventData.attendeeLimit) { + if (totalAttendees > eventData.attendeeLimit) { + return fail(400, { + error: `Event capacity exceeded. You're trying to add ${numberOfGuests + 1} attendees (including yourself), but only ${eventData.attendeeLimit - currentRSVPs.length} spots remain.` + }); + } + } + + // Check if name is already in the list + if (currentRSVPs.some((rsvp) => rsvp.name.toLowerCase() === name.toLowerCase())) { + return fail(400, { error: 'Name already exists for this event' }); + } + + // Prepare RSVPs to insert + const rsvpsToInsert = [ + { + eventId: eventId, + name: name.trim(), + userId: userId, + createdAt: new Date() + } + ]; + + // Add guest entries + for (let i = 1; i <= numberOfGuests; i++) { + rsvpsToInsert.push({ + eventId: eventId, + name: `${name.trim()}'s Guest #${i}`, + userId: userId, + createdAt: new Date() + }); + } + + // Insert all RSVPs + await database.insert(rsvps).values(rsvpsToInsert); + + return { success: true, type: 'add' }; + } catch (err) { + console.error('Error adding RSVP:', err); + return fail(500, { error: 'Failed to add RSVP' }); + } + }, + + removeRSVP: async ({ request, params, cookies }) => { + const eventId = params.id; + const token = params.token; + const formData = await request.formData(); + + const rsvpId = formData.get('rsvpId') as string; + const userId = cookies.get('cactoideUserId'); + + if (!rsvpId || !userId) { + return fail(400, { error: 'RSVP ID and user ID are required' }); + } + + try { + // Verify the invite token is still valid + const [tokenData] = await database + .select() + .from(inviteTokens) + .where(and(eq(inviteTokens.eventId, eventId), eq(inviteTokens.token, token))) + .limit(1); + + if (!tokenData || !isTokenValid(tokenData.expiresAt.toISOString())) { + return fail(403, { error: 'Invalid or expired invite token' }); + } + + // Check if RSVP exists and belongs to the user + const [rsvpData] = await database + .select() + .from(rsvps) + .where(and(eq(rsvps.id, rsvpId), eq(rsvps.eventId, eventId), eq(rsvps.userId, userId))) + .limit(1); + + if (!rsvpData) { + return fail(404, { error: 'RSVP not found or you do not have permission to remove it' }); + } + + // Delete the RSVP + await database.delete(rsvps).where(eq(rsvps.id, rsvpId)); + + return { success: true, type: 'remove' }; + } catch (err) { + console.error('Error removing RSVP:', err); + return fail(500, { error: 'Failed to remove RSVP' }); + } + } +}; diff --git a/src/routes/event/[id]/invite/[token]/+page.svelte b/src/routes/event/[id]/invite/[token]/+page.svelte new file mode 100644 index 0000000..946f8c2 --- /dev/null +++ b/src/routes/event/[id]/invite/[token]/+page.svelte @@ -0,0 +1,472 @@ + + + + {event?.name || t('event.eventTitle')} - Invite Only + + +
+ +
+ {#if error && !event} + +
+
+
⚠️
+

{t('event.eventNotFoundTitle')}

+

{t('event.eventNotFoundDescription')}

+ +
+
+ {: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}