diff --git a/app/features/admin/AdminRepository.server.ts b/app/features/admin/AdminRepository.server.ts index cd04d86e3..9e3f93f32 100644 --- a/app/features/admin/AdminRepository.server.ts +++ b/app/features/admin/AdminRepository.server.ts @@ -1,6 +1,7 @@ import type { Transaction } from "kysely"; import { db, sql } from "~/db/sql"; import type { DB, Tables, TablesInsertable } from "~/db/tables"; +import { actorId } from "~/features/auth/core/user.server"; import * as BadgeRepository from "~/features/badges/BadgeRepository.server"; import * as BuildRepository from "~/features/builds/BuildRepository.server"; import * as XRankPlacementRepository from "~/features/top-search/XRankPlacementRepository.server"; @@ -346,8 +347,13 @@ export function unbanUser({ }); } -export function addModNote(args: TablesInsertable["ModNote"]) { - return db.insertInto("ModNote").values(args).execute(); +export function addModNote( + args: Omit, +) { + return db + .insertInto("ModNote") + .values({ ...args, authorId: actorId() }) + .execute(); } export function findModeNoteById(id: number) { diff --git a/app/features/admin/routes/admin.test.ts b/app/features/admin/routes/admin.test.ts index 1ccf5f173..3bc74ee77 100644 --- a/app/features/admin/routes/admin.test.ts +++ b/app/features/admin/routes/admin.test.ts @@ -9,6 +9,7 @@ import { assertResponseErrored, dbInsertUsers, dbReset, + withUserId, wrappedAction, } from "~/utils/Test"; import type { adminActionSchema } from "../actions/admin.server"; @@ -380,11 +381,12 @@ describe("Account migration", () => { ownerUserId: 2, isMainTeam: true, }); - await TeamRepository.addNewTeamMember({ - teamId: 1, - userId: 1, - maxTeamsAllowed: 1, - }); + await withUserId(1, () => + TeamRepository.joinTeam({ + teamId: 1, + maxTeamsAllowed: 1, + }), + ); await TeamRepository.handleMemberLeaving({ teamId: 1, userId: 1 }); for (const userId of [1, 2]) { @@ -402,14 +404,16 @@ describe("Account migration", () => { }); it("deletes weapon pool from the new user when migrating (takes weapon pool from the old user)", async () => { - await UserRepository.updateProfile({ - userId: 1, - weapons: [{ weaponSplId: 1, isFavorite: 1 }], - }); - await UserRepository.updateProfile({ - userId: 2, - weapons: [{ weaponSplId: 10 }], - }); + await withUserId(1, () => + UserRepository.updateOwnProfile({ + weapons: [{ weaponSplId: 1, isFavorite: 1 }], + }), + ); + await withUserId(2, () => + UserRepository.updateOwnProfile({ + weapons: [{ weaponSplId: 10 }], + }), + ); await migrateUserAction(); diff --git a/app/features/art/ArtRepository.server.test.ts b/app/features/art/ArtRepository.server.test.ts index 6d1940d39..21cd38cbf 100644 --- a/app/features/art/ArtRepository.server.test.ts +++ b/app/features/art/ArtRepository.server.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, test } from "vitest"; -import { dbInsertUsers, dbReset } from "~/utils/Test"; +import { dbInsertUsers, dbReset, withUserId } from "~/utils/Test"; import * as ArtRepository from "./ArtRepository.server"; let imageCounter = 0; @@ -7,14 +7,15 @@ let imageCounter = 0; const createArt = async ({ authorId }: { authorId: number }) => { imageCounter++; - const art = await ArtRepository.insert({ - authorId, - url: `https://example.com/image-${authorId}-${imageCounter}.png`, - validatedAt: Date.now(), - description: null, - linkedUsers: [], - tags: [], - }); + const art = await withUserId(authorId, () => + ArtRepository.insert({ + url: `https://example.com/image-${authorId}-${imageCounter}.png`, + validatedAt: Date.now(), + description: null, + linkedUsers: [], + tags: [], + }), + ); return art.id; }; @@ -91,14 +92,19 @@ describe("findAllTags", () => { test("returns all art tags", async () => { imageCounter++; - await ArtRepository.insert({ - authorId: 1, - url: `https://example.com/image-1-${imageCounter}.png`, - validatedAt: Date.now(), - description: null, - linkedUsers: [], - tags: [{ name: "Character" }, { name: "Weapon" }, { name: "Landscape" }], - }); + await withUserId(1, () => + ArtRepository.insert({ + url: `https://example.com/image-1-${imageCounter}.png`, + validatedAt: Date.now(), + description: null, + linkedUsers: [], + tags: [ + { name: "Character" }, + { name: "Weapon" }, + { name: "Landscape" }, + ], + }), + ); const result = await ArtRepository.findAllTags(); @@ -128,16 +134,17 @@ describe("unlinkUserFromArt", () => { }); test("removes user link from art", async () => { - const art = await ArtRepository.insert({ - authorId: 1, - url: "https://example.com/image-1.png", - validatedAt: Date.now(), - description: null, - linkedUsers: [2], - tags: [], - }); + const art = await withUserId(1, () => + ArtRepository.insert({ + url: "https://example.com/image-1.png", + validatedAt: Date.now(), + description: null, + linkedUsers: [2], + tags: [], + }), + ); - await ArtRepository.unlinkUserFromArt({ userId: 2, artId: art.id }); + await withUserId(2, () => ArtRepository.unlinkSelfFromArt(art.id)); const result = await ArtRepository.findArtsByUserId(2, { includeAuthored: false, @@ -157,23 +164,25 @@ describe("findShowcaseArtsByTag", () => { }); test("returns arts filtered by tag", async () => { - const art1 = await ArtRepository.insert({ - authorId: 1, - url: "https://example.com/image-1.png", - validatedAt: Date.now(), - description: null, - linkedUsers: [], - tags: [{ name: "Character" }], - }); + const art1 = await withUserId(1, () => + ArtRepository.insert({ + url: "https://example.com/image-1.png", + validatedAt: Date.now(), + description: null, + linkedUsers: [], + tags: [{ name: "Character" }], + }), + ); - await ArtRepository.insert({ - authorId: 2, - url: "https://example.com/image-2.png", - validatedAt: Date.now(), - description: null, - linkedUsers: [], - tags: [{ name: "Weapon" }], - }); + await withUserId(2, () => + ArtRepository.insert({ + url: "https://example.com/image-2.png", + validatedAt: Date.now(), + description: null, + linkedUsers: [], + tags: [{ name: "Weapon" }], + }), + ); const tags = await ArtRepository.findAllTags(); const characterTag = tags.find((t) => t.name === "Character"); @@ -187,26 +196,28 @@ describe("findShowcaseArtsByTag", () => { }); test("shows only one art per artist", async () => { - await ArtRepository.insert({ - authorId: 1, - url: "https://example.com/image-1.png", - validatedAt: Date.now(), - description: null, - linkedUsers: [], - tags: [{ name: "Character" }], - }); + await withUserId(1, () => + ArtRepository.insert({ + url: "https://example.com/image-1.png", + validatedAt: Date.now(), + description: null, + linkedUsers: [], + tags: [{ name: "Character" }], + }), + ); const tags = await ArtRepository.findAllTags(); const characterTag = tags.find((t) => t.name === "Character"); - await ArtRepository.insert({ - authorId: 1, - url: "https://example.com/image-2.png", - validatedAt: Date.now(), - description: null, - linkedUsers: [], - tags: [{ id: characterTag?.id }], - }); + await withUserId(1, () => + ArtRepository.insert({ + url: "https://example.com/image-2.png", + validatedAt: Date.now(), + description: null, + linkedUsers: [], + tags: [{ id: characterTag?.id }], + }), + ); const result = await ArtRepository.findShowcaseArtsByTag( characterTag?.id ?? 0, @@ -256,14 +267,15 @@ describe("findArtsByUserId", () => { }); test("returns tagged art", async () => { - const art = await ArtRepository.insert({ - authorId: 1, - url: "https://example.com/image-1.png", - validatedAt: Date.now(), - description: null, - linkedUsers: [2], - tags: [], - }); + const art = await withUserId(1, () => + ArtRepository.insert({ + url: "https://example.com/image-1.png", + validatedAt: Date.now(), + description: null, + linkedUsers: [2], + tags: [], + }), + ); const result = await ArtRepository.findArtsByUserId(2); @@ -314,14 +326,15 @@ describe("deleteOrphanTags", () => { }); test("deletes tags with no associated art", async () => { - const art = await ArtRepository.insert({ - authorId: 1, - url: "https://example.com/image-1.png", - validatedAt: Date.now(), - description: null, - linkedUsers: [], - tags: [{ name: "Orphan1" }, { name: "Orphan2" }], - }); + const art = await withUserId(1, () => + ArtRepository.insert({ + url: "https://example.com/image-1.png", + validatedAt: Date.now(), + description: null, + linkedUsers: [], + tags: [{ name: "Orphan1" }, { name: "Orphan2" }], + }), + ); await ArtRepository.deleteById(art.id); @@ -333,14 +346,15 @@ describe("deleteOrphanTags", () => { }); test("does not delete tags that are still linked to art", async () => { - await ArtRepository.insert({ - authorId: 1, - url: "https://example.com/image-1.png", - validatedAt: Date.now(), - description: null, - linkedUsers: [], - tags: [{ name: "InUse" }], - }); + await withUserId(1, () => + ArtRepository.insert({ + url: "https://example.com/image-1.png", + validatedAt: Date.now(), + description: null, + linkedUsers: [], + tags: [{ name: "InUse" }], + }), + ); const deletedCount = await ArtRepository.deleteOrphanTags(); expect(deletedCount).toBe(0); @@ -362,14 +376,15 @@ describe("insert", () => { }); test("inserts art with all metadata", async () => { - const art = await ArtRepository.insert({ - authorId: 1, - url: "https://example.com/image-1.png", - validatedAt: Date.now(), - description: "Test description", - linkedUsers: [2], - tags: [{ name: "Character" }], - }); + const art = await withUserId(1, () => + ArtRepository.insert({ + url: "https://example.com/image-1.png", + validatedAt: Date.now(), + description: "Test description", + linkedUsers: [2], + tags: [{ name: "Character" }], + }), + ); const result = await ArtRepository.findArtsByUserId(1); @@ -400,14 +415,15 @@ describe("update", () => { }); test("updates art metadata", async () => { - const art = await ArtRepository.insert({ - authorId: 1, - url: "https://example.com/image-1.png", - validatedAt: Date.now(), - description: "Original", - linkedUsers: [2], - tags: [{ name: "Character" }], - }); + const art = await withUserId(1, () => + ArtRepository.insert({ + url: "https://example.com/image-1.png", + validatedAt: Date.now(), + description: "Original", + linkedUsers: [2], + tags: [{ name: "Character" }], + }), + ); await ArtRepository.update(art.id, { description: "Updated", diff --git a/app/features/art/ArtRepository.server.ts b/app/features/art/ArtRepository.server.ts index 9040f48b2..f2feacf18 100644 --- a/app/features/art/ArtRepository.server.ts +++ b/app/features/art/ArtRepository.server.ts @@ -2,21 +2,16 @@ import type { Transaction } from "kysely"; import { jsonArrayFrom } from "kysely/helpers/sqlite"; import { db } from "~/db/sql"; import type { DB, Tables } from "~/db/tables"; +import { actorId } from "~/features/auth/core/user.server"; import { concatUserSubmittedImagePrefix } from "~/utils/kysely.server"; import { seededRandom } from "~/utils/random"; import type { ListedArt } from "./art-types"; -export function unlinkUserFromArt({ - userId, - artId, -}: { - userId: number; - artId: number; -}) { +export function unlinkSelfFromArt(artId: number) { return db .deleteFrom("ArtUserMetadata") .where("artId", "=", artId) - .where("userId", "=", userId) + .where("userId", "=", actorId()) .execute(); } @@ -308,18 +303,19 @@ export async function deleteById(id: number) { type TagsToAdd = Array>>; -type InsertArtArgs = Pick & +type InsertArtArgs = Pick & Pick & { linkedUsers: number[]; tags: TagsToAdd; }; export async function insert(args: InsertArtArgs) { + const authorId = actorId(); return await db.transaction().execute(async (trx) => { const img = await trx .insertInto("UnvalidatedUserSubmittedImage") .values({ - submitterUserId: args.authorId, + submitterUserId: authorId, url: args.url, validatedAt: args.validatedAt, }) @@ -329,13 +325,13 @@ export async function insert(args: InsertArtArgs) { const hasExistingArt = await trx .selectFrom("Art") .select("id") - .where("authorId", "=", args.authorId) + .where("authorId", "=", authorId) .executeTakeFirst(); const art = await trx .insertInto("Art") .values({ - authorId: args.authorId, + authorId, description: args.description, imgId: img.id, isShowcase: hasExistingArt ? 0 : 1, @@ -352,7 +348,7 @@ export async function insert(args: InsertArtArgs) { await insertTags(trx, { tags: args.tags, - authorId: args.authorId, + authorId, artId: art.id, }); diff --git a/app/features/art/actions/art.new.server.ts b/app/features/art/actions/art.new.server.ts index be31e60af..166767e08 100644 --- a/app/features/art/actions/art.new.server.ts +++ b/app/features/art/actions/art.new.server.ts @@ -113,7 +113,6 @@ export const action: ActionFunction = async ({ request }) => { }); const addedArt = await ArtRepository.insert({ - authorId: user.id, description: data.description, url: fileName, validatedAt: user.patronTier ? dateToDatabaseTimestamp(new Date()) : null, diff --git a/app/features/auth/core/user.server.ts b/app/features/auth/core/user.server.ts index 1e9a171c0..03667dba5 100644 --- a/app/features/auth/core/user.server.ts +++ b/app/features/auth/core/user.server.ts @@ -17,6 +17,21 @@ export function requireUser(): AuthenticatedUser { return user; } +/** Id of the acting user, from request context. Throws an Error if there is no + * authenticated user (e.g. called outside a request) — repositories rely on a + * bouncer having already enforced auth, so absence here is a bug, not a 401. */ +export function actorId(): number { + const id = actorIdOrNull(); + if (id === null) throw new Error("No acting user in context"); + return id; +} + +/** Id of the acting user, or null when unauthenticated. Use for reads that + * also serve anonymous visitors, where the actor only scopes the result. */ +export function actorIdOrNull(): number | null { + return getUser()?.id ?? null; +} + export async function isImpersonating(request: Request) { const session = await authSessionStorage.getSession( request.headers.get("Cookie"), diff --git a/app/features/calendar/actions/calendar.tsx b/app/features/calendar/actions/calendar.tsx index 1773567bb..fb88e06a2 100644 --- a/app/features/calendar/actions/calendar.tsx +++ b/app/features/calendar/actions/calendar.tsx @@ -1,5 +1,4 @@ import { type ActionFunctionArgs, redirect } from "react-router"; -import { requireUser } from "~/features/auth/core/user.server"; import { calendarFiltersSearchParamsSchema } from "~/features/calendar/calendar-schemas"; import * as UserRepository from "~/features/user-page/UserRepository.server"; import { @@ -10,13 +9,12 @@ import { calendarPage } from "~/utils/urls"; import { dayMonthYear } from "~/utils/zod"; export const action = async ({ request }: ActionFunctionArgs) => { - const user = requireUser(); const data = await parseRequestPayload({ request, schema: calendarFiltersSearchParamsSchema, }); - await UserRepository.updatePreferences(user.id, { + await UserRepository.updateOwnPreferences({ defaultCalendarFilters: data, }); diff --git a/app/features/chat/RoomLinkRepository.server.ts b/app/features/chat/RoomLinkRepository.server.ts index 68f092930..aec58f5f8 100644 --- a/app/features/chat/RoomLinkRepository.server.ts +++ b/app/features/chat/RoomLinkRepository.server.ts @@ -1,17 +1,18 @@ import { sub } from "date-fns"; import { db } from "~/db/sql"; +import { actorId } from "~/features/auth/core/user.server"; import { databaseTimestampNow, dateToDatabaseTimestamp } from "~/utils/dates"; -export function upsert(args: { userId: number; url: string }) { +export function upsertOwn(url: string) { return db .insertInto("RoomLink") .values({ - userId: args.userId, - url: args.url, + userId: actorId(), + url, }) .onConflict((oc) => oc.column("userId").doUpdateSet({ - url: args.url, + url, createdAt: databaseTimestampNow(), refreshedAt: databaseTimestampNow(), }), @@ -38,11 +39,11 @@ export function findByUserIds(userIds: number[], maxAgeHours: number) { .execute(); } -export function refreshTimestamp(userId: number) { +export function refreshOwnTimestamp() { return db .updateTable("RoomLink") .set({ refreshedAt: databaseTimestampNow() }) - .where("userId", "=", userId) + .where("userId", "=", actorId()) .execute(); } diff --git a/app/features/chat/routes/room.ts b/app/features/chat/routes/room.ts index 8f8ca195d..3c4a14c03 100644 --- a/app/features/chat/routes/room.ts +++ b/app/features/chat/routes/room.ts @@ -1,6 +1,5 @@ import type { ActionFunctionArgs } from "react-router"; import { z } from "zod"; -import { requireUser } from "~/features/auth/core/user.server"; import { parseRequestPayload } from "~/utils/remix.server"; import { isSplatnetRoomUrl } from "../chat-constants"; import * as RoomLinkRepository from "../RoomLinkRepository.server"; @@ -16,7 +15,6 @@ const roomLinkSchema = z.discriminatedUnion("_action", [ ]); export const action = async ({ request }: ActionFunctionArgs) => { - const user = requireUser(); const data = await parseRequestPayload({ request, schema: roomLinkSchema, @@ -24,11 +22,11 @@ export const action = async ({ request }: ActionFunctionArgs) => { switch (data._action) { case "UPSERT": { - await RoomLinkRepository.upsert({ userId: user.id, url: data.url }); + await RoomLinkRepository.upsertOwn(data.url); break; } case "CONFIRM": { - await RoomLinkRepository.refreshTimestamp(user.id); + await RoomLinkRepository.refreshOwnTimestamp(); break; } } diff --git a/app/features/friends/FriendRepository.server.test.ts b/app/features/friends/FriendRepository.server.test.ts index 592e0e499..951b2fb18 100644 --- a/app/features/friends/FriendRepository.server.test.ts +++ b/app/features/friends/FriendRepository.server.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, test } from "vitest"; -import { dbInsertUsers, dbReset } from "~/utils/Test"; +import { dbInsertUsers, dbReset, withUserId } from "~/utils/Test"; import * as FriendRepository from "./FriendRepository.server"; const createFriendRequest = async ({ @@ -330,10 +330,9 @@ describe("deleteFriendship", () => { userTwoId: 2, }); - await FriendRepository.deleteFriendship({ - id: friendship!.id, - userId: 1, - }); + await withUserId(1, () => + FriendRepository.deleteOwnFriendshipById(friendship!.id), + ); const result = await FriendRepository.findFriendship({ userOneId: 1, @@ -350,10 +349,9 @@ describe("deleteFriendship", () => { userTwoId: 2, }); - await FriendRepository.deleteFriendship({ - id: friendship!.id, - userId: 3, - }); + await withUserId(3, () => + FriendRepository.deleteOwnFriendshipById(friendship!.id), + ); const result = await FriendRepository.findFriendship({ userOneId: 1, diff --git a/app/features/friends/FriendRepository.server.ts b/app/features/friends/FriendRepository.server.ts index be59a8d69..af4e7bb4f 100644 --- a/app/features/friends/FriendRepository.server.ts +++ b/app/features/friends/FriendRepository.server.ts @@ -1,6 +1,7 @@ import { type SelectQueryBuilder, sql } from "kysely"; import { db } from "~/db/sql"; import type { DB } from "~/db/tables"; +import { actorId } from "~/features/auth/core/user.server"; import { dateToDatabaseTimestamp } from "~/utils/dates"; import { COMMON_USER_FIELDS } from "~/utils/kysely.server"; @@ -186,13 +187,8 @@ export async function findFriendRequestBetween({ .executeTakeFirst(); } -export async function deleteFriendship({ - id, - userId, -}: { - id: number; - userId: number; -}) { +export async function deleteOwnFriendshipById(id: number) { + const userId = actorId(); return db .deleteFrom("Friendship") .where("Friendship.id", "=", id) diff --git a/app/features/friends/actions/friends.server.ts b/app/features/friends/actions/friends.server.ts index 8bb023784..fdb012acc 100644 --- a/app/features/friends/actions/friends.server.ts +++ b/app/features/friends/actions/friends.server.ts @@ -53,10 +53,7 @@ export const action: ActionFunction = async ({ request }) => { break; } case "DELETE_FRIEND": { - await FriendRepository.deleteFriendship({ - id: result.data.friendshipId, - userId: user.id, - }); + await FriendRepository.deleteOwnFriendshipById(result.data.friendshipId); break; } diff --git a/app/features/img-upload/ImageRepository.server.test.ts b/app/features/img-upload/ImageRepository.server.test.ts index 3de8c0017..3a1cfea7b 100644 --- a/app/features/img-upload/ImageRepository.server.test.ts +++ b/app/features/img-upload/ImageRepository.server.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, test } from "vitest"; import { databaseTimestampNow } from "~/utils/dates"; -import { dbInsertUsers, dbReset } from "~/utils/Test"; +import { dbInsertUsers, dbReset, withUserId } from "~/utils/Test"; import * as ArtRepository from "../art/ArtRepository.server"; import * as CalendarRepository from "../calendar/CalendarRepository.server"; import * as ImageRepository from "./ImageRepository.server"; @@ -28,14 +28,15 @@ const createArtImage = async ({ validatedAt?: number | null; }) => { imageCounter++; - return ArtRepository.insert({ - authorId, - url: `art-${imageCounter}.png`, - validatedAt, - description: null, - linkedUsers: [], - tags: [], - }); + return withUserId(authorId, () => + ArtRepository.insert({ + url: `art-${imageCounter}.png`, + validatedAt, + description: null, + linkedUsers: [], + tags: [], + }), + ); }; const createCalendarEvent = async (authorId: number, avatarImgId?: number) => { @@ -305,14 +306,15 @@ describe("unvalidatedImages", () => { test("fetches unvalidated images with submitter info", async () => { imageCounter++; const filename = `art-${imageCounter}.png`; - await ArtRepository.insert({ - authorId: 1, - url: filename, - validatedAt: null, - description: null, - linkedUsers: [], - tags: [], - }); + await withUserId(1, () => + ArtRepository.insert({ + url: filename, + validatedAt: null, + description: null, + linkedUsers: [], + tags: [], + }), + ); const result = await ImageRepository.unvalidatedImages(); diff --git a/app/features/match-profile/MatchProfileRepository.server.ts b/app/features/match-profile/MatchProfileRepository.server.ts index 85220ca95..f6baa7115 100644 --- a/app/features/match-profile/MatchProfileRepository.server.ts +++ b/app/features/match-profile/MatchProfileRepository.server.ts @@ -1,5 +1,6 @@ import { db } from "~/db/sql"; import type { Tables, UserMapModePreferences } from "~/db/tables"; +import { actorId } from "~/features/auth/core/user.server"; import type { WeaponPoolItem } from "~/form/fields/WeaponPoolFormField"; import type { UnifiedLanguageCode } from "~/modules/i18n/config"; import { modesShort } from "~/modules/in-game-lists/modes"; @@ -42,8 +43,7 @@ export function updateVoiceChat(args: { .execute(); } -export async function updateMatchProfile({ - userId, +export async function updateOwnMatchProfile({ mapModePreferences, vc, languages, @@ -51,7 +51,6 @@ export async function updateMatchProfile({ noScreen, noSplatnet, }: { - userId: number; mapModePreferences: UserMapModePreferences; vc: Tables["User"]["vc"]; languages: string[]; @@ -59,6 +58,7 @@ export async function updateMatchProfile({ noScreen: number; noSplatnet: number; }) { + const userId = actorId(); const currentPreferences = ( await db .selectFrom("User") diff --git a/app/features/notifications/NotificationRepository.server.ts b/app/features/notifications/NotificationRepository.server.ts index 86f6610db..fb4ba3fe6 100644 --- a/app/features/notifications/NotificationRepository.server.ts +++ b/app/features/notifications/NotificationRepository.server.ts @@ -1,6 +1,7 @@ import { sub } from "date-fns"; import { db } from "~/db/sql"; import type { NotificationSubscription, TablesInsertable } from "~/db/tables"; +import { actorId } from "~/features/auth/core/user.server"; import { dateToDatabaseTimestamp } from "../../utils/dates"; import { NOTIFICATIONS } from "./notifications-contants"; import type { Notification } from "./notifications-types"; @@ -68,18 +69,12 @@ export function findAllByType(type: T) { .execute() as Promise>>; } -export function markAsSeen({ - notificationIds, - userId, -}: { - notificationIds: number[]; - userId: number; -}) { +export function markOwnAsSeen(notificationIds: number[]) { return db .updateTable("NotificationUser") .set("seen", 1) .where("NotificationUser.notificationId", "in", notificationIds) - .where("NotificationUser.userId", "=", userId) + .where("NotificationUser.userId", "=", actorId()) .execute(); } @@ -94,15 +89,12 @@ export function deleteOld() { .executeTakeFirst(); } -export function addSubscription(args: { - userId: number; - subscription: NotificationSubscription; -}) { +export function addOwnSubscription(subscription: NotificationSubscription) { return db .insertInto("NotificationUserSubscription") .values({ - userId: args.userId, - subscription: JSON.stringify(args.subscription), + userId: actorId(), + subscription: JSON.stringify(subscription), }) .execute(); } diff --git a/app/features/notifications/routes/notifications.seen.ts b/app/features/notifications/routes/notifications.seen.ts index 49f31861f..ec369e047 100644 --- a/app/features/notifications/routes/notifications.seen.ts +++ b/app/features/notifications/routes/notifications.seen.ts @@ -1,20 +1,15 @@ import type { ActionFunctionArgs } from "react-router"; -import { requireUser } from "~/features/auth/core/user.server"; import { parseRequestPayload } from "~/utils/remix.server"; import * as NotificationRepository from "../NotificationRepository.server"; import { markAsSeenActionSchema } from "../notifications-schemas"; export const action = async ({ request }: ActionFunctionArgs) => { - const user = requireUser(); const data = await parseRequestPayload({ request, schema: markAsSeenActionSchema, }); - await NotificationRepository.markAsSeen({ - userId: user.id, - notificationIds: data.notificationIds, - }); + await NotificationRepository.markOwnAsSeen(data.notificationIds); return null; }; diff --git a/app/features/notifications/routes/notifications.subscribe.ts b/app/features/notifications/routes/notifications.subscribe.ts index 48de80ac0..c3ca7ca55 100644 --- a/app/features/notifications/routes/notifications.subscribe.ts +++ b/app/features/notifications/routes/notifications.subscribe.ts @@ -1,20 +1,15 @@ import type { ActionFunctionArgs } from "react-router"; -import { requireUser } from "~/features/auth/core/user.server"; import { parseRequestPayload } from "~/utils/remix.server"; import * as NotificationRepository from "../NotificationRepository.server"; import { subscribeSchema } from "../notifications-schemas"; export const action = async ({ request }: ActionFunctionArgs) => { - const user = requireUser(); const data = await parseRequestPayload({ request, schema: subscribeSchema, }); - await NotificationRepository.addSubscription({ - userId: user.id, - subscription: data, - }); + await NotificationRepository.addOwnSubscription(data); return null; }; diff --git a/app/features/scrims/ScrimMapRepository.server.ts b/app/features/scrims/ScrimMapRepository.server.ts index 7b663279f..24a672366 100644 --- a/app/features/scrims/ScrimMapRepository.server.ts +++ b/app/features/scrims/ScrimMapRepository.server.ts @@ -1,6 +1,7 @@ import type { Transaction } from "kysely"; import { db } from "~/db/sql"; import type { DB, TablesInsertable } from "~/db/tables"; +import { actorId } from "~/features/auth/core/user.server"; import type { ModeShort, StageId } from "~/modules/in-game-lists/types"; import { databaseTimestampNow } from "~/utils/dates"; import * as Scrim from "./core/Scrim"; @@ -11,9 +12,6 @@ interface ReportMapArgs { scrimPostId: number; mapId: number; winnerSide: NonNullable; - reportedByUserId: NonNullable< - TablesInsertable["ScrimMap"]["reportedByUserId"] - >; } /** @@ -30,7 +28,7 @@ export async function reportMapAndGenerateNext( .set({ winnerSide: args.winnerSide, reportedAt: databaseTimestampNow(), - reportedByUserId: args.reportedByUserId, + reportedByUserId: actorId(), }) .where("id", "=", args.mapId) .where("reportedAt", "is", null) diff --git a/app/features/scrims/ScrimPostRepository.server.ts b/app/features/scrims/ScrimPostRepository.server.ts index 6873b5a60..0503848f6 100644 --- a/app/features/scrims/ScrimPostRepository.server.ts +++ b/app/features/scrims/ScrimPostRepository.server.ts @@ -2,6 +2,7 @@ import { sub } from "date-fns"; import type { Insertable } from "kysely"; import { jsonArrayFrom, jsonBuildObject } from "kysely/helpers/sqlite"; import type { Tables, TablesInsertable } from "~/db/tables"; +import { actorId, actorIdOrNull } from "~/features/auth/core/user.server"; import { databaseTimestampNow, dateToDatabaseTimestamp } from "~/utils/dates"; import { ConcurrentModificationError } from "~/utils/errors"; import { shortNanoid } from "~/utils/id"; @@ -339,7 +340,8 @@ export async function findById(scrimPostId: number): Promise { return mapDBRowToScrimPost(row); } -export async function findAllRelevant(userId?: number): Promise { +export async function findAllRelevant(): Promise { + const userId = actorIdOrNull(); const rows = await findMany(); const mapped = rows @@ -391,15 +393,12 @@ export function deleteRequest(scrimPostRequestId: number) { .execute(); } -export async function cancelScrim( - id: number, - { userId, reason }: { userId: number; reason: string }, -) { +export async function cancelScrim(id: number, reason: string) { await db .updateTable("ScrimPost") .set({ canceledAt: databaseTimestampNow(), - canceledByUserId: userId, + canceledByUserId: actorId(), cancelReason: reason, }) .where("id", "=", id) diff --git a/app/features/scrims/actions/scrims.$id.server.ts b/app/features/scrims/actions/scrims.$id.server.ts index 9f5e60a75..b17fbd3eb 100644 --- a/app/features/scrims/actions/scrims.$id.server.ts +++ b/app/features/scrims/actions/scrims.$id.server.ts @@ -44,10 +44,7 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { errorToast("Cannot cancel a scrim that was already scheduled to start"); } - await ScrimPostRepository.cancelScrim(id, { - userId: user.id, - reason: data.reason, - }); + await ScrimPostRepository.cancelScrim(id, data.reason); const acceptedRequest = post.requests.find((r) => r.isAccepted); if (acceptedRequest) { @@ -128,7 +125,6 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { scrimPostId: post.id, mapId: data.mapId, winnerSide: data.winnerSide, - reportedByUserId: user.id, }); broadcastRevalidate({ post, user }); diff --git a/app/features/scrims/actions/scrims.server.ts b/app/features/scrims/actions/scrims.server.ts index e1a29dff1..72dfa4491 100644 --- a/app/features/scrims/actions/scrims.server.ts +++ b/app/features/scrims/actions/scrims.server.ts @@ -38,7 +38,6 @@ export const action = async ({ request }: ActionFunctionArgs) => { switch (data._action) { case "DELETE_POST": { const post = await findPost({ - userId: user.id, postId: data.scrimPostId, }); requirePermission(post, "DELETE_POST"); @@ -54,7 +53,6 @@ export const action = async ({ request }: ActionFunctionArgs) => { } case "NEW_REQUEST": { const post = await findPost({ - userId: user.id, postId: data.scrimPostId, }); @@ -121,7 +119,6 @@ export const action = async ({ request }: ActionFunctionArgs) => { } case "ACCEPT_REQUEST": { const { post, request } = await findRequest({ - userId: user.id, requestId: data.scrimPostRequestId, }); requirePermission(post, "MANAGE_REQUESTS"); @@ -181,7 +178,6 @@ export const action = async ({ request }: ActionFunctionArgs) => { } case "CANCEL_REQUEST": { const { request } = await findRequest({ - userId: user.id, requestId: data.scrimPostRequestId, }); requirePermission(request, "CANCEL"); @@ -196,7 +192,7 @@ export const action = async ({ request }: ActionFunctionArgs) => { break; } case "PERSIST_SCRIM_FILTERS": { - await UserRepository.updatePreferences(user.id, { + await UserRepository.updateOwnPreferences({ defaultScrimsFilters: data.filters, }); @@ -210,14 +206,8 @@ export const action = async ({ request }: ActionFunctionArgs) => { return null; }; -async function findPost({ - userId, - postId, -}: { - userId: number; - postId: number; -}) { - const posts = await ScrimPostRepository.findAllRelevant(userId); +async function findPost({ postId }: { postId: number }) { + const posts = await ScrimPostRepository.findAllRelevant(); const post = posts.find((post) => post.id === postId); errorToastIfFalsy(post, "Post not found"); @@ -225,14 +215,8 @@ async function findPost({ return post; } -async function findRequest({ - userId, - requestId, -}: { - userId: number; - requestId: number; -}) { - const posts = await ScrimPostRepository.findAllRelevant(userId); +async function findRequest({ requestId }: { requestId: number }) { + const posts = await ScrimPostRepository.findAllRelevant(); const post = posts.find((post) => post.requests.some((request) => request.id === requestId), ); diff --git a/app/features/scrims/loaders/scrims.server.ts b/app/features/scrims/loaders/scrims.server.ts index 81b7e37cc..ffe1e6bcc 100644 --- a/app/features/scrims/loaders/scrims.server.ts +++ b/app/features/scrims/loaders/scrims.server.ts @@ -25,7 +25,7 @@ export const loader = async ({ request }: LoaderFunctionArgs) => { ? user?.preferences?.defaultScrimsFilters : filtersFromSearchParams; - const posts = (await ScrimPostRepository.findAllRelevant(user?.id)) + const posts = (await ScrimPostRepository.findAllRelevant()) .filter( (post) => (user && Scrim.isParticipating(post, user.id)) || diff --git a/app/features/sendouq-match/GroupMatchContinueVoteRepository.server.test.ts b/app/features/sendouq-match/GroupMatchContinueVoteRepository.server.test.ts index 088f8d1ef..cd769b105 100644 --- a/app/features/sendouq-match/GroupMatchContinueVoteRepository.server.test.ts +++ b/app/features/sendouq-match/GroupMatchContinueVoteRepository.server.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, test } from "vitest"; import { db } from "~/db/sql"; -import { dbInsertUsers, dbReset } from "~/utils/Test"; +import { dbInsertUsers, dbReset, withUserId } from "~/utils/Test"; import * as GroupMatchContinueVoteRepository from "./GroupMatchContinueVoteRepository.server"; const insertGroup = async () => { @@ -42,21 +42,24 @@ describe("findForGroups", () => { const groupB = await insertGroup(); const groupC = await insertGroup(); - await GroupMatchContinueVoteRepository.cast({ - groupId: groupA, - userId: 1, - isContinuing: 1, - }); - await GroupMatchContinueVoteRepository.cast({ - groupId: groupB, - userId: 2, - isContinuing: 0, - }); - await GroupMatchContinueVoteRepository.cast({ - groupId: groupC, - userId: 3, - isContinuing: 1, - }); + await withUserId(1, () => + GroupMatchContinueVoteRepository.cast({ + groupId: groupA, + isContinuing: 1, + }), + ); + await withUserId(2, () => + GroupMatchContinueVoteRepository.cast({ + groupId: groupB, + isContinuing: 0, + }), + ); + await withUserId(3, () => + GroupMatchContinueVoteRepository.cast({ + groupId: groupC, + isContinuing: 1, + }), + ); const result = await GroupMatchContinueVoteRepository.findForGroups([ groupA, @@ -83,16 +86,18 @@ describe("cast", () => { test("updates existing vote on conflict instead of inserting a duplicate", async () => { const groupId = await insertGroup(); - await GroupMatchContinueVoteRepository.cast({ - groupId, - userId: 1, - isContinuing: 1, - }); - await GroupMatchContinueVoteRepository.cast({ - groupId, - userId: 1, - isContinuing: 0, - }); + await withUserId(1, () => + GroupMatchContinueVoteRepository.cast({ + groupId, + isContinuing: 1, + }), + ); + await withUserId(1, () => + GroupMatchContinueVoteRepository.cast({ + groupId, + isContinuing: 0, + }), + ); const votes = await fetchVotes(groupId); expect(votes).toHaveLength(1); @@ -103,27 +108,31 @@ describe("cast", () => { const groupA = await insertGroup(); const groupB = await insertGroup(); - await GroupMatchContinueVoteRepository.cast({ - groupId: groupA, - userId: 1, - isContinuing: 1, - }); - await GroupMatchContinueVoteRepository.cast({ - groupId: groupA, - userId: 2, - isContinuing: 1, - }); - await GroupMatchContinueVoteRepository.cast({ - groupId: groupB, - userId: 1, - isContinuing: 1, - }); + await withUserId(1, () => + GroupMatchContinueVoteRepository.cast({ + groupId: groupA, + isContinuing: 1, + }), + ); + await withUserId(2, () => + GroupMatchContinueVoteRepository.cast({ + groupId: groupA, + isContinuing: 1, + }), + ); + await withUserId(1, () => + GroupMatchContinueVoteRepository.cast({ + groupId: groupB, + isContinuing: 1, + }), + ); - await GroupMatchContinueVoteRepository.cast({ - groupId: groupA, - userId: 3, - isContinuing: 0, - }); + await withUserId(3, () => + GroupMatchContinueVoteRepository.cast({ + groupId: groupA, + isContinuing: 0, + }), + ); const groupAVotes = await fetchVotes(groupA); expect(groupAVotes).toHaveLength(1); diff --git a/app/features/sendouq-match/GroupMatchContinueVoteRepository.server.ts b/app/features/sendouq-match/GroupMatchContinueVoteRepository.server.ts index 1d4df60b7..7b2c3776a 100644 --- a/app/features/sendouq-match/GroupMatchContinueVoteRepository.server.ts +++ b/app/features/sendouq-match/GroupMatchContinueVoteRepository.server.ts @@ -1,6 +1,7 @@ import type { Transaction } from "kysely"; import { db } from "~/db/sql"; import type { DB, DBBoolean } from "~/db/tables"; +import { actorId } from "~/features/auth/core/user.server"; export async function findForGroups(groupIds: number[], trx?: Transaction) { if (groupIds.length === 0) return []; @@ -27,15 +28,14 @@ export async function findForGroups(groupIds: number[], trx?: Transaction) { export async function cast( { groupId, - userId, isContinuing, }: { groupId: number; - userId: number; isContinuing: DBBoolean; }, trx?: Transaction, ) { + const userId = actorId(); const executor = trx ?? db; const runner = async (t: Transaction) => { diff --git a/app/features/sendouq-match/ReportedWeaponRepository.server.ts b/app/features/sendouq-match/ReportedWeaponRepository.server.ts index 9f8061146..4d11f4cf3 100644 --- a/app/features/sendouq-match/ReportedWeaponRepository.server.ts +++ b/app/features/sendouq-match/ReportedWeaponRepository.server.ts @@ -1,6 +1,7 @@ import type { NotNull, Transaction } from "kysely"; import { db } from "~/db/sql"; import type { DB, TablesInsertable } from "~/db/tables"; +import { actorId } from "~/features/auth/core/user.server"; import * as Seasons from "~/features/mmr/core/Seasons"; import type { MainWeaponId, @@ -19,15 +20,15 @@ export function createMany( return (trx ?? db).insertInto("ReportedWeapon").values(weapons).execute(); } -export async function upsertOne({ +export async function upsertOwn({ groupMatchId, mapIndex, - userId, weaponSplId, -}: TablesInsertable["ReportedWeapon"] & { +}: Omit & { groupMatchId: number; mapIndex: number; }) { + const userId = actorId(); await db .deleteFrom("ReportedWeapon") .where("groupMatchId", "=", groupMatchId) @@ -58,20 +59,18 @@ export async function replaceByMatchId( } } -export async function deleteByUserMapIndex({ +export async function deleteOwnByMapIndex({ matchId, - userId, mapIndex, }: { matchId: number; - userId: number; mapIndex: number; }) { await db .deleteFrom("ReportedWeapon") .where("groupMatchId", "=", matchId) .where("mapIndex", "=", mapIndex) - .where("userId", "=", userId) + .where("userId", "=", actorId()) .execute(); } @@ -112,17 +111,17 @@ export async function findByMatchId(matchId: number) { return rows; } -export async function upsertOneTournament({ +export async function upsertOwnTournament({ tournamentMatchId, mapIndex, - userId, weaponSplId, createdAt, -}: TablesInsertable["ReportedWeapon"] & { +}: Omit & { tournamentMatchId: number; mapIndex: number; createdAt: number; }) { + const userId = actorId(); await db .deleteFrom("ReportedWeapon") .where("tournamentMatchId", "=", tournamentMatchId) @@ -136,20 +135,18 @@ export async function upsertOneTournament({ .execute(); } -export async function deleteByUserMapIndexTournament({ +export async function deleteOwnByMapIndexTournament({ tournamentMatchId, - userId, mapIndex, }: { tournamentMatchId: number; - userId: number; mapIndex: number; }) { await db .deleteFrom("ReportedWeapon") .where("tournamentMatchId", "=", tournamentMatchId) .where("mapIndex", "=", mapIndex) - .where("userId", "=", userId) + .where("userId", "=", actorId()) .execute(); } diff --git a/app/features/sendouq-match/SQMatchRepository.server.test.ts b/app/features/sendouq-match/SQMatchRepository.server.test.ts index 31ea5d24d..ec0140cd7 100644 --- a/app/features/sendouq-match/SQMatchRepository.server.test.ts +++ b/app/features/sendouq-match/SQMatchRepository.server.test.ts @@ -1,7 +1,7 @@ import { afterEach, beforeEach, describe, expect, test } from "vitest"; import { db } from "~/db/sql"; import type { ModeShort, StageId } from "~/modules/in-game-lists/types"; -import { dbInsertUsers, dbReset } from "~/utils/Test"; +import { dbInsertUsers, dbReset, withUserId } from "~/utils/Test"; import * as SQGroupRepository from "../sendouq/SQGroupRepository.server"; import * as SQMatchRepository from "./SQMatchRepository.server"; @@ -126,10 +126,11 @@ describe("cancelMatch", () => { const bravoGroupId = await createGroup([5, 6, 7, 8]); const match = await createMatch(alphaGroupId, bravoGroupId); - const result = await SQMatchRepository.cancelMatch({ - matchId: match.id, - reportedByUserId: 1, - }); + const result = await withUserId(1, () => + SQMatchRepository.cancelMatch({ + matchId: match.id, + }), + ); expect(result.status).toBe("CANCEL_REPORTED"); expect(result.shouldRefreshCaches).toBe(false); @@ -143,15 +144,17 @@ describe("cancelMatch", () => { const bravoGroupId = await createGroup([5, 6, 7, 8]); const match = await createMatch(alphaGroupId, bravoGroupId); - await SQMatchRepository.cancelMatch({ - matchId: match.id, - reportedByUserId: 1, - }); + await withUserId(1, () => + SQMatchRepository.cancelMatch({ + matchId: match.id, + }), + ); - const result = await SQMatchRepository.cancelMatch({ - matchId: match.id, - reportedByUserId: 5, - }); + const result = await withUserId(5, () => + SQMatchRepository.cancelMatch({ + matchId: match.id, + }), + ); expect(result.status).toBe("CANCEL_CONFIRMED"); expect(result.shouldRefreshCaches).toBe(true); @@ -178,10 +181,11 @@ describe("cancelMatch", () => { reportedCount: 0, }); - const result = await SQMatchRepository.cancelMatch({ - matchId: match.id, - reportedByUserId: 5, - }); + const result = await withUserId(5, () => + SQMatchRepository.cancelMatch({ + matchId: match.id, + }), + ); expect(result.status).toBe("CANT_CANCEL"); expect(result.shouldRefreshCaches).toBe(false); @@ -193,11 +197,12 @@ describe("cancelMatch", () => { const match = await createMatch(alphaGroupId, bravoGroupId); const adminUserId = 1; - const result = await SQMatchRepository.cancelMatch({ - matchId: match.id, - reportedByUserId: adminUserId, - isAdminReport: true, - }); + const result = await withUserId(adminUserId, () => + SQMatchRepository.cancelMatch({ + matchId: match.id, + isAdminReport: true, + }), + ); expect(result.status).toBe("CANCEL_CONFIRMED"); expect(result.shouldRefreshCaches).toBe(true); diff --git a/app/features/sendouq-match/SQMatchRepository.server.ts b/app/features/sendouq-match/SQMatchRepository.server.ts index 6cd55bee3..086c0ecb9 100644 --- a/app/features/sendouq-match/SQMatchRepository.server.ts +++ b/app/features/sendouq-match/SQMatchRepository.server.ts @@ -4,6 +4,7 @@ import { jsonArrayFrom, jsonObjectFrom } from "kysely/helpers/sqlite"; import * as R from "remeda"; import { db } from "~/db/sql"; import type { DB, ParsedMemento } from "~/db/tables"; +import { actorId } from "~/features/auth/core/user.server"; import * as Seasons from "~/features/mmr/core/Seasons"; import type { TournamentMapListMap } from "~/modules/tournament-map-list-generator/types"; import { mostPopularArrayElement } from "~/utils/arrays"; @@ -578,13 +579,12 @@ export type CancelMatchResult = export async function cancelMatch({ matchId, - reportedByUserId, isAdminReport, }: { matchId: number; - reportedByUserId: number; isAdminReport?: boolean; }): Promise { + const reportedByUserId = actorId(); const match = await findById(matchId); invariant(match, "Match not found"); diff --git a/app/features/sendouq-match/actions/q.match.$id.server.ts b/app/features/sendouq-match/actions/q.match.$id.server.ts index 46c2cc85f..58e1cf2eb 100644 --- a/app/features/sendouq-match/actions/q.match.$id.server.ts +++ b/app/features/sendouq-match/actions/q.match.$id.server.ts @@ -192,7 +192,6 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { await GroupMatchContinueVoteRepository.cast( { groupId: viewerGroup.id, - userId: user.id, isContinuing: data.isContinuing, }, trx, @@ -237,27 +236,24 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { break; } case "REPORT_WEAPON": { - await ReportedWeaponRepository.upsertOne({ + await ReportedWeaponRepository.upsertOwn({ groupMatchId: matchId, mapIndex: data.mapIndex, - userId: user.id, weaponSplId: data.weaponSplId, }); break; } case "UNDO_WEAPON_REPORT": { - await ReportedWeaponRepository.deleteByUserMapIndex({ + await ReportedWeaponRepository.deleteOwnByMapIndex({ matchId, - userId: user.id, mapIndex: data.mapIndex, }); break; } case "ADD_PRIVATE_USER_NOTE": { - await PrivateUserNoteRepository.upsert({ - authorId: user.id, + await PrivateUserNoteRepository.upsertOwnNote({ sentiment: data.sentiment, targetId: data.targetId, text: data.comment, @@ -372,7 +368,6 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { const result = await SQMatchRepository.cancelMatch({ matchId, - reportedByUserId: user.id, isAdminReport: true, }); diff --git a/app/features/sendouq-match/loaders/q.match.$id.server.ts b/app/features/sendouq-match/loaders/q.match.$id.server.ts index 5a5992d0c..68be5c9bd 100644 --- a/app/features/sendouq-match/loaders/q.match.$id.server.ts +++ b/app/features/sendouq-match/loaders/q.match.$id.server.ts @@ -31,9 +31,7 @@ export const loader = async ({ params }: LoaderFunctionArgs) => { const [privateNotes, roomLinks, anyUserPrefersNoSplatnet, reportedWeapons] = await Promise.all([ - user - ? PrivateUserNoteRepository.byAuthorUserId(user.id, matchUsers) - : undefined, + user ? PrivateUserNoteRepository.ownNotes(matchUsers) : undefined, RoomLinkRepository.findByUserIds(matchUsers, 3), UserRepository.anyUserPrefersNoSplatnet(matchUsers), ReportedWeaponRepository.findByMatchId(matchId), diff --git a/app/features/sendouq/PrivateUserNoteRepository.server.test.ts b/app/features/sendouq/PrivateUserNoteRepository.server.test.ts new file mode 100644 index 000000000..fd560135f --- /dev/null +++ b/app/features/sendouq/PrivateUserNoteRepository.server.test.ts @@ -0,0 +1,103 @@ +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import * as UserRepository from "~/features/user-page/UserRepository.server"; +import { dbInsertUsers, dbReset, withUser } from "~/utils/Test"; +import * as PrivateUserNoteRepository from "./PrivateUserNoteRepository.server"; + +const authorAndTarget = async () => { + await dbInsertUsers(2); + const author = (await UserRepository.findLeanById(1))!; + return { author }; +}; + +describe("PrivateUserNoteRepository", () => { + afterEach(() => { + dbReset(); + }); + + describe("upsertOwnNote", () => { + beforeEach(async () => { + await authorAndTarget(); + }); + + test("stamps the acting user as the author", async () => { + const author = (await UserRepository.findLeanById(1))!; + + await withUser(author, () => + PrivateUserNoteRepository.upsertOwnNote({ + targetId: 2, + sentiment: "POSITIVE", + text: "good teammate", + }), + ); + + const notes = await withUser(author, () => + PrivateUserNoteRepository.ownNotes(), + ); + + expect(notes).toHaveLength(1); + expect(notes[0]).toMatchObject({ + targetUserId: 2, + sentiment: "POSITIVE", + text: "good teammate", + }); + }); + + test("updates an existing note on conflict", async () => { + const author = (await UserRepository.findLeanById(1))!; + + await withUser(author, () => + PrivateUserNoteRepository.upsertOwnNote({ + targetId: 2, + sentiment: "POSITIVE", + text: "first", + }), + ); + await withUser(author, () => + PrivateUserNoteRepository.upsertOwnNote({ + targetId: 2, + sentiment: "NEGATIVE", + text: "second", + }), + ); + + const notes = await withUser(author, () => + PrivateUserNoteRepository.ownNotes(), + ); + + expect(notes).toHaveLength(1); + expect(notes[0].sentiment).toBe("NEGATIVE"); + expect(notes[0].text).toBe("second"); + }); + }); + + describe("deleteOwnNote", () => { + beforeEach(async () => { + await authorAndTarget(); + }); + + test("deletes the acting user's note", async () => { + const author = (await UserRepository.findLeanById(1))!; + + await withUser(author, () => + PrivateUserNoteRepository.upsertOwnNote({ + targetId: 2, + sentiment: "NEUTRAL", + text: "note", + }), + ); + await withUser(author, () => + PrivateUserNoteRepository.deleteOwnNoteById(2), + ); + + const notes = await withUser(author, () => + PrivateUserNoteRepository.ownNotes(), + ); + + expect(notes).toHaveLength(0); + }); + }); + + test("ownNotes throws when called without an acting user", () => { + expect(() => PrivateUserNoteRepository.ownNotes()).toThrow(); + }); +}); diff --git a/app/features/sendouq/PrivateUserNoteRepository.server.ts b/app/features/sendouq/PrivateUserNoteRepository.server.ts index 86349a1ae..8db71fb6d 100644 --- a/app/features/sendouq/PrivateUserNoteRepository.server.ts +++ b/app/features/sendouq/PrivateUserNoteRepository.server.ts @@ -1,12 +1,14 @@ import { db } from "~/db/sql"; import type { TablesInsertable } from "~/db/tables"; +import { actorId } from "~/features/auth/core/user.server"; import { databaseTimestampNow } from "~/utils/dates"; -export function byAuthorUserId( - authorId: number, - /** Which users to get notes for, if omitted all notes for author are returned */ +export function ownNotes( + /** Which users to get notes for, if omitted all notes for the author are returned */ targetUserIds: number[] = [], ) { + const authorId = actorId(); + let query = db .selectFrom("PrivateUserNote") .select([ @@ -27,11 +29,14 @@ export function byAuthorUserId( return query.execute(); } -export function upsert(args: TablesInsertable["PrivateUserNote"]) { +export function upsertOwnNote( + args: Omit, +) { + const authorId = actorId(); return db .insertInto("PrivateUserNote") .values({ - authorId: args.authorId, + authorId, targetId: args.targetId, sentiment: args.sentiment, text: args.text, @@ -46,16 +51,10 @@ export function upsert(args: TablesInsertable["PrivateUserNote"]) { .execute(); } -export function del({ - authorId, - targetId, -}: { - authorId: number; - targetId: number; -}) { +export function deleteOwnNoteById(targetId: number) { return db .deleteFrom("PrivateUserNote") - .where("authorId", "=", authorId) + .where("authorId", "=", actorId()) .where("targetId", "=", targetId) .execute(); } diff --git a/app/features/sendouq/SQGroupRepository.server.ts b/app/features/sendouq/SQGroupRepository.server.ts index ba850b719..72aaa3c7e 100644 --- a/app/features/sendouq/SQGroupRepository.server.ts +++ b/app/features/sendouq/SQGroupRepository.server.ts @@ -3,6 +3,7 @@ import { type NotNull, sql, type Transaction } from "kysely"; import { jsonArrayFrom, jsonBuildObject } from "kysely/helpers/sqlite"; import { db } from "~/db/sql"; import type { DB, Tables, UserMapModePreferences } from "~/db/tables"; +import { actorId } from "~/features/auth/core/user.server"; import { databaseTimestampNow, dateToDatabaseTimestamp } from "~/utils/dates"; import { shortNanoid } from "~/utils/id"; import invariant from "~/utils/invariant"; @@ -741,13 +742,11 @@ export function refreshGroup(groupId: number, trx?: Transaction) { .execute(); } -export function updateMemberNote({ +export function updateOwnMemberNote({ groupId, - userId, value, }: { groupId: number; - userId: number; value: string | null; }) { return db.transaction().execute(async (trx) => { @@ -755,7 +754,7 @@ export function updateMemberNote({ .updateTable("GroupMember") .set({ note: value }) .where("groupId", "=", groupId) - .where("userId", "=", userId) + .where("userId", "=", actorId()) .execute(); await refreshGroup(groupId, trx); diff --git a/app/features/sendouq/actions/q.looking.server.ts b/app/features/sendouq/actions/q.looking.server.ts index 2c562d8f7..25590e236 100644 --- a/app/features/sendouq/actions/q.looking.server.ts +++ b/app/features/sendouq/actions/q.looking.server.ts @@ -302,9 +302,8 @@ export const action: ActionFunction = async ({ request }) => { break; } case "UPDATE_NOTE": { - await SQGroupRepository.updateMemberNote({ + await SQGroupRepository.updateOwnMemberNote({ groupId: currentGroup.id, - userId: user.id, value: data.value, }); @@ -313,10 +312,7 @@ export const action: ActionFunction = async ({ request }) => { break; } case "DELETE_PRIVATE_USER_NOTE": { - await PrivateUserNoteRepository.del({ - authorId: user.id, - targetId: data.targetId, - }); + await PrivateUserNoteRepository.deleteOwnNoteById(data.targetId); break; } diff --git a/app/features/sendouq/core/SendouQ.server.test.ts b/app/features/sendouq/core/SendouQ.server.test.ts index a46b723fb..40224a8be 100644 --- a/app/features/sendouq/core/SendouQ.server.test.ts +++ b/app/features/sendouq/core/SendouQ.server.test.ts @@ -2,8 +2,9 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { db } from "~/db/sql"; import { refreshUserSkills } from "~/features/mmr/tiered.server"; import * as PrivateUserNoteRepository from "~/features/sendouq/PrivateUserNoteRepository.server"; +import * as UserRepository from "~/features/user-page/UserRepository.server"; import { databaseTimestampNow } from "~/utils/dates"; -import { dbInsertUsers, dbReset } from "~/utils/Test"; +import { dbInsertUsers, dbReset, withUser } from "~/utils/Test"; import * as SQGroupRepository from "../SQGroupRepository.server"; import { refreshSendouQInstance, SendouQ } from "./SendouQ.server"; @@ -68,18 +69,27 @@ const createMatch = async ( .execute(); }; +const ownNotesOf = async (authorId: number, targetUserIds?: number[]) => { + const user = await UserRepository.findLeanById(authorId); + return withUser(user!, () => + PrivateUserNoteRepository.ownNotes(targetUserIds), + ); +}; + const createPrivateNote = async ( authorId: number, targetId: number, sentiment: "POSITIVE" | "NEUTRAL" | "NEGATIVE", text = "test note", ) => { - await PrivateUserNoteRepository.upsert({ - authorId, - targetId, - sentiment, - text, - }); + const user = await UserRepository.findLeanById(authorId); + await withUser(user!, () => + PrivateUserNoteRepository.upsertOwnNote({ + targetId, + sentiment, + text, + }), + ); }; const insertSkill = async (userId: number, ordinal: number, season = 1) => { @@ -263,7 +273,7 @@ describe("SendouQ", () => { test("returns empty array when no groups exist", async () => { await refreshSendouQInstance(); - const notes = await PrivateUserNoteRepository.byAuthorUserId(1); + const notes = await ownNotesOf(1); const groups = SendouQ.previewGroups(1, notes); expect(groups).toEqual([]); @@ -273,7 +283,7 @@ describe("SendouQ", () => { await createGroup([1, 2, 3, 4]); await refreshSendouQInstance(); - const notes = await PrivateUserNoteRepository.byAuthorUserId(1); + const notes = await ownNotesOf(1); const groups = SendouQ.previewGroups(1, notes); expect(groups).toHaveLength(1); @@ -284,7 +294,7 @@ describe("SendouQ", () => { await createGroup([1, 2]); await refreshSendouQInstance(); - const notes = await PrivateUserNoteRepository.byAuthorUserId(1); + const notes = await ownNotesOf(1); const groups = SendouQ.previewGroups(1, notes); expect(groups).toHaveLength(1); @@ -297,7 +307,7 @@ describe("SendouQ", () => { await createPrivateNote(3, 2, "POSITIVE", "Great player"); await refreshSendouQInstance(); - const notes = await PrivateUserNoteRepository.byAuthorUserId(3); + const notes = await ownNotesOf(3); const groups = SendouQ.previewGroups(3, notes); expect(groups).toHaveLength(1); @@ -311,7 +321,7 @@ describe("SendouQ", () => { await createGroup([3, 4, 5, 6], { inviteCode: "CODE2" }); await refreshSendouQInstance(); - const notes = await PrivateUserNoteRepository.byAuthorUserId(1); + const notes = await ownNotesOf(1); const groups = SendouQ.previewGroups(1, notes); expect(groups).toHaveLength(2); @@ -327,7 +337,7 @@ describe("SendouQ", () => { await createGroup([7, 8, 9]); await refreshSendouQInstance(); - const notes = await PrivateUserNoteRepository.byAuthorUserId(1); + const notes = await ownNotesOf(1); const groups = SendouQ.previewGroups(1, notes); expect(groups).toHaveLength(3); @@ -344,7 +354,7 @@ describe("SendouQ", () => { await createGroup([5, 6]); await refreshSendouQInstance(); - const notes = await PrivateUserNoteRepository.byAuthorUserId(1); + const notes = await ownNotesOf(1); const groups = SendouQ.previewGroups(1, notes); const fullGroup = groups.find((g) => g.members === undefined); @@ -389,7 +399,7 @@ describe("SendouQ", () => { .execute(); await refreshSendouQInstance(); - const notes = await PrivateUserNoteRepository.byAuthorUserId(1); + const notes = await ownNotesOf(1); const groups = SendouQ.previewGroups(1, notes); expect(groups).toHaveLength(2); @@ -417,7 +427,7 @@ describe("SendouQ", () => { .execute(); await refreshSendouQInstance(); - const notes = await PrivateUserNoteRepository.byAuthorUserId(1); + const notes = await ownNotesOf(1); const groups = SendouQ.previewGroups(1, notes); expect(groups).toHaveLength(3); @@ -438,7 +448,7 @@ describe("SendouQ", () => { const partialGroupId = await createGroup([6]); await refreshSendouQInstance(); - const notes = await PrivateUserNoteRepository.byAuthorUserId(1); + const notes = await ownNotesOf(1); const groups = SendouQ.previewGroups(1, notes); expect(groups).toHaveLength(2); @@ -459,7 +469,7 @@ describe("SendouQ", () => { await createPrivateNote(1, 3, "NEGATIVE"); await refreshSendouQInstance(); - const notes = await PrivateUserNoteRepository.byAuthorUserId(1); + const notes = await ownNotesOf(1); const groups = SendouQ.previewGroups(1, notes); expect(groups).toHaveLength(3); @@ -476,7 +486,7 @@ describe("SendouQ", () => { await createGroup([3]); await refreshSendouQInstance(); - const notes = await PrivateUserNoteRepository.byAuthorUserId(1); + const notes = await ownNotesOf(1); const groups = SendouQ.previewGroups(1, notes); expect(groups).toHaveLength(2); @@ -498,7 +508,7 @@ describe("SendouQ", () => { await createGroup([1, 2, 3, 4]); await refreshSendouQInstance(); - const notes = await PrivateUserNoteRepository.byAuthorUserId(5); + const notes = await ownNotesOf(5); const groups = SendouQ.lookingGroups(5, notes); expect(groups).toEqual([]); @@ -516,7 +526,7 @@ describe("SendouQ", () => { await createGroup([4], { status: "ACTIVE" }); await refreshSendouQInstance(); - const notes = await PrivateUserNoteRepository.byAuthorUserId(1); + const notes = await ownNotesOf(1); const groups = SendouQ.lookingGroups(1, notes); expect(groups).toHaveLength(1); @@ -532,7 +542,7 @@ describe("SendouQ", () => { await refreshSendouQInstance(); - const notes = await PrivateUserNoteRepository.byAuthorUserId(1); + const notes = await ownNotesOf(1); const groups = SendouQ.lookingGroups(1, notes); expect(groups).toHaveLength(1); @@ -544,7 +554,7 @@ describe("SendouQ", () => { await createGroup([3, 4]); await refreshSendouQInstance(); - const notes = await PrivateUserNoteRepository.byAuthorUserId(1); + const notes = await ownNotesOf(1); const groups = SendouQ.lookingGroups(1, notes); expect(groups).toHaveLength(1); @@ -559,7 +569,7 @@ describe("SendouQ", () => { await createGroup([11, 12, 13, 14]); await refreshSendouQInstance(); - const notes = await PrivateUserNoteRepository.byAuthorUserId(1); + const notes = await ownNotesOf(1); const groups = SendouQ.lookingGroups(1, notes); expect(groups).toHaveLength(1); @@ -574,7 +584,7 @@ describe("SendouQ", () => { await createGroup([10, 11, 12, 13]); await refreshSendouQInstance(); - const notes = await PrivateUserNoteRepository.byAuthorUserId(1); + const notes = await ownNotesOf(1); const groups = SendouQ.lookingGroups(1, notes); expect(groups).toHaveLength(1); @@ -590,7 +600,7 @@ describe("SendouQ", () => { await createGroup([9, 10, 11, 12]); await refreshSendouQInstance(); - const notes = await PrivateUserNoteRepository.byAuthorUserId(1); + const notes = await ownNotesOf(1); const groups = SendouQ.lookingGroups(1, notes); expect(groups).toHaveLength(2); @@ -607,7 +617,7 @@ describe("SendouQ", () => { await createGroup([8, 9, 10, 11]); await refreshSendouQInstance(); - const notes = await PrivateUserNoteRepository.byAuthorUserId(1); + const notes = await ownNotesOf(1); const groups = SendouQ.lookingGroups(1, notes); expect(groups).toHaveLength(3); @@ -645,7 +655,7 @@ describe("SendouQ", () => { await refreshSendouQInstance(); - const notes = await PrivateUserNoteRepository.byAuthorUserId(1); + const notes = await ownNotesOf(1); const groups = SendouQ.lookingGroups(1, notes); const fullGroups = groups.filter((g) => g.members === undefined); @@ -669,7 +679,7 @@ describe("SendouQ", () => { await refreshSendouQInstance(); - const notes = await PrivateUserNoteRepository.byAuthorUserId(1); + const notes = await ownNotesOf(1); const groups = SendouQ.lookingGroups(1, notes); for (const group of groups) { @@ -683,7 +693,7 @@ describe("SendouQ", () => { await createGroup([3]); await refreshSendouQInstance(); - const notes = await PrivateUserNoteRepository.byAuthorUserId(1); + const notes = await ownNotesOf(1); const groups = SendouQ.lookingGroups(1, notes); for (const group of groups) { @@ -708,7 +718,7 @@ describe("SendouQ", () => { await refreshSendouQInstance(); - const notes = await PrivateUserNoteRepository.byAuthorUserId(1); + const notes = await ownNotesOf(1); const groups = SendouQ.lookingGroups(1, notes); const partialGroup = groups.find((g) => @@ -732,7 +742,7 @@ describe("SendouQ", () => { await createGroup([5, 6, 7, 8]); await refreshSendouQInstance(); - const notes = await PrivateUserNoteRepository.byAuthorUserId(1); + const notes = await ownNotesOf(1); const groups = SendouQ.lookingGroups(1, notes); const fullGroup = groups.find((g) => g.members === undefined); @@ -744,7 +754,7 @@ describe("SendouQ", () => { await createGroup([2, 3]); await refreshSendouQInstance(); - const notes = await PrivateUserNoteRepository.byAuthorUserId(1); + const notes = await ownNotesOf(1); const groups = SendouQ.lookingGroups(1, notes); const partialGroup = groups.find((g) => g.members?.length === 2); @@ -758,7 +768,7 @@ describe("SendouQ", () => { await createGroup([3, 4, 5, 6]); await refreshSendouQInstance(); - const notes = await PrivateUserNoteRepository.byAuthorUserId(1); + const notes = await ownNotesOf(1); const groups = SendouQ.lookingGroups(1, notes); for (const group of groups) { @@ -790,7 +800,7 @@ describe("SendouQ", () => { await refreshSendouQInstance(); - const notes = await PrivateUserNoteRepository.byAuthorUserId(1); + const notes = await ownNotesOf(1); const groups = SendouQ.lookingGroups(1, notes); expect(groups[0].members![0].id).toBe(5); @@ -809,7 +819,7 @@ describe("SendouQ", () => { await refreshSendouQInstance(); - const notes = await PrivateUserNoteRepository.byAuthorUserId(1); + const notes = await ownNotesOf(1); const groups = SendouQ.lookingGroups(1, notes); expect(groups[groups.length - 1].members![0].id).toBe(5); @@ -829,7 +839,7 @@ describe("SendouQ", () => { await refreshSendouQInstance(); - const notes = await PrivateUserNoteRepository.byAuthorUserId(1); + const notes = await ownNotesOf(1); const groups = SendouQ.lookingGroups(1, notes); expect(groups[groups.length - 1].members?.some((m) => m.id === 6)).toBe( @@ -861,7 +871,7 @@ describe("SendouQ", () => { await refreshSendouQInstance(); - const notes = await PrivateUserNoteRepository.byAuthorUserId(1); + const notes = await ownNotesOf(1); const groups = SendouQ.lookingGroups(1, notes); expect(groups[0].members![0].id).toBe(4); @@ -880,7 +890,7 @@ describe("SendouQ", () => { await refreshSendouQInstance(); - const notes = await PrivateUserNoteRepository.byAuthorUserId(1); + const notes = await ownNotesOf(1); const groups = SendouQ.lookingGroups(1, notes); expect(groups[0].members![0].id).toBe(2); @@ -904,7 +914,7 @@ describe("SendouQ", () => { await refreshSendouQInstance(); - const notes = await PrivateUserNoteRepository.byAuthorUserId(1); + const notes = await ownNotesOf(1); const groups = SendouQ.lookingGroups(1, notes); expect(groups.length).toBeGreaterThan(0); @@ -936,7 +946,7 @@ describe("SendouQ", () => { await createGroup([1]); await refreshSendouQInstance(); - const notes = await PrivateUserNoteRepository.byAuthorUserId(1); + const notes = await ownNotesOf(1); const groups = SendouQ.lookingGroups(1, notes); expect(groups[0].members![0].id).toBe(3); diff --git a/app/features/sendouq/core/SendouQ.server.ts b/app/features/sendouq/core/SendouQ.server.ts index 9da637582..1da60ac09 100644 --- a/app/features/sendouq/core/SendouQ.server.ts +++ b/app/features/sendouq/core/SendouQ.server.ts @@ -23,7 +23,7 @@ type DBGroupRow = Awaited< ReturnType >[number]; type DBPrivateNoteRow = Awaited< - ReturnType + ReturnType >[number]; type DBRecentlyFinishedMatchRow = Awaited< ReturnType diff --git a/app/features/sendouq/loaders/q.looking.server.ts b/app/features/sendouq/loaders/q.looking.server.ts index a943fb45c..28812f859 100644 --- a/app/features/sendouq/loaders/q.looking.server.ts +++ b/app/features/sendouq/loaders/q.looking.server.ts @@ -14,8 +14,7 @@ export const loader = async ({ request }: LoaderFunctionArgs) => { new URL(request.url).searchParams.get("preview") === "true" && user.roles.includes("SUPPORTER"); - const privateNotes = await PrivateUserNoteRepository.byAuthorUserId( - user.id, + const privateNotes = await PrivateUserNoteRepository.ownNotes( SendouQ.usersInQueue, ); diff --git a/app/features/settings/actions/settings.server.ts b/app/features/settings/actions/settings.server.ts index 77b761479..94d8ded54 100644 --- a/app/features/settings/actions/settings.server.ts +++ b/app/features/settings/actions/settings.server.ts @@ -25,42 +25,41 @@ export const action = async ({ request }: ActionFunctionArgs) => { ? clampThemeToGamut(data.newValue) : null; - await UserRepository.updateCustomTheme(user.id, clampedTheme); + await UserRepository.updateOwnCustomTheme(clampedTheme); break; } case "UPDATE_DISABLE_BUILD_ABILITY_SORTING": { - await UserRepository.updatePreferences(user.id, { + await UserRepository.updateOwnPreferences({ disableBuildAbilitySorting: data.newValue, }); break; } case "DISALLOW_SCRIM_PICKUPS_FROM_UNTRUSTED": { - await UserRepository.updatePreferences(user.id, { + await UserRepository.updateOwnPreferences({ disallowScrimPickupsFromUntrusted: data.newValue, }); break; } case "UPDATE_SPOILER_FREE_MODE": { - await UserRepository.updatePreferences(user.id, { + await UserRepository.updateOwnPreferences({ spoilerFreeMode: data.newValue, }); break; } case "UPDATE_CLOCK_FORMAT": { - await UserRepository.updatePreferences(user.id, { + await UserRepository.updateOwnPreferences({ clockFormat: data.newValue, }); break; } case "UPDATE_WEAPON_REPORT_DEFAULT_OPEN": { - await UserRepository.updatePreferences(user.id, { + await UserRepository.updateOwnPreferences({ weaponReportDefaultOpen: data.newValue, }); break; } case "UPDATE_MATCH_PROFILE": { - await MatchProfileRepository.updateMatchProfile({ - userId: user.id, + await MatchProfileRepository.updateOwnMatchProfile({ mapModePreferences: data.mapModePreferences, vc: data.vc, languages: data.languages, diff --git a/app/features/team/TeamRepository.server.ts b/app/features/team/TeamRepository.server.ts index bd59784bd..b79114cd5 100644 --- a/app/features/team/TeamRepository.server.ts +++ b/app/features/team/TeamRepository.server.ts @@ -2,6 +2,7 @@ import { type Insertable, sql, type Transaction } from "kysely"; import { jsonArrayFrom } from "kysely/helpers/sqlite"; import { db } from "~/db/sql"; import type { CustomTheme, DB, Tables } from "~/db/tables"; +import { actorId } from "~/features/auth/core/user.server"; import * as LFGRepository from "~/features/lfg/LFGRepository.server"; import { subsOfResult } from "~/features/team/team-utils"; import { databaseTimestampNow } from "~/utils/dates"; @@ -395,13 +396,8 @@ export async function updateCustomTheme({ .execute(); } -export function switchMainTeam({ - userId, - teamId, -}: { - userId: number; - teamId: number; -}) { +export function switchOwnMainTeam(teamId: number) { + const userId = actorId(); return db.transaction().execute(async (trx) => { const currentTeams = await teamsByMemberUserId(userId, trx); @@ -483,15 +479,14 @@ export function resetInviteCode(teamId: number) { .execute(); } -export function addNewTeamMember({ - userId, +export function joinTeam({ teamId, maxTeamsAllowed, }: { - userId: number; teamId: number; maxTeamsAllowed: number; }) { + const userId = actorId(); return db.transaction().execute(async (trx) => { const teamCount = (await teamsByMemberUserId(userId, trx)).length; diff --git a/app/features/team/actions/t.$customUrl.index.server.ts b/app/features/team/actions/t.$customUrl.index.server.ts index 64d1ba9b9..a34029d0b 100644 --- a/app/features/team/actions/t.$customUrl.index.server.ts +++ b/app/features/team/actions/t.$customUrl.index.server.ts @@ -48,10 +48,7 @@ export const action: ActionFunction = async ({ request, params }) => { break; } case "MAKE_MAIN_TEAM": { - await TeamRepository.switchMainTeam({ - userId: user.id, - teamId: team.id, - }); + await TeamRepository.switchOwnMainTeam(team.id); break; } diff --git a/app/features/team/actions/t.$customUrl.join.server.ts b/app/features/team/actions/t.$customUrl.join.server.ts index 780f6707d..f38b45730 100644 --- a/app/features/team/actions/t.$customUrl.join.server.ts +++ b/app/features/team/actions/t.$customUrl.join.server.ts @@ -31,13 +31,12 @@ export const action: ActionFunction = async ({ request, params }) => { "Invite code is invalid", ); - await TeamRepository.addNewTeamMember({ + await TeamRepository.joinTeam({ maxTeamsAllowed: user.patronTier && user.patronTier >= 2 ? TEAM.MAX_TEAM_COUNT_PATRON : TEAM.MAX_TEAM_COUNT_NON_PATRON, teamId: team.id, - userId: user.id, }); throw redirect(teamPage(team.customUrl)); diff --git a/app/features/team/routes/t.$customUrl.test.ts b/app/features/team/routes/t.$customUrl.test.ts index 1de64613f..d076193b0 100644 --- a/app/features/team/routes/t.$customUrl.test.ts +++ b/app/features/team/routes/t.$customUrl.test.ts @@ -5,6 +5,7 @@ import { assertResponseErrored, dbInsertUsers, dbReset, + withUserId, wrappedAction, } from "~/utils/Test"; import { action as _teamPageAction } from "../actions/t.$customUrl.index.server"; @@ -105,11 +106,12 @@ describe("Secondary teams", () => { it("only the team owner (or admin) can delete a team", async () => { await createTeamAction({ name: "Team 1" }, { user: "admin" }); - await TeamRepository.addNewTeamMember({ - userId: REGULAR_USER_TEST_ID, - teamId: 1, - maxTeamsAllowed: 2, - }); + await withUserId(REGULAR_USER_TEST_ID, () => + TeamRepository.joinTeam({ + teamId: 1, + maxTeamsAllowed: 2, + }), + ); const response = await teamPageAction( { _action: "DELETE_TEAM" }, @@ -127,16 +129,18 @@ describe("Secondary teams", () => { await createTeamAction({ name: "Team 1" }, { user: "admin" }); await createTeamAction({ name: "Team 2" }, { user: "admin" }); - await TeamRepository.addNewTeamMember({ - userId: REGULAR_USER_TEST_ID, - teamId: 1, - maxTeamsAllowed: 2, - }); - await TeamRepository.addNewTeamMember({ - userId: REGULAR_USER_TEST_ID, - teamId: 2, - maxTeamsAllowed: 2, - }); + await withUserId(REGULAR_USER_TEST_ID, () => + TeamRepository.joinTeam({ + teamId: 1, + maxTeamsAllowed: 2, + }), + ); + await withUserId(REGULAR_USER_TEST_ID, () => + TeamRepository.joinTeam({ + teamId: 2, + maxTeamsAllowed: 2, + }), + ); const { team, secondaryTeams } = await loadTeams(); diff --git a/app/features/tournament-bracket/actions/to.$id.brackets.server.ts b/app/features/tournament-bracket/actions/to.$id.brackets.server.ts index ef451c4cf..63303928f 100644 --- a/app/features/tournament-bracket/actions/to.$id.brackets.server.ts +++ b/app/features/tournament-bracket/actions/to.$id.brackets.server.ts @@ -140,7 +140,6 @@ export const action: ActionFunction = async ({ params, request }) => { tournamentId, maps: { maps, - authorId: user.id, eliminationTeamCount: bracket.type === "single_elimination" || bracket.type === "double_elimination" @@ -239,7 +238,6 @@ export const action: ActionFunction = async ({ params, request }) => { thirdPlaceMatchLinked: data.thirdPlaceMatchLinked, }) : data.maps, - authorId: user.id, eliminationTeamCount: data.eliminationTeamCount ?? undefined, }, }); diff --git a/app/features/tournament-lfg/TournamentLFGRepository.server.test.ts b/app/features/tournament-lfg/TournamentLFGRepository.server.test.ts index 665ac0e72..112ae2da9 100644 --- a/app/features/tournament-lfg/TournamentLFGRepository.server.test.ts +++ b/app/features/tournament-lfg/TournamentLFGRepository.server.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, test } from "vitest"; import { db } from "~/db/sql"; -import { dbInsertUsers, dbReset } from "~/utils/Test"; +import { dbInsertUsers, dbReset, withUserId } from "~/utils/Test"; import * as TournamentLFGRepository from "./TournamentLFGRepository.server"; const createTournament = () => @@ -554,22 +554,24 @@ describe("updateStayAsSub", () => { const tournament = await createTournament(); const team = await createPlaceholder(tournament.id, 1); - await TournamentLFGRepository.updateStayAsSub({ - teamId: team.id, - userId: 1, - value: true, - }); + await withUserId(1, () => + TournamentLFGRepository.updateOwnStayAsSub({ + teamId: team.id, + value: true, + }), + ); let subs = await TournamentLFGRepository.getSubsForTournament( tournament.id, ); expect(subs).toContain(1); - await TournamentLFGRepository.updateStayAsSub({ - teamId: team.id, - userId: 1, - value: false, - }); + await withUserId(1, () => + TournamentLFGRepository.updateOwnStayAsSub({ + teamId: team.id, + value: false, + }), + ); subs = await TournamentLFGRepository.getSubsForTournament(tournament.id); expect(subs).not.toContain(1); diff --git a/app/features/tournament-lfg/TournamentLFGRepository.server.ts b/app/features/tournament-lfg/TournamentLFGRepository.server.ts index 8bcbd2f21..382b3edef 100644 --- a/app/features/tournament-lfg/TournamentLFGRepository.server.ts +++ b/app/features/tournament-lfg/TournamentLFGRepository.server.ts @@ -2,6 +2,7 @@ import type { Transaction } from "kysely"; import { jsonBuildObject } from "kysely/helpers/sqlite"; import { db } from "~/db/sql"; import type { DB, Tables } from "~/db/tables"; +import { actorId } from "~/features/auth/core/user.server"; import { shortNanoid } from "~/utils/id"; import invariant from "~/utils/invariant"; import { @@ -328,20 +329,18 @@ export function updateMemberRole({ .execute(); } -export function updateStayAsSub({ +export function updateOwnStayAsSub({ teamId, - userId, value, }: { teamId: number; - userId: number; value: boolean; }) { return db .updateTable("TournamentTeamMember") .set({ isStayAsSub: value ? 1 : 0 }) .where("tournamentTeamId", "=", teamId) - .where("userId", "=", userId) + .where("userId", "=", actorId()) .execute(); } diff --git a/app/features/tournament-lfg/actions/to.$id.looking.server.ts b/app/features/tournament-lfg/actions/to.$id.looking.server.ts index 0537a1d9f..a9aa117b2 100644 --- a/app/features/tournament-lfg/actions/to.$id.looking.server.ts +++ b/app/features/tournament-lfg/actions/to.$id.looking.server.ts @@ -264,9 +264,8 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { value: data.note ?? null, }); - await TournamentLFGRepository.updateStayAsSub({ + await TournamentLFGRepository.updateOwnStayAsSub({ teamId: ownGroup.id, - userId: user.id, value: data.stayAsSub ?? false, }); diff --git a/app/features/tournament-match/actions/to.$id.matches.$mid.server.ts b/app/features/tournament-match/actions/to.$id.matches.$mid.server.ts index 97ccaf9b8..527575928 100644 --- a/app/features/tournament-match/actions/to.$id.matches.$mid.server.ts +++ b/app/features/tournament-match/actions/to.$id.matches.$mid.server.ts @@ -788,10 +788,9 @@ export const action: ActionFunction = async ({ params, request }) => { "Weapon reporting is closed", ); - await ReportedWeaponRepository.upsertOneTournament({ + await ReportedWeaponRepository.upsertOwnTournament({ tournamentMatchId: matchId, mapIndex: data.mapIndex, - userId: user.id, weaponSplId: data.weaponSplId, createdAt: dateToDatabaseTimestamp(tournament.ctx.startTime), }); @@ -808,9 +807,8 @@ export const action: ActionFunction = async ({ params, request }) => { "Weapon reporting is closed", ); - await ReportedWeaponRepository.deleteByUserMapIndexTournament({ + await ReportedWeaponRepository.deleteOwnByMapIndexTournament({ tournamentMatchId: matchId, - userId: user.id, mapIndex: data.mapIndex, }); diff --git a/app/features/tournament-organization/TournamentOrganizationRepository.server.ts b/app/features/tournament-organization/TournamentOrganizationRepository.server.ts index 820c8e50a..15fe20356 100644 --- a/app/features/tournament-organization/TournamentOrganizationRepository.server.ts +++ b/app/features/tournament-organization/TournamentOrganizationRepository.server.ts @@ -3,6 +3,7 @@ import { sql } from "kysely"; import { jsonArrayFrom } from "kysely/helpers/sqlite"; import { db } from "~/db/sql"; import type { Tables, TablesInsertable } from "~/db/tables"; +import { actorId } from "~/features/auth/core/user.server"; import { TIER_HISTORY_LENGTH, type TournamentTierNumber, @@ -572,17 +573,11 @@ export function update({ }); } -export function removeMember({ - organizationId, - userId, -}: { - organizationId: number; - userId: number; -}) { +export function removeOwnMembership(organizationId: number) { return db .deleteFrom("TournamentOrganizationMember") .where("organizationId", "=", organizationId) - .where("userId", "=", userId) + .where("userId", "=", actorId()) .execute(); } diff --git a/app/features/tournament-organization/actions/org.$slug.server.ts b/app/features/tournament-organization/actions/org.$slug.server.ts index 37aa96269..801a6cb0c 100644 --- a/app/features/tournament-organization/actions/org.$slug.server.ts +++ b/app/features/tournament-organization/actions/org.$slug.server.ts @@ -104,10 +104,9 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { errorToast("Cannot leave as the sole admin of the organization"); } - await TournamentOrganizationRepository.removeMember({ - organizationId: organization.id, - userId: user.id, - }); + await TournamentOrganizationRepository.removeOwnMembership( + organization.id, + ); logger.info( `User left organization: organization=${organization.name} (${organization.id}), userId=${user.id}`, diff --git a/app/features/tournament/SavedCalendarEventRepository.server.ts b/app/features/tournament/SavedCalendarEventRepository.server.ts index 5e06bca23..67d57fc05 100644 --- a/app/features/tournament/SavedCalendarEventRepository.server.ts +++ b/app/features/tournament/SavedCalendarEventRepository.server.ts @@ -1,18 +1,13 @@ import { db } from "~/db/sql"; +import { actorId } from "~/features/auth/core/user.server"; import type { ShowcaseCalendarEvent } from "~/features/calendar/calendar-types"; import * as ShowcaseTournaments from "~/features/front-page/core/ShowcaseTournaments.server"; -export function save({ - userId, - tournamentId, -}: { - userId: number; - tournamentId: number; -}) { +export function saveOwn(tournamentId: number) { return db .insertInto("SavedCalendarEvent") .values((eb) => ({ - userId, + userId: actorId(), calendarEventId: eb .selectFrom("CalendarEvent") .select("CalendarEvent.id") diff --git a/app/features/tournament/TournamentRepository.server.ts b/app/features/tournament/TournamentRepository.server.ts index c3968a96a..f42f862cb 100644 --- a/app/features/tournament/TournamentRepository.server.ts +++ b/app/features/tournament/TournamentRepository.server.ts @@ -10,6 +10,7 @@ import type { Tables, TournamentSettings, } from "~/db/tables"; +import { actorId } from "~/features/auth/core/user.server"; import { identifierToUserIds } from "~/features/mmr/mmr-utils"; import * as Progression from "~/features/tournament-bracket/core/Progression"; import type { TournamentSummary } from "~/features/tournament-bracket/core/summarizer.server"; @@ -782,7 +783,7 @@ export function removeStaff({ interface UpsertPreparedMapsArgs { tournamentId: number; - maps: Omit; + maps: Omit; bracketIdx: number; } @@ -802,7 +803,11 @@ export function upsertPreparedMaps({ tournament.preparedMaps ?? nullFilledArray(tournament.settings.bracketProgression.length); - preparedMaps[bracketIdx] = { ...maps, createdAt: databaseTimestampNow() }; + preparedMaps[bracketIdx] = { + ...maps, + authorId: actorId(), + createdAt: databaseTimestampNow(), + }; await trx .updateTable("Tournament") diff --git a/app/features/tournament/TournamentTeamRepository.server.ts b/app/features/tournament/TournamentTeamRepository.server.ts index 64458bc1c..72191bfbf 100644 --- a/app/features/tournament/TournamentTeamRepository.server.ts +++ b/app/features/tournament/TournamentTeamRepository.server.ts @@ -2,6 +2,7 @@ import type { Transaction } from "kysely"; import { sql } from "kysely"; import { db } from "~/db/sql"; import type { DB, Tables } from "~/db/tables"; +import { actorId } from "~/features/auth/core/user.server"; import type { MapPool } from "~/features/map-list-generator/core/map-pool"; import type { ModeShort, StageId } from "~/modules/in-game-lists/types"; import { flatZip } from "~/utils/arrays"; @@ -79,15 +80,11 @@ export async function updateMemberInGameName({ * * @returns A promise that resolves to an array of tournament IDs where the user's in-game name was updated. */ -export async function updateMemberInGameNameForNonStarted({ - userId, - inGameName, -}: { - /** The ID of the user whose in-game name is to be updated. */ - userId: number; - /** The new in-game name to be set for the user. */ - inGameName: string; -}): Promise { +export async function updateOwnMemberInGameNameForNonStarted( + /** The new in-game name to be set for the acting user. */ + inGameName: string, +): Promise { + const userId = actorId(); const tournamentTeams = await regOpenTournamentTeamsByJoinedUserId(userId); await db @@ -284,15 +281,14 @@ export function copyFromAnotherTournament({ export function update({ team, avatarFileName, - userId, }: { team: Pick< Tables["TournamentTeam"], "id" | "name" | "prefersNotToHost" | "teamId" >; avatarFileName?: string; - userId: number; }) { + const userId = actorId(); return db.transaction().execute(async (trx) => { const avatarImgId = avatarFileName ? await createSubmittedImageInTrx({ diff --git a/app/features/tournament/actions/to.$id.register.server.ts b/app/features/tournament/actions/to.$id.register.server.ts index 527159089..6e567c0da 100644 --- a/app/features/tournament/actions/to.$id.register.server.ts +++ b/app/features/tournament/actions/to.$id.register.server.ts @@ -81,7 +81,6 @@ export const action: ActionFunction = async ({ request, params }) => { ); await TournamentTeamRepository.update({ - userId: user.id, avatarFileName, team: { id: ownTeam.id, @@ -361,10 +360,7 @@ export const action: ActionFunction = async ({ request, params }) => { "Maximum saved tournaments reached", ); - await SavedCalendarEventRepository.save({ - userId: user.id, - tournamentId, - }); + await SavedCalendarEventRepository.saveOwn(tournamentId); break; } case "UNSAVE_TOURNAMENT": { diff --git a/app/features/user-page/UserRepository.server.ts b/app/features/user-page/UserRepository.server.ts index e5b2c522e..8f81368c1 100644 --- a/app/features/user-page/UserRepository.server.ts +++ b/app/features/user-page/UserRepository.server.ts @@ -11,6 +11,7 @@ import type { TablesInsertable, UserPreferences, } from "~/db/tables"; +import { actorId } from "~/features/auth/core/user.server"; import { userRoles } from "~/modules/permissions/mapper.server"; import { isSupporter } from "~/modules/permissions/utils"; import { databaseTimestampNow, dateToDatabaseTimestamp } from "~/utils/dates"; @@ -1043,23 +1044,20 @@ type UpdateProfileArgs = Pick< | "commissionText" | "commissionsOpen" > & { - userId: number; weapons: Pick[]; favoriteBadgeIds?: number[] | null; }; -export function updateProfile(args: UpdateProfileArgs) { +export function updateOwnProfile(args: UpdateProfileArgs) { + const userId = actorId(); return db.transaction().execute(async (trx) => { - await trx - .deleteFrom("UserWeapon") - .where("userId", "=", args.userId) - .execute(); + await trx.deleteFrom("UserWeapon").where("userId", "=", userId).execute(); if (args.weapons.length > 0) { await trx .insertInto("UserWeapon") .values( args.weapons.map((weapon, i) => ({ - userId: args.userId, + userId, weaponSplId: weapon.weaponSplId, isFavorite: weapon.isFavorite, order: i + 1, @@ -1089,26 +1087,24 @@ export function updateProfile(args: UpdateProfileArgs) { commissionsOpenedAt: args.commissionsOpen === 1 ? databaseTimestampNow() : null, }) - .where("id", "=", args.userId) + .where("id", "=", userId) .returning(["User.id", "User.customUrl", "User.discordId"]) .executeTakeFirstOrThrow(); }); } -export function updateCustomTheme(userId: number, css: CustomTheme | null) { +export function updateOwnCustomTheme(css: CustomTheme | null) { return db .updateTable("User") .set({ customTheme: css ? JSON.stringify(css) : null, }) - .where("id", "=", userId) + .where("id", "=", actorId()) .execute(); } -export function updatePreferences( - userId: number, - newPreferences: UserPreferences, -) { +export function updateOwnPreferences(newPreferences: UserPreferences) { + const userId = actorId(); return db.transaction().execute(async (trx) => { const current = ( @@ -1135,15 +1131,15 @@ export function updatePreferences( } type UpdateResultHighlightsArgs = { - userId: number; resultTeamIds: Array; resultTournamentTeamIds: Array; }; -export function updateResultHighlights(args: UpdateResultHighlightsArgs) { +export function updateOwnResultHighlights(args: UpdateResultHighlightsArgs) { + const userId = actorId(); return db.transaction().execute(async (trx) => { await trx .deleteFrom("UserResultHighlight") - .where("userId", "=", args.userId) + .where("userId", "=", userId) .execute(); if (args.resultTeamIds.length > 0) { @@ -1151,7 +1147,7 @@ export function updateResultHighlights(args: UpdateResultHighlightsArgs) { .insertInto("UserResultHighlight") .values( args.resultTeamIds.map((teamId) => ({ - userId: args.userId, + userId, teamId, })), ) @@ -1163,7 +1159,7 @@ export function updateResultHighlights(args: UpdateResultHighlightsArgs) { .set({ isHighlight: 0, }) - .where("TournamentResult.userId", "=", args.userId) + .where("TournamentResult.userId", "=", userId) .execute(); if (args.resultTournamentTeamIds.length > 0) { @@ -1172,7 +1168,7 @@ export function updateResultHighlights(args: UpdateResultHighlightsArgs) { .set({ isHighlight: 1, }) - .where("TournamentResult.userId", "=", args.userId) + .where("TournamentResult.userId", "=", userId) .where( "TournamentResult.tournamentTeamId", "in", @@ -1183,17 +1179,11 @@ export function updateResultHighlights(args: UpdateResultHighlightsArgs) { }); } -export function updateBuildSorting({ - userId, - buildSorting, -}: { - userId: number; - buildSorting: BuildSort[] | null; -}) { +export function updateOwnBuildSorting(buildSorting: BuildSort[] | null) { return db .updateTable("User") .set({ buildSorting: buildSorting ? JSON.stringify(buildSorting) : null }) - .where("id", "=", userId) + .where("id", "=", actorId()) .execute(); } diff --git a/app/features/user-page/actions/u.$identifier.admin.server.ts b/app/features/user-page/actions/u.$identifier.admin.server.ts index c731ecfad..2452b880a 100644 --- a/app/features/user-page/actions/u.$identifier.admin.server.ts +++ b/app/features/user-page/actions/u.$identifier.admin.server.ts @@ -28,7 +28,6 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { switch (data._action) { case "ADD_MOD_NOTE": { await AdminRepository.addModNote({ - authorId: loggedInUser.id, userId: user.id, text: data.value, }); diff --git a/app/features/user-page/actions/u.$identifier.art.server.ts b/app/features/user-page/actions/u.$identifier.art.server.ts index e660fe8ee..33e00323f 100644 --- a/app/features/user-page/actions/u.$identifier.art.server.ts +++ b/app/features/user-page/actions/u.$identifier.art.server.ts @@ -38,10 +38,7 @@ export const action: ActionFunction = async ({ request }) => { artId: data.id, }); - await ArtRepository.unlinkUserFromArt({ - userId: user.id, - artId: data.id, - }); + await ArtRepository.unlinkSelfFromArt(data.id); return successToast("Unlinking art successful"); } diff --git a/app/features/user-page/actions/u.$identifier.builds.server.ts b/app/features/user-page/actions/u.$identifier.builds.server.ts index 54da58a64..85e63464e 100644 --- a/app/features/user-page/actions/u.$identifier.builds.server.ts +++ b/app/features/user-page/actions/u.$identifier.builds.server.ts @@ -35,10 +35,7 @@ export const action: ActionFunction = async ({ request }) => { break; } case "UPDATE_SORTING": { - await UserRepository.updateBuildSorting({ - userId: user.id, - buildSorting: data.buildSorting, - }); + await UserRepository.updateOwnBuildSorting(data.buildSorting); break; } diff --git a/app/features/user-page/actions/u.$identifier.edit.server.ts b/app/features/user-page/actions/u.$identifier.edit.server.ts index 9f5d22e8b..4a045b8c3 100644 --- a/app/features/user-page/actions/u.$identifier.edit.server.ts +++ b/app/features/user-page/actions/u.$identifier.edit.server.ts @@ -43,8 +43,7 @@ export const action: ActionFunction = async ({ request }) => { : 1; const limitedBadgeIds = data.favoriteBadgeIds.slice(0, maxBadgeCount); - const editedUser = await UserRepository.updateProfile({ - userId: user.id, + const editedUser = await UserRepository.updateOwnProfile({ country: data.country, bio: data.bio, customUrl: data.customUrl, @@ -61,17 +60,16 @@ export const action: ActionFunction = async ({ request }) => { commissionText: isArtist ? data.commissionText : null, }); - await UserRepository.updatePreferences(user.id, { + await UserRepository.updateOwnPreferences({ newProfileEnabled: isSupporter ? data.newProfileEnabled : false, }); // TODO: to transaction if (data.inGameName) { const tournamentIdsAffected = - await TournamentTeamRepository.updateMemberInGameNameForNonStarted({ - inGameName: data.inGameName, - userId: user.id, - }); + await TournamentTeamRepository.updateOwnMemberInGameNameForNonStarted( + data.inGameName, + ); for (const tournamentId of tournamentIdsAffected) { clearTournamentDataCache(tournamentId); diff --git a/app/features/user-page/actions/u.$identifier.results.highlights.server.ts b/app/features/user-page/actions/u.$identifier.results.highlights.server.ts index d672a7580..fa20d8e0a 100644 --- a/app/features/user-page/actions/u.$identifier.results.highlights.server.ts +++ b/app/features/user-page/actions/u.$identifier.results.highlights.server.ts @@ -24,8 +24,7 @@ export const action: ActionFunction = async ({ request }) => { data[HIGHLIGHT_TOURNAMENT_CHECKBOX_NAME], ).map((id) => Number.parseInt(id, 10)); - await UserRepository.updateResultHighlights({ - userId: user.id, + await UserRepository.updateOwnResultHighlights({ resultTeamIds, resultTournamentTeamIds, }); diff --git a/app/utils/Test.ts b/app/utils/Test.ts index 0167f5266..cb0038fb4 100644 --- a/app/utils/Test.ts +++ b/app/utils/Test.ts @@ -11,6 +11,7 @@ import { ADMIN_ID } from "~/features/admin/admin-constants"; import { SESSION_KEY } from "~/features/auth/core/authenticator.server"; import { authSessionStorage } from "~/features/auth/core/session.server"; import { + type AuthenticatedUser, getUserFromRequest, userAsyncLocalStorage, } from "~/features/auth/core/user-context.server"; @@ -22,6 +23,27 @@ export function arrayContainsSameItems(arr1: T[], arr2: T[]) { ); } +/** + * Runs `fn` inside the user AsyncLocalStorage store so that repository functions + * resolving the actor via `actorId()` / `actorIdOrNull()` see `user` as the acting + * user. Use in direct repository unit tests, which run outside a request. + */ +export function withUser(user: AuthenticatedUser, fn: () => T): T { + return userAsyncLocalStorage.run({ user }, fn); +} + +/** + * Like {@link withUser} but takes only a user id, building a minimal acting-user + * context. Convenient for repository data-setup in tests where only the actor's id + * matters (repositories read the actor solely via `actorId()` / `actorIdOrNull()`). + */ +export function withUserId(id: number, fn: () => T): T { + return userAsyncLocalStorage.run( + { user: { id } as unknown as AuthenticatedUser }, + fn, + ); +} + /** * Wraps an action function to provide a strongly-typed, reusable handler for executing actions * in unit tests as if it was a normal function. The returned function allows you to pass