@@ -2,10 +2,14 @@ import { asc, count, desc, eq, ilike, inArray, or, sql } from 'drizzle-orm';
22
33import { geekdailyEpisodeItems , geekdailyEpisodes } from '@rebase/db' ;
44import {
5+ buildGeekDailyWechatHtml ,
56 buildGeekDailyBodyMarkdown ,
67 buildGeekDailySummary ,
78 extractGeekDailyBodyNote ,
9+ getGeekDailyEpisodePath ,
810 getGeekDailyEpisodeSlug ,
11+ getGeekDailyWechatGenerationIssue ,
12+ type AdminGeekDailyWechatDraftRecord ,
913 type AdminGeekDailyListItem ,
1014 type ContentStatus ,
1115 type GeekDailyEpisodeInput ,
@@ -14,10 +18,13 @@ import {
1418
1519import { createAuditEntry , type AuditActor } from './audit.js' ;
1620import { getDb } from './db.js' ;
17- import { badRequest , notFound } from './errors.js' ;
21+ import { getEnv } from './env.js' ;
22+ import { badRequest , notFound , serviceUnavailable } from './errors.js' ;
1823import { buildPaginatedMeta , resolvePagination , type PaginationInput } from './pagination.js' ;
1924import { combineFilters , toContainsPattern } from './query-filters.js' ;
25+ import { getPublicSiteConfig } from './site.js' ;
2026import { toIsoString } from './utils.js' ;
27+ import { createWechatOfficialDraft } from './wechat-official.js' ;
2128
2229const mapEpisodeItem = ( row : typeof geekdailyEpisodeItems . $inferSelect ) => ( {
2330 title : row . title ,
@@ -220,6 +227,18 @@ const resolveGeekDailyPublishedAt = (status: ContentStatus, currentPublishedAt?:
220227 return currentPublishedAt ? coerceDate ( currentPublishedAt ) : new Date ( ) ;
221228} ;
222229
230+ const withBaseUrl = ( baseUrl : string , pathname : string ) => new URL ( pathname , baseUrl ) . toString ( ) ;
231+
232+ const truncateByCodePoints = ( value : string , maxLength : number ) => Array . from ( value ) . slice ( 0 , maxLength ) . join ( '' ) ;
233+
234+ const buildWechatDraftTitle = ( record : ReturnType < typeof mapEpisodeDetail > ) =>
235+ truncateByCodePoints ( ( record . title . trim ( ) || `极客日报#${ record . episodeNumber } ` ) . trim ( ) , 32 ) ;
236+
237+ const buildWechatDraftAuthor = ( editors : string [ ] ) => truncateByCodePoints ( ( editors . join ( '、' ) || 'Rebase' ) . trim ( ) , 16 ) ;
238+
239+ const buildWechatDraftDigest = ( record : ReturnType < typeof mapEpisodeDetail > ) =>
240+ truncateByCodePoints ( ( record . summary . trim ( ) || buildGeekDailySummary ( { items : record . items } ) ) . trim ( ) , 128 ) ;
241+
223242const mapEpisodeDetail = ( row : any , items : any [ ] ) => ( {
224243 id : row . id ,
225244 slug : getGeekDailyEpisodeSlug ( row . episodeNumber ) ,
@@ -518,6 +537,79 @@ export const archiveAdminGeekDailyEpisode = async (id: string, actor: AuditActor
518537 return getAdminGeekDailyEpisode ( id ) ;
519538} ;
520539
540+ export const createAdminGeekDailyWechatDraft = async (
541+ id : string ,
542+ actor : AuditActor ,
543+ ) : Promise < AdminGeekDailyWechatDraftRecord > => {
544+ const current = await getAdminGeekDailyEpisode ( id ) ;
545+ if ( ! current ) {
546+ throw notFound ( 'GeekDaily episode not found' ) ;
547+ }
548+
549+ if ( current . status !== 'published' ) {
550+ throw badRequest ( 'only published GeekDaily episodes can create a WeChat draft' ) ;
551+ }
552+
553+ const editorName = current . editors . length > 0 ? current . editors . join ( '、' ) : 'Rebase' ;
554+ const wechatInput = {
555+ episodeNumber : current . episodeNumber ,
556+ editorName,
557+ bodyMarkdown : extractGeekDailyBodyNote ( current . bodyMarkdown ) ,
558+ items : current . items ,
559+ } ;
560+ const issue = getGeekDailyWechatGenerationIssue ( wechatInput ) ;
561+ if ( issue ) {
562+ throw badRequest ( 'wechat draft generation failed validation' , { issue } ) ;
563+ }
564+
565+ const content = buildGeekDailyWechatHtml ( wechatInput ) ;
566+ if ( ! content ) {
567+ throw badRequest ( 'wechat draft content is empty' ) ;
568+ }
569+
570+ const site = await getPublicSiteConfig ( ) ;
571+ const title = buildWechatDraftTitle ( current ) ;
572+ const author = buildWechatDraftAuthor ( current . editors ) ;
573+ const digest = buildWechatDraftDigest ( current ) ;
574+
575+ const thumbMediaId = getEnv ( ) . wechatDefaultThumbMediaId . trim ( ) ;
576+ if ( ! thumbMediaId ) {
577+ throw serviceUnavailable ( 'wechat default thumb media id is not configured' , {
578+ missing : [ 'WECHAT_DEFAULT_THUMB_MEDIA_ID' ] ,
579+ } ) ;
580+ }
581+
582+ const contentSourceUrl = withBaseUrl ( site . primaryDomain , getGeekDailyEpisodePath ( current . episodeNumber ) ) ;
583+ const { mediaId } = await createWechatOfficialDraft ( {
584+ title,
585+ author,
586+ digest,
587+ content,
588+ contentSourceUrl,
589+ thumbMediaId,
590+ } ) ;
591+
592+ await createAuditEntry ( {
593+ ...actor ,
594+ action : 'geekdaily.wechat_draft.create' ,
595+ targetType : 'geekdaily_episode' ,
596+ targetId : id ,
597+ summary : `Created GeekDaily WeChat draft ${ current . episodeNumber } ` ,
598+ } ) ;
599+
600+ return {
601+ episodeId : id ,
602+ episodeNumber : current . episodeNumber ,
603+ title,
604+ author,
605+ digest,
606+ mediaId,
607+ thumbMediaId,
608+ contentSourceUrl,
609+ itemCount : current . items . length ,
610+ } ;
611+ } ;
612+
521613export const listPublicGeekDailyEpisodes = async ( limit = - 1 ) => {
522614 return withPublicGeekDailyCache ( `episodes:${ limit } ` , async ( ) => {
523615 const db = getDb ( ) ;
0 commit comments