Skip to content

Commit ddb9122

Browse files
committed
Add blog post feature: migrations, API helpers, admin tools, and web pages for listing, viewing, and editing posts.
1 parent 40f04ba commit ddb9122

34 files changed

Lines changed: 1935 additions & 60 deletions

android/app/build.gradle

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ android {
1111
applicationId "com.compassconnections.app"
1212
minSdkVersion rootProject.ext.minSdkVersion
1313
targetSdkVersion rootProject.ext.targetSdkVersion
14-
versionCode 158
14+
versionCode 159
1515
versionName "1.40.0"
1616
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
1717
aaptOptions {

backend/api/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@compass/api",
3-
"version": "1.66.1",
3+
"version": "1.67.0",
44
"private": true,
55
"description": "Backend API endpoints",
66
"main": "src/serve.ts",

backend/api/src/app.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ import {blockUser, unblockUser} from './block-user'
5858
import {cancelEvent} from './cancel-event'
5959
import {cancelRsvp} from './cancel-rsvp'
6060
import {getCompatibleProfilesHandler} from './compatible-profiles'
61+
import {createBlogPost} from './create-blog-post'
6162
import {createBookmarkedSearch} from './create-bookmarked-search'
6263
import {createComment} from './create-comment'
6364
import {createCompatibilityQuestion} from './create-compatibility-question'
@@ -71,6 +72,9 @@ import {createUserAndProfile} from './create-user-and-profile'
7172
import {deleteBookmarkedSearch} from './delete-bookmarked-search'
7273
import {deleteCompatibilityAnswer} from './delete-compatibility-answer'
7374
import {deleteMe} from './delete-me'
75+
import {getBlogPost} from './get-blog-post'
76+
import {getBlogPosts} from './get-blog-posts'
77+
import {getBlogPostsAdmin} from './get-blog-posts-admin'
7478
import {getCompatibilityQuestions} from './get-compatibililty-questions'
7579
import {getConnectionInterestsEndpoint} from './get-connection-interests'
7680
import {getCurrentPrivateUser} from './get-current-private-user'
@@ -115,6 +119,7 @@ import {starProfile} from './star-profile'
115119
import {stats} from './stats'
116120
import {transcribeAudio} from './transcribe-audio'
117121
import {unsubscribe} from './unsubscribe'
122+
import {updateBlogPost} from './update-blog-post'
118123
import {updateEvent} from './update-event'
119124
import {updateMe} from './update-me'
120125
import {updateNotifSettings} from './update-notif-setting'
@@ -590,6 +595,11 @@ Commit: ${git.revision} (${git.commitDate})`,
590595
description:
591596
'The public testimonials wall, plus the moderation endpoints that decide what appears on it',
592597
},
598+
{
599+
name: 'Blog',
600+
description:
601+
'The Compass blog: the public index and post reads, plus the admin endpoints that write and publish them',
602+
},
593603
{
594604
name: 'Spotlights',
595605
description:
@@ -659,6 +669,11 @@ const handlers: {[k in APIPath]: APIHandler<k>} = {
659669
'get-testimonials-mod': getTestimonialsMod,
660670
'create-testimonial': createTestimonial,
661671
'update-testimonial-status': updateTestimonialStatus,
672+
'get-blog-posts': getBlogPosts,
673+
'get-blog-post': getBlogPost,
674+
'get-blog-posts-admin': getBlogPostsAdmin,
675+
'create-blog-post': createBlogPost,
676+
'update-blog-post': updateBlogPost,
662677
'get-spotlights': getSpotlights,
663678
'get-spotlights-admin': getSpotlightsAdmin,
664679
'create-spotlight': createSpotlight,
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import {BLOG_FROM, BLOG_FULL_COLUMNS, BlogQueryRow, toAdminBlogPost} from 'api/helpers/blog'
2+
import {APIErrors, APIHandler} from 'api/helpers/endpoint'
3+
import {MAX_BLOG_CONTENT_LENGTH} from 'common/blog/blog'
4+
import {parseJsonContentToText} from 'common/util/parse'
5+
import {throwErrorIfNotAdmin} from 'shared/helpers/auth'
6+
import {createSupabaseDirectClient} from 'shared/supabase/init'
7+
8+
/**
9+
* Create a post. Always as a `draft`, never published, and never broadcast.
10+
*
11+
* Publishing is a second, separate call to `update-blog-post`, for the same reason creating a
12+
* spotlight cannot publish one: the thing that makes a post public also mails every member, and an
13+
* endpoint that can do that as a side effect of "save what I just typed" is one mis-click away from
14+
* sending an unfinished draft to everybody.
15+
*
16+
* Admins only, not mods — publishing under the Compass name is an editorial act, not a moderation
17+
* one. Same split as `create-spotlight`.
18+
*/
19+
export const createBlogPost: APIHandler<'create-blog-post'> = async (props, auth) => {
20+
await throwErrorIfNotAdmin(auth.uid)
21+
22+
const {slug, title, excerpt, content, coverImageUrl} = props
23+
24+
// Flattened once, here, so that every consumer of `content_text` — reading time today, search
25+
// later — reads the same string, and none of them has to know how to walk a ProseMirror tree.
26+
const contentText = parseJsonContentToText(content ?? null)
27+
if (contentText.length > MAX_BLOG_CONTENT_LENGTH) {
28+
throw APIErrors.badRequest(
29+
`That post is ${contentText.length} characters; the limit is ${MAX_BLOG_CONTENT_LENGTH}`,
30+
)
31+
}
32+
33+
const pg = createSupabaseDirectClient()
34+
35+
// The unique index on `slug` is the real guard — two admins creating the same slug at once is not
36+
// a race we can win with a pre-check — so this catches it after the fact and reports it in terms of
37+
// what the admin did rather than as a constraint name.
38+
const existing = await pg.oneOrNone<{status: string}>(
39+
`select status from blog_posts where slug = $(slug)`,
40+
{slug},
41+
)
42+
if (existing) {
43+
throw APIErrors.badRequest(
44+
`The slug “${slug}” is already taken by a ${existing.status} post — edit that one, or pick another slug`,
45+
)
46+
}
47+
48+
const inserted = await pg.one<{id: string}>(
49+
`insert into blog_posts (slug, title, excerpt, content, content_text, cover_image_url, author_id)
50+
values ($(slug), $(title), $(excerpt), $(content), $(contentText), $(coverImageUrl), $(authorId))
51+
returning id`,
52+
{
53+
slug,
54+
title,
55+
excerpt: excerpt?.trim() || null,
56+
content: content ?? {},
57+
contentText,
58+
coverImageUrl: coverImageUrl || null,
59+
authorId: auth.uid,
60+
},
61+
)
62+
63+
// Re-read through the byline join rather than returning the inserted row, so the admin page gets
64+
// exactly the same shape as `get-blog-posts-admin` and does not need a second code path for the
65+
// row it just created.
66+
const row = await pg.one<BlogQueryRow>(
67+
`select ${BLOG_FULL_COLUMNS} ${BLOG_FROM} where b.id = $(id)`,
68+
{id: inserted.id},
69+
)
70+
71+
return {post: toAdminBlogPost(row)}
72+
}

backend/api/src/get-blog-post.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import {BLOG_FROM, BLOG_FULL_COLUMNS, BlogQueryRow, toBlogPost} from 'api/helpers/blog'
2+
import {APIHandler} from 'api/helpers/endpoint'
3+
import {createSupabaseDirectClient} from 'shared/supabase/init'
4+
5+
/**
6+
* One post by slug, body included.
7+
*
8+
* `status = 'published'` is in the query rather than checked afterwards, so a draft is not merely
9+
* hidden by the page — it never reaches the response, and guessing the slug of an unpublished post
10+
* gets you the same `null` as guessing a slug that was never used.
11+
*
12+
* Answers `null` rather than 404ing, because the caller is a statically-generated page that has its
13+
* own not-found state to render, and because this response is CDN-cached: a 404 body cached under a
14+
* slug that is published minutes later is worse than a cached `null` the page can retry past.
15+
*/
16+
export const getBlogPost: APIHandler<'get-blog-post'> = async (props) => {
17+
const pg = createSupabaseDirectClient()
18+
19+
const row = await pg.oneOrNone<BlogQueryRow>(
20+
`select ${BLOG_FULL_COLUMNS}
21+
${BLOG_FROM}
22+
where b.slug = $(slug)
23+
and b.status = 'published'`,
24+
{slug: props.slug},
25+
)
26+
27+
return {post: row ? toBlogPost(row) : null}
28+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import {BLOG_FROM, BLOG_FULL_COLUMNS, BlogQueryRow, toAdminBlogPost} from 'api/helpers/blog'
2+
import {APIHandler} from 'api/helpers/endpoint'
3+
import {throwErrorIfNotAdmin} from 'shared/helpers/auth'
4+
import {createSupabaseDirectClient} from 'shared/supabase/init'
5+
6+
/**
7+
* Every post in every state, bodies included, for `/admin/blog`.
8+
*
9+
* Bodies are included here where the public list omits them: the admin page is an editor, and every
10+
* row on it is one click away from being opened in the rich-text editor. Fetching them lazily would
11+
* put a spinner inside each post's edit box for no benefit — this list is a handful of rows read by
12+
* a handful of people.
13+
*
14+
* Drafts sort first: they are the only group that represents work owed.
15+
*/
16+
export const getBlogPostsAdmin: APIHandler<'get-blog-posts-admin'> = async (_, auth) => {
17+
await throwErrorIfNotAdmin(auth.uid)
18+
19+
const pg = createSupabaseDirectClient()
20+
21+
const rows = await pg.any<BlogQueryRow>(
22+
`select ${BLOG_FULL_COLUMNS}
23+
${BLOG_FROM}
24+
order by case b.status when 'draft' then 0 when 'published' then 1 else 2 end,
25+
coalesce(b.published_time, b.created_time) desc`,
26+
)
27+
28+
return {posts: rows.map(toAdminBlogPost)}
29+
}

backend/api/src/get-blog-posts.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import {BLOG_FROM, BLOG_SUMMARY_COLUMNS, BlogQueryRow, toBlogPostSummary} from 'api/helpers/blog'
2+
import {APIHandler} from 'api/helpers/endpoint'
3+
import {BLOG_POSTS_PER_PAGE} from 'common/blog/blog'
4+
import {createSupabaseDirectClient} from 'shared/supabase/init'
5+
6+
/**
7+
* The `/blog` index. Published rows only — `draft` and `archived` never leave the server.
8+
*
9+
* Bodies are excluded. A blog index that shipped every post's full rich-text document would grow
10+
* without bound as the blog does, and the list renders none of it.
11+
*
12+
* Ordered by `published_time`, not `created_time`: those differ for anything written days before it
13+
* went out, and the date on the card is the one readers reason about.
14+
*/
15+
export const getBlogPosts: APIHandler<'get-blog-posts'> = async (props) => {
16+
const pg = createSupabaseDirectClient()
17+
18+
const rows = await pg.any<BlogQueryRow>(
19+
`select ${BLOG_SUMMARY_COLUMNS}
20+
${BLOG_FROM}
21+
where b.status = 'published'
22+
order by b.published_time desc nulls last, b.id desc
23+
limit $(limit) offset $(offset)`,
24+
{limit: props.limit ?? BLOG_POSTS_PER_PAGE, offset: props.offset ?? 0},
25+
)
26+
27+
return {posts: rows.map(toBlogPostSummary)}
28+
}

backend/api/src/get-spotlights-admin.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ export const getSpotlightsAdmin: APIHandler<'get-spotlights-admin'> = async (_,
6060
// The whole document, unsliced: the admin is reading for the one good sentence, and it is
6161
// rarely in the first paragraph. Capped instead by the candidate list's own `limit 200`.
6262
bio: row.bio,
63+
socialConsent: row.social_media_consent,
6364
}),
6465
),
6566
}

backend/api/src/helpers/blog.ts

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import {type JSONContent} from '@tiptap/core'
2+
import {
3+
AdminBlogPost,
4+
BlogAuthor,
5+
BlogPost,
6+
BlogPostStatus,
7+
BlogPostSummary,
8+
getReadingMinutes,
9+
} from 'common/blog/blog'
10+
11+
/**
12+
* The columns as they come back from `blog_posts`, joined to `users` for the byline.
13+
*
14+
* The author fields are prefixed rather than nested because pg-promise returns a flat row; they are
15+
* all null when `author_id` is null (the account is gone) and the mappers below collapse that to a
16+
* missing byline rather than to a byline made of nulls.
17+
*/
18+
export type BlogQueryRow = {
19+
id: string
20+
slug: string
21+
title: string
22+
excerpt: string | null
23+
content: JSONContent | string | null
24+
content_text: string
25+
cover_image_url: string | null
26+
status: BlogPostStatus
27+
author_id: string | null
28+
author_name: string | null
29+
author_username: string | null
30+
author_avatar_url: string | null
31+
published_time: string | null
32+
notified_time: string | null
33+
created_time: string
34+
updated_time: string
35+
}
36+
37+
/**
38+
* Everything but the body. `content_text` is still selected — the reading-time estimate on the list
39+
* card is computed from it, and it is a fraction of the size of the JSONB document.
40+
*/
41+
export const BLOG_SUMMARY_COLUMNS = `b.id, b.slug, b.title, b.excerpt, b.content_text,
42+
b.cover_image_url, b.status, b.author_id, b.published_time,
43+
b.notified_time, b.created_time, b.updated_time,
44+
u.name as author_name,
45+
u.username as author_username,
46+
u.avatar_url as author_avatar_url`
47+
48+
export const BLOG_FULL_COLUMNS = `${BLOG_SUMMARY_COLUMNS}, b.content`
49+
50+
/** The join every blog read uses. Left, so a post whose author deleted their account still loads. */
51+
export const BLOG_FROM = `from blog_posts b left join users u on u.id = b.author_id`
52+
53+
const toAuthor = (row: BlogQueryRow): BlogAuthor | null =>
54+
row.author_id === null || row.author_name === null || row.author_username === null
55+
? null
56+
: {
57+
name: row.author_name,
58+
username: row.author_username,
59+
avatarUrl: row.author_avatar_url,
60+
}
61+
62+
export const toBlogPostSummary = (row: BlogQueryRow): BlogPostSummary => ({
63+
id: Number(row.id),
64+
slug: row.slug,
65+
title: row.title,
66+
excerpt: row.excerpt,
67+
coverImageUrl: row.cover_image_url,
68+
author: toAuthor(row),
69+
publishedTime: row.published_time === null ? null : new Date(row.published_time).toISOString(),
70+
readingMinutes: getReadingMinutes(row.content_text ?? ''),
71+
})
72+
73+
export const toBlogPost = (row: BlogQueryRow): BlogPost => ({
74+
...toBlogPostSummary(row),
75+
// `{}` is the column default for a post created before anything was typed into the editor. Sent as
76+
// an empty document rather than as `{}`, which the TipTap renderer treats as a malformed node.
77+
content: row.content && Object.keys(row.content).length > 0 ? row.content : '',
78+
})
79+
80+
export const toAdminBlogPost = (row: BlogQueryRow): AdminBlogPost => ({
81+
...toBlogPost(row),
82+
status: row.status,
83+
notifiedTime: row.notified_time === null ? null : new Date(row.notified_time).toISOString(),
84+
createdTime: new Date(row.created_time).toISOString(),
85+
updatedTime: new Date(row.updated_time).toISOString(),
86+
})

backend/api/src/helpers/spotlights.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ export type SpotlightSourceRow = {
9191
/** The TipTap document. `bio_text` is its flattened form, and reads like one when rendered. */
9292
bio: JSONContent | string | null
9393
spotlight_consent: boolean
94+
social_media_consent: boolean
9495
visibility: string
9596
}
9697

@@ -104,6 +105,7 @@ export const SPOTLIGHT_SOURCE_SELECT = `select p.user_id,
104105
p.headline,
105106
p.bio,
106107
p.spotlight_consent,
108+
p.social_media_consent,
107109
p.visibility
108110
from profiles p
109111
join users u on u.id = p.user_id`

0 commit comments

Comments
 (0)