diff --git a/.env.example b/.env.example index 41d13b5..1feabf8 100644 --- a/.env.example +++ b/.env.example @@ -6,23 +6,4 @@ 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 a7ae637..b0d8710 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:95587c1ce688ce6f59934cc234a753a32a1782ca1c7959707a7d2332e69f6f63 + image: cypress/browsers:latest@sha256:9daea41366dfd1b72496bf3e8295eda215a6990c2dbe4f9ff4b8ba47342864fb options: --user 1001 steps: - name: Checkout diff --git a/README.md b/README.md index 24990d2..56fa41d 100644 --- a/README.md +++ b/README.md @@ -1,19 +1,5 @@ # 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. @@ -26,54 +12,52 @@ MeetUP is a social calendar application designed to make coordinating schedules ### Implemented Features -- 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) +- Core infrastructure setup in progress. No user-facing features are implemented yet. ### Planned Features (Roadmap) -- Friendships -- Group calendars -- iCal import and export -- Notifications (in-app and external/mail) +- **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. ## Technologies Used This project is built with a modern tech stack: - **Package Manager:** [Yarn](https://yarnpkg.com/) -- **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/) +- **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)_ -## Development environment setup - -### Without Docker +## Getting Started **Prerequisites:** -- **Node.js**: version 22+ -- **corepack**: enable using `corepack enable` +- 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. -**Installation & Running:** +**Installation & Running Locally:** 1. **Clone the repository:** - - Using HTTPS: - ```bash - git clone https://git.dominikstahl.dev/DHBW-WE/MeetUp.git - ``` - - Or using SSH: + - 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 ``` @@ -82,44 +66,53 @@ 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: + - 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. - ```bash - npx auth secret - ``` + ```env + # Database Connection String (Prisma) + # Example for PostgreSQL: DATABASE_URL="postgresql://USER:PASSWORD@HOST:PORT/DATABASE?schema=public" + DATABASE_URL="your_database_connection_string" - 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: + # Generated with npx auth secret + AUTH_SECRET="your_generated_auth_secret" - ```env - # Generated with npx auth secret - AUTH_SECRET="your_generated_auth_secret" + # Authentik SSO Variables (if you are using this provider) + AUTH_AUTHENTIK_ID= + AUTH_AUTHENTIK_SECRET= + AUTH_AUTHENTIK_ISSUER= - DATABASE_URL="file:./dev.db" - ``` + # Base URL of your application + NEXT_PUBLIC_APP_URL="http://localhost:3000" + ``` 4. **Apply database migrations (Prisma):** - Set up/update the database with these commands: + - 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. + ``` - ```bash - yarn prisma:generate - ``` + Tipp: You can open the prisma database UI with `yarn prisma:studio` - ```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:** +5. **Run the development server:** ```bash yarn dev @@ -127,64 +120,56 @@ This project is built with a modern tech stack: Open [http://localhost:3000](http://localhost:3000) in your browser to see the application. -### With Docker + The test user for the application is: -**Prerequisites:** + ```bash + email: test@example.com + password: password + ``` -- **Docker** -- **Docker Compose** +**Docker Development Environment:** -**Running:** +- The docker development environment can be started with the following command: ```bash yarn dev_container ``` -## Production Deployment using Docker +**Self-Hosting with Docker (Planned):** -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). +- 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. ## Contributing -Contributions are welcome! If you'd like to contribute, please follow these steps: +Contributions are welcome! If you'd like to contribute, please: 1. Fork the repository. -2. Create a new branch: - - ```bash - git checkout -b /- - ``` - - - Example: `feat/42-add_login_form` - +2. Create a new branch (`git checkout -b /-action_name`). 3. Make your changes. -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 /- - ``` +4. Commit your changes (`git commit -m ': add some feature'`). +5. Push to the branch (`git push origin /-action_name`). 6. Open a Pull Request against the `main` branch. -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. +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) diff --git a/cypress/e2e/auth-user.ts b/cypress/e2e/auth-user.ts new file mode 100644 index 0000000..5b02ab9 --- /dev/null +++ b/cypress/e2e/auth-user.ts @@ -0,0 +1,12 @@ +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 2a8385a..a74f770 100644 --- a/cypress/e2e/event-create.cy.ts +++ b/cypress/e2e/event-create.cy.ts @@ -1,40 +1,9 @@ +import authUser from './auth-user'; + describe('event creation', () => { it('loads', () => { - cy.login(); + authUser(); - 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'); + // cy.visit('http://127.0.0.1:3000/events/new'); // TODO: Add event creation tests }); }); diff --git a/cypress/support/commands.ts b/cypress/support/commands.ts index 994b7ef..59717f5 100644 --- a/cypress/support/commands.ts +++ b/cypress/support/commands.ts @@ -1,5 +1,3 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ -/* eslint-disable @typescript-eslint/no-namespace */ /// // *********************************************** // This example commands.ts shows you how to @@ -46,22 +44,6 @@ 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 { @@ -73,7 +55,6 @@ declare global { selector: string, ...args: any[] ): Chainable>; - login(): Chainable; } } } diff --git a/next.config.ts b/next.config.ts index b9574f9..164b423 100644 --- a/next.config.ts +++ b/next.config.ts @@ -6,7 +6,7 @@ const nextConfig: NextConfig = { remotePatterns: [ { protocol: 'https', - hostname: 'i.gifer.com', + hostname: 'img1.wikia.nocookie.net', port: '', pathname: '/**', }, diff --git a/package.json b/package.json index 95266fc..9a33a39 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "meetup", - "version": "0.1.3", + "version": "0.1.0", "private": true, "scripts": { "dev": "next dev --turbopack", @@ -30,7 +30,6 @@ "@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", @@ -52,7 +51,7 @@ "clsx": "^2.1.1", "cmdk": "^1.1.1", "date-fns": "^4.1.0", - "lucide-react": "^0.525.0", + "lucide-react": "^0.523.0", "next": "15.3.4", "next-auth": "^5.0.0-beta.25", "next-swagger-doc": "^0.4.1", @@ -73,15 +72,15 @@ "devDependencies": { "@eslint/eslintrc": "3.3.1", "@tailwindcss/postcss": "4.1.11", - "@types/node": "22.16.0", + "@types/node": "22.15.33", "@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.1", + "cypress": "14.5.0", "dotenv-cli": "8.0.0", - "eslint": "9.30.1", + "eslint": "9.29.0", "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 deleted file mode 100644 index b103c30..0000000 --- a/prisma/migrations/20250701092705_v0_1_3/migration.sql +++ /dev/null @@ -1,170 +0,0 @@ --- 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/prisma/schema.prisma b/prisma/schema.prisma index ffa1c86..4b3c897 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -45,6 +45,12 @@ enum notification_type { CALENDAR_SYNC_ERROR } +enum entity_type { + USER + MEETING + GROUP +} + enum group_member_role { ADMIN MEMBER @@ -253,8 +259,8 @@ model Notification { id String @id @default(cuid()) user_id String type notification_type - related_entity_type String? - related_entity_id String? + related_entity_type entity_type + related_entity_id String message String is_read Boolean @default(false) created_at DateTime @default(now()) diff --git a/src/app/(main)/blocker/[slotId]/page.tsx b/src/app/(main)/blocker/[slotId]/page.tsx deleted file mode 100644 index 893253c..0000000 --- a/src/app/(main)/blocker/[slotId]/page.tsx +++ /dev/null @@ -1,10 +0,0 @@ -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 deleted file mode 100644 index a7c1bc7..0000000 --- a/src/app/(main)/blocker/new/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -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 deleted file mode 100644 index aebc807..0000000 --- a/src/app/(main)/blocker/page.tsx +++ /dev/null @@ -1,56 +0,0 @@ -'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 b2c4005..bfc390d 100644 --- a/src/app/(main)/events/[eventID]/page.tsx +++ b/src/app/(main)/events/[eventID]/page.tsx @@ -8,6 +8,7 @@ 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'; @@ -34,21 +35,27 @@ 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) { + if (isLoading || userLoading) { 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 '-'; @@ -63,8 +70,8 @@ export default function ShowEvent() { }; return ( -
- +
+ @@ -76,7 +83,7 @@ export default function ShowEvent() {

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

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

My Events @@ -28,7 +25,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 deleted file mode 100644 index 908324e..0000000 --- a/src/app/api/blocked_slots/[slotID]/route.ts +++ /dev/null @@ -1,165 +0,0 @@ -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 deleted file mode 100644 index 16f2637..0000000 --- a/src/app/api/blocked_slots/[slotID]/swagger.ts +++ /dev/null @@ -1,90 +0,0 @@ -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 deleted file mode 100644 index afd4f87..0000000 --- a/src/app/api/blocked_slots/route.ts +++ /dev/null @@ -1,127 +0,0 @@ -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 deleted file mode 100644 index 4be89a9..0000000 --- a/src/app/api/blocked_slots/swagger.ts +++ /dev/null @@ -1,66 +0,0 @@ -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 deleted file mode 100644 index 1cbe42c..0000000 --- a/src/app/api/blocked_slots/validation.ts +++ /dev/null @@ -1,78 +0,0 @@ -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/event/[eventID]/route.ts b/src/app/api/event/[eventID]/route.ts index 8c06b64..f3915b3 100644 --- a/src/app/api/event/[eventID]/route.ts +++ b/src/app/api/event/[eventID]/route.ts @@ -129,6 +129,17 @@ export const DELETE = auth(async (req, { params }) => { { participants: { some: { user_id: dbUser.id } } }, ], }, + include: { + participants: { + select: { + user: { + select: { + id: true, + }, + }, + }, + }, + }, }); if (!event) @@ -151,6 +162,18 @@ export const DELETE = auth(async (req, { params }) => { }, }); + for (const participant of event.participants) { + await prisma.notification.create({ + data: { + user_id: participant.user.id, + type: 'MEETING_CANCEL', + related_entity_id: eventID, + related_entity_type: 'MEETING', + message: `The event "${event.title}" has been cancelled by the organizer.`, + }, + }); + } + return returnZodTypeCheckedResponse( SuccessResponseSchema, { success: true, message: 'Event deleted successfully' }, @@ -190,6 +213,17 @@ export const PATCH = auth(async (req, { params }) => { { participants: { some: { user_id: dbUser.id } } }, ], }, + include: { + participants: { + select: { + user: { + select: { + id: true, + }, + }, + }, + }, + }, }); if (!event) @@ -244,8 +278,33 @@ export const PATCH = auth(async (req, { params }) => { }, update: {}, }); + if (event.participants.some((p) => p.user.id === participant)) { + await prisma.notification.create({ + data: { + user_id: participant, + type: 'MEETING_UPDATE', + related_entity_id: eventID, + related_entity_type: 'MEETING', + message: `The event "${event.title}" has been updated.`, + }, + }); + } } + for (const participant of event.participants) { + if (participants && !participants.includes(participant.user.id)) { + await prisma.notification.create({ + data: { + user_id: participant.user.id, + type: 'MEETING_CANCEL', + related_entity_id: eventID, + related_entity_type: 'MEETING', + message: `You have been removed from the event "${event.title}".`, + }, + }); + } + } + const updatedEvent = await prisma.meeting.update({ where: { id: eventID, diff --git a/src/app/api/event/route.ts b/src/app/api/event/route.ts index fb734b1..5f368d1 100644 --- a/src/app/api/event/route.ts +++ b/src/app/api/event/route.ts @@ -167,6 +167,18 @@ export const POST = auth(async (req) => { }, }); + for (const participant of newEvent.participants) { + await prisma.notification.create({ + data: { + user_id: participant.user.id, + type: 'MEETING_INVITE', + related_entity_type: 'MEETING', + related_entity_id: newEvent.id, + message: `You have been invited to the meeting "${newEvent.title}" by ${newEvent.organizer.first_name} ${newEvent.organizer.last_name}.`, + }, + }); + } + return returnZodTypeCheckedResponse( EventResponseSchema, { diff --git a/src/app/api/logout/route.ts b/src/app/api/logout/route.ts deleted file mode 100644 index ba89440..0000000 --- a/src/app/api/logout/route.ts +++ /dev/null @@ -1,8 +0,0 @@ -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/notifications/[notification]/route.ts b/src/app/api/notifications/[notification]/route.ts new file mode 100644 index 0000000..d02d434 --- /dev/null +++ b/src/app/api/notifications/[notification]/route.ts @@ -0,0 +1,77 @@ +import { auth } from '@/auth'; +import { prisma } from '@/prisma'; +import { + getNotificationResponseSchema, + updateNotificationRequestSchema, +} from '../validation'; +import { + returnZodTypeCheckedResponse, + userAuthenticated, +} from '@/lib/apiHelpers'; +import { + ErrorResponseSchema, + ZodErrorResponseSchema, +} from '@/app/api/validation'; + +export const PATCH = auth(async function GET(req, { params }: { params: Promise<{ notification: string }> }) { + const authCheck = userAuthenticated(req); + if (!authCheck.continue) + return returnZodTypeCheckedResponse( + ErrorResponseSchema, + authCheck.response, + authCheck.metadata, + ); + + const userId = authCheck.user.id; + + const notificationId = (await params).notification; + + const dataRaw = await req.json(); + const data = await updateNotificationRequestSchema.safeParseAsync(dataRaw); + if (!data.success) + return returnZodTypeCheckedResponse( + ZodErrorResponseSchema, + { + success: false, + message: 'Invalid request data', + errors: data.error.issues, + }, + { status: 400 }, + ); + const { is_read } = data.data; + + const notification = await prisma.notification.update({ + where: { + id: notificationId, + user_id: userId, + }, + data: { + is_read, + }, + select: { + id: true, + type: true, + related_entity_id: true, + related_entity_type: true, + message: true, + is_read: true, + created_at: true, + }, + }); + + if (!notification) + return returnZodTypeCheckedResponse( + ErrorResponseSchema, + { success: false, message: 'Notification not found or you do not have permission to update it' }, + { status: 404 }, + ); + + return returnZodTypeCheckedResponse( + getNotificationResponseSchema, + { + success: true, + notification, + }, + { status: 200 }, + ); +}); \ No newline at end of file diff --git a/src/app/api/notifications/[notification]/swagger.ts b/src/app/api/notifications/[notification]/swagger.ts new file mode 100644 index 0000000..7cf9bba --- /dev/null +++ b/src/app/api/notifications/[notification]/swagger.ts @@ -0,0 +1,44 @@ +import { OpenAPIRegistry } from '@asteasolutions/zod-to-openapi'; +import { + invalidRequestDataResponse, + notAuthenticatedResponse, + serverReturnedDataValidationErrorResponse, + userNotFoundResponse, +} from '@/lib/defaultApiResponses'; +import { getNotificationResponseSchema, updateNotificationRequestSchema } from '../validation'; +import zod from 'zod/v4'; +import { NotificationIdSchema } from '../../validation'; + +export default function registerSwaggerPaths(registry: OpenAPIRegistry) { + registry.registerPath({ + method: 'patch', + path: '/api/notifications/{notification}', + request: { + body: { + content: { + 'application/json': { + schema: updateNotificationRequestSchema, + }, + }, + }, + params: zod.object({ + notification: NotificationIdSchema, + }), + }, + responses: { + 200: { + description: 'Notification updated successfully', + content: { + 'application/json': { + schema: getNotificationResponseSchema, + }, + }, + }, + ...invalidRequestDataResponse, + ...notAuthenticatedResponse, + ...userNotFoundResponse, + ...serverReturnedDataValidationErrorResponse, + }, + tags: ['Notifications'], + }); +} diff --git a/src/app/api/notifications/route.ts b/src/app/api/notifications/route.ts new file mode 100644 index 0000000..ef0f363 --- /dev/null +++ b/src/app/api/notifications/route.ts @@ -0,0 +1,80 @@ +import { auth } from '@/auth'; +import { prisma } from '@/prisma'; +import { + getNotificationsRequestSchema, + getNotificationsResponseSchema, +} from './validation'; +import { + returnZodTypeCheckedResponse, + userAuthenticated, +} from '@/lib/apiHelpers'; +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 userId = authCheck.user.id; + + const dataRaw = Object.fromEntries(new URL(req.url).searchParams); + const data = await getNotificationsRequestSchema.safeParseAsync(dataRaw); + if (!data.success) + return returnZodTypeCheckedResponse( + ZodErrorResponseSchema, + { + success: false, + message: 'Invalid request data', + errors: data.error.issues, + }, + { status: 400 }, + ); + const { skip, all } = data.data; + + const notifications = await prisma.notification.findMany({ + where: { + user_id: userId, + created_at: all + ? undefined + : { gte: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000) }, + }, + orderBy: { created_at: 'desc' }, + select: { + id: true, + type: true, + related_entity_id: true, + related_entity_type: true, + message: true, + is_read: true, + created_at: true, + }, + take: 50, + skip, + }); + + const total_count = await prisma.notification.count({ + where: { user_id: userId }, + }); + + const unread_count = await prisma.notification.count({ + where: { user_id: userId, is_read: false }, + }); + + return returnZodTypeCheckedResponse( + getNotificationsResponseSchema, + { + success: true, + notifications, + total_count, + unread_count, + }, + { status: 200 }, + ); +}); diff --git a/src/app/api/notifications/swagger.ts b/src/app/api/notifications/swagger.ts new file mode 100644 index 0000000..c5dcc09 --- /dev/null +++ b/src/app/api/notifications/swagger.ts @@ -0,0 +1,36 @@ +import { OpenAPIRegistry } from '@asteasolutions/zod-to-openapi'; +import { + getNotificationsRequestSchema, + getNotificationsResponseSchema, +} from './validation'; +import { + invalidRequestDataResponse, + notAuthenticatedResponse, + serverReturnedDataValidationErrorResponse, + userNotFoundResponse, +} from '@/lib/defaultApiResponses'; + +export default function registerSwaggerPaths(registry: OpenAPIRegistry) { + registry.registerPath({ + method: 'get', + path: '/api/notifications', + request: { + query: getNotificationsRequestSchema, + }, + responses: { + 200: { + description: 'List of notifications for the authenticated user', + content: { + 'application/json': { + schema: getNotificationsResponseSchema, + }, + }, + }, + ...invalidRequestDataResponse, + ...notAuthenticatedResponse, + ...userNotFoundResponse, + ...serverReturnedDataValidationErrorResponse, + }, + tags: ['Notifications'], + }); +} diff --git a/src/app/api/notifications/validation.ts b/src/app/api/notifications/validation.ts new file mode 100644 index 0000000..58cd43f --- /dev/null +++ b/src/app/api/notifications/validation.ts @@ -0,0 +1,44 @@ +import zod from 'zod/v4'; + +export const notificationsSchema = zod.object({ + id: zod.string(), + type: zod.enum([ + 'FRIEND_REQUEST', + 'FRIEND_ACCEPT', + 'MEETING_INVITE', + 'MEETING_UPDATE', + 'MEETING_CANCEL', + 'MEETING_REMINDER', + 'GROUP_MEMBER_ADDED', + 'CALENDAR_SYNC_ERROR', + ]), + related_entity_id: zod.string().nullable(), + related_entity_type: zod.enum(['USER', 'MEETING', 'GROUP']), + message: zod.string().max(500), + is_read: zod.boolean(), + created_at: zod.date(), +}).openapi('Notification'); + +export const getNotificationsResponseSchema = zod.object({ + success: zod.boolean(), + notifications: zod.array(notificationsSchema), + total_count: zod.number(), + unread_count: zod.number(), +}); + +export const getNotificationResponseSchema = zod.object({ + success: zod.boolean(), + notification: notificationsSchema, +}).openapi('GetNotificationResponse'); + +export const getNotificationsRequestSchema = zod.object({ + skip: zod.string().transform((val) => { + const num = parseInt(val, 10); + return isNaN(num) ? 0 : num; + }).default(0), + all: zod.boolean().default(false), +}).openapi('GetNotificationsRequest'); + +export const updateNotificationRequestSchema = zod.object({ + is_read: zod.boolean(), +}).openapi('UpdateNotificationRequest'); \ No newline at end of file diff --git a/src/app/api/search/user/route.ts b/src/app/api/search/user/route.ts index 0bcb6cf..a8b6414 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(req.nextUrl.searchParams); + const dataRaw = Object.fromEntries(new URL(req.url).searchParams); const data = await searchUserSchema.safeParseAsync(dataRaw); if (!data.success) return returnZodTypeCheckedResponse( diff --git a/src/app/api/calendar/route.ts b/src/app/api/user/[user]/calendar/route.ts similarity index 72% rename from src/app/api/calendar/route.ts rename to src/app/api/user/[user]/calendar/route.ts index 440bbd7..62142e9 100644 --- a/src/app/api/calendar/route.ts +++ b/src/app/api/user/[user]/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) { +export const GET = auth(async function GET(req, { params }) { const authCheck = userAuthenticated(req); if (!authCheck.continue) return returnZodTypeCheckedResponse( @@ -24,22 +24,7 @@ export const GET = auth(async function GET(req) { 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 dataRaw = Object.fromEntries(new URL(req.url).searchParams); const data = await userCalendarQuerySchema.safeParseAsync(dataRaw); if (!data.success) return returnZodTypeCheckedResponse( @@ -51,15 +36,15 @@ export const GET = auth(async function GET(req) { }, { status: 400 }, ); - const { end, start, userIds } = data.data; + const { end, start } = data.data; const requestUserId = authCheck.user.id; - const requestedUser = await prisma.user.findMany({ + const requestedUserId = (await params).user; + + const requestedUser = await prisma.user.findFirst({ where: { - id: { - in: userIds, - }, + id: requestedUserId, }, select: { meetingParts: { @@ -72,9 +57,6 @@ export const GET = auth(async function GET(req) { gte: start, }, }, - status: { - not: 'DECLINED', - }, }, orderBy: { meeting: { @@ -82,7 +64,6 @@ export const GET = auth(async function GET(req) { }, }, select: { - user_id: true, meeting: { select: { id: true, @@ -155,7 +136,6 @@ export const GET = auth(async function GET(req) { start_time: 'asc', }, select: { - user_id: true, id: true, reason: true, start_time: true, @@ -173,64 +153,46 @@ export const GET = auth(async function GET(req) { if (!requestedUser) return returnZodTypeCheckedResponse( ErrorResponseSchema, - { success: false, message: 'User/s not found' }, + { success: false, message: 'User not found' }, { status: 404 }, ); const calendar: z.input = []; - for (const event of requestedUser.map((r) => r.meetingParts).flat()) { + for (const event of requestedUser.meetingParts) { if ( event.meeting.participants.some((p) => p.user.id === requestUserId) || event.meeting.organizer_id === requestUserId ) { - calendar.push({ - ...event.meeting, - type: 'event', - users: event.meeting.participants - .map((p) => p.user.id) - .filter((id) => userIds.includes(id)), - }); + calendar.push({ ...event.meeting, type: 'event' }); } 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.map((r) => r.meetingsOrg).flat()) { + for (const event of requestedUser.meetingsOrg) { if ( event.participants.some((p) => p.user.id === requestUserId) || event.organizer_id === requestUserId ) { - calendar.push({ - ...event, - type: 'event', - users: event.participants - .map((p) => p.user.id) - .filter((id) => userIds.includes(id)), - }); + calendar.push({ ...event, type: 'event' }); } 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.map((r) => r.blockedSlots).flat()) { - if (requestUserId === userIds[0] && userIds.length === 1) { + for (const slot of requestedUser.blockedSlots) { + if (requestUserId === requestedUserId) { calendar.push({ start_time: slot.start_time, end_time: slot.end_time, @@ -242,7 +204,6 @@ export const GET = auth(async function GET(req) { created_at: slot.created_at, updated_at: slot.updated_at, type: 'blocked_owned', - users: [requestUserId], }); } else { calendar.push({ @@ -250,7 +211,6 @@ export const GET = auth(async function GET(req) { end_time: slot.end_time, id: slot.id, type: 'blocked_private', - users: [slot.user_id], }); } } diff --git a/src/app/api/calendar/swagger.ts b/src/app/api/user/[user]/calendar/swagger.ts similarity index 77% rename from src/app/api/calendar/swagger.ts rename to src/app/api/user/[user]/calendar/swagger.ts index b4f5898..fb48629 100644 --- a/src/app/api/calendar/swagger.ts +++ b/src/app/api/user/[user]/calendar/swagger.ts @@ -7,12 +7,17 @@ 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/calendar', + path: '/api/user/{user}/calendar', request: { + params: zod.object({ + user: UserIdParamSchema, + }), query: userCalendarQuerySchema, }, responses: { @@ -27,6 +32,6 @@ export default function registerSwaggerPaths(registry: OpenAPIRegistry) { ...notAuthenticatedResponse, ...userNotFoundResponse, }, - tags: ['Calendar'], + tags: ['User'], }); } diff --git a/src/app/api/calendar/validation.ts b/src/app/api/user/[user]/calendar/validation.ts similarity index 90% rename from src/app/api/calendar/validation.ts rename to src/app/api/user/[user]/calendar/validation.ts index bc51489..1572793 100644 --- a/src/app/api/calendar/validation.ts +++ b/src/app/api/user/[user]/calendar/validation.ts @@ -14,8 +14,6 @@ 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', @@ -33,22 +31,17 @@ 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({ - participants: true, organizer: true, + participants: 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', @@ -93,7 +86,6 @@ 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/user/me/validation.ts b/src/app/api/user/me/validation.ts index 4a1d20e..66f07cc 100644 --- a/src/app/api/user/me/validation.ts +++ b/src/app/api/user/me/validation.ts @@ -1,13 +1,11 @@ import zod from 'zod/v4'; import { - emailSchema, firstNameSchema, lastNameSchema, newUserEmailServerSchema, newUserNameServerSchema, passwordSchema, timezoneSchema, - userNameSchema, } from '@/app/api/user/validation'; // ---------------------------------------- @@ -24,15 +22,6 @@ 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 518121d..f16605f 100644 --- a/src/app/api/validation.ts +++ b/src/app/api/validation.ts @@ -86,13 +86,13 @@ export const EventIdParamSchema = registry.registerParameter( }), ); -export const SlotIdParamSchema = registry.registerParameter( - 'SlotIdParam', +export const NotificationIdSchema = registry.registerParameter( + 'NotificationIdParam', zod.string().openapi({ param: { - name: 'slotID', + name: 'notification', in: 'path', }, - example: 'abcde12345', + example: '12345', }), -); +); \ No newline at end of file diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index 9fa84ba..dcd207d 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -42,9 +42,7 @@ export default async function LoginPage() { - {providerMap.length > 0 && !process.env.DISABLE_PASSWORD_LOGIN ? ( - - ) : null} + {providerMap.map((provider) => ( cat gif diff --git a/src/app/not-found.tsx b/src/app/not-found.tsx deleted file mode 100644 index 6a3c299..0000000 --- a/src/app/not-found.tsx +++ /dev/null @@ -1,28 +0,0 @@ -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 a2c5b35..563ebab 100644 --- a/src/app/settings/page.tsx +++ b/src/app/settings/page.tsx @@ -1,5 +1,482 @@ -import SettingsPage from '@/components/settings/settings-page'; +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'; -export default function Page() { - return ; +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. + + + +
+ + +
+
+ + +
+
+ + +
+
+
+ + + + +
+
+
+
+
+ ); } diff --git a/src/auth.ts b/src/auth.ts index 18b3b2d..405b729 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -2,15 +2,8 @@ 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 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 Authentik from 'next-auth/providers/authentik'; import { PrismaAdapter } from '@auth/prisma-adapter'; import { prisma } from '@/prisma'; @@ -95,27 +88,7 @@ const providers: Provider[] = [ } }, }), - - 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, - }; - }, - }), + process.env.AUTH_AUTHENTIK_ID && Authentik, ].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 4b50e90..17f9945 100644 --- a/src/components/buttons/icon-button.tsx +++ b/src/components/buttons/icon-button.tsx @@ -1,20 +1,19 @@ import { Button } from '@/components/ui/button'; -import { LucideProps } from 'lucide-react'; -import React, { ForwardRefExoticComponent, RefAttributes } from 'react'; + +import { IconProp } from '@fortawesome/fontawesome-svg-core'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; export function IconButton({ icon, children, ...props }: { - icon?: ForwardRefExoticComponent< - Omit & RefAttributes - >; - children?: React.ReactNode; + icon: IconProp; + children: React.ReactNode; } & React.ComponentProps) { return ( ); diff --git a/src/components/buttons/notification-button.tsx b/src/components/buttons/notification-button.tsx index f41f325..e536ff5 100644 --- a/src/components/buttons/notification-button.tsx +++ b/src/components/buttons/notification-button.tsx @@ -8,24 +8,28 @@ import { NDot, NotificationDot } from '@/components/misc/notification-dot'; export function NotificationButton({ dotVariant, + icon, children, ...props }: { dotVariant?: NDot; - children: React.ReactNode; + icon?: React.ReactNode; + children?: React.ReactNode; } & React.ComponentProps) { return ( - + + {children} + ); } diff --git a/src/components/buttons/sso-login-button.tsx b/src/components/buttons/sso-login-button.tsx index b5cde0f..013ef73 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 { Fingerprint } from 'lucide-react'; +import { faOpenid } from '@fortawesome/free-brands-svg-icons'; export default function SSOLogin({ provider, @@ -22,7 +22,7 @@ export default function SSOLogin({ className='w-full' type='submit' variant='secondary' - icon={Fingerprint} + icon={faOpenid} {...props} > Login with {providerDisplayName} diff --git a/src/components/calendar.tsx b/src/components/calendar.tsx index d77b00a..a8d6005 100644 --- a/src/components/calendar.tsx +++ b/src/components/calendar.tsx @@ -7,6 +7,7 @@ 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'; @@ -16,8 +17,6 @@ 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: { @@ -26,29 +25,12 @@ 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; @@ -62,21 +44,9 @@ const localizer = momentLocalizer(moment); export default function Calendar({ userId, height, - additionalEvents = [], - className, }: { - userId?: string | string[]; + userId?: string; height: string; - additionalEvents?: { - id: string; - title: string; - start: Date; - end: Date; - type: UserCalendarSchemaItem['type']; - userId?: string; - colorOverride?: string; - }[]; - className?: string; }) { return ( @@ -97,26 +67,10 @@ export default function Calendar({
)} > - {typeof userId === 'string' ? ( - - ) : Array.isArray(userId) && userId.length > 0 ? ( - + {userId ? ( + ) : ( - + )} )} @@ -127,21 +81,9 @@ 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< @@ -150,9 +92,9 @@ function CalendarWithUserEvents({ const [currentDate, setCurrentDate] = React.useState(new Date()); const router = useRouter(); - const { data, refetch, error, isError } = useGetApiCalendar( + const { data, refetch, error, isError } = useGetApiUserUserCalendar( + userId, { - userIds: [userId], start: moment(currentDate) .startOf( currentView === 'agenda' @@ -192,18 +134,9 @@ 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) => { - 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}`); - } + router.push(`/events/${event.id}`); }} onSelectSlot={(slotInfo) => { router.push( @@ -253,220 +176,59 @@ function CalendarWithUserEvents({ selectable={sesstion.data?.user?.id === userId} onEventDrop={(event) => { const { start, end, event: droppedEvent } = event; - if ( - droppedEvent.type === 'blocked_private' || - (droppedEvent.organizer && - droppedEvent.organizer !== sesstion.data?.user?.id) - ) - return; + if (droppedEvent.type === 'blocked_private') return; const startISO = new Date(start).toISOString(); const endISO = new Date(end).toISOString(); - if (droppedEvent.type === 'blocked_owned') { - patchBlockedSlot( - { - slotID: droppedEvent.id, - data: { - start_time: startISO, - end_time: endISO, - }, + patchEvent( + { + eventID: droppedEvent.id, + data: { + start_time: startISO, + end_time: endISO, }, - { - onSuccess: () => { - refetch(); - }, - onError: (error) => { - console.error('Error updating blocked slot:', error); - }, + }, + { + onSuccess: () => { + refetch(); }, - ); - return; - } else if (droppedEvent.type === 'event') { - patchEvent( - { - eventID: droppedEvent.id, - data: { - start_time: startISO, - end_time: endISO, - }, + onError: (error) => { + console.error('Error updating event:', error); }, - { - onSuccess: () => { - refetch(); - }, - onError: (error) => { - console.error('Error updating event:', error); - }, - }, - ); - } + }, + ); }} onEventResize={(event) => { const { start, end, event: resizedEvent } = event; - if ( - resizedEvent.type === 'blocked_private' || - (resizedEvent.organizer && - resizedEvent.organizer !== sesstion.data?.user?.id) - ) - return; + if (resizedEvent.type === 'blocked_private') 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; } - if (resizedEvent.type === 'blocked_owned') { - patchBlockedSlot( - { - slotID: resizedEvent.id, - data: { - start_time: startISO, - end_time: endISO, - }, + patchEvent( + { + eventID: resizedEvent.id, + data: { + start_time: startISO, + end_time: endISO, }, - { - onSuccess: () => { - refetch(); - }, - onError: (error) => { - console.error('Error resizing blocked slot:', error); - }, + }, + { + onSuccess: () => { + refetch(); }, - ); - return; - } else if (resizedEvent.type === 'event') { - patchEvent( - { - eventID: resizedEvent.id, - data: { - start_time: startISO, - end_time: endISO, - }, + onError: (error) => { + console.error('Error resizing event:', error); }, - { - onSuccess: () => { - refetch(); - }, - onError: (error) => { - console.error('Error resizing event:', error); - }, - }, - ); - } + }, + ); }} /> ); } -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; -}) { +function CalendarWithoutUserEvents({ height }: { height: string }) { const [currentView, setCurrentView] = React.useState< 'month' | 'week' | 'day' | 'agenda' | 'work_week' >('week'); @@ -474,8 +236,6 @@ function CalendarWithoutUserEvents({ return ( { setCurrentDate(date); }} - events={additionalEvents} /> ); } diff --git a/src/components/custom-toolbar.css b/src/components/custom-toolbar.css index 1230384..3fba69f 100644 --- a/src/components/custom-toolbar.css +++ b/src/components/custom-toolbar.css @@ -1,15 +1,11 @@ /* Container der Toolbar */ .custom-toolbar { display: flex; - gap: 8px; + flex-direction: column; + gap: 12px; padding: calc(var(--spacing) * 2); padding-left: calc(50px + var(--spacing)); - - @media (max-width: 870px) { - padding-left: 0; - flex-direction: column; - } - box-shadow: none; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; } @@ -29,12 +25,6 @@ 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 { @@ -87,8 +77,6 @@ border-radius: 11px; justify-items: center; align-items: center; - display: flex; - gap: 8px; } .custom-toolbar .navigation-controls .handleWeek button { @@ -105,9 +93,6 @@ padding: 0 8px; border-radius: 11px; justify-items: center; - display: flex; - justify-content: center; - align-items: center; } .right-section .datepicker-box { @@ -115,12 +100,8 @@ background-color: #c6c6c6; height: 36px; border-radius: 11px; - font-size: 14px; + font-size: 12px; 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 76e59ee..36c8fff 100644 --- a/src/components/custom-toolbar.tsx +++ b/src/components/custom-toolbar.tsx @@ -171,9 +171,12 @@ const CustomToolbar: React.FC = ({ }; return ( -
+
-
+
diff --git a/src/components/custom-ui/labeled-input.tsx b/src/components/custom-ui/labeled-input.tsx index 38a6c56..4746a31 100644 --- a/src/components/custom-ui/labeled-input.tsx +++ b/src/components/custom-ui/labeled-input.tsx @@ -1,64 +1,29 @@ 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' ? (