mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
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:
@@ -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> =>
|
||||
|
||||
Reference in New Issue
Block a user