dorfteich/apps/api/src/users/user-search.controller.ts
Claude Fable 5 7471fc70f7 #150: @-Mentions — Inline-Node, instanzweite User-Suche, Autocomplete
Neuer Inline-Atom mention {userId, username}: Markdown-Regel @username
(E-Mail-sicher über Wortgrenzen), Serializer, HTML-Span dt-mention,
Plain-Text für die Suche, Extraktor extractMentionUserIds. Neue
Endpoints GET /users/search (auth, min. 2 Zeichen, Limit 10,
Rate-Limit) und GET /users/brief (Batch-Auflösung für live
Anzeigenamen; gelöschte Nutzer → toter Chip). Editor: MentionView mit
Live-displayName, MentionAutocomplete (Klon des Wikilink-Musters),
Chip-CSS. 5 Unit-Tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0155v2aT8AG1kZDQEZiCLBWC
2026-07-20 00:56:07 +02:00

58 lines
2.1 KiB
TypeScript

import { Controller, Get, Query } from '@nestjs/common';
import type { UserBriefView } from '@dorfteich/shared';
import { AuthenticatedOnly } from '../permissions/permission.decorators';
import { PrismaService } from '../prisma/prisma.service';
import { RateLimit } from '../rate-limit/rate-limit.guard';
/** Cap for the batch `brief` lookup — a page mentions a handful of people. */
const BRIEF_MAX_IDS = 50;
/**
* Instance-wide user lookup for `@` mentions (issue #150). Deliberately
* minimal: only id/username/displayName, only active accounts, only for
* signed-in users, rate-limited, and never enumerable without a query — a
* documented consequence of instance-wide mentions is that logged-in users
* can discover usernames this way.
*/
@Controller('users')
@AuthenticatedOnly()
export class UserSearchController {
constructor(private readonly prisma: PrismaService) {}
@Get('search')
@RateLimit({ scope: 'user-search', limit: 60, windowSeconds: 60 })
async search(@Query('q') q: string | undefined): Promise<UserBriefView[]> {
const query = (q ?? '').trim();
if (query.length < 2) return [];
return this.prisma.user.findMany({
where: {
status: 'ACTIVE',
OR: [
{ username: { contains: query, mode: 'insensitive' } },
{ displayName: { contains: query, mode: 'insensitive' } },
],
},
select: { id: true, username: true, displayName: true },
orderBy: { username: 'asc' },
take: 10,
});
}
/** Batch resolution of mentioned users for live display names; unknown or
* disabled ids are simply absent (the mention renders as a dead chip). */
@Get('brief')
async brief(@Query('ids') ids: string | undefined): Promise<UserBriefView[]> {
const wanted = (ids ?? '')
.split(',')
.map((id) => id.trim())
.filter(Boolean)
.slice(0, BRIEF_MAX_IDS);
if (wanted.length === 0) return [];
return this.prisma.user.findMany({
where: { id: { in: wanted }, status: 'ACTIVE' },
select: { id: true, username: true, displayName: true },
});
}
}