Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,114 changes: 1,093 additions & 21 deletions package-lock.json

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
"@nestjs/common": "^11.0.1",
"@nestjs/config": "^4.0.2",
"@nestjs/core": "^11.0.1",
"@nestjs/event-emitter": "^3.0.1",
"@nestjs/jwt": "^11.0.0",
"@nestjs/mapped-types": "^2.0.5",
"@nestjs/passport": "^11.0.5",
Expand All @@ -52,6 +53,7 @@
"class-transformer": "^0.5.1",
"class-validator": "^0.14.2",
"cookie-parser": "^1.4.7",
"firebase-admin": "^13.6.0",
"ioredis": "^5.8.2",
"joi": "^18.0.2",
"jsonwebtoken": "^9.0.2",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@

-- CreateEnum
CREATE TYPE "NotificationType" AS ENUM ('LIKE', 'REPOST', 'QUOTE', 'REPLY', 'MENTION', 'FOLLOW', 'DM');

-- CreateEnum
CREATE TYPE "Platform" AS ENUM ('WEB', 'IOS', 'ANDROID');

-- CreateTable
CREATE TABLE "notifications" (
"id" TEXT NOT NULL,
"type" "NotificationType" NOT NULL,
"recipient_id" INTEGER NOT NULL,
"actor_id" INTEGER NOT NULL,
"actor_username" VARCHAR(50) NOT NULL,
"actor_avatar_url" VARCHAR(255),
"post_id" INTEGER,
"quote_post_id" INTEGER,
"reply_id" INTEGER,
"thread_post_id" INTEGER,
"conversation_id" INTEGER,
"message_preview" VARCHAR(200),
"post_preview_text" VARCHAR(200),
"is_read" BOOLEAN NOT NULL DEFAULT false,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,

CONSTRAINT "notifications_pkey" PRIMARY KEY ("id")
);

-- CreateTable
CREATE TABLE "device_tokens" (
"id" SERIAL NOT NULL,
"user_id" INTEGER NOT NULL,
"token" VARCHAR(255) NOT NULL,
"platform" "Platform" NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,

CONSTRAINT "device_tokens_pkey" PRIMARY KEY ("id")
);

-- CreateIndex
CREATE INDEX "notifications_recipient_id_created_at_idx" ON "notifications"("recipient_id", "created_at" DESC);

-- CreateIndex
CREATE INDEX "notifications_recipient_id_is_read_idx" ON "notifications"("recipient_id", "is_read");

-- CreateIndex
CREATE UNIQUE INDEX "device_tokens_token_key" ON "device_tokens"("token");

-- CreateIndex
CREATE INDEX "device_tokens_user_id_idx" ON "device_tokens"("user_id");

-- AddForeignKey
ALTER TABLE "notifications" ADD CONSTRAINT "notifications_recipient_id_fkey" FOREIGN KEY ("recipient_id") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;

-- AddForeignKey
ALTER TABLE "notifications" ADD CONSTRAINT "notifications_actor_id_fkey" FOREIGN KEY ("actor_id") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;

-- AddForeignKey
ALTER TABLE "notifications" ADD CONSTRAINT "notifications_post_id_fkey" FOREIGN KEY ("post_id") REFERENCES "posts"("id") ON DELETE CASCADE ON UPDATE CASCADE;

-- AddForeignKey
ALTER TABLE "notifications" ADD CONSTRAINT "notifications_conversation_id_fkey" FOREIGN KEY ("conversation_id") REFERENCES "conversations"("id") ON DELETE CASCADE ON UPDATE CASCADE;

-- AddForeignKey
ALTER TABLE "device_tokens" ADD CONSTRAINT "device_tokens_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "notifications" ADD COLUMN "actor_display_name" VARCHAR(100);
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
-- Add unique indexes to prevent duplicate notifications

CREATE UNIQUE INDEX IF NOT EXISTS idx_notification_like_unique
ON notifications(recipient_id, actor_id, post_id, type)
WHERE type = 'LIKE' AND post_id IS NOT NULL;

CREATE UNIQUE INDEX IF NOT EXISTS idx_notification_repost_unique
ON notifications(recipient_id, actor_id, post_id, type)
WHERE type = 'REPOST' AND post_id IS NOT NULL;

CREATE UNIQUE INDEX IF NOT EXISTS idx_notification_follow_unique
ON notifications(recipient_id, actor_id, type)
WHERE type = 'FOLLOW';

CREATE UNIQUE INDEX IF NOT EXISTS idx_notification_mention_unique
ON notifications(recipient_id, actor_id, post_id, type)
WHERE type = 'MENTION' AND post_id IS NOT NULL;

CREATE UNIQUE INDEX IF NOT EXISTS idx_notification_quote_unique
ON notifications(recipient_id, actor_id, quote_post_id, type)
WHERE type = 'QUOTE' AND quote_post_id IS NOT NULL;

CREATE UNIQUE INDEX IF NOT EXISTS idx_notification_reply_unique
ON notifications(recipient_id, actor_id, reply_id, type)
WHERE type = 'REPLY' AND reply_id IS NOT NULL;
109 changes: 91 additions & 18 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,9 @@ model User {
ConversationsAsUser2 Conversation[] @relation("User2Conversations")
MessagesSent Message[]
interests UserInterest[]
ReceivedNotifications Notification[] @relation("ReceivedNotifications")
SentNotifications Notification[] @relation("SentNotifications")
DeviceTokens DeviceToken[]
}

model Profile {
Expand Down Expand Up @@ -91,7 +94,6 @@ model Interest {

@@map("interests")
}

model UserInterest {
user_id Int
interest_id Int
Expand All @@ -113,7 +115,7 @@ model Post {
interest_id Int?
created_at DateTime @default(now())
is_deleted Boolean @default(false)
summary String?
summary String?
likes Like[]
media Media[]
mentions Mention[]
Expand All @@ -123,6 +125,7 @@ model Post {
User User @relation(fields: [user_id], references: [id])
Interest Interest? @relation(fields: [interest_id], references: [id])
hashtags Hashtag[] @relation("PostHashtags")
Notifications Notification[]

@@map("posts")
}
Expand Down Expand Up @@ -184,11 +187,26 @@ model Repost {
}

model Hashtag {
id Int @id @default(autoincrement())
tag String @unique
created_at DateTime @default(now())
posts Post[] @relation("PostHashtags")
hashtagTrends HashtagTrend[]
id Int @id @default(autoincrement())
tag String @unique
created_at DateTime @default(now())
posts Post[] @relation("PostHashtags")
trends HashtagTrend[]
}

model HashtagTrend {
id Int @id @default(autoincrement())
hashtag_id Int
post_count_1h Int
post_count_24h Int
post_count_7d Int
trending_score Float
calculated_at DateTime @default(now())
hashtag Hashtag @relation(fields: [hashtag_id], references: [id], onDelete: Cascade)

@@index([trending_score])
@@index([hashtag_id])
@@map("hashtag_trends")
}

model Like {
Expand Down Expand Up @@ -221,6 +239,7 @@ model Conversation {
User1 User @relation("User1Conversations", fields: [user1Id], references: [id], onDelete: Cascade)
User2 User @relation("User2Conversations", fields: [user2Id], references: [id], onDelete: Cascade)
Messages Message[]
Notifications Notification[]

@@unique([user1Id, user2Id])
@@map("conversations")
Expand Down Expand Up @@ -259,16 +278,70 @@ enum MediaType {
IMAGE
}

model HashtagTrend {
id Int @id @default(autoincrement())
hashtag_id Int
post_count_1h Int
post_count_24h Int
post_count_7d Int
trending_score Float
calculated_at DateTime @default(now())
hashtag Hashtag @relation(fields: [hashtag_id], references: [id], onDelete: Cascade)
model Notification {
id String @id @default(cuid())
type NotificationType
recipientId Int @map("recipient_id")
actorId Int @map("actor_id")

@@index([trending_score])
@@index([hashtag_id])
// Actor snapshot to avoid N+1 queries
actorUsername String @map("actor_username") @db.VarChar(50)
actorDisplayName String? @map("actor_display_name") @db.VarChar(100)
actorAvatarUrl String? @map("actor_avatar_url") @db.VarChar(255)

// Post-related (for LIKE, REPOST, QUOTE, REPLY, MENTION)
postId Int? @map("post_id")
quotePostId Int? @map("quote_post_id") // for QUOTE type
replyId Int? @map("reply_id") // for REPLY/MENTION in replies
threadPostId Int? @map("thread_post_id") // root post of thread

// DM-related
conversationId Int? @map("conversation_id")
messagePreview String? @map("message_preview") @db.VarChar(200)

postPreviewText String? @map("post_preview_text") @db.VarChar(200)

isRead Boolean @default(false) @map("is_read")
createdAt DateTime @default(now()) @map("created_at")

recipient User @relation("ReceivedNotifications", fields: [recipientId], references: [id], onDelete: Cascade)
actor User @relation("SentNotifications", fields: [actorId], references: [id], onDelete: Cascade)

// Optional relations
post Post? @relation(fields: [postId], references: [id], onDelete: Cascade)
conversation Conversation? @relation(fields: [conversationId], references: [id], onDelete: Cascade)

@@index([recipientId, createdAt(sort: Desc)])
@@index([recipientId, isRead])
@@map("notifications")
}

enum NotificationType {
LIKE
REPOST
QUOTE
REPLY
MENTION
FOLLOW
DM
}

model DeviceToken {
id Int @id @default(autoincrement())
userId Int @map("user_id")
token String @unique @db.VarChar(255)
platform Platform
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")

user User @relation(fields: [userId], references: [id], onDelete: Cascade)

@@index([userId])
@@map("device_tokens")
}

enum Platform {
WEB
IOS
ANDROID
}
15 changes: 13 additions & 2 deletions src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,22 @@ import { AiIntegrationModule } from './ai-integration/ai-integration.module';
import envSchema from './config/validate-config';
import { BullModule } from '@nestjs/bullmq';
import redisConfig from './config/redis.config';
import { EventEmitterModule } from '@nestjs/event-emitter';
import { FirebaseModule } from './firebase/firebase.module';
import { NotificationsModule } from './notifications/notifications.module';

const envFilePath = '.env';

@Module({
imports: [
ConfigModule.forRoot({ envFilePath, isGlobal: true, validationSchema: envSchema, load: [redisConfig] }),
ConfigModule.forRoot({
envFilePath,
isGlobal: true,
validationSchema: envSchema,
load: [redisConfig],
}),
EventEmitterModule.forRoot(),
FirebaseModule,
AuthModule,
UserModule,
UsersModule,
Expand Down Expand Up @@ -73,6 +83,7 @@ const envFilePath = '.env';
ConversationsModule,
PrismaModule,
AiIntegrationModule,
NotificationsModule,
],
controllers: [],
providers: [
Expand All @@ -82,4 +93,4 @@ const envFilePath = '.env';
},
],
})
export class AppModule { }
export class AppModule {}
7 changes: 7 additions & 0 deletions src/firebase/firebase.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { ConfigService } from '@nestjs/config';

export const getFirebaseConfig = (configService: ConfigService) => ({
projectId: configService.get<string>('FIREBASE_PROJECT_ID'),
privateKey: configService.get<string>('FIREBASE_PRIVATE_KEY')?.replace(/\\n/g, '\n'),
clientEmail: configService.get<string>('FIREBASE_CLIENT_EMAIL'),
});
16 changes: 16 additions & 0 deletions src/firebase/firebase.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { Module, Global } from '@nestjs/common';
import { FirebaseService } from './firebase.service';
import { Services } from 'src/utils/constants';

@Global()
@Module({
providers: [
FirebaseService,
{
provide: Services.FIREBASE,
useClass: FirebaseService,
},
],
exports: [FirebaseService, Services.FIREBASE],
})
export class FirebaseModule {}
58 changes: 58 additions & 0 deletions src/firebase/firebase.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { Injectable, OnModuleInit, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import * as admin from 'firebase-admin';
import { getFirebaseConfig } from './firebase.config';

@Injectable()
export class FirebaseService implements OnModuleInit {
private readonly logger = new Logger(FirebaseService.name);
private firebaseApp: admin.app.App;

constructor(private readonly configService: ConfigService) {}

onModuleInit() {
const firebaseConfig = getFirebaseConfig(this.configService);

if (!firebaseConfig.projectId || !firebaseConfig.privateKey || !firebaseConfig.clientEmail) {
this.logger.error(
'Firebase configuration is incomplete. Please check environment variables.',
);
throw new Error('Firebase configuration is incomplete');
}

try {
// Check if Firebase app already exists
if (admin.apps.length === 0) {
this.firebaseApp = admin.initializeApp({
credential: admin.credential.cert({
projectId: firebaseConfig.projectId,
privateKey: firebaseConfig.privateKey,
clientEmail: firebaseConfig.clientEmail,
}),
databaseURL: this.configService.get<string>('FIREBASE_DATABASE_URL'),
});

this.logger.log('Firebase Admin SDK initialized successfully');
} else {
// Use existing Firebase app
this.firebaseApp = admin.app();
this.logger.log('Using existing Firebase Admin SDK instance');
}
} catch (error) {
this.logger.error('Failed to initialize Firebase Admin SDK', error);
throw error;
}
}

getFirestore(): admin.firestore.Firestore {
return admin.firestore(this.firebaseApp);
}

getMessaging(): admin.messaging.Messaging {
return admin.messaging(this.firebaseApp);
}

getAuth(): admin.auth.Auth {
return admin.auth(this.firebaseApp);
}
}
Loading