diff --git a/packages/frontpage/app/api/receive_hook/handlers.ts b/packages/frontpage/app/api/receive_hook/handlers.ts index f9639bf1..7d82226b 100644 --- a/packages/frontpage/app/api/receive_hook/handlers.ts +++ b/packages/frontpage/app/api/receive_hook/handlers.ts @@ -1,7 +1,13 @@ import { getPdsUrl, type DID } from "@/lib/data/atproto/did"; import { type Operation } from "@/lib/data/atproto/event"; import { getDidFromHandleOrDid } from "@/lib/data/atproto/identity"; -import { getAtprotoClient, nsids } from "@/lib/data/atproto/repo"; +import { + type CommentCollectionType, + getAtprotoClient, + nsids, + type VoteCollectionType, + type PostCollectionType, +} from "@/lib/data/atproto/repo"; import * as dbComment from "@/lib/data/db/comment"; import * as dbNotification from "@/lib/data/db/notification"; import * as dbPost from "@/lib/data/db/post"; @@ -10,6 +16,10 @@ import { getBlueskyProfile } from "@/lib/data/user"; import { sendDiscordMessage } from "@/lib/discord"; import { invariant } from "@/lib/utils"; import { AtUri } from "@atproto/syntax"; +import { + FyiFrontpageFeedPost, + FyiFrontpageRichtextBlock, +} from "@repo/frontpage-atproto-client"; import type z from "zod"; type HandlerInput = { @@ -31,36 +41,67 @@ async function getAtprotoClientFromRepo(repo: DID) { return getAtprotoClient(pds); } -export async function handlePost({ op, repo, rkey }: HandlerInput) { +async function hydratePost( + repo: DID, + collection: string, + rkey: string, +): Promise<{ + title: string; + url: string; + createdAt: Date; + cid: string; + $type: PostCollectionType; +}> { const atproto = await getAtprotoClientFromRepo(repo); + if (collection === nsids.FyiUnravelFrontpagePost) { + const record = await atproto.fyi.unravel.frontpage.post.get({ repo, rkey }); + return { + title: record.value.title, + url: record.value.url, + createdAt: new Date(record.value.createdAt), + cid: record.cid, + $type: record.value.$type, + }; + } else if (collection === nsids.FyiFrontpageFeedPost) { + const record = await atproto.fyi.frontpage.feed.post.get({ repo, rkey }); + const subject = record.value.subject; + invariant( + FyiFrontpageFeedPost.isUrlSubject(subject), + `Received non-url subject in frontpage feed post: at://${repo}/${collection}/${rkey}#${record.cid}`, + ); + return { + title: record.value.title, + url: subject.url, + createdAt: new Date(record.value.createdAt), + cid: record.cid, + $type: record.value.$type, + }; + } else { + throw new Error(`Unknown collection for post hydration: ${collection}`); + } +} +export async function handlePost({ op, repo, rkey }: HandlerInput) { if (op.action === "create") { - const postRecord = await atproto.fyi.unravel.frontpage.post.get({ - repo, - rkey, - }); - - invariant(postRecord, "atproto post record not found"); - - const post = await dbPost.uncached_doesPostExist(repo, rkey); - const { title, url, createdAt } = postRecord.value; + const post = await hydratePost(repo, op.path.collection, rkey); - if (post) { + if (await dbPost.uncached_doesPostExist(repo, rkey)) { await dbPost.updatePost(repo, rkey, { status: "live", - cid: postRecord.cid, + cid: post.cid, }); } else { await dbPost.createPost({ post: { - title, - url, - createdAt: new Date(createdAt), + title: post.title, + url: post.url, + createdAt: post.createdAt, }, rkey, - cid: postRecord.cid, + cid: post.cid, authorDid: repo, status: "live", + collection: post.$type, }); } @@ -69,7 +110,7 @@ export async function handlePost({ op, repo, rkey }: HandlerInput) { embeds: [ { title: "New post on Frontpage", - description: title, + description: post.title, url: `https://frontpage.fyi/post/${repo}/${rkey}`, color: 10181046, author: bskyProfile @@ -82,7 +123,7 @@ export async function handlePost({ op, repo, rkey }: HandlerInput) { fields: [ { name: "Link", - value: url, + value: post.url, }, ], }, @@ -96,43 +137,93 @@ export async function handlePost({ op, repo, rkey }: HandlerInput) { } } -export async function handleComment({ op, repo, rkey }: HandlerInput) { +async function hydrateComment( + repo: DID, + collection: string, + rkey: string, +): Promise<{ + cid: string; + content: string; + createdAt: Date; + parentUri: AtUri | null; + postUri: AtUri; + $type: CommentCollectionType; +}> { const atproto = await getAtprotoClientFromRepo(repo); - - if (op.action === "create") { - const commentRecord = await atproto.fyi.unravel.frontpage.comment.get({ + if (collection === nsids.FyiUnravelFrontpageComment) { + const record = await atproto.fyi.unravel.frontpage.comment.get({ + repo, rkey, + }); + return { + cid: record.cid, + content: record.value.content, + createdAt: new Date(record.value.createdAt), + parentUri: record.value.parent + ? new AtUri(record.value.parent.uri) + : null, + postUri: new AtUri(record.value.post.uri), + $type: record.value.$type, + }; + } else if (collection === nsids.FyiFrontpageFeedComment) { + const record = await atproto.fyi.frontpage.feed.comment.get({ repo, + rkey, }); - invariant(commentRecord, "atproto comment record not found"); + const blockContents = record.value.blocks.flatMap((block) => { + if (FyiFrontpageRichtextBlock.isPlaintextParagraph(block.content)) { + return [block.content.text]; + } else { + return []; + } + }); - const comment = await dbComment.uncached_doesCommentExist(repo, rkey); + invariant( + blockContents.length === record.value.blocks.length, + `Received non plaintext blocks in frontpage feed comment: at://${repo}/${collection}/${rkey}#${record.cid}`, + ); - if (comment) { - console.log("comment already exists", commentRecord.value); + return { + cid: record.cid, + content: blockContents.join("\n\n"), + createdAt: new Date(record.value.createdAt), + parentUri: record.value.parent + ? new AtUri(record.value.parent.uri) + : null, + postUri: new AtUri(record.value.post.uri), + $type: record.value.$type, + }; + } else { + throw new Error(`Unknown collection for comment hydration: ${collection}`); + } +} + +export async function handleComment({ op, repo, rkey }: HandlerInput) { + if (op.action === "create") { + const comment = await hydrateComment(repo, op.path.collection, rkey); + + if (await dbComment.uncached_doesCommentExist(repo, rkey)) { await dbComment.updateComment(repo, rkey, { status: "live", - cid: commentRecord.cid, + cid: comment.cid, }); } else { - const { content, createdAt, parent, post } = commentRecord.value; - const postUri = new AtUri(post.uri); - const parentData = parent + const parentData = comment.parentUri ? { - uri: new AtUri(parent.uri), - authorDid: await getDidOrThrow(new AtUri(parent.uri).host), + uri: comment.parentUri, + authorDid: await getDidOrThrow(comment.parentUri.host), } : null; - const postAuthorDid = await getDidOrThrow(postUri.host); + const postAuthorDid = await getDidOrThrow(comment.postUri.host); const createdComment = await dbComment.createComment({ - cid: commentRecord.cid, + cid: comment.cid, authorDid: repo, rkey, - content, - createdAt: new Date(createdAt), + content: comment.content, + createdAt: comment.createdAt, parent: parentData ? { authorDid: parentData.authorDid, @@ -141,9 +232,10 @@ export async function handleComment({ op, repo, rkey }: HandlerInput) { : undefined, post: { authorDid: postAuthorDid, - rkey: postUri.rkey, + rkey: comment.postUri.rkey, }, status: "live", + collection: comment.$type, }); if (!createdComment) { @@ -165,40 +257,66 @@ export async function handleComment({ op, repo, rkey }: HandlerInput) { } } -export async function handleVote({ op, repo, rkey }: HandlerInput) { +async function hydrateVote( + repo: DID, + collection: string, + rkey: string, +): Promise<{ + cid: string; + createdAt: Date; + subject: { + uri: AtUri; + cid: string; + }; + $type: VoteCollectionType; +}> { const atproto = await getAtprotoClientFromRepo(repo); - if (op.action === "create") { - const hydratedRecord = await atproto.fyi.unravel.frontpage.vote.get({ - repo, - rkey, - }); + let record; + if (collection === nsids.FyiUnravelFrontpageVote) { + record = await atproto.fyi.unravel.frontpage.vote.get({ repo, rkey }); + } else if (collection === nsids.FyiFrontpageFeedVote) { + record = await atproto.fyi.frontpage.feed.vote.get({ repo, rkey }); + } else { + throw new Error(`Unknown collection for vote hydration: ${collection}`); + } - invariant(hydratedRecord, "atproto vote record not found"); + return { + cid: record.cid, + createdAt: new Date(record.value.createdAt), + subject: { + uri: new AtUri(record.value.subject.uri), + cid: record.value.subject.cid, + }, + $type: record.value.$type, + }; +} - const { subject } = hydratedRecord.value; - const subjectUri = new AtUri(subject.uri); +export async function handleVote({ op, repo, rkey }: HandlerInput) { + if (op.action === "create") { + const vote = await hydrateVote(repo, op.path.collection, rkey); - switch (subjectUri.collection) { - case nsids.FyiUnravelFrontpagePost: { - const postVote = await dbVote.uncached_doesPostVoteExist(repo, rkey); - if (postVote) { + switch (vote.subject.uri.collection) { + case nsids.FyiUnravelFrontpagePost: + case nsids.FyiFrontpageFeedPost: { + if (await dbVote.uncached_doesPostVoteExist(repo, rkey)) { await dbVote.updatePostVote({ authorDid: repo, rkey, status: "live", - cid: hydratedRecord.cid, + cid: vote.cid, }); } else { const createdDbPostVote = await dbVote.createPostVote({ repo, rkey, - cid: hydratedRecord.cid, + cid: vote.cid, subject: { - rkey: subjectUri.rkey, - authorDid: await getDidOrThrow(subjectUri.host), - cid: subject.cid, + rkey: vote.subject.uri.rkey, + authorDid: await getDidOrThrow(vote.subject.uri.host), + cid: vote.subject.cid, }, status: "live", + collection: vote.$type, }); if (!createdDbPostVote) { @@ -209,29 +327,27 @@ export async function handleVote({ op, repo, rkey }: HandlerInput) { } break; } - case nsids.FyiUnravelFrontpageComment: { - const commentVote = await dbVote.uncached_doesCommentVoteExist( - repo, - rkey, - ); - if (commentVote) { + case nsids.FyiUnravelFrontpageComment: + case nsids.FyiFrontpageFeedComment: { + if (await dbVote.uncached_doesCommentVoteExist(repo, rkey)) { await dbVote.updateCommentVote({ authorDid: repo, rkey, status: "live", - cid: hydratedRecord.cid, + cid: vote.cid, }); } else { const createdDbCommentVote = await dbVote.createCommentVote({ repo, rkey, - cid: hydratedRecord.cid, + cid: vote.cid, subject: { - rkey: subjectUri.rkey, - authorDid: await getDidOrThrow(subjectUri.host), - cid: subject.cid, + rkey: vote.subject.uri.rkey, + authorDid: await getDidOrThrow(vote.subject.uri.host), + cid: vote.subject.cid, }, status: "live", + collection: vote.$type, }); if (!createdDbCommentVote) { @@ -242,11 +358,13 @@ export async function handleVote({ op, repo, rkey }: HandlerInput) { } break; } - default: - invariant(subjectUri.collection, "Unknown collection"); + default: { + throw new Error( + `Unknown vote subject collection: ${vote.subject.uri.collection} received from at://${repo}/${op.path.collection}/${rkey}#${vote.cid}`, + ); + } } } else if (op.action === "delete") { - console.log("deleting vote", rkey); await dbVote.deleteVote({ authorDid: repo, rkey }); } } diff --git a/packages/frontpage/app/api/receive_hook/route.ts b/packages/frontpage/app/api/receive_hook/route.ts index 4c8c58ef..70bfd2b6 100644 --- a/packages/frontpage/app/api/receive_hook/route.ts +++ b/packages/frontpage/app/api/receive_hook/route.ts @@ -47,15 +47,24 @@ export async function POST(request: Request) { console.log("Processing", collection, rkey, op.action); switch (collection) { - case nsids.FyiUnravelFrontpagePost: + case nsids.FyiFrontpageFeedPost: + case nsids.FyiUnravelFrontpagePost: { await handlePost({ op, repo, rkey }); break; - case nsids.FyiUnravelFrontpageComment: + } + + case nsids.FyiFrontpageFeedComment: + case nsids.FyiUnravelFrontpageComment: { await handleComment({ op, repo, rkey }); break; - case nsids.FyiUnravelFrontpageVote: + } + + case nsids.FyiFrontpageFeedVote: + case nsids.FyiUnravelFrontpageVote: { await handleVote({ op, repo, rkey }); break; + } + default: exhaustiveCheck(collection, `Unknown collection ${JSON.stringify(op)}`); } diff --git a/packages/frontpage/lib/api/comment.ts b/packages/frontpage/lib/api/comment.ts index 7d0cb6cc..4114f4b5 100644 --- a/packages/frontpage/lib/api/comment.ts +++ b/packages/frontpage/lib/api/comment.ts @@ -37,6 +37,7 @@ export async function createComment({ parent, post, status: "pending", + collection: nsids.FyiUnravelFrontpageComment, }); invariant(dbCreatedComment, "Failed to insert comment in database"); diff --git a/packages/frontpage/lib/api/post.ts b/packages/frontpage/lib/api/post.ts index 6f2c6a37..4e7204bc 100644 --- a/packages/frontpage/lib/api/post.ts +++ b/packages/frontpage/lib/api/post.ts @@ -6,7 +6,7 @@ import { invariant } from "../utils"; import { TID } from "@atproto/common-web"; import { type DID } from "../data/atproto/did"; import { after } from "next/server"; -import { getAtprotoClient } from "../data/atproto/repo"; +import { getAtprotoClient, nsids } from "../data/atproto/repo"; export type ApiCreatePostInput = { authorDid: DID; @@ -32,6 +32,7 @@ export async function createPost({ rkey, authorDid: user.did, status: "pending", + collection: nsids.FyiUnravelFrontpagePost, }); invariant(dbCreatedPost, "Failed to insert post in database"); diff --git a/packages/frontpage/lib/api/vote.ts b/packages/frontpage/lib/api/vote.ts index b7abe616..2ad3ac2f 100644 --- a/packages/frontpage/lib/api/vote.ts +++ b/packages/frontpage/lib/api/vote.ts @@ -33,6 +33,7 @@ export async function createVote(subject: ApiCreateVoteInput) { cid: subject.cid, }, status: "pending", + collection: nsids.FyiUnravelFrontpageVote, }); invariant(dbCreatedVote, "Failed to insert post vote in database"); @@ -46,6 +47,7 @@ export async function createVote(subject: ApiCreateVoteInput) { cid: subject.cid, }, status: "pending", + collection: nsids.FyiUnravelFrontpageVote, }); invariant(dbCreatedVote, "Failed to insert comment vote in database"); diff --git a/packages/frontpage/lib/data/atproto/event.ts b/packages/frontpage/lib/data/atproto/event.ts index 1c84590e..275a49dd 100644 --- a/packages/frontpage/lib/data/atproto/event.ts +++ b/packages/frontpage/lib/data/atproto/event.ts @@ -7,10 +7,15 @@ import { nsids } from "./repo"; export const Collection = z.union([ z.literal(nsids.FyiUnravelFrontpagePost), + z.literal(nsids.FyiFrontpageFeedPost), z.literal(nsids.FyiUnravelFrontpageComment), + z.literal(nsids.FyiFrontpageFeedComment), z.literal(nsids.FyiUnravelFrontpageVote), + z.literal(nsids.FyiFrontpageFeedVote), ]); +export type Collection = z.infer; + const Path = z.string().transform((p, ctx) => { const collectionResult = Collection.safeParse(p.split("/")[0]); if (!collectionResult.success) { diff --git a/packages/frontpage/lib/data/atproto/repo.ts b/packages/frontpage/lib/data/atproto/repo.ts index 0a1000a8..3a5b99f4 100644 --- a/packages/frontpage/lib/data/atproto/repo.ts +++ b/packages/frontpage/lib/data/atproto/repo.ts @@ -1,4 +1,12 @@ -import { AtpBaseClient } from "@repo/frontpage-atproto-client"; +import { + AtpBaseClient, + type FyiFrontpageFeedComment, + type FyiFrontpageFeedPost, + type FyiFrontpageFeedVote, + type FyiUnravelFrontpageComment, + type FyiUnravelFrontpagePost, + type FyiUnravelFrontpageVote, +} from "@repo/frontpage-atproto-client"; import { getUser } from "../user"; import { fetchAuthenticatedAtproto } from "@/lib/auth"; import { cache } from "react"; @@ -28,3 +36,15 @@ export const getAtprotoClient = cache( return fetch(u, init); }), ); + +export type PostCollectionType = + | FyiFrontpageFeedPost.Record["$type"] + | FyiUnravelFrontpagePost.Record["$type"]; + +export type CommentCollectionType = + | FyiUnravelFrontpageComment.Record["$type"] + | FyiFrontpageFeedComment.Record["$type"]; + +export type VoteCollectionType = + | FyiUnravelFrontpageVote.Record["$type"] + | FyiFrontpageFeedVote.Record["$type"]; diff --git a/packages/frontpage/lib/data/db/comment.ts b/packages/frontpage/lib/data/db/comment.ts index f6026450..337824b5 100644 --- a/packages/frontpage/lib/data/db/comment.ts +++ b/packages/frontpage/lib/data/db/comment.ts @@ -18,7 +18,7 @@ import { deleteCommentAggregateTrigger, newCommentAggregateTrigger, } from "./triggers"; -import { nsids } from "../atproto/repo"; +import type { CommentCollectionType } from "../atproto/repo"; type CommentRow = Omit< InferSelectModel, @@ -322,6 +322,7 @@ export type CreateCommentInput = { rkey: string; }; status: "live" | "pending"; + collection: CommentCollectionType; }; export async function createComment({ @@ -333,6 +334,7 @@ export async function createComment({ parent, post, status, + collection, }: CreateCommentInput) { return await db.transaction(async (tx) => { const existingPost = ( @@ -381,7 +383,7 @@ export async function createComment({ createdAt: createdAt, parentCommentId: existingParent?.id ?? null, status, - collection: nsids.FyiUnravelFrontpageComment, + collection, }) .returning({ id: schema.Comment.id, diff --git a/packages/frontpage/lib/data/db/post.ts b/packages/frontpage/lib/data/db/post.ts index 0c3273f3..09532577 100644 --- a/packages/frontpage/lib/data/db/post.ts +++ b/packages/frontpage/lib/data/db/post.ts @@ -17,7 +17,7 @@ import { getUser, isAdmin } from "../user"; import { type DID } from "../atproto/did"; import { newPostAggregateTrigger } from "./triggers"; import { invariant } from "@/lib/utils"; -import { nsids } from "../atproto/repo"; +import type { PostCollectionType } from "../atproto/repo"; const buildUserHasVotedQuery = cache(async () => { const user = await getUser(); @@ -180,6 +180,7 @@ export type CreatePostInput = { rkey: string; cid?: string; status: "live" | "pending"; + collection: PostCollectionType; }; export async function createPost({ @@ -188,6 +189,7 @@ export async function createPost({ rkey, cid, status, + collection, }: CreatePostInput) { return await db.transaction(async (tx) => { const [insertedPostRow] = await tx @@ -200,7 +202,7 @@ export async function createPost({ url: post.url, createdAt: post.createdAt, status, - collection: nsids.FyiUnravelFrontpagePost, + collection, }) .returning({ postId: schema.Post.id }); diff --git a/packages/frontpage/lib/data/db/vote.ts b/packages/frontpage/lib/data/db/vote.ts index 890c0317..a04da959 100644 --- a/packages/frontpage/lib/data/db/vote.ts +++ b/packages/frontpage/lib/data/db/vote.ts @@ -12,7 +12,7 @@ import { newPostVoteAggregateTrigger, } from "./triggers"; import { invariant } from "@/lib/utils"; -import { nsids } from "../atproto/repo"; +import type { VoteCollectionType } from "../atproto/repo"; export const getVoteForPost = cache(async (postId: number) => { const user = await getUser(); @@ -95,6 +95,7 @@ export type CreateVoteInput = { cid: string; }; status: "live" | "pending"; + collection: VoteCollectionType; }; export const createPostVote = async ({ @@ -102,6 +103,7 @@ export const createPostVote = async ({ rkey, cid, subject, + collection, }: CreateVoteInput) => { return await db.transaction(async (tx) => { const post = ( @@ -133,7 +135,7 @@ export const createPostVote = async ({ createdAt: new Date(), cid: cid ?? "", rkey, - collection: nsids.FyiUnravelFrontpageVote, + collection, }) .returning({ id: schema.PostVote.id }); @@ -152,6 +154,7 @@ export async function createCommentVote({ rkey, cid, subject, + collection, }: CreateVoteInput) { return await db.transaction(async (tx) => { const comment = ( @@ -180,7 +183,7 @@ export async function createCommentVote({ createdAt: new Date(), cid: cid ?? "", rkey, - collection: nsids.FyiUnravelFrontpageVote, + collection, }) .returning({ id: schema.CommentVote.id });