Deactivation

A schema mixin that marks a subject as deactivated (can't log in) while keeping its data fully visible — the deliberate opposite of soft-delete.

@voltro/plugin-deactivation ships a single surface: the deactivation() schema mixin. A deactivated subject — typically a user who can no longer log in — has its data stay fully visible (assignments, history, audit trail). That's the deliberate opposite of softDelete(), which hides the row from default reads and anonymises PII for GDPR.

Status: ✓ shipped.

What it adds

The mixin contributes two columns:

Column Type Meaning
deactivatedAt nullable timestamp set ⇒ the subject is deactivated; null ⇒ active
deactivatedBy nullable reference → Actor who performed the deactivation

It's a pure schema mixin (columns only), exactly like audit() — no read scoping, no delete interception. You set deactivatedAt with a normal ctx.store.update(...).

deactivation() transitively requires audit() (a deactivation is an audit-worthy event, and the timestamps sit together on the row), so audit() auto-stamps updatedAt / updatedBy alongside — the WHO/WHEN of the change is captured for free. Its stable id is voltro/deactivation.

Composing it

import { id, table, text } from '@voltro/database'
import { deactivation } from '@voltro/plugin-deactivation'

export const users = table('users', {
  id:    id(),
  email: text().unique(),
  name:  text(),
}).with(deactivation())

The dep resolver dedupes, so listing audit() explicitly alongside is harmless:

import { audit } from '@voltro/plugin-audit/mixin'
import { deactivation } from '@voltro/plugin-deactivation'

table('users', { /* … */ }).with(audit(), deactivation())

A project that doesn't want the barrel can import from the ./mixin subpath:

import { deactivation } from '@voltro/plugin-deactivation/mixin'

Deactivating + reactivating

Both are plain updates — audit() records the actor and time:

// deactivate — update(table, primaryKey, patch)
await ctx.store.update('users', userId, {
  deactivatedAt: new Date(),
  deactivatedBy: ctx.request.subject.id,
})

// reactivate
await ctx.store.update('users', userId, {
  deactivatedAt: null,
  deactivatedBy: null,
})

Because the row is never hidden, your own queries decide what "active" means — the user stays visible everywhere in the app while their login is refused.

Enforcing "a deactivated user can't log in"

The mixin's promise is self-enforcing through @voltro/plugin-auth's post-authentication subject-guard seam — you do not hand-roll a deactivatedAt === null check in an auth resolver. Import deactivationGuard() from the /guard subpath and wire it onto the auth plugin in one line:

import { authRoutesPlugin } from '@voltro/plugin-auth/plugin'
import { deactivationGuard } from '@voltro/plugin-deactivation/guard'

authRoutesPlugin({
  store,
  secret: process.env.VOLTRO_SESSION_SECRET!,
  defaultTenantId: 'acme',
  subjectGuards: [deactivationGuard()],
})

The auth pipeline runs each subject guard after the credential check (password / MFA / magic-link / passkey) but before it issues a session. When the resolved user's deactivatedAt is set, deactivationGuard() vetoes: the login returns a 403 whose body error is account_deactivated, and no session cookie is issued — on every sign-in path. Reactivate the user (deactivatedAt = null) and the next login proceeds normally. See the auth plugin's subject-guard seam.

AccountDeactivated — a Schema.TaggedError on the same /guard subpath — is the typed form of the rejection for code that prefers matching on _tag === 'AccountDeactivated' over the wire body. The /guard subpath is server-only and is not imported by the mixin, so a *.entity.ts importing deactivation() never drags the auth surface into the browser bundle.

Deactivation vs soft-delete

deactivation() softDelete()
Row visible in default reads yes no (hidden)
PII untouched anonymised
Typical use user can't log in, data stays GDPR erasure / "delete"
Read scoping none filters hidden rows out

The two are orthogonal and compose. A user can be deactivated (visible) and later soft-deleted (hidden) — apply both mixins.

When to use which

  • Deactivation — revoke access but keep the subject's contributions intact and attributable (the common "disable an employee account" case).
  • Soft-delete — the user must disappear from the app and their PII must be scrubbed.
  • Both — start with deactivation, escalate to soft-delete if an erasure request arrives.