feat(smart-scope): add rolling older-than date filters

Allow users to build bounded historical windows without hard-coded calendar dates. Keep the API additive and advertise support for independently released clients.

Closes #1751
This commit is contained in:
neon
2026-10-21 03:59:41 +00:00
parent 95fa2c77fb
commit d1cc18dc51
11 changed files with 165 additions and 13 deletions
@@ -74,6 +74,7 @@ const NO_VALUE_OPERATORS: RuleOperator[] = [
] ]
const BETWEEN_OPERATORS: RuleOperator[] = ['between'] const BETWEEN_OPERATORS: RuleOperator[] = ['between']
const COLLECTION_OPERATORS: RuleOperator[] = ['includesAny', 'includesAll', 'excludesAll'] const COLLECTION_OPERATORS: RuleOperator[] = ['includesAny', 'includesAll', 'excludesAll']
const RELATIVE_DAY_OPERATORS: RuleOperator[] = ['withinLast', 'moreThanAgo']
const SCORE_PRESETS = [ const SCORE_PRESETS = [
{ label: 'outstanding', gte: 90 }, { label: 'outstanding', gte: 90 },
@@ -215,9 +216,10 @@ function parseValue(field: RuleField, operator: RuleOperator, raw: string, chips
if (NO_VALUE_OPERATORS.includes(operator)) return undefined if (NO_VALUE_OPERATORS.includes(operator)) return undefined
if (COLLECTION_OPERATORS.includes(operator)) return chips if (COLLECTION_OPERATORS.includes(operator)) return chips
if (isNumericField(field)) return raw === '' ? undefined : field === 'fileSize' ? Math.round(Number(raw) * BYTES_PER_MIB) : Number(raw) if (isNumericField(field)) return raw === '' ? undefined : field === 'fileSize' ? Math.round(Number(raw) * BYTES_PER_MIB) : Number(raw)
if (isDateField(field) && operator === 'withinLast') { if (isDateField(field) && RELATIVE_DAY_OPERATORS.includes(operator)) {
if (raw === '') return undefined if (raw === '') return undefined
const n = Number(raw) const n = Number(raw)
if (operator === 'moreThanAgo') return n
const multiplier = unit === 'weeks' ? 7 : unit === 'months' ? 30 : 1 const multiplier = unit === 'weeks' ? 7 : unit === 'months' ? 30 : 1
return n * multiplier return n * multiplier
} }
@@ -406,7 +408,7 @@ function removeStatusChip(index: number, status: string) {
function valueInputType(field: RuleField, operator: RuleOperator): string { function valueInputType(field: RuleField, operator: RuleOperator): string {
if (NO_VALUE_OPERATORS.includes(operator)) return 'none' if (NO_VALUE_OPERATORS.includes(operator)) return 'none'
if (isDateField(field)) return operator === 'withinLast' ? 'number' : 'date' if (isDateField(field)) return RELATIVE_DAY_OPERATORS.includes(operator) ? 'number' : 'date'
if (field === 'seriesIndex') return 'text' if (field === 'seriesIndex') return 'text'
if (isNumericField(field)) return 'number' if (isNumericField(field)) return 'number'
return 'text' return 'text'
@@ -579,8 +581,8 @@ function showValueToInput(operator: RuleOperator): boolean {
</select> </select>
</div> </div>
</template> </template>
<!-- withinLast: number + unit selector --> <!-- Relative date: numeric day count, with convenience units for withinLast. -->
<template v-else-if="node.rule.operator === 'withinLast'"> <template v-else-if="RELATIVE_DAY_OPERATORS.includes(node.rule.operator)">
<input <input
v-model="node.rule.value" v-model="node.rule.value"
type="number" type="number"
@@ -590,6 +592,7 @@ function showValueToInput(operator: RuleOperator): boolean {
@input="emitUpdate" @input="emitUpdate"
/> />
<select <select
v-if="node.rule.operator === 'withinLast'"
v-model="node.rule.valueUnit" v-model="node.rule.valueUnit"
class="h-9 rounded-md border border-input bg-background text-foreground text-sm px-2 focus:outline-none focus:ring-2 focus:ring-primary" class="h-9 rounded-md border border-input bg-background text-foreground text-sm px-2 focus:outline-none focus:ring-2 focus:ring-primary"
@change="emitUpdate" @change="emitUpdate"
@@ -598,6 +601,7 @@ function showValueToInput(operator: RuleOperator): boolean {
<option value="weeks">{{ t('book.filter.unit.weeks') }}</option> <option value="weeks">{{ t('book.filter.unit.weeks') }}</option>
<option value="months">{{ t('book.filter.unit.months') }}</option> <option value="months">{{ t('book.filter.unit.months') }}</option>
</select> </select>
<span v-else class="text-sm text-muted-foreground">{{ t('book.filter.unit.daysAgo') }}</span>
</template> </template>
<!-- Text typeahead: publisher, series, language --> <!-- Text typeahead: publisher, series, language -->
<FilterTextTypeahead <FilterTextTypeahead
@@ -123,7 +123,26 @@ describe('BookFilterBuilder', () => {
const operatorSelect = wrapper.findAll('select')[1] const operatorSelect = wrapper.findAll('select')[1]
const operatorOptions = operatorSelect!.findAll('option').map((opt) => opt.text()) const operatorOptions = operatorSelect!.findAll('option').map((opt) => opt.text())
expect(operatorOptions).toEqual(expect.arrayContaining(['before', 'after', 'between', 'within last', 'is empty', 'is not empty'])) expect(operatorOptions).toEqual(expect.arrayContaining(['before', 'after', 'between', 'within last', 'more than', 'is empty', 'is not empty']))
})
it('emits a more-than-ago date rule as an unconverted day count', async () => {
const wrapper = mount(BookFilterBuilder, {
props: {
modelValue: { type: 'group', join: 'AND', rules: [{ type: 'rule', field: 'title', operator: 'contains', value: 'Dune' }] },
},
})
await wrapper.findAll('select')[0]!.setValue('finishedAt')
await wrapper.findAll('select')[1]!.setValue('moreThanAgo')
await wrapper.get('input[type="number"]').setValue('30')
expect(wrapper.text()).toContain('days ago')
expect(lastUpdate(wrapper)).toEqual({
type: 'group',
join: 'AND',
rules: [{ type: 'rule', field: 'finishedAt', operator: 'moreThanAgo', value: 30, valueTo: undefined }],
})
}) })
it('includes Lock Status in field options', () => { it('includes Lock Status in field options', () => {
@@ -358,7 +377,7 @@ describe('BookFilterBuilder custom metadata fields', () => {
['text', ['contains', 'does not contain', 'starts with', 'ends with', 'is', 'is not', 'is empty', 'is not empty']], ['text', ['contains', 'does not contain', 'starts with', 'ends with', 'is', 'is not', 'is empty', 'is not empty']],
['url', ['contains', 'does not contain', 'starts with', 'ends with', 'is', 'is not', 'is empty', 'is not empty']], ['url', ['contains', 'does not contain', 'starts with', 'ends with', 'is', 'is not', 'is empty', 'is not empty']],
['number', ['is', 'is not', 'greater than', 'at least', 'less than', 'at most', 'between', 'is empty', 'is not empty']], ['number', ['is', 'is not', 'greater than', 'at least', 'less than', 'at most', 'between', 'is empty', 'is not empty']],
['date', ['before', 'after', 'between', 'within last', 'is empty', 'is not empty']], ['date', ['before', 'after', 'between', 'within last', 'more than', 'is empty', 'is not empty']],
['boolean', ['is yes', 'is no', 'is empty', 'is not empty']], ['boolean', ['is yes', 'is no', 'is empty', 'is not empty']],
] as const)('offers only the operators a %s custom field supports', async (type, expected) => { ] as const)('offers only the operators a %s custom field supports', async (type, expected) => {
const wrapper = mountWithCustomFields([makeCustomField({ type })]) const wrapper = mountWithCustomFields([makeCustomField({ type })])
@@ -427,6 +446,20 @@ describe('BookFilterBuilder custom metadata fields', () => {
}) })
}) })
it('emits a more-than-ago day count for a date custom field', async () => {
const wrapper = mountWithCustomFields([makeCustomField({ type: 'date' })])
await fieldOptions(wrapper).setValue('custom:7')
await wrapper.findAll('select')[1]!.setValue('moreThanAgo')
await wrapper.get('input[type="number"]').setValue('45')
expect(lastUpdate(wrapper)).toEqual({
type: 'group',
join: 'AND',
rules: [{ type: 'rule', field: 'custom:7', operator: 'moreThanAgo', value: 45, valueTo: undefined }],
})
})
it('emits a boolean custom field rule with no value input at all', async () => { it('emits a boolean custom field rule with no value input at all', async () => {
const wrapper = mountWithCustomFields([makeCustomField({ type: 'boolean' })]) const wrapper = mountWithCustomFields([makeCustomField({ type: 'boolean' })])
@@ -164,6 +164,11 @@ describe('ruleToParts', () => {
expect(ruleToParts(rule)).toEqual({ field: 'Added Date', operator: 'within last', value: '7 days' }) expect(ruleToParts(rule)).toEqual({ field: 'Added Date', operator: 'within last', value: '7 days' })
}) })
it('renders moreThanAgo rules as a day age', () => {
const rule: Rule = { type: 'rule', field: 'finishedAt', operator: 'moreThanAgo', value: 30 }
expect(ruleToParts(rule)).toEqual({ field: 'Date Finished', operator: 'more than', value: '30 days ago' })
})
it('joins array values with commas', () => { it('joins array values with commas', () => {
const rule: Rule = { type: 'rule', field: 'author', operator: 'includesAny', value: ['Tolkien', 'Le Guin'] } const rule: Rule = { type: 'rule', field: 'author', operator: 'includesAny', value: ['Tolkien', 'Le Guin'] }
expect(ruleToParts(rule)).toEqual({ field: 'Author', operator: 'includes any of', value: 'Tolkien, Le Guin' }) expect(ruleToParts(rule)).toEqual({ field: 'Author', operator: 'includes any of', value: 'Tolkien, Le Guin' })
@@ -58,6 +58,7 @@ export function ruleToParts(rule: Rule): { field: string; operator: string; valu
const operator = operatorLabel(rule.operator) const operator = operatorLabel(rule.operator)
if (NO_VALUE_OPS.includes(rule.operator)) return { field, operator, value: null } 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')}` } if (rule.operator === 'withinLast') return { field, operator, value: `${rule.value} ${t('book.filter.unit.days')}` }
if (rule.operator === 'moreThanAgo') return { field, operator, value: `${rule.value} ${t('book.filter.unit.daysAgo')}` }
if (rule.field === 'fileSize') { if (rule.field === 'fileSize') {
const value = typeof rule.value === 'number' ? formatBytes(rule.value) : '' const value = typeof rule.value === 'number' ? formatBytes(rule.value) : ''
const valueTo = typeof rule.valueTo === 'number' ? formatBytes(rule.valueTo) : null const valueTo = typeof rule.valueTo === 'number' ? formatBytes(rule.valueTo) : null
+2
View File
@@ -6180,6 +6180,7 @@
"before": "before", "before": "before",
"after": "after", "after": "after",
"withinLast": "within last", "withinLast": "within last",
"moreThanAgo": "more than",
"isMissing": "is missing", "isMissing": "is missing",
"isPresent": "is present", "isPresent": "is present",
"isUnread": "is unread", "isUnread": "is unread",
@@ -6202,6 +6203,7 @@
"withinLastPlaceholder": "e.g. 7", "withinLastPlaceholder": "e.g. 7",
"unit": { "unit": {
"days": "days", "days": "days",
"daysAgo": "days ago",
"weeks": "weeks", "weeks": "weeks",
"months": "months", "months": "months",
"megabytes": "MB" "megabytes": "MB"
+2
View File
@@ -37,6 +37,8 @@ export const SERVER_FEATURES = [
* names an author but no title. * names an author but no title.
*/ */
"metadata-search-results-per-provider", "metadata-search-results-per-provider",
/** Book and custom date filters accept the `moreThanAgo` rolling-boundary operator. */
"relative-date-ranges",
] as const; ] as const;
export type ServerFeature = (typeof SERVER_FEATURES)[number]; export type ServerFeature = (typeof SERVER_FEATURES)[number];
+7 -5
View File
@@ -94,6 +94,7 @@ export type RuleOperator =
| "before" | "before"
| "after" | "after"
| "withinLast" | "withinLast"
| "moreThanAgo"
| "isMissing" | "isMissing"
| "isPresent" | "isPresent"
| "isUnread" | "isUnread"
@@ -117,13 +118,13 @@ export const FIELD_OPERATORS: Record<StaticRuleField, RuleOperator[]> = {
library: ["includesAny", "excludesAll"], library: ["includesAny", "excludesAll"],
format: ["includesAny", "excludesAll"], format: ["includesAny", "excludesAll"],
fileSize: ["eq", "notEq", "gt", "gte", "lt", "lte", "between", "isEmpty", "isNotEmpty"], fileSize: ["eq", "notEq", "gt", "gte", "lt", "lte", "between", "isEmpty", "isNotEmpty"],
publishedDate: ["before", "after", "between", "withinLast", "isEmpty", "isNotEmpty"], publishedDate: ["before", "after", "between", "withinLast", "moreThanAgo", "isEmpty", "isNotEmpty"],
publishedYear: ["eq", "notEq", "gt", "gte", "lt", "lte", "between", "isEmpty", "isNotEmpty"], publishedYear: ["eq", "notEq", "gt", "gte", "lt", "lte", "between", "isEmpty", "isNotEmpty"],
seriesIndex: ["eq", "notEq", "gt", "gte", "lt", "lte", "between", "isEmpty", "isNotEmpty"], seriesIndex: ["eq", "notEq", "gt", "gte", "lt", "lte", "between", "isEmpty", "isNotEmpty"],
pageCount: ["gt", "gte", "lt", "lte", "between", "isEmpty", "isNotEmpty"], pageCount: ["gt", "gte", "lt", "lte", "between", "isEmpty", "isNotEmpty"],
addedAt: ["before", "after", "between", "withinLast"], addedAt: ["before", "after", "between", "withinLast", "moreThanAgo"],
startedAt: ["before", "after", "between", "withinLast", "isEmpty", "isNotEmpty"], startedAt: ["before", "after", "between", "withinLast", "moreThanAgo", "isEmpty", "isNotEmpty"],
finishedAt: ["before", "after", "between", "withinLast", "isEmpty", "isNotEmpty"], finishedAt: ["before", "after", "between", "withinLast", "moreThanAgo", "isEmpty", "isNotEmpty"],
fileAvailability: ["isMissing", "isPresent"], fileAvailability: ["isMissing", "isPresent"],
rating: ["eq", "gt", "gte", "lt", "lte", "isEmpty", "isNotEmpty"], rating: ["eq", "gt", "gte", "lt", "lte", "isEmpty", "isNotEmpty"],
communityRating: ["eq", "notEq", "gt", "gte", "lt", "lte", "between", "isEmpty", "isNotEmpty"], communityRating: ["eq", "notEq", "gt", "gte", "lt", "lte", "between", "isEmpty", "isNotEmpty"],
@@ -152,7 +153,7 @@ export const CUSTOM_FIELD_TYPE_OPERATORS: Record<CustomMetadataFieldType, RuleOp
text: ["contains", "notContains", "startsWith", "endsWith", "eq", "notEq", "isEmpty", "isNotEmpty"], text: ["contains", "notContains", "startsWith", "endsWith", "eq", "notEq", "isEmpty", "isNotEmpty"],
url: ["contains", "notContains", "startsWith", "endsWith", "eq", "notEq", "isEmpty", "isNotEmpty"], url: ["contains", "notContains", "startsWith", "endsWith", "eq", "notEq", "isEmpty", "isNotEmpty"],
number: ["eq", "notEq", "gt", "gte", "lt", "lte", "between", "isEmpty", "isNotEmpty"], number: ["eq", "notEq", "gt", "gte", "lt", "lte", "between", "isEmpty", "isNotEmpty"],
date: ["before", "after", "between", "withinLast", "isEmpty", "isNotEmpty"], date: ["before", "after", "between", "withinLast", "moreThanAgo", "isEmpty", "isNotEmpty"],
boolean: ["isTrue", "isFalse", "isEmpty", "isNotEmpty"], boolean: ["isTrue", "isFalse", "isEmpty", "isNotEmpty"],
}; };
@@ -183,6 +184,7 @@ export const RULE_OPERATORS: RuleOperator[] = [
"before", "before",
"after", "after",
"withinLast", "withinLast",
"moreThanAgo",
"isMissing", "isMissing",
"isPresent", "isPresent",
"isUnread", "isUnread",
@@ -187,6 +187,7 @@ function buildValueFor(operator: RuleOperator, field: RuleField): { value?: unkn
case 'after': case 'after':
return { value: '2023-01-01' }; return { value: '2023-01-01' };
case 'withinLast': case 'withinLast':
case 'moreThanAgo':
return { value: 7 }; return { value: 7 };
case 'includesAny': case 'includesAny':
case 'includesAll': case 'includesAll':
@@ -206,7 +207,7 @@ function buildValueForCustomType(operator: RuleOperator, type: CustomMetadataFie
return operator === 'between' ? { value: 10, valueTo: 20 } : { value: 10 }; return operator === 'between' ? { value: 10, valueTo: 20 } : { value: 10 };
case 'date': case 'date':
if (operator === 'between') return { value: '2024-01-01', valueTo: '2024-12-31' }; if (operator === 'between') return { value: '2024-01-01', valueTo: '2024-12-31' };
return operator === 'withinLast' ? { value: 7 } : { value: '2024-01-01' }; return operator === 'withinLast' || operator === 'moreThanAgo' ? { value: 7 } : { value: '2024-01-01' };
case 'boolean': case 'boolean':
return {}; return {};
case 'text': case 'text':
@@ -368,6 +369,17 @@ describe('BookQueryBuilder', () => {
).toThrow("Operator 'withinLast' requires a non-negative value"); ).toThrow("Operator 'withinLast' requires a non-negative value");
}); });
it('rejects negative moreThanAgo values', () => {
const { builder } = makeBuilder();
expect(() =>
builder.buildWhere(wrapRule({ type: 'rule', field: 'addedAt', operator: 'moreThanAgo', value: -3 }) as never, {
accessibleLibraryIds: [1],
userId: 10,
}),
).toThrow("Operator 'moreThanAgo' requires a non-negative value");
});
it('requires authenticated user for readProgress filters', () => { it('requires authenticated user for readProgress filters', () => {
const { builder } = makeBuilder(); const { builder } = makeBuilder();
@@ -1289,6 +1301,16 @@ describe('dateRuleToSql (addedAt)', () => {
expect(getRuleSql(where)).toMatchObject({ type: 'sql' }); expect(getRuleSql(where)).toMatchObject({ type: 'sql' });
}); });
it('moreThanAgo produces the inverse added-date interval expression', () => {
const { builder } = makeBuilder();
const where = builder.buildWhere(wrapRule({ type: 'rule', field: 'addedAt', operator: 'moreThanAgo', value: 30 }) as never, BASE_CTX) as any;
const rule = getRuleSql(where);
expect(rule).toMatchObject({ type: 'sql' });
expect(rule.text).toContain('< NOW() - (');
expect(rule.values).toContain(30);
});
it('withinLast with zero days is valid (produces sql expression)', () => { it('withinLast with zero days is valid (produces sql expression)', () => {
const { builder } = makeBuilder(); const { builder } = makeBuilder();
expect(() => builder.buildWhere(wrapRule({ type: 'rule', field: 'addedAt', operator: 'withinLast', value: 0 }) as never, BASE_CTX)).not.toThrow(); expect(() => builder.buildWhere(wrapRule({ type: 'rule', field: 'addedAt', operator: 'withinLast', value: 0 }) as never, BASE_CTX)).not.toThrow();
@@ -1372,6 +1394,7 @@ describe('customFieldRuleToSql', () => {
['before', '2024-01-01', 'value_date'], ['before', '2024-01-01', 'value_date'],
['after', '2024-01-01', 'value_date'], ['after', '2024-01-01', 'value_date'],
['withinLast', 30, 'value_date'], ['withinLast', 30, 'value_date'],
['moreThanAgo', 30, 'value_date'],
] as const)('reads %s with a %s value from %s', (operator, value, column) => { ] as const)('reads %s with a %s value from %s', (operator, value, column) => {
const clause = buildCustomRule({ operator, value }); const clause = buildCustomRule({ operator, value });
@@ -1449,6 +1472,13 @@ describe('customFieldRuleToSql', () => {
expect(collectSqlText(clause).join(' ')).toContain('timezone('); expect(collectSqlText(clause).join(' ')).toContain('timezone(');
}); });
it('keeps date arithmetic Postgres-safe for moreThanAgo', () => {
const clause = buildCustomRule({ operator: 'moreThanAgo', value: 30 });
expectPostgresSafeDateArithmetic(clause);
expect(collectSqlText(clause).join(' ')).toContain('timezone(');
});
it('rejects list values that no custom field operator accepts', () => { it('rejects list values that no custom field operator accepts', () => {
expect(() => buildCustomRule({ operator: 'includesAny', value: ['a'] })).toThrow(BadRequestException); expect(() => buildCustomRule({ operator: 'includesAny', value: ['a'] })).toThrow(BadRequestException);
}); });
@@ -1562,6 +1592,22 @@ describe('read status date filters (startedAt / finishedAt)', () => {
expectPostgresSafeDateArithmetic(rule); expectPostgresSafeDateArithmetic(rule);
}); });
it.each(['startedAt', 'finishedAt'] as const)('uses the complementary calendar-day boundary for moreThanAgo on %s', (field) => {
const { builder } = makeBuilder();
const where = builder.buildWhere(wrapRule({ type: 'rule', field, operator: 'moreThanAgo', value: 30 }) as never, {
...USER_CTX,
timeZone: 'America/Chicago',
}) as any;
const rule = getRuleSql(where);
expect(rule).toMatchObject({ type: 'sql' });
expect(rule.text.toLowerCase()).toContain('< (timezone(');
expect(rule.text.toLowerCase()).toContain('::date - ::int');
expect(rule.values[1]).toBe('America/Chicago');
expect(rule.values[2]).toBe(29);
expectPostgresSafeDateArithmetic(rule);
});
it('supports isEmpty / isNotEmpty operators', () => { it('supports isEmpty / isNotEmpty operators', () => {
const { builder } = makeBuilder(); const { builder } = makeBuilder();
const emptyWhere = builder.buildWhere(wrapRule({ type: 'rule', field: 'startedAt', operator: 'isEmpty' }) as never, USER_CTX) as any; const emptyWhere = builder.buildWhere(wrapRule({ type: 'rule', field: 'startedAt', operator: 'isEmpty' }) as never, USER_CTX) as any;
@@ -391,6 +391,7 @@ export class BookQueryBuilder {
case 'before': case 'before':
case 'after': case 'after':
case 'withinLast': case 'withinLast':
case 'moreThanAgo':
return this.dateExprRuleToSql(sql`${bookCustomMetadataValues.valueDate}`, operator, value as string, valueTo, timeZone, 'custom metadata'); return this.dateExprRuleToSql(sql`${bookCustomMetadataValues.valueDate}`, operator, value as string, valueTo, timeZone, 'custom metadata');
case 'isTrue': case 'isTrue':
return eq(bookCustomMetadataValues.valueBoolean, true); return eq(bookCustomMetadataValues.valueBoolean, true);
@@ -686,6 +687,12 @@ export class BookQueryBuilder {
if (days! < 0) throw new BadRequestException(`Operator '${operator}' requires a non-negative value`); if (days! < 0) throw new BadRequestException(`Operator '${operator}' requires a non-negative value`);
return sql`${books.addedAt} >= NOW() - (${days} * INTERVAL '1 day')`; return sql`${books.addedAt} >= NOW() - (${days} * INTERVAL '1 day')`;
} }
case 'moreThanAgo': {
const days = typeof value === 'string' ? Number(value) : value;
this.assertNumber(days, operator, 'value');
if (days! < 0) throw new BadRequestException(`Operator '${operator}' requires a non-negative value`);
return sql`${books.addedAt} < NOW() - (${days} * INTERVAL '1 day')`;
}
default: default:
throw new BadRequestException(`Invalid operator '${operator}' for date field`); throw new BadRequestException(`Invalid operator '${operator}' for date field`);
} }
@@ -721,6 +728,14 @@ export class BookQueryBuilder {
const shiftDays = wholeDays > 0 ? wholeDays - 1 : 0; const shiftDays = wholeDays > 0 ? wholeDays - 1 : 0;
return sql`${dateExpr} >= (timezone(${timeZone}, now())::date - ${shiftDays}::int)`; return sql`${dateExpr} >= (timezone(${timeZone}, now())::date - ${shiftDays}::int)`;
} }
case 'moreThanAgo': {
const days = typeof value === 'string' ? Number(value) : value;
this.assertNumber(days, operator, 'value');
if (days! < 0) throw new BadRequestException(`Operator '${operator}' requires a non-negative value`);
const wholeDays = Math.floor(days!);
const shiftDays = wholeDays > 0 ? wholeDays - 1 : 0;
return sql`${dateExpr} < (timezone(${timeZone}, now())::date - ${shiftDays}::int)`;
}
case 'isEmpty': case 'isEmpty':
return sql`${dateExpr} is null`; return sql`${dateExpr} is null`;
case 'isNotEmpty': case 'isNotEmpty':
@@ -766,6 +781,14 @@ export class BookQueryBuilder {
const shiftDays = wholeDays > 0 ? wholeDays - 1 : 0; const shiftDays = wholeDays > 0 ? wholeDays - 1 : 0;
return sql`${dateExpr} >= (timezone(${timeZone}, now())::date - ${shiftDays}::int)`; return sql`${dateExpr} >= (timezone(${timeZone}, now())::date - ${shiftDays}::int)`;
} }
case 'moreThanAgo': {
const days = typeof value === 'string' ? Number(value) : value;
this.assertNumber(days, operator, 'value');
if (days! < 0) throw new BadRequestException(`Operator '${operator}' requires a non-negative value`);
const wholeDays = Math.floor(days!);
const shiftDays = wholeDays > 0 ? wholeDays - 1 : 0;
return sql`${dateExpr} < (timezone(${timeZone}, now())::date - ${shiftDays}::int)`;
}
default: default:
throw new BadRequestException(`Invalid operator '${operator}' for ${fieldLabel} field`); throw new BadRequestException(`Invalid operator '${operator}' for ${fieldLabel} field`);
} }
@@ -55,6 +55,7 @@ function validRuleValue(operator: RuleOperator, field: RuleField): { value?: unk
case 'lt': case 'lt':
case 'lte': case 'lte':
case 'withinLast': case 'withinLast':
case 'moreThanAgo':
return { value: 1 }; return { value: 1 };
case 'between': case 'between':
return DATE_FIELDS.includes(field) ? { value: '2024-01-01', valueTo: '2024-06-01' } : { value: 1, valueTo: 2 }; return DATE_FIELDS.includes(field) ? { value: '2024-01-01', valueTo: '2024-06-01' } : { value: 1, valueTo: 2 };
@@ -77,7 +78,7 @@ function validCustomRuleValue(operator: RuleOperator, type: CustomMetadataFieldT
return operator === 'between' ? { value: 1, valueTo: 5 } : { value: 1 }; return operator === 'between' ? { value: 1, valueTo: 5 } : { value: 1 };
case 'date': case 'date':
if (operator === 'between') return { value: '2024-01-01', valueTo: '2024-06-01' }; if (operator === 'between') return { value: '2024-01-01', valueTo: '2024-06-01' };
return operator === 'withinLast' ? { value: 7 } : { value: '2024-01-01' }; return operator === 'withinLast' || operator === 'moreThanAgo' ? { value: 7 } : { value: '2024-01-01' };
case 'text': case 'text':
case 'url': case 'url':
return { value: 'test' }; return { value: 'test' };
@@ -439,6 +440,24 @@ describe('date field value validation', () => {
expect(validateGroupRule(rule)).toBeDefined(); expect(validateGroupRule(rule)).toBeDefined();
}); });
it('accepts a non-negative day count for moreThanAgo', () => {
const rule = { type: 'group', join: 'AND', rules: [{ type: 'rule', field: 'finishedAt', operator: 'moreThanAgo', value: 30 }] };
expect(validateGroupRule(rule)).toBeDefined();
});
it.each([-1, 'not-a-number', undefined])('rejects an invalid moreThanAgo day count: %s', (value) => {
const rule = { type: 'group', join: 'AND', rules: [{ type: 'rule', field: 'finishedAt', operator: 'moreThanAgo', value }] };
expect(() => validateGroupRule(rule)).toThrow(BadRequestException);
});
it('validates moreThanAgo day counts for custom date fields', () => {
const valid = { type: 'group', join: 'AND', rules: [{ type: 'rule', field: customRuleField(7), operator: 'moreThanAgo', value: 14 }] };
const invalid = { type: 'group', join: 'AND', rules: [{ type: 'rule', field: customRuleField(7), operator: 'moreThanAgo', value: -1 }] };
expect(validateGroupRule(valid)).toBeDefined();
expect(() => validateGroupRule(invalid)).toThrow(BadRequestException);
});
it('does not apply date validation to non-date fields sharing the between operator', () => { it('does not apply date validation to non-date fields sharing the between operator', () => {
const rule = { type: 'group', join: 'AND', rules: [{ type: 'rule', field: 'pageCount', operator: 'between', value: 100, valueTo: 300 }] }; const rule = { type: 'group', join: 'AND', rules: [{ type: 'rule', field: 'pageCount', operator: 'between', value: 100, valueTo: 300 }] };
expect(validateGroupRule(rule)).toBeDefined(); expect(validateGroupRule(rule)).toBeDefined();
@@ -20,6 +20,7 @@ const COMMUNITY_RATING_PROVIDER_VALUES = ['any', ...COMMUNITY_RATING_PROVIDER_KE
const DATE_VALUE_FIELDS = new Set<StaticRuleField>(['addedAt', 'startedAt', 'finishedAt', 'publishedDate']); const DATE_VALUE_FIELDS = new Set<StaticRuleField>(['addedAt', 'startedAt', 'finishedAt', 'publishedDate']);
const DATE_VALUE_OPERATORS = new Set<RuleOperator>(['before', 'after', 'between']); const DATE_VALUE_OPERATORS = new Set<RuleOperator>(['before', 'after', 'between']);
const RELATIVE_DATE_OPERATORS = new Set<RuleOperator>(['moreThanAgo']);
const CUSTOM_NUMERIC_OPERATORS = new Set<RuleOperator>(['gt', 'gte', 'lt', 'lte']); const CUSTOM_NUMERIC_OPERATORS = new Set<RuleOperator>(['gt', 'gte', 'lt', 'lte']);
const CUSTOM_DATE_OPERATORS = new Set<RuleOperator>(['before', 'after']); const CUSTOM_DATE_OPERATORS = new Set<RuleOperator>(['before', 'after']);
const ISO_DATE_TIME_RE = /^(\d{4}-\d{2}-\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/; const ISO_DATE_TIME_RE = /^(\d{4}-\d{2}-\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/;
@@ -46,6 +47,11 @@ function isFiniteNumber(value: unknown): value is number {
return typeof value === 'number' && Number.isFinite(value); return typeof value === 'number' && Number.isFinite(value);
} }
function isNonNegativeRelativeDayCount(value: unknown): boolean {
const number = typeof value === 'string' && value.trim() !== '' ? Number(value) : value;
return typeof number === 'number' && Number.isFinite(number) && number >= 0;
}
type UnvalidatedRule = { field: string; operator: string; value?: unknown; valueTo?: unknown }; type UnvalidatedRule = { field: string; operator: string; value?: unknown; valueTo?: unknown };
/** /**
@@ -67,6 +73,10 @@ function checkCustomFieldValue(rule: UnvalidatedRule, ctx: z.RefinementCtx): voi
ctx.addIssue({ code: 'custom', message: `Operator '${operator}' requires a valid date value`, path: ['value'] }); ctx.addIssue({ code: 'custom', message: `Operator '${operator}' requires a valid date value`, path: ['value'] });
return; return;
} }
if (RELATIVE_DATE_OPERATORS.has(operator as RuleOperator) && !isNonNegativeRelativeDayCount(value)) {
ctx.addIssue({ code: 'custom', message: `Operator '${operator}' requires a non-negative day count`, path: ['value'] });
return;
}
if (operator === 'between') { if (operator === 'between') {
// `value` picks the column the query builder reads, so `valueTo` has to suit that same column. // `value` picks the column the query builder reads, so `valueTo` has to suit that same column.
const rangeIsValid = isFiniteNumber(value) ? isFiniteNumber(valueTo) : isValidRuleDateValue(value) && isValidRuleDateValue(valueTo); const rangeIsValid = isFiniteNumber(value) ? isFiniteNumber(valueTo) : isValidRuleDateValue(value) && isValidRuleDateValue(valueTo);
@@ -112,6 +122,11 @@ const ruleSchema: z.ZodType<Rule> = z
ctx.addIssue({ code: 'custom', message: `Operator '${rule.operator}' requires a valid date valueTo`, path: ['valueTo'] }); ctx.addIssue({ code: 'custom', message: `Operator '${rule.operator}' requires a valid date valueTo`, path: ['valueTo'] });
} }
} }
if (DATE_VALUE_FIELDS.has(rule.field as StaticRuleField) && RELATIVE_DATE_OPERATORS.has(rule.operator as RuleOperator)) {
if (!isNonNegativeRelativeDayCount(rule.value)) {
ctx.addIssue({ code: 'custom', message: `Operator '${rule.operator}' requires a non-negative day count`, path: ['value'] });
}
}
}) as z.ZodType<Rule>; }) as z.ZodType<Rule>;
const groupRuleSchema = (maxDepth: number): z.ZodType<GroupRule> => const groupRuleSchema = (maxDepth: number): z.ZodType<GroupRule> =>