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
@@ -187,6 +187,7 @@ function buildValueFor(operator: RuleOperator, field: RuleField): { value?: unkn
case 'after':
return { value: '2023-01-01' };
case 'withinLast':
case 'moreThanAgo':
return { value: 7 };
case 'includesAny':
case 'includesAll':
@@ -206,7 +207,7 @@ function buildValueForCustomType(operator: RuleOperator, type: CustomMetadataFie
return operator === 'between' ? { value: 10, valueTo: 20 } : { value: 10 };
case 'date':
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':
return {};
case 'text':
@@ -368,6 +369,17 @@ describe('BookQueryBuilder', () => {
).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', () => {
const { builder } = makeBuilder();
@@ -1289,6 +1301,16 @@ describe('dateRuleToSql (addedAt)', () => {
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)', () => {
const { builder } = makeBuilder();
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'],
['after', '2024-01-01', 'value_date'],
['withinLast', 30, 'value_date'],
['moreThanAgo', 30, 'value_date'],
] as const)('reads %s with a %s value from %s', (operator, value, column) => {
const clause = buildCustomRule({ operator, value });
@@ -1449,6 +1472,13 @@ describe('customFieldRuleToSql', () => {
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', () => {
expect(() => buildCustomRule({ operator: 'includesAny', value: ['a'] })).toThrow(BadRequestException);
});
@@ -1562,6 +1592,22 @@ describe('read status date filters (startedAt / finishedAt)', () => {
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', () => {
const { builder } = makeBuilder();
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 'after':
case 'withinLast':
case 'moreThanAgo':
return this.dateExprRuleToSql(sql`${bookCustomMetadataValues.valueDate}`, operator, value as string, valueTo, timeZone, 'custom metadata');
case 'isTrue':
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`);
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:
throw new BadRequestException(`Invalid operator '${operator}' for date field`);
}
@@ -721,6 +728,14 @@ export class BookQueryBuilder {
const shiftDays = wholeDays > 0 ? wholeDays - 1 : 0;
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':
return sql`${dateExpr} is null`;
case 'isNotEmpty':
@@ -766,6 +781,14 @@ export class BookQueryBuilder {
const shiftDays = wholeDays > 0 ? wholeDays - 1 : 0;
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:
throw new BadRequestException(`Invalid operator '${operator}' for ${fieldLabel} field`);
}
@@ -55,6 +55,7 @@ function validRuleValue(operator: RuleOperator, field: RuleField): { value?: unk
case 'lt':
case 'lte':
case 'withinLast':
case 'moreThanAgo':
return { value: 1 };
case 'between':
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 };
case 'date':
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 'url':
return { value: 'test' };
@@ -439,6 +440,24 @@ describe('date field value validation', () => {
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', () => {
const rule = { type: 'group', join: 'AND', rules: [{ type: 'rule', field: 'pageCount', operator: 'between', value: 100, valueTo: 300 }] };
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_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_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})$/;
@@ -46,6 +47,11 @@ function isFiniteNumber(value: unknown): value is number {
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 };
/**
@@ -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'] });
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') {
// `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);
@@ -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'] });
}
}
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>;
const groupRuleSchema = (maxDepth: number): z.ZodType<GroupRule> =>