130 lines
3.8 KiB
TypeScript
130 lines
3.8 KiB
TypeScript
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<StoryBeatState | null> {
|
|
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<StoryBeatState> {
|
|
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<StoryBeatState> {
|
|
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 };
|
|
}
|