@jant/core 0.3.35 → 0.3.37
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/commands/export.js +1 -1
- package/bin/commands/import-site.js +529 -0
- package/bin/commands/reset-password.js +3 -2
- package/dist/client/assets/heic-to-DIRPI3VF.js +1 -0
- package/dist/client/assets/module-RjUF93sV.js +716 -0
- package/dist/client/assets/native-48B9X9Wg.js +1 -0
- package/dist/client/assets/url-FWFqPJPb.js +1 -0
- package/dist/client/client.css +1 -1
- package/dist/client/client.js +4564 -3013
- package/dist/index.js +12885 -8161
- package/package.json +23 -6
- package/src/__tests__/helpers/app.ts +10 -10
- package/src/__tests__/helpers/db.ts +91 -87
- package/src/app.tsx +157 -31
- package/src/auth.ts +20 -2
- package/src/client/archive-nav.js +187 -0
- package/src/client/audio-player.ts +478 -0
- package/src/client/audio-processor.ts +84 -0
- package/src/{lib → client}/avatar-upload.ts +4 -3
- package/src/{lib → client}/collection-form-bridge.ts +2 -2
- package/src/{ui → client}/components/__tests__/jant-collection-form.test.ts +26 -9
- package/src/client/components/__tests__/jant-compose-dialog.test.ts +1140 -0
- package/src/client/components/__tests__/jant-compose-editor.test.ts +504 -0
- package/src/{ui → client}/components/__tests__/jant-post-form.test.ts +37 -17
- package/src/{ui → client}/components/__tests__/jant-settings-avatar.test.ts +2 -2
- package/src/{ui → client}/components/__tests__/jant-settings-general.test.ts +3 -3
- package/src/client/components/collection-sidebar-types.ts +43 -0
- package/src/{ui → client}/components/collection-types.ts +3 -4
- package/src/client/components/compose-types.ts +174 -0
- package/src/client/components/jant-collection-form.ts +667 -0
- package/src/client/components/jant-collection-sidebar.ts +805 -0
- package/src/client/components/jant-compose-dialog.ts +2161 -0
- package/src/client/components/jant-compose-editor.ts +1813 -0
- package/src/client/components/jant-compose-fullscreen.ts +283 -0
- package/src/client/components/jant-media-lightbox.ts +259 -0
- package/src/{ui → client}/components/jant-nav-manager.ts +97 -298
- package/src/{ui → client}/components/jant-post-form.ts +141 -12
- package/src/client/components/jant-post-menu.ts +1019 -0
- package/src/{ui → client}/components/jant-settings-avatar.ts +3 -3
- package/src/{ui → client}/components/jant-settings-general.ts +38 -4
- package/src/client/components/jant-text-preview.ts +232 -0
- package/src/{ui → client}/components/nav-manager-types.ts +6 -18
- package/src/{ui → client}/components/post-form-template.ts +137 -38
- package/src/{ui → client}/components/post-form-types.ts +15 -4
- package/src/client/compose-bridge.ts +583 -0
- package/src/{lib → client}/image-processor.ts +26 -8
- package/src/client/lazy-slugify.ts +51 -0
- package/src/client/media-metadata.ts +247 -0
- package/src/client/multipart-upload.ts +160 -0
- package/src/{lib → client}/nav-manager-bridge.ts +1 -1
- package/src/{lib → client}/post-form-bridge.ts +53 -2
- package/src/{lib → client}/settings-bridge.ts +3 -15
- package/src/client/thread-context.ts +140 -0
- package/src/client/tiptap/bubble-menu.ts +205 -0
- package/src/client/tiptap/create-editor.ts +86 -0
- package/src/client/tiptap/exitable-marks.ts +73 -0
- package/src/client/tiptap/extensions.ts +65 -0
- package/src/client/tiptap/image-node.ts +482 -0
- package/src/client/tiptap/link-toolbar.ts +371 -0
- package/src/client/tiptap/more-break.ts +50 -0
- package/src/client/tiptap/paste-image.ts +129 -0
- package/src/client/tiptap/slash-commands.ts +438 -0
- package/src/{lib → client}/toast.ts +101 -3
- package/src/client/types/sortablejs.d.ts +44 -0
- package/src/client/upload-with-metadata.ts +54 -0
- package/src/client/video-processor.ts +207 -0
- package/src/client.ts +27 -17
- package/src/db/__tests__/migrations.test.ts +118 -0
- package/src/db/index.ts +52 -0
- package/src/db/migrations/0000_baseline.sql +269 -0
- package/src/db/migrations/0001_fts_setup.sql +31 -0
- package/src/db/migrations/meta/0000_snapshot.json +703 -119
- package/src/db/migrations/meta/0001_snapshot.json +1337 -0
- package/src/db/migrations/meta/_journal.json +4 -39
- package/src/db/schema.ts +409 -140
- package/src/i18n/__tests__/detect.test.ts +115 -0
- package/src/i18n/context.tsx +2 -2
- package/src/i18n/detect.ts +85 -1
- package/src/i18n/i18n.ts +1 -1
- package/src/i18n/index.ts +2 -1
- package/src/i18n/locales/en.po +783 -1087
- package/src/i18n/locales/en.ts +1 -1
- package/src/i18n/locales/zh-Hans.po +867 -812
- package/src/i18n/locales/zh-Hans.ts +1 -1
- package/src/i18n/locales/zh-Hant.po +878 -823
- package/src/i18n/locales/zh-Hant.ts +1 -1
- package/src/i18n/middleware.ts +6 -0
- package/src/index.ts +5 -7
- package/src/lib/__tests__/blurhash-placeholder.test.ts +75 -0
- package/src/lib/__tests__/constants.test.ts +0 -1
- package/src/lib/__tests__/markdown-to-tiptap.test.ts +358 -0
- package/src/lib/__tests__/nanoid.test.ts +26 -0
- package/src/lib/__tests__/resolve-config.test.ts +2 -2
- package/src/lib/__tests__/schemas.test.ts +186 -65
- package/src/lib/__tests__/slug.test.ts +126 -0
- package/src/lib/__tests__/sse.test.ts +6 -6
- package/src/lib/__tests__/summary.test.ts +264 -0
- package/src/lib/__tests__/theme.test.ts +1 -1
- package/src/lib/__tests__/timeline.test.ts +33 -30
- package/src/lib/__tests__/tiptap-to-markdown.test.ts +346 -0
- package/src/lib/__tests__/url.test.ts +2 -2
- package/src/lib/__tests__/view.test.ts +140 -65
- package/src/lib/blurhash-placeholder.ts +102 -0
- package/src/lib/constants.ts +3 -1
- package/src/lib/emoji-catalog.ts +963 -0
- package/src/lib/errors.ts +11 -8
- package/src/lib/feed.ts +77 -31
- package/src/lib/html.ts +2 -1
- package/src/lib/icon-catalog.ts +5033 -1
- package/src/lib/icons.ts +3 -2
- package/src/lib/index.ts +0 -1
- package/src/lib/markdown-to-tiptap.ts +286 -0
- package/src/lib/media-helpers.ts +22 -12
- package/src/lib/nanoid.ts +29 -0
- package/src/lib/navigation.ts +1 -1
- package/src/lib/render.tsx +24 -5
- package/src/lib/resolve-config.ts +13 -2
- package/src/lib/schemas.ts +226 -58
- package/src/lib/search-snippet.ts +34 -0
- package/src/lib/slug.ts +96 -0
- package/src/lib/sse.ts +6 -6
- package/src/lib/storage.ts +115 -7
- package/src/lib/summary.ts +158 -0
- package/src/lib/theme.ts +11 -8
- package/src/lib/timeline.ts +76 -34
- package/src/lib/tiptap-render.ts +191 -0
- package/src/lib/tiptap-to-markdown.ts +305 -0
- package/src/lib/upload.ts +263 -14
- package/src/lib/url.ts +37 -22
- package/src/lib/view.ts +236 -55
- package/src/middleware/__tests__/auth.test.ts +191 -11
- package/src/middleware/__tests__/onboarding.test.ts +12 -10
- package/src/middleware/auth.ts +63 -9
- package/src/middleware/error-handler.ts +3 -3
- package/src/middleware/onboarding.ts +1 -1
- package/src/middleware/secure-headers.ts +40 -0
- package/src/preset.css +83 -2
- package/src/routes/__tests__/compose.test.ts +17 -24
- package/src/routes/api/__tests__/collections.test.ts +109 -61
- package/src/routes/api/__tests__/nav-items.test.ts +46 -29
- package/src/routes/api/__tests__/posts.test.ts +132 -68
- package/src/routes/api/__tests__/search.test.ts +15 -2
- package/src/routes/api/__tests__/upload-multipart.test.ts +534 -0
- package/src/routes/api/collections.ts +57 -31
- package/src/routes/api/custom-urls.ts +80 -0
- package/src/routes/api/export.ts +31 -0
- package/src/routes/api/nav-items.ts +23 -19
- package/src/routes/api/posts.ts +81 -62
- package/src/routes/api/search.ts +3 -4
- package/src/routes/api/upload-multipart.ts +245 -0
- package/src/routes/api/upload.ts +92 -24
- package/src/routes/auth/__tests__/setup.test.ts +20 -60
- package/src/routes/auth/reset.tsx +5 -4
- package/src/routes/auth/setup.tsx +39 -31
- package/src/routes/auth/signin.tsx +13 -14
- package/src/routes/compose.tsx +27 -63
- package/src/routes/dash/__tests__/settings-avatar.test.ts +44 -9
- package/src/routes/dash/custom-urls.tsx +414 -0
- package/src/routes/dash/settings.tsx +475 -99
- package/src/routes/feed/__tests__/rss.test.ts +22 -23
- package/src/routes/feed/rss.ts +6 -2
- package/src/routes/feed/sitemap.ts +2 -12
- package/src/routes/pages/__tests__/collections.test.ts +5 -6
- package/src/routes/pages/__tests__/featured.test.ts +36 -18
- package/src/routes/pages/archive.tsx +177 -37
- package/src/routes/pages/collection.tsx +43 -14
- package/src/routes/pages/collections.tsx +11 -2
- package/src/routes/pages/featured.tsx +27 -3
- package/src/routes/pages/home.tsx +15 -14
- package/src/routes/pages/latest.tsx +1 -11
- package/src/routes/pages/new.tsx +39 -0
- package/src/routes/pages/page.tsx +95 -49
- package/src/routes/pages/search.tsx +1 -1
- package/src/services/__tests__/api-token.test.ts +135 -0
- package/src/services/__tests__/collection.test.ts +275 -227
- package/src/services/__tests__/custom-url.test.ts +213 -0
- package/src/services/__tests__/media.test.ts +162 -22
- package/src/services/__tests__/navigation.test.ts +109 -68
- package/src/services/__tests__/post-timeline.test.ts +205 -32
- package/src/services/__tests__/post.test.ts +800 -230
- package/src/services/__tests__/search.test.ts +67 -10
- package/src/services/__tests__/settings.test.ts +3 -3
- package/src/services/api-token.ts +166 -0
- package/src/services/auth.ts +17 -2
- package/src/services/collection.ts +397 -131
- package/src/services/custom-url.ts +188 -0
- package/src/services/export.ts +802 -0
- package/src/services/index.ts +26 -19
- package/src/services/media.ts +100 -22
- package/src/services/navigation.ts +158 -47
- package/src/services/path.ts +339 -0
- package/src/services/post.ts +764 -172
- package/src/services/search.ts +161 -74
- package/src/services/settings.ts +6 -2
- package/src/styles/components.css +293 -62
- package/src/styles/tokens.css +93 -5
- package/src/styles/ui.css +4349 -766
- package/src/types/bindings.ts +8 -0
- package/src/types/config.ts +34 -4
- package/src/types/constants.ts +17 -2
- package/src/types/entities.ts +83 -37
- package/src/types/operations.ts +20 -27
- package/src/types/props.ts +52 -17
- package/src/types/views.ts +48 -24
- package/src/ui/color-themes.ts +133 -23
- package/src/ui/compose/ComposeDialog.tsx +255 -16
- package/src/ui/compose/ComposePrompt.tsx +1 -1
- package/src/ui/dash/CrudPageHeader.tsx +1 -1
- package/src/ui/dash/ListItemRow.tsx +1 -1
- package/src/ui/dash/StatusBadge.tsx +12 -2
- package/src/ui/dash/appearance/AdvancedContent.tsx +71 -59
- package/src/ui/dash/appearance/ColorThemeContent.tsx +48 -33
- package/src/ui/dash/appearance/FontThemeContent.tsx +65 -73
- package/src/ui/dash/appearance/NavigationContent.tsx +106 -135
- package/src/ui/dash/index.ts +0 -3
- package/src/ui/dash/settings/AccountContent.tsx +87 -146
- package/src/ui/dash/settings/AccountMenuContent.tsx +147 -0
- package/src/ui/dash/settings/ApiTokensContent.tsx +232 -0
- package/src/ui/dash/settings/AvatarContent.tsx +78 -0
- package/src/ui/dash/settings/GeneralContent.tsx +3 -62
- package/src/ui/dash/settings/SessionsContent.tsx +159 -0
- package/src/ui/dash/settings/SettingsRootContent.tsx +266 -0
- package/src/ui/feed/LinkCard.tsx +89 -40
- package/src/ui/feed/NoteCard.tsx +39 -25
- package/src/ui/feed/PostStatusBadges.tsx +67 -0
- package/src/ui/feed/QuoteCard.tsx +38 -23
- package/src/ui/feed/ThreadPreview.tsx +90 -26
- package/src/ui/feed/TimelineFeed.tsx +3 -2
- package/src/ui/feed/TimelineItem.tsx +15 -6
- package/src/ui/feed/__tests__/thread-preview.test.ts +107 -0
- package/src/ui/feed/thread-preview-state.ts +61 -0
- package/src/ui/font-themes.ts +2 -2
- package/src/ui/layouts/BaseLayout.tsx +2 -2
- package/src/ui/layouts/SiteLayout.tsx +116 -103
- package/src/ui/pages/ArchivePage.tsx +923 -95
- package/src/ui/pages/CollectionPage.tsx +6 -35
- package/src/ui/pages/CollectionsPage.tsx +2 -1
- package/src/ui/pages/ComposePage.tsx +54 -0
- package/src/ui/pages/FeaturedPage.tsx +2 -1
- package/src/ui/pages/HomePage.tsx +1 -1
- package/src/ui/pages/PostPage.tsx +30 -45
- package/src/ui/pages/SearchPage.tsx +182 -38
- package/src/ui/shared/AdminBreadcrumb.tsx +29 -0
- package/src/ui/shared/CollectionsSidebar.tsx +239 -4
- package/src/ui/shared/MediaGallery.tsx +475 -41
- package/src/ui/shared/PostFooter.tsx +204 -0
- package/src/ui/shared/StarRating.tsx +27 -0
- package/src/ui/shared/__tests__/format-chars.test.ts +35 -0
- package/src/ui/shared/index.ts +0 -1
- package/src/db/migrations/0000_square_wallflower.sql +0 -118
- package/src/db/migrations/0001_add_search_fts.sql +0 -34
- package/src/db/migrations/0002_add_media_attachments.sql +0 -3
- package/src/db/migrations/0003_add_navigation_links.sql +0 -8
- package/src/db/migrations/0004_add_storage_provider.sql +0 -3
- package/src/db/migrations/0005_v2_schema_migration.sql +0 -268
- package/src/db/migrations/0006_rename_slug_to_path.sql +0 -5
- package/src/db/migrations/0007_post_collections_m2m.sql +0 -94
- package/src/db/migrations/0008_add_collection_dividers.sql +0 -8
- package/src/db/migrations/0009_drop_collection_show_divider.sql +0 -2
- package/src/db/migrations/0010_add_performance_indexes.sql +0 -16
- package/src/db/migrations/0011_add_path_registry.sql +0 -23
- package/src/db/migrations/meta/0003_snapshot.json +0 -821
- package/src/lib/__tests__/sqid.test.ts +0 -65
- package/src/lib/collections-reorder.ts +0 -28
- package/src/lib/compose-bridge.ts +0 -280
- package/src/lib/media-upload.ts +0 -148
- package/src/lib/sqid.ts +0 -79
- package/src/routes/api/__tests__/pages.test.ts +0 -218
- package/src/routes/api/pages.ts +0 -73
- package/src/routes/dash/__tests__/pages.test.ts +0 -226
- package/src/routes/dash/appearance.tsx +0 -240
- package/src/routes/dash/collections.tsx +0 -211
- package/src/routes/dash/index.tsx +0 -103
- package/src/routes/dash/media.tsx +0 -132
- package/src/routes/dash/pages.tsx +0 -239
- package/src/routes/dash/posts.tsx +0 -334
- package/src/routes/dash/redirects.tsx +0 -257
- package/src/routes/pages/post.tsx +0 -59
- package/src/services/__tests__/page.test.ts +0 -298
- package/src/services/__tests__/path-registry.test.ts +0 -165
- package/src/services/__tests__/redirect.test.ts +0 -159
- package/src/services/page.ts +0 -203
- package/src/services/path-registry.ts +0 -160
- package/src/services/redirect.ts +0 -97
- package/src/types/sortablejs.d.ts +0 -29
- package/src/ui/components/__tests__/jant-compose-dialog.test.ts +0 -512
- package/src/ui/components/__tests__/jant-compose-editor.test.ts +0 -272
- package/src/ui/components/compose-types.ts +0 -75
- package/src/ui/components/jant-collection-form.ts +0 -512
- package/src/ui/components/jant-compose-dialog.ts +0 -495
- package/src/ui/components/jant-compose-editor.ts +0 -814
- package/src/ui/dash/PageForm.tsx +0 -185
- package/src/ui/dash/PostList.tsx +0 -95
- package/src/ui/dash/appearance/AppearanceNav.tsx +0 -60
- package/src/ui/dash/collections/CollectionForm.tsx +0 -166
- package/src/ui/dash/collections/CollectionsListContent.tsx +0 -146
- package/src/ui/dash/collections/IconPickerGrid.tsx +0 -50
- package/src/ui/dash/collections/ViewCollectionContent.tsx +0 -103
- package/src/ui/dash/media/MediaListContent.tsx +0 -201
- package/src/ui/dash/media/ViewMediaContent.tsx +0 -208
- package/src/ui/dash/pages/PagesContent.tsx +0 -74
- package/src/ui/dash/posts/PostForm.tsx +0 -248
- package/src/ui/dash/settings/SettingsNav.tsx +0 -52
- package/src/ui/layouts/DashLayout.tsx +0 -165
- package/src/ui/pages/SinglePage.tsx +0 -23
- package/src/ui/shared/ThreadView.tsx +0 -136
- /package/src/{ui → client}/components/settings-types.ts +0 -0
package/src/services/post.ts
CHANGED
|
@@ -3,20 +3,38 @@
|
|
|
3
3
|
*
|
|
4
4
|
* CRUD operations for posts with Thread support.
|
|
5
5
|
* Posts have format (note/link/quote), status (draft/published),
|
|
6
|
-
*
|
|
6
|
+
* visibility (public/unlisted/private), featuredAt, and pinnedAt timestamp.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import { eq, and, isNull, desc,
|
|
9
|
+
import { eq, and, isNull, desc, inArray, sql, isNotNull } from "drizzle-orm";
|
|
10
10
|
import type { BatchItem } from "drizzle-orm/batch";
|
|
11
|
-
import
|
|
12
|
-
import {
|
|
11
|
+
import { uuidv7 } from "uuidv7";
|
|
12
|
+
import { type Database, batchQueryRows } from "../db/index.js";
|
|
13
|
+
import { pathRegistry, posts, postCollections } from "../db/schema.js";
|
|
13
14
|
import { now } from "../lib/time.js";
|
|
14
|
-
import {
|
|
15
|
+
import { renderTiptapJson } from "../lib/tiptap-render.js";
|
|
16
|
+
import { extractSummary, extractBodyText } from "../lib/summary.js";
|
|
17
|
+
import { markdownToTiptapJson } from "../lib/markdown-to-tiptap.js";
|
|
18
|
+
import { generatePostSlug } from "../lib/slug.js";
|
|
19
|
+
import { normalizePath, slugify } from "../lib/url.js";
|
|
15
20
|
import type { StorageDriver } from "../lib/storage.js";
|
|
16
21
|
import type { MediaService } from "./media.js";
|
|
17
|
-
import type {
|
|
18
|
-
|
|
19
|
-
|
|
22
|
+
import type {
|
|
23
|
+
Format,
|
|
24
|
+
Status,
|
|
25
|
+
Visibility,
|
|
26
|
+
MediaKind,
|
|
27
|
+
Post,
|
|
28
|
+
CreatePost,
|
|
29
|
+
UpdatePost,
|
|
30
|
+
ThreadTimelineContext,
|
|
31
|
+
} from "../types.js";
|
|
32
|
+
import {
|
|
33
|
+
ConflictError,
|
|
34
|
+
ValidationError,
|
|
35
|
+
NotFoundError,
|
|
36
|
+
} from "../lib/errors.js";
|
|
37
|
+
import { createPathService, type PathService } from "./path.js";
|
|
20
38
|
|
|
21
39
|
/** Dependencies for operations that coordinate with other services */
|
|
22
40
|
export interface PostDeleteDeps {
|
|
@@ -27,26 +45,51 @@ export interface PostDeleteDeps {
|
|
|
27
45
|
export interface PostFilters {
|
|
28
46
|
format?: Format;
|
|
29
47
|
status?: Status;
|
|
30
|
-
|
|
48
|
+
visibility?: Visibility;
|
|
31
49
|
pinned?: boolean;
|
|
32
|
-
|
|
33
|
-
|
|
50
|
+
featured?: boolean;
|
|
51
|
+
collectionId?: string;
|
|
52
|
+
/** Exclude posts that are replies (have replyToId set) */
|
|
34
53
|
excludeReplies?: boolean;
|
|
54
|
+
/** Exclude unlisted posts from results */
|
|
55
|
+
excludeUnlisted?: boolean;
|
|
56
|
+
/** Exclude private posts from results */
|
|
57
|
+
excludePrivate?: boolean;
|
|
35
58
|
includeDeleted?: boolean;
|
|
36
|
-
threadId?:
|
|
59
|
+
threadId?: string;
|
|
60
|
+
/** Unix timestamp (inclusive) — only posts published at or after this time */
|
|
61
|
+
publishedAfter?: number;
|
|
62
|
+
/** Unix timestamp (exclusive) — only posts published before this time */
|
|
63
|
+
publishedBefore?: number;
|
|
64
|
+
/** Media kinds to filter by (OR logic: post has media of ANY selected kind). */
|
|
65
|
+
mediaKinds?: MediaKind[];
|
|
66
|
+
/** Filter by media presence */
|
|
67
|
+
hasMedia?: boolean;
|
|
68
|
+
/** Filter by title presence */
|
|
69
|
+
hasTitle?: boolean;
|
|
37
70
|
limit?: number;
|
|
38
|
-
cursor?:
|
|
71
|
+
cursor?: string; // post id for cursor pagination (UUIDv7 sorts chronologically)
|
|
39
72
|
offset?: number; // offset for page-based pagination
|
|
40
73
|
}
|
|
41
74
|
|
|
75
|
+
/** Config for automatic summary extraction */
|
|
76
|
+
export interface SummaryConfig {
|
|
77
|
+
maxParagraphs: number;
|
|
78
|
+
maxChars: number;
|
|
79
|
+
}
|
|
80
|
+
|
|
42
81
|
export interface PostService {
|
|
43
|
-
getById(id:
|
|
44
|
-
|
|
82
|
+
getById(id: string): Promise<Post | null>;
|
|
83
|
+
getBySlug(slug: string): Promise<Post | null>;
|
|
45
84
|
list(filters?: PostFilters): Promise<Post[]>;
|
|
46
85
|
/** Count posts matching filters (ignores cursor, offset, limit) */
|
|
47
86
|
count(filters?: PostFilters): Promise<number>;
|
|
48
|
-
create(data: CreatePost): Promise<Post>;
|
|
49
|
-
update(
|
|
87
|
+
create(data: CreatePost, summaryConfig?: SummaryConfig): Promise<Post>;
|
|
88
|
+
update(
|
|
89
|
+
id: string,
|
|
90
|
+
data: UpdatePost,
|
|
91
|
+
summaryConfig?: SummaryConfig,
|
|
92
|
+
): Promise<Post | null>;
|
|
50
93
|
/**
|
|
51
94
|
* Soft-delete a post and clean up its media (storage files + DB records).
|
|
52
95
|
* Thread roots cascade to all replies.
|
|
@@ -54,32 +97,151 @@ export interface PostService {
|
|
|
54
97
|
* @param id - Post ID
|
|
55
98
|
* @param deps - Media service and optional storage driver for file cleanup
|
|
56
99
|
*/
|
|
57
|
-
delete(id:
|
|
58
|
-
getThread(rootId:
|
|
59
|
-
|
|
60
|
-
rootId:
|
|
100
|
+
delete(id: string, deps?: PostDeleteDeps): Promise<boolean>;
|
|
101
|
+
getThread(rootId: string): Promise<Post[]>;
|
|
102
|
+
updateThreadStatusAndVisibility(
|
|
103
|
+
rootId: string,
|
|
61
104
|
status: Status,
|
|
62
|
-
|
|
105
|
+
visibility: Visibility,
|
|
63
106
|
): Promise<void>;
|
|
64
107
|
/** Get reply counts for multiple posts */
|
|
65
|
-
getReplyCounts(postIds:
|
|
108
|
+
getReplyCounts(postIds: string[]): Promise<Map<string, number>>;
|
|
66
109
|
/** Get preview replies for multiple thread roots */
|
|
67
110
|
getThreadPreviews(
|
|
68
|
-
rootIds:
|
|
111
|
+
rootIds: string[],
|
|
69
112
|
previewCount?: number,
|
|
70
|
-
): Promise<Map<
|
|
113
|
+
): Promise<Map<string, Post[]>>;
|
|
114
|
+
/** Get latest-reply context for multiple thread roots (for timeline display) */
|
|
115
|
+
getThreadTimelineContext(
|
|
116
|
+
rootIds: string[],
|
|
117
|
+
): Promise<Map<string, ThreadTimelineContext>>;
|
|
118
|
+
/** Get distinct years that have published posts */
|
|
119
|
+
getDistinctYears(filters?: PostFilters): Promise<number[]>;
|
|
120
|
+
/** For each thread ID, return the ID of the last published, non-deleted post */
|
|
121
|
+
getLastPostIdsByThread(threadIds: string[]): Promise<Map<string, string>>;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const SLUG_RE = /^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$/;
|
|
125
|
+
|
|
126
|
+
function isValidSlug(value: string): boolean {
|
|
127
|
+
return SLUG_RE.test(value);
|
|
71
128
|
}
|
|
72
129
|
|
|
73
|
-
/** Check if an error is a SQLite UNIQUE constraint violation
|
|
130
|
+
/** Check if an error (or any of its causes) is a SQLite UNIQUE constraint violation */
|
|
74
131
|
function isUniqueConstraintError(err: unknown): boolean {
|
|
75
|
-
|
|
76
|
-
|
|
132
|
+
let current: unknown = err;
|
|
133
|
+
while (current) {
|
|
134
|
+
const msg = String(current);
|
|
135
|
+
if (
|
|
136
|
+
msg.includes("UNIQUE constraint") ||
|
|
137
|
+
msg.includes("SQLITE_CONSTRAINT")
|
|
138
|
+
) {
|
|
139
|
+
return true;
|
|
140
|
+
}
|
|
141
|
+
current =
|
|
142
|
+
current instanceof Error && current.cause !== current
|
|
143
|
+
? current.cause
|
|
144
|
+
: undefined;
|
|
145
|
+
}
|
|
146
|
+
return false;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function hasNonEmptyText(value: string | null | undefined): boolean {
|
|
150
|
+
return typeof value === "string" && value.trim().length > 0;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function assertPostFormatShape(data: {
|
|
154
|
+
format: Format;
|
|
155
|
+
url?: string | null;
|
|
156
|
+
quoteText?: string | null;
|
|
157
|
+
}): void {
|
|
158
|
+
const hasUrl = hasNonEmptyText(data.url);
|
|
159
|
+
const hasQuoteText = hasNonEmptyText(data.quoteText);
|
|
160
|
+
|
|
161
|
+
if (data.format === "note") {
|
|
162
|
+
if (hasUrl) {
|
|
163
|
+
throw new ValidationError("Notes can't include a URL.");
|
|
164
|
+
}
|
|
165
|
+
if (hasQuoteText) {
|
|
166
|
+
throw new ValidationError("Notes can't include quoted text.");
|
|
167
|
+
}
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (data.format === "link") {
|
|
172
|
+
if (!hasUrl) {
|
|
173
|
+
throw new ValidationError("Link posts need a URL.");
|
|
174
|
+
}
|
|
175
|
+
if (hasQuoteText) {
|
|
176
|
+
throw new ValidationError("Link posts can't include quoted text.");
|
|
177
|
+
}
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (!hasQuoteText) {
|
|
182
|
+
throw new ValidationError("Quote posts need quoted text.");
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function isThreadReply(post: Pick<Post, "replyToId">): boolean {
|
|
187
|
+
return post.replyToId !== null;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function assertDraftPublishedAt(
|
|
191
|
+
status: Status,
|
|
192
|
+
publishedAt: number | undefined,
|
|
193
|
+
): void {
|
|
194
|
+
if (status === "draft" && publishedAt !== undefined) {
|
|
195
|
+
throw new ValidationError("Drafts can't set a publish time.");
|
|
196
|
+
}
|
|
77
197
|
}
|
|
78
198
|
|
|
79
199
|
export function createPostService(
|
|
80
200
|
db: Database,
|
|
81
|
-
|
|
201
|
+
config: { slugIdLength: number },
|
|
202
|
+
paths: PathService = createPathService(db),
|
|
82
203
|
): PostService {
|
|
204
|
+
const effectiveVisibilityExpr = sql<string>`coalesce(
|
|
205
|
+
${posts.visibility},
|
|
206
|
+
(SELECT root.visibility FROM post AS root WHERE root.id = ${posts.threadId})
|
|
207
|
+
)`;
|
|
208
|
+
|
|
209
|
+
/** Check if a slug is available (not used by posts or custom_urls) */
|
|
210
|
+
async function isSlugAvailable(slug: string): Promise<boolean> {
|
|
211
|
+
return paths.isPathAvailable(slug);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
async function pathExists(path: string): Promise<boolean> {
|
|
215
|
+
const rows = await db
|
|
216
|
+
.select({ id: pathRegistry.id })
|
|
217
|
+
.from(pathRegistry)
|
|
218
|
+
.where(eq(pathRegistry.path, normalizePath(path)))
|
|
219
|
+
.limit(1);
|
|
220
|
+
return rows.length > 0;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
async function recalculateThreadLastActivity(rootId: string): Promise<void> {
|
|
224
|
+
const rootRows = await db
|
|
225
|
+
.select({
|
|
226
|
+
latestPublishedAt: sql<number | null>`MAX(${posts.publishedAt})`.as(
|
|
227
|
+
"latest_published_at",
|
|
228
|
+
),
|
|
229
|
+
})
|
|
230
|
+
.from(posts)
|
|
231
|
+
.where(and(eq(posts.threadId, rootId), isNull(posts.deletedAt)));
|
|
232
|
+
|
|
233
|
+
const latestPublishedAt = rootRows[0]?.latestPublishedAt ?? null;
|
|
234
|
+
const root = await db
|
|
235
|
+
.select({ updatedAt: posts.updatedAt })
|
|
236
|
+
.from(posts)
|
|
237
|
+
.where(eq(posts.id, rootId))
|
|
238
|
+
.limit(1);
|
|
239
|
+
|
|
240
|
+
const lastActivityAt = latestPublishedAt ?? root[0]?.updatedAt ?? now();
|
|
241
|
+
|
|
242
|
+
await db.update(posts).set({ lastActivityAt }).where(eq(posts.id, rootId));
|
|
243
|
+
}
|
|
244
|
+
|
|
83
245
|
/** Build WHERE conditions from filters (shared by list and count) */
|
|
84
246
|
function buildFilterConditions(filters: PostFilters) {
|
|
85
247
|
const conditions = [];
|
|
@@ -87,11 +249,28 @@ export function createPostService(
|
|
|
87
249
|
if (filters.status) {
|
|
88
250
|
conditions.push(eq(posts.status, filters.status));
|
|
89
251
|
}
|
|
90
|
-
if (filters.
|
|
91
|
-
conditions.push(
|
|
252
|
+
if (filters.visibility !== undefined) {
|
|
253
|
+
conditions.push(sql`${effectiveVisibilityExpr} = ${filters.visibility}`);
|
|
254
|
+
}
|
|
255
|
+
if (filters.excludeUnlisted) {
|
|
256
|
+
conditions.push(sql`${effectiveVisibilityExpr} != 'unlisted'`);
|
|
257
|
+
}
|
|
258
|
+
if (filters.excludePrivate) {
|
|
259
|
+
conditions.push(sql`${effectiveVisibilityExpr} != 'private'`);
|
|
92
260
|
}
|
|
93
261
|
if (filters.pinned !== undefined) {
|
|
94
|
-
conditions.push(
|
|
262
|
+
conditions.push(
|
|
263
|
+
filters.pinned
|
|
264
|
+
? sql`${posts.pinnedAt} IS NOT NULL`
|
|
265
|
+
: isNull(posts.pinnedAt),
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
if (filters.featured !== undefined) {
|
|
269
|
+
conditions.push(
|
|
270
|
+
filters.featured
|
|
271
|
+
? sql`${posts.featuredAt} IS NOT NULL`
|
|
272
|
+
: isNull(posts.featuredAt),
|
|
273
|
+
);
|
|
95
274
|
}
|
|
96
275
|
if (filters.format) {
|
|
97
276
|
conditions.push(eq(posts.format, filters.format));
|
|
@@ -99,45 +278,136 @@ export function createPostService(
|
|
|
99
278
|
if (filters.collectionId !== undefined) {
|
|
100
279
|
// Filter by collection via junction table
|
|
101
280
|
conditions.push(
|
|
102
|
-
sql`${posts.id} IN (SELECT post_id FROM
|
|
281
|
+
sql`${posts.id} IN (SELECT post_id FROM post_collection WHERE collection_id = ${filters.collectionId})`,
|
|
103
282
|
);
|
|
104
283
|
}
|
|
105
284
|
if (filters.threadId) {
|
|
106
285
|
conditions.push(eq(posts.threadId, filters.threadId));
|
|
107
286
|
}
|
|
108
287
|
if (filters.excludeReplies) {
|
|
109
|
-
conditions.push(isNull(posts.
|
|
288
|
+
conditions.push(isNull(posts.replyToId));
|
|
110
289
|
}
|
|
111
290
|
if (!filters.includeDeleted) {
|
|
112
291
|
conditions.push(isNull(posts.deletedAt));
|
|
113
292
|
}
|
|
293
|
+
if (filters.publishedAfter !== undefined) {
|
|
294
|
+
conditions.push(sql`${posts.publishedAt} >= ${filters.publishedAfter}`);
|
|
295
|
+
}
|
|
296
|
+
if (filters.publishedBefore !== undefined) {
|
|
297
|
+
conditions.push(sql`${posts.publishedAt} < ${filters.publishedBefore}`);
|
|
298
|
+
}
|
|
299
|
+
if (filters.mediaKinds && filters.mediaKinds.length > 0) {
|
|
300
|
+
const placeholders = filters.mediaKinds.map((k) => sql`${k}`);
|
|
301
|
+
conditions.push(
|
|
302
|
+
sql`${posts.id} IN (SELECT post_id FROM media WHERE media_kind IN (${sql.join(placeholders, sql`, `)}))`,
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
if (filters.hasMedia !== undefined) {
|
|
306
|
+
if (filters.hasMedia) {
|
|
307
|
+
conditions.push(sql`${posts.id} IN (SELECT post_id FROM media)`);
|
|
308
|
+
} else {
|
|
309
|
+
conditions.push(sql`${posts.id} NOT IN (SELECT post_id FROM media)`);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
if (filters.hasTitle !== undefined) {
|
|
313
|
+
if (filters.hasTitle) {
|
|
314
|
+
conditions.push(
|
|
315
|
+
sql`${posts.title} IS NOT NULL AND ${posts.title} != ''`,
|
|
316
|
+
);
|
|
317
|
+
} else {
|
|
318
|
+
conditions.push(sql`(${posts.title} IS NULL OR ${posts.title} = '')`);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
114
321
|
|
|
115
322
|
return conditions;
|
|
116
323
|
}
|
|
117
324
|
|
|
118
|
-
function toPost(
|
|
325
|
+
function toPost(
|
|
326
|
+
row: typeof posts.$inferSelect,
|
|
327
|
+
slug: string,
|
|
328
|
+
visibility: Visibility,
|
|
329
|
+
): Post {
|
|
119
330
|
return {
|
|
120
331
|
id: row.id,
|
|
121
332
|
format: row.format as Format,
|
|
122
333
|
status: row.status as Status,
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
334
|
+
visibility,
|
|
335
|
+
pinnedAt: row.pinnedAt,
|
|
336
|
+
featuredAt: row.featuredAt,
|
|
337
|
+
slug,
|
|
126
338
|
title: row.title,
|
|
127
339
|
url: row.url,
|
|
128
340
|
body: row.body,
|
|
129
341
|
bodyHtml: row.bodyHtml,
|
|
342
|
+
bodyText: row.bodyText,
|
|
130
343
|
quoteText: row.quoteText,
|
|
344
|
+
summary: row.summary,
|
|
131
345
|
rating: row.rating,
|
|
132
346
|
replyToId: row.replyToId,
|
|
133
347
|
threadId: row.threadId,
|
|
134
348
|
deletedAt: row.deletedAt,
|
|
135
349
|
publishedAt: row.publishedAt,
|
|
350
|
+
lastActivityAt: row.lastActivityAt ?? row.publishedAt ?? row.updatedAt,
|
|
136
351
|
createdAt: row.createdAt,
|
|
137
352
|
updatedAt: row.updatedAt,
|
|
138
353
|
};
|
|
139
354
|
}
|
|
140
355
|
|
|
356
|
+
async function hydratePost(
|
|
357
|
+
row: typeof posts.$inferSelect | undefined,
|
|
358
|
+
): Promise<Post | null> {
|
|
359
|
+
if (!row) return null;
|
|
360
|
+
const slug = await paths.getPostSlug(row.id);
|
|
361
|
+
if (!slug) return null;
|
|
362
|
+
const rootVisibilityMap = await getThreadVisibilityMap([row.threadId]);
|
|
363
|
+
const visibility = rootVisibilityMap.get(row.threadId) ?? row.visibility;
|
|
364
|
+
if (!visibility) return null;
|
|
365
|
+
return toPost(row, slug, visibility as Visibility);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
async function hydratePosts(
|
|
369
|
+
rows: (typeof posts.$inferSelect)[],
|
|
370
|
+
): Promise<Post[]> {
|
|
371
|
+
if (rows.length === 0) return [];
|
|
372
|
+
const slugMap = await paths.getPostSlugMap(rows.map((row) => row.id));
|
|
373
|
+
const rootVisibilityMap = await getThreadVisibilityMap(
|
|
374
|
+
rows.map((row) => row.threadId),
|
|
375
|
+
);
|
|
376
|
+
return rows
|
|
377
|
+
.map((row) => {
|
|
378
|
+
const slug = slugMap.get(row.id);
|
|
379
|
+
const visibility =
|
|
380
|
+
rootVisibilityMap.get(row.threadId) ?? row.visibility;
|
|
381
|
+
return slug && visibility
|
|
382
|
+
? toPost(row, slug, visibility as Visibility)
|
|
383
|
+
: null;
|
|
384
|
+
})
|
|
385
|
+
.filter((row): row is Post => row !== null);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
async function getThreadVisibilityMap(
|
|
389
|
+
threadIds: string[],
|
|
390
|
+
): Promise<Map<string, Visibility>> {
|
|
391
|
+
const uniqueThreadIds = [...new Set(threadIds)];
|
|
392
|
+
const result = new Map<string, Visibility>();
|
|
393
|
+
if (uniqueThreadIds.length === 0) return result;
|
|
394
|
+
|
|
395
|
+
const rows = await batchQueryRows(uniqueThreadIds, (chunk) =>
|
|
396
|
+
db
|
|
397
|
+
.select({ id: posts.id, visibility: posts.visibility })
|
|
398
|
+
.from(posts)
|
|
399
|
+
.where(inArray(posts.id, chunk)),
|
|
400
|
+
);
|
|
401
|
+
|
|
402
|
+
for (const row of rows) {
|
|
403
|
+
if (row.visibility) {
|
|
404
|
+
result.set(row.id, row.visibility as Visibility);
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
return result;
|
|
409
|
+
}
|
|
410
|
+
|
|
141
411
|
return {
|
|
142
412
|
async getById(id) {
|
|
143
413
|
const result = await db
|
|
@@ -145,20 +415,28 @@ export function createPostService(
|
|
|
145
415
|
.from(posts)
|
|
146
416
|
.where(and(eq(posts.id, id), isNull(posts.deletedAt)))
|
|
147
417
|
.limit(1);
|
|
148
|
-
return
|
|
418
|
+
return hydratePost(result[0]);
|
|
149
419
|
},
|
|
150
420
|
|
|
151
|
-
async
|
|
152
|
-
const
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
return result[0] ? toPost(result[0]) : null;
|
|
421
|
+
async getBySlug(slug) {
|
|
422
|
+
const resolved = await paths.resolve(slug);
|
|
423
|
+
if (!resolved || resolved.kind !== "slug" || !resolved.postId) {
|
|
424
|
+
return null;
|
|
425
|
+
}
|
|
426
|
+
return this.getById(resolved.postId);
|
|
158
427
|
},
|
|
159
428
|
|
|
160
429
|
async list(filters = {}) {
|
|
161
430
|
const conditions = buildFilterConditions(filters);
|
|
431
|
+
const sortTimestamp =
|
|
432
|
+
filters.status === "draft"
|
|
433
|
+
? posts.updatedAt
|
|
434
|
+
: filters.status === "published"
|
|
435
|
+
? posts.lastActivityAt
|
|
436
|
+
: sql<number>`CASE
|
|
437
|
+
WHEN ${posts.status} = 'draft' THEN ${posts.updatedAt}
|
|
438
|
+
ELSE ${posts.lastActivityAt}
|
|
439
|
+
END`;
|
|
162
440
|
|
|
163
441
|
if (filters.cursor) {
|
|
164
442
|
conditions.push(sql`${posts.id} < ${filters.cursor}`);
|
|
@@ -168,7 +446,11 @@ export function createPostService(
|
|
|
168
446
|
.select()
|
|
169
447
|
.from(posts)
|
|
170
448
|
.where(conditions.length > 0 ? and(...conditions) : undefined)
|
|
171
|
-
.orderBy(
|
|
449
|
+
.orderBy(
|
|
450
|
+
desc(posts.pinnedAt),
|
|
451
|
+
filters.featured ? desc(posts.featuredAt) : desc(sortTimestamp),
|
|
452
|
+
desc(posts.id),
|
|
453
|
+
)
|
|
172
454
|
.limit(filters.limit ?? 100);
|
|
173
455
|
|
|
174
456
|
if (filters.offset !== undefined) {
|
|
@@ -176,7 +458,7 @@ export function createPostService(
|
|
|
176
458
|
}
|
|
177
459
|
|
|
178
460
|
const rows = await query;
|
|
179
|
-
return rows
|
|
461
|
+
return hydratePosts(rows);
|
|
180
462
|
},
|
|
181
463
|
|
|
182
464
|
async count(filters = {}) {
|
|
@@ -190,144 +472,331 @@ export function createPostService(
|
|
|
190
472
|
return result[0]?.count ?? 0;
|
|
191
473
|
},
|
|
192
474
|
|
|
193
|
-
async create(data) {
|
|
475
|
+
async create(data, summaryConfig) {
|
|
476
|
+
const id = uuidv7();
|
|
194
477
|
const timestamp = now();
|
|
195
478
|
|
|
196
|
-
|
|
479
|
+
assertPostFormatShape({
|
|
480
|
+
format: data.format,
|
|
481
|
+
url: data.url,
|
|
482
|
+
quoteText: data.quoteText,
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
const body = data.bodyMarkdown
|
|
486
|
+
? markdownToTiptapJson(data.bodyMarkdown)
|
|
487
|
+
: (data.body ?? null);
|
|
488
|
+
const bodyHtml = body ? renderTiptapJson(body) : null;
|
|
489
|
+
const bodyText = body ? extractBodyText(body) : null;
|
|
490
|
+
|
|
491
|
+
// Generate summary for titled notes with body content
|
|
492
|
+
let summary: string | null = null;
|
|
493
|
+
if (data.format === "note" && data.title && body && summaryConfig) {
|
|
494
|
+
summary = extractSummary(
|
|
495
|
+
body,
|
|
496
|
+
summaryConfig.maxParagraphs,
|
|
497
|
+
summaryConfig.maxChars,
|
|
498
|
+
);
|
|
499
|
+
}
|
|
197
500
|
|
|
198
501
|
// Handle thread relationship
|
|
199
|
-
let threadId
|
|
502
|
+
let threadId = id;
|
|
200
503
|
let status: Status = data.status ?? "published";
|
|
201
|
-
let
|
|
504
|
+
let visibility: Visibility | null = data.visibility ?? "public";
|
|
202
505
|
|
|
203
506
|
if (data.replyToId) {
|
|
204
507
|
const parent = await this.getById(data.replyToId);
|
|
205
|
-
if (parent) {
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
508
|
+
if (!parent) {
|
|
509
|
+
throw new NotFoundError("Parent post");
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
if (data.pinned) {
|
|
513
|
+
throw new ConflictError(
|
|
514
|
+
"Cannot pin a thread reply. Pin the root post instead.",
|
|
515
|
+
);
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
threadId = parent.threadId;
|
|
519
|
+
|
|
520
|
+
// Replies inherit visibility from the root at read time.
|
|
521
|
+
const root =
|
|
522
|
+
parent.threadId === parent.id
|
|
523
|
+
? parent
|
|
524
|
+
: await this.getById(parent.threadId);
|
|
525
|
+
if (root) {
|
|
526
|
+
if (data.status !== "draft") {
|
|
212
527
|
status = root.status as Status;
|
|
213
|
-
featured = root.featured === 1;
|
|
214
528
|
}
|
|
215
529
|
}
|
|
530
|
+
visibility = null;
|
|
216
531
|
}
|
|
217
532
|
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
533
|
+
assertDraftPublishedAt(status, data.publishedAt);
|
|
534
|
+
const publishedAt =
|
|
535
|
+
status === "published" ? (data.publishedAt ?? timestamp) : null;
|
|
536
|
+
|
|
537
|
+
// Resolve slug from slug, path, or title
|
|
538
|
+
let slug: string;
|
|
539
|
+
let aliasPath: string | null = null;
|
|
540
|
+
|
|
221
541
|
if (data.path) {
|
|
222
|
-
|
|
542
|
+
const normalized = normalizePath(data.path);
|
|
543
|
+
if (isValidSlug(normalized)) {
|
|
544
|
+
// Path is a valid slug — use it directly
|
|
545
|
+
slug = await generatePostSlug({
|
|
546
|
+
slug: normalized,
|
|
547
|
+
idLength: config.slugIdLength,
|
|
548
|
+
isAvailable: isSlugAvailable,
|
|
549
|
+
});
|
|
550
|
+
} else {
|
|
551
|
+
// Path is not a valid slug — slugify it for the slug, keep original as alias
|
|
552
|
+
const slugified = slugify(normalized);
|
|
553
|
+
slug = await generatePostSlug({
|
|
554
|
+
slug: slugified || undefined,
|
|
555
|
+
title: data.title,
|
|
556
|
+
idLength: config.slugIdLength,
|
|
557
|
+
isAvailable: isSlugAvailable,
|
|
558
|
+
});
|
|
559
|
+
// Verify the alias path is available before proceeding
|
|
560
|
+
if (!(await paths.isPathAvailable(normalized))) {
|
|
561
|
+
throw new ConflictError(`Path "${normalized}" is already in use`);
|
|
562
|
+
}
|
|
563
|
+
aliasPath = normalized;
|
|
564
|
+
}
|
|
565
|
+
} else {
|
|
566
|
+
slug = await generatePostSlug({
|
|
567
|
+
slug: data.slug,
|
|
568
|
+
title: data.title,
|
|
569
|
+
idLength: config.slugIdLength,
|
|
570
|
+
isAvailable: isSlugAvailable,
|
|
571
|
+
});
|
|
223
572
|
}
|
|
224
573
|
|
|
225
|
-
|
|
574
|
+
const collectionIds = [...new Set(data.collectionIds ?? [])];
|
|
575
|
+
|
|
226
576
|
try {
|
|
227
|
-
|
|
228
|
-
.insert(posts)
|
|
229
|
-
|
|
577
|
+
const writeQueries: BatchItem<"sqlite">[] = [
|
|
578
|
+
db.insert(posts).values({
|
|
579
|
+
id,
|
|
230
580
|
format: data.format,
|
|
231
581
|
status,
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
582
|
+
visibility,
|
|
583
|
+
pinnedAt: data.pinned ? timestamp : null,
|
|
584
|
+
featuredAt: data.featured ? timestamp : null,
|
|
235
585
|
title: data.title ?? null,
|
|
236
586
|
url: data.url ?? null,
|
|
237
|
-
body:
|
|
587
|
+
body: body ?? null,
|
|
238
588
|
bodyHtml,
|
|
589
|
+
bodyText,
|
|
239
590
|
quoteText: data.quoteText ?? null,
|
|
591
|
+
summary,
|
|
240
592
|
rating: data.rating ?? null,
|
|
241
593
|
replyToId: data.replyToId ?? null,
|
|
242
594
|
threadId,
|
|
243
|
-
publishedAt
|
|
595
|
+
publishedAt,
|
|
596
|
+
lastActivityAt: publishedAt ?? timestamp,
|
|
244
597
|
createdAt: timestamp,
|
|
245
598
|
updatedAt: timestamp,
|
|
246
|
-
})
|
|
247
|
-
.
|
|
599
|
+
}),
|
|
600
|
+
db.insert(pathRegistry).values({
|
|
601
|
+
id: uuidv7(),
|
|
602
|
+
path: normalizePath(slug),
|
|
603
|
+
kind: "slug",
|
|
604
|
+
postId: id,
|
|
605
|
+
collectionId: null,
|
|
606
|
+
redirectToPath: null,
|
|
607
|
+
redirectType: null,
|
|
608
|
+
createdAt: timestamp,
|
|
609
|
+
updatedAt: timestamp,
|
|
610
|
+
}),
|
|
611
|
+
];
|
|
612
|
+
|
|
613
|
+
if (aliasPath) {
|
|
614
|
+
writeQueries.push(
|
|
615
|
+
db.insert(pathRegistry).values({
|
|
616
|
+
id: uuidv7(),
|
|
617
|
+
path: normalizePath(aliasPath),
|
|
618
|
+
kind: "alias",
|
|
619
|
+
postId: id,
|
|
620
|
+
collectionId: null,
|
|
621
|
+
redirectToPath: null,
|
|
622
|
+
redirectType: null,
|
|
623
|
+
createdAt: timestamp,
|
|
624
|
+
updatedAt: timestamp,
|
|
625
|
+
}),
|
|
626
|
+
);
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
if (collectionIds.length > 0) {
|
|
630
|
+
writeQueries.push(
|
|
631
|
+
db.insert(postCollections).values(
|
|
632
|
+
collectionIds.map((collectionId) => ({
|
|
633
|
+
postId: id,
|
|
634
|
+
collectionId,
|
|
635
|
+
createdAt: timestamp,
|
|
636
|
+
})),
|
|
637
|
+
),
|
|
638
|
+
);
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
await db.batch(
|
|
642
|
+
writeQueries as [
|
|
643
|
+
(typeof writeQueries)[number],
|
|
644
|
+
...(typeof writeQueries)[number][],
|
|
645
|
+
],
|
|
646
|
+
);
|
|
248
647
|
} catch (err) {
|
|
249
|
-
if (
|
|
250
|
-
|
|
251
|
-
|
|
648
|
+
if (err instanceof ConflictError) {
|
|
649
|
+
throw new ConflictError(`Slug "${slug}" is already in use`);
|
|
650
|
+
}
|
|
651
|
+
if (isUniqueConstraintError(err) && (await pathExists(slug))) {
|
|
652
|
+
throw new ConflictError(`Slug "${slug}" is already in use`);
|
|
252
653
|
}
|
|
253
654
|
throw err;
|
|
254
655
|
}
|
|
255
656
|
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
// Update registry with actual post ID
|
|
260
|
-
if (post.path) {
|
|
261
|
-
await pathRegistry.release(post.path);
|
|
262
|
-
await pathRegistry.claim(post.path, "post", post.id);
|
|
657
|
+
const post = await this.getById(id);
|
|
658
|
+
if (!post) {
|
|
659
|
+
throw new ConflictError(`Slug "${slug}" could not be resolved`);
|
|
263
660
|
}
|
|
264
661
|
|
|
265
|
-
//
|
|
266
|
-
if (data.
|
|
267
|
-
await
|
|
268
|
-
data.collectionIds.map((collectionId) => ({
|
|
269
|
-
postId: post.id,
|
|
270
|
-
collectionId,
|
|
271
|
-
})),
|
|
272
|
-
);
|
|
662
|
+
// Bump thread root's lastActivityAt when creating a published reply
|
|
663
|
+
if (data.replyToId && status === "published") {
|
|
664
|
+
await recalculateThreadLastActivity(threadId);
|
|
273
665
|
}
|
|
274
666
|
|
|
275
667
|
return post;
|
|
276
668
|
},
|
|
277
669
|
|
|
278
|
-
async update(id, data) {
|
|
670
|
+
async update(id, data, summaryConfig) {
|
|
279
671
|
const existing = await this.getById(id);
|
|
280
672
|
if (!existing) return null;
|
|
281
673
|
|
|
282
|
-
// Handle path changes in the registry before modifying the post
|
|
283
|
-
const pathChanging =
|
|
284
|
-
data.path !== undefined && data.path !== existing.path;
|
|
285
|
-
if (pathChanging) {
|
|
286
|
-
// Claim new path (if non-null) before releasing old
|
|
287
|
-
if (data.path) {
|
|
288
|
-
await pathRegistry.claim(data.path, "post", id);
|
|
289
|
-
}
|
|
290
|
-
// Release old path (if it existed)
|
|
291
|
-
if (existing.path) {
|
|
292
|
-
await pathRegistry.release(existing.path);
|
|
293
|
-
}
|
|
294
|
-
}
|
|
295
|
-
|
|
296
674
|
const timestamp = now();
|
|
675
|
+
const nextFormat = data.format ?? existing.format;
|
|
676
|
+
const nextUrl = data.url !== undefined ? data.url : existing.url;
|
|
677
|
+
const nextQuoteText =
|
|
678
|
+
data.quoteText !== undefined ? data.quoteText : existing.quoteText;
|
|
679
|
+
const nextStatus = data.status ?? existing.status;
|
|
680
|
+
|
|
681
|
+
assertPostFormatShape({
|
|
682
|
+
format: nextFormat,
|
|
683
|
+
url: nextUrl,
|
|
684
|
+
quoteText: nextQuoteText,
|
|
685
|
+
});
|
|
686
|
+
assertDraftPublishedAt(nextStatus, data.publishedAt);
|
|
687
|
+
|
|
297
688
|
const updates: Partial<typeof posts.$inferInsert> = {
|
|
298
689
|
updatedAt: timestamp,
|
|
299
690
|
};
|
|
300
691
|
|
|
692
|
+
// Handle slug change
|
|
693
|
+
const slugChanged =
|
|
694
|
+
data.slug !== undefined && data.slug !== existing.slug;
|
|
695
|
+
if (slugChanged && data.slug) {
|
|
696
|
+
try {
|
|
697
|
+
await paths.updatePostSlug(id, data.slug);
|
|
698
|
+
} catch (err) {
|
|
699
|
+
if (err instanceof ConflictError) {
|
|
700
|
+
throw new ConflictError(`Slug "${data.slug}" is already in use`);
|
|
701
|
+
}
|
|
702
|
+
throw err;
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
|
|
301
706
|
if (data.format !== undefined) updates.format = data.format;
|
|
302
|
-
if (data.path !== undefined) updates.path = data.path;
|
|
303
707
|
if (data.title !== undefined) updates.title = data.title;
|
|
304
708
|
if (data.url !== undefined) updates.url = data.url;
|
|
305
709
|
if (data.quoteText !== undefined) updates.quoteText = data.quoteText;
|
|
306
710
|
if (data.rating !== undefined) updates.rating = data.rating;
|
|
307
|
-
if (data.
|
|
308
|
-
updates.
|
|
309
|
-
if (data.
|
|
711
|
+
if (data.pinned !== undefined)
|
|
712
|
+
updates.pinnedAt = data.pinned ? now() : null;
|
|
713
|
+
if (data.featured !== undefined)
|
|
714
|
+
updates.featuredAt = data.featured ? now() : null;
|
|
715
|
+
|
|
716
|
+
if (data.body !== undefined || data.bodyMarkdown !== undefined) {
|
|
717
|
+
const normalizedBody = data.bodyMarkdown
|
|
718
|
+
? markdownToTiptapJson(data.bodyMarkdown)
|
|
719
|
+
: (data.body ?? null);
|
|
720
|
+
updates.body = normalizedBody;
|
|
721
|
+
updates.bodyHtml = normalizedBody
|
|
722
|
+
? renderTiptapJson(normalizedBody)
|
|
723
|
+
: null;
|
|
724
|
+
updates.bodyText = normalizedBody
|
|
725
|
+
? extractBodyText(normalizedBody)
|
|
726
|
+
: null;
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
// Recompute summary when body, title, or format change
|
|
730
|
+
if (summaryConfig) {
|
|
731
|
+
const format = data.format ?? (existing.format as Format);
|
|
732
|
+
const title = data.title !== undefined ? data.title : existing.title;
|
|
733
|
+
const body =
|
|
734
|
+
data.bodyMarkdown !== undefined
|
|
735
|
+
? data.bodyMarkdown
|
|
736
|
+
? markdownToTiptapJson(data.bodyMarkdown)
|
|
737
|
+
: null
|
|
738
|
+
: data.body !== undefined
|
|
739
|
+
? data.body
|
|
740
|
+
: existing.body;
|
|
741
|
+
if (format === "note" && title && body) {
|
|
742
|
+
updates.summary = extractSummary(
|
|
743
|
+
body,
|
|
744
|
+
summaryConfig.maxParagraphs,
|
|
745
|
+
summaryConfig.maxChars,
|
|
746
|
+
);
|
|
747
|
+
} else {
|
|
748
|
+
updates.summary = null;
|
|
749
|
+
}
|
|
750
|
+
}
|
|
310
751
|
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
752
|
+
// Thread replies inherit visibility/pinned from root — reject direct changes
|
|
753
|
+
if (isThreadReply(existing)) {
|
|
754
|
+
if (data.visibility !== undefined) {
|
|
755
|
+
throw new ConflictError(
|
|
756
|
+
"Cannot change visibility of a thread reply. Update the root post instead.",
|
|
757
|
+
);
|
|
758
|
+
}
|
|
759
|
+
if (data.pinned !== undefined) {
|
|
760
|
+
throw new ConflictError(
|
|
761
|
+
"Cannot pin a thread reply. Pin the root post instead.",
|
|
762
|
+
);
|
|
763
|
+
}
|
|
314
764
|
}
|
|
315
765
|
|
|
316
|
-
// Handle status/
|
|
766
|
+
// Handle status/visibility change - cascade to thread if this is root
|
|
317
767
|
const statusChanged =
|
|
318
768
|
data.status !== undefined && data.status !== existing.status;
|
|
319
|
-
const
|
|
320
|
-
data.
|
|
321
|
-
|
|
769
|
+
const visibilityChanged =
|
|
770
|
+
data.visibility !== undefined &&
|
|
771
|
+
data.visibility !== existing.visibility;
|
|
772
|
+
const publishedAtChanged = data.publishedAt !== undefined;
|
|
773
|
+
const nextPublishedAt =
|
|
774
|
+
nextStatus === "draft"
|
|
775
|
+
? null
|
|
776
|
+
: publishedAtChanged
|
|
777
|
+
? (data.publishedAt ?? timestamp)
|
|
778
|
+
: existing.status === "draft"
|
|
779
|
+
? timestamp
|
|
780
|
+
: (existing.publishedAt ?? timestamp);
|
|
322
781
|
|
|
323
782
|
if (statusChanged) updates.status = data.status;
|
|
324
|
-
if (
|
|
783
|
+
if (visibilityChanged && !isThreadReply(existing)) {
|
|
784
|
+
updates.visibility = data.visibility;
|
|
785
|
+
}
|
|
786
|
+
if (statusChanged || publishedAtChanged || existing.status === "draft") {
|
|
787
|
+
updates.publishedAt = nextPublishedAt;
|
|
788
|
+
updates.lastActivityAt = nextPublishedAt ?? timestamp;
|
|
789
|
+
}
|
|
325
790
|
|
|
326
791
|
// Build all write queries for atomic execution via D1 batch
|
|
327
|
-
const needsCascade =
|
|
328
|
-
|
|
792
|
+
const needsCascade = statusChanged && !isThreadReply(existing);
|
|
793
|
+
const needsReplyVisibilityCleanup =
|
|
794
|
+
!isThreadReply(existing) && (statusChanged || visibilityChanged);
|
|
329
795
|
const needsCollectionSync = data.collectionIds !== undefined;
|
|
330
|
-
const
|
|
796
|
+
const needsThreadActivityRecalc =
|
|
797
|
+
statusChanged || publishedAtChanged || existing.status === "draft";
|
|
798
|
+
const hasExtraWrites =
|
|
799
|
+
needsCascade || needsReplyVisibilityCleanup || needsCollectionSync;
|
|
331
800
|
|
|
332
801
|
if (!hasExtraWrites) {
|
|
333
802
|
// Simple case: only the post update
|
|
@@ -336,7 +805,11 @@ export function createPostService(
|
|
|
336
805
|
.set(updates)
|
|
337
806
|
.where(eq(posts.id, id))
|
|
338
807
|
.returning();
|
|
339
|
-
|
|
808
|
+
if (needsThreadActivityRecalc) {
|
|
809
|
+
await recalculateThreadLastActivity(existing.threadId);
|
|
810
|
+
return this.getById(id);
|
|
811
|
+
}
|
|
812
|
+
return hydratePost(result[0]);
|
|
340
813
|
}
|
|
341
814
|
|
|
342
815
|
// Complex case: batch cascade + update + collection sync atomically
|
|
@@ -348,16 +821,23 @@ export function createPostService(
|
|
|
348
821
|
.update(posts)
|
|
349
822
|
.set({
|
|
350
823
|
status: data.status ?? (existing.status as Status),
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
? 1
|
|
357
|
-
: 0,
|
|
824
|
+
publishedAt: nextStatus === "published" ? nextPublishedAt : null,
|
|
825
|
+
lastActivityAt:
|
|
826
|
+
nextStatus === "published"
|
|
827
|
+
? (nextPublishedAt ?? timestamp)
|
|
828
|
+
: timestamp,
|
|
358
829
|
updatedAt: timestamp,
|
|
359
830
|
})
|
|
360
|
-
.where(eq(posts.threadId, id)),
|
|
831
|
+
.where(and(eq(posts.threadId, id), isNotNull(posts.replyToId))),
|
|
832
|
+
);
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
if (needsReplyVisibilityCleanup) {
|
|
836
|
+
writeQueries.push(
|
|
837
|
+
db
|
|
838
|
+
.update(posts)
|
|
839
|
+
.set({ visibility: null, updatedAt: timestamp })
|
|
840
|
+
.where(and(eq(posts.threadId, id), isNotNull(posts.replyToId))),
|
|
361
841
|
);
|
|
362
842
|
}
|
|
363
843
|
|
|
@@ -379,6 +859,7 @@ export function createPostService(
|
|
|
379
859
|
data.collectionIds!.map((collectionId) => ({
|
|
380
860
|
postId: id,
|
|
381
861
|
collectionId,
|
|
862
|
+
createdAt: now(),
|
|
382
863
|
})),
|
|
383
864
|
),
|
|
384
865
|
);
|
|
@@ -394,7 +875,11 @@ export function createPostService(
|
|
|
394
875
|
const updateResult = results[updateIdx] as
|
|
395
876
|
| (typeof posts.$inferSelect)[]
|
|
396
877
|
| undefined;
|
|
397
|
-
|
|
878
|
+
if (needsThreadActivityRecalc) {
|
|
879
|
+
await recalculateThreadLastActivity(existing.threadId);
|
|
880
|
+
return this.getById(id);
|
|
881
|
+
}
|
|
882
|
+
return hydratePost(updateResult?.[0]);
|
|
398
883
|
},
|
|
399
884
|
|
|
400
885
|
async delete(id, deps) {
|
|
@@ -403,8 +888,8 @@ export function createPostService(
|
|
|
403
888
|
|
|
404
889
|
// Clean up media for all affected posts
|
|
405
890
|
if (deps?.media) {
|
|
406
|
-
let postIds:
|
|
407
|
-
if (!existing
|
|
891
|
+
let postIds: string[];
|
|
892
|
+
if (!isThreadReply(existing)) {
|
|
408
893
|
const thread = await this.getThread(id);
|
|
409
894
|
postIds = thread.map((p) => p.id);
|
|
410
895
|
} else {
|
|
@@ -421,32 +906,21 @@ export function createPostService(
|
|
|
421
906
|
}
|
|
422
907
|
}
|
|
423
908
|
|
|
424
|
-
// Release paths from registry
|
|
425
|
-
if (!existing.threadId) {
|
|
426
|
-
// Thread root: release paths for all posts in thread
|
|
427
|
-
const thread = await this.getThread(id);
|
|
428
|
-
for (const post of thread) {
|
|
429
|
-
if (post.path) {
|
|
430
|
-
await pathRegistry.release(post.path);
|
|
431
|
-
}
|
|
432
|
-
}
|
|
433
|
-
} else if (existing.path) {
|
|
434
|
-
await pathRegistry.release(existing.path);
|
|
435
|
-
}
|
|
436
|
-
|
|
437
909
|
const timestamp = now();
|
|
438
910
|
|
|
439
911
|
// If this is a thread root, soft delete all posts in the thread
|
|
440
|
-
if (!existing
|
|
912
|
+
if (!isThreadReply(existing)) {
|
|
441
913
|
await db
|
|
442
914
|
.update(posts)
|
|
443
915
|
.set({ deletedAt: timestamp, updatedAt: timestamp })
|
|
444
|
-
.where(
|
|
916
|
+
.where(eq(posts.threadId, id));
|
|
445
917
|
} else {
|
|
918
|
+
// Soft-delete the single reply
|
|
446
919
|
await db
|
|
447
920
|
.update(posts)
|
|
448
921
|
.set({ deletedAt: timestamp, updatedAt: timestamp })
|
|
449
922
|
.where(eq(posts.id, id));
|
|
923
|
+
await recalculateThreadLastActivity(existing.threadId);
|
|
450
924
|
}
|
|
451
925
|
|
|
452
926
|
return true;
|
|
@@ -456,23 +930,37 @@ export function createPostService(
|
|
|
456
930
|
const rows = await db
|
|
457
931
|
.select()
|
|
458
932
|
.from(posts)
|
|
459
|
-
.where(
|
|
460
|
-
and(
|
|
461
|
-
or(eq(posts.id, rootId), eq(posts.threadId, rootId)),
|
|
462
|
-
isNull(posts.deletedAt),
|
|
463
|
-
),
|
|
464
|
-
)
|
|
933
|
+
.where(and(eq(posts.threadId, rootId), isNull(posts.deletedAt)))
|
|
465
934
|
.orderBy(posts.createdAt);
|
|
466
935
|
|
|
467
|
-
return rows
|
|
936
|
+
return hydratePosts(rows);
|
|
468
937
|
},
|
|
469
938
|
|
|
470
|
-
async
|
|
939
|
+
async updateThreadStatusAndVisibility(rootId, status, visibility) {
|
|
471
940
|
const timestamp = now();
|
|
472
|
-
await db
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
941
|
+
await db.batch([
|
|
942
|
+
db
|
|
943
|
+
.update(posts)
|
|
944
|
+
.set({
|
|
945
|
+
status,
|
|
946
|
+
visibility,
|
|
947
|
+
publishedAt: status === "published" ? timestamp : null,
|
|
948
|
+
lastActivityAt: timestamp,
|
|
949
|
+
updatedAt: timestamp,
|
|
950
|
+
})
|
|
951
|
+
.where(eq(posts.id, rootId)),
|
|
952
|
+
db
|
|
953
|
+
.update(posts)
|
|
954
|
+
.set({
|
|
955
|
+
status,
|
|
956
|
+
visibility: null,
|
|
957
|
+
publishedAt: status === "published" ? timestamp : null,
|
|
958
|
+
lastActivityAt: timestamp,
|
|
959
|
+
updatedAt: timestamp,
|
|
960
|
+
})
|
|
961
|
+
.where(and(eq(posts.threadId, rootId), isNotNull(posts.replyToId))),
|
|
962
|
+
]);
|
|
963
|
+
await recalculateThreadLastActivity(rootId);
|
|
476
964
|
},
|
|
477
965
|
|
|
478
966
|
async getReplyCounts(postIds) {
|
|
@@ -484,14 +972,19 @@ export function createPostService(
|
|
|
484
972
|
count: sql<number>`count(*)`.as("count"),
|
|
485
973
|
})
|
|
486
974
|
.from(posts)
|
|
487
|
-
.where(
|
|
975
|
+
.where(
|
|
976
|
+
and(
|
|
977
|
+
inArray(posts.threadId, postIds),
|
|
978
|
+
eq(posts.status, "published"),
|
|
979
|
+
isNotNull(posts.replyToId),
|
|
980
|
+
isNull(posts.deletedAt),
|
|
981
|
+
),
|
|
982
|
+
)
|
|
488
983
|
.groupBy(posts.threadId);
|
|
489
984
|
|
|
490
|
-
const counts = new Map<
|
|
985
|
+
const counts = new Map<string, number>();
|
|
491
986
|
for (const row of rows) {
|
|
492
|
-
|
|
493
|
-
counts.set(row.threadId, row.count);
|
|
494
|
-
}
|
|
987
|
+
counts.set(row.threadId, row.count);
|
|
495
988
|
}
|
|
496
989
|
return counts;
|
|
497
990
|
},
|
|
@@ -502,13 +995,18 @@ export function createPostService(
|
|
|
502
995
|
const rows = await db
|
|
503
996
|
.select()
|
|
504
997
|
.from(posts)
|
|
505
|
-
.where(
|
|
998
|
+
.where(
|
|
999
|
+
and(
|
|
1000
|
+
inArray(posts.threadId, rootIds),
|
|
1001
|
+
eq(posts.status, "published"),
|
|
1002
|
+
isNotNull(posts.replyToId),
|
|
1003
|
+
isNull(posts.deletedAt),
|
|
1004
|
+
),
|
|
1005
|
+
)
|
|
506
1006
|
.orderBy(posts.threadId, posts.createdAt);
|
|
507
1007
|
|
|
508
|
-
const result = new Map<
|
|
509
|
-
for (const
|
|
510
|
-
const post = toPost(row);
|
|
511
|
-
if (post.threadId === null) continue;
|
|
1008
|
+
const result = new Map<string, Post[]>();
|
|
1009
|
+
for (const post of await hydratePosts(rows)) {
|
|
512
1010
|
const list = result.get(post.threadId);
|
|
513
1011
|
if (list) {
|
|
514
1012
|
if (list.length < previewCount) {
|
|
@@ -520,5 +1018,99 @@ export function createPostService(
|
|
|
520
1018
|
}
|
|
521
1019
|
return result;
|
|
522
1020
|
},
|
|
1021
|
+
|
|
1022
|
+
async getThreadTimelineContext(rootIds) {
|
|
1023
|
+
if (rootIds.length === 0) return new Map();
|
|
1024
|
+
|
|
1025
|
+
// Fetch all non-deleted replies ordered by thread, newest first
|
|
1026
|
+
const rows = await db
|
|
1027
|
+
.select()
|
|
1028
|
+
.from(posts)
|
|
1029
|
+
.where(
|
|
1030
|
+
and(
|
|
1031
|
+
inArray(posts.threadId, rootIds),
|
|
1032
|
+
eq(posts.status, "published"),
|
|
1033
|
+
isNotNull(posts.replyToId),
|
|
1034
|
+
isNull(posts.deletedAt),
|
|
1035
|
+
),
|
|
1036
|
+
)
|
|
1037
|
+
.orderBy(posts.threadId, desc(posts.createdAt), desc(posts.id));
|
|
1038
|
+
|
|
1039
|
+
// Group by threadId, extract latest reply + its parent + count
|
|
1040
|
+
const grouped = new Map<string, Post[]>();
|
|
1041
|
+
for (const post of await hydratePosts(rows)) {
|
|
1042
|
+
const list = grouped.get(post.threadId);
|
|
1043
|
+
if (list) {
|
|
1044
|
+
list.push(post);
|
|
1045
|
+
} else {
|
|
1046
|
+
grouped.set(post.threadId, [post]);
|
|
1047
|
+
}
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
const result = new Map<string, ThreadTimelineContext>();
|
|
1051
|
+
for (const [threadId, replies] of grouped) {
|
|
1052
|
+
// replies are ordered newest-first; first element is the latest
|
|
1053
|
+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- grouped only contains non-empty arrays
|
|
1054
|
+
const latestReply = replies[0]!;
|
|
1055
|
+
const totalReplyCount = replies.length;
|
|
1056
|
+
|
|
1057
|
+
// Find parent of latestReply if it's not the root
|
|
1058
|
+
let parentReply: Post | null = null;
|
|
1059
|
+
if (latestReply.replyToId && latestReply.replyToId !== threadId) {
|
|
1060
|
+
parentReply =
|
|
1061
|
+
replies.find((r) => r.id === latestReply.replyToId) ?? null;
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
result.set(threadId, { latestReply, parentReply, totalReplyCount });
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
return result;
|
|
1068
|
+
},
|
|
1069
|
+
|
|
1070
|
+
async getLastPostIdsByThread(threadIds) {
|
|
1071
|
+
const result = new Map<string, string>();
|
|
1072
|
+
if (threadIds.length === 0) return result;
|
|
1073
|
+
|
|
1074
|
+
const unique = [...new Set(threadIds)];
|
|
1075
|
+
const rows = await db
|
|
1076
|
+
.select({
|
|
1077
|
+
threadId: posts.threadId,
|
|
1078
|
+
id: sql<string>`(
|
|
1079
|
+
SELECT p2.id FROM post AS p2
|
|
1080
|
+
WHERE p2.thread_id = ${posts.threadId}
|
|
1081
|
+
AND p2.deleted_at IS NULL
|
|
1082
|
+
AND p2.status = 'published'
|
|
1083
|
+
ORDER BY p2.created_at DESC, p2.id DESC
|
|
1084
|
+
LIMIT 1
|
|
1085
|
+
)`.as("last_id"),
|
|
1086
|
+
})
|
|
1087
|
+
.from(posts)
|
|
1088
|
+
.where(inArray(posts.id, unique));
|
|
1089
|
+
|
|
1090
|
+
for (const row of rows) {
|
|
1091
|
+
if (row.id) result.set(row.threadId, row.id);
|
|
1092
|
+
}
|
|
1093
|
+
return result;
|
|
1094
|
+
},
|
|
1095
|
+
|
|
1096
|
+
async getDistinctYears(filters = {}) {
|
|
1097
|
+
const conditions = [
|
|
1098
|
+
...buildFilterConditions(filters),
|
|
1099
|
+
isNotNull(posts.publishedAt),
|
|
1100
|
+
];
|
|
1101
|
+
|
|
1102
|
+
const rows = await db
|
|
1103
|
+
.select({
|
|
1104
|
+
year: sql<string>`strftime('%Y', ${posts.publishedAt}, 'unixepoch')`.as(
|
|
1105
|
+
"year",
|
|
1106
|
+
),
|
|
1107
|
+
})
|
|
1108
|
+
.from(posts)
|
|
1109
|
+
.where(conditions.length > 0 ? and(...conditions) : undefined)
|
|
1110
|
+
.groupBy(sql`strftime('%Y', ${posts.publishedAt}, 'unixepoch')`)
|
|
1111
|
+
.orderBy(desc(sql`strftime('%Y', ${posts.publishedAt}, 'unixepoch')`));
|
|
1112
|
+
|
|
1113
|
+
return rows.map((r) => parseInt(r.year, 10));
|
|
1114
|
+
},
|
|
523
1115
|
};
|
|
524
1116
|
}
|