diff --git a/docs/frontpage-data-layers.md b/docs/frontpage-data-layers.md index 2026f851..9c03aeee 100644 --- a/docs/frontpage-data-layers.md +++ b/docs/frontpage-data-layers.md @@ -20,6 +20,12 @@ Generally, the Next.js app should only speak to this layer, and not directly to As this layer is designed to speak XRPC, it uses AT protocol types and concepts where possible. It borrows many conventions from the Bsky API eg. Using AT URIs to identify resources. Unlike the Bsky API it also offers write operations, not just read operations. This is because Frontpage chooses option 2 in [Paul's Leaflet](https://pfrazee.leaflet.pub/3m5hwua4sh22v) while Bsky chooses option 1. The main reason for this is to allow Frontpage to read it's own writes, Bsky is able to do this via specific logic that it injects into the PDS itself - we don't have that option. +#### A note on AT URI types + +In the API layer we generally use generic `AtUri` types from `@atproto/syntax` to represent resources. This matches the bsky API and also allows us to be generic about the specific collection being used (important in the case of posts/comments/votes that can exist in the old or current lexicons). + +As data flows deeper into the Frontpage app we're less concerned with following conventions in other atproto apps, and more concerned with type safety and clarity. Therefore in the DB layer we convert these generic `AtUri` types into more specific types that represent exactly which collection is being used. Another difference is that the `AtUri` type allows for the actor (or `host`, in AT terms) to be either a DID or a handle, while in the DB layer we require this to always be a DID. This is because the database only stores DIDs (handles are mutable), so we need to resolve handles to DIDs before we can interact with the database. + ### DB Layer Code for this layer is in `packages/frontpage/lib/data/db`. This layer is responsible for interacting with the Frontpage database. It's structured like a traditional database access layer, with functions for creating, reading, updating, and deleting records in the database. diff --git a/packages/frontpage/app/(app)/_components/post-card.tsx b/packages/frontpage/app/(app)/_components/post-card.tsx index 2e5c2d60..f8fc89f8 100644 --- a/packages/frontpage/app/(app)/_components/post-card.tsx +++ b/packages/frontpage/app/(app)/_components/post-card.tsx @@ -16,7 +16,8 @@ import { ShareDropdownButton } from "./share-button"; import { createVote, deleteVote } from "@/lib/api/vote"; import { deletePost } from "@/lib/api/post"; import { invariant } from "@/lib/utils"; -import { nsids } from "@/lib/data/atproto/repo"; +import { nsids, type PostCollectionType } from "@/lib/data/atproto/repo"; +import { AtUri } from "@atproto/syntax"; type PostProps = { id: number; @@ -27,6 +28,7 @@ type PostProps = { createdAt: Date; commentCount: number; rkey: string; + collection: PostCollectionType; cid: string | null; isUpvoted: boolean; }; @@ -40,6 +42,7 @@ export async function PostCard({ createdAt, commentCount, rkey, + collection, cid, isUpvoted, }: PostProps) { @@ -126,12 +129,13 @@ export async function PostCard({ rkey, cid, author, + collection, })} /> {/* TODO: there's a bug here where delete shows on deleted posts */} {user?.did === author ? ( ) : null} @@ -142,10 +146,13 @@ export async function PostCard({ ); } -export async function deletePostAction(rkey: string) { +export async function deletePostAction( + collection: PostCollectionType, + rkey: string, +) { "use server"; const user = await ensureUser(); - await deletePost({ authorDid: user.did, rkey }); + await deletePost(new AtUri(`at://${user.did}/${collection}/${rkey}`)); revalidatePath("/"); } @@ -155,6 +162,7 @@ export async function reportPostAction( rkey: string; cid: string | null; author: DID; + collection: PostCollectionType; }, formData: FormData, ) { @@ -168,9 +176,9 @@ export async function reportPostAction( await createReport({ ...formResult.data, - subjectUri: `at://${input.author}/${nsids.FyiUnravelFrontpagePost}/${input.rkey}`, + subjectUri: `at://${input.author}/${input.collection}/${input.rkey}`, subjectDid: input.author, - subjectCollection: nsids.FyiUnravelFrontpagePost, + subjectCollection: input.collection, subjectRkey: input.rkey, subjectCid: input.cid ?? undefined, }); diff --git a/packages/frontpage/app/(app)/moderation/_components/report-card.tsx b/packages/frontpage/app/(app)/moderation/_components/report-card.tsx index 868185b3..1dc39b53 100644 --- a/packages/frontpage/app/(app)/moderation/_components/report-card.tsx +++ b/packages/frontpage/app/(app)/moderation/_components/report-card.tsx @@ -58,16 +58,22 @@ async function performModerationAction( switch (report.subjectCollection) { case nsids.FyiUnravelFrontpagePost: return await moderatePost({ - rkey: report.subjectRkey!, - authorDid: report.subjectDid as DID, + uri: { + actor: report.subjectDid, + collection: report.subjectCollection, + rkey: report.subjectRkey!, + }, cid: report.subjectCid!, hide: input.status === "accepted", }); case nsids.FyiUnravelFrontpageComment: return await moderateComment({ - rkey: report.subjectRkey!, - authorDid: report.subjectDid as DID, + uri: { + actor: report.subjectDid, + collection: report.subjectCollection, + rkey: report.subjectRkey!, + }, cid: report.subjectCid!, hide: input.status === "accepted", }); diff --git a/packages/frontpage/app/(app)/page.tsx b/packages/frontpage/app/(app)/page.tsx index 5e88dd91..b30a325c 100644 --- a/packages/frontpage/app/(app)/page.tsx +++ b/packages/frontpage/app/(app)/page.tsx @@ -39,6 +39,7 @@ async function getMorePostsAction(cursor: number | null) { cid={post.cid} rkey={post.rkey} isUpvoted={post.userHasVoted} + collection={post.collection} /> ))} diff --git a/packages/frontpage/app/(app)/post/[postAuthor]/[postRkey]/_lib/comment-client.tsx b/packages/frontpage/app/(app)/post/[postAuthor]/[postRkey]/_lib/comment-client.tsx index b3237e1e..56c6e683 100644 --- a/packages/frontpage/app/(app)/post/[postAuthor]/[postRkey]/_lib/comment-client.tsx +++ b/packages/frontpage/app/(app)/post/[postAuthor]/[postRkey]/_lib/comment-client.tsx @@ -148,7 +148,7 @@ export function CommentClientWrapperWithToolbar({ postAuthorDid={postAuthorDid} // eslint-disable-next-line jsx-a11y/no-autofocus autoFocus - onActionDone={() => { + onDoneAction={() => { startTransition(() => { setShowNewComment(false); }); @@ -226,13 +226,13 @@ export function NewComment({ postAuthorDid, extraButton, textAreaRef, - onActionDone, + onDoneAction, }: { parent?: { did: DID; rkey: string }; postRkey: string; postAuthorDid: DID; autoFocus?: boolean; - onActionDone?: () => void; + onDoneAction?: () => void; extraButton?: React.ReactNode; textAreaRef?: React.RefObject; }) { @@ -252,7 +252,7 @@ export function NewComment({ event.preventDefault(); startTransition(() => { action(new FormData(event.currentTarget)); - onActionDone?.(); + onDoneAction?.(); setInput(""); }); }} diff --git a/packages/frontpage/app/(app)/post/[postAuthor]/[postRkey]/_lib/page-data.tsx b/packages/frontpage/app/(app)/post/[postAuthor]/[postRkey]/_lib/page-data.tsx index 1ed61c10..8b6d53b2 100644 --- a/packages/frontpage/app/(app)/post/[postAuthor]/[postRkey]/_lib/page-data.tsx +++ b/packages/frontpage/app/(app)/post/[postAuthor]/[postRkey]/_lib/page-data.tsx @@ -2,6 +2,7 @@ import "server-only"; import { getDidFromHandleOrDid } from "@/lib/data/atproto/identity"; import { getPost } from "@/lib/data/db/post"; import { notFound } from "next/navigation"; +import { nsids } from "@/lib/data/atproto/repo"; export type PostPageParams = Awaited< PageProps<"/post/[postAuthor]/[postRkey]">["params"] @@ -12,7 +13,22 @@ export async function getPostPageData(params: PostPageParams) { if (!authorDid) { notFound(); } - const post = await getPost(authorDid, params.postRkey); + const [unravelPost, frontpagePost] = await Promise.all([ + getPost({ + actor: authorDid, + collection: nsids.FyiUnravelFrontpagePost, + rkey: params.postRkey, + }), + getPost({ + actor: authorDid, + collection: nsids.FyiFrontpageFeedPost, + rkey: params.postRkey, + }), + ]); + + // Choosing frontpagePost over unravelPost if both exist + // This shouldn't happen in regular usage, only if a user creates a post on purpose with an existing rkey + const post = frontpagePost ?? unravelPost; if (!post) { notFound(); } diff --git a/packages/frontpage/app/(app)/post/[postAuthor]/[postRkey]/layout.tsx b/packages/frontpage/app/(app)/post/[postAuthor]/[postRkey]/layout.tsx index 297ef6cd..3a4180c6 100644 --- a/packages/frontpage/app/(app)/post/[postAuthor]/[postRkey]/layout.tsx +++ b/packages/frontpage/app/(app)/post/[postAuthor]/[postRkey]/layout.tsx @@ -1,12 +1,12 @@ import { getUser } from "@/lib/data/user"; import { notFound } from "next/navigation"; import { PostCard } from "../../../_components/post-card"; -import { getPost } from "@/lib/data/db/post"; import { getDidFromHandleOrDid } from "@/lib/data/atproto/identity"; import { Alert, AlertTitle, AlertDescription } from "@/lib/components/ui/alert"; import { Spinner } from "@/lib/components/ui/spinner"; import { NewComment } from "./_lib/comment-client"; import { SuperHackyScrollToTop } from "./scroller"; +import { getPostPageData } from "./_lib/page-data"; export default async function PostLayout( props: LayoutProps<"/post/[postAuthor]/[postRkey]">, @@ -20,7 +20,7 @@ export default async function PostLayout( if (!didParam) { notFound(); } - const post = await getPost(didParam, params.postRkey); + const { post } = await getPostPageData(params); if (!post) { notFound(); } @@ -40,6 +40,7 @@ export default async function PostLayout( rkey={post.rkey} cid={post.cid} isUpvoted={post.userHasVoted} + collection={post.collection} /> {post.status === "pending" ? ( // TODO: This should have a spinner and refresh on an interval diff --git a/packages/frontpage/app/(app)/post/new/_action.ts b/packages/frontpage/app/(app)/post/new/_action.ts index cd6eebc1..24ec9964 100644 --- a/packages/frontpage/app/(app)/post/new/_action.ts +++ b/packages/frontpage/app/(app)/post/new/_action.ts @@ -26,7 +26,7 @@ export async function newPostAction(_prevState: unknown, formData: FormData) { try { const [{ rkey }, handle] = await Promise.all([ - createPost({ authorDid: user.did, title, url }), + createPost({ actor: user.did, title, url }), getVerifiedHandle(user.did), ]); diff --git a/packages/frontpage/lib/api/post.ts b/packages/frontpage/lib/api/post.ts index 4e7204bc..27f30e07 100644 --- a/packages/frontpage/lib/api/post.ts +++ b/packages/frontpage/lib/api/post.ts @@ -2,37 +2,33 @@ import "server-only"; import * as db from "../data/db/post"; import { ensureUser } from "../data/user"; import { DataLayerError } from "../data/error"; -import { invariant } from "../utils"; +import { exhaustiveCheck, invariant } from "../utils"; import { TID } from "@atproto/common-web"; import { type DID } from "../data/atproto/did"; import { after } from "next/server"; import { getAtprotoClient, nsids } from "../data/atproto/repo"; +import { type AtUri } from "@atproto/syntax"; export type ApiCreatePostInput = { - authorDid: DID; + actor: DID; title: string; url: string; }; -export async function createPost({ - authorDid, - title, - url, -}: ApiCreatePostInput) { +export async function createPost({ actor, title, url }: ApiCreatePostInput) { const user = await ensureUser(); - if (user.did !== authorDid) { + if (user.did !== actor) { throw new DataLayerError("You can only create posts for yourself"); } const rkey = TID.next().toString(); + const uri = { actor, collection: nsids.FyiUnravelFrontpagePost, rkey }; try { const dbCreatedPost = await db.createPost({ post: { title, url, createdAt: new Date() }, - rkey, - authorDid: user.did, + uri, status: "pending", - collection: nsids.FyiUnravelFrontpagePost, }); invariant(dbCreatedPost, "Failed to insert post in database"); @@ -53,28 +49,41 @@ export async function createPost({ return { rkey }; } catch (e) { - await db.deletePost({ authorDid: user.did, rkey }); + await db.deletePost(uri); // eslint-disable-next-line @typescript-eslint/restrict-template-expressions throw new DataLayerError(`Failed to create post: ${e}`); } } -export async function deletePost({ authorDid, rkey }: db.DeletePostInput) { +export async function deletePost(uri: AtUri) { const user = await ensureUser(); + const postUri = await db.resolvePostUri(uri); - if (authorDid !== user.did) { + if (postUri.actor !== user.did) { throw new DataLayerError("You can only delete your own posts"); } try { - const atproto = getAtprotoClient(); - after(() => - atproto.fyi.unravel.frontpage.post.delete({ - repo: authorDid, - rkey, - }), - ); - await db.deletePost({ authorDid: user.did, rkey }); + after(async () => { + const atproto = getAtprotoClient(); + if (postUri.collection === nsids.FyiUnravelFrontpagePost) { + await atproto.fyi.unravel.frontpage.post.delete({ + repo: user.did, + rkey: postUri.rkey, + }); + } else if (postUri.collection === nsids.FyiFrontpageFeedPost) { + await atproto.fyi.frontpage.feed.post.delete({ + repo: user.did, + rkey: postUri.rkey, + }); + } else { + exhaustiveCheck( + postUri.collection, + "Cannot delete post. Unknown post collection", + ); + } + }); + await db.deletePost(postUri); } catch (e) { // eslint-disable-next-line @typescript-eslint/restrict-template-expressions throw new DataLayerError(`Failed to delete post: ${e}`); diff --git a/packages/frontpage/lib/data/atproto/repo.ts b/packages/frontpage/lib/data/atproto/repo.ts index 3a5b99f4..9073e0f6 100644 --- a/packages/frontpage/lib/data/atproto/repo.ts +++ b/packages/frontpage/lib/data/atproto/repo.ts @@ -10,6 +10,7 @@ import { import { getUser } from "../user"; import { fetchAuthenticatedAtproto } from "@/lib/auth"; import { cache } from "react"; +import { type AtUri } from "@atproto/syntax"; export { ids as nsids } from "@repo/frontpage-atproto-client/lexicons"; @@ -48,3 +49,8 @@ export type CommentCollectionType = export type VoteCollectionType = | FyiUnravelFrontpageVote.Record["$type"] | FyiFrontpageFeedVote.Record["$type"]; + +export type StrongRef = { + uri: AtUri; + cid: string; +}; diff --git a/packages/frontpage/lib/data/db/comment.ts b/packages/frontpage/lib/data/db/comment.ts index 0c222a56..e74af0c7 100644 --- a/packages/frontpage/lib/data/db/comment.ts +++ b/packages/frontpage/lib/data/db/comment.ts @@ -18,7 +18,34 @@ import { deleteCommentAggregateTrigger, newCommentAggregateTrigger, } from "./triggers"; -import type { CommentCollectionType } from "../atproto/repo"; +import { nsids, type CommentCollectionType } from "../atproto/repo"; +import { type AtUri } from "@atproto/syntax"; +import { getDidFromHandleOrDid } from "../atproto/identity"; +import { type PostUri } from "./post"; + +export type CommentUri = { + actor: DID; + collection: CommentCollectionType; + rkey: string; +}; + +export async function resolveCommentUri(uri: AtUri): Promise { + invariant( + uri.collection === nsids.FyiUnravelFrontpageComment || + uri.collection === nsids.FyiFrontpageFeedComment, + "Invalid comment collection", + ); + + const actor = await getDidFromHandleOrDid(uri.host); + + invariant(actor, "Failed to resolve actor from URI"); + + return { + actor, + collection: uri.collection, + rkey: uri.rkey, + }; +} type CommentRow = Omit, "cid"> & { cid: string | null; @@ -183,14 +210,15 @@ const findCommentSubtree = ( return null; }; -export const getComment = cache(async (authorDid: DID, rkey: string) => { +export const getComment = cache(async (uri: CommentUri) => { const rows = await db .select() .from(schema.Comment) .where( and( - eq(schema.Comment.authorDid, authorDid), - eq(schema.Comment.rkey, rkey), + eq(schema.Comment.authorDid, uri.actor), + eq(schema.Comment.collection, uri.collection), + eq(schema.Comment.rkey, uri.rkey), ), ) .limit(1); @@ -201,8 +229,7 @@ export const getComment = cache(async (authorDid: DID, rkey: string) => { type UpdateCommentInput = Partial>; export const updateComment = async ( - repo: DID, - rkey: string, + uri: CommentUri, input: UpdateCommentInput, ) => { await db @@ -212,16 +239,24 @@ export const updateComment = async ( cid: input.cid ?? undefined, }) .where( - and(eq(schema.Comment.authorDid, repo), eq(schema.Comment.rkey, rkey)), + and( + eq(schema.Comment.authorDid, uri.actor), + eq(schema.Comment.collection, uri.collection), + eq(schema.Comment.rkey, uri.rkey), + ), ); }; -export async function uncached_doesCommentExist(repo: DID, rkey: string) { +export async function uncached_doesCommentExist(uri: CommentUri) { const row = await db .select({ id: schema.Comment.id }) .from(schema.Comment) .where( - and(eq(schema.Comment.rkey, rkey), eq(schema.Comment.authorDid, repo)), + and( + eq(schema.Comment.authorDid, uri.actor), + eq(schema.Comment.collection, uri.collection), + eq(schema.Comment.rkey, uri.rkey), + ), ) .limit(1); @@ -275,14 +310,12 @@ export async function shouldHideComment(comment: CommentModel) { } type ModerateCommentInput = { - rkey: string; - authorDid: DID; + uri: CommentUri; cid: string; hide: boolean; }; export async function moderateComment({ - rkey, - authorDid, + uri, cid, hide, }: ModerateCommentInput) { @@ -298,8 +331,9 @@ export async function moderateComment({ .set({ status: hide ? "moderator_hidden" : "live" }) .where( and( - eq(schema.Comment.rkey, rkey), - eq(schema.Comment.authorDid, authorDid), + eq(schema.Comment.authorDid, uri.actor), + eq(schema.Comment.collection, uri.collection), + eq(schema.Comment.rkey, uri.rkey), eq(schema.Comment.cid, cid), ), ); @@ -307,32 +341,23 @@ export async function moderateComment({ export type CreateCommentInput = { cid?: string; - authorDid: DID; - rkey: string; + uri: CommentUri; content: string; createdAt: Date; - parent?: { - authorDid: DID; - rkey: string; - }; - post: { - authorDid: DID; - rkey: string; - }; + // TODO: parent and post should include CIDs as they are strongRefs in atproto + parent?: CommentUri; + post: PostUri; status: "live" | "pending"; - collection: CommentCollectionType; }; export async function createComment({ cid, - authorDid, - rkey, + uri, content, createdAt, parent, post, status, - collection, }: CreateCommentInput) { return await db.transaction(async (tx) => { const existingPost = ( @@ -341,8 +366,9 @@ export async function createComment({ .from(schema.Post) .where( and( + eq(schema.Post.authorDid, post.actor), + eq(schema.Post.collection, post.collection), eq(schema.Post.rkey, post.rkey), - eq(schema.Post.authorDid, post.authorDid), ), ) .limit(1) @@ -356,8 +382,9 @@ export async function createComment({ .from(schema.Comment) .where( and( + eq(schema.Comment.authorDid, parent.actor), + eq(schema.Comment.collection, parent.collection), eq(schema.Comment.rkey, parent.rkey), - eq(schema.Comment.authorDid, parent.authorDid), ), ) .limit(1) @@ -367,21 +394,21 @@ export async function createComment({ invariant(existingPost, "Post not found"); if (existingPost.status !== "live") { - throw new Error(`[naughty] Cannot comment on deleted post. ${authorDid}`); + throw new Error(`[naughty] Cannot comment on deleted post. ${uri.actor}`); } const [insertedComment] = await tx .insert(schema.Comment) .values({ cid: cid ?? "", - rkey, + rkey: uri.rkey, body: content, postId: existingPost.id, - authorDid, + authorDid: uri.actor, createdAt: createdAt, parentCommentId: existingParent?.id ?? null, status, - collection, + collection: uri.collection, }) .returning({ id: schema.Comment.id, @@ -406,15 +433,16 @@ export type DeleteCommentInput = { authorDid: DID; }; -export async function deleteComment({ rkey, authorDid }: DeleteCommentInput) { +export async function deleteComment(uri: CommentUri) { await db.transaction(async (tx) => { const [updatedComment] = await tx .update(schema.Comment) .set({ status: "deleted" }) .where( and( - eq(schema.Comment.rkey, rkey), - eq(schema.Comment.authorDid, authorDid), + eq(schema.Comment.authorDid, uri.actor), + eq(schema.Comment.collection, uri.collection), + eq(schema.Comment.rkey, uri.rkey), ne(schema.Comment.status, "deleted"), ), ) diff --git a/packages/frontpage/lib/data/db/post.ts b/packages/frontpage/lib/data/db/post.ts index 09532577..77335a75 100644 --- a/packages/frontpage/lib/data/db/post.ts +++ b/packages/frontpage/lib/data/db/post.ts @@ -17,7 +17,33 @@ import { getUser, isAdmin } from "../user"; import { type DID } from "../atproto/did"; import { newPostAggregateTrigger } from "./triggers"; import { invariant } from "@/lib/utils"; -import type { PostCollectionType } from "../atproto/repo"; +import { nsids, type PostCollectionType } from "../atproto/repo"; +import { type AtUri } from "@atproto/syntax"; +import { getDidFromHandleOrDid } from "../atproto/identity"; + +export type PostUri = { + actor: DID; + collection: PostCollectionType; + rkey: string; +}; + +export async function resolvePostUri(uri: AtUri): Promise { + invariant( + uri.collection === nsids.FyiUnravelFrontpagePost || + uri.collection === nsids.FyiFrontpageFeedPost, + "Invalid post collection", + ); + + const actor = await getDidFromHandleOrDid(uri.host); + + invariant(actor, "Failed to resolve actor from URI"); + + return { + actor, + collection: uri.collection, + rkey: uri.rkey, + }; +} const buildUserHasVotedQuery = cache(async () => { const user = await getUser(); @@ -56,6 +82,7 @@ export const getFrontpagePosts = cache(async (offset: number) => { rank: schema.PostAggregates.rank, userHasVoted: userHasVoted.postId, status: schema.Post.status, + collection: schema.Post.collection, }) .from(schema.PostAggregates) .innerJoin(schema.Post, eq(schema.PostAggregates.postId, schema.Post.id)) @@ -88,6 +115,7 @@ export const getFrontpagePosts = cache(async (offset: number) => { voteCount: row.voteCount, commentCount: row.commentCount, userHasVoted: Boolean(row.userHasVoted), + collection: row.collection, })); return { @@ -134,14 +162,18 @@ export const getUserPosts = cache(async (userDid: DID) => { })); }); -export const getPost = cache(async (authorDid: DID, rkey: string) => { +export const getPost = cache(async (uri: PostUri) => { const userHasVoted = await buildUserHasVotedQuery(); const rows = await db .select() .from(schema.Post) .where( - and(eq(schema.Post.authorDid, authorDid), eq(schema.Post.rkey, rkey)), + and( + eq(schema.Post.authorDid, uri.actor), + eq(schema.Post.collection, uri.collection), + eq(schema.Post.rkey, uri.rkey), + ), ) .innerJoin( schema.PostAggregates, @@ -162,12 +194,16 @@ export const getPost = cache(async (authorDid: DID, rkey: string) => { }; }); -export async function uncached_doesPostExist(authorDid: DID, rkey: string) { +export async function uncached_doesPostExist(uri: PostUri) { const row = await db .select({ id: schema.Post.id }) .from(schema.Post) .where( - and(eq(schema.Post.authorDid, authorDid), eq(schema.Post.rkey, rkey)), + and( + eq(schema.Post.authorDid, uri.actor), + eq(schema.Post.collection, uri.collection), + eq(schema.Post.rkey, uri.rkey), + ), ) .limit(1); @@ -176,33 +212,24 @@ export async function uncached_doesPostExist(authorDid: DID, rkey: string) { export type CreatePostInput = { post: { title: string; url: string; createdAt: Date }; - authorDid: DID; - rkey: string; + uri: PostUri; cid?: string; status: "live" | "pending"; - collection: PostCollectionType; }; -export async function createPost({ - post, - authorDid, - rkey, - cid, - status, - collection, -}: CreatePostInput) { +export async function createPost({ post, uri, cid, status }: CreatePostInput) { return await db.transaction(async (tx) => { const [insertedPostRow] = await tx .insert(schema.Post) .values({ - rkey, + rkey: uri.rkey, cid: cid ?? "", - authorDid, + authorDid: uri.actor, title: post.title, url: post.url, createdAt: post.createdAt, status, - collection, + collection: uri.collection, }) .returning({ postId: schema.Post.id }); @@ -222,33 +249,31 @@ type UpdatePostInput = Partial< Omit, "id"> >; -export const updatePost = async ( - repo: DID, - rkey: string, - input: UpdatePostInput, -) => { +export const updatePost = async (uri: PostUri, input: UpdatePostInput) => { await db .update(schema.Post) .set(input) - .where(and(eq(schema.Post.authorDid, repo), eq(schema.Post.rkey, rkey))); -}; - -export type DeletePostInput = { - authorDid: DID; - rkey: string; + .where( + and( + eq(schema.Post.authorDid, uri.actor), + eq(schema.Post.collection, uri.collection), + eq(schema.Post.rkey, uri.rkey), + ), + ); }; -export async function deletePost({ authorDid, rkey }: DeletePostInput) { - console.log("Deleting post", rkey); +export async function deletePost(uri: PostUri) { + console.log("Deleting post", uri.rkey); await db.transaction(async (tx) => { - console.log("Updating post status to deleted", rkey); + console.log("Updating post status to deleted", uri.rkey); const [updatedPost] = await tx .update(schema.Post) .set({ status: "deleted" }) .where( and( - eq(schema.Post.rkey, rkey), - eq(schema.Post.authorDid, authorDid), + eq(schema.Post.authorDid, uri.actor), + eq(schema.Post.collection, uri.collection), + eq(schema.Post.rkey, uri.rkey), ne(schema.Post.status, "deleted"), ), ) @@ -264,17 +289,11 @@ export async function deletePost({ authorDid, rkey }: DeletePostInput) { } type ModeratePostInput = { - rkey: string; - authorDid: DID; + uri: PostUri; cid: string; hide: boolean; }; -export async function moderatePost({ - rkey, - authorDid, - cid, - hide, -}: ModeratePostInput) { +export async function moderatePost({ uri, cid, hide }: ModeratePostInput) { const adminUser = await isAdmin(); if (!adminUser) { @@ -286,8 +305,9 @@ export async function moderatePost({ .set({ status: hide ? "moderator_hidden" : "live" }) .where( and( - eq(schema.Post.rkey, rkey), - eq(schema.Post.authorDid, authorDid), + eq(schema.Post.authorDid, uri.actor), + eq(schema.Post.collection, uri.collection), + eq(schema.Post.rkey, uri.rkey), eq(schema.Post.cid, cid), ), ); diff --git a/packages/frontpage/lib/data/db/report.ts b/packages/frontpage/lib/data/db/report.ts index e23b59e6..e4da096d 100644 --- a/packages/frontpage/lib/data/db/report.ts +++ b/packages/frontpage/lib/data/db/report.ts @@ -96,6 +96,7 @@ type CreateReportOptions = { subjectCid?: string; }; +// TODO: We don't need to pass uri and its parts separately, just pass AtUri and use it's parts export const createReport = async ({ subjectUri, subjectDid, diff --git a/packages/frontpage/lib/data/db/vote.ts b/packages/frontpage/lib/data/db/vote.ts index a04da959..b44a8931 100644 --- a/packages/frontpage/lib/data/db/vote.ts +++ b/packages/frontpage/lib/data/db/vote.ts @@ -12,7 +12,35 @@ import { newPostVoteAggregateTrigger, } from "./triggers"; import { invariant } from "@/lib/utils"; -import type { VoteCollectionType } from "../atproto/repo"; +import { nsids, type VoteCollectionType } from "../atproto/repo"; +import type { AtUri } from "@atproto/syntax"; +import { getDidFromHandleOrDid } from "../atproto/identity"; +import { type PostUri } from "./post"; +import { type CommentUri } from "./comment"; + +export type VoteUri = { + actor: DID; + collection: VoteCollectionType; + rkey: string; +}; + +export async function resolveVoteUri(uri: AtUri): Promise { + invariant( + uri.collection === nsids.FyiUnravelFrontpageVote || + uri.collection === nsids.FyiFrontpageFeedVote, + "Invalid vote collection", + ); + + const actor = await getDidFromHandleOrDid(uri.host); + + invariant(actor, "Failed to resolve actor from URI"); + + return { + actor, + collection: uri.collection, + rkey: uri.rkey, + }; +} export const getVoteForPost = cache(async (postId: number) => { const user = await getUser(); @@ -32,17 +60,15 @@ export const getVoteForPost = cache(async (postId: number) => { return rows[0] ?? null; }); -export const uncached_doesPostVoteExist = async ( - authorDid: DID, - rkey: string, -) => { +export const uncached_doesPostVoteExist = async (uri: VoteUri) => { const row = await db .select({ id: schema.PostVote.id }) .from(schema.PostVote) .where( and( - eq(schema.PostVote.authorDid, authorDid), - eq(schema.PostVote.rkey, rkey), + eq(schema.PostVote.authorDid, uri.actor), + eq(schema.PostVote.collection, uri.collection), + eq(schema.PostVote.rkey, uri.rkey), ), ) .limit(1); @@ -50,17 +76,15 @@ export const uncached_doesPostVoteExist = async ( return Boolean(row[0]); }; -export const uncached_doesCommentVoteExist = async ( - authorDid: DID, - rkey: string, -) => { +export const uncached_doesCommentVoteExist = async (uri: VoteUri) => { const row = await db .select({ id: schema.CommentVote.id }) .from(schema.CommentVote) .where( and( - eq(schema.CommentVote.authorDid, authorDid), - eq(schema.CommentVote.rkey, rkey), + eq(schema.CommentVote.authorDid, uri.actor), + eq(schema.CommentVote.collection, uri.collection), + eq(schema.CommentVote.rkey, uri.rkey), ), ) .limit(1); @@ -85,26 +109,19 @@ export const getVoteForComment = cache( }, ); -export type CreateVoteInput = { - repo: DID; - rkey: string; +export type CreateVoteInputCommon = { + uri: VoteUri; cid?: string; - subject: { - rkey: string; - authorDid: DID; - cid: string; - }; + subjectCid: string; status: "live" | "pending"; - collection: VoteCollectionType; }; export const createPostVote = async ({ - repo, - rkey, + uri, cid, subject, - collection, -}: CreateVoteInput) => { + subjectCid, +}: CreateVoteInputCommon & { subject: PostUri }) => { return await db.transaction(async (tx) => { const post = ( await tx @@ -112,30 +129,31 @@ export const createPostVote = async ({ .from(schema.Post) .where( and( + eq(schema.Post.authorDid, subject.actor), + eq(schema.Post.collection, subject.collection), eq(schema.Post.rkey, subject.rkey), - eq(schema.Post.authorDid, subject.authorDid), - eq(schema.Post.cid, subject.cid), + eq(schema.Post.cid, subjectCid), ), ) )[0]; invariant( post, - `Post not found with rkey: ${subject.rkey} repo: ${subject.authorDid} cid: ${subject.cid}`, + `Post not found with rkey: ${subject.rkey} repo: ${subject.actor} cid: ${subjectCid}`, ); - if (post.authorDid === repo) { - throw new Error(`[naughty] Cannot vote on own content ${repo}`); + if (post.authorDid === uri.actor) { + throw new Error(`[naughty] Cannot vote on own content ${uri.actor}`); } const [insertedVote] = await tx .insert(schema.PostVote) .values({ postId: post.id, - authorDid: repo, + authorDid: uri.actor, createdAt: new Date(), cid: cid ?? "", - rkey, - collection, + rkey: uri.rkey, + collection: uri.collection, }) .returning({ id: schema.PostVote.id }); @@ -150,12 +168,11 @@ export const createPostVote = async ({ }; export async function createCommentVote({ - repo, - rkey, + uri, cid, subject, - collection, -}: CreateVoteInput) { + subjectCid, +}: CreateVoteInputCommon & { subject: CommentUri }) { return await db.transaction(async (tx) => { const comment = ( await tx @@ -163,27 +180,29 @@ export async function createCommentVote({ .from(schema.Comment) .where( and( + eq(schema.Comment.authorDid, subject.actor), + eq(schema.Comment.collection, subject.collection), eq(schema.Comment.rkey, subject.rkey), - eq(schema.Comment.authorDid, subject.authorDid), + eq(schema.Comment.cid, subjectCid), ), ) )[0]; invariant(comment, `Comment not found with rkey: ${subject.rkey}`); - if (comment.authorDid === repo) { - throw new Error(`[naughty] Cannot vote on own content ${repo}`); + if (comment.authorDid === uri.actor) { + throw new Error(`[naughty] Cannot vote on own content ${uri.actor}`); } const [insertedVote] = await tx .insert(schema.CommentVote) .values({ commentId: comment.id, - authorDid: repo, + authorDid: uri.actor, createdAt: new Date(), cid: cid ?? "", - rkey, - collection, + rkey: uri.rkey, + collection: uri.collection, }) .returning({ id: schema.CommentVote.id }); @@ -199,61 +218,55 @@ export async function createCommentVote({ type UpdatePostVoteInput = Partial< Omit, "id"> -> & { - authorDid: DID; - rkey: string; -}; - -export const updatePostVote = async (input: UpdatePostVoteInput) => { - const { rkey, authorDid, ...updateFields } = input; +>; +export const updatePostVote = async ( + uri: VoteUri, + input: UpdatePostVoteInput, +) => { return await db .update(schema.PostVote) - .set(updateFields) + .set(input) .where( and( - eq(schema.PostVote.rkey, rkey), - eq(schema.PostVote.authorDid, authorDid), + eq(schema.PostVote.authorDid, uri.actor), + eq(schema.PostVote.collection, uri.collection), + eq(schema.PostVote.rkey, uri.rkey), ), ); }; type UpdateCommentVoteInput = Partial< - Omit, "id"> -> & { - authorDid: DID; - rkey: string; -}; - -export const updateCommentVote = async (input: UpdateCommentVoteInput) => { - const { rkey, authorDid, ...updateFields } = input; + Omit, "id"> +>; +export const updateCommentVote = async ( + uri: VoteUri, + input: UpdateCommentVoteInput, +) => { return await db .update(schema.CommentVote) - .set(updateFields) + .set(input) .where( and( - eq(schema.CommentVote.rkey, rkey), - eq(schema.CommentVote.authorDid, authorDid), + eq(schema.CommentVote.authorDid, uri.actor), + eq(schema.CommentVote.collection, uri.collection), + eq(schema.CommentVote.rkey, uri.rkey), ), ); }; -// Try deleting from both tables. In reality only one will have a record. -// Relies on sqlite not throwing an error if the record doesn't exist. -export type DeleteVoteInput = { - authorDid: DID; - rkey: string; -}; - -export const deleteVote = async ({ authorDid, rkey }: DeleteVoteInput) => { +export const deleteVote = async (uri: VoteUri) => { + // Try deleting from both tables. In reality only one will have a record. + // Relies on sqlite not throwing an error if the record doesn't exist. await db.transaction(async (tx) => { const [deletedCommentVoteRow] = await tx .delete(schema.CommentVote) .where( and( - eq(schema.CommentVote.rkey, rkey), - eq(schema.CommentVote.authorDid, authorDid), + eq(schema.CommentVote.authorDid, uri.actor), + eq(schema.CommentVote.collection, uri.collection), + eq(schema.CommentVote.rkey, uri.rkey), ), ) .returning({ @@ -264,8 +277,9 @@ export const deleteVote = async ({ authorDid, rkey }: DeleteVoteInput) => { .delete(schema.PostVote) .where( and( - eq(schema.PostVote.rkey, rkey), - eq(schema.PostVote.authorDid, authorDid), + eq(schema.PostVote.authorDid, uri.actor), + eq(schema.PostVote.collection, uri.collection), + eq(schema.PostVote.rkey, uri.rkey), ), ) .returning({ postId: schema.PostVote.postId }); diff --git a/packages/frontpage/lib/schema.ts b/packages/frontpage/lib/schema.ts index 3b583175..d148da14 100644 --- a/packages/frontpage/lib/schema.ts +++ b/packages/frontpage/lib/schema.ts @@ -26,7 +26,7 @@ const nsids = { FyiUnravelFrontpageComment: "fyi.unravel.frontpage.comment", FyiFrontpageFeedVote: "fyi.frontpage.feed.vote", FyiUnravelFrontpageVote: "fyi.unravel.frontpage.vote", -}; +} as const; const did = customType<{ data: DID }>({ dataType() {