Skip to content
scsiwyg
sign insign up
get startedmcpcommunityapiplaygroundswaggersign insign up
Stone Maps

Account Lifecycle: Designing Suspend, Reinstate, and Delete

#stonemaps#devlog#design#build

Account Lifecycle: Designing Suspend, Reinstate, and Delete

Most apps make it easy to sign up and hard to leave. Stone Maps tries to take leaving seriously.

The account lifecycle — suspend, reinstate, delete — exists because these are real states people end up in, and pretending they don't happen is a design choice that usually hurts users. Someone forgets they have an account and comes back a year later. Someone needs a break but doesn't want to lose their journal. Someone is done and wants to be gone.

These are three different things, and they deserve three different behaviors.

The Schema

Three columns were added to the users table:

typescript
/** Account lifecycle status: active | suspended | deleted */
status: text('status').default('active').notNull(),
suspendedAt: timestamp('suspended_at'),
deletedAt: timestamp('deleted_at'),

status is the source of truth. suspendedAt and deletedAt are timestamps for auditing — when did this change happen, and can we ever recover the record. The default is active. Every existing user migrated to active without touching their data.

This is a soft-delete pattern. A deleted account is not DELETE FROM users WHERE id = ?. It's UPDATE users SET status = 'deleted', deletedAt = NOW() WHERE id = ?. The row stays. The journal stays. The stone-pair stays. If someone contacts support and says "I deleted my account by mistake," we have something to work with.

Blocking at Login

The enforcement point is lib/auth.ts, in the authorize() callback of the credentials provider. Before issuing a session, we check status:

typescript
if (user.status === 'suspended') {
  throw new Error('Account suspended');
}
if (user.status === 'deleted') {
  throw new Error('Account deleted');
}

This is the right place to check. Not in middleware (too early, before we know who the user is). Not in every API route (too spread out, too easy to miss). In the auth layer, on every login attempt.

Active sessions — users already logged in when their account is suspended — expire naturally. We don't invalidate JWT tokens on suspension. For early access with a small user base, this is acceptable. If we add a server-side session store later, forced logout becomes trivial.

The API

Two routes handle lifecycle changes.

POST /api/users/me/account — suspend or reinstate:

typescript
const updates =
  action === 'suspend'
    ? { status: 'suspended', suspendedAt: new Date() }
    : { status: 'active', suspendedAt: null };

The reinstate path nulls out suspendedAt. The timestamp is only meaningful when the account is actually suspended; an active account with a stale suspendedAt would be confusing. Clearing it keeps the data clean.

DELETE /api/users/me — permanent deletion. This one sets status = 'deleted' and deletedAt = NOW(). It does not cascade — posts, conversations, media are retained. We'll add data export and cascade deletion later. For now, the row is marked gone and login is blocked.

The UI

The settings screen has an account management section in the Data tab. Two rows: Deactivate and Delete.

Both use a two-step confirm pattern. First press changes the button to "Confirm / Cancel." Second press executes the action. This is not a modal, not a typed confirmation, not a password re-entry. Just a second deliberate click.

The decision to not require password re-entry was conscious. People forget passwords. A journaling app shouldn't make you prove you own your account before you can leave it. The two-step confirm is enough friction to prevent accidents without becoming a barrier.

On delete, after the API call returns, the client routes to /auth/signin. The session is over. There's no message on that page that says "your account has been deleted" — it's just the sign-in screen. That might feel abrupt. We'll probably add a simple farewell message eventually.

Suspend vs. Delete

The distinction matters more than it might seem.

Suspension is reversible. You can reinstate immediately. You use it when you want to stop using the app but aren't sure you want to be gone permanently. Your data is intact, your stone-pair is intact, your journal is exactly where you left it.

Deletion is permanent in intent, soft in implementation. You're saying "I'm done." The door isn't closed — support can recover a recently deleted account — but we don't advertise that. The UX communicates finality even when the data layer doesn't enforce it yet.

We could have built "archive" as a middle state — something between suspension and deletion. We chose not to, at least for now. Three states is already a lot. Archive would have needed its own auth behavior, its own UI, its own API action. The complexity wasn't worth it when suspension covers the "I need a break" case adequately.

What's Missing

Export. Before you delete your account, you should be able to download your journal. We don't have that yet. It's on the roadmap as a hard requirement before anything like GDPR compliance is plausible.

Admin-initiated suspension. The admin dashboard can see account statuses but can't yet suspend an account server-side. That's a near-term addition — necessary for handling bad actors before we open beyond early access.

Team cleanup. If you're the owner of a team when you delete your account, the team hangs in an ownerless state. We handle this gracefully in the UI (teams show without an owner) but it's not a resolved data state. Another thing to clean up before real scale.

The lifecycle is built. The edges still need work.