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:
| Entity | File | Pattern |
|---|---|---|
| Role | admin/role/role.entity.ts | slugify(name) |
| Permission | admin/permission/permission.entity.ts | slugify(name) |
| Pricing | admin/pricing/pricing.entity.ts | slugify(roleName) + has the bug below |
| Object | object/shared/entities/object.entity.ts | slugify(name) |
| ObjectTemplate | object/object-templates/entities/object-template.entity.ts | slugify(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:
messaging/core/entities/provider-config.entity.tsmessaging/core/entities/session.entity.ts- Set by
instagram-oauth.service.ts:308,facebook-oauth.service.ts:283,tiktok-oauth.service.ts:285
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 OAuthScope: ~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:
messaging/core/entities/call.entity.tsmessaging/core/entities/chat.entity.tsmessaging/core/entities/message.entity.ts
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.
| Entity | Status |
|---|---|
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:
- 26 migration files in
src/database/migrations/andsrc/database/migrations/tenant/ src/api/v1/object/tasks/services/task-agenda.service.ts:506—findOne({ where: { slug: 'task' } })- All FE rounds 1-3 of slug migration
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:
| Test | Failure mode |
|---|---|
data-controller-admin-filter.spec.ts | TS error: permissionNames missing from AdminUser mock |
data-mutation.serial-number-rollback.spec.ts | DI: AccessFilterBuilder not registered in test module |
data-mutation.recalc-warnings.spec.ts | Same DI issue |
search.query.spec.ts | Compilation errors |
list.query.spec.ts | Compilation errors |
record-validator.service.spec.ts | Same DI issue |
data-response.dto-clamping.spec.ts | Compilation errors |
data-response.shape-a.spec.ts | Compilation errors |
chunked-upload.dto.spec.ts + ~10 other export-import tests | Wrong relative import paths (test files reference ../export-import/... instead of ../) |
dashboard-repository.service.spec.ts | Hardcoded slug: 'test-dashboard-abc123' — would also need updating in Phase 4 |
permission.service.spec.ts | Mock 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:
- Records dynamic tables — both
slugandrandom_idexist src/api/v1/object/records/mutations/helpers/record-id.util.ts
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
| Phase | Status | Scope |
|---|---|---|
| Phase 1 | ✅ DONE | Refactor 10 safe entities + records helper. Add generateOpaqueSlug(). |
| Phase 2 | ✅ DONE | Add @BeforeInsert hook to 6 entities that were missing it. Fix import-row drift. |
| Phase 4b-partial | ✅ DONE | 6 email entities + messaging Session + messaging Template refactored to generateOpaqueSlug(). Item #6 above (8 of 11 done). |
| Phase 3 | 📋 PLANNED | Pattern B (external_ref column) on ProviderConfig + Session. Item #4 above. |
| Phase 4a | 📋 PLANNED | Pricing (after fixing workspace.service.ts) + Role + Permission + Workflow-template (after updating seeders). Items #1, #2, #3, #7, #8, #11 above. |
| Phase 4b-remaining | 📋 PLANNED | Messaging Call / Chat / Message (composite-slug-as-natural-key — needs careful webhook router refactor). Item #5 above. |
| Phase 5 | ❌ DO NOT DO | Object, Field, Relation, CustomView, ObjectTemplate. These slugs ARE the public contract — refactoring breaks the FE and 26 migrations. Items #9, #10 above. |
| Side cleanup | 📋 PLANNED | Fix 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