feat: add Activity type system, DB migration, and self-only showActivity in sanitizeUser

- Add Activity, ActivityType, ActivityTimestamps, ActivityAssets types to shared types
- Add activity_update client event and activities field on presence_update server event
- Add userActivities to ready payload and showActivity to User/UpdateUserRequest
- Create shared activities.ts with ACTIVITY_LIMITS, ACTIVITY_PRIORITY, getPrimaryActivity
- Add show_activity column to users table (schema + migration)
- Update sanitizeUser with isSelf parameter; only include showActivity for self
- Fix .map(sanitizeUser) calls to use arrow wrapper to prevent index-as-boolean bug
- Mark auth routes (register/login) as isSelf=true since they return own user data
This commit is contained in:
Jannis Braun
2026-03-21 01:38:36 +01:00
parent ce981ee5fc
commit bc408235c9
11 changed files with 83 additions and 18 deletions
+6
View File
@@ -151,6 +151,12 @@ export function runMigrations(db: Database.Database): void {
{ name: 'password_changed_at', type: 'INTEGER' },
]
},
{
name: 'users',
columns: [
{ name: 'show_activity', type: 'INTEGER NOT NULL DEFAULT 1' },
]
},
// gif_api_key is handled by migrateRenameGifApiKey() — do NOT add it here
// or it will race with the tenor_api_key → gif_api_key rename migration
];
+1
View File
@@ -20,6 +20,7 @@ export const users = sqliteTable('users', {
discoverable: integer('discoverable').default(1),
profileUpdatedAt: integer('profile_updated_at'),
passwordChangedAt: integer('password_changed_at'),
showActivity: integer('show_activity').notNull().default(1),
createdAt: integer('created_at').notNull(),
});
+2 -2
View File
@@ -122,7 +122,7 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
const response: AuthResponse = {
token,
user: sanitizeUser(user),
user: sanitizeUser(user, true),
};
return reply.code(201).send(response);
@@ -207,7 +207,7 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
const response: AuthResponse = {
token,
user: sanitizeUser({ ...user, status: 'online' }),
user: sanitizeUser({ ...user, status: 'online' }, true),
};
return reply.code(200).send(response);
+5 -5
View File
@@ -192,7 +192,7 @@ export function broadcastDmMessage(dmChannelId: string, message: DmMessageWithUs
id: dmChannel.id,
ownerId: dmChannel.ownerId ?? null,
createdAt: dmChannel.createdAt,
members: users.map(sanitizeUser),
members: users.map(u => sanitizeUser(u)),
lastMessage: message,
},
});
@@ -290,7 +290,7 @@ export async function dmRoutes(app: FastifyInstance): Promise<void> {
const members = memberIds
.map(id => userMap.get(id))
.filter((u): u is NonNullable<typeof u> => u !== undefined)
.map(sanitizeUser);
.map(u => sanitizeUser(u));
const lastMsg = lastMessageMap.get(channelId) ?? null;
@@ -407,7 +407,7 @@ export async function dmRoutes(app: FastifyInstance): Promise<void> {
id: dmChannel.id,
ownerId: dmChannel.ownerId ?? null,
createdAt: dmChannel.createdAt,
members: users.map(sanitizeUser),
members: users.map(u => sanitizeUser(u)),
lastMessage: lastMsg ? {
id: lastMsg.id,
dmChannelId: lastMsg.dmChannelId,
@@ -446,7 +446,7 @@ export async function dmRoutes(app: FastifyInstance): Promise<void> {
const currentUserRow = db.select().from(schema.users).where(eq(schema.users.id, request.userId)).get();
const members = [currentUserRow, targetUser]
.filter((u): u is NonNullable<typeof u> => u !== undefined)
.map(sanitizeUser);
.map(u => sanitizeUser(u));
const result: DmChannel = {
id: dmChannelId,
@@ -600,7 +600,7 @@ export async function dmRoutes(app: FastifyInstance): Promise<void> {
id: dmChannel.id,
ownerId: dmChannel.ownerId ?? null,
createdAt: dmChannel.createdAt,
members: users.map(sanitizeUser),
members: users.map(u => sanitizeUser(u)),
lastMessage: lastMsg ? {
id: lastMsg.id,
dmChannelId: lastMsg.dmChannelId,
+1 -1
View File
@@ -502,6 +502,6 @@ export async function socialRoutes(app: FastifyInstance): Promise<void> {
.limit(10)
.all();
return reply.code(200).send(users.map(sanitizeUser));
return reply.code(200).send(users.map(u => sanitizeUser(u)));
});
}
+4 -4
View File
@@ -33,7 +33,7 @@ export async function userRoutes(app: FastifyInstance): Promise<void> {
return reply.code(401).send({ error: 'This account has been deleted', statusCode: 401 });
}
return reply.code(200).send(sanitizeUser(user));
return reply.code(200).send(sanitizeUser(user, true));
});
// POST /api/users/@me/verify-password — verify password matches current account
@@ -328,7 +328,7 @@ export async function userRoutes(app: FastifyInstance): Promise<void> {
const storedTs = currentUser.profileUpdatedAt ?? currentUser.createdAt;
if (profileUpdatedAt < storedTs) {
// Incoming data is older — return current state without updating
return reply.code(200).send(sanitizeUser(currentUser));
return reply.code(200).send(sanitizeUser(currentUser, true));
}
}
(updateData as Record<string, unknown>).profileUpdatedAt = profileUpdatedAt;
@@ -373,7 +373,7 @@ export async function userRoutes(app: FastifyInstance): Promise<void> {
return reply.code(404).send({ error: 'User not found', statusCode: 404 });
}
const sanitized = sanitizeUser(updatedUser);
const sanitized = sanitizeUser(updatedUser, true);
// Broadcast presence update if status changed
if (status !== undefined) {
@@ -681,7 +681,7 @@ export async function userRoutes(app: FastifyInstance): Promise<void> {
const mutualFriendIds = [...myFriendIds].filter((id) => targetFriendIds.has(id));
const mutualFriends = mutualFriendIds.length > 0
? db.select().from(schema.users).where(inArray(schema.users.id, mutualFriendIds)).all().map(sanitizeUser)
? db.select().from(schema.users).where(inArray(schema.users.id, mutualFriendIds)).all().map(u => sanitizeUser(u))
: [];
// Mutual spaces: spaces both me and the target are members of
+3 -1
View File
@@ -1,7 +1,7 @@
import type { User, ReplicatedInstance } from '@backspace/shared';
import { schema } from '../db/index.js';
export function sanitizeUser(row: typeof schema.users.$inferSelect): User {
export function sanitizeUser(row: typeof schema.users.$inferSelect, isSelf = false): User {
// Tombstoned (deleted) users — return anonymized profile
if (row.isDeleted === 1) {
return {
@@ -23,6 +23,7 @@ export function sanitizeUser(row: typeof schema.users.$inferSelect): User {
homeInstance: null,
homeUserId: null,
replicatedInstances: [],
...(isSelf ? { showActivity: false } : {}),
};
}
@@ -53,5 +54,6 @@ export function sanitizeUser(row: typeof schema.users.$inferSelect): User {
homeInstance: row.homeInstance ?? null,
homeUserId: row.homeUserId ?? null,
replicatedInstances,
...(isSelf ? { showActivity: row.showActivity !== 0 } : {}),
};
}
+2 -2
View File
@@ -682,7 +682,7 @@ function buildReadyPayload(userId: string): {
if (!userRow) {
throw new Error('User not found');
}
const user = sanitizeUser(userRow);
const user = sanitizeUser(userRow, true);
// Get user's space memberships
const memberships = db.select()
@@ -931,7 +931,7 @@ function buildReadyPayload(userId: string): {
const members = memberRows
.map(m => dmUserMap.get(m.userId))
.filter((u): u is NonNullable<typeof u> => u != null)
.map(sanitizeUser);
.map(u => sanitizeUser(u));
const last = dmLastMsgMap.get(dm.dmChannelId) ?? null;