diff --git a/apps/backend/.env.example b/apps/backend/.env.example index 8b4fb8f..3a06056 100644 --- a/apps/backend/.env.example +++ b/apps/backend/.env.example @@ -1,5 +1,6 @@ # Node Environment NODE_ENV=development +RESET_DB=false PORT=5000 CORS_ORIGIN=http://localhost:3000 diff --git a/apps/backend/src/config/index.ts b/apps/backend/src/config/index.ts index 5f2a7bd..44fbee9 100644 --- a/apps/backend/src/config/index.ts +++ b/apps/backend/src/config/index.ts @@ -9,6 +9,8 @@ const environmentVariableSchema = z.object({ // Node Environment NODE_ENV: z.enum(["development", "test", "production"]), + RESET_DB: z.coerce.boolean(), + PORT: z.coerce.number().positive().int().default(5000), CORS_ORIGIN: z.string().default("http://localhost:3000"), diff --git a/apps/backend/src/index.ts b/apps/backend/src/index.ts index 246a9c4..5cec363 100644 --- a/apps/backend/src/index.ts +++ b/apps/backend/src/index.ts @@ -1,9 +1,15 @@ import { createServer } from "@repo/backend/server"; import envValidate from "@repo/backend/config"; -import { dbTestConnection } from "@repo/database"; +import { dbPush, dbReset, dbSeed, dbWaitForConnection } from "@repo/database"; await envValidate(); -await dbTestConnection(); +await dbWaitForConnection(); + +if (process.env.NODE_ENV === "development" && process.env.RESET_DB === true) { + await dbPush(); + await dbReset(); + await dbSeed(); +} const server = createServer(); server.listen(process.env.PORT, () => { diff --git a/apps/backend/turbo.json b/apps/backend/turbo.json index 7fdd780..1c02cf7 100644 --- a/apps/backend/turbo.json +++ b/apps/backend/turbo.json @@ -4,6 +4,7 @@ "build": { "env": [ "NODE_ENV", + "RESET_DB", "EXPRESS_ENV", "PORT", "CORS_ORIGIN", diff --git a/packages/database/drizzle.config.ts b/packages/database/drizzle.config.ts index 6c85a0a..ffe4106 100644 --- a/packages/database/drizzle.config.ts +++ b/packages/database/drizzle.config.ts @@ -2,7 +2,7 @@ import { defineConfig } from "drizzle-kit"; export default defineConfig({ out: "./drizzle", - schema: "./src/schema/*.ts", + schema: "./src/schema/index.ts", dialect: "postgresql", dbCredentials: { host: process.env.POSTGRES_HOST!, @@ -10,5 +10,6 @@ export default defineConfig({ database: process.env.POSTGRES_DATABASE!, user: process.env.POSTGRES_USER!, password: process.env.POSTGRES_PASSWORD!, + ssl: process.env.NODE_ENV === "production" ? true : false, }, }); diff --git a/packages/database/package.json b/packages/database/package.json index 4573e1f..008b3ee 100644 --- a/packages/database/package.json +++ b/packages/database/package.json @@ -8,6 +8,7 @@ "./schema": "./src/schema/index.ts" }, "scripts": { + "predev": "drizzle-kit studio &", "dev": "docker compose -f ../../docker-compose.yml --profile postgres up", "lint:types": "tsc --noEmit", "lint:check": "eslint --max-warnings=0", @@ -21,9 +22,11 @@ "drizzle-orm": ">=0.40.0" }, "dependencies": { + "drizzle-seed": "^0.3.1", "pg": "^8.13.3" }, "devDependencies": { + "@faker-js/faker": "^9.6.0", "@repo/eslint-config": "*", "@repo/typescript-config": "*", "@types/pg": "^8.11.11", diff --git a/packages/database/src/index.ts b/packages/database/src/index.ts index aff3887..6b3e712 100644 --- a/packages/database/src/index.ts +++ b/packages/database/src/index.ts @@ -1,4 +1,8 @@ +import { execSync } from "node:child_process"; +import { faker } from "@faker-js/faker"; import { drizzle } from "drizzle-orm/node-postgres"; +import { reset } from "drizzle-seed"; +import { sql } from "drizzle-orm"; import * as schema from "./schema"; const db = drizzle({ @@ -13,13 +17,442 @@ const db = drizzle({ }, }); -const dbTestConnection = async () => { +type DbType = typeof db; + +const dbWaitForConnection = async (maxRetries = 5, delayMs = 2000) => { + let retries = 0; + + while (true) { + try { + const result = await db.execute("SELECT NOW()"); + console.log("✅ PostgreSQL connected:", result.rows[0].now); + return; + } catch (err) { + retries++; + console.error( + `❌ PostgreSQL connection error (attempt ${retries}):`, + err + ); + + if (retries >= maxRetries) { + console.error("❌ Max retries reached. Exiting..."); + process.exit(1); + } + + await new Promise((resolve) => setTimeout(resolve, delayMs)); + } + } +}; + +const dbPush = async (dbToPush?: DbType) => { + dbToPush ??= db; + execSync("npm run db:push -w @repo/database", { + env: process.env, + stdio: "inherit", + }); +}; + +const dbReset = async (dbToReset?: DbType) => { + dbToReset ??= db; + await reset(dbToReset, schema); +}; + +const dbSeed = async (dbToSeed?: DbType) => { + dbToSeed ??= db; + + const NUM_USERS = 100; + const NUM_CHATS = 50; + const MAX_MESSAGES_PER_CHAT = 50; + const FRIENDSHIP_DENSITY = 0.1; // 10% chance of friendship between any two users + const BLOCK_DENSITY = 0.02; // 2% chance of blocking + try { - const result = await db.execute("SELECT NOW()"); - console.log("✅ PostgreSQL connected:", result.rows[0].now); - } catch (err) { - console.error("❌ PostgreSQL connection error:", err); + // 1. Generate Users + console.log("Generating users..."); + const userRecords = []; + + faker.seed(2000); + + for (let i = 0; i < NUM_USERS; i++) { + const user = { + phoneNumber: faker.string.numeric({ length: 10 }), + displayName: faker.person.fullName(), + profilePicture: faker.image.avatar(), + about: faker.lorem.sentence(), + status: faker.datatype.boolean(), + lastSeen: faker.date.recent(), + }; + userRecords.push(user); + } + + const insertedUsers = await db + .insert(schema.users) + .values(userRecords) + .returning(); + console.log(`Inserted ${insertedUsers.length} users`); + + // 2. Create Privacy Settings + console.log("Generating privacy settings..."); + const privacySettings = insertedUsers.map((user) => ({ + userId: user.id, + showOnlineStatus: faker.datatype.boolean(0.8), // 80% true + showLastSeen: faker.datatype.boolean(0.7), // 70% true + showReadReceipts: faker.datatype.boolean(0.6), // 60% true + })); + + await db.insert(schema.userPrivacySettings).values(privacySettings); + console.log(`Created privacy settings for ${privacySettings.length} users`); + + // 3. Create Friendships + console.log("Generating friendships..."); + const friendships = []; + + for (let i = 0; i < insertedUsers.length; i++) { + for (let j = i + 1; j < insertedUsers.length; j++) { + if (Math.random() < FRIENDSHIP_DENSITY) { + const statuses = ["pending", "accepted", "denied"]; + const status = faker.helpers.arrayElement( + statuses + ) as (typeof schema.friendStatusEnum.enumValues)[number]; + + friendships.push({ + userA: insertedUsers[i].id, + userB: insertedUsers[j].id, + status, + }); + } + } + } + + await db.insert(schema.friends).values(friendships); + console.log(`Created ${friendships.length} friendships`); + + // 4. Create Blocked Users + console.log("Generating blocked users..."); + const blockedUserRecords = []; + + for (let i = 0; i < insertedUsers.length; i++) { + for (let j = 0; j < insertedUsers.length; j++) { + if (i !== j && Math.random() < BLOCK_DENSITY) { + blockedUserRecords.push({ + blockerId: insertedUsers[i].id, + blockedId: insertedUsers[j].id, + }); + } + } + } + + await db.insert(schema.blockedUsers).values(blockedUserRecords); + console.log(`Created ${blockedUserRecords.length} blocked relationships`); + + // 5. Create Chats + console.log("Generating chats..."); + const chatRecords = []; + + // Direct chats + for (let i = 0; i < NUM_CHATS * 0.7; i++) { + chatRecords.push({ + type: "direct" as (typeof schema.chatTypeEnum.enumValues)[number], + name: "Direct Chat", + description: "", + picture: "", + }); + } + + // Group chats + for (let i = 0; i < NUM_CHATS * 0.3; i++) { + chatRecords.push({ + type: "group" as (typeof schema.chatTypeEnum.enumValues)[number], + name: faker.word.adjective() + " " + faker.word.noun() + " Group", + description: faker.lorem.sentence(), + picture: faker.image.url(), + }); + } + + const insertedChats = await db + .insert(schema.chats) + .values(chatRecords) + .returning(); + console.log(`Created ${insertedChats.length} chats`); + + // 6. Add Chat Participants + console.log("Adding chat participants..."); + + const chatParticipantRecords: { + userId: number; + chatId: number; + role: (typeof schema.userRoleEnum.enumValues)[number]; + joinedAt: Date; + }[] = []; + + for (const chat of insertedChats) { + if (chat.type === "direct") { + // For direct chats, add exactly 2 participants + const participants = faker.helpers.arrayElements(insertedUsers, 2); + + participants.forEach((user) => { + chatParticipantRecords.push({ + userId: user.id, + chatId: chat.id, + role: "user" as (typeof schema.userRoleEnum.enumValues)[number], + joinedAt: faker.date.recent(), + }); + }); + } else { + // For group chats, add 3-10 participants + const numParticipants = faker.number.int({ min: 3, max: 10 }); + const participants = faker.helpers.arrayElements( + insertedUsers, + numParticipants + ); + + // First user is admin, rest are normal users + participants.forEach((user, index) => { + chatParticipantRecords.push({ + userId: user.id, + chatId: chat.id, + role: + index === 0 + ? "admin" + : ("user" as (typeof schema.userRoleEnum.enumValues)[number]), + joinedAt: faker.date.recent({ days: 30 }), + }); + }); + } + } + + await db.insert(schema.chatParticipants).values(chatParticipantRecords); + console.log(`Added ${chatParticipantRecords.length} chat participants`); + + // 7. Generate Messages + console.log("Generating messages..."); + const messageRecords = []; + + for (const chat of insertedChats) { + // Get participants for this chat + const chatParticipants = await db + .select() + .from(schema.chatParticipants) + .where(() => sql`${schema.chatParticipants.chatId} = ${chat.id}`); + + if (chatParticipants.length === 0) continue; + + // Generate random number of messages for this chat + const numMessages = faker.number.int({ + min: 1, + max: MAX_MESSAGES_PER_CHAT, + }); + + // Messages need to be in chronological order for the conversation to make sense + let currentDate = faker.date.past({ years: 0.5 }); + + for (let i = 0; i < numMessages; i++) { + // Move time forward a bit for each message + currentDate = new Date( + currentDate.getTime() + + faker.number.int({ min: 60000, max: 86400000 }) + ); // 1 min to 1 day later + + const sender = faker.helpers.arrayElement(chatParticipants); + messageRecords.push({ + chatId: chat.id, + senderId: sender.userId, + content: faker.lorem.paragraph(), + sentAt: currentDate, + }); + } + } + + const insertedMessages = await db + .insert(schema.messages) + .values(messageRecords) + .returning(); + console.log(`Created ${insertedMessages.length} messages`); + + // 8. Generate Message Read Receipts + console.log("Generating message read receipts..."); + const readReceiptRecords = []; + + for (const message of insertedMessages) { + // Get chat participants except the sender + const participants = await db + .select() + .from(schema.chatParticipants) + .where( + () => + sql`${schema.chatParticipants.chatId} = ${message.chatId} AND ${schema.chatParticipants.userId} != ${message.senderId}` + ); + + for (const participant of participants) { + const statuses = ["sent", "delivered", "read"] as Array< + (typeof schema.messageStatusEnum.enumValues)[number] + >; + const status = faker.helpers.arrayElement(statuses); + readReceiptRecords.push({ + chatId: message.chatId, + messageId: message.id, + userId: participant.userId, + readAt: + status === "read" + ? faker.date.between({ + from: message.sentAt, + to: new Date( + message.sentAt.getTime() + + faker.number.int({ min: 60000, max: 86400000 }) + ), + }) + : null, + status, + }); + } + } + + await db.insert(schema.messageReadReceipts).values(readReceiptRecords); + console.log(`Created ${readReceiptRecords.length} read receipts`); + + // 9. Generate OTPs + // console.log("Generating OTPs..."); + // const otpRecords = insertedUsers.map((user) => ({ + // phoneNumber: user.phoneNumber, + // otp: faker.string.numeric(6), + // expiresAt: faker.date.soon({ days: 1 }), + // })); + + // await db.insert(schema.otps).values(otpRecords); + // console.log(`Created ${otpRecords.length} OTP records`); + + // 10. Generate Refresh Tokens + // console.log("Generating refresh tokens..."); + // const refreshTokenRecords = []; + + // for (let i = 0; i < insertedUsers.length * 0.7; i++) { + // const user = insertedUsers[i]; + // refreshTokenRecords.push({ + // userId: user.id, + // token: faker.string.alphanumeric(64), + // }); + // } + + // await db.insert(schema.refreshTokens).values(refreshTokenRecords); + // console.log(`Created ${refreshTokenRecords.length} refresh tokens`); + + console.log("Database seeding completed successfully!"); + } catch (error) { + console.error("Error seeding database:", error); + throw error; } + + // for (let i = 1; i <= 10; i++) { + // const phoneNumber = faker.string.alphanumeric({ length: 10 }); + + // dbToSeed.transaction(async (trx) => { + // await trx.insert(schema.users).values({ + // phoneNumber: phoneNumber, + // displayName: faker.person.firstName(), + // profilePicture: faker.image.avatar(), + // about: faker.lorem.paragraph(), + // status: faker.datatype.boolean(), + // lastSeen: faker.date.recent(), + // }); + + // await trx.insert(schema.otps).values({ + // phoneNumber: phoneNumber, + // otp: faker.string.alphanumeric({ length: 6 }), + // expiresAt: faker.date.future(), + // }); + + // const user = await trx.query.users.findFirst({ + // where: eq(schema.users.phoneNumber, phoneNumber), + // }); + + // await trx.insert(schema.refreshTokens).values({ + // userId: user!.id, + // token: faker.string.alphanumeric({ length: 6 }), + // }); + + // await trx.insert(schema.friends).values({ + // userA: user!.id, + // userB: faker.number.int({ min: i + 1, max: 10 }), + // status: faker.helpers.arrayElement(["pending", "accepted", "denied"]), + // }); + + // await trx.insert(schema.blockedUsers).values({ + // blockerId: i, + // blockedId: faker.number.int({ min: i + 1, max: 10 }), + // }); + + // await trx.insert(schema.userPrivacySettings).values({ + // userId: i, + // showOnlineStatus: faker.datatype.boolean(), + // showLastSeen: faker.datatype.boolean(), + // showReadReceipts: faker.datatype.boolean(), + // }); + // }); + // } + + // for (let i = 1; i <= 10; i++) { + // await dbToSeed.insert(schema.chats).values({ + // type: faker.helpers.arrayElement(["direct", "group"]), + // name: faker.lorem.word(), + // description: faker.lorem.words(), + // picture: faker.image.url(), + // }); + + // await dbToSeed.insert(schema.messages).values({ + // chatId: i, + // senderId: faker.number.int({ min: 1, max: 10 }), + // content: faker.lorem.paragraph(), + // sentAt: faker.date.recent(), + // }); + + // await dbToSeed.insert(schema.chatParticipants).values({ + // userId: faker.number.int({ min: 1, max: 10 }), + // chatId: i, + // role: faker.helpers.arrayElement(["user", "admin"]), + // joinedAt: faker.date.recent(), + // }); + // } + + // await seed(dbToSeed, schema).refine((f) => ({ + // users: { + // columns: { + // phoneNumber: f.phoneNumber({ template: "##########" }), + // about: f.loremIpsum(), + // profilePicture: f.valuesFromArray({ + // values: images, + // }), + // }, + // }, + // otps: { + // columns: { + // id: f.intPrimaryKey(), + // phoneNumber: f.phoneNumber({ template: "##########" }), + // otp: f.valuesFromArray({ values: ["123456", "234567", "345678"] }), + // expiresAt: f.date({ + // minDate: new Date(Date.now() + 5 * 60 * 1000), + // maxDate: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), + // }), + // }, + // }, + // chats: { + // columns: { + // description: f.loremIpsum(), + // picture: f.valuesFromArray({ + // values: images, + // }), + // }, + // }, + // messages: { + // columns: { + // content: f.loremIpsum(), + // }, + // }, + // refreshTokens: { + // columns: { + // userId: f.intPrimaryKey(), + // }, + // }, + // })); }; -export { db, dbTestConnection }; +export { db, dbWaitForConnection, dbPush, dbReset, dbSeed }; diff --git a/packages/database/src/schema/auth.ts b/packages/database/src/schema/auth.ts index 493cf9e..7b19955 100644 --- a/packages/database/src/schema/auth.ts +++ b/packages/database/src/schema/auth.ts @@ -10,7 +10,7 @@ import { timeStamps } from "./base"; export const otps = pgTable("otps", { id: integer().primaryKey().generatedAlwaysAsIdentity(), - phoneNumber: varchar({ length: 15 }).notNull(), + phoneNumber: varchar({ length: 15 }).unique().notNull(), otp: varchar({ length: 6 }).notNull(), expiresAt: timestamp().notNull(), ...timeStamps(false), diff --git a/packages/database/src/schema/chats.ts b/packages/database/src/schema/chats.ts index 445b799..7f41615 100644 --- a/packages/database/src/schema/chats.ts +++ b/packages/database/src/schema/chats.ts @@ -6,7 +6,7 @@ import { users } from "./users"; export const chats = pgTable("chats", { id: integer().primaryKey().generatedAlwaysAsIdentity(), type: chatTypeEnum().notNull().default("direct"), - name: text().unique().notNull(), + name: text().notNull(), description: text().notNull(), picture: text().notNull(), ...timeStamps(false), diff --git a/packages/database/src/schema/realtions.ts b/packages/database/src/schema/realtions.ts index 480ea2d..0463a6b 100644 --- a/packages/database/src/schema/realtions.ts +++ b/packages/database/src/schema/realtions.ts @@ -6,9 +6,9 @@ import { messageReadReceipts, messages } from "./messages"; export const usersRealtions = relations(users, ({ one, many }) => ({ userPrivacySettings: one(userPrivacySettings), - chats: many(chats), - friends: many(friends), - blockedUsers: many(blockedUsers), + friendsSent: many(friends, { relationName: "friendsSent" }), + friendsReceived: many(friends, { relationName: "friendsReceived" }), + blockedUsers: many(blockedUsers, { relationName: "blocked" }), chatParticipants: many(chatParticipants), })); @@ -26,12 +26,12 @@ export const friendsRealtions = relations(friends, ({ one }) => ({ userA: one(users, { fields: [friends.userA], references: [users.id], - relationName: "userA", + relationName: "friendsSent", }), userB: one(users, { fields: [friends.userB], references: [users.id], - relationName: "userB", + relationName: "friendsReceived", }), })); diff --git a/packages/database/src/schema/users.ts b/packages/database/src/schema/users.ts index 5733278..8b591f2 100644 --- a/packages/database/src/schema/users.ts +++ b/packages/database/src/schema/users.ts @@ -10,7 +10,7 @@ import { timeStamps } from "./base"; export const users = pgTable("users", { id: integer().primaryKey().generatedAlwaysAsIdentity(), - phoneNumber: varchar({ length: 15 }).notNull(), + phoneNumber: varchar({ length: 15 }).unique().notNull(), displayName: varchar({ length: 255 }).notNull().default("New User"), profilePicture: text(), about: text().default("Hello, I'm a new user!"),