import type { Payload } from "payload"; import type { StoryBeatState, User } from "@/payload-types"; import { emitGameEvent } from "@/utils/event-log/emit"; import { EventTypes } from "@/utils/event-log/eventTypes"; import { STORY_REACTIONS, type StoryReaction } from "./reactions"; export { STORY_REACTIONS, REACTION_LABELS, type StoryReaction } from "./reactions"; async function findBeatState( payload: Payload, userId: number, beatId: number, ): Promise { const result = await payload.find({ collection: "story-beat-states", where: { and: [{ user: { equals: userId } }, { beat: { equals: beatId } }], }, limit: 1, depth: 0, overrideAccess: true, }); return (result.docs[0] as StoryBeatState | undefined) ?? null; } async function findOrCreateBeatState( payload: Payload, userId: number, beatId: number, ): Promise { const existing = await findBeatState(payload, userId, beatId); if (existing) return existing; const created = await payload.create({ collection: "story-beat-states", data: { user: userId, beat: beatId, readAt: null, reaction: null, }, overrideAccess: true, depth: 0, }); return created as StoryBeatState; } /** * Mark a story beat as read for the given user. The beat's read access gate * (published only, staff see all) is enforced via overrideAccess: false, so * marking a draft read as a normal user throws. * * Idempotent: a second call returns the existing state without changing readAt. */ export async function markBeatRead( payload: Payload, { user, beatId }: { user: User; beatId: number }, ): Promise { const beat = await payload.findByID({ collection: "story-beats", id: beatId, user, overrideAccess: false, depth: 0, }); const state = await findOrCreateBeatState(payload, user.id, beatId); if (state.readAt) return state; const readAt = new Date().toISOString(); const updated = await payload.update({ collection: "story-beat-states", id: state.id, data: { readAt }, overrideAccess: true, depth: 0, }); emitGameEvent(payload, { type: EventTypes.StoryBeatRead, message: `${user.displayName || user.username} read the story beat "${beat.title}"`, actor: user.id, targetCollection: "story-beats", targetId: beat.id, data: { beatId: beat.id }, }); return updated as StoryBeatState; } /** * Set (or toggle off) the user's reaction on a story beat. Passing the user's * current reaction clears it; passing a different one replaces it. * * The beat's read access gate is enforced via overrideAccess: false. */ export async function setBeatReaction( payload: Payload, { user, beatId, reaction, }: { user: User; beatId: number; reaction: StoryReaction | null }, ): Promise<{ reaction: StoryReaction | null }> { if (reaction !== null && !STORY_REACTIONS.includes(reaction)) { throw new Error("Invalid reaction."); } const beat = await payload.findByID({ collection: "story-beats", id: beatId, user, overrideAccess: false, depth: 0, }); const state = await findOrCreateBeatState(payload, user.id, beatId); const next: StoryReaction | null = state.reaction === reaction ? null : reaction; await payload.update({ collection: "story-beat-states", id: state.id, data: { reaction: next }, overrideAccess: true, depth: 0, }); if (next !== null) { emitGameEvent(payload, { type: EventTypes.StoryBeatReact, message: `${user.displayName || user.username} reacted ${next} to the story beat "${beat.title}"`, actor: user.id, targetCollection: "story-beats", targetId: beat.id, data: { beatId: beat.id, reaction: next }, }); } return { reaction: next }; }