Skip to content

eng-274 feat(crm): add personal saved views#383

Merged
Connorbelez merged 3 commits intomainfrom
eng-274
Apr 9, 2026
Merged

eng-274 feat(crm): add personal saved views#383
Connorbelez merged 3 commits intomainfrom
eng-274

Conversation

@Connorbelez
Copy link
Copy Markdown
Owner

@Connorbelez Connorbelez commented Apr 3, 2026

feat(crm): add personal saved views

responding to feedback

Summary by CodeRabbit

  • New Features

    • Users can now create, manage, and set default personal saved views for CRM objects.
    • Saved views support custom filters, field visibility, ordering, grouping, and aggregates with proper permission isolation.
    • Expanded filter operators including date comparisons (before, after), range filtering (between), and equality operators.
    • Calendar and kanban views now integrate with personal saved views.
  • Tests

    • Added comprehensive test coverage for user saved views creation, defaults, and permission scoping.

@linear
Copy link
Copy Markdown

linear bot commented Apr 3, 2026

Copy link
Copy Markdown

@sourcery-ai sourcery-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry @Connorbelez, you have reached your weekly rate limit of 500000 diff characters.

Please try again later or upgrade to continue using Sourcery

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Apr 3, 2026

Warning

Rate limit exceeded

@Connorbelez has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 6 minutes and 9 seconds before requesting another review.

Your organization is not enrolled in usage-based pricing. Contact your admin to enable usage-based pricing to continue reviews beyond the rate limit, or try again in 6 minutes and 9 seconds.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: d3054523-2d19-4fb3-a591-27d0e6cf3ec6

📥 Commits

Reviewing files that changed from the base of the PR and between 0d7fff7 and 8148143.

📒 Files selected for processing (13)
  • convex/crm/__tests__/records.test.ts
  • convex/crm/__tests__/userSavedViews.test.ts
  • convex/crm/__tests__/viewEngine.test.ts
  • convex/crm/calendarQuery.ts
  • convex/crm/recordQueries.ts
  • convex/crm/types.ts
  • convex/crm/userSavedViews.ts
  • convex/crm/validators.ts
  • convex/crm/viewQueries.ts
  • convex/crm/viewState.ts
  • convex/schema.ts
  • convex/test/moduleMaps.ts
  • convex/test/registerAuditLogComponent.ts
🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 11.90% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title 'feat(crm): add personal saved views' directly and clearly summarizes the main change—implementing personal saved views functionality in the CRM module.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch eng-274

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Owner Author

Connorbelez commented Apr 3, 2026

@Connorbelez Connorbelez marked this pull request as ready for review April 3, 2026 14:14
Copilot AI review requested due to automatic review settings April 3, 2026 14:14
Copy link
Copy Markdown

@greptile-apps greptile-apps bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Your free trial has ended. If you'd like to continue receiving code reviews, you can add a payment method here.

@Connorbelez Connorbelez changed the title feat(crm): add personal saved views eng-273 feat(crm): add personal saved views Apr 3, 2026
@Connorbelez Connorbelez changed the title eng-273 feat(crm): add personal saved views eng-274 feat(crm): add personal saved views Apr 3, 2026
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
convex/crm/recordQueries.ts (1)

265-285: ⚠️ Potential issue | 🟡 Minor

logicalOperator field declared in RecordFilter but not used.

The applyFilters function uses .every() which applies AND logic to all filters, ignoring the logicalOperator field. Per the type definition in types.ts, RecordFilter includes logicalOperator?: "and" | "or", but it's never consulted here.

Note that calendarQuery.ts does use logicalOperator in its applyViewFilters function, so there's an inconsistency between the two filter application paths.

🔧 Suggested fix to support OR logic
 export function applyFilters(
 	records: UnifiedRecord[],
 	filters: RecordFilter[],
 	fieldDefsById: Map<string, FieldDef>
 ): UnifiedRecord[] {
 	// No filters = no constraints; return all records (permissive empty case)
 	if (filters.length === 0) {
 		return records;
 	}

-	return records.filter((record) =>
-		filters.every((filter) => {
+	return records.filter((record) => {
+		let combined: boolean | undefined;
+		for (const filter of filters) {
 			const fieldDef = fieldDefsById.get(filter.fieldDefId.toString());
 			if (!fieldDef) {
-				return false; // fail-closed: unknown fieldDefId never matches
+				return false;
 			}
 			const fieldValue = record.fields[fieldDef.name];
-			return matchesFilter(fieldValue, filter.operator, filter.value);
-		})
-	);
+			const nextMatch = matchesFilter(fieldValue, filter.operator, filter.value);
+			if (combined === undefined) {
+				combined = nextMatch;
+				continue;
+			}
+			combined = filter.logicalOperator === "or"
+				? combined || nextMatch
+				: combined && nextMatch;
+		}
+		return combined ?? true;
+	});
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@convex/crm/recordQueries.ts` around lines 265 - 285, applyFilters currently
uses filters.every(...) which enforces AND for all filters and ignores
RecordFilter.logicalOperator; update applyFilters to evaluate each filter using
matchesFilter but combine their boolean results according to each
filter.logicalOperator (treat undefined as "and" or use a default behavior
matching calendarQuery.applyViewFilters), e.g., iterate filters for a record and
accumulate a running result switching between AND/OR when filter.logicalOperator
is "and"/"or"; ensure you still fetch fieldDef via
fieldDefsById.get(filter.fieldDefId.toString()) and treat a missing fieldDef as
a false match for that individual filter (but do not short-circuit incorrectly
when combining with OR).
🧹 Nitpick comments (6)
convex/crm/types.ts (1)

69-74: SavedViewFilterDefinition.value type mismatch with RecordFilter.value.

SavedViewFilterDefinition.value is typed as v.optional(v.string()), but RecordFilter.value is unknown. This creates a type mismatch when converting saved view filters to record filters. Operators like between expect value to be number[], and is_any_of expects string[].

Consider either:

  1. Using a union type for value in SavedViewFilterDefinition to match supported filter value shapes, or
  2. Documenting that string serialization/deserialization is expected during conversion
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@convex/crm/types.ts` around lines 69 - 74, SavedViewFilterDefinition.value
currently typed as optional string conflicts with RecordFilter.value (unknown)
and operators that expect arrays or numbers; update the
SavedViewFilterDefinition interface so value is a union covering the supported
shapes (e.g., value?: string | string[] | number | number[] | boolean | null) to
match operator expectations (and adjust any v.* validators accordingly), and
ensure conversion code that maps SavedViewFilterDefinition -> RecordFilter
handles deserialization/typing for operators like "between" and "is_any_of"
based on SavedViewFilterDefinition.operator.
convex/crm/calendarQuery.ts (1)

99-158: Consider consolidating filter matching logic with recordQueries.ts.

matchesViewFilter duplicates much of the logic from matchesFilter in recordQueries.ts, with some differences (array handling for equals/is/is_not). This creates maintenance burden and potential for drift.

Consider either:

  1. Extracting shared logic to a common helper module
  2. Enhancing matchesFilter to handle the array cases and reusing it here
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@convex/crm/calendarQuery.ts` around lines 99 - 158, matchesViewFilter
duplicates most of the logic in matchesFilter (recordQueries.ts) and only
differs in special-casing arrays for "equals"/"is"/"is_not", which risks drift;
refactor by either extracting the shared predicate logic into a common helper
(e.g., create a new normalizeAndMatch/compareValue helper used by both
matchesFilter and matchesViewFilter) or by enhancing matchesFilter to accept an
options flag to handle array semantics and then replace the duplicated branches
in matchesViewFilter to call matchesFilter with that flag; update the
implementations of matchesViewFilter and any call sites to use the shared helper
or updated matchesFilter so array handling is centralized and tests updated
accordingly.
convex/crm/validators.ts (1)

164-169: Validator value type may be too restrictive for some operators.

The value field is v.optional(v.string()), but operators like between (expects 2-element numeric array) and is_any_of (expects string array) require non-string values. This means these operators cannot be used with saved view filters as currently defined.

If this is intentional (e.g., stored as JSON strings and parsed at runtime), consider adding a comment documenting this behavior. Otherwise, consider using a union type:

♻️ Suggested fix
 export const savedViewFilterValidator = v.object({
 	fieldDefId: v.id("fieldDefs"),
 	operator: filterOperatorValidator,
-	value: v.optional(v.string()),
+	value: v.optional(
+		v.union(
+			v.string(),
+			v.number(),
+			v.array(v.union(v.string(), v.number()))
+		)
+	),
 	logicalOperator: v.optional(logicalOperatorValidator),
 });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@convex/crm/validators.ts` around lines 164 - 169, The
savedViewFilterValidator's value is currently v.optional(v.string()) which is
too restrictive for operators like "between" (expects a two-element numeric
array) and "is_any_of" (expects a string array); update savedViewFilterValidator
(the value field) to accept a union of the expected types (e.g., optional
string, optional array of strings, optional array of numbers, and optional
single number where applicable) or a more permissive typed union that covers
"between" and "is_any_of" cases, and add a short comment in the
savedViewFilterValidator explaining the accepted shapes (including the
two-element numeric array requirement for "between") so callers know the
expected formats.
convex/crm/userSavedViews.ts (1)

238-339: Avoid type assertion; use returned value from validation.

requireSourceView returns the validated sourceView, but line 267 uses a type assertion on the input. Using the returned value is cleaner.

♻️ Proposed fix
-		await requireSourceView(ctx, {
+		const sourceView = await requireSourceView(ctx, {
 			objectDefId: args.objectDefId,
 			orgId,
 			sourceViewDefId: args.sourceViewDefId,
 			viewType: args.viewType,
 		});

 		const snapshot = await loadBaseViewState(
 			ctx,
-			args.sourceViewDefId as Id<"viewDefs">,
+			sourceView._id,
 			orgId
 		);

And update the insert call to use sourceView._id:

 		const userSavedViewId = await ctx.db.insert("userSavedViews", {
 			orgId,
 			objectDefId: args.objectDefId,
 			ownerAuthId: ctx.viewer.authId,
-			sourceViewDefId: args.sourceViewDefId,
+			sourceViewDefId: sourceView._id,
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@convex/crm/userSavedViews.ts` around lines 238 - 339, The handler in
createUserSavedView currently asserts args.sourceViewDefId instead of using the
validated return from requireSourceView; change it to capture the return value
from requireSourceView (e.g., const sourceView = await requireSourceView(ctx,
{...})) and pass sourceView._id into loadBaseViewState and into the
ctx.db.insert payload (replace uses of args.sourceViewDefId with
sourceView._id), ensuring all subsequent logic uses the validated sourceView
instead of the raw arg.
convex/crm/viewState.ts (1)

32-42: Silent JSON parse fallback may hide malformed data.

When JSON.parse fails, the function returns the raw string. This is acceptable for forward-compatibility, but malformed JSON in stored filters could silently produce incorrect filter behavior at runtime.

Consider adding observability (logging or metrics) when parsing fails to detect data corruption:

 function parseStoredFilterValue(value: string | undefined): unknown {
   if (value === undefined) {
     return undefined;
   }

   try {
     return JSON.parse(value);
   } catch {
+    // Consider logging this case for observability
     return value;
   }
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@convex/crm/viewState.ts` around lines 32 - 42, The parseStoredFilterValue
function currently swallows JSON.parse errors and returns the raw string, which
can hide malformed stored filter data; update the catch block in
parseStoredFilterValue to record observability (e.g., call an existing logger or
telemetry/metrics function) including the error message and the original value
(or a truncated/hashed version for privacy) so malformed JSON is detectable, and
then continue returning the fallback value as before; ensure you reference
parseStoredFilterValue in your changes and use the project's standard
logging/telemetry utilities rather than adding ad-hoc console calls.
convex/crm/viewQueries.ts (1)

260-296: Redundant sort operation.

The displayOrder is set to the index during iteration over fieldOrder, so the final sort (line 295) will always produce the same order as the iteration. Consider removing the sort.

♻️ Proposed fix to remove redundant sort
 	return args.effectiveView.fieldOrder
 		.flatMap((fieldId, index) => {
 			const fieldDef = args.fieldDefsById.get(fieldId.toString());
 			if (!fieldDef) {
 				return [];
 			}
 
 			const sourceViewField = viewFieldByFieldId.get(fieldId.toString());
 			return [
 				{
 					fieldDefId: fieldId,
 					name: fieldDef.name,
 					label: fieldDef.label,
 					fieldType: fieldDef.fieldType,
 					width: sourceViewField?.width,
 					isVisible: visibleFieldIds.has(fieldId.toString()),
 					displayOrder: index,
 				},
 			];
-		})
-		.sort((left, right) => left.displayOrder - right.displayOrder);
+		});
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@convex/crm/viewQueries.ts` around lines 260 - 296, The final .sort in
buildEffectiveColumns is redundant because displayOrder is assigned from the
iteration index of effectiveView.fieldOrder, so the array already reflects the
correct order; remove the .sort((left, right) => left.displayOrder -
right.displayOrder) and return the flatMap result directly (keeping the existing
logic that builds ColumnDef items using fieldOrder, displayOrder,
viewFieldByFieldId, visibleFieldIds and fieldDefsById).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Outside diff comments:
In `@convex/crm/recordQueries.ts`:
- Around line 265-285: applyFilters currently uses filters.every(...) which
enforces AND for all filters and ignores RecordFilter.logicalOperator; update
applyFilters to evaluate each filter using matchesFilter but combine their
boolean results according to each filter.logicalOperator (treat undefined as
"and" or use a default behavior matching calendarQuery.applyViewFilters), e.g.,
iterate filters for a record and accumulate a running result switching between
AND/OR when filter.logicalOperator is "and"/"or"; ensure you still fetch
fieldDef via fieldDefsById.get(filter.fieldDefId.toString()) and treat a missing
fieldDef as a false match for that individual filter (but do not short-circuit
incorrectly when combining with OR).

---

Nitpick comments:
In `@convex/crm/calendarQuery.ts`:
- Around line 99-158: matchesViewFilter duplicates most of the logic in
matchesFilter (recordQueries.ts) and only differs in special-casing arrays for
"equals"/"is"/"is_not", which risks drift; refactor by either extracting the
shared predicate logic into a common helper (e.g., create a new
normalizeAndMatch/compareValue helper used by both matchesFilter and
matchesViewFilter) or by enhancing matchesFilter to accept an options flag to
handle array semantics and then replace the duplicated branches in
matchesViewFilter to call matchesFilter with that flag; update the
implementations of matchesViewFilter and any call sites to use the shared helper
or updated matchesFilter so array handling is centralized and tests updated
accordingly.

In `@convex/crm/types.ts`:
- Around line 69-74: SavedViewFilterDefinition.value currently typed as optional
string conflicts with RecordFilter.value (unknown) and operators that expect
arrays or numbers; update the SavedViewFilterDefinition interface so value is a
union covering the supported shapes (e.g., value?: string | string[] | number |
number[] | boolean | null) to match operator expectations (and adjust any v.*
validators accordingly), and ensure conversion code that maps
SavedViewFilterDefinition -> RecordFilter handles deserialization/typing for
operators like "between" and "is_any_of" based on
SavedViewFilterDefinition.operator.

In `@convex/crm/userSavedViews.ts`:
- Around line 238-339: The handler in createUserSavedView currently asserts
args.sourceViewDefId instead of using the validated return from
requireSourceView; change it to capture the return value from requireSourceView
(e.g., const sourceView = await requireSourceView(ctx, {...})) and pass
sourceView._id into loadBaseViewState and into the ctx.db.insert payload
(replace uses of args.sourceViewDefId with sourceView._id), ensuring all
subsequent logic uses the validated sourceView instead of the raw arg.

In `@convex/crm/validators.ts`:
- Around line 164-169: The savedViewFilterValidator's value is currently
v.optional(v.string()) which is too restrictive for operators like "between"
(expects a two-element numeric array) and "is_any_of" (expects a string array);
update savedViewFilterValidator (the value field) to accept a union of the
expected types (e.g., optional string, optional array of strings, optional array
of numbers, and optional single number where applicable) or a more permissive
typed union that covers "between" and "is_any_of" cases, and add a short comment
in the savedViewFilterValidator explaining the accepted shapes (including the
two-element numeric array requirement for "between") so callers know the
expected formats.

In `@convex/crm/viewQueries.ts`:
- Around line 260-296: The final .sort in buildEffectiveColumns is redundant
because displayOrder is assigned from the iteration index of
effectiveView.fieldOrder, so the array already reflects the correct order;
remove the .sort((left, right) => left.displayOrder - right.displayOrder) and
return the flatMap result directly (keeping the existing logic that builds
ColumnDef items using fieldOrder, displayOrder, viewFieldByFieldId,
visibleFieldIds and fieldDefsById).

In `@convex/crm/viewState.ts`:
- Around line 32-42: The parseStoredFilterValue function currently swallows
JSON.parse errors and returns the raw string, which can hide malformed stored
filter data; update the catch block in parseStoredFilterValue to record
observability (e.g., call an existing logger or telemetry/metrics function)
including the error message and the original value (or a truncated/hashed
version for privacy) so malformed JSON is detectable, and then continue
returning the fallback value as before; ensure you reference
parseStoredFilterValue in your changes and use the project's standard
logging/telemetry utilities rather than adding ad-hoc console calls.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 2f4fca17-3a61-4df7-843e-7a478a361ef1

📥 Commits

Reviewing files that changed from the base of the PR and between f6d46c9 and 0d7fff7.

📒 Files selected for processing (11)
  • convex/crm/__tests__/userSavedViews.test.ts
  • convex/crm/calendarQuery.ts
  • convex/crm/recordQueries.ts
  • convex/crm/types.ts
  • convex/crm/userSavedViews.ts
  • convex/crm/validators.ts
  • convex/crm/viewQueries.ts
  • convex/crm/viewState.ts
  • convex/schema.ts
  • convex/test/moduleMaps.ts
  • convex/test/registerAuditLogComponent.ts

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR adds support for personal (per-user) saved views in the CRM by introducing a userSavedViews data model and resolving an “effective view” that overlays a system view with a user’s saved configuration (columns/filters/defaults).

Changes:

  • Add crm/userSavedViews queries/mutations plus shared view-resolution utilities (resolveEffectiveViewState).
  • Update view record/schema queries (table/kanban/calendar) to accept userSavedViewId and/or apply the user’s default saved view.
  • Update schema/validators/types for structured saved-view filters and add coverage tests.

Reviewed changes

Copilot reviewed 11 out of 11 changed files in this pull request and generated 7 comments.

Show a summary per file
File Description
convex/test/registerAuditLogComponent.ts Reworks component module loading for audit log tests; registers aggregate components used by audit log.
convex/test/moduleMaps.ts Adds module-map entries for new CRM modules used in tests.
convex/schema.ts Updates userSavedViews schema (structured filters) and adds an index for default lookup.
convex/crm/viewState.ts New shared logic for resolving system vs saved vs effective view state.
convex/crm/viewQueries.ts Applies effective view state to table/kanban queries and expands getViewSchema output.
convex/crm/validators.ts Adds savedViewFilterValidator.
convex/crm/userSavedViews.ts New CRUD + default-selection endpoints for personal saved views with validation + audit logging.
convex/crm/types.ts Introduces SavedViewFilterDefinition + EffectiveViewDefinition; expands filter operator unions.
convex/crm/recordQueries.ts Extends filter operator matching behavior (e.g., between/is_not/after/before).
convex/crm/calendarQuery.ts Switches calendar filtering to operate on resolved effective view filters.
convex/crm/tests/userSavedViews.test.ts Adds test coverage for default scoping, overlay application, and explicit saved-view selection across layouts.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread convex/crm/viewQueries.ts Outdated
Comment thread convex/crm/viewQueries.ts Outdated
Comment thread convex/crm/viewState.ts
Comment thread convex/crm/calendarQuery.ts Outdated
Comment thread convex/crm/userSavedViews.ts
Comment thread convex/schema.ts Outdated
Comment thread convex/crm/viewState.ts
@Connorbelez Connorbelez changed the base branch from eng-271 to graphite-base/383 April 3, 2026 15:40
@Connorbelez Connorbelez changed the base branch from graphite-base/383 to eng-273 April 3, 2026 15:40
@Connorbelez Connorbelez force-pushed the eng-273 branch 2 times, most recently from a870ae5 to 59a11dc Compare April 9, 2026 18:16
@Connorbelez Connorbelez changed the base branch from eng-273 to graphite-base/383 April 9, 2026 18:17
@graphite-app graphite-app bot changed the base branch from graphite-base/383 to main April 9, 2026 18:18
@Connorbelez Connorbelez merged commit 8a6c61e into main Apr 9, 2026
0 of 2 checks passed
Copy link
Copy Markdown
Owner Author

Merge activity

@coderabbitai coderabbitai bot mentioned this pull request Apr 10, 2026
Connorbelez added a commit that referenced this pull request Apr 20, 2026
feat(crm): add personal saved views

responding to feedback

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->

## Summary by CodeRabbit

* **New Features**
  * Users can now create, manage, and set default personal saved views for CRM objects.
  * Saved views support custom filters, field visibility, ordering, grouping, and aggregates with proper permission isolation.
  * Expanded filter operators including date comparisons (before, after), range filtering (between), and equality operators.
  * Calendar and kanban views now integrate with personal saved views.

* **Tests**
  * Added comprehensive test coverage for user saved views creation, defaults, and permission scoping.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants