diff --git a/prisma/migrations/20250920215413_add_last_notified_at/migration.sql b/prisma/migrations/20250920215413_add_last_notified_at/migration.sql new file mode 100644 index 0000000..ab59469 --- /dev/null +++ b/prisma/migrations/20250920215413_add_last_notified_at/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "UserRelationship" ADD COLUMN "lastNotifiedAt" TIMESTAMP(3); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 855e494..92227e0 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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]) diff --git a/sources/app/social/friendAdd.ts b/sources/app/social/friendAdd.ts index 055e258..f4467ec 100644 --- a/sources/app/social/friendAdd.ts +++ b/sources/app/social/friendAdd.ts @@ -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 { // Prevent self-friendship if (ctx.uid === uid) { @@ -40,6 +48,9 @@ export async function friendAdd(ctx: Context, uid: string): Promise ({ + 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); + }); + }); +}); \ No newline at end of file diff --git a/sources/app/social/friendNotification.ts b/sources/app/social/friendNotification.ts new file mode 100644 index 0000000..ce0dd33 --- /dev/null +++ b/sources/app/social/friendNotification.ts @@ -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 { + // 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 { + // 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() + } + }); + } +} \ No newline at end of file diff --git a/sources/app/social/relationshipSet.ts b/sources/app/social/relationshipSet.ts index b6e8d05..783fbda 100644 --- a/sources/app/social/relationshipSet.ts +++ b/sources/app/social/relationshipSet.ts @@ -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 } }); }