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 {
|
model UserRelationship {
|
||||||
fromUserId String
|
fromUserId String
|
||||||
fromUser Account @relation("RelationshipsFrom", fields: [fromUserId], references: [id], onDelete: Cascade)
|
fromUser Account @relation("RelationshipsFrom", fields: [fromUserId], references: [id], onDelete: Cascade)
|
||||||
toUserId String
|
toUserId String
|
||||||
toUser Account @relation("RelationshipsTo", fields: [toUserId], references: [id], onDelete: Cascade)
|
toUser Account @relation("RelationshipsTo", fields: [toUserId], references: [id], onDelete: Cascade)
|
||||||
status RelationshipStatus @default(pending)
|
status RelationshipStatus @default(pending)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
acceptedAt DateTime?
|
acceptedAt DateTime?
|
||||||
|
lastNotifiedAt DateTime?
|
||||||
|
|
||||||
@@id([fromUserId, toUserId])
|
@@id([fromUserId, toUserId])
|
||||||
@@index([toUserId, status])
|
@@index([toUserId, status])
|
||||||
|
@ -4,7 +4,15 @@ import { db } from "@/storage/db";
|
|||||||
import { RelationshipStatus } from "@prisma/client";
|
import { RelationshipStatus } from "@prisma/client";
|
||||||
import { relationshipSet } from "./relationshipSet";
|
import { relationshipSet } from "./relationshipSet";
|
||||||
import { relationshipGet } from "./relationshipGet";
|
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> {
|
export async function friendAdd(ctx: Context, uid: string): Promise<UserProfile | null> {
|
||||||
// Prevent self-friendship
|
// Prevent self-friendship
|
||||||
if (ctx.uid === uid) {
|
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, targetUser.id, currentUser.id, RelationshipStatus.friend);
|
||||||
await relationshipSet(tx, currentUser.id, targetUser.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 the target user profile
|
||||||
return buildUserProfile(targetUser, RelationshipStatus.friend);
|
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);
|
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 the target user profile
|
||||||
return buildUserProfile(targetUser, RelationshipStatus.requested);
|
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 { Prisma } from "@prisma/client";
|
||||||
import { RelationshipStatus } 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) {
|
if (status === RelationshipStatus.friend) {
|
||||||
await tx.userRelationship.upsert({
|
await tx.userRelationship.upsert({
|
||||||
where: {
|
where: {
|
||||||
@ -14,11 +24,14 @@ export async function relationshipSet(tx: Prisma.TransactionClient, from: string
|
|||||||
fromUserId: from,
|
fromUserId: from,
|
||||||
toUserId: to,
|
toUserId: to,
|
||||||
status,
|
status,
|
||||||
acceptedAt: new Date()
|
acceptedAt: new Date(),
|
||||||
|
lastNotifiedAt: lastNotifiedAt || null
|
||||||
},
|
},
|
||||||
update: {
|
update: {
|
||||||
status,
|
status,
|
||||||
acceptedAt: new Date()
|
acceptedAt: new Date(),
|
||||||
|
// Preserve existing lastNotifiedAt, only update if explicitly provided
|
||||||
|
lastNotifiedAt: lastNotifiedAt || existing?.lastNotifiedAt || undefined
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
@ -33,11 +46,14 @@ export async function relationshipSet(tx: Prisma.TransactionClient, from: string
|
|||||||
fromUserId: from,
|
fromUserId: from,
|
||||||
toUserId: to,
|
toUserId: to,
|
||||||
status,
|
status,
|
||||||
acceptedAt: null
|
acceptedAt: null,
|
||||||
|
lastNotifiedAt: lastNotifiedAt || null
|
||||||
},
|
},
|
||||||
update: {
|
update: {
|
||||||
status,
|
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