Corteksa

Slug and schema tech debt

Slug and schema tech debt

This document captures every architectural issue, pattern violation, and design smell discovered during the slug-first refactor work (Phase 1 + Phase 2). These items are NOT fixed by the current PR and need separate, focused PRs.

Use this as a backlog when planning the next slug / schema cleanup sprint.

Index by severity:

  • 🔴 HIGH — Real bugs in production code that crash or corrupt data
  • 🟡 MEDIUM — Architectural smells that work today but block growth
  • 🟢 LOW — Cosmetic inconsistencies, would be nice to unify

🔴 HIGH — Real bugs that will surface in production

1. Pricing.workspace.service.ts:304 blocks the entity from joining the slug-first contract

File: src/api/v1/user/workspace/workspace.service.ts:304

if (pricingPlan.slug === 'free') { ... }

This is the free-tier gate. If we ever change pricing.slug to opaque hex, the gate silently flips every workspace to "paid" and breaks billing logic.

Fix: Switch the comparison to pricingPlan.role_name === 'Free' (the existing UNIQUE column) OR add an explicit tier enum column (FREE | PROFESSIONAL | ENTERPRISE) and use that.

Scope: ~10 LOC + entity migration if tier column is added. Must ship before refactoring Pricing.generateSlug().


2. 5 entities crash on duplicate name values

The following entities still use slugify(this.name) without a uniqueness suffix. Two rows with the same name collide on the UNIQUE index and INSERT throws 23505:

EntityFilePattern
Roleadmin/role/role.entity.tsslugify(name)
Permissionadmin/permission/permission.entity.tsslugify(name)
Pricingadmin/pricing/pricing.entity.tsslugify(roleName) + has the bug below
Objectobject/shared/entities/object.entity.tsslugify(name)
ObjectTemplateobject/object-templates/entities/object-template.entity.tsslugify(name)

Fix: migrate to generateOpaqueSlug() from common/utils/slug.util.ts. Each entity needs its own prep work (audit hardcoded slug references first — see Phase 4 below).


3. Pricing.generateSlug() has a copy-paste bug

File: src/api/v1/admin/pricing/pricing.entity.ts:71-78

generateSlug() {
  if (this.name) {                   // ← guards on `this.name`
    this.slug = slugify(this.roleName, {  // ← but slugifies `this.roleName`
      ...
    });
  }
}

Two inconsistent fields. If name is set but roleName is empty, slug becomes empty string (which then crashes the UNIQUE index on any second insert). If roleName is set but name is empty, slug is never generated.

Fix: decide which field is the source of truth (probably roleName based on the column name), guard on that field. Or refactor to generateOpaqueSlug() and side-step the issue entirely.


🟡 MEDIUM — Architectural smells that work today but block growth

4. ProviderConfig.slug and Session.slug do TWO jobs (public ID + lookup key)

Files:

OAuth services set slug: 'instagram-${igAccountId}' because the slug doubles as a natural-key for the OAuth-reconnect upsert. The slug is sometimes opaque (entity-generated), sometimes a composite (OAuth-generated) — callers can't trust its shape.

Fix (Pattern B): add a separate external_ref UNIQUE column. OAuth services upsert by external_ref. The entity hook becomes the SOLE source of slug.

@Column({ unique: true })
slug: string;  // always opaque, generated by hook

@Column({ name: 'external_ref', nullable: true, unique: true })
external_ref: string | null;  // = 'instagram-${igAccountId}', only set by OAuth

Scope: ~60 LOC + 1 migration. High value: the next person reading the code finally sees "slug = public ID, period."


5. Slug-as-natural-key on Call, Chat, Message

Files:

Same shape as #4 but with composite-from-multiple-fields:

this.slug = `chat-${session?.slug || session_id}-${provider_id}`

The slug encodes (sessionId, providerId) — the webhook router decodes it to look up the row. Changing to opaque hex without refactoring the webhook router will break message routing.

Fix: harder than #4 because the webhook router decodes the slug. Need a composite UNIQUE index on (session_id, provider_id) (or (chat_id, provider_id) for messages) and refactor the router to query by those columns instead.

Scope: ~150 LOC + 1 migration + careful webhook testing. Do NOT touch without a focused PR.


6. The 11 entities still using their own slug pattern → NOW 3 (8 ✅ DONE in Phase 4b-partial)

These had hooks but each used a different shape — prefix-name-timestamp, composite, slugify(name). The 8 safe ones were refactored in this PR. The 3 remaining (object-side) are intentionally deferred because their slugs are part of the public FE contract.

EntityStatus
email/email-account.entity.ts✅ refactored to generateOpaqueSlug()
email/email-attachment.entity.ts✅ refactored
email/email-label.entity.ts✅ refactored
email/email-provider-config.entity.ts✅ refactored
email/email-thread.entity.ts✅ refactored
email/email-message.entity.ts✅ refactored
messaging/session.entity.ts✅ refactored (also dropped @BeforeUpdate — slugs never regenerate on update)
messaging/template.entity.ts✅ refactored
object/shared/entities/relation.entity.ts⏳ Phase 5 — FE contract
object/shared/entities/field.entity.ts⏳ Phase 5 — FE contract
object/views/entities/custom-view.entity.ts⏳ Phase 5 — FE contract

Remaining scope: 3 object-side entities, deferred to never-do (#9, #10).


🟡 MEDIUM — Phase 4 blockers (high-risk entities)

These entities have hardcoded slug literals in production code, migrations, or FE contracts. Refactoring them requires preparatory work first.

7. Role — hardcoded slugs in seeders

File: src/database/seeders/role.seeder.ts

{ slug: 'super-admin' }, { slug: 'object-manager' }, { slug: 'user-manager' }, { slug: 'read-only' }

Fix: before refactoring Role.generateSlug(), update seeders to look up by name (UNIQUE column on Role) instead of generating known slugs.


8. Permission — hardcoded slugs in seeders

File: src/database/seeders/document-template-permissions.seeder.ts

{ slug: 'documenttemplate-read' }, { slug: 'documenttemplate-create' }, ...

Permission also has route_name (the actual canonical key used by @RouteName() decorator throughout the codebase). The slug is arguably redundant.

Fix: drop slug from Permission entirely, OR refactor to opaque + update seeders to look up by route_name.


9. Object (CRM object meta-entity) — referenced by ~26 migrations + the FE

The object.entity.ts slug like contact, deal, task is referenced in 26 migrations that hardcode WHERE slug = 'task' for schema operations. And the FE addresses every records endpoint via /object/data/:objectSlug — those URLs would need a migration path.

Files affected:

Fix: Don't refactor this. The object slug is intentional and stable (it IS the public identifier of the CRM object). Different from the entities that need opaque slugs.


10. Field — slug is THE canonical wire key

File: src/api/v1/object/shared/entities/field.entity.ts

Field slugs like name-mokc6sdo, status-mokc6sdo are the canonical wire key for the FE. Rounds 1-3 of FRONTEND_CHANGES_*.md are entirely about migrating the FE to use field.slug everywhere.

The current pattern slugify(name) + suffix produces slugs like name-mokc6sdo which the FE explicitly relies on.

Fix: Don't refactor this either. The current pattern IS the public contract.


11. Workflow-template — hardcoded slugs in seeder

File: src/api/v1/workflow/services/workflow-template-seed.service.ts

{ slug: 'auto-reply-whatsapp' }, { slug: 'vip-deal-alert' }, { slug: 'status-update-notification' }, ...

7 hardcoded template slugs. Any code that finds a template by slug: 'auto-reply-whatsapp' would break if generators changed.

Fix: if you want opaque slugs, refactor seeder to look up by name first. Or accept that workflow templates are like Role/Permission — internal config where readable slugs help.


🟢 LOW — Cosmetic / nice-to-have

12. Pre-existing broken tests on master

Found during this work — not caused by our changes, broken on master:

TestFailure mode
data-controller-admin-filter.spec.tsTS error: permissionNames missing from AdminUser mock
data-mutation.serial-number-rollback.spec.tsDI: AccessFilterBuilder not registered in test module
data-mutation.recalc-warnings.spec.tsSame DI issue
search.query.spec.tsCompilation errors
list.query.spec.tsCompilation errors
record-validator.service.spec.tsSame DI issue
data-response.dto-clamping.spec.tsCompilation errors
data-response.shape-a.spec.tsCompilation errors
chunked-upload.dto.spec.ts + ~10 other export-import testsWrong relative import paths (test files reference ../export-import/... instead of ../)
dashboard-repository.service.spec.tsHardcoded slug: 'test-dashboard-abc123' — would also need updating in Phase 4
permission.service.spec.tsMock object missing generateSlug method

Fix: these are 11+ test files that don't compile. Likely the test infrastructure changed at some point and these were never updated. Worth a focused "fix broken tests" PR before adding more tests.


13. generateEmailSlug is now technically dead code

File: src/common/utils/slug.util.ts

After Phase 1, Admin and User stopped using generateEmailSlug. The function is still imported by Pricing, Role, Permission (Phase 4 entities). Once Phase 4 ships, it becomes dead code and should be deleted.

Fix: delete in the Phase 4 PR.


14. The random_id column on records may be redundant after slug becomes opaque

Files:

Before Phase 1, slug was name-hex and random_id was the pure hex. They served different purposes.

After Phase 1, slug IS pure hex. random_id is now essentially a duplicate column with a shorter random — both are unique, both are searched in search.query.ts:77-79.

Fix: drop random_id column from records tables (massive migration — every per-object table) OR keep it for backwards compat and accept the redundancy.

Recommendation: keep for now. Migration risk too high vs the benefit.


15. Drift bug fixed but worth noting: import-row-processor was inconsistent

File: src/api/v1/object/export-import/services/import/import-row-processor.service.ts:166

Fixed in Phase 2. Was using slugify(name) + timestamp + random for imported records while normal creates used opaque hex. Drift like this is how slug shapes diverge over time.

Lesson: any place that sets a slug should call generateOpaqueSlug() from the shared util. Don't reinvent.


Phased plan summary

PhaseStatusScope
Phase 1✅ DONERefactor 10 safe entities + records helper. Add generateOpaqueSlug().
Phase 2✅ DONEAdd @BeforeInsert hook to 6 entities that were missing it. Fix import-row drift.
Phase 4b-partial✅ DONE6 email entities + messaging Session + messaging Template refactored to generateOpaqueSlug(). Item #6 above (8 of 11 done).
Phase 3📋 PLANNEDPattern B (external_ref column) on ProviderConfig + Session. Item #4 above.
Phase 4a📋 PLANNEDPricing (after fixing workspace.service.ts) + Role + Permission + Workflow-template (after updating seeders). Items #1, #2, #3, #7, #8, #11 above.
Phase 4b-remaining📋 PLANNEDMessaging Call / Chat / Message (composite-slug-as-natural-key — needs careful webhook router refactor). Item #5 above.
Phase 5❌ DO NOT DOObject, Field, Relation, CustomView, ObjectTemplate. These slugs ARE the public contract — refactoring breaks the FE and 26 migrations. Items #9, #10 above.
Side cleanup📋 PLANNEDFix 11+ pre-existing broken tests (item #12). Delete generateEmailSlug after Phase 4a (item #13).

When to revisit this document

  • After every Phase PR — strike through completed items and add anything newly discovered
  • Before starting Phase 3+ — re-audit hardcoded slug references in case new code was added since this audit
  • When onboarding a new dev — this is the "why does slug look like this?" answer

On this page