diff --git a/.env.example b/.env.example index 1feabf8..41d13b5 100644 --- a/.env.example +++ b/.env.example @@ -6,4 +6,23 @@ AUTH_AUTHENTIK_ID= AUTH_AUTHENTIK_SECRET= AUTH_AUTHENTIK_ISSUER= +AUTH_DISCORD_ID= +AUTH_DISCORD_SECRET= + +AUTH_FACEBOOK_ID= +AUTH_FACEBOOK_SECRET= + +AUTH_GITHUB_ID= +AUTH_GITHUB_SECRET= + +AUTH_GITLAB_ID= +AUTH_GITLAB_SECRET= + +AUTH_GOOGLE_ID= +AUTH_GOOGLE_SECRET= + +AUTH_KEYCLOAK_ID= +AUTH_KEYCLOAK_SECRET= +AUTH_KEYCLOAK_ISSUER= + NEXT_PUBLIC_APP_URL= 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/README.md b/README.md index 56fa41d..24990d2 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,19 @@ # MeetUP +## Table of contents + +- [Description](#description) +- [Project Status](#project-status) +- [Features](#features) + - [Implemented Features](#implemented-features) + - [Planned Features](#planned-features-roadmap) +- [Technologies Used](#technologies-used) +- [Development environment setup](#development-environment-setup) + - [Without Docker](#without-docker) + - [With Docker](#with-docker) +- [Production Deployment using Docker](#production-deployment-using-docker) +- [Contributing](#contributing) + ## Description MeetUP is a social calendar application designed to make coordinating schedules with friends seamless and intuitive. It was created because it can be a hassle coordinating meetings between multiple friends and across different friend groups. MeetUP aims to simplify the process of finding mutual availability without endless back-and-forth messaging. @@ -12,52 +26,54 @@ MeetUP is a social calendar application designed to make coordinating schedules ### Implemented Features -- Core infrastructure setup in progress. No user-facing features are implemented yet. +- Event creation, deletion, and editing +- SSO and credentials login and signup +- Participant invitation and status +- Calendar of your own events +- Calendar of other users' availability (only in event creation form) ### Planned Features (Roadmap) -- **Friendships:** Connect with friends to share calendars. -- **Group Calendars:** Create and manage shared calendars for groups. -- **iCal Import:** Import existing calendars from iCalendar (.ics) files. -- **iCal Export:** Export personal or shared calendars in iCalendar (.ics) format. -- **Email Notifications:** Receive email alerts for event bookings, reminders, and updates. -- **View Blocked Slots:** See when friends are busy without revealing event details. -- **Book Timeslots:** Request and confirm meeting times in friends' available slots. -- **SSO Compatibility:** Planning for Single Sign-On integration. +- Friendships +- Group calendars +- iCal import and export +- Notifications (in-app and external/mail) ## Technologies Used This project is built with a modern tech stack: - **Package Manager:** [Yarn](https://yarnpkg.com/) -- **Framework:** [Next.js](https://nextjs.org/) - React framework for server-side rendering and static site generation. -- **Language:** [TypeScript](https://www.typescriptlang.org/) - Superset of JavaScript that adds static typing. -- **ORM:** [Prisma](https://www.prisma.io/) - Next-generation ORM for Node.js and TypeScript. -- **Authentication:** [Auth.js](https://authjs.dev/) (formerly NextAuth.js) - Authentication for Next.js. -- **Styling:** [Tailwind CSS](https://tailwindcss.com/) - A utility-first CSS framework. -- **UI Components:** [shadcn/ui](https://ui.shadcn.com/) - Re-usable components built using Radix UI and Tailwind CSS. -- **Containerization:** [Docker](https://www.docker.com/) (for planned self-hosting option) -- _(You can also list related tools here, e.g., ESLint, Prettier, testing libraries if you plan to use them)_ +- **Framework:** [Next.js](https://nextjs.org/) +- **Language:** [TypeScript](https://www.typescriptlang.org/) +- **ORM:** [Prisma](https://www.prisma.io/) +- **Authentication:** [Auth.js](https://authjs.dev/) +- **Styling:** [Tailwind CSS](https://tailwindcss.com/) +- **UI Components:** [shadcn/ui](https://ui.shadcn.com/) +- **Containerization:** [Docker](https://www.docker.com/) +- **API Docs:** [Swagger](https://swagger.io/) +- **React hook API client:** [orval](https://orval.dev/) -## Getting Started +## Development environment setup + +### Without Docker **Prerequisites:** -- Node.js: Version is continually upgraded. It's recommended to use the latest LTS or a recent stable version. (Check `.nvmrc` if available). -- Yarn: Version is continually upgraded. (Check `package.json` engines field if specified). -- A database supported by Prisma (e.g., PostgreSQL, MySQL, SQLite). Ensure your database server is running. +- **Node.js**: version 22+ +- **corepack**: enable using `corepack enable` -**Installation & Running Locally:** +**Installation & Running:** 1. **Clone the repository:** - - Using SSH: + - Using HTTPS: + ```bash + git clone https://git.dominikstahl.dev/DHBW-WE/MeetUp.git + ``` + - Or using SSH: ```bash git clone ssh://git@git.dominikstahl.dev/DHBW-WE/MeetUp.git ``` - - Or using HTTPS (recommended for most users): - ```bash - git clone [https://git.dominikstahl.dev/DHBW-WE/MeetUp.git](https://git.dominikstahl.dev/DHBW-WE/MeetUp.git) - ``` ```bash cd MeetUp ``` @@ -66,53 +82,44 @@ This project is built with a modern tech stack: yarn install ``` 3. **Set up environment variables:** - - You will need to create an `AUTH_SECRET`. You can generate one using the following command: - ```bash - npx auth secret - ``` - - Copy the `.env.example` file (if it exists) to `.env.local`. If not, create `.env.local`. - ```bash - # If .env.example exists: - cp .env.example .env.local - # Otherwise, create .env.local and add the following: - ``` - - Ensure the following environment variables are set in your `.env.local` file. Adjust `DATABASE_URL` for your specific database provider and credentials. + You will need to create an `AUTH_SECRET`. You can generate one using the following command: - ```env - # Database Connection String (Prisma) - # Example for PostgreSQL: DATABASE_URL="postgresql://USER:PASSWORD@HOST:PORT/DATABASE?schema=public" - DATABASE_URL="your_database_connection_string" + ```bash + npx auth secret + ``` - # Generated with npx auth secret - AUTH_SECRET="your_generated_auth_secret" + Add any additional needed environment variables into the generated `.env.local` file. + Example variables can be found in the `.env.example` file. The following variables are required: - # Authentik SSO Variables (if you are using this provider) - AUTH_AUTHENTIK_ID= - AUTH_AUTHENTIK_SECRET= - AUTH_AUTHENTIK_ISSUER= + ```env + # Generated with npx auth secret + AUTH_SECRET="your_generated_auth_secret" - # Base URL of your application - NEXT_PUBLIC_APP_URL="http://localhost:3000" - ``` + DATABASE_URL="file:./dev.db" + ``` 4. **Apply database migrations (Prisma):** - - Ensure your Prisma schema (`prisma/schema.prisma`) is defined. - - Setup/update the database with these commands: - ```bash - yarn prisma:generate - ``` - ```bash - yarn prisma:db:push - ``` - - Run the following command to apply migrations and generate Prisma Client: - ```bash - npx prisma migrate dev - # You might be prompted to name your first migration. - ``` + Set up/update the database with these commands: - Tipp: You can open the prisma database UI with `yarn prisma:studio` + ```bash + yarn prisma:generate + ``` -5. **Run the development server:** + ```bash + yarn prisma:db:push + ``` + + Tip: You can open the Prisma database UI with `yarn prisma:studio` + +5. **Generate needed TypeScript files:** + Generate the `swagger.json` file and the API client using: + + ```bash + yarn swagger:generate + yarn orval:generate + ``` + +6. **Run the development server:** ```bash yarn dev @@ -120,56 +127,64 @@ This project is built with a modern tech stack: Open [http://localhost:3000](http://localhost:3000) in your browser to see the application. - The test user for the application is: +### With Docker - ```bash - email: test@example.com - password: password - ``` +**Prerequisites:** -**Docker Development Environment:** +- **Docker** +- **Docker Compose** -- The docker development environment can be started with the following command: +**Running:** ```bash yarn dev_container ``` -**Self-Hosting with Docker (Planned):** +## Production Deployment using Docker -- A Docker image and `docker-compose.yml` file will be provided in the future to allow for easy self-hosting of the MeetUP application. This setup will also include database services. Instructions will be updated here once available. +The application can be hosted using the [Docker container](https://git.dominikstahl.dev/DHBW-WE/-/packages/container/meetup/main). + +There is an example Docker Compose file provided [here](https://git.dominikstahl.dev/DHBW-WE/MeetUp/src/branch/main/docker-compose.yml). ## Contributing -Contributions are welcome! If you'd like to contribute, please: +Contributions are welcome! If you'd like to contribute, please follow these steps: 1. Fork the repository. -2. Create a new branch (`git checkout -b /-action_name`). +2. Create a new branch: + + ```bash + git checkout -b /- + ``` + + - Example: `feat/42-add_login_form` + 3. Make your changes. -4. Commit your changes (`git commit -m ': add some feature'`). -5. Push to the branch (`git push origin /-action_name`). +4. Commit your changes using [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/): + - The commit message should be structured as follows: + + ``` + (optional scope): + ``` + + - Example: `feat(auth): add login form` + - Example: `fix(events): correct event time calculation` + - Example: `docs: update README with setup instructions` + + - Used types: + - `feat`: Feature added + - `fix`: Bug fix + - `test`: Add or modify tests + - `docs`: Documentation changes + - `chore`: Changes to non-code files (workflows, lock files, etc.) + - `refactor`: Code refactoring without changing functionality + - `style`: Code style changes (formatting, etc.) + - `revert`: Revert a previous commit + +5. Push to your branch: + ```bash + git push origin /- + ``` 6. Open a Pull Request against the `main` branch. -Possible actions are: - - *feat* -> Feature added - *fix* -> Fixed a bug - *test* -> Modified or added tests - *docs* -> Modified documentation - *chore* -> changes to non code files (workflows, lock files, ...) - *refactor* -> rewritten code without changing functionality - *style* -> code style (yarn format) - *revert* -> reverts a previous commit - -Please ensure your code adheres to the project's coding standards (e.g., run linters/formatters if configured) and that any database schema changes are accompanied by a Prisma migration. - ---- - -**(Optional Sections You Might Want to Add Later):** - -- **Screenshots/Demo:** (Once you have UI to show) -- **API Reference:** (If you plan to expose an API) -- **Detailed Deployment Guides:** (For various platforms beyond Docker) -- **License:** (e.g., MIT, GPL - Important for open source projects) -- **Contact:** (How to get in touch with the maintainers) -- **Acknowledgements:** (Credit to any libraries, inspirations, or contributors) +Please ensure your code adheres to the project's coding standards (run `yarn format`) and that any database schema changes are accompanied by a Prisma migration. 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/support/commands.ts b/cypress/support/commands.ts index 59717f5..994b7ef 100644 --- a/cypress/support/commands.ts +++ b/cypress/support/commands.ts @@ -1,3 +1,5 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable @typescript-eslint/no-namespace */ /// // *********************************************** // This example commands.ts shows you how to @@ -44,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 { @@ -55,6 +73,7 @@ declare global { selector: string, ...args: any[] ): Chainable>; + login(): Chainable; } } } 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 9a33a39..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", @@ -30,6 +30,7 @@ "@fortawesome/free-solid-svg-icons": "^6.7.2", "@fortawesome/react-fontawesome": "^0.2.2", "@hookform/resolvers": "^5.0.1", + "@marko19907/string-to-color": "^1.0.0", "@prisma/client": "^6.9.0", "@radix-ui/react-avatar": "^1.1.10", "@radix-ui/react-collapsible": "^1.1.11", @@ -51,7 +52,7 @@ "clsx": "^2.1.1", "cmdk": "^1.1.1", "date-fns": "^4.1.0", - "lucide-react": "^0.523.0", + "lucide-react": "^0.525.0", "next": "15.3.4", "next-auth": "^5.0.0-beta.25", "next-swagger-doc": "^0.4.1", @@ -72,15 +73,15 @@ "devDependencies": { "@eslint/eslintrc": "3.3.1", "@tailwindcss/postcss": "4.1.11", - "@types/node": "22.15.33", + "@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.29.0", + "eslint": "9.30.1", "eslint-config-next": "15.3.4", "eslint-config-prettier": "10.1.5", "orval": "7.10.0", 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 bfc390d..b2c4005 100644 --- a/src/app/(main)/events/[eventID]/page.tsx +++ b/src/app/(main)/events/[eventID]/page.tsx @@ -8,7 +8,6 @@ import { useDeleteApiEventEventID, useGetApiEventEventID, } from '@/generated/api/event/event'; -import { useGetApiUserMe } from '@/generated/api/user/user'; import { RedirectButton } from '@/components/buttons/redirect-button'; import { useSession } from 'next-auth/react'; import ParticipantListEntry from '@/components/custom-ui/participant-list-entry'; @@ -35,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 '-'; @@ -70,8 +63,8 @@ export default function ShowEvent() { }; return ( -
- +
+ @@ -83,7 +76,7 @@ export default function ShowEvent() {

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

@@ -94,8 +87,8 @@ export default function ShowEvent() { start Time
@@ -104,8 +97,8 @@ export default function ShowEvent() { end Time
@@ -113,7 +106,9 @@ export default function ShowEvent() { - +
@@ -121,7 +116,9 @@ export default function ShowEvent() { created:
@@ -129,7 +126,9 @@ export default function ShowEvent() { updated:
@@ -141,23 +140,31 @@ export default function ShowEvent() { - +
- +
{' '} -
- {event.participants?.map((user) => ( - +
+ {eventData.data.event.participants?.map((user) => ( + ))}
@@ -165,7 +172,8 @@ export default function ShowEvent() {
- {session.data?.user?.id === event.organizer.id ? ( + {session.data?.user?.id === + eventData.data.event.organizer.id ? ( Delete Event Are you sure you want to delete the event “ - {event.title}”? This action cannot be undone. + {eventData.data.event.title}”? This action + cannot be undone. @@ -194,7 +203,7 @@ export default function ShowEvent() { variant='muted' onClick={() => { deleteEvent.mutate( - { eventID: event.id }, + { eventID: eventData.data.event.id }, { onSuccess: () => { router.push('/home'); @@ -202,7 +211,7 @@ export default function ShowEvent() { )); @@ -220,7 +229,8 @@ export default function ShowEvent() { ) : null}
- {session.data?.user?.id === event.organizer.id ? ( + {session.data?.user?.id === + eventData.data.event.organizer.id ? ( - - + + - - - - - - -
+ + + + + + ); } diff --git a/src/app/(main)/events/new/page.tsx b/src/app/(main)/events/new/page.tsx index 2db7ae2..1dc1bde 100644 --- a/src/app/(main)/events/new/page.tsx +++ b/src/app/(main)/events/new/page.tsx @@ -1,12 +1,10 @@ -import { ThemePicker } from '@/components/misc/theme-picker'; 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 f0391dd..c22b8bd 100644 --- a/src/app/(main)/events/page.tsx +++ b/src/app/(main)/events/page.tsx @@ -17,7 +17,10 @@ export default function Events() { const events = eventsData?.data?.events || []; return ( -
+
{/* Heading */}

My Events @@ -25,7 +28,7 @@ export default function Events() { {/* Scrollable event list */}
-
+
{events.length > 0 ? ( events.map((event) => ( - +
+
+

+ Welcome, + + {isLoading + ? 'Loading...' + : data?.data.user?.first_name || + data?.data.user?.name || + 'Unknown User'}{' '} + 👋 + +

+
+
+ +
); } 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/user/[user]/calendar/route.ts b/src/app/api/calendar/route.ts similarity index 72% rename from src/app/api/user/[user]/calendar/route.ts rename to src/app/api/calendar/route.ts index 62142e9..440bbd7 100644 --- a/src/app/api/user/[user]/calendar/route.ts +++ b/src/app/api/calendar/route.ts @@ -15,7 +15,7 @@ import { } from '@/app/api/validation'; import { z } from 'zod/v4'; -export const GET = auth(async function GET(req, { params }) { +export const GET = auth(async function GET(req) { const authCheck = userAuthenticated(req); if (!authCheck.continue) return returnZodTypeCheckedResponse( @@ -24,7 +24,22 @@ export const GET = auth(async function GET(req, { params }) { authCheck.metadata, ); - const dataRaw = Object.fromEntries(new URL(req.url).searchParams); + 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 userCalendarQuerySchema.safeParseAsync(dataRaw); if (!data.success) return returnZodTypeCheckedResponse( @@ -36,15 +51,15 @@ export const GET = auth(async function GET(req, { params }) { }, { status: 400 }, ); - const { end, start } = data.data; + const { end, start, userIds } = data.data; const requestUserId = authCheck.user.id; - const requestedUserId = (await params).user; - - const requestedUser = await prisma.user.findFirst({ + const requestedUser = await prisma.user.findMany({ where: { - id: requestedUserId, + id: { + in: userIds, + }, }, select: { meetingParts: { @@ -57,6 +72,9 @@ export const GET = auth(async function GET(req, { params }) { gte: start, }, }, + status: { + not: 'DECLINED', + }, }, orderBy: { meeting: { @@ -64,6 +82,7 @@ export const GET = auth(async function GET(req, { params }) { }, }, select: { + user_id: true, meeting: { select: { id: true, @@ -136,6 +155,7 @@ export const GET = auth(async function GET(req, { params }) { start_time: 'asc', }, select: { + user_id: true, id: true, reason: true, start_time: true, @@ -153,46 +173,64 @@ export const GET = auth(async function GET(req, { params }) { if (!requestedUser) return returnZodTypeCheckedResponse( ErrorResponseSchema, - { success: false, message: 'User not found' }, + { success: false, message: 'User/s not found' }, { status: 404 }, ); const calendar: z.input = []; - for (const event of requestedUser.meetingParts) { + for (const event of requestedUser.map((r) => r.meetingParts).flat()) { if ( event.meeting.participants.some((p) => p.user.id === requestUserId) || event.meeting.organizer_id === requestUserId ) { - calendar.push({ ...event.meeting, type: 'event' }); + calendar.push({ + ...event.meeting, + type: 'event', + users: event.meeting.participants + .map((p) => p.user.id) + .filter((id) => userIds.includes(id)), + }); } else { calendar.push({ id: event.meeting.id, start_time: event.meeting.start_time, end_time: event.meeting.end_time, type: 'blocked_private', + users: event.meeting.participants + .map((p) => p.user.id) + .filter((id) => userIds.includes(id)), }); } } - for (const event of requestedUser.meetingsOrg) { + for (const event of requestedUser.map((r) => r.meetingsOrg).flat()) { if ( event.participants.some((p) => p.user.id === requestUserId) || event.organizer_id === requestUserId ) { - calendar.push({ ...event, type: 'event' }); + calendar.push({ + ...event, + type: 'event', + users: event.participants + .map((p) => p.user.id) + .filter((id) => userIds.includes(id)), + }); } else { calendar.push({ id: event.id, start_time: event.start_time, end_time: event.end_time, type: 'blocked_private', + users: event.participants + .map((p) => p.user.id) + .filter((id) => userIds.includes(id)), }); } } - for (const slot of requestedUser.blockedSlots) { - if (requestUserId === requestedUserId) { + for (const slot of requestedUser.map((r) => r.blockedSlots).flat()) { + if (requestUserId === userIds[0] && userIds.length === 1) { calendar.push({ start_time: slot.start_time, end_time: slot.end_time, @@ -204,6 +242,7 @@ export const GET = auth(async function GET(req, { params }) { created_at: slot.created_at, updated_at: slot.updated_at, type: 'blocked_owned', + users: [requestUserId], }); } else { calendar.push({ @@ -211,6 +250,7 @@ export const GET = auth(async function GET(req, { params }) { end_time: slot.end_time, id: slot.id, type: 'blocked_private', + users: [slot.user_id], }); } } diff --git a/src/app/api/user/[user]/calendar/swagger.ts b/src/app/api/calendar/swagger.ts similarity index 77% rename from src/app/api/user/[user]/calendar/swagger.ts rename to src/app/api/calendar/swagger.ts index fb48629..b4f5898 100644 --- a/src/app/api/user/[user]/calendar/swagger.ts +++ b/src/app/api/calendar/swagger.ts @@ -7,17 +7,12 @@ import { userNotFoundResponse, } from '@/lib/defaultApiResponses'; import { OpenAPIRegistry } from '@asteasolutions/zod-to-openapi'; -import zod from 'zod/v4'; -import { UserIdParamSchema } from '@/app/api/validation'; export default function registerSwaggerPaths(registry: OpenAPIRegistry) { registry.registerPath({ method: 'get', - path: '/api/user/{user}/calendar', + path: '/api/calendar', request: { - params: zod.object({ - user: UserIdParamSchema, - }), query: userCalendarQuerySchema, }, responses: { @@ -32,6 +27,6 @@ export default function registerSwaggerPaths(registry: OpenAPIRegistry) { ...notAuthenticatedResponse, ...userNotFoundResponse, }, - tags: ['User'], + tags: ['Calendar'], }); } diff --git a/src/app/api/user/[user]/calendar/validation.ts b/src/app/api/calendar/validation.ts similarity index 90% rename from src/app/api/user/[user]/calendar/validation.ts rename to src/app/api/calendar/validation.ts index 1572793..bc51489 100644 --- a/src/app/api/user/[user]/calendar/validation.ts +++ b/src/app/api/calendar/validation.ts @@ -14,6 +14,8 @@ export const BlockedSlotSchema = zod end_time: eventEndTimeSchema, type: zod.literal('blocked_private'), id: zod.string(), + users: zod.string().array(), + user_id: zod.string().optional(), }) .openapi('BlockedSlotSchema', { description: 'Blocked time slot in the user calendar', @@ -31,17 +33,22 @@ export const OwnedBlockedSlotSchema = zod created_at: zod.date().nullish(), updated_at: zod.date().nullish(), type: zod.literal('blocked_owned'), + users: zod.string().array(), + user_id: zod.string().optional(), }) .openapi('OwnedBlockedSlotSchema', { description: 'Blocked slot owned by the user', }); export const VisibleSlotSchema = EventSchema.omit({ - organizer: true, participants: true, + organizer: true, }) .extend({ 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', @@ -86,6 +93,7 @@ export const userCalendarQuerySchema = zod ); return endOfWeek; }), + userIds: zod.string().array(), }) .openapi('UserCalendarQuerySchema', { description: 'Query parameters for filtering the user calendar', 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 a8b6414..0bcb6cf 100644 --- a/src/app/api/search/user/route.ts +++ b/src/app/api/search/user/route.ts @@ -19,7 +19,7 @@ export const GET = auth(async function GET(req) { authCheck.metadata, ); - const dataRaw = Object.fromEntries(new URL(req.url).searchParams); + const dataRaw = Object.fromEntries(req.nextUrl.searchParams); const data = await searchUserSchema.safeParseAsync(dataRaw); if (!data.success) return returnZodTypeCheckedResponse( diff --git a/src/app/api/user/me/validation.ts b/src/app/api/user/me/validation.ts index 66f07cc..4a1d20e 100644 --- a/src/app/api/user/me/validation.ts +++ b/src/app/api/user/me/validation.ts @@ -1,11 +1,13 @@ import zod from 'zod/v4'; import { + emailSchema, firstNameSchema, lastNameSchema, newUserEmailServerSchema, newUserNameServerSchema, passwordSchema, timezoneSchema, + userNameSchema, } from '@/app/api/user/validation'; // ---------------------------------------- @@ -22,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/validation.ts b/src/app/api/validation.ts index 38b95bd..518121d 100644 --- a/src/app/api/validation.ts +++ b/src/app/api/validation.ts @@ -85,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/login/page.tsx b/src/app/login/page.tsx index dcd207d..9fa84ba 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -42,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/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/settings/page.tsx b/src/app/settings/page.tsx index 563ebab..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 { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; -import { ScrollableSettingsWrapper } from '@/components/wrappers/settings-scroll'; -import { Switch } from '@/components/ui/switch'; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@/components/ui/select'; +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 405b729..18b3b2d 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -2,8 +2,15 @@ import NextAuth, { CredentialsSignin } from 'next-auth'; import { Prisma } from '@/generated/prisma'; import type { Provider } from 'next-auth/providers'; + import Credentials from 'next-auth/providers/credentials'; -import Authentik from 'next-auth/providers/authentik'; +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 { PrismaAdapter } from '@auth/prisma-adapter'; import { prisma } from '@/prisma'; @@ -88,7 +95,27 @@ const providers: Provider[] = [ } }, }), - process.env.AUTH_AUTHENTIK_ID && Authentik, + + 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 17f9945..4b50e90 100644 --- a/src/components/buttons/icon-button.tsx +++ b/src/components/buttons/icon-button.tsx @@ -1,19 +1,20 @@ import { Button } from '@/components/ui/button'; - -import { IconProp } from '@fortawesome/fontawesome-svg-core'; -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +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/sso-login-button.tsx b/src/components/buttons/sso-login-button.tsx index 013ef73..b5cde0f 100644 --- a/src/components/buttons/sso-login-button.tsx +++ b/src/components/buttons/sso-login-button.tsx @@ -1,6 +1,6 @@ import { signIn } from '@/auth'; import { IconButton } from '@/components/buttons/icon-button'; -import { faOpenid } from '@fortawesome/free-brands-svg-icons'; +import { Fingerprint } from 'lucide-react'; export default function SSOLogin({ provider, @@ -22,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 a8d6005..d77b00a 100644 --- a/src/components/calendar.tsx +++ b/src/components/calendar.tsx @@ -7,7 +7,6 @@ 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 { useGetApiUserUserCalendar } from '@/generated/api/user/user'; import { useRouter } from 'next/navigation'; import { usePatchApiEventEventID } from '@/generated/api/event/event'; import { useSession } from 'next-auth/react'; @@ -17,6 +16,8 @@ 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 { useGetApiCalendar } from '@/generated/api/calendar/calendar'; +import { usePatchApiBlockedSlotsSlotID } from '@/generated/api/blocked-slots/blocked-slots'; moment.updateLocale('en', { week: { @@ -25,12 +26,29 @@ moment.updateLocale('en', { }, }); +function eventPropGetter(event: { + id: string; + start: Date; + end: Date; + type: UserCalendarSchemaItem['type']; + userId?: string; + colorOverride?: string; +}) { + return { + style: event.colorOverride + ? { backgroundColor: event.colorOverride } + : undefined, + }; +} + const DaDRBCalendar = withDragAndDrop< { id: string; start: Date; end: Date; type: UserCalendarSchemaItem['type']; + userId?: string; + organizer?: string; }, { id: string; @@ -44,9 +62,21 @@ const localizer = momentLocalizer(moment); export default function Calendar({ userId, height, + additionalEvents = [], + className, }: { - userId?: string; + userId?: string | string[]; height: string; + additionalEvents?: { + id: string; + title: string; + start: Date; + end: Date; + type: UserCalendarSchemaItem['type']; + userId?: string; + colorOverride?: string; + }[]; + className?: string; }) { return ( @@ -67,10 +97,26 @@ export default function Calendar({
)} > - {userId ? ( - + {typeof userId === 'string' ? ( + + ) : Array.isArray(userId) && userId.length > 0 ? ( + ) : ( - + )} )} @@ -81,9 +127,21 @@ export default function Calendar({ function CalendarWithUserEvents({ userId, height, + additionalEvents, + className, }: { userId: string; height: string; + additionalEvents?: { + id: string; + title: string; + start: Date; + end: Date; + type: UserCalendarSchemaItem['type']; + userId?: string; + colorOverride?: string; + }[]; + className?: string; }) { const sesstion = useSession(); const [currentView, setCurrentView] = React.useState< @@ -92,9 +150,9 @@ function CalendarWithUserEvents({ const [currentDate, setCurrentDate] = React.useState(new Date()); const router = useRouter(); - const { data, refetch, error, isError } = useGetApiUserUserCalendar( - userId, + const { data, refetch, error, isError } = useGetApiCalendar( { + userIds: [userId], start: moment(currentDate) .startOf( currentView === 'agenda' @@ -134,9 +192,18 @@ function CalendarWithUserEvents({ }, }, }); + const { mutate: patchBlockedSlot } = usePatchApiBlockedSlotsSlotID({ + mutation: { + throwOnError(error) { + throw error.response?.data || 'Failed to update blocked slot'; + }, + }, + }); return ( { setCurrentDate(date); }} - events={ - data?.data.calendar.map((event) => ({ + events={[ + ...(data?.data.calendar.map((event) => ({ id: event.id, title: event.type === 'event' ? event.title : 'Blocker', start: new Date(event.start_time), end: new Date(event.end_time), type: event.type, - })) ?? [] - } + userId: event.users[0], + organizer: event.type === 'event' ? event.organizer_id : undefined, + })) ?? []), + ...(additionalEvents ?? []), + ]} onSelectEvent={(event) => { - 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( @@ -176,59 +253,220 @@ function CalendarWithUserEvents({ 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); + }, + }, + ); + } }} /> ); } -function CalendarWithoutUserEvents({ height }: { height: string }) { +function CalendarWithMultiUserEvents({ + userIds, + height, + additionalEvents, + className, +}: { + userIds: string[]; + height: string; + additionalEvents?: { + id: string; + title: string; + start: Date; + end: Date; + type: UserCalendarSchemaItem['type']; + userId?: string; + colorOverride?: string; + }[]; + className?: string; +}) { + const [currentView, setCurrentView] = React.useState< + 'month' | 'week' | 'day' | 'agenda' | 'work_week' + >('week'); + const [currentDate, setCurrentDate] = React.useState(new Date()); + + const { data, error, isError } = useGetApiCalendar( + { + userIds: userIds, + start: moment(currentDate) + .startOf( + currentView === 'agenda' + ? 'month' + : currentView === 'work_week' + ? 'week' + : currentView, + ) + .toISOString(), + end: moment(currentDate) + .endOf( + currentView === 'agenda' + ? 'month' + : currentView === 'work_week' + ? 'week' + : currentView, + ) + .toISOString(), + }, + { + query: { + refetchOnWindowFocus: true, + refetchOnReconnect: true, + refetchOnMount: true, + }, + }, + ); + + if (isError) { + throw error.response?.data || 'Failed to fetch calendar data'; + } + + return ( + { + setCurrentDate(date); + }} + events={[ + ...(data?.data.calendar.map((event) => ({ + id: event.id, + title: event.type === 'event' ? event.title : 'Blocker', + start: new Date(event.start_time), + end: new Date(event.end_time), + type: event.type, + userId: event.users[0], + })) ?? []), + ...(additionalEvents ?? []), + ]} + /> + ); +} + +function CalendarWithoutUserEvents({ + height, + additionalEvents, + className, +}: { + height: string; + additionalEvents?: { + id: string; + title: string; + start: Date; + end: Date; + type: UserCalendarSchemaItem['type']; + userId?: string; + colorOverride?: string; + }[]; + className?: string; +}) { const [currentView, setCurrentView] = React.useState< 'month' | 'week' | 'day' | 'agenda' | 'work_week' >('week'); @@ -236,6 +474,8 @@ function CalendarWithoutUserEvents({ height }: { height: string }) { return ( { setCurrentDate(date); }} + events={additionalEvents} /> ); } diff --git a/src/components/custom-toolbar.css b/src/components/custom-toolbar.css index 3fba69f..1230384 100644 --- a/src/components/custom-toolbar.css +++ b/src/components/custom-toolbar.css @@ -1,11 +1,15 @@ /* Container der Toolbar */ .custom-toolbar { display: flex; - flex-direction: column; - gap: 12px; + gap: 8px; padding: calc(var(--spacing) * 2); padding-left: calc(50px + var(--spacing)); - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + + @media (max-width: 870px) { + padding-left: 0; + flex-direction: column; + } + box-shadow: none; font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; } @@ -25,6 +29,12 @@ display: flex; gap: 8px; justify-content: center; + align-items: center; +} + +.custom-toolbar .navigation-controls .handleWeek { + display: grid; + grid-template-columns: 1fr 1fr; } .custom-toolbar .navigation-controls button { @@ -77,6 +87,8 @@ border-radius: 11px; justify-items: center; align-items: center; + display: flex; + gap: 8px; } .custom-toolbar .navigation-controls .handleWeek button { @@ -93,6 +105,9 @@ padding: 0 8px; border-radius: 11px; justify-items: center; + display: flex; + justify-content: center; + align-items: center; } .right-section .datepicker-box { @@ -100,8 +115,12 @@ background-color: #c6c6c6; height: 36px; border-radius: 11px; - font-size: 12px; + font-size: 14px; align-self: center; + font-family: 'Varela Round', sans-serif; + display: flex; + align-items: center; + justify-content: center; } .datepicker { diff --git a/src/components/custom-toolbar.tsx b/src/components/custom-toolbar.tsx index 36c8fff..76e59ee 100644 --- a/src/components/custom-toolbar.tsx +++ b/src/components/custom-toolbar.tsx @@ -171,12 +171,9 @@ const CustomToolbar: React.FC = ({ }; 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' ? (