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..b94444f 100644 --- a/.gitignore +++ b/.gitignore @@ -40,3 +40,7 @@ yarn-error.log* # typescript *.tsbuildinfo next-env.d.ts + +# database +/prisma/dev.db +src/generated/prisma \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 2711500..bada821 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 28d2782..03448ab 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.7.0", "@radix-ui/react-dropdown-menu": "^2.1.14", "@radix-ui/react-hover-card": "^1.1.13", "@radix-ui/react-label": "^2.1.6", @@ -37,9 +44,10 @@ "devDependencies": { "@eslint/eslintrc": "3.3.1", "@tailwindcss/postcss": "4.1.6", - "@types/node": "22.15.17", + "@types/node": "22.15.18", "@types/react": "19.1.4", "@types/react-dom": "19.1.5", + "dotenv-cli": "8.0.0", "eslint": "9.26.0", "eslint-config-next": "15.3.2", "eslint-config-prettier": "10.1.5", 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/components/user/login-form.tsx b/src/components/user/login-form.tsx index 2869930..7c44f6f 100644 --- a/src/components/user/login-form.tsx +++ b/src/components/user/login-form.tsx @@ -24,14 +24,14 @@ export default function LoginForm() { >