From 08c5262df6fd48f893b2f5454145759bffbfd903 Mon Sep 17 00:00:00 2001 From: SquidSpirit Date: Wed, 6 Aug 2025 21:36:54 +0800 Subject: [PATCH] BLOG-118 Fix to allow nullable published_time to support unpublished posts (#121) ### Description This PR updates the application to handle posts that may not have a publication date (e.g., drafts) by making the `published_time` field optional across the entire post feature stack. This ensures that draft posts can be processed and rendered without causing errors, and prevents search engine metadata from being generated for content that is not yet published. #### Key Changes: * **DTO & Schema (`postInfoResponseDto.ts`):** * The Zod schema for `PostInfoResponseSchema` has been updated to mark `published_time` as `.nullable()`. * The `PostInfoResponseDto` class now correctly handles a `null` value from the API, mapping it to `Date | null`. * **Domain Entity (`postInfo.ts`):** * The core `PostInfo` entity's `publishedTime` property is now typed as `Date | null` to reflect the business logic that a post may be unpublished. * **View Model (`postInfoViewModel.ts`):** * Updated `publishedTime` to be `Date | null`. * Added a new `isPublished` boolean getter for convenient conditional logic in the UI. * The `formattedPublishedTime` getter now returns `string | null`. * Dehydration and rehydration logic (`dehydrate`/`rehydrate`) has been updated to correctly handle the nullable `publishedTime`. * **UI Component (`PostContentPage.svelte`):** * The component now uses the new `isPublished` flag to conditionally render the `` component for SEO. This ensures that structured data is only included for posts that have been officially published. ### Package Changes _No response_ ### Screenshots _No response_ ### Reference Resolves #118 ### Checklist - [x] A milestone is set - [x] The related issuse has been linked to this branch Reviewed-on: https://git.squidspirit.com/squid/blog/pulls/121 Co-authored-by: SquidSpirit Co-committed-by: SquidSpirit --- .../adapter/gateway/postInfoResponseDto.ts | 14 +++++++---- .../adapter/presenter/postInfoViewModel.ts | 23 +++++++++++++------ .../src/lib/post/domain/entity/postInfo.ts | 4 ++-- .../post/framework/ui/PostContentPage.svelte | 14 ++++++----- 4 files changed, 36 insertions(+), 19 deletions(-) diff --git a/frontend/src/lib/post/adapter/gateway/postInfoResponseDto.ts b/frontend/src/lib/post/adapter/gateway/postInfoResponseDto.ts index 91737ff..861244b 100644 --- a/frontend/src/lib/post/adapter/gateway/postInfoResponseDto.ts +++ b/frontend/src/lib/post/adapter/gateway/postInfoResponseDto.ts @@ -8,7 +8,7 @@ export const PostInfoResponseSchema = z.object({ description: z.string(), preview_image_url: z.url(), labels: z.array(LabelResponseSchema), - published_time: z.iso.datetime({ offset: true }) + published_time: z.iso.datetime({ offset: true }).nullable() }); export class PostInfoResponseDto { @@ -17,7 +17,7 @@ export class PostInfoResponseDto { readonly description: string; readonly previewImageUrl: URL; readonly labels: readonly LabelResponseDto[]; - readonly publishedTime: Date; + readonly publishedTime: Date | null; private constructor(props: { id: number; @@ -25,7 +25,7 @@ export class PostInfoResponseDto { description: string; previewImageUrl: URL; labels: LabelResponseDto[]; - publishedTime: Date; + publishedTime: Date | null; }) { this.id = props.id; this.title = props.title; @@ -37,13 +37,19 @@ export class PostInfoResponseDto { static fromJson(json: unknown): PostInfoResponseDto { const parsedJson = PostInfoResponseSchema.parse(json); + + let published_time: Date | null = null; + if (parsedJson.published_time !== null) { + published_time = new Date(parsedJson.published_time); + } + return new PostInfoResponseDto({ id: parsedJson.id, title: parsedJson.title, description: parsedJson.description, previewImageUrl: new URL(parsedJson.preview_image_url), labels: parsedJson.labels.map((label) => LabelResponseDto.fromJson(label)), - publishedTime: new Date(parsedJson.published_time) + publishedTime: published_time }); } diff --git a/frontend/src/lib/post/adapter/presenter/postInfoViewModel.ts b/frontend/src/lib/post/adapter/presenter/postInfoViewModel.ts index 670acb7..7fcfcbd 100644 --- a/frontend/src/lib/post/adapter/presenter/postInfoViewModel.ts +++ b/frontend/src/lib/post/adapter/presenter/postInfoViewModel.ts @@ -10,7 +10,7 @@ export class PostInfoViewModel { readonly description: string; readonly previewImageUrl: URL; readonly labels: readonly LabelViewModel[]; - readonly publishedTime: Date; + readonly publishedTime: Date | null; private constructor(props: { id: number; @@ -18,7 +18,7 @@ export class PostInfoViewModel { description: string; previewImageUrl: URL; labels: readonly LabelViewModel[]; - publishedTime: Date; + publishedTime: Date | null; }) { this.id = props.id; this.title = props.title; @@ -40,18 +40,27 @@ export class PostInfoViewModel { } static rehydrate(props: DehydratedPostInfoProps): PostInfoViewModel { + let publishedTime: Date | null = null; + if (props.publishedTime !== null) { + publishedTime = new Date(props.publishedTime); + } + return new PostInfoViewModel({ id: props.id, title: props.title, description: props.description, previewImageUrl: new URL(props.previewImageUrl), labels: props.labels.map((label) => LabelViewModel.rehydrate(label)), - publishedTime: new Date(props.publishedTime) + publishedTime: publishedTime }); } - get formattedPublishedTime(): string { - return this.publishedTime.toISOString().slice(0, 10); + get isPublished(): boolean { + return this.publishedTime !== null; + } + + get formattedPublishedTime(): string | null { + return this.publishedTime?.toISOString().slice(0, 10) ?? null; } dehydrate(): DehydratedPostInfoProps { @@ -61,7 +70,7 @@ export class PostInfoViewModel { description: this.description, previewImageUrl: this.previewImageUrl.href, labels: this.labels.map((label) => label.dehydrate()), - publishedTime: this.publishedTime.getTime() + publishedTime: this.publishedTime?.getTime() ?? null }; } } @@ -72,5 +81,5 @@ export interface DehydratedPostInfoProps { description: string; previewImageUrl: string; labels: DehydratedLabelProps[]; - publishedTime: number; + publishedTime: number | null; } diff --git a/frontend/src/lib/post/domain/entity/postInfo.ts b/frontend/src/lib/post/domain/entity/postInfo.ts index 129ffe2..002c788 100644 --- a/frontend/src/lib/post/domain/entity/postInfo.ts +++ b/frontend/src/lib/post/domain/entity/postInfo.ts @@ -6,7 +6,7 @@ export class PostInfo { readonly description: string; readonly previewImageUrl: URL; readonly labels: readonly Label[]; - readonly publishedTime: Date; + readonly publishedTime: Date | null; constructor(props: { id: number; @@ -14,7 +14,7 @@ export class PostInfo { description: string; previewImageUrl: URL; labels: readonly Label[]; - publishedTime: Date; + publishedTime: Date | null; }) { this.id = props.id; this.title = props.title; diff --git a/frontend/src/lib/post/framework/ui/PostContentPage.svelte b/frontend/src/lib/post/framework/ui/PostContentPage.svelte index 8bd038a..207faed 100644 --- a/frontend/src/lib/post/framework/ui/PostContentPage.svelte +++ b/frontend/src/lib/post/framework/ui/PostContentPage.svelte @@ -22,12 +22,14 @@ {generateTitle(state.data?.info.title)} {#if state.data} - + {#if state.data.info.isPublished} + + {/if} {/if}