1
0
Fork 0
polaris-task-force/src/lib/story/index.ts

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 };
}