diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index b0d8710..a7ae637 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -10,7 +10,7 @@ jobs: name: Tests runs-on: docker container: - image: cypress/browsers:latest@sha256:9daea41366dfd1b72496bf3e8295eda215a6990c2dbe4f9ff4b8ba47342864fb + image: cypress/browsers:latest@sha256:95587c1ce688ce6f59934cc234a753a32a1782ca1c7959707a7d2332e69f6f63 options: --user 1001 steps: - name: Checkout diff --git a/.prettierrc.json b/.prettierrc.json index c9dddfe..e29d501 100644 --- a/.prettierrc.json +++ b/.prettierrc.json @@ -1,21 +1,4 @@ { "singleQuote": true, - "jsxSingleQuote": true, - "semi": true, - "trailingComma": "all", - "printWidth": 80, - "tabWidth": 2, - "useTabs": false, - "endOfLine": "lf", - "importOrder": [ - "^@/components/(.*)$", - "^@/lib/(.*)$", - "^@/app/(.*)$", - "^@/generated/(.*)$", - "^@/(auth|prisma)$", - "^[./]" - ], - "importOrderSeparation": true, - "importOrderSortSpecifiers": true, - "plugins": ["@trivago/prettier-plugin-sort-imports"] + "jsxSingleQuote": true } diff --git a/cypress.config.ts b/cypress.config.ts index 01bd43f..bebdaa5 100644 --- a/cypress.config.ts +++ b/cypress.config.ts @@ -9,5 +9,8 @@ export default defineConfig({ }, e2e: { + setupNodeEvents(on, config) { + // implement node event listeners here + }, }, }); diff --git a/cypress/e2e/auth-user.ts b/cypress/e2e/auth-user.ts deleted file mode 100644 index 5b02ab9..0000000 --- a/cypress/e2e/auth-user.ts +++ /dev/null @@ -1,12 +0,0 @@ -export default function authUser() { - cy.visit('http://127.0.0.1:3000/login'); - cy.getBySel('login-header').should('exist'); - cy.getBySel('login-form').should('exist'); - cy.getBySel('email-input').should('exist'); - cy.getBySel('password-input').should('exist'); - cy.getBySel('login-button').should('exist'); - cy.getBySel('email-input').type('cypress@example.com'); - cy.getBySel('password-input').type('Password123!'); - cy.getBySel('login-button').click(); - cy.url().should('include', '/home'); -} diff --git a/cypress/e2e/event-create.cy.ts b/cypress/e2e/event-create.cy.ts index a74f770..2a8385a 100644 --- a/cypress/e2e/event-create.cy.ts +++ b/cypress/e2e/event-create.cy.ts @@ -1,9 +1,40 @@ -import authUser from './auth-user'; - describe('event creation', () => { it('loads', () => { - authUser(); + cy.login(); - // cy.visit('http://127.0.0.1:3000/events/new'); // TODO: Add event creation tests + cy.visit('http://127.0.0.1:3000/events/new'); + cy.getBySel('event-form').should('exist'); + cy.getBySel('event-form').within(() => { + cy.getBySel('event-name-input').should('exist'); + cy.getBySel('event-start-time-picker').should('exist'); + cy.getBySel('event-end-time-picker').should('exist'); + cy.getBySel('event-location-input').should('exist'); + cy.getBySel('event-description-input').should('exist'); + cy.getBySel('event-save-button').should('exist'); + }); + }); + + it('creates an event', () => { + cy.login(); + cy.visit( + 'http://127.0.0.1:3000/events/new?start=2025-07-01T01:00:00.000Z&end=2025-07-01T04:30:00.000Z', + ); + + cy.getBySel('event-form').should('exist'); + cy.getBySel('event-form').within(() => { + cy.getBySel('event-name-input').type('Cypress Test Event'); + cy.getBySel('event-location-input').type('Cypress Park'); + cy.getBySel('event-description-input').type( + 'This is a test event created by Cypress.', + ); + cy.getBySel('event-save-button').click(); + }); + cy.wait(1000); + cy.visit('http://127.0.0.1:3000/events'); + cy.getBySel('event-list-entry').should('exist'); + cy.getBySel('event-list-entry') + .contains('Cypress Test Event') + .should('exist'); + cy.getBySel('event-list-entry').contains('Cypress Park').should('exist'); }); }); diff --git a/cypress/e2e/seed.ts b/cypress/e2e/seed.ts index a39f255..c3cd389 100644 --- a/cypress/e2e/seed.ts +++ b/cypress/e2e/seed.ts @@ -1,4 +1,3 @@ -// eslint-disable-next-line no-relative-import-paths/no-relative-import-paths import { PrismaClient } from '../../src/generated/prisma'; const prisma = new PrismaClient(); diff --git a/cypress/support/commands.ts b/cypress/support/commands.ts index aeb4071..994b7ef 100644 --- a/cypress/support/commands.ts +++ b/cypress/support/commands.ts @@ -1,5 +1,5 @@ -/* eslint-disable @typescript-eslint/no-namespace */ /* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable @typescript-eslint/no-namespace */ /// // *********************************************** // This example commands.ts shows you how to @@ -46,6 +46,22 @@ Cypress.Commands.add('getBySelLike', (selector, ...args) => { return cy.get(`[data-cy*=${selector}]`, ...args); }); +Cypress.Commands.add('login', () => { + cy.session('auth', () => { + cy.visit('http://127.0.0.1:3000/login'); + cy.getBySel('login-header').should('exist'); + cy.getBySel('login-form').should('exist'); + cy.getBySel('email-input').should('exist'); + cy.getBySel('password-input').should('exist'); + cy.getBySel('login-button').should('exist'); + cy.getBySel('email-input').type('cypress@example.com'); + cy.getBySel('password-input').type('Password123!'); + cy.getBySel('login-button').click(); + cy.url().should('include', '/home'); + cy.getBySel('header').should('exist'); + }); +}); + declare global { namespace Cypress { interface Chainable { @@ -57,6 +73,7 @@ declare global { selector: string, ...args: any[] ): Chainable>; + login(): Chainable; } } } diff --git a/cypress/support/component.ts b/cypress/support/component.ts index bc93c00..b1f1c92 100644 --- a/cypress/support/component.ts +++ b/cypress/support/component.ts @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/no-namespace */ // *********************************************************** // This example support/component.ts is processed and // loaded automatically before your test files. @@ -13,13 +12,14 @@ // You can read more here: // https://on.cypress.io/configuration // *********************************************************** -import { mount } from 'cypress/react'; import '@/app/globals.css'; // Import commands.js using ES2015 syntax: import './commands'; +import { mount } from 'cypress/react'; + // Augment the Cypress namespace to include type definitions for // your custom command. // Alternatively, can be defined in cypress/support/component.d.ts diff --git a/cypress/support/e2e.ts b/cypress/support/e2e.ts index 492ec44..e66558e 100644 --- a/cypress/support/e2e.ts +++ b/cypress/support/e2e.ts @@ -12,5 +12,6 @@ // You can read more here: // https://on.cypress.io/configuration // *********************************************************** + // Import commands.js using ES2015 syntax: import './commands'; diff --git a/eslint.config.mjs b/eslint.config.mjs index 5e7d76f..b557f04 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -1,7 +1,6 @@ -import { FlatCompat } from '@eslint/eslintrc'; -import noRelativeImportPaths from 'eslint-plugin-no-relative-import-paths'; import { dirname } from 'path'; import { fileURLToPath } from 'url'; +import { FlatCompat } from '@eslint/eslintrc'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); @@ -15,21 +14,6 @@ const eslintConfig = [ { ignores: ['src/generated/**', '.next/**', 'public/**'], }, - { - plugins: { - 'no-relative-import-paths': noRelativeImportPaths, - }, - rules: { - 'no-relative-import-paths/no-relative-import-paths': [ - 'error', - { - allowSameFolder: true, - rootDir: 'src', - prefix: "@", - }, - ], - }, - }, ]; export default eslintConfig; diff --git a/exportSwagger.ts b/exportSwagger.ts index 4d033fd..6b6df1e 100644 --- a/exportSwagger.ts +++ b/exportSwagger.ts @@ -1,9 +1,8 @@ +import { registry } from '@/lib/swagger'; import { OpenApiGeneratorV3 } from '@asteasolutions/zod-to-openapi'; import fs from 'fs'; import path from 'path'; -import { registry } from '@/lib/swagger'; - function recursiveFileSearch(dir: string, fileList: string[] = []): string[] { const files = fs.readdirSync(dir); files.forEach((file) => { diff --git a/next.config.ts b/next.config.ts index 164b423..b9574f9 100644 --- a/next.config.ts +++ b/next.config.ts @@ -6,7 +6,7 @@ const nextConfig: NextConfig = { remotePatterns: [ { protocol: 'https', - hostname: 'img1.wikia.nocookie.net', + hostname: 'i.gifer.com', port: '', pathname: '/**', }, diff --git a/package.json b/package.json index 02848ca..95266fc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "meetup", - "version": "0.1.0", + "version": "0.1.3", "private": true, "scripts": { "dev": "next dev --turbopack", @@ -73,19 +73,17 @@ "devDependencies": { "@eslint/eslintrc": "3.3.1", "@tailwindcss/postcss": "4.1.11", - "@trivago/prettier-plugin-sort-imports": "^5.2.2", - "@types/node": "22.15.34", + "@types/node": "22.16.0", "@types/react": "19.1.8", "@types/react-big-calendar": "1.16.2", "@types/react-dom": "19.1.6", "@types/swagger-ui-react": "5.18.0", "@types/webpack-env": "1.18.8", - "cypress": "14.5.0", + "cypress": "14.5.1", "dotenv-cli": "8.0.0", - "eslint": "9.30.0", + "eslint": "9.30.1", "eslint-config-next": "15.3.4", "eslint-config-prettier": "10.1.5", - "eslint-plugin-no-relative-import-paths": "^1.6.1", "orval": "7.10.0", "postcss": "8.5.6", "prettier": "3.6.2", diff --git a/prisma/migrations/20250701092705_v0_1_3/migration.sql b/prisma/migrations/20250701092705_v0_1_3/migration.sql new file mode 100644 index 0000000..b103c30 --- /dev/null +++ b/prisma/migrations/20250701092705_v0_1_3/migration.sql @@ -0,0 +1,170 @@ +-- RedefineTables +PRAGMA defer_foreign_keys=ON; +PRAGMA foreign_keys=OFF; +CREATE TABLE "new_blocked_slots" ( + "id" TEXT NOT NULL PRIMARY KEY, + "user_id" TEXT NOT NULL, + "start_time" DATETIME NOT NULL, + "end_time" DATETIME NOT NULL, + "reason" TEXT, + "is_recurring" BOOLEAN NOT NULL DEFAULT false, + "rrule" TEXT, + "recurrence_end_date" DATETIME, + "created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" DATETIME NOT NULL, + CONSTRAINT "blocked_slots_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); +INSERT INTO "new_blocked_slots" ("created_at", "end_time", "id", "is_recurring", "reason", "recurrence_end_date", "rrule", "start_time", "updated_at", "user_id") SELECT "created_at", "end_time", "id", "is_recurring", "reason", "recurrence_end_date", "rrule", "start_time", "updated_at", "user_id" FROM "blocked_slots"; +DROP TABLE "blocked_slots"; +ALTER TABLE "new_blocked_slots" RENAME TO "blocked_slots"; +CREATE INDEX "blocked_slots_user_id_start_time_end_time_idx" ON "blocked_slots"("user_id", "start_time", "end_time"); +CREATE INDEX "blocked_slots_user_id_is_recurring_idx" ON "blocked_slots"("user_id", "is_recurring"); +CREATE TABLE "new_calendar_export_tokens" ( + "id" TEXT NOT NULL PRIMARY KEY, + "user_id" TEXT NOT NULL, + "token" TEXT NOT NULL, + "scope" TEXT NOT NULL DEFAULT 'MEETINGS_ONLY', + "is_active" BOOLEAN NOT NULL DEFAULT true, + "created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "last_accessed_at" DATETIME, + CONSTRAINT "calendar_export_tokens_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); +INSERT INTO "new_calendar_export_tokens" ("created_at", "id", "is_active", "last_accessed_at", "scope", "token", "user_id") SELECT "created_at", "id", "is_active", "last_accessed_at", "scope", "token", "user_id" FROM "calendar_export_tokens"; +DROP TABLE "calendar_export_tokens"; +ALTER TABLE "new_calendar_export_tokens" RENAME TO "calendar_export_tokens"; +CREATE UNIQUE INDEX "calendar_export_tokens_token_key" ON "calendar_export_tokens"("token"); +CREATE INDEX "calendar_export_tokens_user_id_idx" ON "calendar_export_tokens"("user_id"); +CREATE TABLE "new_calendar_subscriptions" ( + "id" TEXT NOT NULL PRIMARY KEY, + "user_id" TEXT NOT NULL, + "feed_url" TEXT NOT NULL, + "name" TEXT, + "color" TEXT, + "is_enabled" BOOLEAN NOT NULL DEFAULT true, + "last_synced_at" DATETIME, + "last_sync_error" TEXT, + "sync_frequency_minutes" INTEGER DEFAULT 60, + "created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" DATETIME NOT NULL, + CONSTRAINT "calendar_subscriptions_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); +INSERT INTO "new_calendar_subscriptions" ("color", "created_at", "feed_url", "id", "is_enabled", "last_sync_error", "last_synced_at", "name", "sync_frequency_minutes", "updated_at", "user_id") SELECT "color", "created_at", "feed_url", "id", "is_enabled", "last_sync_error", "last_synced_at", "name", "sync_frequency_minutes", "updated_at", "user_id" FROM "calendar_subscriptions"; +DROP TABLE "calendar_subscriptions"; +ALTER TABLE "new_calendar_subscriptions" RENAME TO "calendar_subscriptions"; +CREATE INDEX "calendar_subscriptions_user_id_is_enabled_idx" ON "calendar_subscriptions"("user_id", "is_enabled"); +CREATE TABLE "new_email_queue" ( + "id" TEXT NOT NULL PRIMARY KEY, + "user_id" TEXT NOT NULL, + "subject" TEXT NOT NULL, + "body_html" TEXT NOT NULL, + "body_text" TEXT, + "status" TEXT NOT NULL DEFAULT 'PENDING', + "scheduled_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "attempts" INTEGER NOT NULL DEFAULT 0, + "last_attempt_at" DATETIME, + "sent_at" DATETIME, + "error_message" TEXT, + "created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" DATETIME NOT NULL, + CONSTRAINT "email_queue_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); +INSERT INTO "new_email_queue" ("attempts", "body_html", "body_text", "created_at", "error_message", "id", "last_attempt_at", "scheduled_at", "sent_at", "status", "subject", "updated_at", "user_id") SELECT "attempts", "body_html", "body_text", "created_at", "error_message", "id", "last_attempt_at", "scheduled_at", "sent_at", "status", "subject", "updated_at", "user_id" FROM "email_queue"; +DROP TABLE "email_queue"; +ALTER TABLE "new_email_queue" RENAME TO "email_queue"; +CREATE INDEX "idx_email_queue_pending_jobs" ON "email_queue"("status", "scheduled_at"); +CREATE INDEX "idx_email_queue_user_history" ON "email_queue"("user_id", "created_at"); +CREATE TABLE "new_external_events" ( + "id" TEXT NOT NULL PRIMARY KEY, + "subscription_id" TEXT NOT NULL, + "ical_uid" TEXT NOT NULL, + "summary" TEXT, + "description" TEXT, + "start_time" DATETIME NOT NULL, + "end_time" DATETIME NOT NULL, + "is_all_day" BOOLEAN NOT NULL DEFAULT false, + "location" TEXT, + "rrule" TEXT, + "dtstamp" DATETIME, + "sequence" INTEGER, + "show_as_free" BOOLEAN NOT NULL DEFAULT false, + "last_fetched_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "external_events_subscription_id_fkey" FOREIGN KEY ("subscription_id") REFERENCES "calendar_subscriptions" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); +INSERT INTO "new_external_events" ("description", "dtstamp", "end_time", "ical_uid", "id", "is_all_day", "last_fetched_at", "location", "rrule", "sequence", "show_as_free", "start_time", "subscription_id", "summary") SELECT "description", "dtstamp", "end_time", "ical_uid", "id", "is_all_day", "last_fetched_at", "location", "rrule", "sequence", "show_as_free", "start_time", "subscription_id", "summary" FROM "external_events"; +DROP TABLE "external_events"; +ALTER TABLE "new_external_events" RENAME TO "external_events"; +CREATE INDEX "external_events_subscription_id_start_time_end_time_idx" ON "external_events"("subscription_id", "start_time", "end_time"); +CREATE INDEX "external_events_subscription_id_show_as_free_idx" ON "external_events"("subscription_id", "show_as_free"); +CREATE UNIQUE INDEX "external_events_subscription_id_ical_uid_key" ON "external_events"("subscription_id", "ical_uid"); +CREATE TABLE "new_friendships" ( + "user_id_1" TEXT NOT NULL, + "user_id_2" TEXT NOT NULL, + "status" TEXT NOT NULL DEFAULT 'PENDING', + "requested_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "accepted_at" DATETIME, + + PRIMARY KEY ("user_id_1", "user_id_2"), + CONSTRAINT "friendships_user_id_1_fkey" FOREIGN KEY ("user_id_1") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "friendships_user_id_2_fkey" FOREIGN KEY ("user_id_2") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); +INSERT INTO "new_friendships" ("accepted_at", "requested_at", "status", "user_id_1", "user_id_2") SELECT "accepted_at", "requested_at", "status", "user_id_1", "user_id_2" FROM "friendships"; +DROP TABLE "friendships"; +ALTER TABLE "new_friendships" RENAME TO "friendships"; +CREATE INDEX "idx_friendships_user2_status" ON "friendships"("user_id_2", "status"); +CREATE TABLE "new_group_members" ( + "group_id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "role" TEXT NOT NULL DEFAULT 'MEMBER', + "added_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + + PRIMARY KEY ("group_id", "user_id"), + CONSTRAINT "group_members_group_id_fkey" FOREIGN KEY ("group_id") REFERENCES "groups" ("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "group_members_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); +INSERT INTO "new_group_members" ("added_at", "group_id", "role", "user_id") SELECT "added_at", "group_id", "role", "user_id" FROM "group_members"; +DROP TABLE "group_members"; +ALTER TABLE "new_group_members" RENAME TO "group_members"; +CREATE INDEX "group_members_user_id_idx" ON "group_members"("user_id"); +CREATE TABLE "new_meeting_participants" ( + "meeting_id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "status" TEXT NOT NULL DEFAULT 'PENDING', + "added_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + + PRIMARY KEY ("meeting_id", "user_id"), + CONSTRAINT "meeting_participants_meeting_id_fkey" FOREIGN KEY ("meeting_id") REFERENCES "meetings" ("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "meeting_participants_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); +INSERT INTO "new_meeting_participants" ("added_at", "meeting_id", "status", "user_id") SELECT "added_at", "meeting_id", "status", "user_id" FROM "meeting_participants"; +DROP TABLE "meeting_participants"; +ALTER TABLE "new_meeting_participants" RENAME TO "meeting_participants"; +CREATE INDEX "idx_participants_user_status" ON "meeting_participants"("user_id", "status"); +CREATE TABLE "new_notifications" ( + "id" TEXT NOT NULL PRIMARY KEY, + "user_id" TEXT NOT NULL, + "type" TEXT NOT NULL, + "related_entity_type" TEXT, + "related_entity_id" TEXT, + "message" TEXT NOT NULL, + "is_read" BOOLEAN NOT NULL DEFAULT false, + "created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "notifications_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); +INSERT INTO "new_notifications" ("created_at", "id", "is_read", "message", "related_entity_id", "related_entity_type", "type", "user_id") SELECT "created_at", "id", "is_read", "message", "related_entity_id", "related_entity_type", "type", "user_id" FROM "notifications"; +DROP TABLE "notifications"; +ALTER TABLE "new_notifications" RENAME TO "notifications"; +CREATE INDEX "idx_notifications_user_read_time" ON "notifications"("user_id", "is_read", "created_at"); +CREATE TABLE "new_user_notification_preferences" ( + "user_id" TEXT NOT NULL, + "notification_type" TEXT NOT NULL, + "email_enabled" BOOLEAN NOT NULL DEFAULT false, + "updated_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + + PRIMARY KEY ("user_id", "notification_type"), + CONSTRAINT "user_notification_preferences_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); +INSERT INTO "new_user_notification_preferences" ("email_enabled", "notification_type", "updated_at", "user_id") SELECT "email_enabled", "notification_type", "updated_at", "user_id" FROM "user_notification_preferences"; +DROP TABLE "user_notification_preferences"; +ALTER TABLE "new_user_notification_preferences" RENAME TO "user_notification_preferences"; +PRAGMA foreign_keys=ON; +PRAGMA defer_foreign_keys=OFF; diff --git a/src/app/(main)/blocker/[slotId]/page.tsx b/src/app/(main)/blocker/[slotId]/page.tsx new file mode 100644 index 0000000..893253c --- /dev/null +++ b/src/app/(main)/blocker/[slotId]/page.tsx @@ -0,0 +1,10 @@ +import BlockedSlotForm from '@/components/forms/blocked-slot-form'; + +export default async function NewBlockedSlotPage({ + params, +}: { + params: Promise<{ slotId?: string }>; +}) { + const resolvedParams = await params; + return ; +} diff --git a/src/app/(main)/blocker/new/page.tsx b/src/app/(main)/blocker/new/page.tsx new file mode 100644 index 0000000..a7c1bc7 --- /dev/null +++ b/src/app/(main)/blocker/new/page.tsx @@ -0,0 +1,5 @@ +import BlockedSlotForm from '@/components/forms/blocked-slot-form'; + +export default function NewBlockedSlotPage() { + return ; +} diff --git a/src/app/(main)/blocker/page.tsx b/src/app/(main)/blocker/page.tsx new file mode 100644 index 0000000..aebc807 --- /dev/null +++ b/src/app/(main)/blocker/page.tsx @@ -0,0 +1,56 @@ +'use client'; + +import { RedirectButton } from '@/components/buttons/redirect-button'; +import BlockedSlotListEntry from '@/components/custom-ui/blocked-slot-list-entry'; +import { Label } from '@/components/ui/label'; +import { useGetApiBlockedSlots } from '@/generated/api/blocked-slots/blocked-slots'; + +export default function BlockedSlots() { + const { data: blockedSlotsData, isLoading, error } = useGetApiBlockedSlots(); + + if (isLoading) return
Loading...
; + if (error) + return ( +
+ Error loading blocked slots +
+ ); + + const blockedSlots = blockedSlotsData?.data?.blocked_slots || []; + + return ( +
+ {/* Heading */} +

+ My Blockers +

+ + {/* Scrollable blocked slot list */} +
+
+ {blockedSlots.length > 0 ? ( + blockedSlots.map((slot) => ( + + )) + ) : ( +
+ + +
+ )} +
+
+
+ ); +} diff --git a/src/app/(main)/events/[eventID]/page.tsx b/src/app/(main)/events/[eventID]/page.tsx index a309c19..b2c4005 100644 --- a/src/app/(main)/events/[eventID]/page.tsx +++ b/src/app/(main)/events/[eventID]/page.tsx @@ -1,16 +1,20 @@ 'use client'; -import { useSession } from 'next-auth/react'; -import { useParams, useRouter } from 'next/navigation'; import React, { useState } from 'react'; -import { toast } from 'sonner'; - -import { RedirectButton } from '@/components/buttons/redirect-button'; -import ParticipantListEntry from '@/components/custom-ui/participant-list-entry'; import Logo from '@/components/misc/logo'; -import { ToastInner } from '@/components/misc/toast-inner'; -import { Button } from '@/components/ui/button'; import { Card, CardContent, CardHeader } from '@/components/ui/card'; +import { Label } from '@/components/ui/label'; +import { + useDeleteApiEventEventID, + useGetApiEventEventID, +} from '@/generated/api/event/event'; +import { RedirectButton } from '@/components/buttons/redirect-button'; +import { useSession } from 'next-auth/react'; +import ParticipantListEntry from '@/components/custom-ui/participant-list-entry'; +import { useParams, useRouter } from 'next/navigation'; +import { Button } from '@/components/ui/button'; +import { ToastInner } from '@/components/misc/toast-inner'; +import { toast } from 'sonner'; import { Dialog, DialogContent, @@ -20,13 +24,6 @@ import { DialogTitle, DialogTrigger, } from '@/components/ui/dialog'; -import { Label } from '@/components/ui/label'; - -import { - useDeleteApiEventEventID, - useGetApiEventEventID, -} from '@/generated/api/event/event'; -import { useGetApiUserMe } from '@/generated/api/user/user'; export default function ShowEvent() { const session = useSession(); @@ -37,27 +34,21 @@ export default function ShowEvent() { // Fetch event data const { data: eventData, isLoading, error } = useGetApiEventEventID(eventID); - const { data: userData, isLoading: userLoading } = useGetApiUserMe(); const deleteEvent = useDeleteApiEventEventID(); - if (isLoading || userLoading) { + if (isLoading) { return ( -
- Loading... -
+
Loading...
); } if (error || !eventData?.data?.event) { return ( -
+
Error loading event.
); } - const event = eventData.data.event; - const organiserName = userData?.data.user?.name || 'Unknown User'; - // Format dates & times for display const formatDate = (isoString?: string) => { if (!isoString) return '-'; @@ -72,167 +63,186 @@ export default function ShowEvent() { }; return ( - - +
+ + - -
-
-
-
- + +
+
+
+
+ +
+
+

+ {eventData.data.event.title || 'Untitled Event'} +

+
+
-
-

- {event.title || 'Untitled Event'} -

-
-
-
-
-
- - -
-
- - -
-
- - -
-
-
-
- - + + +
); } diff --git a/src/app/(main)/events/edit/[eventID]/page.tsx b/src/app/(main)/events/edit/[eventID]/page.tsx index 276aa9a..b099f10 100644 --- a/src/app/(main)/events/edit/[eventID]/page.tsx +++ b/src/app/(main)/events/edit/[eventID]/page.tsx @@ -1,7 +1,6 @@ -import { Suspense } from 'react'; - -import EventForm from '@/components/forms/event-form'; import { Card, CardContent, CardHeader } from '@/components/ui/card'; +import EventForm from '@/components/forms/event-form'; +import { Suspense } from 'react'; export default async function Page({ params, diff --git a/src/app/(main)/events/new/page.tsx b/src/app/(main)/events/new/page.tsx index c1b67f6..1dc1bde 100644 --- a/src/app/(main)/events/new/page.tsx +++ b/src/app/(main)/events/new/page.tsx @@ -1,11 +1,10 @@ -import { Suspense } from 'react'; - -import EventForm from '@/components/forms/event-form'; import { Card, CardContent, CardHeader } from '@/components/ui/card'; +import EventForm from '@/components/forms/event-form'; +import { Suspense } from 'react'; export default function NewEvent() { return ( -
+
diff --git a/src/app/(main)/events/page.tsx b/src/app/(main)/events/page.tsx index 6353ce4..c22b8bd 100644 --- a/src/app/(main)/events/page.tsx +++ b/src/app/(main)/events/page.tsx @@ -3,7 +3,6 @@ import { RedirectButton } from '@/components/buttons/redirect-button'; import EventListEntry from '@/components/custom-ui/event-list-entry'; import { Label } from '@/components/ui/label'; - import { useGetApiEvent } from '@/generated/api/event/event'; export default function Events() { @@ -18,7 +17,10 @@ export default function Events() { const events = eventsData?.data?.events || []; return ( -
+
{/* Heading */}

My Events @@ -26,7 +28,7 @@ export default function Events() { {/* Scrollable event list */}
-
+
{events.length > 0 ? ( events.map((event) => ( {isLoading ? 'Loading...' - : data?.data.user?.first_name || 'Unknown User'}{' '} + : data?.data.user?.first_name || + data?.data.user?.name || + 'Unknown User'}{' '} 👋

diff --git a/src/app/(main)/layout.tsx b/src/app/(main)/layout.tsx index d156b91..7106e70 100644 --- a/src/app/(main)/layout.tsx +++ b/src/app/(main)/layout.tsx @@ -1,9 +1,9 @@ -import { cookies } from 'next/headers'; import React from 'react'; +import { cookies } from 'next/headers'; import { AppSidebar } from '@/components/custom-ui/app-sidebar'; -import Header from '@/components/misc/header'; import SidebarProviderWrapper from '@/components/wrappers/sidebar-provider'; +import Header from '@/components/misc/header'; export default async function Layout({ children, diff --git a/src/app/api-doc/page.tsx b/src/app/api-doc/page.tsx index c5446ca..c6e9694 100644 --- a/src/app/api-doc/page.tsx +++ b/src/app/api-doc/page.tsx @@ -1,5 +1,4 @@ import { getApiDocs } from '@/lib/swagger'; - import ReactSwagger from './react-swagger'; export default async function IndexPage() { diff --git a/src/app/api/blocked_slots/[slotID]/route.ts b/src/app/api/blocked_slots/[slotID]/route.ts new file mode 100644 index 0000000..908324e --- /dev/null +++ b/src/app/api/blocked_slots/[slotID]/route.ts @@ -0,0 +1,165 @@ +import { auth } from '@/auth'; +import { prisma } from '@/prisma'; +import { + returnZodTypeCheckedResponse, + userAuthenticated, +} from '@/lib/apiHelpers'; +import { + updateBlockedSlotSchema, + BlockedSlotResponseSchema, +} from '@/app/api/blocked_slots/validation'; +import { + ErrorResponseSchema, + SuccessResponseSchema, + ZodErrorResponseSchema, +} from '@/app/api/validation'; + +export const GET = auth(async function GET(req, { params }) { + const authCheck = userAuthenticated(req); + if (!authCheck.continue) + return returnZodTypeCheckedResponse( + ErrorResponseSchema, + authCheck.response, + authCheck.metadata, + ); + + const slotID = (await params).slotID; + + const blockedSlot = await prisma.blockedSlot.findUnique({ + where: { + id: slotID, + user_id: authCheck.user.id, + }, + select: { + id: true, + start_time: true, + end_time: true, + reason: true, + created_at: true, + updated_at: true, + is_recurring: true, + recurrence_end_date: true, + rrule: true, + }, + }); + + if (!blockedSlot) { + return returnZodTypeCheckedResponse( + ErrorResponseSchema, + { + success: false, + message: 'Blocked slot not found or not owned by user', + }, + { status: 404 }, + ); + } + + return returnZodTypeCheckedResponse( + BlockedSlotResponseSchema, + { + blocked_slot: blockedSlot, + }, + { + status: 200, + }, + ); +}); + +export const PATCH = auth(async function PATCH(req, { params }) { + const authCheck = userAuthenticated(req); + if (!authCheck.continue) + return returnZodTypeCheckedResponse( + ErrorResponseSchema, + authCheck.response, + authCheck.metadata, + ); + + const slotID = (await params).slotID; + + const dataRaw = await req.json(); + const data = await updateBlockedSlotSchema.safeParseAsync(dataRaw); + if (!data.success) + return returnZodTypeCheckedResponse( + ZodErrorResponseSchema, + { + success: false, + message: 'Invalid request data', + errors: data.error.issues, + }, + { status: 400 }, + ); + + const blockedSlot = await prisma.blockedSlot.update({ + where: { + id: slotID, + user_id: authCheck.user.id, + }, + data: data.data, + select: { + id: true, + start_time: true, + end_time: true, + reason: true, + created_at: true, + updated_at: true, + is_recurring: true, + recurrence_end_date: true, + rrule: true, + }, + }); + + if (!blockedSlot) { + return returnZodTypeCheckedResponse( + ErrorResponseSchema, + { + success: false, + message: 'Blocked slot not found or not owned by user', + }, + { status: 404 }, + ); + } + + return returnZodTypeCheckedResponse( + BlockedSlotResponseSchema, + { success: true, blocked_slot: blockedSlot }, + { status: 200 }, + ); +}); + +export const DELETE = auth(async function DELETE(req, { params }) { + const authCheck = userAuthenticated(req); + if (!authCheck.continue) + return returnZodTypeCheckedResponse( + ErrorResponseSchema, + authCheck.response, + authCheck.metadata, + ); + + const slotID = (await params).slotID; + + const deletedSlot = await prisma.blockedSlot.delete({ + where: { + id: slotID, + user_id: authCheck.user.id, + }, + }); + + if (!deletedSlot) { + return returnZodTypeCheckedResponse( + ErrorResponseSchema, + { + success: false, + message: 'Blocked slot not found or not owned by user', + }, + { status: 404 }, + ); + } + + return returnZodTypeCheckedResponse( + SuccessResponseSchema, + { success: true }, + { + status: 200, + }, + ); +}); diff --git a/src/app/api/blocked_slots/[slotID]/swagger.ts b/src/app/api/blocked_slots/[slotID]/swagger.ts new file mode 100644 index 0000000..16f2637 --- /dev/null +++ b/src/app/api/blocked_slots/[slotID]/swagger.ts @@ -0,0 +1,90 @@ +import { + updateBlockedSlotSchema, + BlockedSlotResponseSchema, +} from '@/app/api/blocked_slots/validation'; +import { + notAuthenticatedResponse, + serverReturnedDataValidationErrorResponse, + userNotFoundResponse, + invalidRequestDataResponse, +} from '@/lib/defaultApiResponses'; +import { OpenAPIRegistry } from '@asteasolutions/zod-to-openapi'; +import { SlotIdParamSchema } from '@/app/api/validation'; +import zod from 'zod/v4'; + +export default function registerSwaggerPaths(registry: OpenAPIRegistry) { + registry.registerPath({ + method: 'get', + path: '/api/blocked_slots/{slotID}', + request: { + params: zod.object({ + slotID: SlotIdParamSchema, + }), + }, + responses: { + 200: { + description: 'Blocked slot retrieved successfully', + content: { + 'application/json': { + schema: BlockedSlotResponseSchema, + }, + }, + }, + ...userNotFoundResponse, + ...notAuthenticatedResponse, + ...serverReturnedDataValidationErrorResponse, + }, + tags: ['Blocked Slots'], + }); + + registry.registerPath({ + method: 'delete', + path: '/api/blocked_slots/{slotID}', + request: { + params: zod.object({ + slotID: SlotIdParamSchema, + }), + }, + responses: { + 204: { + description: 'Blocked slot deleted successfully', + }, + ...userNotFoundResponse, + ...notAuthenticatedResponse, + ...serverReturnedDataValidationErrorResponse, + }, + tags: ['Blocked Slots'], + }); + + registry.registerPath({ + method: 'patch', + path: '/api/blocked_slots/{slotID}', + request: { + params: zod.object({ + slotID: SlotIdParamSchema, + }), + body: { + content: { + 'application/json': { + schema: updateBlockedSlotSchema, + }, + }, + }, + }, + responses: { + 200: { + description: 'Blocked slot updated successfully', + content: { + 'application/json': { + schema: BlockedSlotResponseSchema, + }, + }, + }, + ...userNotFoundResponse, + ...notAuthenticatedResponse, + ...serverReturnedDataValidationErrorResponse, + ...invalidRequestDataResponse, + }, + tags: ['Blocked Slots'], + }); +} diff --git a/src/app/api/blocked_slots/route.ts b/src/app/api/blocked_slots/route.ts new file mode 100644 index 0000000..afd4f87 --- /dev/null +++ b/src/app/api/blocked_slots/route.ts @@ -0,0 +1,127 @@ +import { auth } from '@/auth'; +import { prisma } from '@/prisma'; +import { + returnZodTypeCheckedResponse, + userAuthenticated, +} from '@/lib/apiHelpers'; +import { + blockedSlotsQuerySchema, + BlockedSlotsResponseSchema, + BlockedSlotsSchema, + createBlockedSlotSchema, +} from './validation'; +import { + ErrorResponseSchema, + ZodErrorResponseSchema, +} from '@/app/api/validation'; + +export const GET = auth(async function GET(req) { + const authCheck = userAuthenticated(req); + if (!authCheck.continue) + return returnZodTypeCheckedResponse( + ErrorResponseSchema, + authCheck.response, + authCheck.metadata, + ); + + const dataRaw: Record = {}; + for (const [key, value] of req.nextUrl.searchParams.entries()) { + if (key.endsWith('[]')) { + const cleanKey = key.slice(0, -2); + if (!dataRaw[cleanKey]) { + dataRaw[cleanKey] = []; + } + if (Array.isArray(dataRaw[cleanKey])) { + (dataRaw[cleanKey] as string[]).push(value); + } else { + dataRaw[cleanKey] = [dataRaw[cleanKey] as string, value]; + } + } else { + dataRaw[key] = value; + } + } + const data = await blockedSlotsQuerySchema.safeParseAsync(dataRaw); + if (!data.success) + return returnZodTypeCheckedResponse( + ZodErrorResponseSchema, + { + success: false, + message: 'Invalid request data', + errors: data.error.issues, + }, + { status: 400 }, + ); + const { start, end } = data.data; + + const requestUserId = authCheck.user.id; + + const blockedSlots = await prisma.blockedSlot.findMany({ + where: { + user_id: requestUserId, + start_time: { gte: start }, + end_time: { lte: end }, + }, + orderBy: { start_time: 'asc' }, + select: { + id: true, + start_time: true, + end_time: true, + reason: true, + created_at: true, + updated_at: true, + }, + }); + + return returnZodTypeCheckedResponse( + BlockedSlotsResponseSchema, + { success: true, blocked_slots: blockedSlots }, + { status: 200 }, + ); +}); + +export const POST = auth(async function POST(req) { + const authCheck = userAuthenticated(req); + if (!authCheck.continue) + return returnZodTypeCheckedResponse( + ErrorResponseSchema, + authCheck.response, + authCheck.metadata, + ); + + const dataRaw = await req.json(); + const data = await createBlockedSlotSchema.safeParseAsync(dataRaw); + if (!data.success) + return returnZodTypeCheckedResponse( + ZodErrorResponseSchema, + { + success: false, + message: 'Invalid request data', + errors: data.error.issues, + }, + { status: 400 }, + ); + + const requestUserId = authCheck.user.id; + + if (!requestUserId) { + return returnZodTypeCheckedResponse( + ErrorResponseSchema, + { + success: false, + message: 'User not authenticated', + }, + { status: 401 }, + ); + } + + const blockedSlot = await prisma.blockedSlot.create({ + data: { + ...data.data, + user_id: requestUserId, + }, + }); + + return returnZodTypeCheckedResponse(BlockedSlotsSchema, blockedSlot, { + status: 201, + }); +}); diff --git a/src/app/api/blocked_slots/swagger.ts b/src/app/api/blocked_slots/swagger.ts new file mode 100644 index 0000000..4be89a9 --- /dev/null +++ b/src/app/api/blocked_slots/swagger.ts @@ -0,0 +1,66 @@ +import { + BlockedSlotResponseSchema, + BlockedSlotsResponseSchema, + blockedSlotsQuerySchema, + createBlockedSlotSchema, +} from './validation'; +import { + invalidRequestDataResponse, + notAuthenticatedResponse, + serverReturnedDataValidationErrorResponse, + userNotFoundResponse, +} from '@/lib/defaultApiResponses'; +import { OpenAPIRegistry } from '@asteasolutions/zod-to-openapi'; + +export default function registerSwaggerPaths(registry: OpenAPIRegistry) { + registry.registerPath({ + method: 'get', + path: '/api/blocked_slots', + request: { + query: blockedSlotsQuerySchema, + }, + responses: { + 200: { + description: 'Blocked slots retrieved successfully.', + content: { + 'application/json': { + schema: BlockedSlotsResponseSchema, + }, + }, + }, + ...notAuthenticatedResponse, + ...userNotFoundResponse, + ...serverReturnedDataValidationErrorResponse, + }, + tags: ['Blocked Slots'], + }); + + registry.registerPath({ + method: 'post', + path: '/api/blocked_slots', + request: { + body: { + content: { + 'application/json': { + schema: createBlockedSlotSchema, + }, + }, + }, + }, + responses: { + 201: { + description: 'Blocked slot created successfully.', + content: { + 'application/json': { + schema: BlockedSlotResponseSchema, + }, + }, + }, + ...notAuthenticatedResponse, + ...userNotFoundResponse, + ...serverReturnedDataValidationErrorResponse, + ...invalidRequestDataResponse, + }, + tags: ['Blocked Slots'], + }); +} diff --git a/src/app/api/blocked_slots/validation.ts b/src/app/api/blocked_slots/validation.ts new file mode 100644 index 0000000..1cbe42c --- /dev/null +++ b/src/app/api/blocked_slots/validation.ts @@ -0,0 +1,78 @@ +import { extendZodWithOpenApi } from '@asteasolutions/zod-to-openapi'; +import zod from 'zod/v4'; +import { + eventEndTimeSchema, + eventStartTimeSchema, +} from '@/app/api/event/validation'; + +extendZodWithOpenApi(zod); + +export const blockedSlotsQuerySchema = zod.object({ + start: eventStartTimeSchema.optional(), + end: eventEndTimeSchema.optional(), +}); + +export const blockedSlotRecurrenceEndDateSchema = zod.iso + .datetime() + .or(zod.date().transform((date) => date.toISOString())); + +export const BlockedSlotsSchema = zod + .object({ + start_time: eventStartTimeSchema, + end_time: eventEndTimeSchema, + id: zod.string(), + reason: zod.string().nullish(), + created_at: zod.date(), + updated_at: zod.date(), + }) + .openapi('BlockedSlotsSchema', { + description: 'Blocked time slot in the user calendar', + }); + +export const BlockedSlotsResponseSchema = zod.object({ + success: zod.boolean().default(true), + blocked_slots: zod.array(BlockedSlotsSchema), +}); + +export const BlockedSlotResponseSchema = zod.object({ + success: zod.boolean().default(true), + blocked_slot: BlockedSlotsSchema, +}); + +export const createBlockedSlotSchema = zod + .object({ + start_time: eventStartTimeSchema, + end_time: eventEndTimeSchema, + reason: zod.string().nullish(), + }) + .refine( + (data) => { + return new Date(data.start_time) < new Date(data.end_time); + }, + { + message: 'Start time must be before end time', + path: ['end_time'], + }, + ); + +export const createBlockedSlotClientSchema = zod + .object({ + start_time: zod.iso.datetime({ local: true }), + end_time: zod.iso.datetime({ local: true }), + reason: zod.string().nullish(), + }) + .refine( + (data) => { + return new Date(data.start_time) < new Date(data.end_time); + }, + { + message: 'Start time must be before end time', + path: ['end_time'], + }, + ); + +export const updateBlockedSlotSchema = zod.object({ + start_time: eventStartTimeSchema.optional(), + end_time: eventEndTimeSchema.optional(), + reason: zod.string().optional(), +}); diff --git a/src/app/api/calendar/route.ts b/src/app/api/calendar/route.ts index 3462304..440bbd7 100644 --- a/src/app/api/calendar/route.ts +++ b/src/app/api/calendar/route.ts @@ -1,23 +1,19 @@ -import { z } from 'zod/v4'; - +import { auth } from '@/auth'; +import { prisma } from '@/prisma'; import { returnZodTypeCheckedResponse, userAuthenticated, } from '@/lib/apiHelpers'; - +import { + userCalendarQuerySchema, + UserCalendarResponseSchema, + UserCalendarSchema, +} from './validation'; import { ErrorResponseSchema, ZodErrorResponseSchema, } from '@/app/api/validation'; - -import { auth } from '@/auth'; -import { prisma } from '@/prisma'; - -import { - UserCalendarResponseSchema, - UserCalendarSchema, - userCalendarQuerySchema, -} from './validation'; +import { z } from 'zod/v4'; export const GET = auth(async function GET(req) { const authCheck = userAuthenticated(req); diff --git a/src/app/api/calendar/swagger.ts b/src/app/api/calendar/swagger.ts index cce0b21..b4f5898 100644 --- a/src/app/api/calendar/swagger.ts +++ b/src/app/api/calendar/swagger.ts @@ -1,14 +1,12 @@ -import { OpenAPIRegistry } from '@asteasolutions/zod-to-openapi'; - +import { + userCalendarQuerySchema, + UserCalendarResponseSchema, +} from './validation'; import { notAuthenticatedResponse, userNotFoundResponse, } from '@/lib/defaultApiResponses'; - -import { - UserCalendarResponseSchema, - userCalendarQuerySchema, -} from './validation'; +import { OpenAPIRegistry } from '@asteasolutions/zod-to-openapi'; export default function registerSwaggerPaths(registry: OpenAPIRegistry) { registry.registerPath({ diff --git a/src/app/api/calendar/validation.ts b/src/app/api/calendar/validation.ts index 6afd7a0..bc51489 100644 --- a/src/app/api/calendar/validation.ts +++ b/src/app/api/calendar/validation.ts @@ -1,9 +1,8 @@ import { extendZodWithOpenApi } from '@asteasolutions/zod-to-openapi'; import zod from 'zod/v4'; - import { - EventSchema, eventEndTimeSchema, + EventSchema, eventStartTimeSchema, } from '@/app/api/event/validation'; @@ -49,6 +48,7 @@ export const VisibleSlotSchema = EventSchema.omit({ type: zod.literal('event'), users: zod.string().array(), user_id: zod.string().optional(), + organizer_id: zod.string().optional(), }) .openapi('VisibleSlotSchema', { description: 'Visible time slot in the user calendar', diff --git a/src/app/api/event/[eventID]/participant/[user]/route.ts b/src/app/api/event/[eventID]/participant/[user]/route.ts index fcaedf2..890308c 100644 --- a/src/app/api/event/[eventID]/participant/[user]/route.ts +++ b/src/app/api/event/[eventID]/participant/[user]/route.ts @@ -1,21 +1,18 @@ +import { prisma } from '@/prisma'; +import { auth } from '@/auth'; import { returnZodTypeCheckedResponse, userAuthenticated, } from '@/lib/apiHelpers'; - import { ErrorResponseSchema, SuccessResponseSchema, ZodErrorResponseSchema, } from '@/app/api/validation'; - -import { auth } from '@/auth'; -import { prisma } from '@/prisma'; - import { ParticipantResponseSchema, updateParticipantSchema, -} from '@/app/api/event/[eventID]/participant/validation'; +} from '../validation'; export const GET = auth(async (req, { params }) => { const authCheck = userAuthenticated(req); diff --git a/src/app/api/event/[eventID]/participant/[user]/swagger.ts b/src/app/api/event/[eventID]/participant/[user]/swagger.ts index df6f1e4..b08bd74 100644 --- a/src/app/api/event/[eventID]/participant/[user]/swagger.ts +++ b/src/app/api/event/[eventID]/participant/[user]/swagger.ts @@ -1,24 +1,21 @@ import { OpenAPIRegistry } from '@asteasolutions/zod-to-openapi'; import zod from 'zod/v4'; - +import { + ParticipantResponseSchema, + updateParticipantSchema, +} from '../validation'; import { invalidRequestDataResponse, notAuthenticatedResponse, serverReturnedDataValidationErrorResponse, userNotFoundResponse, } from '@/lib/defaultApiResponses'; - import { EventIdParamSchema, - SuccessResponseSchema, UserIdParamSchema, + SuccessResponseSchema, } from '@/app/api/validation'; -import { - ParticipantResponseSchema, - updateParticipantSchema, -} from '@/app/api/event/[eventID]/participant/validation'; - export default function registerSwaggerPaths(registry: OpenAPIRegistry) { registry.registerPath({ method: 'get', diff --git a/src/app/api/event/[eventID]/participant/route.ts b/src/app/api/event/[eventID]/participant/route.ts index 614aa65..91ce965 100644 --- a/src/app/api/event/[eventID]/participant/route.ts +++ b/src/app/api/event/[eventID]/participant/route.ts @@ -1,20 +1,17 @@ +import { prisma } from '@/prisma'; +import { auth } from '@/auth'; import { returnZodTypeCheckedResponse, userAuthenticated, } from '@/lib/apiHelpers'; - import { ErrorResponseSchema, ZodErrorResponseSchema, } from '@/app/api/validation'; - -import { auth } from '@/auth'; -import { prisma } from '@/prisma'; - import { + inviteParticipantSchema, ParticipantResponseSchema, ParticipantsResponseSchema, - inviteParticipantSchema, } from './validation'; export const GET = auth(async (req, { params }) => { diff --git a/src/app/api/event/[eventID]/participant/swagger.ts b/src/app/api/event/[eventID]/participant/swagger.ts index d3c7139..38dfd58 100644 --- a/src/app/api/event/[eventID]/participant/swagger.ts +++ b/src/app/api/event/[eventID]/participant/swagger.ts @@ -1,21 +1,18 @@ import { OpenAPIRegistry } from '@asteasolutions/zod-to-openapi'; import zod from 'zod/v4'; - +import { + ParticipantsResponseSchema, + ParticipantResponseSchema, + inviteParticipantSchema, +} from './validation'; import { invalidRequestDataResponse, notAuthenticatedResponse, serverReturnedDataValidationErrorResponse, userNotFoundResponse, } from '@/lib/defaultApiResponses'; - import { EventIdParamSchema } from '@/app/api/validation'; -import { - ParticipantResponseSchema, - ParticipantsResponseSchema, - inviteParticipantSchema, -} from './validation'; - export default function registerSwaggerPaths(registry: OpenAPIRegistry) { registry.registerPath({ method: 'get', diff --git a/src/app/api/event/[eventID]/participant/validation.ts b/src/app/api/event/[eventID]/participant/validation.ts index 8256ecb..bacb9ac 100644 --- a/src/app/api/event/[eventID]/participant/validation.ts +++ b/src/app/api/event/[eventID]/participant/validation.ts @@ -1,9 +1,8 @@ import { extendZodWithOpenApi } from '@asteasolutions/zod-to-openapi'; import zod from 'zod/v4'; - import { - PublicUserSchema, existingUserIdServerSchema, + PublicUserSchema, } from '@/app/api/user/validation'; extendZodWithOpenApi(zod); diff --git a/src/app/api/event/[eventID]/route.ts b/src/app/api/event/[eventID]/route.ts index 13a1168..8c06b64 100644 --- a/src/app/api/event/[eventID]/route.ts +++ b/src/app/api/event/[eventID]/route.ts @@ -1,20 +1,16 @@ +import { prisma } from '@/prisma'; +import { auth } from '@/auth'; import { returnZodTypeCheckedResponse, userAuthenticated, } from '@/lib/apiHelpers'; - -import { - EventResponseSchema, - updateEventSchema, -} from '@/app/api/event/validation'; import { ErrorResponseSchema, SuccessResponseSchema, ZodErrorResponseSchema, -} from '@/app/api/validation'; - -import { auth } from '@/auth'; -import { prisma } from '@/prisma'; +} from '../../validation'; +import { EventResponseSchema } from '../validation'; +import { updateEventSchema } from '../validation'; export const GET = auth(async (req, { params }) => { const authCheck = userAuthenticated(req); diff --git a/src/app/api/event/[eventID]/swagger.ts b/src/app/api/event/[eventID]/swagger.ts index b9c44be..4703556 100644 --- a/src/app/api/event/[eventID]/swagger.ts +++ b/src/app/api/event/[eventID]/swagger.ts @@ -1,19 +1,16 @@ import { OpenAPIRegistry } from '@asteasolutions/zod-to-openapi'; -import zod from 'zod/v4'; - +import { EventResponseSchema, updateEventSchema } from '../validation'; import { invalidRequestDataResponse, notAuthenticatedResponse, serverReturnedDataValidationErrorResponse, userNotFoundResponse, } from '@/lib/defaultApiResponses'; - import { EventIdParamSchema, SuccessResponseSchema, } from '@/app/api/validation'; - -import { EventResponseSchema, updateEventSchema } from '@/app/api/event/validation'; +import zod from 'zod/v4'; export default function registerSwaggerPaths(registry: OpenAPIRegistry) { registry.registerPath({ diff --git a/src/app/api/event/route.ts b/src/app/api/event/route.ts index 9afb35e..fb734b1 100644 --- a/src/app/api/event/route.ts +++ b/src/app/api/event/route.ts @@ -1,16 +1,14 @@ +import { prisma } from '@/prisma'; +import { auth } from '@/auth'; import { returnZodTypeCheckedResponse, userAuthenticated, } from '@/lib/apiHelpers'; - -import { auth } from '@/auth'; -import { prisma } from '@/prisma'; - -import { ErrorResponseSchema, ZodErrorResponseSchema } from '@/app/api/validation'; +import { ErrorResponseSchema, ZodErrorResponseSchema } from '../validation'; import { + createEventSchema, EventResponseSchema, EventsResponseSchema, - createEventSchema, } from './validation'; export const GET = auth(async (req) => { diff --git a/src/app/api/event/swagger.ts b/src/app/api/event/swagger.ts index 3f3309d..b78afef 100644 --- a/src/app/api/event/swagger.ts +++ b/src/app/api/event/swagger.ts @@ -1,5 +1,9 @@ import { OpenAPIRegistry } from '@asteasolutions/zod-to-openapi'; - +import { + EventResponseSchema, + EventsResponseSchema, + createEventSchema, +} from './validation'; import { invalidRequestDataResponse, notAuthenticatedResponse, @@ -7,12 +11,6 @@ import { userNotFoundResponse, } from '@/lib/defaultApiResponses'; -import { - EventResponseSchema, - EventsResponseSchema, - createEventSchema, -} from './validation'; - export default function registerSwaggerPaths(registry: OpenAPIRegistry) { registry.registerPath({ method: 'get', diff --git a/src/app/api/event/validation.ts b/src/app/api/event/validation.ts index 5ea6674..b8e176b 100644 --- a/src/app/api/event/validation.ts +++ b/src/app/api/event/validation.ts @@ -1,10 +1,9 @@ import { extendZodWithOpenApi } from '@asteasolutions/zod-to-openapi'; import zod from 'zod/v4'; - import { - PublicUserSchema, existingUserIdServerSchema, -} from '@/app/api/user/validation'; + PublicUserSchema, +} from '../user/validation'; import { ParticipantSchema } from './[eventID]/participant/validation'; extendZodWithOpenApi(zod); diff --git a/src/app/api/logout/route.ts b/src/app/api/logout/route.ts new file mode 100644 index 0000000..ba89440 --- /dev/null +++ b/src/app/api/logout/route.ts @@ -0,0 +1,8 @@ +import { signOut } from '@/auth'; +import { NextResponse } from 'next/server'; + +export const GET = async () => { + await signOut(); + + return NextResponse.redirect('/login'); +}; diff --git a/src/app/api/search/user/route.ts b/src/app/api/search/user/route.ts index 73428e8..0bcb6cf 100644 --- a/src/app/api/search/user/route.ts +++ b/src/app/api/search/user/route.ts @@ -1,18 +1,15 @@ +import { auth } from '@/auth'; +import { prisma } from '@/prisma'; +import { searchUserSchema, searchUserResponseSchema } from './validation'; import { returnZodTypeCheckedResponse, userAuthenticated, } from '@/lib/apiHelpers'; - import { ErrorResponseSchema, ZodErrorResponseSchema, } from '@/app/api/validation'; -import { auth } from '@/auth'; -import { prisma } from '@/prisma'; - -import { searchUserResponseSchema, searchUserSchema } from './validation'; - export const GET = auth(async function GET(req) { const authCheck = userAuthenticated(req); if (!authCheck.continue) diff --git a/src/app/api/search/user/swagger.ts b/src/app/api/search/user/swagger.ts index 2a44693..90ca54e 100644 --- a/src/app/api/search/user/swagger.ts +++ b/src/app/api/search/user/swagger.ts @@ -1,5 +1,5 @@ import { OpenAPIRegistry } from '@asteasolutions/zod-to-openapi'; - +import { searchUserResponseSchema, searchUserSchema } from './validation'; import { invalidRequestDataResponse, notAuthenticatedResponse, @@ -7,8 +7,6 @@ import { userNotFoundResponse, } from '@/lib/defaultApiResponses'; -import { searchUserResponseSchema, searchUserSchema } from './validation'; - export default function registerSwaggerPaths(registry: OpenAPIRegistry) { registry.registerPath({ method: 'get', diff --git a/src/app/api/search/user/validation.ts b/src/app/api/search/user/validation.ts index f262a7e..c1662b0 100644 --- a/src/app/api/search/user/validation.ts +++ b/src/app/api/search/user/validation.ts @@ -1,6 +1,5 @@ import zod from 'zod/v4'; - -import { PublicUserSchema } from '@/app/api/user/validation'; +import { PublicUserSchema } from '../../user/validation'; export const searchUserSchema = zod.object({ query: zod.string().optional().default(''), diff --git a/src/app/api/user/[user]/route.ts b/src/app/api/user/[user]/route.ts index f50727d..b90b1f8 100644 --- a/src/app/api/user/[user]/route.ts +++ b/src/app/api/user/[user]/route.ts @@ -1,15 +1,12 @@ +import { auth } from '@/auth'; +import { prisma } from '@/prisma'; import { returnZodTypeCheckedResponse, userAuthenticated, } from '@/lib/apiHelpers'; - +import { PublicUserResponseSchema } from '../validation'; import { ErrorResponseSchema } from '@/app/api/validation'; -import { auth } from '@/auth'; -import { prisma } from '@/prisma'; - -import { PublicUserResponseSchema } from '@/app/api/user/validation'; - export const GET = auth(async function GET(req, { params }) { const authCheck = userAuthenticated(req); if (!authCheck.continue) diff --git a/src/app/api/user/[user]/swagger.ts b/src/app/api/user/[user]/swagger.ts index 1f39610..741cbf9 100644 --- a/src/app/api/user/[user]/swagger.ts +++ b/src/app/api/user/[user]/swagger.ts @@ -1,13 +1,11 @@ -import { OpenAPIRegistry } from '@asteasolutions/zod-to-openapi'; -import zod from 'zod/v4'; - +import { PublicUserResponseSchema } from '../validation'; import { notAuthenticatedResponse, userNotFoundResponse, } from '@/lib/defaultApiResponses'; - -import { UserIdParamSchema } from '@/app/api/validation'; -import { PublicUserResponseSchema } from '@/app/api/user/validation'; +import { OpenAPIRegistry } from '@asteasolutions/zod-to-openapi'; +import zod from 'zod/v4'; +import { UserIdParamSchema } from '../../validation'; export default function registerSwaggerPaths(registry: OpenAPIRegistry) { registry.registerPath({ diff --git a/src/app/api/user/me/password/route.ts b/src/app/api/user/me/password/route.ts index cd031b3..0b92559 100644 --- a/src/app/api/user/me/password/route.ts +++ b/src/app/api/user/me/password/route.ts @@ -1,20 +1,16 @@ -import bcrypt from 'bcryptjs'; - +import { auth } from '@/auth'; +import { prisma } from '@/prisma'; +import { updateUserPasswordServerSchema } from '../validation'; import { returnZodTypeCheckedResponse, userAuthenticated, } from '@/lib/apiHelpers'; - +import { FullUserResponseSchema } from '../../validation'; import { ErrorResponseSchema, ZodErrorResponseSchema, } from '@/app/api/validation'; - -import { auth } from '@/auth'; -import { prisma } from '@/prisma'; - -import { FullUserResponseSchema } from '@/app/api/user/validation'; -import { updateUserPasswordServerSchema } from '@/app/api/user/me/validation'; +import bcrypt from 'bcryptjs'; export const PATCH = auth(async function PATCH(req) { const authCheck = userAuthenticated(req); diff --git a/src/app/api/user/me/password/swagger.ts b/src/app/api/user/me/password/swagger.ts index 099c9b2..0bc62f0 100644 --- a/src/app/api/user/me/password/swagger.ts +++ b/src/app/api/user/me/password/swagger.ts @@ -1,5 +1,6 @@ import { OpenAPIRegistry } from '@asteasolutions/zod-to-openapi'; - +import { FullUserResponseSchema } from '../../validation'; +import { updateUserPasswordServerSchema } from '../validation'; import { invalidRequestDataResponse, notAuthenticatedResponse, @@ -7,9 +8,6 @@ import { userNotFoundResponse, } from '@/lib/defaultApiResponses'; -import { FullUserResponseSchema } from '@/app/api/user/validation'; -import { updateUserPasswordServerSchema } from '@/app/api/user/me/validation'; - export default function registerSwaggerPaths(registry: OpenAPIRegistry) { registry.registerPath({ method: 'patch', diff --git a/src/app/api/user/me/route.ts b/src/app/api/user/me/route.ts index 967d07c..5571a6b 100644 --- a/src/app/api/user/me/route.ts +++ b/src/app/api/user/me/route.ts @@ -1,20 +1,17 @@ +import { auth } from '@/auth'; +import { prisma } from '@/prisma'; +import { updateUserServerSchema } from './validation'; import { returnZodTypeCheckedResponse, userAuthenticated, } from '@/lib/apiHelpers'; - +import { FullUserResponseSchema } from '../validation'; import { ErrorResponseSchema, SuccessResponseSchema, ZodErrorResponseSchema, } from '@/app/api/validation'; -import { auth } from '@/auth'; -import { prisma } from '@/prisma'; - -import { FullUserResponseSchema } from '@/app/api/user/validation'; -import { updateUserServerSchema } from './validation'; - export const GET = auth(async function GET(req) { const authCheck = userAuthenticated(req); if (!authCheck.continue) diff --git a/src/app/api/user/me/swagger.ts b/src/app/api/user/me/swagger.ts index 54faf1a..6a9e375 100644 --- a/src/app/api/user/me/swagger.ts +++ b/src/app/api/user/me/swagger.ts @@ -1,15 +1,13 @@ import { OpenAPIRegistry } from '@asteasolutions/zod-to-openapi'; - +import { FullUserResponseSchema } from '../validation'; +import { updateUserServerSchema } from './validation'; import { invalidRequestDataResponse, notAuthenticatedResponse, serverReturnedDataValidationErrorResponse, userNotFoundResponse, } from '@/lib/defaultApiResponses'; - -import { SuccessResponseSchema } from '@/app/api/validation'; -import { FullUserResponseSchema } from '@/app/api/user/validation'; -import { updateUserServerSchema } from './validation'; +import { SuccessResponseSchema } from '../../validation'; export default function registerSwaggerPaths(registry: OpenAPIRegistry) { registry.registerPath({ diff --git a/src/app/api/user/me/validation.ts b/src/app/api/user/me/validation.ts index 7d736de..4a1d20e 100644 --- a/src/app/api/user/me/validation.ts +++ b/src/app/api/user/me/validation.ts @@ -1,12 +1,13 @@ import zod from 'zod/v4'; - import { + emailSchema, firstNameSchema, lastNameSchema, newUserEmailServerSchema, newUserNameServerSchema, passwordSchema, timezoneSchema, + userNameSchema, } from '@/app/api/user/validation'; // ---------------------------------------- @@ -23,6 +24,15 @@ export const updateUserServerSchema = zod.object({ timezone: timezoneSchema.optional(), }); +export const updateUserClientSchema = zod.object({ + name: userNameSchema.optional(), + first_name: firstNameSchema.optional(), + last_name: lastNameSchema.optional(), + email: emailSchema.optional(), + image: zod.url().optional(), + timezone: timezoneSchema.optional(), +}); + export const updateUserPasswordServerSchema = zod .object({ current_password: zod.string().min(1, 'Current password is required'), diff --git a/src/app/api/user/validation.ts b/src/app/api/user/validation.ts index 6991674..89b8ba4 100644 --- a/src/app/api/user/validation.ts +++ b/src/app/api/user/validation.ts @@ -1,9 +1,7 @@ import { extendZodWithOpenApi } from '@asteasolutions/zod-to-openapi'; -import zod from 'zod/v4'; - -import { allTimeZones } from '@/lib/timezones'; - import { prisma } from '@/prisma'; +import zod from 'zod/v4'; +import { allTimeZones } from '@/lib/timezones'; extendZodWithOpenApi(zod); diff --git a/src/app/api/validation.ts b/src/app/api/validation.ts index 2a87bb2..518121d 100644 --- a/src/app/api/validation.ts +++ b/src/app/api/validation.ts @@ -1,8 +1,7 @@ +import { registry } from '@/lib/swagger'; import { extendZodWithOpenApi } from '@asteasolutions/zod-to-openapi'; import zod from 'zod/v4'; -import { registry } from '@/lib/swagger'; - extendZodWithOpenApi(zod); export const ErrorResponseSchema = zod @@ -86,3 +85,14 @@ export const EventIdParamSchema = registry.registerParameter( example: '67890', }), ); + +export const SlotIdParamSchema = registry.registerParameter( + 'SlotIdParam', + zod.string().openapi({ + param: { + name: 'slotID', + in: 'path', + }, + example: 'abcde12345', + }), +); diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 27f76ff..47cec2d 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,11 +1,10 @@ -import type { Metadata } from 'next'; -import { SessionProvider } from 'next-auth/react'; - -import { Toaster } from '@/components/ui/sonner'; -import { QueryProvider } from '@/components/wrappers/query-provider'; import { ThemeProvider } from '@/components/wrappers/theme-provider'; +import type { Metadata } from 'next'; import './globals.css'; +import { QueryProvider } from '@/components/wrappers/query-provider'; +import { Toaster } from '@/components/ui/sonner'; +import { SessionProvider } from 'next-auth/react'; export const metadata: Metadata = { title: 'MeetUp', diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index 5ae1edc..9fa84ba 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -1,24 +1,22 @@ -import Image from 'next/image'; -import { redirect } from 'next/navigation'; - +import { auth, providerMap } from '@/auth'; import SSOLogin from '@/components/buttons/sso-login-button'; +import LoginForm from '@/components/forms/login-form'; +import { redirect } from 'next/navigation'; +import { Button } from '@/components/ui/button'; +import Image from 'next/image'; +import { Separator } from '@/components/ui/separator'; +import Logo from '@/components/misc/logo'; import { Card, CardContent, CardHeader, } from '@/components/custom-ui/login-card'; -import LoginForm from '@/components/forms/login-form'; -import Logo from '@/components/misc/logo'; import { ThemePicker } from '@/components/misc/theme-picker'; -import { Button } from '@/components/ui/button'; import { HoverCard, - HoverCardContent, HoverCardTrigger, + HoverCardContent, } from '@/components/ui/hover-card'; -import { Separator } from '@/components/ui/separator'; - -import { auth, providerMap } from '@/auth'; export default async function LoginPage() { const session = await auth(); @@ -44,7 +42,9 @@ export default async function LoginPage() { - + {providerMap.length > 0 && !process.env.DISABLE_PASSWORD_LOGIN ? ( + + ) : null} {providerMap.map((provider) => ( dancing penguin diff --git a/src/app/logout/page.tsx b/src/app/logout/page.tsx index 72b7eb4..e3da2fd 100644 --- a/src/app/logout/page.tsx +++ b/src/app/logout/page.tsx @@ -1,3 +1,4 @@ +import { signOut } from '@/auth'; import { Button } from '@/components/ui/button'; import { Card, @@ -7,8 +8,6 @@ import { CardTitle, } from '@/components/ui/card'; -import { signOut } from '@/auth'; - export default function SignOutPage() { return (
diff --git a/src/app/not-found.tsx b/src/app/not-found.tsx new file mode 100644 index 0000000..6a3c299 --- /dev/null +++ b/src/app/not-found.tsx @@ -0,0 +1,28 @@ +import Link from 'next/link'; +import { Button } from '@/components/ui/button'; + +export default function NotFound() { + return ( +
+
+
+

404

+

Page Not Found

+

+ Sorry, we couldn't find the page you're looking for. It + might have been moved, deleted, or doesn't exist. +

+
+ +
+ + +
+
+
+ ); +} diff --git a/src/app/page.tsx b/src/app/page.tsx index 9e7e5a6..a86e576 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,6 +1,5 @@ -import { redirect } from 'next/navigation'; - import { auth } from '@/auth'; +import { redirect } from 'next/navigation'; export default async function Home() { const session = await auth(); diff --git a/src/app/settings/page.tsx b/src/app/settings/page.tsx index 5381a3b..a2c5b35 100644 --- a/src/app/settings/page.tsx +++ b/src/app/settings/page.tsx @@ -1,482 +1,5 @@ -import { Button } from '@/components/ui/button'; -import { - Card, - CardContent, - CardDescription, - CardFooter, - CardHeader, - CardTitle, -} from '@/components/ui/card'; -import { Input } from '@/components/ui/input'; -import { Label } from '@/components/ui/label'; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@/components/ui/select'; -import { Switch } from '@/components/ui/switch'; -import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; -import { ScrollableSettingsWrapper } from '@/components/wrappers/settings-scroll'; +import SettingsPage from '@/components/settings/settings-page'; -export default function SettingsPage() { - return ( -
-
- - - Account - Notifications - Calendar - Privacy - Appearance - - - - - - - Account Settings - - Manage your account details and preferences. - - - -
- - -
-
- - -

- Email is managed by your SSO provider. -

-
-
- - -

- Upload a new profile picture. -

-
-
- - -
- -
- - -
-
- -

- Permanently delete your account and all associated data. -

-
-
-
- - - - -
-
- - - - - - Notification Preferences - - Choose how you want to be notified. - - - -
- - -
-
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
-
-
- - - - -
-
- - - - - - Calendar & Availability - - Manage your calendar display, default availability, and iCal - integrations. - - - -
- - Display - -
- - -
-
- - -
-
- - -
-
- -
- - Availability - -
- -

- Define your typical available hours (e.g., - Monday-Friday, 9 AM - 5 PM). -

- -
-
- -

- Min time before a booking can be made. -

-
- -
-
-
- -

- Max time in advance a booking can be made. -

- -
-
- -
- - iCalendar Integration - -
- - - -
-
- - - -
-
-
-
- - - - -
-
- - - - - - Sharing & Privacy - - Control who can see your calendar and book time with you. - - - -
- - -
-
- -

- (Override for Default Visibility) -
- - This setting will override the default visibility for - your calendar. You can set specific friends or groups to - see your full calendar details. - -

- -
-
- - -
-
- - -

- Prevent specific users from seeing your calendar or - booking time. -

-
-
-
- - - - -
-
- - - - - - Appearance - - Customize the look and feel of the application. - - - -
- - -
-
- - -
-
- - -
-
-
- - - - -
-
-
-
-
- ); +export default function Page() { + return ; } diff --git a/src/auth.ts b/src/auth.ts index 4fa8b23..18b3b2d 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -1,21 +1,22 @@ -import { PrismaAdapter } from '@auth/prisma-adapter'; import NextAuth, { CredentialsSignin } from 'next-auth'; + +import { Prisma } from '@/generated/prisma'; import type { Provider } from 'next-auth/providers'; -import AuthentikProvider from 'next-auth/providers/authentik'; + import Credentials from 'next-auth/providers/credentials'; +import AuthentikProvider from 'next-auth/providers/authentik'; import DiscordProvider from 'next-auth/providers/discord'; import FacebookProvider from 'next-auth/providers/facebook'; import GithubProvider from 'next-auth/providers/github'; import GitlabProvider from 'next-auth/providers/gitlab'; import GoogleProvider from 'next-auth/providers/google'; import KeycloakProvider from 'next-auth/providers/keycloak'; -import { ZodError } from 'zod/v4'; + +import { PrismaAdapter } from '@auth/prisma-adapter'; +import { prisma } from '@/prisma'; import { loginSchema } from '@/lib/auth/validation'; - -import { Prisma } from '@/generated/prisma'; - -import { prisma } from '@/prisma'; +import { ZodError } from 'zod/v4'; class InvalidLoginError extends CredentialsSignin { constructor(code: string) { @@ -94,13 +95,27 @@ const providers: Provider[] = [ } }, }), - process.env.AUTH_AUTHENTIK_ID && AuthentikProvider, + process.env.AUTH_DISCORD_ID && DiscordProvider, process.env.AUTH_FACEBOOK_ID && FacebookProvider, process.env.AUTH_GITHUB_ID && GithubProvider, process.env.AUTH_GITLAB_ID && GitlabProvider, process.env.AUTH_GOOGLE_ID && GoogleProvider, process.env.AUTH_KEYCLOAK_ID && KeycloakProvider, + + process.env.AUTH_AUTHENTIK_ID && + AuthentikProvider({ + profile(profile) { + return { + id: profile.sub, + name: profile.preferred_username, + first_name: profile.given_name.split(' ')[0] || '', + last_name: profile.given_name.split(' ')[1] || '', + email: profile.email, + image: profile.picture, + }; + }, + }), ].filter(Boolean) as Provider[]; export const providerMap = providers diff --git a/src/components/buttons/icon-button.tsx b/src/components/buttons/icon-button.tsx index bc6b51e..4b50e90 100644 --- a/src/components/buttons/icon-button.tsx +++ b/src/components/buttons/icon-button.tsx @@ -1,19 +1,20 @@ -import { IconProp } from '@fortawesome/fontawesome-svg-core'; -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; - import { Button } from '@/components/ui/button'; +import { LucideProps } from 'lucide-react'; +import React, { ForwardRefExoticComponent, RefAttributes } from 'react'; export function IconButton({ icon, children, ...props }: { - icon: IconProp; - children: React.ReactNode; + icon?: ForwardRefExoticComponent< + Omit & RefAttributes + >; + children?: React.ReactNode; } & React.ComponentProps) { return ( ); diff --git a/src/components/buttons/notification-button.tsx b/src/components/buttons/notification-button.tsx index f0d9fe4..f41f325 100644 --- a/src/components/buttons/notification-button.tsx +++ b/src/components/buttons/notification-button.tsx @@ -1,10 +1,10 @@ -import { NDot, NotificationDot } from '@/components/misc/notification-dot'; import { Button } from '@/components/ui/button'; import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; +import { NDot, NotificationDot } from '@/components/misc/notification-dot'; export function NotificationButton({ dotVariant, diff --git a/src/components/buttons/redirect-button.tsx b/src/components/buttons/redirect-button.tsx index e605640..e67acc1 100644 --- a/src/components/buttons/redirect-button.tsx +++ b/src/components/buttons/redirect-button.tsx @@ -1,6 +1,5 @@ -import Link from 'next/link'; - import { Button } from '@/components/ui/button'; +import Link from 'next/link'; export function RedirectButton({ redirectUrl, diff --git a/src/components/buttons/sso-login-button.tsx b/src/components/buttons/sso-login-button.tsx index e06fe34..b5cde0f 100644 --- a/src/components/buttons/sso-login-button.tsx +++ b/src/components/buttons/sso-login-button.tsx @@ -1,8 +1,6 @@ -import { faOpenid } from '@fortawesome/free-brands-svg-icons'; - -import { IconButton } from '@/components/buttons/icon-button'; - import { signIn } from '@/auth'; +import { IconButton } from '@/components/buttons/icon-button'; +import { Fingerprint } from 'lucide-react'; export default function SSOLogin({ provider, @@ -24,7 +22,7 @@ export default function SSOLogin({ className='w-full' type='submit' variant='secondary' - icon={faOpenid} + icon={Fingerprint} {...props} > Login with {providerDisplayName} diff --git a/src/components/calendar.tsx b/src/components/calendar.tsx index a1ea3fc..d77b00a 100644 --- a/src/components/calendar.tsx +++ b/src/components/calendar.tsx @@ -1,24 +1,23 @@ 'use client'; -import { QueryErrorResetBoundary } from '@tanstack/react-query'; -import moment from 'moment'; -import { useSession } from 'next-auth/react'; -import { useRouter } from 'next/navigation'; -import React from 'react'; import { Calendar as RBCalendar, momentLocalizer } from 'react-big-calendar'; import withDragAndDrop from 'react-big-calendar/lib/addons/dragAndDrop'; +import moment from 'moment'; +import '@/components/react-big-calendar.css'; import 'react-big-calendar/lib/addons/dragAndDrop/styles.css'; +import CustomToolbar from '@/components/custom-toolbar'; +import React from 'react'; +import { useRouter } from 'next/navigation'; +import { usePatchApiEventEventID } from '@/generated/api/event/event'; +import { useSession } from 'next-auth/react'; +import { UserCalendarSchemaItem } from '@/generated/api/meetup.schemas'; +import { QueryErrorResetBoundary } from '@tanstack/react-query'; import { ErrorBoundary } from 'react-error-boundary'; +import { Button } from '@/components/ui/button'; import { fromZodIssue } from 'zod-validation-error/v4'; import type { $ZodIssue } from 'zod/v4/core'; - -import CustomToolbar from '@/components/custom-toolbar'; -import '@/components/react-big-calendar.css'; -import { Button } from '@/components/ui/button'; - import { useGetApiCalendar } from '@/generated/api/calendar/calendar'; -import { usePatchApiEventEventID } from '@/generated/api/event/event'; -import { UserCalendarSchemaItem } from '@/generated/api/meetup.schemas'; +import { usePatchApiBlockedSlotsSlotID } from '@/generated/api/blocked-slots/blocked-slots'; moment.updateLocale('en', { week: { @@ -49,6 +48,7 @@ const DaDRBCalendar = withDragAndDrop< end: Date; type: UserCalendarSchemaItem['type']; userId?: string; + organizer?: string; }, { id: string; @@ -192,6 +192,13 @@ function CalendarWithUserEvents({ }, }, }); + const { mutate: patchBlockedSlot } = usePatchApiBlockedSlotsSlotID({ + mutation: { + throwOnError(error) { + throw error.response?.data || 'Failed to update blocked slot'; + }, + }, + }); return ( { - router.push(`/events/${event.id}`); + if (event.type === 'blocked_private') return; + if (event.type === 'blocked_owned') { + router.push(`/blocker/${event.id}`); + return; + } + if (event.type === 'event') { + router.push(`/events/${event.id}`); + } }} onSelectSlot={(slotInfo) => { router.push( @@ -235,56 +250,108 @@ function CalendarWithUserEvents({ resourceTitleAccessor={(event) => event.title} startAccessor={(event) => event.start} endAccessor={(event) => event.end} - selectable={sesstion.data?.user?.id === userId && !additionalEvents} + selectable={sesstion.data?.user?.id === userId} onEventDrop={(event) => { const { start, end, event: droppedEvent } = event; - if (droppedEvent.type === 'blocked_private') return; + if ( + droppedEvent.type === 'blocked_private' || + (droppedEvent.organizer && + droppedEvent.organizer !== sesstion.data?.user?.id) + ) + return; const startISO = new Date(start).toISOString(); const endISO = new Date(end).toISOString(); - patchEvent( - { - eventID: droppedEvent.id, - data: { - start_time: startISO, - end_time: endISO, + if (droppedEvent.type === 'blocked_owned') { + patchBlockedSlot( + { + slotID: droppedEvent.id, + data: { + start_time: startISO, + end_time: endISO, + }, }, - }, - { - onSuccess: () => { - refetch(); + { + onSuccess: () => { + refetch(); + }, + onError: (error) => { + console.error('Error updating blocked slot:', error); + }, }, - onError: (error) => { - console.error('Error updating event:', error); + ); + return; + } else if (droppedEvent.type === 'event') { + patchEvent( + { + eventID: droppedEvent.id, + data: { + start_time: startISO, + end_time: endISO, + }, }, - }, - ); + { + onSuccess: () => { + refetch(); + }, + onError: (error) => { + console.error('Error updating event:', error); + }, + }, + ); + } }} onEventResize={(event) => { const { start, end, event: resizedEvent } = event; - if (resizedEvent.type === 'blocked_private') return; + if ( + resizedEvent.type === 'blocked_private' || + (resizedEvent.organizer && + resizedEvent.organizer !== sesstion.data?.user?.id) + ) + return; const startISO = new Date(start).toISOString(); const endISO = new Date(end).toISOString(); if (startISO === endISO) { console.warn('Start and end times are the same, skipping resize.'); return; } - patchEvent( - { - eventID: resizedEvent.id, - data: { - start_time: startISO, - end_time: endISO, + if (resizedEvent.type === 'blocked_owned') { + patchBlockedSlot( + { + slotID: resizedEvent.id, + data: { + start_time: startISO, + end_time: endISO, + }, }, - }, - { - onSuccess: () => { - refetch(); + { + onSuccess: () => { + refetch(); + }, + onError: (error) => { + console.error('Error resizing blocked slot:', error); + }, }, - onError: (error) => { - console.error('Error resizing event:', error); + ); + return; + } else if (resizedEvent.type === 'event') { + patchEvent( + { + eventID: resizedEvent.id, + data: { + start_time: startISO, + end_time: endISO, + }, }, - }, - ); + { + onSuccess: () => { + refetch(); + }, + onError: (error) => { + console.error('Error resizing event:', error); + }, + }, + ); + } }} /> ); diff --git a/src/components/custom-toolbar.tsx b/src/components/custom-toolbar.tsx index b4549b1..76e59ee 100644 --- a/src/components/custom-toolbar.tsx +++ b/src/components/custom-toolbar.tsx @@ -1,11 +1,9 @@ -import React, { useEffect, useState } from 'react'; -import { NavigateAction } from 'react-big-calendar'; +import React, { useState, useEffect } from 'react'; +import './custom-toolbar.css'; +import { Button } from '@/components/ui/button'; import DatePicker from 'react-datepicker'; import 'react-datepicker/dist/react-datepicker.css'; - -import { Button } from '@/components/ui/button'; - -import './custom-toolbar.css'; +import { NavigateAction } from 'react-big-calendar'; interface CustomToolbarProps { //Aktuell angezeigtes Datum diff --git a/src/components/custom-ui/app-sidebar.tsx b/src/components/custom-ui/app-sidebar.tsx index c95d8af..cbef9d6 100644 --- a/src/components/custom-ui/app-sidebar.tsx +++ b/src/components/custom-ui/app-sidebar.tsx @@ -1,17 +1,6 @@ 'use client'; -import { ChevronDown } from 'lucide-react'; -import { - CalendarClock, - CalendarDays, - CalendarPlus, - Star, - User, - Users, -} from 'lucide-react'; -import Link from 'next/link'; import React from 'react'; - import { Sidebar, SidebarContent, @@ -24,20 +13,34 @@ import { SidebarMenuButton, SidebarMenuItem, } from '@/components/custom-ui/sidebar'; -import Logo from '@/components/misc/logo'; + +import { CalendarMinus, CalendarMinus2, ChevronDown } from 'lucide-react'; import { Collapsible, CollapsibleContent, CollapsibleTrigger, } from '@/components/ui/collapsible'; +import Logo from '@/components/misc/logo'; + +import Link from 'next/link'; + +import { + Star, + CalendarDays, + //User, + //Users, + CalendarClock, + CalendarPlus, +} from 'lucide-react'; + const items = [ { title: 'Calendar', url: '/home', icon: CalendarDays, }, - { + /*{ title: 'Friends', url: '#', icon: User, @@ -46,12 +49,17 @@ const items = [ title: 'Groups', url: '#', icon: Users, - }, + },*/ { title: 'Events', url: '/events', icon: CalendarClock, }, + { + title: 'Blockers', + url: '/blocker', + icon: CalendarMinus, + }, ]; export function AppSidebar() { @@ -59,25 +67,27 @@ export function AppSidebar() { <> - - + + + + - - + + {' '} Favorites @@ -120,6 +130,17 @@ export function AppSidebar() { + + + + + New Blocker + + + diff --git a/src/components/custom-ui/blocked-slot-list-entry.tsx b/src/components/custom-ui/blocked-slot-list-entry.tsx new file mode 100644 index 0000000..9d1acdf --- /dev/null +++ b/src/components/custom-ui/blocked-slot-list-entry.tsx @@ -0,0 +1,56 @@ +'use client'; + +import { Card } from '@/components/ui/card'; +import Logo from '@/components/misc/logo'; +import { Label } from '@/components/ui/label'; +import Link from 'next/link'; +import zod from 'zod/v4'; +import { BlockedSlotsSchema } from '@/app/api/blocked_slots/validation'; + +type BlockedSlotListEntryProps = zod.output; + +export default function BlockedSlotListEntry(slot: BlockedSlotListEntryProps) { + const formatDate = (isoString?: string) => { + if (!isoString) return '-'; + return new Date(isoString).toLocaleDateString(); + }; + const formatTime = (isoString?: string) => { + if (!isoString) return '-'; + return new Date(isoString).toLocaleTimeString([], { + hour: '2-digit', + minute: '2-digit', + }); + }; + return ( + + +
+
+ +
+
+

{slot.reason}

+
+
+
+ + +
+
+ + +
+
+
+
+ + ); +} diff --git a/src/components/custom-ui/event-list-entry.tsx b/src/components/custom-ui/event-list-entry.tsx index fbb94a9..b52d438 100644 --- a/src/components/custom-ui/event-list-entry.tsx +++ b/src/components/custom-ui/event-list-entry.tsx @@ -1,24 +1,20 @@ 'use client'; -import { useSession } from 'next-auth/react'; +import { Card } from '@/components/ui/card'; +import Logo from '@/components/misc/logo'; +import { Label } from '@/components/ui/label'; import Link from 'next/link'; import zod from 'zod/v4'; - -import Logo from '@/components/misc/logo'; -import { Card } from '@/components/ui/card'; -import { Label } from '@/components/ui/label'; - import { EventSchema } from '@/app/api/event/validation'; - -import { usePatchApiEventEventIDParticipantUser } from '@/generated/api/event-participant/event-participant'; - +import { useSession } from 'next-auth/react'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, -} from '@/components/ui/select'; +} from '../ui/select'; +import { usePatchApiEventEventIDParticipantUser } from '@/generated/api/event-participant/event-participant'; type EventListEntryProps = zod.output; @@ -47,7 +43,10 @@ export default function EventListEntry({ return ( -
+
diff --git a/src/components/custom-ui/labeled-input.tsx b/src/components/custom-ui/labeled-input.tsx index 4746a31..38a6c56 100644 --- a/src/components/custom-ui/labeled-input.tsx +++ b/src/components/custom-ui/labeled-input.tsx @@ -1,29 +1,64 @@ import { Input, Textarea } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; +import React, { ForwardRefExoticComponent, RefAttributes } from 'react'; +import { Button } from '../ui/button'; +import { Eye, EyeOff, LucideProps } from 'lucide-react'; +import { cn } from '@/lib/utils'; export default function LabeledInput({ type, label, + subtext, placeholder, value, + defaultValue, name, + icon, variantSize = 'default', autocomplete, error, + 'data-cy': dataCy, ...rest }: { - type: 'text' | 'email' | 'password'; label: string; + subtext?: string; placeholder?: string; value?: string; name?: string; + icon?: ForwardRefExoticComponent< + Omit & RefAttributes + >; variantSize?: 'default' | 'big' | 'textarea'; autocomplete?: string; error?: string; + 'data-cy'?: string; } & React.InputHTMLAttributes) { + const [passwordVisible, setPasswordVisible] = React.useState(false); + const [inputValue, setInputValue] = React.useState( + value || defaultValue || '', + ); + + React.useEffect(() => { + if (value !== undefined) { + setInputValue(value); + } + }, [value]); + + const handleInputChange = (e: React.ChangeEvent) => { + setInputValue(e.target.value); + if (rest.onChange) { + rest.onChange(e); + } + }; + return (
+ {subtext && ( + + )} {variantSize === 'textarea' ? (