import type { User, PublicUser, UpdateMeBody, ProfileUpdateEvent } from '@altricade/core'; import { userChannel, EventType } from '@altricade/core'; import { HttpError } from '../../shared/http-error'; import type { Publisher } from '../../shared/publisher'; import type { UsersRepository } from './users.repository'; import { toUser, toPublicUser } from './users.mapper'; export interface UsersServiceDeps { users: UsersRepository; publish: Publisher; } export interface UsersService { getMe(userId: string): Promise; getPublicProfile(username: string): Promise; updateProfile(userId: string, patch: UpdateMeBody): Promise; setAvatar(userId: string, avatarUrl: string): Promise; search(query: string, excludeUserId: string): Promise; heartbeat(userId: string): Promise; } const SEARCH_LIMIT = 20; // Escape LIKE wildcards so user input is matched literally. const escapeLike = (value: string): string => value.replace(/[\\%_]/g, '\\$&'); export const createUsersService = ({ users, publish }: UsersServiceDeps): UsersService => ({ getMe: async (userId) => { const row = await users.findById(userId); if (row === undefined) { throw new HttpError(404, 'not_found', 'User not found'); } return toUser(row); }, getPublicProfile: async (username) => { const row = await users.findByUsername(username); if (row === undefined) { throw new HttpError(404, 'not_found', 'User not found'); } return toPublicUser(row); }, updateProfile: async (userId, patch) => { let row; try { row = await users.updateProfile(userId, patch); } catch (error) { // Unique violation on the citext username column (23505) → taken. if (typeof error === 'object' && error !== null && 'code' in error && error.code === '23505') { throw new HttpError(409, 'username_taken', 'That username is already taken'); } throw error; } if (row === undefined) { throw new HttpError(404, 'not_found', 'User not found'); } return toUser(row); }, setAvatar: async (userId, avatarUrl) => { const row = await users.setAvatar(userId, avatarUrl); if (row === undefined) { throw new HttpError(404, 'not_found', 'User not found'); } const user = toUser(row); const event: ProfileUpdateEvent = { type: EventType.ProfileUpdate, userId, displayName: user.displayName, avatarUrl: user.avatarUrl, }; await publish(userChannel(userId), event); return user; }, search: async (query, excludeUserId) => { const trimmed = query.trim(); if (trimmed.length === 0) { return []; } const rows = await users.searchByPrefix(escapeLike(trimmed), excludeUserId, SEARCH_LIMIT); return rows.map(toPublicUser); }, heartbeat: (userId) => users.touchLastSeen(userId), }); declare module 'fastify' { interface FastifyInstance { usersService: UsersService; } }