We need a comprehensive, up-to-date checklist of all code that still needs refactoring to use the Active Record pattern. An existing conversion plan lives at apps/web/src/models/active-record-conversion.md (created 2026-03-02). Our job is to verify whether that plan is still current, identify any gaps (new tables, new files), and produce a single authoritative checklist suitable for handing off to Kindling.
- Active Record library:
apps/web/src/lib/active-record/(baseRecord,Collection,Relationclasses) - Secure layer:
apps/web/src/models/base/secure-record.ts(addsSecureModel+SecureCollectionwith auto-scoping, permissions, FK ownership validation) - Existing conversion plan:
apps/web/src/models/active-record-conversion.md-- tiered list of tables with scope columns, FK references, permissions, and rawdb()file locations - CHANGELOG pending items (
apps/web/src/models/CHANGELOG.md):- No models use FK ownership validation (
references) yet SecureCollectionshould enforcemustBeScoped- Five shared services still use raw Kysely (destinations, trucks, tow-reasons, hold-reasons, custom-fields)
- No models use FK ownership validation (
| Model | Table | File |
|---|---|---|
DestinationRecord |
destination |
models/destination.ts |
TruckRecord |
truck |
models/truck.ts |
TowReasonRecord |
tow_reason |
models/tow-reason.ts |
HoldReasonRecord |
hold_reason |
models/hold-reason.ts |
CustomFieldDefinitionRecord |
custom_field_definition |
models/custom-field-definition.ts |
ServiceRateRecord |
service_rate |
models/service-rate.ts |
PortalUserCompanyRecord |
portal_user_company |
models/portal-user-company.ts |
The following tables/areas exist in the codebase but are not listed in active-record-conversion.md:
| Table | Scope Column | Raw db() Files |
Notes |
|---|---|---|---|
auction |
companyId |
auctions/actions.ts, auctions/queries.ts, auctions/import-actions.ts |
Heavy CRUD + complex queries |
auction_vehicle |
(scoped via auctionId) | auctions/actions.ts, auctions/queries.ts, auctions/import-actions.ts |
Junction table, indirect scoping |
buyer |
companyId |
auctions/buyer-actions.ts, auctions/queries.ts, auctions/import-actions.ts |
Buyer management |
permission_group |
companyId |
settings/permission-groups/actions.ts, shared/services/permission-groups.ts, settings/users/actions.ts |
RBAC groups |
permission_group_permission |
(scoped via permissionGroupId) | Same as above | Junction table |
| Service | Raw Tables Queried |
|---|---|
shared/services/permission-groups.ts |
permission_group, permission_group_permission |
shared/services/users.ts |
auth_user (may remain raw -- auth infra) |
All 15 shared services still use raw db() -- zero have been converted to use models:
| Service File | Tables Queried | Has Existing Model? |
|---|---|---|
accounts.ts |
account |
No |
calls.ts |
call |
No |
trucks.ts |
call (not truck!) |
Yes (TruckRecord) |
tow-reasons.ts |
tow_reason |
Yes (TowReasonRecord) |
hold-reasons.ts |
hold_reason |
Yes (HoldReasonRecord) |
custom-fields.ts |
custom_field_definition |
Yes (CustomFieldDefinitionRecord) |
companies.ts |
company |
No |
invitations.ts |
invitation |
No |
display-id.ts |
tenant_counter |
No (not candidate -- atomic counter) |
impound-search.ts |
impound |
No |
vehicle.ts |
vehicle |
No |
permission-groups.ts |
permission_group, permission_group_permission |
No |
users.ts |
auth_user |
No (auth infra -- not candidate) |
clickins.ts |
(TBD) | No |
integration/* |
conversation_log, various |
No |
The existing plan's tier structure and table inventory are mostly accurate but missing:
- 3 new tables:
auction,auction_vehicle,buyer - 2 new tables:
permission_group,permission_group_permission - Auctions page:
actions.ts,queries.ts,import-actions.ts,buyer-actions.ts-- the single largest area of rawdb()usage not in the plan - The shared services flagged in the CHANGELOG as "pending" are still all unconverted
| Item | Status |
|---|---|
FK ownership validation (references) |
Still not used by any model |
SecureCollection enforce mustBeScoped |
Still pending |
| Shared services conversion (5 files) | Still raw db() |
-
destination--DestinationRecord -
truck--TruckRecord -
tow_reason--TowReasonRecord -
hold_reason--HoldReasonRecord(WARNING: norequiredScope) -
custom_field_definition--CustomFieldDefinitionRecord -
service_rate--ServiceRateRecord -
portal_user_company--PortalUserCompanyRecord
-
account-- Files:accounts/actions.ts,accounts/queries.ts,services/accounts.ts, bulk-import -
call-- Files:calls/actions.ts,calls/queries.ts,calls/routes.ts,services/calls.ts, bulk-import -
impound-- Files:impounds/actions.ts,new-impound/routes,impound-search.ts, yard-pass -
vehicle-- Files:vehicle-db.ts, bulk-import, calls, impounds (NOTE: usescompany_idsnake_case)
-
charge-- Files:chargeActions.ts,impoundChargeActions.ts,calls/actions.ts(nocompanyId-- indirect scope) -
invoice-- Files:chargeActions.ts,impoundChargeActions.ts(nocompanyId-- indirect scope) -
account_rate-- Files:accountRateActions.ts(nocompanyId-- indirect scope)
-
call_note-- Files:calls/actions.ts -
call_status_history-- Files:calls/actions.ts,services/calls.ts -
impound_note-- Files:impounds/actions.ts -
impound_activity-- Files:impound-activity.ts -
impound_release-- Files:impounds/actions.ts -
vehicle_image-- Files:vehicle-images.ts -
vin_scan-- Files:vin-scans.ts -
inspection_record-- Files:inspection/actions.ts -
address-- Files:spatial.ts, calls, destinations (NOTE: usescompany_idsnake_case, nullable)
-
company-- Files:company/actions.ts,services/companies.ts -
invitation-- Files:invitations.ts, auth routes -
driver_route_sheet-- Files:route-sheet/actions.ts -
driver_route_sheet_item-- Files:route-sheet/actions.ts(nocompanyId-- indirect scope) -
custom_field_archive-- Files:custom-fields/actions.ts -
yard_pass_log-- Files:yard-pass-actions.ts -
clickins_inspection-- Files:clickins-inspections.ts
-
report_category-- Files:reports/actions.ts -
report_definition-- Files:reports/actions.ts,reports/queries.ts -
report_run-- Files:reports/actions.ts(nocompanyId-- indirect scope) -
conversation_log-- Files:conversation-log.ts,ivr/account-lookup.ts -
bulk_import_batch-- Files:bulk-import/actions.ts
-
auction-- Files:auctions/actions.ts,auctions/queries.ts,auctions/import-actions.ts -
auction_vehicle-- Files: same as auction (nocompanyId-- indirect scope via auctionId) -
buyer-- Files:auctions/buyer-actions.ts,auctions/queries.ts,auctions/import-actions.ts
-
permission_group-- Files:settings/permission-groups/actions.ts,shared/services/permission-groups.ts -
permission_group_permission-- Files: same as above (nocompanyId-- indirect scope)
auth_user-- Managed by Better Auth adapterauth_account-- Auth provider link tableauth_session-- Session storeauth_verification-- Token verificationportal_user-- Portal authportal_account-- Portal auth provider linksportal_session-- Portal session storeportal_verification-- Portal token verificationportal_user_company_account-- Junction table managed byPortalUserCompanyRecord.delete()call_status-- Global enum-like lookup, not tenant-scopedcall_note_read_status-- User-specific read markerstenant_counter-- Atomic counter, special transaction semanticsexpo_push_token-- Device token, user-scoped not company-scoped
These settings page action files have raw Kysely queries for tables that already have Active Record models:
-
settings/subpages/trucks/actions.ts-- rawtruckqueries (model exists:TruckRecord) -
settings/subpages/trucks/queries.ts-- rawtruck,auth_userqueries -
settings/subpages/services/actions.ts-- rawservice_rate,account,charge,companyqueries (model exists:ServiceRateRecord) -
settings/subpages/tow-reasons/actions.ts-- rawtow_reason,accountqueries (model exists:TowReasonRecord)
accounts/actions.ts-- importsDestinationsmodel but still uses rawdb()foraccounttable- Some Zod
superRefine()validators use rawdb()for uniqueness checks even when a model exists for the table
-
portal/queries.ts-- raw queries oncompany,call,impound,vehicle(portal has separate auth flow, may need unscoped or portal-scoped access)
admin/pages/subpages/companies/actions.ts-- rawcompany,addressqueries- Admin pages make cross-company queries by design. These should remain raw or use
is_internal_admingating, NOT SecureModel.
- Add FK ownership validation (
references) to existing models - Enforce
mustBeScopedinSecureCollectionfactory - Convert 5 shared services to use existing models:
trucks.ts,tow-reasons.ts,hold-reasons.ts,custom-fields.ts,destinations.ts - Convert settings page actions to use existing models (trucks, services, tow-reasons)
The original conversion plan at apps/web/src/models/active-record-conversion.md is stale -- it was written 2026-03-02, before auctions (merged 2026-03-04/2026-03-11) and permission groups (merged 2026-03-12) existed. It needs updating to reflect the current state of the codebase.
-
Add Tier 6 -- Auctions & Marketplace:
auction(companyId-scoped),auction_vehicle(indirect scope via auctionId),buyer(companyId-scoped). Note the heavy rawdb()surface area acrossauctions/actions.ts,auctions/queries.ts,auctions/import-actions.ts,auctions/buyer-actions.ts. -
Add Tier 7 -- RBAC:
permission_group(companyId-scoped),permission_group_permission(indirect scope via permissionGroupId). Raw usage insettings/permission-groups/actions.tsandshared/services/permission-groups.ts. -
Add "Existing Model Consumer Migration" section: Document the settings page actions and shared services that still use raw
db()for tables that already have models. This is a distinct work stream from creating new models -- it's about migrating call sites. -
Add portal queries note:
portal/queries.tsuses cross-company queries with a separate auth flow. Needs special handling -- possibly unscoped access or a portal-scoped actor pattern. -
Update security concerns: Add
permission_groupscoping considerations (company-scoped, butpermission_group_permissionis indirect). -
Update recommended conversion order: Slot auctions after existing Tier 4 (settings), and RBAC alongside it. Both are self-contained feature areas.
Start with consumer migrations (call sites that already have models) before creating new models. This develops the migration playbook with minimal risk -- if something goes wrong, the model layer is already proven.
Models exist, tests exist. We are only swapping raw db() call sites to use them. Each chunk touches a different feature area, so they can run in parallel with no merge conflicts.
Chunk A: Tow Reasons + Hold Reasons
shared/services/tow-reasons.ts-> useTowReasons/TowReasonRecordshared/services/hold-reasons.ts-> useHoldReasons/HoldReasonRecordsettings/subpages/tow-reasons/actions.ts-> useTowReasons- Scope: ~3 files, read-heavy queries, simple equality filters
- Learning target: how do
superRefine()validators interact with collection queries? Do we need.getQuery()escape hatches for complex filters?
Chunk B: Trucks
shared/services/trucks.ts-> useTrucks/TruckRecordsettings/subpages/trucks/actions.ts-> useTruckssettings/subpages/trucks/queries.ts-> useTrucks(has joins toauth_user-- may need.getQuery())- Scope: ~3 files
- Learning target: how do we handle queries that join across tables (trucks + drivers)? Does
.getQuery()+ Kysely join work cleanly, or do we need a pattern for cross-model joins?
Chunk C: Custom Fields
shared/services/custom-fields.ts-> useCustomFieldDefinitions/CustomFieldDefinitionRecord- Scope: ~1 file, straightforward read queries
- Learning target: baseline migration -- simplest possible case. Good "hello world" for the pattern.
Expected learnings from Wave 1:
- Pattern for migrating
superRefine()uniqueness validators - Pattern for queries that need joins (use
.getQuery()vs raw) - Pattern for services that return plain data vs record instances
- Whether
SecureCollectionneeds any API additions (e.g..count(),.exists()) - How actor propagation works through shared services (do callers already have actors?)
Apply Wave 1 learnings. Still consumer-only, but slightly more complex.
Chunk D: Service Rates
settings/subpages/services/actions.ts-> useServiceRates/ServiceRateRecord- This file also queries
account,charge,company-- only theservice_rateparts can be converted now. The rest waits for those models. - Learning target: partial migration in a file that touches multiple tables
Chunk E: Destinations
- Any remaining
destinationraw queries in settings or account pages - Learning target: FK ownership -- destinations are referenced by accounts. Good test case for
referencesconfig if we enable it.
Create models for the Tier 1 tables. These have the highest IDOR risk and most raw query surface area. Sequential because call and impound depend on account.
accountmodel -- foundation for everything elsecallmodel -- largest raw query surface, references account/truck/destination/tow_reasonimpoundmodel -- second largest, similar shape to callvehiclemodel -- referenced by calls and impounds (note:company_idsnake_case)
After each model is created, migrate its consumer files (actions, queries, services) before moving to the next model. This keeps the blast radius small.
charge, invoice, account_rate -- these have indirect scoping (no companyId, scoped via parent call/impound/account). Needs parent-chain ownership validation pattern.
These are smaller tables that hang off calls or impounds. Can parallelize by parent:
Call children (parallel): call_note, call_status_history, vin_scan
Impound children (parallel): impound_note, impound_activity, impound_release, yard_pass_log
Shared children: vehicle_image, inspection_record, address, clickins_inspection
Auctions: auction, auction_vehicle, buyer -- self-contained feature area
RBAC: permission_group, permission_group_permission -- self-contained
Reporting: report_category, report_definition, report_run
invitation,driver_route_sheet+ items,custom_field_archive,conversation_log,bulk_import_batch- Portal queries (special handling TBD)
- Infrastructure: enable FK ownership validation (
references), enforcemustBeScoped
| Wave | What | Parallelism | Risk | Depends On |
|---|---|---|---|---|
| 1 | Consumer migrations (tow-reasons, trucks, custom-fields) | 3 parallel | Lowest | Nothing |
| 2 | Consumer migrations (service-rates, destinations) | 2 parallel | Low | Wave 1 learnings |
| 3 | New models: account, call, impound, vehicle | Sequential | Medium | Wave 1-2 learnings |
| 4 | New models: charge, invoice, account_rate | Sequential | Medium | Wave 3 (parent models) |
| 5 | Supporting entity models | Parallel by parent | Low-Med | Wave 3 (parent models) |
| 6 | Auctions, RBAC, reporting | Parallel by area | Medium | Wave 3 patterns |
| 7 | Remaining + infrastructure | Mixed | Low | All above |
Published as a public gist for PR visibility: https://gist.github.com/justinvdm/93104d6354a70eb90c550454347b94df
Each Wave 1 PR (#1001, #1002, #1003) has a comment linking here. Keep the gist updated when the plan changes.
Objective: Migrate the tow-reasons and hold-reasons shared services and settings page actions from raw db() queries to use the existing TowReasons/TowReasonRecord and HoldReasons/HoldReasonRecord Active Record models.
Problem: These services and actions bypass the model layer entirely, duplicating scoping logic and missing permission enforcement. The models already exist and are tested -- the call sites just haven't adopted them.
What to Do:
- Replace raw Kysely queries in
shared/services/tow-reasons.tswithTowReasonscollection methods. Functions should accept an actor or useTowReasons.for()where called from request context. - Replace raw Kysely queries in
shared/services/hold-reasons.tswithHoldReasonscollection methods. - Replace raw Kysely queries in the tow-reasons settings page actions with model usage, following the same
Model.for({ companyId })pattern used byDestinations.for()throughout the codebase. - Where a raw query does something the collection API doesn't support (aggregates, complex filters), use
.getQuery()to access the underlying Kysely builder rather than bypassing the model. - Record learnings about any patterns that emerge -- especially around Zod
superRefine()validators, aggregate queries, and cases where.getQuery()was needed.
What NOT to Do:
- Do not modify the model definitions (
models/tow-reason.ts,models/hold-reason.ts) or the baseSecureModel/SecureCollectionclasses. - Do not change function signatures or return types of the service functions -- consumers should not need updating.
- Do not convert the admin page call sites (those intentionally bypass models for cross-company access).
- Do not add new features or refactor surrounding code. This is a 1:1 migration of query implementation.
Invariants & Constraints:
- All existing E2E tests must continue to pass.
- The
HoldReasonRecordhas norequiredScope-- it's scoped indirectly viatowReasonId. Respect this; do not add scoping it doesn't have. accountIdsfiltering happens in-memory (JSON column parsed in JS). Preserve this pattern.
Objective: Migrate the trucks shared service, settings page actions, and settings page queries from raw db() queries to use the existing Trucks/TruckRecord Active Record model.
Problem: The trucks shared service and settings page actions use raw db() Kysely queries, bypassing the Trucks/TruckRecord Active Record model that already exists with company scoping and permission enforcement. This duplicates scoping logic and misses the security guarantees the model layer provides.
What to Do:
- Replace raw Kysely queries in
shared/services/trucks.tswithTruckscollection methods. - Replace raw Kysely queries in the trucks settings page actions and queries with model usage.
- For queries that join trucks with other tables (e.g. drivers/auth_user), use
.getQuery()to get the scoped Kysely builder and then add joins on top. This tests an important pattern for future migrations. - Record learnings about cross-table joins when using the Active Record layer.
What NOT to Do:
- Do not modify the model definition (
models/truck.ts) or the base classes. - Do not change function signatures or return types.
- Do not convert admin page call sites.
Invariants & Constraints:
- All existing E2E tests must continue to pass.
- The trucks queries file joins to
auth_userfor driver data. The join pattern chosen here becomes precedent for Wave 3+ migrations.
Objective: Migrate the custom-fields shared service from raw db() queries to use the existing CustomFieldDefinitions/CustomFieldDefinitionRecord Active Record model.
Problem: The custom-fields shared service uses raw db() Kysely queries, bypassing the CustomFieldDefinitions/CustomFieldDefinitionRecord Active Record model that already exists with company scoping and permission enforcement. This duplicates scoping logic and misses the security guarantees the model layer provides.
What to Do:
- Replace raw Kysely queries in
shared/services/custom-fields.tswithCustomFieldDefinitionscollection methods. - Where the service has complex filtering (LIKE patterns, dynamic ordering, aggregate queries), use
.getQuery()for the scoped builder. - Record learnings about complex query patterns with the Active Record layer.
What NOT to Do:
- Do not modify the model definition or base classes.
- Do not change function signatures or return types.
- Do not convert the custom-fields settings page actions file -- it has complex multi-table transactions (archival, column migration) that are out of scope for Wave 1.
- Do not convert admin page call sites.
Invariants & Constraints:
- All existing E2E tests must continue to pass.
- The actions file (
settings/subpages/custom-fields/actions.ts) is explicitly out of scope -- it touchescustom_field_archive,call, andimpoundtables in transactions. That's a Wave 3+ problem.