Skip to content

Commit 083acad

Browse files
HMarzbancursoragent
andcommitted
feat(documents): add Favorites to the owner list
Owner-only mark stored as DocumentFavorite. Soft-delete keeps the join so restore stays favorited; purge drops it. Closes #173 Co-authored-by: Cursor <[email protected]>
1 parent ab96cf8 commit 083acad

18 files changed

Lines changed: 535 additions & 84 deletions

File tree

‎CONTEXT.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@ Shared names for docs.plus domain concepts. Architecture reviews and deepenings
1111
- **HeadingScale** — visual size by rank inside a section. Distinct from Block style. The same heading level can look larger or smaller depending on the section.
1212
- **Heading slug trail** (`?h=`) — outline ancestry from the target heading up through each parent to Title. Not a join of every heading before the target. `id=` is the resolver.
1313

14+
## Documents list
15+
16+
- **Favorite** — a per-user mark that pins a document to the top of Settings → Documents. Stored as `DocumentFavorite` (`userId` + `documentId`). Owner-only. Soft-delete keeps the row; purge cascade drops it. Not **Bookmark** (chat messages and the hyperlink picker) and not **Pin** (channel messages).
17+
1418
## Document access
1519

1620
- **Private** — only the owner may open the document (REST slug + WS room sealed).
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
-- Soft-delete keeps the join so restore stays favorited. Purge cascade drops it.
2+
-- documentId leads the primary key so the metadata FK cascade is indexed.
3+
CREATE TABLE "DocumentFavorite" (
4+
"userId" TEXT NOT NULL,
5+
"documentId" TEXT NOT NULL,
6+
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
7+
8+
CONSTRAINT "DocumentFavorite_pkey" PRIMARY KEY ("documentId","userId")
9+
);
10+
11+
CREATE INDEX "DocumentFavorite_userId_idx" ON "DocumentFavorite"("userId");
12+
13+
ALTER TABLE "DocumentFavorite"
14+
ADD CONSTRAINT "DocumentFavorite_documentId_fkey"
15+
FOREIGN KEY ("documentId") REFERENCES "DocumentMetadata"("documentId")
16+
ON DELETE CASCADE ON UPDATE CASCADE;

‎apps/hocuspocus.server/prisma/schema.prisma‎

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -30,22 +30,23 @@ model Documents {
3030
}
3131

3232
model DocumentMetadata {
33-
id Int @id @default(autoincrement())
34-
slug String @unique
33+
id Int @id @default(autoincrement())
34+
slug String @unique
3535
title String?
3636
description String?
3737
keywords String?
38-
isPrivate Boolean @default(false)
39-
createdAt DateTime @default(now())
40-
updatedAt DateTime @updatedAt
41-
documentId String @unique
42-
readOnly Boolean @default(false)
38+
isPrivate Boolean @default(false)
39+
createdAt DateTime @default(now())
40+
updatedAt DateTime @updatedAt
41+
documentId String @unique
42+
readOnly Boolean @default(false)
4343
ownerId String?
4444
email String?
4545
deletedAt DateTime? // Soft-delete tombstone; the reaper purges the footprint after retention.
4646
4747
documents Documents[]
4848
clientAuthors DocumentClientAuthor[]
49+
favorites DocumentFavorite[]
4950
5051
@@index([ownerId])
5152
@@index([deletedAt])
@@ -84,6 +85,17 @@ model DocumentClientAuthor {
8485
@@id([documentId, clientId])
8586
}
8687

88+
// Soft-delete keeps the join so restore stays favorited. Purge cascade drops it.
89+
model DocumentFavorite {
90+
userId String
91+
documentId String
92+
createdAt DateTime @default(now())
93+
metadata DocumentMetadata @relation(fields: [documentId], references: [documentId], onDelete: Cascade)
94+
95+
@@id([documentId, userId])
96+
@@index([userId])
97+
}
98+
8799
// ============================================================================
88100
// Idempotency Tables
89101
// Prevent duplicate sends on worker retries

‎apps/hocuspocus.server/src/api/controllers/documents.controller.ts‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { createDocumentWithContent } from '../../modules/document-content'
77
import type {
88
CreateDocumentInput,
99
DocumentQueryInput,
10+
SetDocumentFavoriteInput,
1011
TrashPurgeInput,
1112
TrashRestoreInput,
1213
UpdateDocumentMetadataInput
@@ -318,6 +319,36 @@ export const restoreTrash = async (c: AppContext): Promise<Response> => {
318319
}
319320
}
320321

322+
export const setDocumentFavorite = async (c: AppContext): Promise<Response> => {
323+
const prisma = c.get('prisma')
324+
const documentId = c.req.param('documentId')
325+
if (documentId === undefined) return c.json({ error: 'Missing document id' }, 400)
326+
const requesterId = c.get('userId') as string
327+
const { favorite } = getValidJson<SetDocumentFavoriteInput>(c)
328+
329+
try {
330+
const result = await documentsService.setDocumentFavorite(
331+
prisma,
332+
documentId,
333+
requesterId,
334+
favorite
335+
)
336+
if (result.status === 'forbidden') return forbiddenResponse(c)
337+
if (result.status === 'not-found') {
338+
return c.json(
339+
{ success: false, error: { code: 'NOT_FOUND', message: 'Document not found' } },
340+
404
341+
)
342+
}
343+
return c.json({
344+
success: true,
345+
data: { documentId: result.documentId, isFavorite: result.isFavorite }
346+
})
347+
} catch (error) {
348+
return handleError(c, error, { documentId })
349+
}
350+
}
351+
321352
export const duplicateDocument = async (c: AppContext): Promise<Response> => {
322353
const prisma = c.get('prisma')
323354
const documentId = c.req.param('documentId')

‎apps/hocuspocus.server/src/api/routers/documents.router.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { contentBodyLimit } from '../../modules/document-content'
66
import {
77
createDocumentSchema,
88
documentQuerySchema,
9+
setDocumentFavoriteSchema,
910
trashPurgeSchema,
1011
trashRestoreSchema,
1112
updateDocumentMetadataSchema,
@@ -76,4 +77,11 @@ documents.post('/:documentId/restore', requireUser, documentsController.restoreD
7677

7778
documents.post('/:documentId/duplicate', requireUser, documentsController.duplicateDocument)
7879

80+
documents.put(
81+
'/:documentId/favorite',
82+
requireUser,
83+
zValidator('json', setDocumentFavoriteSchema, houseEnvelopeHook),
84+
documentsController.setDocumentFavorite
85+
)
86+
7987
export default documents

‎apps/hocuspocus.server/src/api/services/documents.service.ts‎

Lines changed: 100 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,34 @@ const SORT_FIELD_MAP: Record<
204204
title_desc: { field: 'title', dir: 'desc' }
205205
}
206206

207+
const LIST_METADATA_SELECT = {
208+
id: true,
209+
slug: true,
210+
title: true,
211+
description: true,
212+
documentId: true,
213+
keywords: true,
214+
ownerId: true,
215+
readOnly: true,
216+
isPrivate: true,
217+
createdAt: true,
218+
updatedAt: true,
219+
deletedAt: true
220+
} as const
221+
222+
/** Owner live lists pin this user's favorites first. Trash and the fleet skip it. */
223+
function buildDocumentsOrderBy(args: {
224+
deleted?: boolean
225+
sort?: SearchDocumentsParams['sort']
226+
favoritesFirst?: boolean
227+
}) {
228+
const { field, dir } =
229+
SORT_FIELD_MAP[args.sort ?? 'updatedAt_desc'] ?? SORT_FIELD_MAP.updatedAt_desc
230+
const sortOrder = args.deleted ? { deletedAt: 'desc' as const } : { [field]: dir }
231+
if (!args.favoritesFirst) return sortOrder
232+
return [{ favorites: { _count: 'desc' as const } }, sortOrder]
233+
}
234+
207235
export const searchDocuments = async (prisma: PrismaClient, params: SearchDocumentsParams) => {
208236
const {
209237
title,
@@ -229,11 +257,6 @@ export const searchDocuments = async (prisma: PrismaClient, params: SearchDocume
229257
let docs
230258
let total
231259

232-
const { field: orderField, dir: orderDir } =
233-
SORT_FIELD_MAP[sort ?? 'updatedAt_desc'] ?? SORT_FIELD_MAP.updatedAt_desc
234-
// Trash is always newest-tombstone-first; the sort allowlist governs live lists only.
235-
const orderBy = deleted ? { deletedAt: 'desc' as const } : { [orderField]: orderDir }
236-
237260
// Live lists hide soft-deleted rows until the reaper purges them; the Trash
238261
// view inverts that to show only the caller's tombstoned docs.
239262
const deletedWhere = deleted ? { deletedAt: { not: null } } : { deletedAt: null }
@@ -247,6 +270,15 @@ export const searchDocuments = async (prisma: PrismaClient, params: SearchDocume
247270
// private rows. Owner-scoped calls (ownerId === token.sub) are unaffected.
248271
const privacyWhere = !requesterId || !ownerId ? { isPrivate: false } : {}
249272

273+
const favoritesFirst = Boolean(requesterId && ownerId && requesterId === ownerId && !deleted)
274+
const orderBy = buildDocumentsOrderBy({ deleted, sort, favoritesFirst })
275+
const listSelect = favoritesFirst
276+
? {
277+
...LIST_METADATA_SELECT,
278+
favorites: { where: { userId: requesterId }, select: { userId: true } }
279+
}
280+
: LIST_METADATA_SELECT
281+
250282
if (title || reqKeywords || description) {
251283
// to_tsquery (the `search` clauses) throws a 500 on operator punctuation
252284
// like `C++` or `foo)`. Reduce each token to bare word characters, so bad
@@ -277,20 +309,7 @@ export const searchDocuments = async (prisma: PrismaClient, params: SearchDocume
277309
skip: offset,
278310
take: limit,
279311
where: searchWhere,
280-
select: {
281-
id: true,
282-
slug: true,
283-
title: true,
284-
description: true,
285-
documentId: true,
286-
keywords: true,
287-
ownerId: true,
288-
readOnly: true,
289-
isPrivate: true,
290-
createdAt: true,
291-
updatedAt: true,
292-
deletedAt: true
293-
},
312+
select: listSelect,
294313
orderBy
295314
}),
296315
prisma.documentMetadata.count({ where: searchWhere })
@@ -303,43 +322,36 @@ export const searchDocuments = async (prisma: PrismaClient, params: SearchDocume
303322
skip: offset,
304323
take: limit,
305324
where: listWhere,
306-
select: {
307-
id: true,
308-
slug: true,
309-
title: true,
310-
description: true,
311-
documentId: true,
312-
keywords: true,
313-
ownerId: true,
314-
readOnly: true,
315-
isPrivate: true,
316-
createdAt: true,
317-
updatedAt: true,
318-
deletedAt: true
319-
},
325+
select: listSelect,
320326
orderBy
321327
}),
322328
prisma.documentMetadata.count({ where: listWhere })
323329
])
324330
}
325331

326-
const formattedDocs = docs.map((doc: any) => ({
327-
...doc,
328-
keywords: doc.keywords
329-
? doc.keywords
330-
.split(',')
331-
.map((k: string) => k.trim())
332-
.filter(Boolean)
333-
: []
334-
}))
332+
const formattedDocs = docs.map((doc) => {
333+
const { favorites, keywords, ...rest } = doc as typeof doc & {
334+
favorites?: { userId: string }[]
335+
}
336+
return {
337+
...rest,
338+
...(favoritesFirst ? { isFavorite: (favorites?.length ?? 0) > 0 } : {}),
339+
keywords: keywords
340+
? keywords
341+
.split(',')
342+
.map((k) => k.trim())
343+
.filter(Boolean)
344+
: []
345+
}
346+
})
335347

336-
const ownerIds = formattedDocs.filter((doc: any) => doc.ownerId).map((doc: any) => doc.ownerId!)
348+
const ownerIds = formattedDocs.filter((doc) => doc.ownerId).map((doc) => doc.ownerId!)
337349
const ownerProfiles = await getOwnerProfiles(ownerIds)
338350

339-
const docsWithOwners = formattedDocs.map((doc: any) => {
351+
const docsWithOwners = formattedDocs.map((doc) => {
340352
if (!doc.ownerId) return doc
341353

342-
const ownerProfile = ownerProfiles.find((profile: any) => profile.id === doc.ownerId)
354+
const ownerProfile = ownerProfiles.find((profile) => profile.id === doc.ownerId)
343355
if (!ownerProfile) return doc
344356

345357
// snake_case mirrors `public.users` so the FE consumes the same
@@ -538,6 +550,49 @@ export const softDeleteDocument = (
538550
export const restoreDocument = (prisma: PrismaClient, documentId: string, requesterId?: string) =>
539551
setDeletedAt(prisma, documentId, requesterId, null)
540552

553+
export type SetFavoriteResult =
554+
| { status: 'ok'; documentId: string; isFavorite: boolean }
555+
| { status: 'forbidden' }
556+
| { status: 'not-found' }
557+
558+
export const setDocumentFavorite = async (
559+
prisma: PrismaClient,
560+
documentId: string,
561+
requesterId: string | undefined,
562+
favorite: boolean
563+
): Promise<SetFavoriteResult> => {
564+
if (!documentId || documentId.trim().length === 0) {
565+
throw new ValidationError('Document ID is required and cannot be empty')
566+
}
567+
568+
const existing = await prisma.documentMetadata.findUnique({
569+
where: { documentId },
570+
select: { ownerId: true, deletedAt: true }
571+
})
572+
573+
if (!existing || existing.deletedAt) return { status: 'not-found' }
574+
if (!requesterId || !isDocumentOwner(existing, requesterId)) return { status: 'forbidden' }
575+
576+
try {
577+
if (favorite) {
578+
await prisma.documentFavorite.upsert({
579+
where: { documentId_userId: { documentId, userId: requesterId } },
580+
create: { documentId, userId: requesterId },
581+
update: {}
582+
})
583+
} else {
584+
await prisma.documentFavorite.deleteMany({
585+
where: { documentId, userId: requesterId }
586+
})
587+
}
588+
} catch (error) {
589+
documentsServiceLogger.error({ err: error, documentId }, 'Error setting document favorite')
590+
throw handlePrismaError(error)
591+
}
592+
593+
return { status: 'ok', documentId, isFavorite: favorite }
594+
}
595+
541596
export type DuplicateDocumentResult =
542597
| { status: 'forbidden' }
543598
| { status: 'not-found' }

‎apps/hocuspocus.server/src/modules/openapi/domain/components.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -150,7 +150,7 @@ const pushHealth: JsonSchema = {
150150
const documentSummary: JsonSchema = {
151151
type: 'object',
152152
description:
153-
'Document metadata row. Includes at least `documentId`, `slug`, `title`, `description`, `keywords`, `readOnly`, `isPrivate`, `createdAt`, `updatedAt`, plus the joined snake_case owner profile.',
153+
'Document metadata row. Includes at least `documentId`, `slug`, `title`, `description`, `keywords`, `readOnly`, `isPrivate`, `createdAt`, `updatedAt`, plus the joined snake_case owner profile. Owner-scoped live lists also send `isFavorite`.',
154154
additionalProperties: true
155155
}
156156

0 commit comments

Comments
 (0)