feat: send friend requests notifications
This commit is contained in:
parent
0ce1bb4c9a
commit
c534331ce5
@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "UserRelationship" ADD COLUMN "lastNotifiedAt" TIMESTAMP(3);
|
@ -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])
|
||||
|
@ -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);
|
||||
}
|
||||
|
61
sources/app/social/friendNotification.spec.ts
Normal file
61
sources/app/social/friendNotification.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
170
sources/app/social/friendNotification.ts
Normal file
170
sources/app/social/friendNotification.ts
Normal 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()
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
||||
});
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user