93 lines
2.9 KiB
TypeScript
93 lines
2.9 KiB
TypeScript
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<User>;
|
|
getPublicProfile(username: string): Promise<PublicUser>;
|
|
updateProfile(userId: string, patch: UpdateMeBody): Promise<User>;
|
|
setAvatar(userId: string, avatarUrl: string): Promise<User>;
|
|
search(query: string, excludeUserId: string): Promise<PublicUser[]>;
|
|
heartbeat(userId: string): Promise<void>;
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|