feat: send friend requests notifications

This commit is contained in:
Steve Korshakov 2025-09-20 14:55:07 -07:00
parent 0ce1bb4c9a
commit c534331ce5
6 changed files with 277 additions and 13 deletions

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "UserRelationship" ADD COLUMN "lastNotifiedAt" TIMESTAMP(3);

View File

@ -308,14 +308,15 @@ enum RelationshipStatus {
}
model UserRelationship {
fromUserId String
fromUser Account @relation("RelationshipsFrom", fields: [fromUserId], references: [id], onDelete: Cascade)
toUserId String
toUser Account @relation("RelationshipsTo", fields: [toUserId], references: [id], onDelete: Cascade)
status RelationshipStatus @default(pending)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
acceptedAt DateTime?
fromUserId String
fromUser Account @relation("RelationshipsFrom", fields: [fromUserId], references: [id], onDelete: Cascade)
toUserId String
toUser Account @relation("RelationshipsTo", fields: [toUserId], references: [id], onDelete: Cascade)
status RelationshipStatus @default(pending)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
acceptedAt DateTime?
lastNotifiedAt DateTime?
@@id([fromUserId, toUserId])
@@index([toUserId, status])

View File

@ -4,7 +4,15 @@ import { db } from "@/storage/db";
import { RelationshipStatus } from "@prisma/client";
import { relationshipSet } from "./relationshipSet";
import { relationshipGet } from "./relationshipGet";
import { sendFriendRequestNotification, sendFriendshipEstablishedNotification } from "./friendNotification";
/**
* Add a friend or accept a friend request.
* Handles:
* - Accepting incoming friend requests (both users become friends)
* - Sending new friend requests
* - Sending appropriate notifications with 24-hour cooldown
*/
export async function friendAdd(ctx: Context, uid: string): Promise<UserProfile | null> {
// Prevent self-friendship
if (ctx.uid === uid) {
@ -40,6 +48,9 @@ export async function friendAdd(ctx: Context, uid: string): Promise<UserProfile
await relationshipSet(tx, targetUser.id, currentUser.id, RelationshipStatus.friend);
await relationshipSet(tx, currentUser.id, targetUser.id, RelationshipStatus.friend);
// Send friendship established notifications to both users
await sendFriendshipEstablishedNotification(tx, currentUser.id, targetUser.id);
// Return the target user profile
return buildUserProfile(targetUser, RelationshipStatus.friend);
}
@ -54,6 +65,9 @@ export async function friendAdd(ctx: Context, uid: string): Promise<UserProfile
await relationshipSet(tx, targetUser.id, currentUser.id, RelationshipStatus.pending);
}
// Send friend request notification to the receiver
await sendFriendRequestNotification(tx, targetUser.id, currentUser.id);
// Return the target user profile
return buildUserProfile(targetUser, RelationshipStatus.requested);
}

View File

@ -0,0 +1,61 @@
import { describe, it, expect, vi } from "vitest";
import { RelationshipStatus } from "@prisma/client";
// Mock the dependencies that require environment variables
vi.mock("@/storage/files", () => ({
getPublicUrl: vi.fn((path: string) => `https://example.com/${path}`)
}));
vi.mock("@/app/feed/feedPost", () => ({
feedPost: vi.fn()
}));
vi.mock("@/storage/inTx", () => ({
afterTx: vi.fn()
}));
// Import after mocking
import { shouldSendNotification } from "./friendNotification";
describe("friendNotification", () => {
describe("shouldSendNotification", () => {
it("should return true when lastNotifiedAt is null", () => {
const result = shouldSendNotification(null, RelationshipStatus.pending);
expect(result).toBe(true);
});
it("should return false for rejected relationships", () => {
const result = shouldSendNotification(null, RelationshipStatus.rejected);
expect(result).toBe(false);
});
it("should return false for rejected relationships even if 24 hours passed", () => {
const twentyFiveHoursAgo = new Date(Date.now() - 25 * 60 * 60 * 1000);
const result = shouldSendNotification(twentyFiveHoursAgo, RelationshipStatus.rejected);
expect(result).toBe(false);
});
it("should return true when 24 hours have passed since last notification", () => {
const twentyFiveHoursAgo = new Date(Date.now() - 25 * 60 * 60 * 1000);
const result = shouldSendNotification(twentyFiveHoursAgo, RelationshipStatus.pending);
expect(result).toBe(true);
});
it("should return false when less than 24 hours have passed", () => {
const tenHoursAgo = new Date(Date.now() - 10 * 60 * 60 * 1000);
const result = shouldSendNotification(tenHoursAgo, RelationshipStatus.pending);
expect(result).toBe(false);
});
it("should work for friend status", () => {
const twentyFiveHoursAgo = new Date(Date.now() - 25 * 60 * 60 * 1000);
const result = shouldSendNotification(twentyFiveHoursAgo, RelationshipStatus.friend);
expect(result).toBe(true);
});
it("should work for requested status", () => {
const result = shouldSendNotification(null, RelationshipStatus.requested);
expect(result).toBe(true);
});
});
});

View File

@ -0,0 +1,170 @@
import { Prisma, RelationshipStatus } from "@prisma/client";
import { feedPost } from "@/app/feed/feedPost";
import { Context } from "@/context";
import { afterTx } from "@/storage/inTx";
/**
* Check if a notification should be sent based on the last notification time and relationship status.
* Returns true if:
* - No previous notification was sent (lastNotifiedAt is null)
* - OR 24 hours have passed since the last notification
* - AND the relationship is not rejected
*/
export function shouldSendNotification(
lastNotifiedAt: Date | null,
status: RelationshipStatus
): boolean {
// Don't send notifications for rejected relationships
if (status === RelationshipStatus.rejected) {
return false;
}
// If never notified, send notification
if (!lastNotifiedAt) {
return true;
}
// Check if 24 hours have passed since last notification
const twentyFourHoursAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
return lastNotifiedAt < twentyFourHoursAgo;
}
/**
* Send a friend request notification to the receiver and update lastNotifiedAt.
* This creates a feed item for the receiver about the incoming friend request.
*/
export async function sendFriendRequestNotification(
tx: Prisma.TransactionClient,
receiverUserId: string,
senderUserId: string
): Promise<void> {
// Check if we should send notification to receiver
const receiverRelationship = await tx.userRelationship.findUnique({
where: {
fromUserId_toUserId: {
fromUserId: receiverUserId,
toUserId: senderUserId
}
}
});
if (!receiverRelationship || !shouldSendNotification(
receiverRelationship.lastNotifiedAt,
receiverRelationship.status
)) {
return;
}
// Create feed notification for receiver
const receiverCtx = Context.create(receiverUserId);
await feedPost(
tx,
receiverCtx,
{
kind: 'friend_request',
uid: senderUserId
},
`friend_request_${senderUserId}` // repeatKey to avoid duplicates
);
// Update lastNotifiedAt for the receiver's relationship record
await tx.userRelationship.update({
where: {
fromUserId_toUserId: {
fromUserId: receiverUserId,
toUserId: senderUserId
}
},
data: {
lastNotifiedAt: new Date()
}
});
}
/**
* Send friendship established notifications to both users and update lastNotifiedAt.
* This creates feed items for both users about the new friendship.
*/
export async function sendFriendshipEstablishedNotification(
tx: Prisma.TransactionClient,
user1Id: string,
user2Id: string
): Promise<void> {
// Check and send notification to user1
const user1Relationship = await tx.userRelationship.findUnique({
where: {
fromUserId_toUserId: {
fromUserId: user1Id,
toUserId: user2Id
}
}
});
if (user1Relationship && shouldSendNotification(
user1Relationship.lastNotifiedAt,
user1Relationship.status
)) {
const user1Ctx = Context.create(user1Id);
await feedPost(
tx,
user1Ctx,
{
kind: 'friend_accepted',
uid: user2Id
},
`friend_accepted_${user2Id}` // repeatKey to avoid duplicates
);
// Update lastNotifiedAt for user1
await tx.userRelationship.update({
where: {
fromUserId_toUserId: {
fromUserId: user1Id,
toUserId: user2Id
}
},
data: {
lastNotifiedAt: new Date()
}
});
}
// Check and send notification to user2
const user2Relationship = await tx.userRelationship.findUnique({
where: {
fromUserId_toUserId: {
fromUserId: user2Id,
toUserId: user1Id
}
}
});
if (user2Relationship && shouldSendNotification(
user2Relationship.lastNotifiedAt,
user2Relationship.status
)) {
const user2Ctx = Context.create(user2Id);
await feedPost(
tx,
user2Ctx,
{
kind: 'friend_accepted',
uid: user1Id
},
`friend_accepted_${user1Id}` // repeatKey to avoid duplicates
);
// Update lastNotifiedAt for user2
await tx.userRelationship.update({
where: {
fromUserId_toUserId: {
fromUserId: user2Id,
toUserId: user1Id
}
},
data: {
lastNotifiedAt: new Date()
}
});
}
}

View File

@ -1,7 +1,17 @@
import { Prisma } from "@prisma/client";
import { RelationshipStatus } from "@prisma/client";
export async function relationshipSet(tx: Prisma.TransactionClient, from: string, to: string, status: RelationshipStatus) {
export async function relationshipSet(tx: Prisma.TransactionClient, from: string, to: string, status: RelationshipStatus, lastNotifiedAt?: Date) {
// Get existing relationship to preserve lastNotifiedAt
const existing = await tx.userRelationship.findUnique({
where: {
fromUserId_toUserId: {
fromUserId: from,
toUserId: to
}
}
});
if (status === RelationshipStatus.friend) {
await tx.userRelationship.upsert({
where: {
@ -14,11 +24,14 @@ export async function relationshipSet(tx: Prisma.TransactionClient, from: string
fromUserId: from,
toUserId: to,
status,
acceptedAt: new Date()
acceptedAt: new Date(),
lastNotifiedAt: lastNotifiedAt || null
},
update: {
status,
acceptedAt: new Date()
acceptedAt: new Date(),
// Preserve existing lastNotifiedAt, only update if explicitly provided
lastNotifiedAt: lastNotifiedAt || existing?.lastNotifiedAt || undefined
}
});
} else {
@ -33,11 +46,14 @@ export async function relationshipSet(tx: Prisma.TransactionClient, from: string
fromUserId: from,
toUserId: to,
status,
acceptedAt: null
acceptedAt: null,
lastNotifiedAt: lastNotifiedAt || null
},
update: {
status,
acceptedAt: null
acceptedAt: null,
// Preserve existing lastNotifiedAt, only update if explicitly provided
lastNotifiedAt: lastNotifiedAt || existing?.lastNotifiedAt || undefined
}
});
}