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() {
>