mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
feat(smart-scope): filter by community review count
Allow provider-aware review count criteria so rating thresholds can be qualified by community review volume. Closes #1041
This commit is contained in:
@@ -11,6 +11,7 @@ import {
|
||||
isCustomRuleField,
|
||||
parseCustomRuleFieldId,
|
||||
type CommunityRatingProvider,
|
||||
type CommunityRatingRuleField,
|
||||
type CustomMetadataFieldType,
|
||||
type CustomRuleField,
|
||||
type GroupRule,
|
||||
@@ -44,7 +45,16 @@ const emit = defineEmits<{
|
||||
|
||||
const MAX_DEPTH = 5
|
||||
const BYTES_PER_MIB = 1024 ** 2
|
||||
const NUMERIC_FIELDS: RuleField[] = ['seriesIndex', 'publishedYear', 'pageCount', 'fileSize', 'rating', 'communityRating', 'metadataScore']
|
||||
const NUMERIC_FIELDS: RuleField[] = [
|
||||
'seriesIndex',
|
||||
'publishedYear',
|
||||
'pageCount',
|
||||
'fileSize',
|
||||
'rating',
|
||||
'communityRating',
|
||||
'communityRatingCount',
|
||||
'metadataScore',
|
||||
]
|
||||
const DATE_FIELDS: RuleField[] = ['publishedDate', 'addedAt', 'startedAt', 'finishedAt']
|
||||
const NO_VALUE_OPERATORS: RuleOperator[] = [
|
||||
'isEmpty',
|
||||
@@ -135,6 +145,15 @@ function isNumericField(field: RuleField): boolean {
|
||||
return NUMERIC_FIELDS.includes(field) || customFieldType(field) === 'number'
|
||||
}
|
||||
|
||||
function isCommunityRatingField(field: RuleField): field is CommunityRatingRuleField {
|
||||
return field === 'communityRating' || field === 'communityRatingCount'
|
||||
}
|
||||
|
||||
function providerForRule(rule: Rule): CommunityRatingProvider {
|
||||
if (rule.field === 'communityRating' || rule.field === 'communityRatingCount') return rule.provider ?? 'any'
|
||||
return 'any'
|
||||
}
|
||||
|
||||
function isDateField(field: RuleField): boolean {
|
||||
return DATE_FIELDS.includes(field) || customFieldType(field) === 'date'
|
||||
}
|
||||
@@ -173,7 +192,7 @@ function toEditableRule(r: Rule): EditableRule {
|
||||
return {
|
||||
field: r.field,
|
||||
operator: r.operator,
|
||||
provider: r.field === 'communityRating' ? (r.provider ?? 'any') : 'any',
|
||||
provider: providerForRule(r),
|
||||
value: usesChips ? '' : value,
|
||||
valueChips: usesChips ? (r.value as string[]) : [],
|
||||
valueTo,
|
||||
@@ -257,7 +276,7 @@ function emitUpdate() {
|
||||
: n.rule.valueTo
|
||||
: undefined,
|
||||
}
|
||||
return n.rule.field === 'communityRating' ? ({ ...rule, provider: n.rule.provider } as Rule) : (rule as Rule)
|
||||
return isCommunityRatingField(n.rule.field) ? ({ ...rule, provider: n.rule.provider } as Rule) : (rule as Rule)
|
||||
})
|
||||
const isSubGroup = (props.depth ?? 0) > 0
|
||||
if (!isSubGroup && rules.length === 0 && !props.preserveIncompleteRoot) {
|
||||
@@ -393,7 +412,7 @@ function valueInputType(field: RuleField, operator: RuleOperator): string {
|
||||
|
||||
function numericInputMin(field: RuleField): string | undefined {
|
||||
if (field === 'fileSize') return '0'
|
||||
if (field === 'communityRating') return '0'
|
||||
if (isCommunityRatingField(field)) return '0'
|
||||
return undefined
|
||||
}
|
||||
|
||||
@@ -406,6 +425,7 @@ function numericInputMax(field: RuleField): string | undefined {
|
||||
function numericInputStep(field: RuleField): string | undefined {
|
||||
if (field === 'fileSize') return '0.1'
|
||||
if (field === 'communityRating') return '0.1'
|
||||
if (field === 'communityRatingCount') return '1'
|
||||
return undefined
|
||||
}
|
||||
|
||||
@@ -465,7 +485,7 @@ function showValueToInput(operator: RuleOperator): boolean {
|
||||
</select>
|
||||
|
||||
<div
|
||||
v-if="node.rule.field === 'communityRating'"
|
||||
v-if="isCommunityRatingField(node.rule.field)"
|
||||
class="h-9 flex items-center gap-2 rounded-md border border-input bg-background px-2 shrink-0"
|
||||
>
|
||||
<img
|
||||
|
||||
@@ -234,6 +234,46 @@ describe('BookFilterBuilder', () => {
|
||||
|
||||
expect((wrapper.get('select[aria-label="Community rating provider"]').element as HTMLSelectElement).value).toBe('any')
|
||||
})
|
||||
|
||||
it('serializes a provider-aware community review count rule', async () => {
|
||||
const wrapper = mount(BookFilterBuilder, {
|
||||
props: {
|
||||
modelValue: { type: 'group', join: 'AND', rules: [{ type: 'rule', field: 'title', operator: 'contains', value: 'Dune' }] },
|
||||
},
|
||||
})
|
||||
|
||||
const [fieldSelect] = wrapper.findAll('select')
|
||||
await fieldSelect!.setValue('communityRatingCount')
|
||||
await wrapper.findAll('select')[1]!.setValue('gte')
|
||||
await wrapper.get('select[aria-label="Community rating provider"]').setValue('hardcover')
|
||||
|
||||
const input = wrapper.get('input[type="number"]')
|
||||
expect(input.attributes('min')).toBe('0')
|
||||
expect(input.attributes('max')).toBeUndefined()
|
||||
expect(input.attributes('step')).toBe('1')
|
||||
await input.setValue('1000')
|
||||
|
||||
expect(lastUpdate(wrapper)).toEqual({
|
||||
type: 'group',
|
||||
join: 'AND',
|
||||
rules: [{ type: 'rule', field: 'communityRatingCount', operator: 'gte', value: 1000, valueTo: undefined, provider: 'hardcover' }],
|
||||
})
|
||||
})
|
||||
|
||||
it('hydrates a saved community review count provider', () => {
|
||||
const wrapper = mount(BookFilterBuilder, {
|
||||
props: {
|
||||
modelValue: {
|
||||
type: 'group',
|
||||
join: 'AND',
|
||||
rules: [{ type: 'rule', field: 'communityRatingCount', operator: 'lt', value: 25, provider: 'goodreads' }],
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect((wrapper.get('select[aria-label="Community rating provider"]').element as HTMLSelectElement).value).toBe('goodreads')
|
||||
expect((wrapper.get('input[type="number"]').element as HTMLInputElement).value).toBe('25')
|
||||
})
|
||||
})
|
||||
|
||||
describe('BookFilterBuilder custom metadata fields', () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Rule, StaticRuleField, TableViewType } from '@bookorbit/types'
|
||||
|
||||
type QuickFilterField = Exclude<StaticRuleField, 'communityRating'>
|
||||
type QuickFilterField = Exclude<StaticRuleField, 'communityRating' | 'communityRatingCount'>
|
||||
|
||||
const TEXT_FIELD_MAP: Partial<Record<string, QuickFilterField>> = {
|
||||
title: 'title',
|
||||
|
||||
@@ -137,6 +137,10 @@ describe('community rating labels', () => {
|
||||
it('exposes a Community Rating field label', () => {
|
||||
expect(fieldLabel('communityRating')).toBe('Community Rating')
|
||||
})
|
||||
|
||||
it('exposes a Community Review Count field label', () => {
|
||||
expect(fieldLabel('communityRatingCount')).toBe('Community Review Count')
|
||||
})
|
||||
})
|
||||
|
||||
describe('ruleToParts', () => {
|
||||
@@ -189,4 +193,9 @@ describe('ruleToParts', () => {
|
||||
const rule: Rule = { type: 'rule', field: 'communityRating', provider: 'any', operator: 'gte', value: 4.5 }
|
||||
expect(ruleToParts(rule)).toEqual({ field: 'Community Rating (Any provider)', operator: 'at least', value: '4.5' })
|
||||
})
|
||||
|
||||
it('renders provider context for community review count rules', () => {
|
||||
const rule: Rule = { type: 'rule', field: 'communityRatingCount', provider: 'hardcover', operator: 'gte', value: 1000 }
|
||||
expect(ruleToParts(rule)).toEqual({ field: 'Community Review Count (Hardcover)', operator: 'at least', value: '1000' })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -44,14 +44,17 @@ const NO_VALUE_OPS: RuleOperator[] = [
|
||||
]
|
||||
|
||||
function communityRatingProviderLabel(rule: Rule): string {
|
||||
if (rule.field !== 'communityRating') return ''
|
||||
if (rule.field !== 'communityRating' && rule.field !== 'communityRatingCount') return ''
|
||||
const provider = rule.provider ?? 'any'
|
||||
if (provider === 'any') return t('book.filter.anyProvider')
|
||||
return PROVIDER_SHORT_LABELS[provider] ?? provider
|
||||
}
|
||||
|
||||
export function ruleToParts(rule: Rule): { field: string; operator: string; value: string | null } {
|
||||
const field = rule.field === 'communityRating' ? `${fieldLabel(rule.field)} (${communityRatingProviderLabel(rule)})` : fieldLabel(rule.field)
|
||||
const field =
|
||||
rule.field === 'communityRating' || rule.field === 'communityRatingCount'
|
||||
? `${fieldLabel(rule.field)} (${communityRatingProviderLabel(rule)})`
|
||||
: fieldLabel(rule.field)
|
||||
const operator = operatorLabel(rule.operator)
|
||||
if (NO_VALUE_OPS.includes(rule.operator)) return { field, operator, value: null }
|
||||
if (rule.operator === 'withinLast') return { field, operator, value: `${rule.value} ${t('book.filter.unit.days')}` }
|
||||
|
||||
@@ -4078,6 +4078,7 @@
|
||||
"fileAvailability": "File Availability",
|
||||
"rating": "Rating",
|
||||
"communityRating": "Community Rating",
|
||||
"communityRatingCount": "Community Review Count",
|
||||
"readProgress": "Reading Progress",
|
||||
"readStatus": "Read Status",
|
||||
"description": "Description",
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { CommunityRatingProviderKey } from "./metadata-fetch";
|
||||
* Most fields map directly to a DB column. The exceptions:
|
||||
* - `fileAvailability` - derived from `books.status` ('present' | 'missing')
|
||||
* - `communityRating` - from `book_community_ratings.rating` (optionally provider-specific)
|
||||
* - `communityRatingCount` - from `book_community_ratings.rating_count` (optionally provider-specific)
|
||||
* - `readProgress` - aggregated from `reading_progress.percentage` (per-user, per-book-file)
|
||||
* - `readStatus` - stored in `user_book_status.status` (per-user)
|
||||
* - `startedAt` - from `user_book_status.started_at` (per-user)
|
||||
@@ -47,6 +48,7 @@ export type StaticRuleField =
|
||||
| "fileAvailability"
|
||||
| "rating"
|
||||
| "communityRating"
|
||||
| "communityRatingCount"
|
||||
| "readProgress"
|
||||
| "readStatus"
|
||||
| "description"
|
||||
@@ -122,6 +124,7 @@ export const FIELD_OPERATORS: Record<StaticRuleField, RuleOperator[]> = {
|
||||
fileAvailability: ["isMissing", "isPresent"],
|
||||
rating: ["eq", "gt", "gte", "lt", "lte", "isEmpty", "isNotEmpty"],
|
||||
communityRating: ["eq", "notEq", "gt", "gte", "lt", "lte", "between", "isEmpty", "isNotEmpty"],
|
||||
communityRatingCount: ["eq", "notEq", "gt", "gte", "lt", "lte", "between", "isEmpty", "isNotEmpty"],
|
||||
readProgress: ["isUnread", "isInProgress", "isFinished"],
|
||||
readStatus: ["includesAny", "excludesAll", "isEmpty", "isNotEmpty"],
|
||||
description: ["isEmpty", "isNotEmpty"],
|
||||
@@ -189,11 +192,12 @@ export const RULE_OPERATORS: RuleOperator[] = [
|
||||
];
|
||||
|
||||
export type CommunityRatingProvider = CommunityRatingProviderKey | "any";
|
||||
export type CommunityRatingRuleField = "communityRating" | "communityRatingCount";
|
||||
export type RuleValue = string | number | string[] | number[];
|
||||
|
||||
export type StandardRule = {
|
||||
type: "rule";
|
||||
field: Exclude<StaticRuleField, "communityRating">;
|
||||
field: Exclude<StaticRuleField, CommunityRatingRuleField>;
|
||||
operator: RuleOperator;
|
||||
value?: RuleValue;
|
||||
valueTo?: string | number;
|
||||
@@ -201,7 +205,7 @@ export type StandardRule = {
|
||||
|
||||
export type CommunityRatingRule = {
|
||||
type: "rule";
|
||||
field: "communityRating";
|
||||
field: CommunityRatingRuleField;
|
||||
operator: RuleOperator;
|
||||
provider?: CommunityRatingProvider;
|
||||
value?: RuleValue;
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
CREATE INDEX "book_community_ratings_count_book_idx" ON "book_community_ratings" USING btree ("rating_count","book_id");--> statement-breakpoint
|
||||
CREATE INDEX "book_community_ratings_provider_count_book_idx" ON "book_community_ratings" USING btree ("provider","rating_count","book_id");
|
||||
File diff suppressed because it is too large
Load Diff
@@ -575,6 +575,13 @@
|
||||
"when": 1787931558680,
|
||||
"tag": "0081_add_public_collections",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 82,
|
||||
"version": "7",
|
||||
"when": 1788151243360,
|
||||
"tag": "0082_add_community_rating_count_filter_indexes",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -155,6 +155,8 @@ export const bookCommunityRatings = pgTable(
|
||||
index('book_community_ratings_provider_idx').on(t.provider),
|
||||
index('book_community_ratings_rating_book_idx').on(t.rating, t.bookId),
|
||||
index('book_community_ratings_provider_rating_book_idx').on(t.provider, t.rating, t.bookId),
|
||||
index('book_community_ratings_count_book_idx').on(t.ratingCount, t.bookId),
|
||||
index('book_community_ratings_provider_count_book_idx').on(t.provider, t.ratingCount, t.bookId),
|
||||
check('book_community_ratings_rating_range_chk', sql`${t.rating} >= 0 and ${t.rating} <= 5`),
|
||||
check('book_community_ratings_count_nonnegative_chk', sql`${t.ratingCount} is null or ${t.ratingCount} >= 0`),
|
||||
],
|
||||
|
||||
@@ -136,7 +136,16 @@ const USER_CTX = { accessibleLibraryIds: [1] as number[], userId: 10 };
|
||||
* new operator is added to RuleOperator but not handled here.
|
||||
*/
|
||||
function buildValueFor(operator: RuleOperator, field: RuleField): { value?: unknown; valueTo?: unknown } {
|
||||
const numericFields: RuleField[] = ['publishedYear', 'seriesIndex', 'pageCount', 'fileSize', 'rating', 'communityRating', 'metadataScore'];
|
||||
const numericFields: RuleField[] = [
|
||||
'publishedYear',
|
||||
'seriesIndex',
|
||||
'pageCount',
|
||||
'fileSize',
|
||||
'rating',
|
||||
'communityRating',
|
||||
'communityRatingCount',
|
||||
'metadataScore',
|
||||
];
|
||||
const dateFields: RuleField[] = ['publishedDate', 'addedAt', 'startedAt', 'finishedAt'];
|
||||
const isNumericField = numericFields.includes(field);
|
||||
|
||||
@@ -554,6 +563,89 @@ describe('communityRating filter field', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('communityRatingCount filter field', () => {
|
||||
it('matches any provider count with the requested numeric predicate', () => {
|
||||
const { builder } = makeBuilder();
|
||||
|
||||
const where = builder.buildWhere(
|
||||
wrapRule({ type: 'rule', field: 'communityRatingCount', operator: 'gte', value: 1000, provider: 'any' }) as never,
|
||||
USER_CTX,
|
||||
) as any;
|
||||
|
||||
const ruleSql = getRuleSql(where) as any;
|
||||
expect(ruleSql).toMatchObject({ type: 'sql' });
|
||||
expect(ruleSql.values[0].whereClause.clauses).toHaveLength(2);
|
||||
expect(ruleSql.values[0].whereClause.clauses[1]).toMatchObject({ type: 'gte', right: 1000 });
|
||||
expect(collectColumnNames(ruleSql)).toContain('rating_count');
|
||||
});
|
||||
|
||||
it('matches a specific provider count in the same exists subquery', () => {
|
||||
const { builder } = makeBuilder();
|
||||
|
||||
const where = builder.buildWhere(
|
||||
wrapRule({ type: 'rule', field: 'communityRatingCount', operator: 'lt', value: 25, provider: 'hardcover' }) as never,
|
||||
USER_CTX,
|
||||
) as any;
|
||||
|
||||
const predicates = (getRuleSql(where) as any).values[0].whereClause.clauses;
|
||||
expect(predicates).toHaveLength(3);
|
||||
expect(predicates[1]).toMatchObject({ type: 'eq', right: 'hardcover' });
|
||||
expect(predicates[2]).toMatchObject({ type: 'lt', right: 25 });
|
||||
expect(collectColumnNames(predicates[2])).toContain('rating_count');
|
||||
});
|
||||
|
||||
it('treats a missing row or null count as empty', () => {
|
||||
const { builder } = makeBuilder();
|
||||
|
||||
const where = builder.buildWhere(
|
||||
wrapRule({ type: 'rule', field: 'communityRatingCount', operator: 'isEmpty', provider: 'goodreads' }) as never,
|
||||
USER_CTX,
|
||||
) as any;
|
||||
|
||||
const ruleSql = getRuleSql(where) as any;
|
||||
expect(ruleSql).toMatchObject({ type: 'not' });
|
||||
expect(ruleSql.value.values[0].whereClause.clauses[1]).toMatchObject({ type: 'eq', right: 'goodreads' });
|
||||
expect(ruleSql.value.values[0].whereClause.clauses[2]).toMatchObject({ type: 'isNotNull' });
|
||||
expect(collectColumnNames(ruleSql.value.values[0].whereClause.clauses[2])).toContain('rating_count');
|
||||
});
|
||||
|
||||
it('requires a non-null count for isNotEmpty', () => {
|
||||
const { builder } = makeBuilder();
|
||||
|
||||
const where = builder.buildWhere(
|
||||
wrapRule({ type: 'rule', field: 'communityRatingCount', operator: 'isNotEmpty', provider: 'goodreads' }) as never,
|
||||
USER_CTX,
|
||||
) as any;
|
||||
|
||||
const predicates = (getRuleSql(where) as any).values[0].whereClause.clauses;
|
||||
expect(predicates[1]).toMatchObject({ type: 'eq', right: 'goodreads' });
|
||||
expect(predicates[2]).toMatchObject({ type: 'isNotNull' });
|
||||
expect(collectColumnNames(predicates[2])).toContain('rating_count');
|
||||
});
|
||||
|
||||
it('keeps rating and count predicates provider-specific in an AND group', () => {
|
||||
const { builder } = makeBuilder();
|
||||
|
||||
const where = builder.buildWhere(
|
||||
{
|
||||
type: 'group',
|
||||
join: 'AND',
|
||||
rules: [
|
||||
{ type: 'rule', field: 'communityRating', operator: 'gte', value: 4.5, provider: 'amazon' },
|
||||
{ type: 'rule', field: 'communityRatingCount', operator: 'gte', value: 1000, provider: 'amazon' },
|
||||
],
|
||||
} as never,
|
||||
USER_CTX,
|
||||
) as any;
|
||||
|
||||
const [ratingRule, countRule] = where.clauses[1].clauses;
|
||||
expect(ratingRule.values[0].whereClause.clauses[1]).toMatchObject({ type: 'eq', right: 'amazon' });
|
||||
expect(countRule.values[0].whereClause.clauses[1]).toMatchObject({ type: 'eq', right: 'amazon' });
|
||||
expect(collectColumnNames(ratingRule)).toContain('rating');
|
||||
expect(collectColumnNames(countRule)).toContain('rating_count');
|
||||
});
|
||||
});
|
||||
|
||||
describe('readStatus filter field', () => {
|
||||
it('includesAny generates exists with inArray', () => {
|
||||
const { builder, db } = makeBuilder();
|
||||
|
||||
@@ -173,7 +173,23 @@ export class BookQueryBuilder {
|
||||
if (userId === undefined) throw new BadRequestException('rating filter requires an authenticated user');
|
||||
return this.ratingRuleToSql(operator, value as number | undefined, valueTo as number | undefined, userId);
|
||||
case 'communityRating':
|
||||
return this.communityRatingRuleToSql(operator, value as number | undefined, valueTo as number | undefined, rule.provider);
|
||||
return this.communityRatingRuleToSql(
|
||||
bookCommunityRatings.rating,
|
||||
'communityRating',
|
||||
operator,
|
||||
value as number | undefined,
|
||||
valueTo as number | undefined,
|
||||
rule.provider,
|
||||
);
|
||||
case 'communityRatingCount':
|
||||
return this.communityRatingRuleToSql(
|
||||
bookCommunityRatings.ratingCount,
|
||||
'communityRatingCount',
|
||||
operator,
|
||||
value as number | undefined,
|
||||
valueTo as number | undefined,
|
||||
rule.provider,
|
||||
);
|
||||
case 'author':
|
||||
return this.authorRuleToSql(operator, value as string[]);
|
||||
case 'genre':
|
||||
@@ -516,6 +532,8 @@ export class BookQueryBuilder {
|
||||
}
|
||||
|
||||
private communityRatingRuleToSql(
|
||||
metricColumn: AnyColumn,
|
||||
field: 'communityRating' | 'communityRatingCount',
|
||||
operator: string,
|
||||
value: number | undefined,
|
||||
valueTo: number | undefined,
|
||||
@@ -536,32 +554,32 @@ export class BookQueryBuilder {
|
||||
switch (operator) {
|
||||
case 'eq':
|
||||
this.assertNumber(value, operator, 'value');
|
||||
return existsCommunityRating(eq(bookCommunityRatings.rating, value!));
|
||||
return existsCommunityRating(eq(metricColumn, value!));
|
||||
case 'notEq':
|
||||
this.assertNumber(value, operator, 'value');
|
||||
return existsCommunityRating(ne(bookCommunityRatings.rating, value!));
|
||||
return existsCommunityRating(ne(metricColumn, value!));
|
||||
case 'gt':
|
||||
this.assertNumber(value, operator, 'value');
|
||||
return existsCommunityRating(gt(bookCommunityRatings.rating, value!));
|
||||
return existsCommunityRating(gt(metricColumn, value!));
|
||||
case 'gte':
|
||||
this.assertNumber(value, operator, 'value');
|
||||
return existsCommunityRating(gte(bookCommunityRatings.rating, value!));
|
||||
return existsCommunityRating(gte(metricColumn, value!));
|
||||
case 'lt':
|
||||
this.assertNumber(value, operator, 'value');
|
||||
return existsCommunityRating(lt(bookCommunityRatings.rating, value!));
|
||||
return existsCommunityRating(lt(metricColumn, value!));
|
||||
case 'lte':
|
||||
this.assertNumber(value, operator, 'value');
|
||||
return existsCommunityRating(lte(bookCommunityRatings.rating, value!));
|
||||
return existsCommunityRating(lte(metricColumn, value!));
|
||||
case 'between':
|
||||
this.assertNumber(value, operator, 'value');
|
||||
this.assertNumber(valueTo, operator, 'valueTo');
|
||||
return existsCommunityRating(and(gte(bookCommunityRatings.rating, value!), lte(bookCommunityRatings.rating, valueTo!))!);
|
||||
return existsCommunityRating(and(gte(metricColumn, value!), lte(metricColumn, valueTo!))!);
|
||||
case 'isEmpty':
|
||||
return not(existsCommunityRating());
|
||||
return not(existsCommunityRating(isNotNull(metricColumn)));
|
||||
case 'isNotEmpty':
|
||||
return existsCommunityRating();
|
||||
return existsCommunityRating(isNotNull(metricColumn));
|
||||
default:
|
||||
throw new BadRequestException(`Invalid operator '${operator}' for communityRating field`);
|
||||
throw new BadRequestException(`Invalid operator '${operator}' for ${field} field`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -213,6 +213,16 @@ describe('validateGroupRule', () => {
|
||||
expect(validateGroupRule(rule)).toEqual(rule);
|
||||
});
|
||||
|
||||
it('accepts a community rating count rule with an explicit provider', () => {
|
||||
const rule = {
|
||||
type: 'group',
|
||||
join: 'AND',
|
||||
rules: [{ type: 'rule', field: 'communityRatingCount', operator: 'gte', value: 1000, provider: COMMUNITY_RATING_PROVIDER_KEYS[0] }],
|
||||
};
|
||||
|
||||
expect(validateGroupRule(rule)).toEqual(rule);
|
||||
});
|
||||
|
||||
it('accepts a community rating rule without provider for backwards compatibility', () => {
|
||||
const rule = {
|
||||
type: 'group',
|
||||
@@ -243,6 +253,16 @@ describe('validateGroupRule', () => {
|
||||
expect(() => validateGroupRule(rule)).toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('rejects unsupported providers on community rating count rules', () => {
|
||||
const rule = {
|
||||
type: 'group',
|
||||
join: 'AND',
|
||||
rules: [{ type: 'rule', field: 'communityRatingCount', operator: 'gte', value: 1000, provider: 'kobo' }],
|
||||
};
|
||||
|
||||
expect(() => validateGroupRule(rule)).toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('accepts array values for includesAny operators', () => {
|
||||
const rule = {
|
||||
type: 'group',
|
||||
|
||||
@@ -93,8 +93,12 @@ const ruleSchema: z.ZodType<Rule> = z
|
||||
if (!allowedOperators?.includes(rule.operator as RuleOperator)) {
|
||||
ctx.addIssue({ code: 'custom', message: 'Operator is not valid for this field', path: ['operator'] });
|
||||
}
|
||||
if (rule.provider !== undefined && rule.field !== 'communityRating') {
|
||||
ctx.addIssue({ code: 'custom', message: 'Provider is only valid for community rating rules', path: ['provider'] });
|
||||
if (rule.provider !== undefined && rule.field !== 'communityRating' && rule.field !== 'communityRatingCount') {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
message: 'Provider is only valid for community rating rules and community rating count rules',
|
||||
path: ['provider'],
|
||||
});
|
||||
}
|
||||
if (custom) {
|
||||
checkCustomFieldValue(rule, ctx);
|
||||
|
||||
Reference in New Issue
Block a user