feat(explore): add backend logic for the explore page

Signed-off-by: Erik Michelson <github@erik.michelson.eu>
This commit is contained in:
Erik Michelson
2025-03-05 20:34:10 +01:00
committed by Philip Molares
parent fa110dbdd3
commit 4e033863ff
11 changed files with 512 additions and 0 deletions
@@ -0,0 +1,135 @@
/*
* SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { NoteType, OptionalSortMode, SortMode } from '@hedgedoc/commons';
import {
BadRequestException,
Controller,
Get,
Query,
UseGuards,
} from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { SessionGuard } from '../../../auth/session.guard';
import { NoteExploreEntryDto } from '../../../dtos/note-explore-entry.dto';
import { ExploreService } from '../../../explore/explore.service';
import { ConsoleLoggerService } from '../../../logger/console-logger.service';
import { OpenApi } from '../../utils/decorators/openapi.decorator';
import { RequestUserId } from '../../utils/decorators/request-user-id.decorator';
type OptionalNoteType = NoteType | '';
@UseGuards(SessionGuard)
@OpenApi(401, 403)
@ApiTags('explore')
@Controller('explore')
export class ExploreController {
constructor(
private readonly logger: ConsoleLoggerService,
private readonly exploreService: ExploreService,
) {
this.logger.setContext(ExploreController.name);
}
@Get('my')
@OpenApi(200)
getMyNotes(
@RequestUserId() userId: number,
@Query('page') page: number,
@Query('sort') sort?: OptionalSortMode,
@Query('search') search?: string,
@Query('type') type?: OptionalNoteType,
): Promise<NoteExploreEntryDto[]> {
this.checkQueryParams(page, sort, type);
return this.exploreService.getMyNoteExploreEntries(
userId,
page,
type,
sort,
search,
);
}
@Get('shared')
@OpenApi(200)
getSharedNotes(
@RequestUserId() userId: number,
@Query('page') page: number,
@Query('sort') sort?: OptionalSortMode,
@Query('search') search?: string,
@Query('type') type?: OptionalNoteType,
): Promise<NoteExploreEntryDto[]> {
this.checkQueryParams(page, sort, type);
return this.exploreService.getSharedWithMeExploreEntries(
userId,
page,
type,
sort,
search,
);
}
@Get('public')
@OpenApi(200)
getPublicNotes(
@Query('page') page: number,
@Query('sort') sort?: OptionalSortMode,
@Query('search') search?: string,
@Query('type') type?: OptionalNoteType,
): Promise<NoteExploreEntryDto[]> {
this.checkQueryParams(page, sort, type);
return this.exploreService.getPublicNoteExploreEntries(
page,
type,
sort,
search,
);
}
@Get('pinned')
@OpenApi(200)
getMyPinnedNotes(
@RequestUserId() userId: number,
): Promise<NoteExploreEntryDto[]> {
return this.exploreService.getMyPinnedNoteExploreEntries(userId);
}
private checkQueryParams(
page: number,
sort?: OptionalSortMode,
type?: OptionalNoteType,
): void {
this.ensurePageNumberIsValid(page);
this.ensureTypeQueryParamIsValid(type);
this.ensureSortQueryParamIsValid(sort);
}
private ensurePageNumberIsValid(page: number): void {
if (page < 1) {
throw new BadRequestException('Page number must be greater than 0');
}
}
private ensureTypeQueryParamIsValid(type?: OptionalNoteType): void {
if (type === undefined || type === '') {
return;
}
const validValues = Object.values(NoteType);
if (!validValues.includes(type)) {
throw new BadRequestException(`Invalid note type in search: ${type}`);
}
}
private ensureSortQueryParamIsValid(sort?: OptionalSortMode): void {
if (sort === undefined || sort === '') {
return;
}
const validValues = Object.values(SortMode);
if (!validValues.includes(sort)) {
throw new BadRequestException(`Invalid sort mode in search: ${sort}`);
}
}
}
@@ -8,6 +8,7 @@ import { Module } from '@nestjs/common';
import { AliasModule } from '../../alias/alias.module';
import { ApiTokenModule } from '../../api-token/api-token.module';
import { AuthModule } from '../../auth/auth.module';
import { ExploreModule } from '../../explore/explore.module';
import { FrontendConfigModule } from '../../frontend-config/frontend-config.module';
import { GroupsModule } from '../../groups/groups.module';
import { MediaModule } from '../../media/media.module';
@@ -23,6 +24,7 @@ import { LdapController } from './auth/ldap/ldap.controller';
import { LocalController } from './auth/local/local.controller';
import { OidcController } from './auth/oidc/oidc.controller';
import { ConfigController } from './config/config.controller';
import { ExploreController } from './explore/explore.controller';
import { GroupsController } from './groups/groups.controller';
import { MeController } from './me/me.controller';
import { MediaController } from './media/media.controller';
@@ -36,6 +38,7 @@ import { UsersController } from './users/users.controller';
FrontendConfigModule,
PermissionsModule,
AliasModule,
ExploreModule,
MediaModule,
RevisionsModule,
AuthModule,
@@ -45,6 +48,7 @@ import { UsersController } from './users/users.controller';
controllers: [
ApiTokensController,
ConfigController,
ExploreController,
GuestController,
MediaController,
MeController,
+2
View File
@@ -30,6 +30,7 @@ import { Loglevel } from './config/loglevel.enum';
import mediaConfig from './config/media.config';
import noteConfig from './config/note.config';
import { eventModuleConfig } from './events';
import { ExploreModule } from './explore/explore.module';
import { FrontendConfigModule } from './frontend-config/frontend-config.module';
import { FrontendConfigService } from './frontend-config/frontend-config.service';
import { GroupsModule } from './groups/groups.module';
@@ -125,6 +126,7 @@ const routes: Routes = [
SessionModule,
MediaRedirectModule,
MessageModule,
ExploreModule,
],
controllers: [],
providers: [
@@ -0,0 +1,9 @@
/*
* SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { NoteExploreEntrySchema } from '@hedgedoc/commons';
import { createZodDto } from 'nestjs-zod';
export class NoteExploreEntryDto extends createZodDto(NoteExploreEntrySchema) {}
+16
View File
@@ -0,0 +1,16 @@
/*
* SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Module } from '@nestjs/common';
import { GroupsModule } from '../groups/groups.module';
import { ExploreService } from './explore.service';
@Module({
imports: [GroupsModule],
providers: [ExploreService],
exports: [ExploreService],
})
export class ExploreModule {}
+299
View File
@@ -0,0 +1,299 @@
/*
* SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { NoteType, OptionalSortMode, SortMode } from '@hedgedoc/commons';
import {
FieldNameAlias,
FieldNameNote,
FieldNameNoteGroupPermission,
FieldNameNoteUserPermission,
FieldNameRevision,
FieldNameRevisionTag,
FieldNameUser,
FieldNameUserPinnedNote,
SpecialGroup,
TableAlias,
TableNote,
TableNoteGroupPermission,
TableNoteUserPermission,
TableRevision,
TableRevisionTag,
TableUser,
TableUserPinnedNote,
} from '@hedgedoc/database';
import { Injectable } from '@nestjs/common';
import { Knex } from 'knex';
import { DateTime } from 'luxon';
import { InjectConnection } from 'nest-knexjs';
import { NoteExploreEntryDto } from '../dtos/note-explore-entry.dto';
import { GroupsService } from '../groups/groups.service';
import { ConsoleLoggerService } from '../logger/console-logger.service';
const ENTRIES_PER_PAGE_LIMIT = 20;
interface QueryResult {
primaryAlias: string;
title: string;
noteType: NoteType;
ownerUsername: string;
lastChangedAt: string;
revisionUuid: string;
tag: string;
}
interface QueryResultWithTagList extends Omit<QueryResult, 'tag'> {
tags: string[];
}
@Injectable()
export class ExploreService {
constructor(
private readonly logger: ConsoleLoggerService,
@InjectConnection()
private readonly knex: Knex,
private readonly groupsService: GroupsService,
) {
this.logger.setContext(ExploreService.name);
}
async getPublicNoteExploreEntries(
page: number,
noteType?: NoteType | '',
sortBy?: OptionalSortMode,
search?: string,
): Promise<NoteExploreEntryDto[]> {
const everyoneGroupId = await this.groupsService.getGroupIdByName(
SpecialGroup.EVERYONE,
);
const queryBase = this.knex(TableNoteGroupPermission).join(
TableNote,
`${TableNoteGroupPermission}.${FieldNameNoteGroupPermission.noteId}`,
`${TableNote}.${FieldNameNote.id}`,
);
let query = this.applyCommonQuery(queryBase);
query = query.andWhere(
`${TableNoteGroupPermission}.${FieldNameNoteGroupPermission.groupId}`,
everyoneGroupId,
);
query = this.applyFiltersToQuery(query, noteType, search);
query = this.applySortingToQuery(query, sortBy);
query = this.applyPaginationToQuery(query, page);
const results = (await query) as QueryResult[];
return this.transformQueryResultIntoDtos(results);
}
async getMyNoteExploreEntries(
userId: number,
page: number,
noteType?: NoteType | '',
sortBy?: OptionalSortMode,
search?: string,
): Promise<NoteExploreEntryDto[]> {
const queryBase = this.knex(TableNote);
let query = this.applyCommonQuery(queryBase);
query = query.andWhere(`${TableNote}.${FieldNameNote.ownerId}`, userId);
query = this.applyFiltersToQuery(query, noteType, search);
query = this.applySortingToQuery(query, sortBy);
query = this.applyPaginationToQuery(query, page);
const results = (await query) as QueryResult[];
return this.transformQueryResultIntoDtos(results);
}
async getSharedWithMeExploreEntries(
userId: number,
page: number,
noteType?: NoteType | '',
sortBy?: OptionalSortMode,
search?: string,
): Promise<NoteExploreEntryDto[]> {
const queryBase = this.knex(TableNoteUserPermission).join(
TableNote,
`${TableNoteUserPermission}.${FieldNameNoteUserPermission.noteId}`,
`${TableNote}.${FieldNameNote.id}`,
);
let query = this.applyCommonQuery(queryBase);
query = query.andWhere(
`${TableNoteUserPermission}.${FieldNameNoteUserPermission.userId}`,
userId,
);
query = this.applyFiltersToQuery(query, noteType, search);
query = this.applySortingToQuery(query, sortBy);
query = this.applyPaginationToQuery(query, page);
const results = (await query) as QueryResult[];
return this.transformQueryResultIntoDtos(results);
}
async getMyPinnedNoteExploreEntries(
userId: number,
): Promise<NoteExploreEntryDto[]> {
const queryBase = this.knex(TableUserPinnedNote).join(
TableNote,
`${TableUserPinnedNote}.${FieldNameUserPinnedNote.noteId}`,
`${TableNote}.${FieldNameNote.id}`,
);
let query = this.applyCommonQuery(queryBase);
query = query.andWhere(
`${TableUserPinnedNote}.${FieldNameUserPinnedNote.userId}`,
userId,
);
query = this.applySortingToQuery(query, SortMode.UPDATED_AT_DESC);
const results = (await query) as QueryResult[];
return this.transformQueryResultIntoDtos(results);
}
private transformQueryResultIntoDtos(
results: QueryResult[],
): NoteExploreEntryDto[] {
const resultsWithTagList = results.reduce((map, result) => {
const existing = map.get(result.primaryAlias);
if (!existing) {
map.set(result.primaryAlias, {
primaryAlias: result.primaryAlias,
title: result.title,
noteType: result.noteType,
ownerUsername: result.ownerUsername,
lastChangedAt: result.lastChangedAt,
revisionUuid: result.revisionUuid,
tags: result.tag ? [result.tag] : [],
});
} else if (result.tag) {
existing.tags.push(result.tag);
}
return map;
}, new Map<string, QueryResultWithTagList>());
return Array.from(resultsWithTagList.values()).map((result) =>
NoteExploreEntryDto.create({
primaryAlias: result.primaryAlias,
title: result.title,
type: result.noteType,
tags: result.tags,
owner: result.ownerUsername,
lastChangedAt: DateTime.fromSQL(result.lastChangedAt, {
zone: 'UTC',
}).toISO(),
}),
);
}
// The correct return type with all joins and selects is very specific and should just be inferred from Knex
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
private applyCommonQuery(query: Knex.QueryBuilder) {
return query
.select({
primaryAlias: `${TableAlias}.${FieldNameAlias.alias}`,
title: `${TableRevision}.${FieldNameRevision.title}`,
noteType: `${TableRevision}.${FieldNameRevision.noteType}`,
ownerUsername: `${TableUser}.${FieldNameUser.username}`,
lastChangedAt: `${TableRevision}.${FieldNameRevision.createdAt}`,
revisionUuid: `${TableRevision}.${FieldNameRevision.uuid}`,
tag: `${TableRevisionTag}.${FieldNameRevisionTag.tag}`,
})
.join(
TableAlias,
`${TableAlias}.${FieldNameAlias.noteId}`,
`${TableNote}.${FieldNameNote.id}`,
)
.join(
TableUser,
`${TableUser}.${FieldNameUser.id}`,
`${TableNote}.${FieldNameNote.ownerId}`,
)
.join(
this.knex(TableRevision)
.select(`${FieldNameRevision.uuid}`, `${FieldNameRevision.noteId}`)
.max(`${FieldNameRevision.createdAt}`)
.groupBy(`${FieldNameRevision.noteId}`)
.as('latest_revision'),
function () {
this.on(
`latest_revision.${FieldNameRevision.noteId}`,
`${TableNote}.${FieldNameNote.id}`,
);
},
)
.join(TableRevision, function () {
this.on(
`${TableRevision}.${FieldNameRevision.noteId}`,
`${TableNote}.${FieldNameNote.id}`,
).andOn(
`${TableRevision}.${FieldNameRevision.uuid}`,
`latest_revision.${FieldNameRevision.uuid}`,
);
})
.leftJoin(
TableRevisionTag,
`${TableRevisionTag}.${FieldNameRevisionTag.revisionUuid}`,
`${TableRevision}.${FieldNameRevision.uuid}`,
)
.where(`${TableAlias}.${FieldNameAlias.isPrimary}`, true);
}
private applyFiltersToQuery<T extends Knex.QueryBuilder>(
query: T,
noteType?: NoteType | '',
search?: string,
): T {
let filteredQuery = query;
if (noteType) {
filteredQuery = filteredQuery.andWhere(
`${TableRevision}.${FieldNameRevision.noteType}`,
noteType,
) as T;
}
if (search) {
const searchLowercase = search.toLowerCase();
filteredQuery = filteredQuery.andWhereRaw(
'(LOWER(??) LIKE ? OR LOWER(??) LIKE ?)',
[
`${TableRevision}.${FieldNameRevision.title}`,
`%${searchLowercase}%`,
`${TableRevisionTag}.${FieldNameRevisionTag.tag}`,
`%${searchLowercase}%`,
],
) as T;
}
return filteredQuery;
}
private applyPaginationToQuery<T extends Knex.QueryBuilder>(
query: T,
page: number,
): T {
return query
.limit(ENTRIES_PER_PAGE_LIMIT)
.offset((page - 1) * ENTRIES_PER_PAGE_LIMIT) as T;
}
private applySortingToQuery<T extends Knex.QueryBuilder>(
query: T,
sortBy?: OptionalSortMode,
): T {
switch (sortBy) {
case SortMode.TITLE_ASC:
return query.orderBy(
`${TableRevision}.${FieldNameRevision.title}`,
'asc',
) as T;
case SortMode.TITLE_DESC:
return query.orderBy(
`${TableRevision}.${FieldNameRevision.title}`,
'desc',
) as T;
case SortMode.UPDATED_AT_ASC:
return query.orderBy(
`${TableRevision}.${FieldNameRevision.createdAt}`,
'asc',
) as T;
default:
case SortMode.UPDATED_AT_DESC:
return query.orderBy(
`${TableRevision}.${FieldNameRevision.createdAt}`,
'desc',
) as T;
}
}
}
+1
View File
@@ -7,3 +7,4 @@ export * from './note.dto.js'
export * from './note.media-deletion.dto.js'
export * from './note-metadata.dto.js'
export * from './note-metadata-update.dto.js'
export * from './note-explore-entry.dto.js'
@@ -0,0 +1,26 @@
/*
* SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { z } from 'zod'
import { NoteType } from '../../note-frontmatter/index.js'
export const NoteExploreEntrySchema = z
.object({
primaryAlias: z.string().describe('The primary alias of the note'),
title: z.string().describe('The title of the note'),
type: z
.nativeEnum(NoteType)
.describe('The type of the note (document or slide)'),
tags: z.array(z.string()).describe('The tags of the note'),
owner: z.string().nullable().describe('The owner of the note'),
lastChangedAt: z
.string()
.datetime()
.describe('The last time the note was changed'),
})
.describe('DTO for a note entry in the explore page')
export type NoteExploreEntryInterface = z.infer<typeof NoteExploreEntrySchema>
+6
View File
@@ -0,0 +1,6 @@
/*
* SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
export * from './sort-mode.enum.js'
@@ -0,0 +1,13 @@
/*
* SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
export enum SortMode {
TITLE_ASC = 'title_asc',
TITLE_DESC = 'title_desc',
UPDATED_AT_ASC = 'updated_at_asc',
UPDATED_AT_DESC = 'updated_at_desc',
}
export type OptionalSortMode = SortMode | ''
+1
View File
@@ -4,6 +4,7 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
export * from './dtos/index.js'
export * from './explore-page/index.js'
export * from './frontmatter-extractor/index.js'
export * from './message-transporters/index.js'
export * from './note-frontmatter/index.js'