V1.14.1: choose where a new encounter goes

- A new encounter, battle or quest, goes to the bottom of the
  campaign's encounter list. It used to go to the top.
- A menu beside the name when adding one says where it goes instead:
  at the bottom, at the top, or straight after any encounter on the
  list. Top or bottom is remembered per browser.
- All packages at version 1.14.1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-10 11:50:41 +02:00
co-authored by Claude Opus 5.5
parent e9f8169286
commit 61221f262c
9 changed files with 108 additions and 20 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "backend",
"version": "1.14.0",
"version": "1.14.1",
"private": true,
"type": "commonjs",
"main": "src/index.js",
+21 -3
View File
@@ -1718,13 +1718,31 @@ describe('ordering, archiving and deleting encounters', () => {
}
const names = async (auth) => (await request(app).get(`${c}/encounters`).set(auth)).body.encounters.map((e) => e.name);
// New encounters go to the top.
expect(await names(dmAuth)).toEqual(['Three', 'Two', 'One']);
// New encounters go to the bottom.
expect(await names(dmAuth)).toEqual(['One', 'Two', 'Three']);
const order = (encounterIds, auth = dmAuth) => request(app).put(`${c}/encounters/order`).set(auth).send({ encounterIds });
expect((await order([ids.One, ids.Two, ids.Three], playerAuth)).status).toBe(403);
expect((await order([ids.Three, ids.Two, ids.One], playerAuth)).status).toBe(403);
expect((await order([ids.One, ids.One])).status).toBe(400);
expect((await order([ids.One, 'not-an-encounter'])).status).toBe(400);
expect((await order([ids.Three, ids.One, ids.Two])).status).toBe(204);
expect(await names(playerAuth)).toEqual(['Three', 'One', 'Two']);
// A new one, of either kind, still lands at the bottom of a rearranged list,
// unless the DM says where: the top, or straight after another.
const add = async (body) => (await request(app).post(`${c}/encounters`).set(dmAuth).send(body)).body.encounter.id;
const extra = [await add({ name: 'Four', mode: 'QUEST' })];
expect(await names(dmAuth)).toEqual(['Three', 'One', 'Two', 'Four']);
extra.push(await add({ name: 'First', position: 'TOP' }));
expect(await names(dmAuth)).toEqual(['First', 'Three', 'One', 'Two', 'Four']);
extra.push(await add({ name: 'After One', mode: 'QUEST', afterEncounterId: ids.One }));
expect(await names(dmAuth)).toEqual(['First', 'Three', 'One', 'After One', 'Two', 'Four']);
extra.push(await add({ name: 'Last', position: 'BOTTOM' }));
extra.push(await add({ name: 'After Last', position: 'TOP', afterEncounterId: extra[3] }));
expect(await names(dmAuth)).toEqual(['First', 'Three', 'One', 'After One', 'Two', 'Four', 'Last', 'After Last']);
expect((await request(app).post(`${c}/encounters`).set(dmAuth).send({ name: 'Lost', afterEncounterId: 'not-an-encounter' })).status).toBe(400);
expect((await request(app).post(`${c}/encounters`).set(dmAuth).send({ name: 'Lost', position: 'MIDDLE' })).status).toBe(400);
for (const id of extra) await request(app).delete(`/encounters/${id}`).set(dmAuth);
expect((await order([ids.One, ids.Two, ids.Three])).status).toBe(204);
expect(await names(playerAuth)).toEqual(['One', 'Two', 'Three']);
+37 -7
View File
@@ -706,6 +706,9 @@ router.delete('/:id/monsters/:monsterId', requireCampaignMember(), requireDM(),
// Encounters
// ---------------------------------------------------------------------------
// The campaign's own order, which the DM arranges.
const ENCOUNTER_ORDER = [{ sortOrder: 'asc' }, { createdAt: 'asc' }];
router.get('/:id/encounters', requireCampaignMember(), async (req, res, next) => {
try {
const isDM = req.membership.role === 'DM';
@@ -714,7 +717,7 @@ router.get('/:id/encounters', requireCampaignMember(), async (req, res, next) =>
const mine = { archivedAt: null, participants: { some: { character: { userId: req.user.id } } } };
const encounters = await prisma.encounter.findMany({
where: { campaignId: req.campaign.id, ...(isDM ? {} : mine) },
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'desc' }],
orderBy: ENCOUNTER_ORDER,
});
// dmNotes never leaves the server for a player.
res.json({ encounters: isDM ? encounters : encounters.map(({ dmNotes, ...encounter }) => encounter) });
@@ -728,21 +731,48 @@ const createEncounterSchema = z.object({
mode: z.enum(['BATTLE', 'QUEST']).optional(),
description: z.string().max(4000).optional().nullable(),
locationId: z.string().optional().nullable(),
// Where it goes in the campaign's order: the top, the bottom, or straight
// after another encounter (which wins over `position`). The bottom if
// neither is given.
position: z.enum(['TOP', 'BOTTOM']).optional(),
afterEncounterId: z.string().optional().nullable(),
});
router.post('/:id/encounters', requireCampaignMember(), requireDM(), async (req, res, next) => {
try {
const parsed = createEncounterSchema.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
if (parsed.data.locationId) {
const location = await prisma.location.findFirst({ where: { id: parsed.data.locationId, campaignId: req.campaign.id } });
const { position, afterEncounterId, ...fields } = parsed.data;
if (fields.locationId) {
const location = await prisma.location.findFirst({ where: { id: fields.locationId, campaignId: req.campaign.id } });
if (!location) return res.status(400).json({ error: 'That location is not part of this campaign.' });
}
// A new encounter goes to the top of the campaign's order.
const first = await prisma.encounter.aggregate({ where: { campaignId: req.campaign.id }, _min: { sortOrder: true } });
const encounter = await prisma.encounter.create({
data: { ...parsed.data, campaignId: req.campaign.id, sortOrder: (first._min.sortOrder ?? 1) - 1 },
const order = await prisma.encounter.findMany({
where: { campaignId: req.campaign.id },
orderBy: ENCOUNTER_ORDER,
select: { id: true, sortOrder: true },
});
let index = position === 'TOP' ? 0 : order.length;
if (afterEncounterId) {
const at = order.findIndex((e) => e.id === afterEncounterId);
if (at === -1) return res.status(400).json({ error: 'The encounter to put this one after is not part of this campaign.' });
index = at + 1;
}
const encounter = await prisma.$transaction(async (tx) => {
// At the bottom nothing else moves. Anywhere else, the whole order is
// numbered afresh around the newcomer.
if (index === order.length) {
const last = order.length > 0 ? order[order.length - 1].sortOrder : -1;
return tx.encounter.create({ data: { ...fields, campaignId: req.campaign.id, sortOrder: last + 1 } });
}
const created = await tx.encounter.create({ data: { ...fields, campaignId: req.campaign.id, sortOrder: index } });
for (const [i, existing] of order.entries()) {
const sortOrder = i < index ? i : i + 1;
if (existing.sortOrder !== sortOrder) await tx.encounter.update({ where: { id: existing.id }, data: { sortOrder } });
}
return created;
});
res.status(201).json({ encounter });
} catch (err) {