Skip to content
Merged

to main #1019

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,4 @@

## Routes & Verbs
- **Venue Updates (`/venue/:id`)**: `PATCH /venue/:id` is the standard partial-merge update verb. `PUT /venue/:id` is maintained alongside `PATCH` for backward compatibility, both routing to `controller.updateVenue`. Address updates enforce immutability once set (`400: address cannot be removed`).
- **Setlist API Sorting (`GET /setlist` and `GET /setlist/:id`)**: Accepts `?sort=title` (or `sort=artist` / `sort=order`) to return items in alphabetical or specified order. Sorting is read-time view only and strips `sort` from Mongoose query params so stored MongoDB item order is never mutated.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "web-jam-back",
"version": "2.11.0",
"version": "2.11.1",
"description": "web-jam.com",
"type": "module",
"main": "build/src/index.js",
Expand Down
28 changes: 28 additions & 0 deletions src/model/setlist/setlist-controller.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,37 @@
import { Request, Response } from 'express';
import mongoose from 'mongoose';
import Controller from '../../lib/controller.js';
import setlistModel from './setlist-facade.js';
import { Icontroller } from '../../lib/routeUtils.js';

class SetlistController extends Controller {
async find(req: Request, res: Response): Promise<unknown> {
const sortOption = typeof req.query.sort === 'string' ? req.query.sort : undefined;
let collection;
try {
collection = await (this.model as unknown as { find: (query: unknown, sort?: string) => Promise<unknown[]> }).find(req.query, sortOption);
} catch (e) {
return res.status(500).json({ message: (e as Error).message });
}
return res.status(200).json(collection);
}

async findById(req: Request<{ id: string }>, res: Response): Promise<unknown> {
if (!mongoose.Types.ObjectId.isValid(req.params.id)) {
return res.status(400).json({ message: 'Find id is invalid' });
}
const sortOption = typeof req.query.sort === 'string' ? req.query.sort : undefined;
let doc;
try {
doc = await (this.model as unknown as { findById: (id: string, sort?: string) => Promise<unknown | null> }).findById(req.params.id, sortOption);
} catch (e) {
return res.status(500).json({ message: (e as Error).message });
}
if (!doc) {
return res.status(400).json({ message: 'nothing found with id provided' });
}
return res.status(200).json(doc);
}
}

export default new SetlistController(setlistModel) as unknown as Icontroller;
12 changes: 7 additions & 5 deletions src/model/setlist/setlist-facade.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,16 @@ type AnyDoc = Record<string, unknown>;
// own inline fields) — web-jam-back#946. lean() + populate() together are
// fine; the prior bug was the missing populate, not lean itself.
class SetlistModel extends Model {
find(query: QueryFilter<AnyDoc>): Promise<AnyDoc[]> {
return this.Schema.find(query).populate('items.songId').lean().exec()
.then((docs) => (docs as AnyDoc[]).map((doc) => resolveSetlistDoc(doc))) as unknown as Promise<AnyDoc[]>;
find(query: QueryFilter<AnyDoc>, sortOption?: string): Promise<AnyDoc[]> {
const mongoQuery = { ...query };
delete (mongoQuery as Record<string, unknown>).sort;
return this.Schema.find(mongoQuery).populate('items.songId').lean().exec()
.then((docs) => (docs as AnyDoc[]).map((doc) => resolveSetlistDoc(doc, sortOption))) as unknown as Promise<AnyDoc[]>;
}

findById(id: string): Promise<AnyDoc | null> {
findById(id: string, sortOption?: string): Promise<AnyDoc | null> {
return this.Schema.findById(id).populate('items.songId').lean().exec()
.then((doc) => (doc ? resolveSetlistDoc(doc as AnyDoc) : null)) as unknown as Promise<AnyDoc | null>;
.then((doc) => (doc ? resolveSetlistDoc(doc as AnyDoc, sortOption) : null)) as unknown as Promise<AnyDoc | null>;
}
}

Expand Down
43 changes: 40 additions & 3 deletions src/model/setlist/setlist-resolve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,9 +78,46 @@ export function resolveSetlistItem(item: SetlistItemLean): ResolvedSetlistItem {
};
}

export function resolveSetlistDoc<T extends { items?: SetlistItemLean[] }>(doc: T): T {
function compareStr(valA?: string, valB?: string, isDesc?: boolean): number {
const strA = String(valA || '').trim();
const strB = String(valB || '').trim();
const cmp = strA.localeCompare(strB, undefined, { sensitivity: 'base', numeric: true });
return isDesc ? -cmp : cmp;
}

export function sortSetlistItems<T extends { title?: string; artist?: string; order?: number }>(
items: T[],
sortOption?: string,
): T[] {
if (!Array.isArray(items) || items.length <= 1) return items;
const opt = (sortOption || '').trim().toLowerCase();

const isDesc = opt.endsWith(':desc') || opt.endsWith('_desc') || opt.endsWith('-desc');
const field = opt.replace(/[:_-]desc$/, '').replace(/[:_-]asc$/, '');

return items.slice().sort((a, b) => {
if (field === 'title') {
const cmp = compareStr(a.title, b.title, isDesc);
return cmp !== 0 ? cmp : Number(a.order ?? 0) - Number(b.order ?? 0);
}
if (field === 'artist') {
const cmp = compareStr(a.artist, b.artist, isDesc);
return cmp !== 0 ? cmp : compareStr(a.title, b.title, false);
}
if (field === 'order' && isDesc) {
return Number(b.order ?? 0) - Number(a.order ?? 0);
}
return Number(a.order ?? 0) - Number(b.order ?? 0);
});
}

export function resolveSetlistDoc<T extends { items?: SetlistItemLean[] }>(doc: T, sortOption?: string): T {
if (!doc || !Array.isArray(doc.items)) return doc;
return { ...doc, items: doc.items.map(resolveSetlistItem) };
const resolvedItems = doc.items.map(resolveSetlistItem);
const sortedItems = sortSetlistItems(resolvedItems, sortOption);
return { ...doc, items: sortedItems };
}

export default { toSetlistPlayerLink, resolveSetlistItem, resolveSetlistDoc };
export default {
toSetlistPlayerLink, resolveSetlistItem, resolveSetlistDoc, sortSetlistItems,
};
52 changes: 51 additions & 1 deletion test/unit/setlist-resolve.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import {
toSetlistPlayerLink, resolveSetlistItem, resolveSetlistDoc, type SetlistItemLean,
toSetlistPlayerLink, resolveSetlistItem, resolveSetlistDoc, sortSetlistItems, type SetlistItemLean,
} from '#src/model/setlist/setlist-resolve.js';

describe('setlist-resolve', () => {
Expand Down Expand Up @@ -85,6 +85,44 @@ describe('setlist-resolve', () => {
});
});

describe('sortSetlistItems', () => {
const items = [
{ order: 1, title: 'Wagon Wheel', artist: 'Old Crow Medicine Show' },
{ order: 2, title: 'Folsom Prison Blues', artist: 'Johnny Cash' },
{ order: 3, title: 'Amie', artist: 'Pure Prairie League' },
];

it('defaults to stored order ascending', () => {
const sorted = sortSetlistItems(items);
expect(sorted.map((i) => i.title)).toEqual(['Wagon Wheel', 'Folsom Prison Blues', 'Amie']);
});

it('sorts by title A-Z for sort=title', () => {
const sorted = sortSetlistItems(items, 'title');
expect(sorted.map((i) => i.title)).toEqual(['Amie', 'Folsom Prison Blues', 'Wagon Wheel']);
});

it('sorts by title Z-A for sort=title:desc', () => {
const sorted = sortSetlistItems(items, 'title:desc');
expect(sorted.map((i) => i.title)).toEqual(['Wagon Wheel', 'Folsom Prison Blues', 'Amie']);
});

it('sorts by artist A-Z for sort=artist', () => {
const sorted = sortSetlistItems(items, 'artist');
expect(sorted.map((i) => i.artist)).toEqual(['Johnny Cash', 'Old Crow Medicine Show', 'Pure Prairie League']);
});

it('sorts by artist Z-A for sort=artist:desc', () => {
const sorted = sortSetlistItems(items, 'artist:desc');
expect(sorted.map((i) => i.artist)).toEqual(['Pure Prairie League', 'Old Crow Medicine Show', 'Johnny Cash']);
});

it('sorts by order descending for sort=order:desc', () => {
const sorted = sortSetlistItems(items, 'order:desc');
expect(sorted.map((i) => i.order)).toEqual([3, 2, 1]);
});
});

describe('resolveSetlistDoc', () => {
it('resolves every item in a mixed setlist (referenced + inline)', () => {
const doc = resolveSetlistDoc({
Expand All @@ -103,6 +141,18 @@ describe('setlist-resolve', () => {
expect(doc.items[1].title).toBe('Inline Cover');
});

it('sorts resolved items when sortOption is provided', () => {
const doc = resolveSetlistDoc({
name: 'Sorted Set',
items: [
{ order: 1, title: 'Zebra' },
{ order: 2, title: 'Apple' },
],
}, 'title');
expect(doc.items[0].title).toBe('Apple');
expect(doc.items[1].title).toBe('Zebra');
});

it('passes through a doc with no items array unchanged', () => {
const doc = { name: 'Empty' } as { name: string; items?: SetlistItemLean[] };
expect(resolveSetlistDoc(doc)).toBe(doc);
Expand Down
44 changes: 44 additions & 0 deletions test/unit/setlist-router.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -206,7 +206,51 @@ describe('The Setlist API', () => {
const list = await request(app).get('/setlist').set({ origin: allowedUrl });
expect(list.status).toBe(200);
const found = (list.body as Array<{ _id: string }>).find((s) => s._id === created._id.toString());
});
});

describe('sort options query support (web-jam-back#945)', () => {
it('returns items in alphabetical order by title when ?sort=title is passed without mutating stored order', async () => {
const created = await SetlistModel.create({
name: 'Practice Set',
items: [
{ order: 1, title: 'Wagon Wheel', artist: 'Old Crow Medicine Show' },
{ order: 2, title: 'Amie', artist: 'Pure Prairie League' },
{ order: 3, title: 'Folsom Prison Blues', artist: 'Johnny Cash' },
],
}) as unknown as { _id: string };

// GET /setlist/:id?sort=title
r = await request(app).get(`/setlist/${created._id}?sort=title`).set({ origin: allowedUrl });
expect(r.status).toBe(200);
expect(r.body.items.map((i: { title: string }) => i.title)).toEqual(['Amie', 'Folsom Prison Blues', 'Wagon Wheel']);

// GET /setlist?sort=title
r = await request(app).get('/setlist?sort=title').set({ origin: allowedUrl });
expect(r.status).toBe(200);
const found = (r.body as Array<{ _id: string; items: Array<{ title: string }> }>).find((s) => s._id === created._id.toString());
expect(found).toBeDefined();
expect(found?.items.map((i) => i.title)).toEqual(['Amie', 'Folsom Prison Blues', 'Wagon Wheel']);

// Verify stored order was NOT mutated (plain GET without sort returns stored order)
r = await request(app).get(`/setlist/${created._id}`).set({ origin: allowedUrl });
expect(r.status).toBe(200);
expect(r.body.items.map((i: { title: string }) => i.title)).toEqual(['Wagon Wheel', 'Amie', 'Folsom Prison Blues']);
});

it('returns items sorted by artist when ?sort=artist is passed', async () => {
const created = await SetlistModel.create({
name: 'Artist Sorted Set',
items: [
{ order: 1, title: 'Wagon Wheel', artist: 'Old Crow Medicine Show' },
{ order: 2, title: 'Folsom Prison Blues', artist: 'Johnny Cash' },
{ order: 3, title: 'Amie', artist: 'Pure Prairie League' },
],
}) as unknown as { _id: string };

r = await request(app).get(`/setlist/${created._id}?sort=artist`).set({ origin: allowedUrl });
expect(r.status).toBe(200);
expect(r.body.items.map((i: { artist: string }) => i.artist)).toEqual(['Johnny Cash', 'Old Crow Medicine Show', 'Pure Prairie League']);
});
});

Expand Down
Loading