fix(presence): reset stale users.status on boot; drop REST-login online write
users.status was only flipped back to offline by the WebSocket disconnect path (5s grace timer in ConnectionManager). Process exits (deploy/crash/OOM) lose those in-memory timers, freezing any non-offline row at its last value and making the user appear permanently online to friends and space co-members. Confirmed in production on the Pi instance: a user appeared online for ~3 days with no live socket. Add resetStalePresenceOnBoot() in utils/presenceBoot.ts and call it from index.ts after getDb()/seedDatabase() and before WebSocket route registration. The reset is federation-safe: it only updates rows where home_instance IS NULL (replicated stubs are projections of remote presence and must not be stomped) and is_deleted = 0 (tombstoned users are excluded from broadcasts). Also remove the redundant status='online' write from POST /api/auth/login. A successful REST login does not imply a live socket; the WS auth handshake is the single source of truth. Login alone could otherwise produce the same stuck-online row when a client logs in and never establishes a WS. Tests cover: locally-homed online/idle/dnd reset, replicated rows untouched, tombstoned rows untouched, idempotence, mixed populations. Updates docs/systems/activity-presence.md (Connect/Disconnect Flow, new Boot Reset section) and docs/systems/auth.md (login no longer mutates status).
This commit is contained in:
@@ -12,6 +12,7 @@ Source files:
|
|||||||
- `packages/web/src/components/modals/settingsPanels/PrivacyPanel.tsx` — showActivity toggle UI
|
- `packages/web/src/components/modals/settingsPanels/PrivacyPanel.tsx` — showActivity toggle UI
|
||||||
- `packages/server/src/ws/handler.ts` — ConnectionManager (in-memory activity state, rate limiting, disconnect cleanup)
|
- `packages/server/src/ws/handler.ts` — ConnectionManager (in-memory activity state, rate limiting, disconnect cleanup)
|
||||||
- `packages/server/src/ws/events.ts` — handlePresenceUpdate, handleActivityUpdate, validateActivities
|
- `packages/server/src/ws/events.ts` — handlePresenceUpdate, handleActivityUpdate, validateActivities
|
||||||
|
- `packages/server/src/utils/presenceBoot.ts` — boot-time reset of orphaned `users.status` rows (federation-safe)
|
||||||
- `packages/server/src/routes/users.ts` — REST showActivity toggle with server-side activity clear
|
- `packages/server/src/routes/users.ts` — REST showActivity toggle with server-side activity clear
|
||||||
- `packages/desktop/src/activityDetector.ts` — Process polling, game dictionary matching (boundary: see Desktop section)
|
- `packages/desktop/src/activityDetector.ts` — Process polling, game dictionary matching (boundary: see Desktop section)
|
||||||
- `packages/desktop/src/preload.ts` — IPC channel exposure (activity-detected, get-current-activity)
|
- `packages/desktop/src/preload.ts` — IPC channel exposure (activity-detected, get-current-activity)
|
||||||
@@ -122,16 +123,40 @@ function getPrimaryActivity(activities: Activity[]): Activity | null {
|
|||||||
|
|
||||||
The `users.status` column (see database.md) stores the current presence status. Default: `'offline'`.
|
The `users.status` column (see database.md) stores the current presence status. Default: `'offline'`.
|
||||||
|
|
||||||
- **On connect:** Server sets `status = 'online'` in DB (`ws/handler.ts:1344`)
|
- **On connect:** Server sets `status = 'online'` in DB at WebSocket auth (`ws/handler.ts`, the line after `authenticated = true`). The REST `/api/auth/login` route does **not** set status — login alone does not imply a live socket; the WS handshake is the single source of truth.
|
||||||
- **On manual change:** Client sends `presence_update` with `status` field; server persists to DB (`ws/events.ts:483`)
|
- **On manual change:** Client sends `presence_update` with `status` field; server persists to DB (`ws/events.ts`)
|
||||||
- **On disconnect:** After 5s grace period, server sets `status = 'offline'` in DB (`ws/handler.ts:225`)
|
- **On disconnect:** After 5s grace period, server sets `status = 'offline'` in DB (`ws/handler.ts:finalizeDisconnect`)
|
||||||
|
- **On boot:** Server resets stale rows for locally-homed, non-deleted users (see "Boot Reset" below).
|
||||||
|
|
||||||
|
### Boot Reset (`utils/presenceBoot.ts`)
|
||||||
|
|
||||||
|
`users.status` is only flipped back to `'offline'` by `ConnectionManager.finalizeDisconnect()` after a real WS close + 5s grace timer. Those timers live in process memory, so a server restart (deploy, crash, OOM, kill) loses them and any row currently set to `'online'`, `'idle'`, or `'dnd'` stays frozen at that value forever — making the user appear permanently online to friends and space co-members until they next connect.
|
||||||
|
|
||||||
|
`resetStalePresenceOnBoot()` runs once during server boot in `index.ts`, after `getDb()`/`seedDatabase()` and before WebSocket route registration. It executes a single update:
|
||||||
|
|
||||||
|
```
|
||||||
|
UPDATE users
|
||||||
|
SET status = 'offline'
|
||||||
|
WHERE home_instance IS NULL
|
||||||
|
AND is_deleted = 0
|
||||||
|
AND status != 'offline'
|
||||||
|
```
|
||||||
|
|
||||||
|
Three guards on the WHERE clause:
|
||||||
|
|
||||||
|
1. **`home_instance IS NULL`** — replicated user stubs (federated identities homed elsewhere) have their status projected to us by the home instance via `presence_update` relays, not by our local WS state. Their status must not be touched on our boot.
|
||||||
|
2. **`is_deleted = 0`** — tombstoned users are excluded from presence broadcasts already; their stored status is left alone as a maintenance courtesy (no behavioral effect either way, but avoids silent rewrites).
|
||||||
|
3. **`status != 'offline'`** — keeps the operation a no-op once steady-state is reached; `changes` is logged only when non-zero.
|
||||||
|
|
||||||
|
Because the in-memory `ConnectionManager` is empty at boot by construction, no live connection can be misrepresented by this reset.
|
||||||
|
|
||||||
### Connect/Disconnect Flow
|
### Connect/Disconnect Flow
|
||||||
|
|
||||||
1. **Auth succeeds** → `status` set to `'online'` in DB → `presence_update` broadcast to all user's spaces (excludes self; self gets `ready` payload)
|
1. **Server boot** → `resetStalePresenceOnBoot()` flips any locally-homed, non-deleted `online`/`idle`/`dnd` rows to `offline`. Federated rows untouched.
|
||||||
2. **Last socket closes** → 5-second grace period (`scheduleDisconnect`) to allow tab refresh/reconnect
|
2. **Auth succeeds** → `status` set to `'online'` in DB → `presence_update` broadcast to all user's spaces (excludes self; self gets `ready` payload)
|
||||||
3. **Grace period expires** → `finalizeDisconnect`: sets DB status to `'offline'`, clears in-memory activities, broadcasts `presence_update` with `status: 'offline'` and `activities: []` to all spaces
|
3. **Last socket closes** → 5-second grace period (`scheduleDisconnect`) to allow tab refresh/reconnect
|
||||||
4. **Reconnect during grace** → `cancelDisconnect` prevents offline broadcast; new connection proceeds normally
|
4. **Grace period expires** → `finalizeDisconnect`: sets DB status to `'offline'`, clears in-memory activities, broadcasts `presence_update` with `status: 'offline'` and `activities: []` to all spaces
|
||||||
|
5. **Reconnect during grace** → `cancelDisconnect` prevents offline broadcast; new connection proceeds normally
|
||||||
|
|
||||||
### Presence Broadcast Scope
|
### Presence Broadcast Scope
|
||||||
|
|
||||||
|
|||||||
@@ -191,8 +191,7 @@ Validates format (same rules as local registration: 3-32 chars, `/^[a-z0-9_]+$/`
|
|||||||
5. Verify password via bcrypt
|
5. Verify password via bcrypt
|
||||||
6. **If password invalid AND user is federated:** attempt self-healing (see below)
|
6. **If password invalid AND user is federated:** attempt self-healing (see below)
|
||||||
7. **If password invalid AND user is local:** reject
|
7. **If password invalid AND user is local:** reject
|
||||||
8. Set user status to `'online'`
|
8. Sign JWT, return `{ token, user }`. **Note:** Login does NOT mutate `users.status`. A successful login does not by itself imply a live connection (the client may never establish a WebSocket due to network failure, mobile background, error path); writing `'online'` here would produce a permanently stuck-online row that no disconnect timer cleans up. The WebSocket auth path (`ws/handler.ts`) is the single source of truth for `status = 'online'`. See `docs/systems/activity-presence.md` "Boot Reset" for the mitigation that runs on server start.
|
||||||
9. Sign JWT, return `{ token, user }`
|
|
||||||
|
|
||||||
### Federation Password Self-Healing
|
### Federation Password Self-Healing
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import { federationRoutes } from './routes/federation.js';
|
|||||||
import { startFederationWorkers, stopFederationWorkers } from './utils/federationWorker.js';
|
import { startFederationWorkers, stopFederationWorkers } from './utils/federationWorker.js';
|
||||||
import './utils/federationRollback.js'; // Side-effect: registers rollback callbacks for outbox terminal failures.
|
import './utils/federationRollback.js'; // Side-effect: registers rollback callbacks for outbox terminal failures.
|
||||||
import { registerCallRelayHooks } from './ws/events.js';
|
import { registerCallRelayHooks } from './ws/events.js';
|
||||||
|
import { resetStalePresenceOnBoot } from './utils/presenceBoot.js';
|
||||||
|
|
||||||
import { registerWebSocket } from './ws/handler.js';
|
import { registerWebSocket } from './ws/handler.js';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
@@ -82,6 +83,14 @@ async function main(): Promise<void> {
|
|||||||
getDb();
|
getDb();
|
||||||
await seedDatabase();
|
await seedDatabase();
|
||||||
|
|
||||||
|
// Reset orphaned `users.status` rows for locally-homed users. The previous
|
||||||
|
// process's in-memory disconnect timers are gone, so any non-offline row
|
||||||
|
// is stale by construction. Replicated (federated) rows are skipped — their
|
||||||
|
// status is a projection of remote presence, not local WS state. Must run
|
||||||
|
// before WS auth is accepted so the first connection broadcasts the correct
|
||||||
|
// online transition. See utils/presenceBoot.ts.
|
||||||
|
resetStalePresenceOnBoot();
|
||||||
|
|
||||||
await app.register(authRoutes);
|
await app.register(authRoutes);
|
||||||
await app.register(userRoutes);
|
await app.register(userRoutes);
|
||||||
await app.register(spaceRoutes);
|
await app.register(spaceRoutes);
|
||||||
|
|||||||
@@ -308,13 +308,18 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
db.update(schema.users).set({ status: 'online' }).where(eq(schema.users.id, user.id)).run();
|
// Note: status='online' is set exclusively by the WebSocket auth path
|
||||||
|
// (ws/handler.ts). A successful REST /login does not by itself imply a
|
||||||
|
// live connection — the client may never establish a WS (transient
|
||||||
|
// network failure, mobile background, error path), which would otherwise
|
||||||
|
// produce a permanently stuck-online row that no disconnect timer can
|
||||||
|
// clean up. The user's reported status remains whatever it was; the WS
|
||||||
|
// handshake will flip it to 'online' once a real socket attaches.
|
||||||
const token = signJwt({ userId: user.id, username: user.username });
|
const token = signJwt({ userId: user.id, username: user.username });
|
||||||
|
|
||||||
const response: AuthResponse = {
|
const response: AuthResponse = {
|
||||||
token,
|
token,
|
||||||
user: sanitizeUser({ ...user, status: 'online' }, true),
|
user: sanitizeUser(user, true),
|
||||||
};
|
};
|
||||||
|
|
||||||
return reply.code(200).send(response);
|
return reply.code(200).send(response);
|
||||||
|
|||||||
@@ -0,0 +1,189 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
|
import Database from 'better-sqlite3';
|
||||||
|
import { drizzle } from 'drizzle-orm/better-sqlite3';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import * as schema from '../db/schema.js';
|
||||||
|
import { setWorkerId } from './snowflake.js';
|
||||||
|
|
||||||
|
setWorkerId(1);
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
|
type TestDb = ReturnType<typeof drizzle<typeof schema>>;
|
||||||
|
let sqlite: Database.Database;
|
||||||
|
let testDb: TestDb;
|
||||||
|
|
||||||
|
vi.mock('../db/index.js', () => ({
|
||||||
|
getDb: () => testDb,
|
||||||
|
getRawDb: () => sqlite,
|
||||||
|
schema,
|
||||||
|
}));
|
||||||
|
|
||||||
|
function applyMigrations(db: Database.Database): void {
|
||||||
|
const dir = path.resolve(__dirname, '../../drizzle');
|
||||||
|
for (const f of fs.readdirSync(dir).filter((f) => f.endsWith('.sql')).sort()) {
|
||||||
|
const sqlText = fs.readFileSync(path.join(dir, f), 'utf8');
|
||||||
|
for (const stmt of sqlText.split(/-->\s*statement-breakpoint/)) {
|
||||||
|
const clean = stmt.trim();
|
||||||
|
if (clean) db.exec(clean);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function insertUser(overrides: Partial<typeof schema.users.$inferInsert>): string {
|
||||||
|
const id = overrides.id ?? `u-${Math.random().toString(36).slice(2, 10)}`;
|
||||||
|
testDb
|
||||||
|
.insert(schema.users)
|
||||||
|
.values({
|
||||||
|
id,
|
||||||
|
username: overrides.username ?? `user-${id}`,
|
||||||
|
passwordHash: overrides.passwordHash ?? 'hash',
|
||||||
|
status: overrides.status ?? 'offline',
|
||||||
|
isDeleted: overrides.isDeleted ?? 0,
|
||||||
|
homeInstance: overrides.homeInstance ?? null,
|
||||||
|
homeUserId: overrides.homeUserId ?? null,
|
||||||
|
createdAt: overrides.createdAt ?? Date.now(),
|
||||||
|
...overrides,
|
||||||
|
} as typeof schema.users.$inferInsert)
|
||||||
|
.run();
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatus(id: string): string | null {
|
||||||
|
const row = testDb
|
||||||
|
.select({ status: schema.users.status })
|
||||||
|
.from(schema.users)
|
||||||
|
.where(eq(schema.users.id, id))
|
||||||
|
.get();
|
||||||
|
return row?.status ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
sqlite = new Database(':memory:');
|
||||||
|
testDb = drizzle(sqlite, { schema });
|
||||||
|
applyMigrations(sqlite);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('resetStalePresenceOnBoot', () => {
|
||||||
|
it('resets locally-homed online users to offline', async () => {
|
||||||
|
const aliceId = insertUser({ username: 'alice', status: 'online' });
|
||||||
|
const { resetStalePresenceOnBoot } = await import('./presenceBoot.js');
|
||||||
|
|
||||||
|
const changed = resetStalePresenceOnBoot();
|
||||||
|
|
||||||
|
expect(changed).toBe(1);
|
||||||
|
expect(getStatus(aliceId)).toBe('offline');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resets locally-homed idle and dnd users to offline', async () => {
|
||||||
|
const idleId = insertUser({ username: 'idle-user', status: 'idle' });
|
||||||
|
const dndId = insertUser({ username: 'dnd-user', status: 'dnd' });
|
||||||
|
const { resetStalePresenceOnBoot } = await import('./presenceBoot.js');
|
||||||
|
|
||||||
|
const changed = resetStalePresenceOnBoot();
|
||||||
|
|
||||||
|
expect(changed).toBe(2);
|
||||||
|
expect(getStatus(idleId)).toBe('offline');
|
||||||
|
expect(getStatus(dndId)).toBe('offline');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not modify replicated (federated) user rows', async () => {
|
||||||
|
const localId = insertUser({ username: 'local', status: 'online' });
|
||||||
|
const remoteOnlineId = insertUser({
|
||||||
|
username: 'remote-online',
|
||||||
|
status: 'online',
|
||||||
|
homeInstance: 'orbit.example',
|
||||||
|
homeUserId: 'remote-uid-1',
|
||||||
|
});
|
||||||
|
const remoteIdleId = insertUser({
|
||||||
|
username: 'remote-idle',
|
||||||
|
status: 'idle',
|
||||||
|
homeInstance: 'nova.example',
|
||||||
|
homeUserId: 'remote-uid-2',
|
||||||
|
});
|
||||||
|
const remoteDndId = insertUser({
|
||||||
|
username: 'remote-dnd',
|
||||||
|
status: 'dnd',
|
||||||
|
homeInstance: 'orbit.example',
|
||||||
|
homeUserId: 'remote-uid-3',
|
||||||
|
});
|
||||||
|
const { resetStalePresenceOnBoot } = await import('./presenceBoot.js');
|
||||||
|
|
||||||
|
const changed = resetStalePresenceOnBoot();
|
||||||
|
|
||||||
|
expect(changed).toBe(1);
|
||||||
|
expect(getStatus(localId)).toBe('offline');
|
||||||
|
// Replicated rows must keep their projected remote status untouched.
|
||||||
|
expect(getStatus(remoteOnlineId)).toBe('online');
|
||||||
|
expect(getStatus(remoteIdleId)).toBe('idle');
|
||||||
|
expect(getStatus(remoteDndId)).toBe('dnd');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not modify soft-deleted (tombstoned) users', async () => {
|
||||||
|
const tombstonedId = insertUser({
|
||||||
|
username: 'gone',
|
||||||
|
status: 'online',
|
||||||
|
isDeleted: 1,
|
||||||
|
});
|
||||||
|
const { resetStalePresenceOnBoot } = await import('./presenceBoot.js');
|
||||||
|
|
||||||
|
const changed = resetStalePresenceOnBoot();
|
||||||
|
|
||||||
|
expect(changed).toBe(0);
|
||||||
|
// Tombstoned rows are excluded from presence broadcasts; their stored
|
||||||
|
// status must not be silently rewritten by a maintenance task.
|
||||||
|
expect(getStatus(tombstonedId)).toBe('online');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves already-offline users untouched', async () => {
|
||||||
|
const offId = insertUser({ username: 'off', status: 'offline' });
|
||||||
|
const { resetStalePresenceOnBoot } = await import('./presenceBoot.js');
|
||||||
|
|
||||||
|
const changed = resetStalePresenceOnBoot();
|
||||||
|
|
||||||
|
expect(changed).toBe(0);
|
||||||
|
expect(getStatus(offId)).toBe('offline');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is idempotent — second call after the first changes nothing', async () => {
|
||||||
|
insertUser({ username: 'a', status: 'online' });
|
||||||
|
insertUser({ username: 'b', status: 'idle' });
|
||||||
|
const { resetStalePresenceOnBoot } = await import('./presenceBoot.js');
|
||||||
|
|
||||||
|
expect(resetStalePresenceOnBoot()).toBe(2);
|
||||||
|
expect(resetStalePresenceOnBoot()).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles a mixed population correctly', async () => {
|
||||||
|
// Locally-homed: should reset 'online' and 'dnd'
|
||||||
|
const localOnline = insertUser({ username: 'lo', status: 'online' });
|
||||||
|
const localDnd = insertUser({ username: 'ld', status: 'dnd' });
|
||||||
|
const localOffline = insertUser({ username: 'loff', status: 'offline' });
|
||||||
|
// Federated: should be untouched regardless of status
|
||||||
|
const remote = insertUser({
|
||||||
|
username: 'rem',
|
||||||
|
status: 'online',
|
||||||
|
homeInstance: 'peer.example',
|
||||||
|
homeUserId: 'peer-1',
|
||||||
|
});
|
||||||
|
// Tombstoned local: untouched
|
||||||
|
const tomb = insertUser({
|
||||||
|
username: 'tomb',
|
||||||
|
status: 'online',
|
||||||
|
isDeleted: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { resetStalePresenceOnBoot } = await import('./presenceBoot.js');
|
||||||
|
const changed = resetStalePresenceOnBoot();
|
||||||
|
|
||||||
|
expect(changed).toBe(2);
|
||||||
|
expect(getStatus(localOnline)).toBe('offline');
|
||||||
|
expect(getStatus(localDnd)).toBe('offline');
|
||||||
|
expect(getStatus(localOffline)).toBe('offline');
|
||||||
|
expect(getStatus(remote)).toBe('online');
|
||||||
|
expect(getStatus(tomb)).toBe('online');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { and, isNull, ne, eq } from 'drizzle-orm';
|
||||||
|
import { getDb, schema } from '../db/index.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset orphaned presence state at server boot.
|
||||||
|
*
|
||||||
|
* `users.status` is only flipped back to `'offline'` by the WebSocket
|
||||||
|
* disconnect path (`ConnectionManager.finalizeDisconnect` after a 5s grace
|
||||||
|
* timer). When the server process exits — deploy, crash, OOM, kill — those
|
||||||
|
* in-memory grace timers are lost and any rows currently set to `'online'`,
|
||||||
|
* `'idle'`, or `'dnd'` stay frozen at that value forever, causing users to
|
||||||
|
* appear permanently online to friends and space co-members until they next
|
||||||
|
* connect.
|
||||||
|
*
|
||||||
|
* At boot, the in-memory `ConnectionManager` is empty by construction, so
|
||||||
|
* any non-`offline` status row is by definition stale and safe to reset.
|
||||||
|
*
|
||||||
|
* Federation safety:
|
||||||
|
* - The `users` table contains replicated user stubs for users whose home
|
||||||
|
* instance is elsewhere (`home_instance` non-null). Their `status` is a
|
||||||
|
* projection of remote presence, broadcast to us by their home instance,
|
||||||
|
* and is NOT a function of our local WebSocket state. We must not touch
|
||||||
|
* replicated rows — only reset rows where `home_instance IS NULL`.
|
||||||
|
* - Soft-deleted (tombstoned) users have `is_deleted = 1` and are excluded
|
||||||
|
* from presence broadcasts already; leave their stored status alone.
|
||||||
|
*
|
||||||
|
* Called once during server boot, after `getDb()` succeeds and before the
|
||||||
|
* WebSocket handler is registered. Idempotent — re-running has no effect
|
||||||
|
* once all locally-homed users are `'offline'`.
|
||||||
|
*
|
||||||
|
* @returns Number of rows reset (for logging / test assertions).
|
||||||
|
*/
|
||||||
|
export function resetStalePresenceOnBoot(): number {
|
||||||
|
const db = getDb();
|
||||||
|
const result = db.update(schema.users)
|
||||||
|
.set({ status: 'offline' })
|
||||||
|
.where(and(
|
||||||
|
isNull(schema.users.homeInstance),
|
||||||
|
eq(schema.users.isDeleted, 0),
|
||||||
|
ne(schema.users.status, 'offline'),
|
||||||
|
))
|
||||||
|
.run();
|
||||||
|
|
||||||
|
// better-sqlite3's RunResult exposes `changes`; drizzle passes it through.
|
||||||
|
const changes = (result as { changes?: number }).changes ?? 0;
|
||||||
|
if (changes > 0) {
|
||||||
|
console.log(`[presenceBoot] Reset ${changes} stale user status row(s) to 'offline'`);
|
||||||
|
}
|
||||||
|
return changes;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user