diff --git a/packages/api/src/routes/swipe-security.test.ts b/packages/api/src/routes/swipe-security.test.ts new file mode 100644 index 00000000..060380b6 --- /dev/null +++ b/packages/api/src/routes/swipe-security.test.ts @@ -0,0 +1,303 @@ +import prisma from "@pegada/database"; +import { breedData } from "@pegada/database/fixtures/breed-data"; +import { generateFakeUserWithDog } from "@pegada/database/fixtures/generate-fake-user-with-dog"; +import { + AccountBlockedError, + DogUnavailableError, +} from "@pegada/shared/errors/errors"; +import { Gender } from "@prisma/client"; + +import { appRouter } from "../root"; +import { DogService } from "../services/dog-service"; +import { PushNotificationService } from "../services/push-notification-service"; +import { SwipeService } from "../services/swipe-service"; +import { createInnerTRPCContext } from "../trpc"; + +jest.mock("../services/push-notification-service", () => ({ + PushNotificationService: { + enqueuePushNotification: jest.fn(async () => undefined), + }, +})); + +jest.mock("../shared/observability", () => ({ + observability: { + enabled: false, + disabledReason: "explicitly-disabled", + capture: jest.fn(), + captureError: jest.fn(), + identify: jest.fn(), + reset: jest.fn(), + register: jest.fn(), + flush: jest.fn(), + shutdown: jest.fn(), + }, + getPostHogNode: jest.fn(() => null), +})); + +jest.mock("superjson", () => ({ + __esModule: true, + default: { + serialize: (value: unknown) => value, + deserialize: (value: unknown) => value, + }, +})); + +const enqueuePushNotification = jest.mocked( + PushNotificationService.enqueuePushNotification, +); + +const callerFor = (userId: string) => + appRouter.createCaller( + createInnerTRPCContext({ session: { user: { id: userId } } }), + ); + +jest.setTimeout(30_000); + +beforeAll(async () => { + await prisma.breed.createMany({ data: breedData, skipDuplicates: true }); +}); + +beforeEach(async () => { + enqueuePushNotification.mockClear(); + await prisma.message.deleteMany(); + await prisma.match.deleteMany(); + await prisma.interest.deleteMany(); + await prisma.image.deleteMany(); + await prisma.dog.deleteMany(); + await prisma.user.deleteMany(); +}); + +afterAll(async () => { + await prisma.$disconnect(); +}); + +it("blocks a banned account but still lets it delete the account", async () => { + const { user } = await generateFakeUserWithDog({ banned: true }); + const caller = callerFor(user.id); + + await expect(caller.swipe.all({ limit: 10 })).rejects.toMatchObject({ + code: "FORBIDDEN", + message: AccountBlockedError.message, + }); + + await expect(caller.user.deleteMe()).resolves.toEqual({ ok: true }); + await expect( + prisma.user.findUnique({ where: { id: user.id } }), + ).resolves.toBeNull(); +}); + +it("rejects self, banned, and deleted swipe targets without writing interests", async () => { + const [requester, bannedTarget, removedTarget] = await Promise.all([ + generateFakeUserWithDog(), + generateFakeUserWithDog({ banned: true }), + generateFakeUserWithDog(), + ]); + + await prisma.image.deleteMany({ where: { dogId: removedTarget.dog.id } }); + await prisma.dog.delete({ where: { id: removedTarget.dog.id } }); + await prisma.user.delete({ where: { id: removedTarget.user.id } }); + + const caller = callerFor(requester.user.id); + await Promise.all( + [requester.dog.id, bannedTarget.dog.id, removedTarget.dog.id].map((id) => + expect( + caller.swipe.swipe({ id, swipeType: "INTERESTED" }), + ).rejects.toMatchObject({ + code: "NOT_FOUND", + message: DogUnavailableError.message, + }), + ), + ); + + await expect( + prisma.interest.count({ where: { requesterId: requester.dog.id } }), + ).resolves.toBe(0); +}); + +it("serializes concurrent free likes at the daily limit", async () => { + const requester = await generateFakeUserWithDog(); + const targets = await Promise.all( + Array.from({ length: 12 }, () => generateFakeUserWithDog()), + ); + const caller = callerFor(requester.user.id); + + const results = await Promise.allSettled( + targets.map(({ dog }) => + caller.swipe.swipe({ id: dog.id, swipeType: "INTERESTED" }), + ), + ); + + const fulfilled = results.filter(({ status }) => status === "fulfilled"); + const rejected = results.filter(({ status }) => status === "rejected"); + + expect(fulfilled).toHaveLength(10); + expect(rejected).toHaveLength(2); + expect(rejected).toEqual([ + expect.objectContaining({ + reason: expect.objectContaining({ code: "TOO_MANY_REQUESTS" }), + }), + expect.objectContaining({ + reason: expect.objectContaining({ code: "TOO_MANY_REQUESTS" }), + }), + ]); + await expect( + prisma.interest.count({ + where: { + requesterId: requester.dog.id, + swipeType: { in: ["INTERESTED", "MAYBE"] }, + }, + }), + ).resolves.toBe(10); +}); + +it("keeps the rolling quota after dislikes and dog replacement", async () => { + const requester = await generateFakeUserWithDog(); + const targets = await Promise.all( + Array.from({ length: 11 }, () => generateFakeUserWithDog()), + ); + const caller = callerFor(requester.user.id); + + for (const { dog } of targets.slice(0, 10)) { + // Keep each pair in order: the dislike must not erase the preceding like. + // eslint-disable-next-line no-await-in-loop + await caller.swipe.swipe({ id: dog.id, swipeType: "INTERESTED" }); + // eslint-disable-next-line no-await-in-loop + await caller.swipe.swipe({ id: dog.id, swipeType: "NOT_INTERESTED" }); + } + + await DogService.deleteDog(requester.dog.id); + await prisma.dog.create({ + data: { + userId: requester.user.id, + name: "Replacement", + gender: Gender.MALE, + images: { + create: { + position: 0, + status: "APPROVED", + url: "https://placedog.net/800/600", + }, + }, + }, + }); + + await expect( + caller.swipe.swipe({ + id: targets[10]!.dog.id, + swipeType: "INTERESTED", + }), + ).rejects.toMatchObject({ code: "TOO_MANY_REQUESTS" }); + + await expect( + prisma.interest.count({ + where: { + requesterId: requester.dog.id, + swipeType: "NOT_INTERESTED", + lastPositiveAt: { not: null }, + }, + }), + ).resolves.toBe(10); +}); + +it("resets a legacy quota only after its tenth newest like expires", async () => { + const requester = await generateFakeUserWithDog(); + const targets = await Promise.all( + Array.from({ length: 11 }, () => generateFakeUserWithDog()), + ); + const now = Date.now(); + const timestamps = targets.map( + (_, index) => new Date(now - (index + 1) * 60 * 60 * 1000), + ); + + await prisma.interest.createMany({ + data: targets.map(({ dog }, index) => ({ + requesterId: requester.dog.id, + responderId: dog.id, + swipeType: "INTERESTED", + lastPositiveAt: timestamps[index], + })), + }); + + const quota = await new SwipeService({}).getRemainingDailyLikes({ + userId: requester.user.id, + }); + + expect(quota.remainingSwipes).toBe(0); + expect(quota.likeLimitResetAt).toEqual( + new Date(timestamps[9]!.getTime() + 24 * 60 * 60 * 1000), + ); +}); + +it("keeps one interest row for concurrent writes to the same pair", async () => { + const [requester, responder] = await Promise.all([ + generateFakeUserWithDog(), + generateFakeUserWithDog(), + ]); + const caller = callerFor(requester.user.id); + + await Promise.all( + Array.from({ length: 8 }, () => + caller.swipe.swipe({ + id: responder.dog.id, + swipeType: "INTERESTED", + }), + ), + ); + + await expect( + prisma.interest.count({ + where: { + requesterId: requester.dog.id, + responderId: responder.dog.id, + }, + }), + ).resolves.toBe(1); +}); + +it("creates one match when both dogs like each other concurrently", async () => { + const [first, second] = await Promise.all([ + generateFakeUserWithDog(undefined, { + pushToken: "ExponentPushToken[first]", + }), + generateFakeUserWithDog(undefined, { + pushToken: "ExponentPushToken[second]", + }), + ]); + + const responses = await Promise.all([ + callerFor(first.user.id).swipe.swipe({ + id: second.dog.id, + swipeType: "INTERESTED", + }), + callerFor(second.user.id).swipe.swipe({ + id: first.dog.id, + swipeType: "INTERESTED", + }), + ]); + + expect(responses.filter(({ match }) => Boolean(match))).toHaveLength(1); + await expect( + prisma.match.count({ where: { deletedAt: null } }), + ).resolves.toBe(1); +}); + +it("keeps banned and self profiles out of the swipe deck and direct lookup", async () => { + const [requester, visibleTarget, bannedTarget] = await Promise.all([ + generateFakeUserWithDog({ gender: Gender.MALE }), + generateFakeUserWithDog({ gender: Gender.FEMALE }), + generateFakeUserWithDog({ gender: Gender.FEMALE, banned: true }), + ]); + const caller = callerFor(requester.user.id); + + const results = await caller.swipe.all({ limit: 10 }); + expect(results.map(({ id }) => id)).toContain(visibleTarget.dog.id); + expect(results.map(({ id }) => id)).not.toContain(requester.dog.id); + expect(results.map(({ id }) => id)).not.toContain(bannedTarget.dog.id); + + await expect( + caller.dog.get({ id: bannedTarget.dog.id }), + ).rejects.toMatchObject({ + code: "NOT_FOUND", + message: DogUnavailableError.message, + }); +}); diff --git a/packages/api/src/routes/user.ts b/packages/api/src/routes/user.ts index e21aeb36..5ed2f51e 100644 --- a/packages/api/src/routes/user.ts +++ b/packages/api/src/routes/user.ts @@ -1,7 +1,11 @@ import { z } from "zod"; import { UserService } from "../services/user-service"; -import { createTRPCRouter, protectedProcedure } from "../trpc"; +import { + authenticatedProcedure, + createTRPCRouter, + protectedProcedure, +} from "../trpc"; export const userSchema = z.object({ city: z.string().optional().nullable(), @@ -25,7 +29,7 @@ export const userRouter = createTRPCRouter({ * Hard-delete the current user's account and every dependent record. * Required for App Store compliance (Guideline 5.1.1(v)). */ - deleteMe: protectedProcedure.mutation(async ({ ctx }) => { + deleteMe: authenticatedProcedure.mutation(async ({ ctx }) => { const userId = ctx.session.user.id; await UserService.deleteAccount(userId); return { ok: true }; diff --git a/packages/api/src/services/SuggestionService/suggestion-service.ts b/packages/api/src/services/SuggestionService/suggestion-service.ts index fbaff989..d3086da2 100644 --- a/packages/api/src/services/SuggestionService/suggestion-service.ts +++ b/packages/api/src/services/SuggestionService/suggestion-service.ts @@ -193,8 +193,12 @@ export class SuggestionService { WHERE TRUE /* Exclude dogs already loaded on the client. */ ${notInCondition} + /* Never return the viewer's own dog. */ + AND "Dog"."id" <> ${dog.id} /* Exclude dogs that have been deleted */ AND "Dog"."deletedAt" IS NULL + AND "Dog"."banned" = false + AND "User"."deletedAt" IS NULL /* Exclude dogs with any rejected images and no approved images. Shadowban */ AND ( EXISTS ( diff --git a/packages/api/src/services/dog-service.ts b/packages/api/src/services/dog-service.ts index 06838d78..fa05c0eb 100644 --- a/packages/api/src/services/dog-service.ts +++ b/packages/api/src/services/dog-service.ts @@ -1,6 +1,7 @@ import type { DogServerSchema } from "@pegada/shared/schemas/dog-schema"; import prisma from "@pegada/database"; +import { DogUnavailableError } from "@pegada/shared/errors/errors"; import { IMAGE_STATUS } from "@pegada/shared/schemas/dog-schema"; import { @@ -182,7 +183,9 @@ export class DogService { const dog = await prisma.dog.findFirst({ where: { id, + banned: false, deletedAt: null, + user: { deletedAt: null }, // Users must have at least one approved image. // Shadowban users with rejected images. images: { @@ -194,7 +197,7 @@ export class DogService { }); if (!dog) { - throw new Error("Dog not found"); + throw new DogUnavailableError(); } return transformDistanceBetweenUserAndDog(dog, user); @@ -211,7 +214,7 @@ export class DogService { static async getFullDogByUserId(userId: string) { const dog = await prisma.dog.findFirst({ - where: { userId, deletedAt: null }, + where: { userId, banned: false, deletedAt: null }, select: serverOnlyFullDogSelect, }); @@ -233,7 +236,7 @@ export class DogService { static async getDogByUserId(userId: string) { const dog = await prisma.dog.findFirstOrThrow({ - where: { userId, deletedAt: null }, + where: { userId, banned: false, deletedAt: null }, }); return dog; diff --git a/packages/api/src/services/match-service.ts b/packages/api/src/services/match-service.ts index 3a0f88de..83559217 100644 --- a/packages/api/src/services/match-service.ts +++ b/packages/api/src/services/match-service.ts @@ -1,4 +1,5 @@ import type { Language } from "@pegada/shared/i18n/types/types"; +import type { Prisma } from "@prisma/client"; import prisma from "@pegada/database"; import { IMAGE_STATUS } from "@pegada/shared/schemas/dog-schema"; @@ -16,8 +17,12 @@ class MatchService { this.language = props.language; } - async createMatch(requesterId: string, responderId: string) { - const existingMatch = await prisma.match.findFirst({ + async createMatch( + requesterId: string, + responderId: string, + db: Pick, + ) { + const existingMatches = await db.match.findMany({ where: { deletedAt: null, OR: [ @@ -25,15 +30,24 @@ class MatchService { { requesterId: responderId, responderId: requesterId }, ], }, + orderBy: { createdAt: "asc" }, select: { id: true }, }); + const [existingMatch, ...duplicates] = existingMatches; if (existingMatch) { - sendError("Match already exists"); - return existingMatch; + if (duplicates.length > 0) { + await db.match.updateMany({ + where: { id: { in: duplicates.map(({ id }) => id) } }, + data: { deletedAt: new Date() }, + }); + sendError("Duplicate active matches were closed"); + } + + return { match: existingMatch, notification: null }; } - const match = await prisma.match.create({ + const match = await db.match.create({ data: { requesterId, responderId, @@ -52,26 +66,38 @@ class MatchService { }, }); - if (match.responder.user.pushToken) { - await PushNotificationService.enqueuePushNotification({ - to: match.responder.user.pushToken, - title: TranslationService.translate("server:notification.match.title", { - lng: this.language, - replace: { name: match.responder.name }, - }), - body: TranslationService.translate("server:notification.match.body", { - lng: this.language, - }), - data: { - url: `match/${match.id}/${match.requesterId}`, - }, - }); - } + const notification = match.responder.user.pushToken + ? { + to: match.responder.user.pushToken, + title: TranslationService.translate( + "server:notification.match.title", + { + lng: this.language, + replace: { name: match.responder.name }, + }, + ), + body: TranslationService.translate("server:notification.match.body", { + lng: this.language, + }), + data: { + url: `match/${match.id}/${match.requesterId}`, + }, + } + : null; - // `responder.user` exists only to address the notification. Returning the - // Prisma result here used to serialize the complete User row through - // `swipe.swipe`, including email, location, push token and active OTP. - return { id: match.id }; + return { match: { id: match.id }, notification }; + } + + async sendMatchNotification( + notification: NonNullable< + Awaited>["notification"] + >, + ) { + try { + await PushNotificationService.enqueuePushNotification(notification); + } catch (error) { + sendError(error); + } } static async getMatchesForDog(dogId: string) { diff --git a/packages/api/src/services/swipe-service.ts b/packages/api/src/services/swipe-service.ts index 1f862440..2c6763a5 100644 --- a/packages/api/src/services/swipe-service.ts +++ b/packages/api/src/services/swipe-service.ts @@ -1,18 +1,35 @@ import type { DogService } from "./dog-service"; import type { Language } from "@pegada/shared/i18n/types/types"; +import type { Prisma } from "@prisma/client"; import prisma from "@pegada/database"; import { FREE_DAILY_SWIPE_LIMIT } from "@pegada/shared/constants/constants"; -import { LikeLimitReachedError } from "@pegada/shared/errors/errors"; +import { + AccountBlockedError, + DogUnavailableError, + LikeLimitReachedError, +} from "@pegada/shared/errors/errors"; +import { IMAGE_STATUS } from "@pegada/shared/schemas/dog-schema"; import { PlanType } from "@prisma/client"; import { addDays } from "date-fns/addDays"; -import { setHours } from "date-fns/setHours"; +import { subDays } from "date-fns/subDays"; import { sendError } from "../errors/errors"; import MatchService from "./match-service"; import { PushNotificationService } from "./push-notification-service"; import { TranslationService } from "./translation-service"; -import { UserService } from "./user-service"; + +type InterestDatabase = Pick; +type QuotaDatabase = Pick; + +const lockTransaction = async (db: Prisma.TransactionClient, key: string) => { + await db.$queryRaw` + SELECT pg_advisory_xact_lock(hashtextextended(${key}, 0))::text + `; +}; + +const swipePairKey = (firstDogId: string, secondDogId: string) => + [firstDogId, secondDogId].sort().join(":"); export class SwipeService { language?: Language; @@ -24,7 +41,12 @@ export class SwipeService { async sendLikeNotification(dogId: string) { try { const dog = await prisma.dog.findFirst({ - where: { id: dogId, deletedAt: null }, + where: { + id: dogId, + banned: false, + deletedAt: null, + user: { deletedAt: null }, + }, include: { user: true }, }); @@ -47,24 +69,29 @@ export class SwipeService { async getRemainingDailyLikes({ userId, - dogId, + db = prisma, }: { userId: string; - dogId: string; + db?: QuotaDatabase; }) { - const userPlan = await UserService.getSubscriptionType(userId); + const user = await db.user.findFirst({ + where: { id: userId, deletedAt: null }, + select: { plan: true }, + }); + + if (!user) throw new AccountBlockedError(); // Only apply daily swipe limit to free users - if (userPlan !== PlanType.FREE) return { remainingSwipes: Infinity }; + if (user.plan !== PlanType.FREE) return { remainingSwipes: Infinity }; - const today = setHours(new Date(), 0); - const dailyLikeCount = await prisma.interest.findMany({ + const windowStart = subDays(new Date(), 1); + const dailyLikeCount = await db.interest.findMany({ where: { - requesterId: dogId, - updatedAt: { gte: today }, - deletedAt: null, - swipeType: { notIn: ["NOT_INTERESTED"] }, + requester: { userId }, + lastPositiveAt: { gte: windowStart }, }, + orderBy: { lastPositiveAt: "desc" }, + select: { lastPositiveAt: true }, take: FREE_DAILY_SWIPE_LIMIT, }); @@ -74,11 +101,11 @@ export class SwipeService { // If the user has reached their daily swipe limit, return the time at which the limit will reset const oldestLike = dailyLikeCount.at(-1); - if (!oldestLike) return { remainingSwipes }; + if (!oldestLike?.lastPositiveAt) return { remainingSwipes }; return { remainingSwipes, - likeLimitResetAt: addDays(oldestLike.updatedAt, 1), + likeLimitResetAt: addDays(oldestLike.lastPositiveAt, 1), }; } @@ -95,104 +122,187 @@ export class SwipeService { swipeType: "NOT_INTERESTED" | "MAYBE" | "INTERESTED"; userId: string; }) { - let remainingDailyLikes; + if (responderId === requester.id) throw new DogUnavailableError(); - if (swipeType !== "NOT_INTERESTED") { - remainingDailyLikes = await this.getRemainingDailyLikes({ - userId, - dogId: requester.id, - }); - - if (remainingDailyLikes.likeLimitResetAt) { - throw new LikeLimitReachedError({ - likeLimitResetAt: remainingDailyLikes.likeLimitResetAt, + const isRequesterShadowbanned = requester.images.some( + (image) => image.status === "REJECTED", + ); + const requesterHasImages = requester.images.some( + (image) => image.status === "APPROVED", + ); + const canSendNotifications = !isRequesterShadowbanned && requesterHasImages; + const matchService = new MatchService({ language: this.language }); + const result = await prisma.$transaction( + async (tx) => { + await lockTransaction(tx, `swipe-user:${userId}`); + await lockTransaction( + tx, + `swipe-pair:${swipePairKey(requester.id, responderId)}`, + ); + + const activeRequester = await tx.dog.findFirst({ + where: { + id: requester.id, + userId, + banned: false, + deletedAt: null, + user: { deletedAt: null }, + }, + select: { id: true }, }); - } - } - const { interest, previousStatus } = - await SwipeService.createOrUpdateInterest( - requester.id, - responderId, - swipeType, - ); + if (!activeRequester) throw new AccountBlockedError(); - if (swipeType === "NOT_INTERESTED") { - if (previousStatus) { - const existingMatch = await prisma.match.findFirst({ + const responder = await tx.dog.findFirst({ where: { + id: responderId, + banned: false, deletedAt: null, - OR: [ - { requesterId: requester.id, responderId }, - // Inverted match - { requesterId: responderId, responderId: requester.id }, - ], + user: { deletedAt: null }, + images: { + some: { status: IMAGE_STATUS.APPROVED }, + none: { status: IMAGE_STATUS.REJECTED }, + }, }, + select: { id: true }, }); - if (existingMatch) { - await prisma.match.update({ - where: { id: existingMatch.id }, - data: { deletedAt: new Date() }, + if (!responder) throw new DogUnavailableError(); + + if (swipeType !== "NOT_INTERESTED") { + const alreadyCounted = await tx.interest.findUnique({ + where: { + requesterId_responderId: { + requesterId: requester.id, + responderId, + }, + lastPositiveAt: { gte: subDays(new Date(), 1) }, + }, + select: { id: true }, }); - } - } - if (!remainingDailyLikes) { - return { interest }; - } + if (!alreadyCounted) { + const remainingDailyLikes = await this.getRemainingDailyLikes({ + userId, + db: tx, + }); + + if (remainingDailyLikes.likeLimitResetAt) { + throw new LikeLimitReachedError({ + likeLimitResetAt: remainingDailyLikes.likeLimitResetAt, + }); + } + } + } - return { interest, remainingDailyLikes }; - } + const { interest } = await SwipeService.createOrUpdateInterest( + requester.id, + responderId, + swipeType, + tx, + ); + + if (swipeType === "NOT_INTERESTED") { + await tx.match.updateMany({ + where: { + deletedAt: null, + OR: [ + { requesterId: requester.id, responderId }, + { requesterId: responderId, responderId: requester.id }, + ], + }, + data: { deletedAt: new Date() }, + }); - const hasMutualInterest = await SwipeService.checkForMutualInterest( - responderId, - requester.id, - ); + return { + interest, + match: null, + matchNotification: null, + sendLikeNotification: false, + }; + } - // Needs to have at least one approved image and no rejected images to be able to send notifications - const isRequesterShadowbanned = requester.images.some( - (image) => image.status === "REJECTED", - ); + const hasMutualInterest = await SwipeService.checkForMutualInterest( + responderId, + requester.id, + tx, + ); + + if (!hasMutualInterest) { + return { + interest, + match: null, + matchNotification: null, + sendLikeNotification: canSendNotifications, + }; + } - const requesterHasImages = requester.images.some( - (image) => image.status === "APPROVED", + const { match, notification } = await matchService.createMatch( + requester.id, + responderId, + tx, + ); + + return { + interest, + match, + matchNotification: notification, + sendLikeNotification: false, + }; + }, + { timeout: 10_000 }, ); - const canSendNotifications = !isRequesterShadowbanned && requesterHasImages; - - if (!hasMutualInterest) { - if (canSendNotifications) { - await this.sendLikeNotification(responderId); - } - return { interest }; + if (result.matchNotification) { + await matchService.sendMatchNotification(result.matchNotification); + } else if (result.sendLikeNotification) { + await this.sendLikeNotification(responderId); } - const matchService = new MatchService({ language: this.language }); - const match = await matchService.createMatch(requester.id, responderId); - - return { interest, match }; + return result.match + ? { interest: result.interest, match: result.match } + : { interest: result.interest }; } static async createOrUpdateInterest( requesterId: string, responderId: string, swipeType: "INTERESTED" | "MAYBE" | "NOT_INTERESTED", + db: InterestDatabase = prisma, ) { - const existingInterest = await prisma.interest.findFirst({ - where: { requesterId, responderId, deletedAt: null }, + const existingInterest = await db.interest.findUnique({ + where: { + requesterId_responderId: { requesterId, responderId }, + }, }); - const previousStatus = existingInterest ? existingInterest.swipeType : ""; - - const interest = existingInterest - ? await prisma.interest.update({ - where: { id: existingInterest.id }, - data: { swipeType }, - }) - : await prisma.interest.create({ - data: { requesterId, responderId, swipeType }, - }); + const previousStatus = existingInterest?.swipeType ?? ""; + const recentPositiveAt = + existingInterest?.lastPositiveAt && + existingInterest.lastPositiveAt >= subDays(new Date(), 1) + ? existingInterest.lastPositiveAt + : null; + const lastPositiveAt = + swipeType === "NOT_INTERESTED" + ? existingInterest?.lastPositiveAt + : (recentPositiveAt ?? new Date()); + + const interest = await db.interest.upsert({ + where: { + requesterId_responderId: { requesterId, responderId }, + }, + create: { + requesterId, + responderId, + swipeType, + lastPositiveAt, + }, + update: { + swipeType, + deletedAt: null, + lastPositiveAt, + }, + }); return { interest, previousStatus }; } @@ -200,8 +310,9 @@ export class SwipeService { static async checkForMutualInterest( requesterId: string, responderId: string, + db: InterestDatabase = prisma, ) { - const mutualInterest = await prisma.interest.findFirst({ + const mutualInterest = await db.interest.findFirst({ where: { requesterId, responderId, diff --git a/packages/api/src/trpc.ts b/packages/api/src/trpc.ts index 93061103..e815aa0d 100644 --- a/packages/api/src/trpc.ts +++ b/packages/api/src/trpc.ts @@ -9,7 +9,10 @@ import type { NextRequest } from "next/server"; import { prisma } from "@pegada/database"; -import { IntentionalError } from "@pegada/shared/errors/errors"; +import { + AccountBlockedError, + IntentionalError, +} from "@pegada/shared/errors/errors"; import { Language } from "@pegada/shared/i18n/types/types"; import { RequestHeaders } from "@pegada/shared/types/types"; import { initTRPC, TRPCError } from "@trpc/server"; @@ -153,6 +156,34 @@ const enforceUserIsAuthed = t.middleware(({ ctx, next }) => { }); }); +const enforceUserIsActive = t.middleware(async ({ ctx, next }) => { + const userId = ctx.session?.user?.id; + if (!userId) { + throw new TRPCError({ code: "UNAUTHORIZED" }); + } + + const account = await ctx.db.user.findFirst({ + where: { id: userId, deletedAt: null }, + select: { + dogs: { + where: { banned: true, deletedAt: null }, + select: { id: true }, + take: 1, + }, + }, + }); + + if (!account) { + throw new TRPCError({ code: "UNAUTHORIZED" }); + } + + if (account.dogs.length > 0) { + throw new AccountBlockedError(); + } + + return next(); +}); + /** * Protected (authed) procedure * @@ -162,4 +193,6 @@ const enforceUserIsAuthed = t.middleware(({ ctx, next }) => { * * @see https://trpc.io/docs/procedures */ -export const protectedProcedure = t.procedure.use(enforceUserIsAuthed); +export const authenticatedProcedure = t.procedure.use(enforceUserIsAuthed); +export const protectedProcedure = + authenticatedProcedure.use(enforceUserIsActive); diff --git a/packages/database/migrations/20260831140000_harden_swipe_integrity/migration.sql b/packages/database/migrations/20260831140000_harden_swipe_integrity/migration.sql new file mode 100644 index 00000000..e5ca32d1 --- /dev/null +++ b/packages/database/migrations/20260831140000_harden_swipe_integrity/migration.sql @@ -0,0 +1,47 @@ +ALTER TABLE "Interest" ADD COLUMN "lastPositiveAt" TIMESTAMP(3); + +UPDATE "Interest" +SET "lastPositiveAt" = "updatedAt" +WHERE "swipeType" IN ('INTERESTED', 'MAYBE'); + +-- Carry the newest positive swipe onto the row that survives deduplication. +WITH positive_history AS ( + SELECT + "requesterId", + "responderId", + MAX("lastPositiveAt") AS "lastPositiveAt" + FROM "Interest" + GROUP BY "requesterId", "responderId" +) +UPDATE "Interest" AS interest +SET "lastPositiveAt" = positive_history."lastPositiveAt" +FROM positive_history +WHERE + interest."requesterId" = positive_history."requesterId" + AND interest."responderId" = positive_history."responderId"; + +-- Keep one canonical row for each directional dog pair before adding the +-- uniqueness constraint. Prefer an active row, then the most recent one. +WITH ranked AS ( + SELECT + "id", + ROW_NUMBER() OVER ( + PARTITION BY "requesterId", "responderId" + ORDER BY + ("deletedAt" IS NULL) DESC, + "updatedAt" DESC, + "createdAt" DESC, + "id" DESC + ) AS position + FROM "Interest" +) +DELETE FROM "Interest" +WHERE "id" IN (SELECT "id" FROM ranked WHERE position > 1); + +DROP INDEX IF EXISTS "Interest_requesterId_responderId_idx"; + +CREATE UNIQUE INDEX "interest_requester_responder_key" +ON "Interest"("requesterId", "responderId"); + +CREATE INDEX "Interest_requesterId_lastPositiveAt_idx" +ON "Interest"("requesterId", "lastPositiveAt"); diff --git a/packages/database/schema.prisma b/packages/database/schema.prisma index 126bbf64..8cfdf7d6 100644 --- a/packages/database/schema.prisma +++ b/packages/database/schema.prisma @@ -186,19 +186,18 @@ model Interest { swipeType SwipeType - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - deletedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + deletedAt DateTime? + lastPositiveAt DateTime? matchId String? @unique(map: "interest_matchid_key") match Match? @relation(fields: [matchId], references: [id]) @@index([responderId], map: "interest_responderid_idx") @@index([requesterId], map: "interest_requesterid_idx") - // "exclude dogs you have already swiped" filters on both columns at once, - // once per candidate dog. `requesterId` alone matches every dog the viewer - // ever swiped and then filters. - @@index([requesterId, responderId]) + @@unique([requesterId, responderId], map: "interest_requester_responder_key") + @@index([requesterId, lastPositiveAt]) @@index([updatedAt]) @@index([deletedAt]) @@index([swipeType]) diff --git a/packages/shared/errors/errors.ts b/packages/shared/errors/errors.ts index f2b703c8..09112885 100644 --- a/packages/shared/errors/errors.ts +++ b/packages/shared/errors/errors.ts @@ -60,3 +60,33 @@ export class LikeLimitReachedError extends IntentionalError { this.likeLimitResetAt = likeLimitResetAt; } } + +export class AccountBlockedError extends IntentionalError { + static message = "This account is blocked."; + static error_code = "ACCOUNT_BLOCKED"; + error_code = AccountBlockedError.error_code; + + constructor() { + super({ + code: "FORBIDDEN", + message: AccountBlockedError.message, + }); + + this.name = "AccountBlockedError"; + } +} + +export class DogUnavailableError extends IntentionalError { + static message = "This profile is unavailable."; + static error_code = "DOG_UNAVAILABLE"; + error_code = DogUnavailableError.error_code; + + constructor() { + super({ + code: "NOT_FOUND", + message: DogUnavailableError.message, + }); + + this.name = "DogUnavailableError"; + } +}