diff --git a/.env.example b/.env.example index 6f53284..1feabf8 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,4 @@ -DATABASE_URL= +DATABASE_URL="file:./dev.db" AUTH_SECRET= # Added by `npx auth`. Read more: https://cli.authjs.dev diff --git a/.gitignore b/.gitignore index 7b8da95..918f651 100644 --- a/.gitignore +++ b/.gitignore @@ -40,3 +40,8 @@ yarn-error.log* # typescript *.tsbuildinfo next-env.d.ts + +# database +/prisma/*.db* +src/generated/prisma +data diff --git a/Dockerfile b/Dockerfile index 5ffe24f..bc3e550 100644 --- a/Dockerfile +++ b/Dockerfile @@ -15,6 +15,7 @@ WORKDIR /app RUN corepack enable COPY --from=deps /app/node_modules ./node_modules COPY . . +RUN yarn prisma:generate RUN yarn build # ----- Runner ----- diff --git a/eslint.config.mjs b/eslint.config.mjs index 1ed31e4..b557f04 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -11,6 +11,9 @@ const compat = new FlatCompat({ const eslintConfig = [ ...compat.extends('next/core-web-vitals', 'next/typescript', 'prettier'), + { + ignores: ['src/generated/**', '.next/**', 'public/**'], + }, ]; export default eslintConfig; diff --git a/package.json b/package.json index 0cc1236..50b1b0f 100644 --- a/package.json +++ b/package.json @@ -7,14 +7,21 @@ "build": "prettier --check . && next build", "start": "next start", "lint": "next lint", - "format": "prettier --write ." + "format": "prettier --write .", + "prisma:migrate": "dotenv -e .env.local -- prisma migrate dev", + "prisma:generate": "dotenv -e .env.local -- prisma generate", + "prisma:studio": "dotenv -e .env.local -- prisma studio", + "prisma:db:push": "dotenv -e .env.local -- prisma db push", + "prisma:migrate:reset": "dotenv -e .env.local -- prisma migrate reset" }, "dependencies": { + "@auth/prisma-adapter": "^2.9.1", "@fortawesome/fontawesome-svg-core": "^6.7.2", "@fortawesome/free-brands-svg-icons": "^6.7.2", "@fortawesome/free-regular-svg-icons": "^6.7.2", "@fortawesome/free-solid-svg-icons": "^6.7.2", "@fortawesome/react-fontawesome": "^0.2.2", + "@prisma/client": "^6.8.2", "@radix-ui/react-dropdown-menu": "^2.1.14", "@radix-ui/react-hover-card": "^1.1.13", "@radix-ui/react-label": "^2.1.6", @@ -40,14 +47,15 @@ "@types/node": "22.15.19", "@types/react": "19.1.4", "@types/react-dom": "19.1.5", + "dotenv-cli": "8.0.0", "eslint": "9.27.0", "eslint-config-next": "15.3.2", "eslint-config-prettier": "10.1.5", "postcss": "8.5.3", "prettier": "3.5.3", "prisma": "6.8.2", - "tailwindcss": "4.1.7", - "tw-animate-css": "1.3.0", + "tailwindcss": "4.1.6", + "tw-animate-css": "1.2.9", "typescript": "5.8.3" }, "packageManager": "yarn@4.9.1" diff --git a/prisma/migrations/20250519192553_init/migration.sql b/prisma/migrations/20250519192553_init/migration.sql new file mode 100644 index 0000000..660babb --- /dev/null +++ b/prisma/migrations/20250519192553_init/migration.sql @@ -0,0 +1,298 @@ +-- CreateTable +CREATE TABLE "users" ( + "id" TEXT NOT NULL PRIMARY KEY, + "name" TEXT NOT NULL, + "first_name" TEXT, + "last_name" TEXT, + "email" TEXT NOT NULL, + "email_verified" DATETIME, + "password_hash" TEXT, + "image" TEXT, + "timezone" TEXT NOT NULL DEFAULT 'UTC', + "created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" DATETIME NOT NULL +); + +-- CreateTable +CREATE TABLE "accounts" ( + "id" TEXT NOT NULL PRIMARY KEY, + "user_id" TEXT NOT NULL, + "type" TEXT NOT NULL, + "provider" TEXT NOT NULL, + "provider_account_id" TEXT NOT NULL, + "refresh_token" TEXT, + "access_token" TEXT, + "expires_at" INTEGER, + "token_type" TEXT, + "scope" TEXT, + "id_token" TEXT, + "session_state" TEXT, + CONSTRAINT "accounts_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "sessions" ( + "id" TEXT NOT NULL PRIMARY KEY, + "session_token" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "expires" DATETIME NOT NULL, + CONSTRAINT "sessions_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "verification_tokens" ( + "identifier" TEXT NOT NULL, + "token" TEXT NOT NULL, + "expires" DATETIME NOT NULL +); + +-- CreateTable +CREATE TABLE "authenticators" ( + "user_id" TEXT NOT NULL, + "credential_id" TEXT NOT NULL, + "provider_account_id" TEXT NOT NULL, + "credential_public_key" TEXT NOT NULL, + "counter" INTEGER NOT NULL, + "credential_device_type" TEXT NOT NULL, + "credential_backed_up" BOOLEAN NOT NULL, + "transports" TEXT, + + PRIMARY KEY ("user_id", "credential_id"), + CONSTRAINT "authenticators_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "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 RESTRICT ON UPDATE CASCADE, + CONSTRAINT "friendships_user_id_2_fkey" FOREIGN KEY ("user_id_2") REFERENCES "users" ("id") ON DELETE RESTRICT ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "groups" ( + "id" TEXT NOT NULL PRIMARY KEY, + "name" TEXT NOT NULL, + "description" TEXT, + "creator_id" TEXT NOT NULL, + "created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" DATETIME NOT NULL, + CONSTRAINT "groups_creator_id_fkey" FOREIGN KEY ("creator_id") REFERENCES "users" ("id") ON DELETE RESTRICT ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "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 RESTRICT ON UPDATE CASCADE, + CONSTRAINT "group_members_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE RESTRICT ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "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 RESTRICT ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "meetings" ( + "id" TEXT NOT NULL PRIMARY KEY, + "title" TEXT NOT NULL, + "description" TEXT, + "start_time" DATETIME NOT NULL, + "end_time" DATETIME NOT NULL, + "organizer_id" TEXT NOT NULL, + "location" TEXT, + "status" TEXT NOT NULL DEFAULT 'CONFIRMED', + "created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" DATETIME NOT NULL, + CONSTRAINT "meetings_organizer_id_fkey" FOREIGN KEY ("organizer_id") REFERENCES "users" ("id") ON DELETE RESTRICT ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "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 RESTRICT ON UPDATE CASCADE, + CONSTRAINT "meeting_participants_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE RESTRICT ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "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 RESTRICT ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "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 RESTRICT ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "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 RESTRICT ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "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 RESTRICT ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "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 RESTRICT ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "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 RESTRICT ON UPDATE CASCADE +); + +-- CreateIndex +CREATE UNIQUE INDEX "users_name_key" ON "users"("name"); + +-- CreateIndex +CREATE UNIQUE INDEX "users_email_key" ON "users"("email"); + +-- CreateIndex +CREATE UNIQUE INDEX "accounts_provider_provider_account_id_key" ON "accounts"("provider", "provider_account_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "sessions_session_token_key" ON "sessions"("session_token"); + +-- CreateIndex +CREATE UNIQUE INDEX "verification_tokens_identifier_token_key" ON "verification_tokens"("identifier", "token"); + +-- CreateIndex +CREATE INDEX "idx_friendships_user2_status" ON "friendships"("user_id_2", "status"); + +-- CreateIndex +CREATE INDEX "groups_creator_id_idx" ON "groups"("creator_id"); + +-- CreateIndex +CREATE INDEX "group_members_user_id_idx" ON "group_members"("user_id"); + +-- CreateIndex +CREATE INDEX "blocked_slots_user_id_start_time_end_time_idx" ON "blocked_slots"("user_id", "start_time", "end_time"); + +-- CreateIndex +CREATE INDEX "blocked_slots_user_id_is_recurring_idx" ON "blocked_slots"("user_id", "is_recurring"); + +-- CreateIndex +CREATE INDEX "meetings_start_time_end_time_idx" ON "meetings"("start_time", "end_time"); + +-- CreateIndex +CREATE INDEX "meetings_organizer_id_idx" ON "meetings"("organizer_id"); + +-- CreateIndex +CREATE INDEX "meetings_status_idx" ON "meetings"("status"); + +-- CreateIndex +CREATE INDEX "idx_participants_user_status" ON "meeting_participants"("user_id", "status"); + +-- CreateIndex +CREATE INDEX "idx_notifications_user_read_time" ON "notifications"("user_id", "is_read", "created_at"); + +-- CreateIndex +CREATE INDEX "idx_email_queue_pending_jobs" ON "email_queue"("status", "scheduled_at"); + +-- CreateIndex +CREATE INDEX "idx_email_queue_user_history" ON "email_queue"("user_id", "created_at"); + +-- CreateIndex +CREATE UNIQUE INDEX "calendar_export_tokens_token_key" ON "calendar_export_tokens"("token"); + +-- CreateIndex +CREATE INDEX "calendar_export_tokens_user_id_idx" ON "calendar_export_tokens"("user_id"); + +-- CreateIndex +CREATE INDEX "calendar_subscriptions_user_id_is_enabled_idx" ON "calendar_subscriptions"("user_id", "is_enabled"); + +-- CreateIndex +CREATE INDEX "external_events_subscription_id_start_time_end_time_idx" ON "external_events"("subscription_id", "start_time", "end_time"); + +-- CreateIndex +CREATE INDEX "external_events_subscription_id_show_as_free_idx" ON "external_events"("subscription_id", "show_as_free"); + +-- CreateIndex +CREATE UNIQUE INDEX "external_events_subscription_id_ical_uid_key" ON "external_events"("subscription_id", "ical_uid"); diff --git a/prisma/migrations/migration_lock.toml b/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000..2a5a444 --- /dev/null +++ b/prisma/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (e.g., Git) +provider = "sqlite" diff --git a/prisma/schema.prisma b/prisma/schema.prisma index e8b9fe9..712a068 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -5,11 +5,355 @@ // Try Prisma Accelerate: https://pris.ly/cli/accelerate-init generator client { - provider = "prisma-client-js" - output = "../generated/prisma" + provider = "prisma-client-js" + output = "../src/generated/prisma" } datasource db { - provider = "postgresql" - url = env("DATABASE_URL") + provider = "sqlite" + url = env("DATABASE_URL") } + +enum participant_status { + PENDING + ACCEPTED + DECLINED + TENTATIVE +} + +enum meeting_status { + TENTATIVE + CONFIRMED + CANCELLED +} + +enum friendship_status { + PENDING + ACCEPTED + DECLINED + BLOCKED +} + +enum notification_type { + FRIEND_REQUEST + FRIEND_ACCEPT + MEETING_INVITE + MEETING_UPDATE + MEETING_CANCEL + MEETING_REMINDER + GROUP_MEMBER_ADDED + CALENDAR_SYNC_ERROR +} + +enum group_member_role { + ADMIN + MEMBER +} + +enum calendar_export_scope { + MEETINGS_ONLY + MEETINGS_AND_BLOCKED + BLOCKED_ONLY +} + +enum email_queue_status { + PENDING + PROCESSING + SENT + FAILED + CANCELLED +} + +model User { + id String @id @default(cuid()) + name String @unique + first_name String? + last_name String? + email String @unique + emailVerified DateTime? @map("email_verified") + password_hash String? + image String? + timezone String @default("UTC") + created_at DateTime @default(now()) + updated_at DateTime @updatedAt + + accounts Account[] + sessions Session[] + authenticators Authenticator[] + friendships1 Friendship[] @relation("FriendshipUser1") + friendships2 Friendship[] @relation("FriendshipUser2") + groupsCreated Group[] @relation("GroupCreator") + groupMembers GroupMember[] + blockedSlots BlockedSlot[] + meetingsOrg Meeting[] @relation("MeetingOrganizer") + meetingParts MeetingParticipant[] + notifications Notification[] + notifPrefs UserNotificationPreference[] + emailQueue EmailQueue[] + calendarTokens CalendarExportToken[] + subscriptions CalendarSubscription[] + + @@map("users") +} + +model Account { + id String @id @default(cuid()) + userId String @map("user_id") + type String + provider String + providerAccountId String @map("provider_account_id") + refresh_token String? + access_token String? + expires_at Int? + token_type String? + scope String? + id_token String? + session_state String? + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([provider, providerAccountId]) + @@map("accounts") +} + +model Session { + id String @id @default(cuid()) + sessionToken String @unique @map("session_token") + userId String @map("user_id") + expires DateTime + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@map("sessions") +} + +model VerificationToken { + identifier String + token String + expires DateTime + + @@unique([identifier, token]) + @@map("verification_tokens") +} + +model Authenticator { + user_id String + credential_id String + provider_account_id String + credential_public_key String + counter Int + credential_device_type String + credential_backed_up Boolean + transports String? + + user User @relation(fields: [user_id], references: [id], onDelete: Cascade) + + @@id([user_id, credential_id]) + @@map("authenticators") +} + +model Friendship { + user_id_1 String + user_id_2 String + status friendship_status @default(PENDING) + requested_at DateTime @default(now()) + accepted_at DateTime? + + user1 User @relation("FriendshipUser1", fields: [user_id_1], references: [id]) + user2 User @relation("FriendshipUser2", fields: [user_id_2], references: [id]) + + @@id([user_id_1, user_id_2]) + @@index([user_id_2, status], name: "idx_friendships_user2_status") + @@map("friendships") +} + +model Group { + id String @id @default(cuid()) + name String + description String? + creator_id String + created_at DateTime @default(now()) + updated_at DateTime @updatedAt + + creator User @relation("GroupCreator", fields: [creator_id], references: [id]) + members GroupMember[] + + @@index([creator_id]) + @@map("groups") +} + +model GroupMember { + group_id String + user_id String + role group_member_role @default(MEMBER) + added_at DateTime @default(now()) + + group Group @relation(fields: [group_id], references: [id]) + user User @relation(fields: [user_id], references: [id]) + + @@id([group_id, user_id]) + @@index([user_id]) + @@map("group_members") +} + +model BlockedSlot { + id String @id @default(cuid()) + user_id String + start_time DateTime + end_time DateTime + reason String? + is_recurring Boolean @default(false) + rrule String? + recurrence_end_date DateTime? + created_at DateTime @default(now()) + updated_at DateTime @updatedAt + + user User @relation(fields: [user_id], references: [id]) + + @@index([user_id, start_time, end_time]) + @@index([user_id, is_recurring]) + @@map("blocked_slots") +} + +model Meeting { + id String @id @default(cuid()) + title String + description String? + start_time DateTime + end_time DateTime + organizer_id String + location String? + status meeting_status @default(CONFIRMED) + created_at DateTime @default(now()) + updated_at DateTime @updatedAt + + organizer User @relation("MeetingOrganizer", fields: [organizer_id], references: [id]) + participants MeetingParticipant[] + + @@index([start_time, end_time]) + @@index([organizer_id]) + @@index([status]) + @@map("meetings") +} + +model MeetingParticipant { + meeting_id String + user_id String + status participant_status @default(PENDING) + added_at DateTime @default(now()) + + meeting Meeting @relation(fields: [meeting_id], references: [id]) + user User @relation(fields: [user_id], references: [id]) + + @@id([meeting_id, user_id]) + @@index([user_id, status], name: "idx_participants_user_status") + @@map("meeting_participants") +} + +model Notification { + id String @id @default(cuid()) + user_id String + type notification_type + related_entity_type String? + related_entity_id String? + message String + is_read Boolean @default(false) + created_at DateTime @default(now()) + + user User @relation(fields: [user_id], references: [id]) + + @@index([user_id, is_read, created_at], name: "idx_notifications_user_read_time") + @@map("notifications") +} + +model UserNotificationPreference { + user_id String + notification_type notification_type + email_enabled Boolean @default(false) + updated_at DateTime @default(now()) + + user User @relation(fields: [user_id], references: [id]) + + @@id([user_id, notification_type]) + @@map("user_notification_preferences") +} + +model EmailQueue { + id String @id @default(cuid()) + user_id String + subject String + body_html String + body_text String? + status email_queue_status @default(PENDING) + scheduled_at DateTime @default(now()) + attempts Int @default(0) + last_attempt_at DateTime? + sent_at DateTime? + error_message String? + created_at DateTime @default(now()) + updated_at DateTime @updatedAt + + user User @relation(fields: [user_id], references: [id]) + + @@index([status, scheduled_at], name: "idx_email_queue_pending_jobs") + @@index([user_id, created_at], name: "idx_email_queue_user_history") + @@map("email_queue") +} + +model CalendarExportToken { + id String @id @default(cuid()) + user_id String + token String @unique + scope calendar_export_scope @default(MEETINGS_ONLY) + is_active Boolean @default(true) + created_at DateTime @default(now()) + last_accessed_at DateTime? + + user User @relation(fields: [user_id], references: [id]) + + @@index([user_id]) + @@map("calendar_export_tokens") +} + +model CalendarSubscription { + id String @id @default(cuid()) + user_id String + feed_url String + name String? + color String? + is_enabled Boolean @default(true) + last_synced_at DateTime? + last_sync_error String? + sync_frequency_minutes Int? @default(60) + created_at DateTime @default(now()) + updated_at DateTime @updatedAt + + user User @relation(fields: [user_id], references: [id]) + externalEvents ExternalEvent[] + + @@index([user_id, is_enabled]) + @@map("calendar_subscriptions") +} + +model ExternalEvent { + id String @id @default(cuid()) + subscription_id String + ical_uid String + summary String? + description String? + start_time DateTime + end_time DateTime + is_all_day Boolean @default(false) + location String? + rrule String? + dtstamp DateTime? + sequence Int? + show_as_free Boolean @default(false) + last_fetched_at DateTime @default(now()) + + subscription CalendarSubscription @relation(fields: [subscription_id], references: [id]) + + @@unique([subscription_id, ical_uid], name: "uq_external_event_sub_uid") + @@index([subscription_id, start_time, end_time]) + @@index([subscription_id, show_as_free]) + @@map("external_events") +} \ No newline at end of file diff --git a/src/auth.ts b/src/auth.ts index 09a5065..38ec47a 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -5,6 +5,9 @@ import Credentials from 'next-auth/providers/credentials'; import Authentik from 'next-auth/providers/authentik'; +import { PrismaAdapter } from '@auth/prisma-adapter'; +import { prisma } from '@/prisma'; + const providers: Provider[] = [ !process.env.DISABLE_PASSWORD_LOGIN && Credentials({ @@ -34,6 +37,7 @@ export const providerMap = providers export const { handlers, signIn, signOut, auth } = NextAuth({ providers, + adapter: PrismaAdapter(prisma), session: { strategy: 'jwt', }, diff --git a/src/prisma.ts b/src/prisma.ts new file mode 100644 index 0000000..f7a28d8 --- /dev/null +++ b/src/prisma.ts @@ -0,0 +1,7 @@ +import { PrismaClient } from '@/generated/prisma'; + +const globalForPrisma = globalThis as unknown as { prisma: PrismaClient }; + +export const prisma = globalForPrisma.prisma || new PrismaClient(); + +if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma; diff --git a/yarn.lock b/yarn.lock index fdfb635..085be3d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -46,7 +46,18 @@ __metadata: languageName: node linkType: hard -"@emnapi/core@npm:^1.4.0, @emnapi/core@npm:^1.4.3": +"@auth/prisma-adapter@npm:^2.9.1": + version: 2.9.1 + resolution: "@auth/prisma-adapter@npm:2.9.1" + dependencies: + "@auth/core": "npm:0.39.1" + peerDependencies: + "@prisma/client": ">=2.26.0 || >=3 || >=4 || >=5 || >=6" + checksum: 10c0/615ee7c02f690e35ccac8206607a4345ca6455c322a741ddd4dbd52e7a068ace9e5c46c4d8a50fe471ca2c2fb6a5b4bb9924cbd2e911613f671fe929c33278d4 + languageName: node + linkType: hard + +"@emnapi/core@npm:^1.4.3": version: 1.4.3 resolution: "@emnapi/core@npm:1.4.3" dependencies: @@ -525,13 +536,13 @@ __metadata: linkType: hard "@napi-rs/wasm-runtime@npm:^0.2.9": - version: 0.2.9 - resolution: "@napi-rs/wasm-runtime@npm:0.2.9" + version: 0.2.10 + resolution: "@napi-rs/wasm-runtime@npm:0.2.10" dependencies: - "@emnapi/core": "npm:^1.4.0" - "@emnapi/runtime": "npm:^1.4.0" + "@emnapi/core": "npm:^1.4.3" + "@emnapi/runtime": "npm:^1.4.3" "@tybys/wasm-util": "npm:^0.9.0" - checksum: 10c0/1cc40b854b255f84e12ade634456ba489f6bf90659ef8164a16823c515c294024c96ee2bb81ab51f35493ba9496f62842b960f915dbdcdc1791f221f989e9e59 + checksum: 10c0/4dce9bbb94a8969805574e1b55fdbeb7623348190265d77f6507ba32e535610deeb53a33ba0bb8b05a6520f379d418b92e8a01c5cd7b9486b136d2c0c26be0bd languageName: node linkType: hard @@ -648,6 +659,21 @@ __metadata: languageName: node linkType: hard +"@prisma/client@npm:^6.8.2": + version: 6.8.2 + resolution: "@prisma/client@npm:6.8.2" + peerDependencies: + prisma: "*" + typescript: ">=5.1.0" + peerDependenciesMeta: + prisma: + optional: true + typescript: + optional: true + checksum: 10c0/5ce12e4a83ece542df80195f6642e24224de8f94c2f2fd26527ca6b3a2573ea9798b5d23eac8518d8f268be19f3856516ad4e08c9bc0a4df4356a5faab879db6 + languageName: node + linkType: hard + "@prisma/config@npm:6.8.2": version: 6.8.2 resolution: "@prisma/config@npm:6.8.2" @@ -1915,11 +1941,11 @@ __metadata: linkType: hard "aria-hidden@npm:^1.2.4": - version: 1.2.4 - resolution: "aria-hidden@npm:1.2.4" + version: 1.2.6 + resolution: "aria-hidden@npm:1.2.6" dependencies: tslib: "npm:^2.0.0" - checksum: 10c0/8abcab2e1432efc4db415e97cb3959649ddf52c8fc815d7384f43f3d3abf56f1c12852575d00df9a8927f421d7e0712652dd5f8db244ea57634344e29ecfc74a + checksum: 10c0/7720cb539497a9f760f68f98a4b30f22c6767aa0e72fa7d58279f7c164e258fc38b2699828f8de881aab0fc8e9c56d1313a3f1a965046fc0381a554dbc72b54a languageName: node linkType: hard @@ -2376,6 +2402,34 @@ __metadata: languageName: node linkType: hard +"dotenv-cli@npm:8.0.0": + version: 8.0.0 + resolution: "dotenv-cli@npm:8.0.0" + dependencies: + cross-spawn: "npm:^7.0.6" + dotenv: "npm:^16.3.0" + dotenv-expand: "npm:^10.0.0" + minimist: "npm:^1.2.6" + bin: + dotenv: cli.js + checksum: 10c0/000469632758b7b44aaaa80cbbbd7f0c94dc170ec02e51aa8d8280341a0108fb7407954c23054257b77235b064033efdb8745836633eb6fd1586924953cf0528 + languageName: node + linkType: hard + +"dotenv-expand@npm:^10.0.0": + version: 10.0.0 + resolution: "dotenv-expand@npm:10.0.0" + checksum: 10c0/298f5018e29cfdcb0b5f463ba8e8627749103fbcf6cf81c561119115754ed582deee37b49dfc7253028aaba875ab7aea5fa90e5dac88e511d009ab0e6677924e + languageName: node + linkType: hard + +"dotenv@npm:^16.3.0": + version: 16.5.0 + resolution: "dotenv@npm:16.5.0" + checksum: 10c0/5bc94c919fbd955bf0ba44d33922a1e93d1078e64a1db5c30faeded1d996e7a83c55332cb8ea4fae5a9ca4d0be44cbceb95c5811e70f9f095298df09d1997dd9 + languageName: node + linkType: hard + "dunder-proto@npm:^1.0.0, dunder-proto@npm:^1.0.1": version: 1.0.1 resolution: "dunder-proto@npm:1.0.1" @@ -3747,12 +3801,14 @@ __metadata: version: 0.0.0-use.local resolution: "meetup@workspace:." dependencies: + "@auth/prisma-adapter": "npm:^2.9.1" "@eslint/eslintrc": "npm:3.3.1" "@fortawesome/fontawesome-svg-core": "npm:^6.7.2" "@fortawesome/free-brands-svg-icons": "npm:^6.7.2" "@fortawesome/free-regular-svg-icons": "npm:^6.7.2" "@fortawesome/free-solid-svg-icons": "npm:^6.7.2" "@fortawesome/react-fontawesome": "npm:^0.2.2" + "@prisma/client": "npm:^6.8.2" "@radix-ui/react-dropdown-menu": "npm:^2.1.14" "@radix-ui/react-hover-card": "npm:^1.1.13" "@radix-ui/react-label": "npm:^2.1.6" @@ -3768,6 +3824,7 @@ __metadata: "@types/react-dom": "npm:19.1.5" class-variance-authority: "npm:^0.7.1" clsx: "npm:^2.1.1" + dotenv-cli: "npm:8.0.0" eslint: "npm:9.27.0" eslint-config-next: "npm:15.3.2" eslint-config-prettier: "npm:10.1.5" @@ -3781,8 +3838,8 @@ __metadata: react: "npm:^19.0.0" react-dom: "npm:^19.0.0" tailwind-merge: "npm:^3.2.0" - tailwindcss: "npm:4.1.7" - tw-animate-css: "npm:1.3.0" + tailwindcss: "npm:4.1.6" + tw-animate-css: "npm:1.2.9" typescript: "npm:5.8.3" languageName: unknown linkType: soft @@ -4300,8 +4357,8 @@ __metadata: linkType: hard "react-remove-scroll@npm:^2.6.3": - version: 2.6.3 - resolution: "react-remove-scroll@npm:2.6.3" + version: 2.7.0 + resolution: "react-remove-scroll@npm:2.7.0" dependencies: react-remove-scroll-bar: "npm:^2.3.7" react-style-singleton: "npm:^2.2.3" @@ -4314,7 +4371,7 @@ __metadata: peerDependenciesMeta: "@types/react": optional: true - checksum: 10c0/068e9704ff26816fffc4c8903e2c6c8df7291ee08615d7c1ab0cf8751f7080e2c5a5d78ef5d908b11b9cfc189f176d312e44cb02ea291ca0466d8283b479b438 + checksum: 10c0/cf8b9a1b0808cafe9f2b1fbb2ab56e3efff7f311fba847f26154b972a681c003c288af517cf48d0b68704c2be0d3d73152e7ec2cc8590fa495135b0aac07a871 languageName: node linkType: hard @@ -4848,6 +4905,13 @@ __metadata: languageName: node linkType: hard +"tailwindcss@npm:4.1.6": + version: 4.1.6 + resolution: "tailwindcss@npm:4.1.6" + checksum: 10c0/de5a442d23d20872bfccb2b451720e8031dde27e1984a1ccb1d8c7308915213f1b3b62724ce5176d1b7031fbedc6a75fe844b73e3f13443141c11739447d8808 + languageName: node + linkType: hard + "tailwindcss@npm:4.1.7": version: 4.1.7 resolution: "tailwindcss@npm:4.1.7" @@ -4923,10 +4987,10 @@ __metadata: languageName: node linkType: hard -"tw-animate-css@npm:1.3.0": - version: 1.3.0 - resolution: "tw-animate-css@npm:1.3.0" - checksum: 10c0/c72a2c189d6aebd6cc31189c3ca1cf4cf2c3f37f005d0879cd40cfdd6550bfb665384e9a50b91dfc9befe9860ff09adb536a7f7431bf18132aef7e04734a02f2 +"tw-animate-css@npm:1.2.9": + version: 1.2.9 + resolution: "tw-animate-css@npm:1.2.9" + checksum: 10c0/ffbd6dfbb34490a8752b185b40192168ce4bfa69949d153dc831d9471dec2758501f27f1784b6615de7da73a546d01d7201ed691f5220fe3e0a53694cb275a3a languageName: node linkType: hard