From e7ef79658d61286bbcc7349cd5cbff4e35db5498 Mon Sep 17 00:00:00 2001 From: WillCorrigan Date: Tue, 26 Nov 2024 08:16:36 +0000 Subject: [PATCH 1/9] Refactor notification and comment APIs; unify input types and enhance error handling --- .../[postAuthor]/[postRkey]/_lib/actions.tsx | 40 ++--- .../frontpage/app/api/receive_hook/route.ts | 121 ++------------ packages/frontpage/lib/api/comment.ts | 71 ++++++++ packages/frontpage/lib/api/post.ts | 76 +++++++++ packages/frontpage/lib/api/relayHandler.ts | 157 ++++++++++++++++++ packages/frontpage/lib/api/vote.ts | 63 +++++++ .../frontpage/lib/data/atproto/comment.ts | 3 +- packages/frontpage/lib/data/atproto/event.ts | 2 +- packages/frontpage/lib/data/atproto/post.ts | 6 +- packages/frontpage/lib/data/atproto/vote.ts | 40 ++++- packages/frontpage/lib/data/db/comment.ts | 13 +- .../frontpage/lib/data/db/notification.ts | 2 +- packages/frontpage/lib/data/db/post.ts | 55 ++---- packages/frontpage/lib/data/db/vote.ts | 120 +++++++++---- 14 files changed, 538 insertions(+), 231 deletions(-) create mode 100644 packages/frontpage/lib/api/comment.ts create mode 100644 packages/frontpage/lib/api/post.ts create mode 100644 packages/frontpage/lib/api/relayHandler.ts create mode 100644 packages/frontpage/lib/api/vote.ts diff --git a/packages/frontpage/app/(app)/post/[postAuthor]/[postRkey]/_lib/actions.tsx b/packages/frontpage/app/(app)/post/[postAuthor]/[postRkey]/_lib/actions.tsx index 6e6b3137..ca047f27 100644 --- a/packages/frontpage/app/(app)/post/[postAuthor]/[postRkey]/_lib/actions.tsx +++ b/packages/frontpage/app/(app)/post/[postAuthor]/[postRkey]/_lib/actions.tsx @@ -1,19 +1,16 @@ "use server"; -import { - CommentCollection, - createComment, - deleteComment, -} from "@/lib/data/atproto/comment"; +import { CommentCollection } from "@/lib/data/atproto/comment"; import { DID } from "@/lib/data/atproto/did"; import { createVote, deleteVote } from "@/lib/data/atproto/vote"; -import { getComment, uncached_doesCommentExist } from "@/lib/data/db/comment"; +import { deleteComment, getComment } from "@/lib/data/db/comment"; import { getPost } from "@/lib/data/db/post"; import { parseReportForm } from "@/lib/data/db/report-shared"; import { createReport } from "@/lib/data/db/report"; import { getVoteForComment } from "@/lib/data/db/vote"; import { ensureUser } from "@/lib/data/user"; import { revalidatePath } from "next/cache"; +import { createComment } from "@/lib/api/comment"; export async function createCommentAction( input: { parentRkey?: string; postRkey: string; postAuthorDid: DID }, @@ -41,32 +38,19 @@ export async function createCommentAction( throw new Error(`[naughty] Cannot comment on deleted post. ${user.did}`); } - const { rkey } = await createComment({ - content, - post, + await createComment({ parent: comment, + post, + content, + repo: user.did, }); - await waitForComment(rkey); - revalidatePath(`/post`); -} -const MAX_POLLS = 15; -async function waitForComment(rkey: string) { - let exists = false; - let polls = 0; - while (!exists && polls < MAX_POLLS) { - exists = await uncached_doesCommentExist(rkey); - await new Promise((resolve) => setTimeout(resolve, 250)); - polls++; - } - if (!exists) { - throw new Error(`Comment not found after polling: ${rkey}`); - } + revalidatePath(`/post`); } export async function deleteCommentAction(rkey: string) { - await ensureUser(); - await deleteComment(rkey); + const user = await ensureUser(); + await deleteComment({ rkey, repo: user.did }); revalidatePath("/post"); } @@ -109,8 +93,8 @@ export async function commentVoteAction(input: { } export async function commentUnvoteAction(commentId: number) { - await ensureUser(); - const vote = await getVoteForComment(commentId); + const user = await ensureUser(); + const vote = await getVoteForComment(commentId, user.did); if (!vote) { console.error("Vote not found for comment", commentId); return; diff --git a/packages/frontpage/app/api/receive_hook/route.ts b/packages/frontpage/app/api/receive_hook/route.ts index 04bda27c..aa3702ee 100644 --- a/packages/frontpage/app/api/receive_hook/route.ts +++ b/packages/frontpage/app/api/receive_hook/route.ts @@ -1,22 +1,11 @@ import { db } from "@/lib/db"; import * as schema from "@/lib/schema"; -import { atprotoGetRecord } from "@/lib/data/atproto/record"; import { Commit } from "@/lib/data/atproto/event"; import * as atprotoPost from "@/lib/data/atproto/post"; -import * as dbPost from "@/lib/data/db/post"; import * as atprotoComment from "@/lib/data/atproto/comment"; -import { VoteRecord } from "@/lib/data/atproto/vote"; +import * as atprotoVote from "@/lib/data/atproto/vote"; import { getPdsUrl } from "@/lib/data/atproto/did"; -import { - unauthed_createComment, - unauthed_deleteComment, -} from "@/lib/data/db/comment"; -import { - unauthed_createPostVote, - unauthed_deleteVote, - unauthed_createCommentVote, -} from "@/lib/data/db/vote"; -import { unauthed_createNotification } from "@/lib/data/db/notification"; +import { handleComment, handlePost, handleVote } from "@/lib/api/relayHandler"; export async function POST(request: Request) { const auth = request.headers.get("Authorization"); @@ -40,101 +29,21 @@ export async function POST(request: Request) { const { collection, rkey } = op.path; console.log("Processing", collection, rkey, op.action); - if (collection === atprotoPost.PostCollection) { - if (op.action === "create") { - const record = await atprotoGetRecord({ - serviceEndpoint: service, - repo, - collection, - rkey, - }); - const postRecord = atprotoPost.PostRecord.parse(record.value); - await dbPost.unauthed_createPost({ - post: postRecord, - rkey, - authorDid: repo, - cid: record.cid, - offset: seq, - }); - } else if (op.action === "delete") { - await dbPost.unauthed_deletePost({ - rkey, - authorDid: repo, - offset: seq, - }); - } + switch (collection) { + case atprotoPost.PostCollection: + await handlePost({ op, repo, rkey }); + break; + case atprotoComment.CommentCollection: + await handleComment({ op, repo, rkey }); + break; + case atprotoVote.VoteCollection: + await handleVote({ op, repo, rkey }); + break; + default: + throw new Error(`Unknown collection: ${collection}, ${op}`); } - // repo is actually the did of the user - if (collection === atprotoComment.CommentCollection) { - if (op.action === "create") { - const comment = await atprotoComment.getComment({ rkey, repo }); - const createdComment = await unauthed_createComment({ - cid: comment.cid, - comment, - repo, - rkey, - }); - - const didToNotify = createdComment.parent - ? createdComment.parent.authorDid - : createdComment.post.authordid; - - if (didToNotify !== repo) { - await unauthed_createNotification({ - commentId: createdComment.id, - did: didToNotify, - reason: createdComment.parent ? "commentReply" : "postComment", - }); - } - } else if (op.action === "delete") { - await unauthed_deleteComment({ rkey, repo }); - } - - await db.transaction(async (tx) => { - await tx.insert(schema.ConsumedOffset).values({ offset: seq }); - }); - } - - if (collection === "fyi.unravel.frontpage.vote") { - if (op.action === "create") { - const hydratedRecord = await atprotoGetRecord({ - serviceEndpoint: service, - repo, - collection, - rkey, - }); - const hydratedVoteRecordValue = VoteRecord.parse(hydratedRecord.value); - - if ( - hydratedVoteRecordValue.subject.uri.collection === - atprotoPost.PostCollection - ) { - await unauthed_createPostVote({ - repo, - rkey, - vote: hydratedVoteRecordValue, - cid: hydratedRecord.cid, - }); - } else if ( - hydratedVoteRecordValue.subject.uri.collection === - atprotoComment.CommentCollection - ) { - await unauthed_createCommentVote({ - cid: hydratedRecord.cid, - vote: hydratedVoteRecordValue, - repo, - rkey, - }); - } - } else if (op.action === "delete") { - await unauthed_deleteVote(rkey, repo); - } - - await db.transaction(async (tx) => { - await tx.insert(schema.ConsumedOffset).values({ offset: seq }); - }); - } + await db.insert(schema.ConsumedOffset).values({ offset: seq }); }); await Promise.all(promises); diff --git a/packages/frontpage/lib/api/comment.ts b/packages/frontpage/lib/api/comment.ts new file mode 100644 index 00000000..f3ff7961 --- /dev/null +++ b/packages/frontpage/lib/api/comment.ts @@ -0,0 +1,71 @@ +import "server-only"; +import * as atproto from "../data/atproto/comment"; +import { DataLayerError } from "../data/error"; +import { ensureUser } from "../data/user"; +import * as db from "../data/db/comment"; +import { DID, getPdsUrl } from "../data/atproto/did"; + +export type ApiCreateCommentInput = atproto.CommentInput & { + repo: DID; +}; + +export async function createComment({ + parent, + post, + content, + repo, +}: ApiCreateCommentInput) { + const user = await ensureUser(); + const service = await getPdsUrl(repo); + + if (!service) { + throw new DataLayerError("Failed to get service URL"); + } + + try { + const { rkey, cid } = await atproto.createComment({ + parent, + post, + content, + }); + + if (!rkey || !cid) { + throw new DataLayerError("Failed to create comment"); + } + + const comment = await atproto.getComment({ + rkey, + repo: user.did, + }); + + if (!comment) { + throw new DataLayerError( + "Failed to retrieve atproto comment, database creation aborted", + ); + } + + const createdComment = await db.createComment({ + cid, + comment, + repo, + rkey: rkey, + }); + + if (!createdComment) { + throw new DataLayerError("Failed to insert comment in database"); + } + } catch (e) { + throw new DataLayerError(`Failed to create comment: ${e}`); + } +} + +export async function deleteComment({ rkey, repo }: db.DeleteCommentInput) { + await ensureUser(); + + try { + await atproto.deleteComment(rkey); + await db.deleteComment({ rkey, repo }); + } catch (e) { + throw new DataLayerError(`Failed to delete comment: ${e}`); + } +} diff --git a/packages/frontpage/lib/api/post.ts b/packages/frontpage/lib/api/post.ts new file mode 100644 index 00000000..e13d671e --- /dev/null +++ b/packages/frontpage/lib/api/post.ts @@ -0,0 +1,76 @@ +import "server-only"; +import * as db from "../data/db/post"; +import * as atproto from "../data/atproto/post"; +import { ensureUser, getBlueskyProfile } from "../data/user"; +import { DataLayerError } from "../data/error"; +import { sendDiscordMessage } from "../discord"; + +export type ApiCreatePostInput = { + post: atproto.Post; + rkey: string; + cid: string; +}; + +export async function createPost({ post, rkey, cid }: ApiCreatePostInput) { + const user = await ensureUser(); + + try { + const createdPost = await atproto.createPost({ + title: post.title, + url: post.url, + }); + + if (!createdPost) { + throw new DataLayerError("Failed to create post"); + } + + const dbCreatedPost = await db.createPost({ + post, + rkey, + authorDid: user.did, + cid, + }); + + if (!dbCreatedPost) { + throw new DataLayerError("Failed to insert post in database"); + } + } catch (e) { + throw new DataLayerError(`Failed to create post: ${e}`); + } + const bskyProfile = await getBlueskyProfile(user.did); + await sendDiscordMessage({ + embeds: [ + { + title: "New post on Frontpage", + description: post.title, + url: `https://frontpage.fyi/post/${user.did}/${rkey}`, + color: 10181046, + author: bskyProfile + ? { + name: `@${bskyProfile.handle}`, + icon_url: bskyProfile.avatar, + url: `https://frontpage.fyi/profile/${bskyProfile.handle}`, + } + : undefined, + fields: [ + { + name: "Link", + value: post.url, + }, + ], + }, + ], + }); +} + +export async function deletePost(rkey: string) { + const user = await ensureUser(); + + try { + await atproto.deletePost(rkey); + + await db.deletePost({ rkey, authorDid: user.did }); + } catch (e) { + throw new DataLayerError(`Failed to delete post: ${e}`); + } +} diff --git a/packages/frontpage/lib/api/relayHandler.ts b/packages/frontpage/lib/api/relayHandler.ts new file mode 100644 index 00000000..a1fa131d --- /dev/null +++ b/packages/frontpage/lib/api/relayHandler.ts @@ -0,0 +1,157 @@ +import * as atprotoComment from "../data/atproto/comment"; +import { DID } from "../data/atproto/did"; +import { Operation } from "../data/atproto/event"; +import * as atprotoPost from "../data/atproto/post"; +import * as atprotoVote from "../data/atproto/vote"; +import * as dbComment from "../data/db/comment"; +import * as dbNotification from "../data/db/notification"; +import * as dbPost from "../data/db/post"; +import * as dbVote from "../data/db/vote"; +import { getBlueskyProfile } from "../data/user"; +import { sendDiscordMessage } from "../discord"; + +type HandlerInput = { + op: Zod.infer; + repo: DID; + rkey: string; +}; + +// These handlers are called from the receive_hook route +// It processes operations received from the Drainpipe service +// Since we use read after write, we need to check if the record exists before creating it +// If it's a delete then setting the status to delete again doesn't matter + +export async function handlePost({ op, repo, rkey }: HandlerInput) { + if (op.action === "create") { + const postRecord = await atprotoPost.getPost({ + repo, + rkey, + }); + + const post = await dbPost.uncached_doesPostExist(repo, rkey); + + if (!post && postRecord) { + await dbPost.createPost({ + post: postRecord, + rkey, + authorDid: repo, + cid: postRecord.cid, + }); + + const bskyProfile = await getBlueskyProfile(repo); + await sendDiscordMessage({ + embeds: [ + { + title: "New post on Frontpage", + description: postRecord.title, + url: `https://frontpage.fyi/post/${repo}/${rkey}`, + color: 10181046, + author: bskyProfile + ? { + name: `@${bskyProfile.handle}`, + icon_url: bskyProfile.avatar, + url: `https://frontpage.fyi/profile/${bskyProfile.handle}`, + } + : undefined, + fields: [ + { + name: "Link", + value: postRecord.url, + }, + ], + }, + ], + }); + } + } else if (op.action === "delete") { + await dbPost.deletePost({ + rkey, + authorDid: repo, + }); + } +} + +export async function handleComment({ op, repo, rkey }: HandlerInput) { + if (op.action === "create") { + const commentRecord = await atprotoComment.getComment({ + rkey, + repo, + }); + + const comment = await dbComment.uncached_doesCommentExist(rkey); + + if (!comment && commentRecord) { + const createdComment = await dbComment.createComment({ + cid: commentRecord.cid, + comment: commentRecord, + repo, + rkey, + }); + + const didToNotify = createdComment.parent + ? createdComment.parent.authorDid + : createdComment.post.authordid; + + if (didToNotify !== repo) { + await dbNotification.createNotification({ + commentId: createdComment.id, + did: didToNotify, + reason: createdComment.parent ? "commentReply" : "postComment", + }); + } + } + } else if (op.action === "delete") { + await dbComment.deleteComment({ rkey, repo }); + } +} + +export async function handleVote({ op, repo, rkey }: HandlerInput) { + if (op.action === "create") { + const hydratedRecord = await atprotoVote.getVote({ + repo, + rkey, + }); + + switch (hydratedRecord.subject.uri.collection) { + case atprotoPost.PostCollection: + const postVote = await dbVote.uncached_doesPostVoteExist( + repo, + rkey, + hydratedRecord.cid, + ); + + if (!postVote) { + await dbVote.createPostVote({ + repo, + rkey, + vote: hydratedRecord, + cid: hydratedRecord.cid, + }); + } + break; + case atprotoComment.CommentCollection: + const commentVote = await dbVote.uncached_doesCommentVoteExist( + repo, + rkey, + hydratedRecord.cid, + ); + + if (!commentVote) { + await dbVote.createCommentVote({ + cid: hydratedRecord.cid, + vote: hydratedRecord, + repo, + rkey, + }); + } + break; + default: + throw new Error( + `Unknown collection: ${hydratedRecord.subject.uri.collection}`, + ); + } + } else if (op.action === "delete") { + // do we get collections with jetstream now? + await dbVote.deleteVote(rkey, repo); + } +} diff --git a/packages/frontpage/lib/api/vote.ts b/packages/frontpage/lib/api/vote.ts new file mode 100644 index 00000000..f17a9b24 --- /dev/null +++ b/packages/frontpage/lib/api/vote.ts @@ -0,0 +1,63 @@ +import "server-only"; +import * as db from "../data/db/vote"; +import * as atproto from "../data/atproto/vote"; +import { DataLayerError } from "../data/error"; +import { ensureUser } from "../data/user"; +import { PostCollection } from "../data/atproto/post"; +import { DID } from "../data/atproto/did"; +import { CommentCollection } from "../data/atproto/comment"; + +export type ApiCreateVoteInput = { + rkey: string; + cid: string; + repo: DID; + collection: typeof PostCollection | typeof CommentCollection; +}; + +export async function createVote({ + rkey, + cid, + repo, + collection, +}: ApiCreateVoteInput) { + await ensureUser(); + + try { + const createdVote = await atproto.createVote({ + subjectRkey: rkey, + subjectCid: cid, + subjectCollection: collection, + subjectAuthorDid: repo, + }); + + if (!createdVote) { + throw new DataLayerError("Failed to create vote"); + } + + if (collection == PostCollection) { + const dbCreatedVote = await db.createPostVote({ + repo, + rkey, + vote: createdVote, + cid, + }); + + if (!dbCreatedVote) { + throw new DataLayerError("Failed to insert post vote in database"); + } + } else if (collection == CommentCollection) { + const dbCreatedVote = await db.createCommentVote({ + repo, + rkey, + vote: createdVote, + cid, + }); + + if (!dbCreatedVote) { + throw new DataLayerError("Failed to insert comment vote in database"); + } + } + } catch (e) { + throw new DataLayerError(`Failed to create post vote: ${e}`); + } +} diff --git a/packages/frontpage/lib/data/atproto/comment.ts b/packages/frontpage/lib/data/atproto/comment.ts index 42dfb3ba..1ad7281f 100644 --- a/packages/frontpage/lib/data/atproto/comment.ts +++ b/packages/frontpage/lib/data/atproto/comment.ts @@ -30,7 +30,7 @@ export const CommentRecord = z.object({ export type Comment = z.infer; -type CommentInput = { +export type CommentInput = { parent?: { cid: string; rkey: string; authorDid: DID }; post: { cid: string; rkey: string; authorDid: DID }; content: string; @@ -68,6 +68,7 @@ export async function createComment({ parent, post, content }: CommentInput) { return { rkey: result.uri.rkey, + cid: result.cid, }; } diff --git a/packages/frontpage/lib/data/atproto/event.ts b/packages/frontpage/lib/data/atproto/event.ts index ab09328e..46ad05c9 100644 --- a/packages/frontpage/lib/data/atproto/event.ts +++ b/packages/frontpage/lib/data/atproto/event.ts @@ -40,7 +40,7 @@ const Path = z.string().transform((p, ctx) => { }; }); -const Operation = z.union([ +export const Operation = z.union([ z.object({ action: z.union([z.literal("create"), z.literal("update")]), path: Path, diff --git a/packages/frontpage/lib/data/atproto/post.ts b/packages/frontpage/lib/data/atproto/post.ts index 8d8f9309..56f35d92 100644 --- a/packages/frontpage/lib/data/atproto/post.ts +++ b/packages/frontpage/lib/data/atproto/post.ts @@ -19,7 +19,7 @@ export const PostRecord = z.object({ export type Post = z.infer; -type PostInput = { +export type PostInput = { title: string; url: string; }; @@ -57,12 +57,12 @@ export async function getPost({ rkey, repo }: { rkey: string; repo: DID }) { throw new DataLayerError("Failed to get service url"); } - const { value } = await atprotoGetRecord({ + const { value, cid } = await atprotoGetRecord({ serviceEndpoint: service, repo, collection: PostCollection, rkey, }); - return PostRecord.parse(value); + return { ...PostRecord.parse(value), cid }; } diff --git a/packages/frontpage/lib/data/atproto/vote.ts b/packages/frontpage/lib/data/atproto/vote.ts index 93bdb788..022873dd 100644 --- a/packages/frontpage/lib/data/atproto/vote.ts +++ b/packages/frontpage/lib/data/atproto/vote.ts @@ -1,11 +1,18 @@ import "server-only"; import { ensureUser } from "../user"; -import { atprotoCreateRecord, atprotoDeleteRecord } from "./record"; +import { + atprotoCreateRecord, + atprotoDeleteRecord, + atprotoGetRecord, +} from "./record"; import { z } from "zod"; import { PostCollection } from "./post"; import { CommentCollection } from "./comment"; -import { DID } from "./did"; +import { DID, getPdsUrl } from "./did"; import { createAtUriParser } from "./uri"; +import { DataLayerError } from "../error"; + +export const VoteCollection = "fyi.unravel.frontpage.vote"; const VoteSubjectCollection = z.union([ z.literal(PostCollection), @@ -22,7 +29,7 @@ export const VoteRecord = z.object({ export type Vote = z.infer; -type VoteInput = { +export type VoteInput = { subjectRkey: string; subjectCid: string; subjectCollection: string; @@ -48,17 +55,38 @@ export async function createVote({ VoteRecord.parse(record); - await atprotoCreateRecord({ - collection: "fyi.unravel.frontpage.vote", + const response = await atprotoCreateRecord({ + collection: VoteCollection, record: record, }); + + const createdRecord = { createdAt: record.createdAt, subject: response }; + const parsedRecord = VoteRecord.parse(createdRecord); + return parsedRecord; } export async function deleteVote(rkey: string) { await ensureUser(); await atprotoDeleteRecord({ - collection: "fyi.unravel.frontpage.vote", + collection: VoteCollection, rkey, }); } + +export async function getVote({ rkey, repo }: { rkey: string; repo: DID }) { + const service = await getPdsUrl(repo); + + if (!service) { + throw new DataLayerError("Failed to get service url"); + } + + const { value, cid } = await atprotoGetRecord({ + serviceEndpoint: service, + repo, + collection: VoteCollection, + rkey, + }); + + return { ...VoteRecord.parse(value), cid }; +} diff --git a/packages/frontpage/lib/data/db/comment.ts b/packages/frontpage/lib/data/db/comment.ts index ec190095..53c30573 100644 --- a/packages/frontpage/lib/data/db/comment.ts +++ b/packages/frontpage/lib/data/db/comment.ts @@ -253,19 +253,19 @@ export async function moderateComment({ ); } -export type UnauthedCreateCommentInput = { +export type CreateCommentInput = { cid: string; comment: atprotoComment.Comment; repo: DID; rkey: string; }; -export async function unauthed_createComment({ +export async function createComment({ cid, comment, repo, rkey, -}: UnauthedCreateCommentInput) { +}: CreateCommentInput) { return await db.transaction(async (tx) => { const parentComment = comment.parent != null @@ -332,15 +332,12 @@ export async function unauthed_createComment({ }); } -export type UnauthedDeleteCommentInput = { +export type DeleteCommentInput = { rkey: string; repo: DID; }; -export async function unauthed_deleteComment({ - rkey, - repo, -}: UnauthedDeleteCommentInput) { +export async function deleteComment({ rkey, repo }: DeleteCommentInput) { await db.transaction(async (tx) => { const [deletedComment] = await tx .update(schema.Comment) diff --git a/packages/frontpage/lib/data/db/notification.ts b/packages/frontpage/lib/data/db/notification.ts index 21a013ba..63d8ce6a 100644 --- a/packages/frontpage/lib/data/db/notification.ts +++ b/packages/frontpage/lib/data/db/notification.ts @@ -135,7 +135,7 @@ type CreateNotificationInput = { commentId: number; }; -export async function unauthed_createNotification({ +export async function createNotification({ did, reason, commentId, diff --git a/packages/frontpage/lib/data/db/post.ts b/packages/frontpage/lib/data/db/post.ts index 082d8afd..ebfd78f5 100644 --- a/packages/frontpage/lib/data/db/post.ts +++ b/packages/frontpage/lib/data/db/post.ts @@ -4,10 +4,9 @@ import { cache } from "react"; import { db } from "@/lib/db"; import { eq, sql, desc, and, isNull, or } from "drizzle-orm"; import * as schema from "@/lib/schema"; -import { getBlueskyProfile, getUser, isAdmin } from "../user"; +import { getUser, isAdmin } from "../user"; import * as atprotoPost from "../atproto/post"; import { DID } from "../atproto/did"; -import { sendDiscordMessage } from "@/lib/discord"; import { newPostAggregateTrigger } from "./triggers"; const buildUserHasVotedQuery = cache(async () => { @@ -164,22 +163,20 @@ export async function uncached_doesPostExist(authorDid: DID, rkey: string) { return Boolean(row[0]); } -type CreatePostInput = { +export type CreatePostInput = { post: atprotoPost.Post; authorDid: DID; rkey: string; cid: string; - offset: number; }; -export async function unauthed_createPost({ +export async function createPost({ post, rkey, authorDid, cid, - offset, }: CreatePostInput) { - await db.transaction(async (tx) => { + return await db.transaction(async (tx) => { const [insertedPostRow] = await tx .insert(schema.Post) .values({ @@ -198,47 +195,19 @@ export async function unauthed_createPost({ await newPostAggregateTrigger(insertedPostRow.postId, tx); - await tx.insert(schema.ConsumedOffset).values({ offset }); - }); - - const bskyProfile = await getBlueskyProfile(authorDid); - await sendDiscordMessage({ - embeds: [ - { - title: "New post on Frontpage", - description: post.title, - url: `https://frontpage.fyi/post/${authorDid}/${rkey}`, - color: 10181046, - author: bskyProfile - ? { - name: `@${bskyProfile.handle}`, - icon_url: bskyProfile.avatar, - url: `https://frontpage.fyi/profile/${bskyProfile.handle}`, - } - : undefined, - fields: [ - { - name: "Link", - value: post.url, - }, - ], - }, - ], + return { + postId: insertedPostRow.postId, + }; }); } -type DeletePostInput = { +export type DeletePostInput = { rkey: string; authorDid: DID; - offset: number; }; -export async function unauthed_deletePost({ - rkey, - authorDid, - offset, -}: DeletePostInput) { - console.log("Deleting post", rkey, offset); +export async function deletePost({ rkey, authorDid }: DeletePostInput) { + console.log("Deleting post", rkey); await db.transaction(async (tx) => { console.log("Updating post status to deleted", rkey); await tx @@ -247,10 +216,6 @@ export async function unauthed_deletePost({ .where( and(eq(schema.Post.rkey, rkey), eq(schema.Post.authorDid, authorDid)), ); - - console.log("Inserting consumed offset", offset); - await tx.insert(schema.ConsumedOffset).values({ offset }); - console.log("Done deleting post"); }); console.log("Done deleting post transaction"); } diff --git a/packages/frontpage/lib/data/db/vote.ts b/packages/frontpage/lib/data/db/vote.ts index e118f2c4..5489b9d8 100644 --- a/packages/frontpage/lib/data/db/vote.ts +++ b/packages/frontpage/lib/data/db/vote.ts @@ -32,38 +32,76 @@ export const getVoteForPost = cache(async (postId: number) => { return rows[0] ?? null; }); -export const getVoteForComment = cache(async (commentId: number) => { - const user = await getUser(); - if (!user) return null; +export const uncached_doesPostVoteExist = async ( + authorDid: DID, + rkey: string, + cid: string, +) => { + 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.cid, cid), + ), + ) + .limit(1); - const rows = await db - .select() + return Boolean(row[0]); +}; +export const uncached_doesCommentVoteExist = async ( + authorDid: DID, + rkey: string, + cid: string, +) => { + const row = await db + .select({ id: schema.CommentVote.id }) .from(schema.CommentVote) .where( and( - eq(schema.CommentVote.authorDid, user.did), - eq(schema.CommentVote.commentId, commentId), + eq(schema.CommentVote.authorDid, authorDid), + eq(schema.CommentVote.rkey, rkey), + eq(schema.CommentVote.cid, cid), ), ) .limit(1); - return rows[0] ?? null; -}); + return Boolean(row[0]); +}; + +export const getVoteForComment = cache( + async (commentId: number, userDid: DID) => { + const rows = await db + .select() + .from(schema.CommentVote) + .where( + and( + eq(schema.CommentVote.authorDid, userDid), + eq(schema.CommentVote.commentId, commentId), + ), + ) + .limit(1); + + return rows[0] ?? null; + }, +); -export type UnauthedCreatePostVoteInput = { +export type CreatePostVoteInput = { repo: DID; rkey: string; vote: atprotoVote.Vote; cid: string; }; -export const unauthed_createPostVote = async ({ +export const createPostVote = async ({ repo, rkey, vote, cid, -}: UnauthedCreatePostVoteInput) => { - await db.transaction(async (tx) => { +}: CreatePostVoteInput) => { + return await db.transaction(async (tx) => { const subject = ( await tx .select() @@ -80,19 +118,28 @@ export const unauthed_createPostVote = async ({ if (subject.authorDid === repo) { throw new Error(`[naughty] Cannot vote on own content ${repo}`); } - await tx.insert(schema.PostVote).values({ - postId: subject.id, - authorDid: repo, - createdAt: new Date(vote.createdAt), - cid, - rkey, - }); + const [insertedVote] = await tx + .insert(schema.PostVote) + .values({ + postId: subject.id, + authorDid: repo, + createdAt: new Date(vote.createdAt), + cid, + rkey, + }) + .returning({ id: schema.PostVote.id }); + + if (!insertedVote) { + throw new Error("Failed to insert vote"); + } await newPostVoteAggregateTrigger(subject.id, tx); + + return { id: insertedVote?.id }; }); }; -export type UnauthedCreateCommentVoteInput = { +export type CreateCommentVoteInput = { repo: DID; rkey: string; vote: atprotoVote.Vote; @@ -100,13 +147,13 @@ export type UnauthedCreateCommentVoteInput = { cid: string; }; -export async function unauthed_createCommentVote({ +export async function createCommentVote({ repo, rkey, vote, cid, -}: UnauthedCreateCommentVoteInput) { - await db.transaction(async (tx) => { +}: CreateCommentVoteInput) { + return await db.transaction(async (tx) => { const subject = ( await tx .select() @@ -124,21 +171,30 @@ export async function unauthed_createCommentVote({ throw new Error(`[naughty] Cannot vote on own content ${repo}`); } - await tx.insert(schema.CommentVote).values({ - commentId: subject.id, - authorDid: repo, - createdAt: new Date(vote.createdAt), - cid: cid, - rkey, - }); + const [insertedVote] = await tx + .insert(schema.CommentVote) + .values({ + commentId: subject.id, + authorDid: repo, + createdAt: new Date(vote.createdAt), + cid: cid, + rkey, + }) + .returning({ id: schema.CommentVote.id }); + + if (!insertedVote) { + throw new Error("Failed to insert vote"); + } await newCommentVoteAggregateTrigger(subject.postId, subject.id, tx); + + return { id: insertedVote?.id }; }); } // 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 const unauthed_deleteVote = async (rkey: string, repo: DID) => { +export const deleteVote = async (rkey: string, repo: DID) => { await db.transaction(async (tx) => { const [deletedCommentVoteRow] = await tx .delete(schema.CommentVote) From fb597ba638818fa43d529a87a9c88d814d2190e5 Mon Sep 17 00:00:00 2001 From: WillCorrigan Date: Wed, 27 Nov 2024 05:04:56 +0000 Subject: [PATCH 2/9] update actions to use api functions instead of db directly --- .../app/(app)/_components/post-card.tsx | 10 ++- .../[postAuthor]/[postRkey]/_lib/actions.tsx | 6 +- .../frontpage/app/(app)/post/new/_action.ts | 18 +---- .../frontpage/app/api/receive_hook/route.ts | 7 +- packages/frontpage/lib/api/comment.ts | 18 +++-- packages/frontpage/lib/api/post.ts | 79 +++++++++++-------- packages/frontpage/lib/api/relayHandler.ts | 27 ++++++- packages/frontpage/lib/api/vote.ts | 66 ++++++++++------ packages/frontpage/lib/data/atproto/post.ts | 1 + packages/frontpage/lib/data/atproto/vote.ts | 18 +++-- packages/frontpage/lib/data/db/vote.ts | 20 ++--- 11 files changed, 156 insertions(+), 114 deletions(-) diff --git a/packages/frontpage/app/(app)/_components/post-card.tsx b/packages/frontpage/app/(app)/_components/post-card.tsx index c0720e5b..eff84b50 100644 --- a/packages/frontpage/app/(app)/_components/post-card.tsx +++ b/packages/frontpage/app/(app)/_components/post-card.tsx @@ -1,10 +1,9 @@ import Link from "next/link"; -import { createVote, deleteVote } from "@/lib/data/atproto/vote"; import { getVoteForPost } from "@/lib/data/db/vote"; import { ensureUser, getUser } from "@/lib/data/user"; import { TimeAgo } from "@/lib/components/time-ago"; import { VoteButton } from "./vote-button"; -import { PostCollection, deletePost } from "@/lib/data/atproto/post"; +import { PostCollection } from "@/lib/data/atproto/post"; import { getVerifiedHandle } from "@/lib/data/atproto/identity"; import { UserHoverCard } from "@/lib/components/user-hover-card"; import type { DID } from "@/lib/data/atproto/did"; @@ -15,6 +14,8 @@ import { revalidatePath } from "next/cache"; import { ReportDialogDropdownButton } from "./report-dialog"; import { DeleteButton } from "./delete-button"; import { ShareDropdownButton } from "./share-button"; +import { createVote, deleteVote } from "@/lib/api/vote"; +import { deletePost } from "@/lib/api/post"; type PostProps = { id: number; @@ -56,9 +57,9 @@ export async function PostCard({ "use server"; await ensureUser(); await createVote({ - subjectAuthorDid: author, - subjectCid: cid, subjectRkey: rkey, + subjectCid: cid, + subjectAuthorDid: author, subjectCollection: PostCollection, }); }} @@ -130,6 +131,7 @@ export async function PostCard({ author, })} /> + {/* TODO: there's a bug here where delete shows on deleted posts */} {user?.did === author ? ( setTimeout(resolve, 250)); - polls++; - } -} diff --git a/packages/frontpage/app/api/receive_hook/route.ts b/packages/frontpage/app/api/receive_hook/route.ts index aa3702ee..7d026ec6 100644 --- a/packages/frontpage/app/api/receive_hook/route.ts +++ b/packages/frontpage/app/api/receive_hook/route.ts @@ -24,7 +24,7 @@ export async function POST(request: Request) { if (!service) { throw new Error("No AtprotoPersonalDataServer service found"); } - + console.log("ops", ops); const promises = ops.map(async (op) => { const { collection, rkey } = op.path; console.log("Processing", collection, rkey, op.action); @@ -42,11 +42,10 @@ export async function POST(request: Request) { default: throw new Error(`Unknown collection: ${collection}, ${op}`); } - - await db.insert(schema.ConsumedOffset).values({ offset: seq }); }); await Promise.all(promises); - + console.log("offset", seq); + await db.insert(schema.ConsumedOffset).values({ offset: seq }); return new Response("OK"); } diff --git a/packages/frontpage/lib/api/comment.ts b/packages/frontpage/lib/api/comment.ts index f3ff7961..419529bf 100644 --- a/packages/frontpage/lib/api/comment.ts +++ b/packages/frontpage/lib/api/comment.ts @@ -4,6 +4,7 @@ import { DataLayerError } from "../data/error"; import { ensureUser } from "../data/user"; import * as db from "../data/db/comment"; import { DID, getPdsUrl } from "../data/atproto/did"; +import { createNotification } from "../data/db/notification"; export type ApiCreateCommentInput = atproto.CommentInput & { repo: DID; @@ -16,11 +17,6 @@ export async function createComment({ repo, }: ApiCreateCommentInput) { const user = await ensureUser(); - const service = await getPdsUrl(repo); - - if (!service) { - throw new DataLayerError("Failed to get service URL"); - } try { const { rkey, cid } = await atproto.createComment({ @@ -54,6 +50,18 @@ export async function createComment({ if (!createdComment) { throw new DataLayerError("Failed to insert comment in database"); } + + const didToNotify = createdComment.parent + ? createdComment.parent.authorDid + : createdComment.post.authordid; + + if (didToNotify !== repo) { + await createNotification({ + commentId: createdComment.id, + did: didToNotify, + reason: createdComment.parent ? "commentReply" : "postComment", + }); + } } catch (e) { throw new DataLayerError(`Failed to create comment: ${e}`); } diff --git a/packages/frontpage/lib/api/post.ts b/packages/frontpage/lib/api/post.ts index e13d671e..2d7a0f87 100644 --- a/packages/frontpage/lib/api/post.ts +++ b/packages/frontpage/lib/api/post.ts @@ -5,25 +5,32 @@ import { ensureUser, getBlueskyProfile } from "../data/user"; import { DataLayerError } from "../data/error"; import { sendDiscordMessage } from "../discord"; -export type ApiCreatePostInput = { - post: atproto.Post; - rkey: string; - cid: string; -}; +export interface ApiCreatePostInput extends Omit {} -export async function createPost({ post, rkey, cid }: ApiCreatePostInput) { +export async function createPost({ title, url }: ApiCreatePostInput) { const user = await ensureUser(); try { - const createdPost = await atproto.createPost({ - title: post.title, - url: post.url, + const { rkey, cid } = await atproto.createPost({ + title: title, + url: url, }); - if (!createdPost) { + if (!rkey || !cid) { throw new DataLayerError("Failed to create post"); } + const post = await atproto.getPost({ + rkey, + repo: user.did, + }); + + if (!post) { + throw new DataLayerError( + "Failed to retrieve atproto post, database creation aborted", + ); + } + const dbCreatedPost = await db.createPost({ post, rkey, @@ -34,33 +41,37 @@ export async function createPost({ post, rkey, cid }: ApiCreatePostInput) { if (!dbCreatedPost) { throw new DataLayerError("Failed to insert post in database"); } + + const bskyProfile = await getBlueskyProfile(user.did); + + await sendDiscordMessage({ + embeds: [ + { + title: "New post on Frontpage", + description: title, + url: `https://frontpage.fyi/post/${user.did}/${rkey}`, + color: 10181046, + author: bskyProfile + ? { + name: `@${bskyProfile.handle}`, + icon_url: bskyProfile.avatar, + url: `https://frontpage.fyi/profile/${bskyProfile.handle}`, + } + : undefined, + fields: [ + { + name: "Link", + value: url, + }, + ], + }, + ], + }); + + return { rkey, cid }; } catch (e) { throw new DataLayerError(`Failed to create post: ${e}`); } - const bskyProfile = await getBlueskyProfile(user.did); - await sendDiscordMessage({ - embeds: [ - { - title: "New post on Frontpage", - description: post.title, - url: `https://frontpage.fyi/post/${user.did}/${rkey}`, - color: 10181046, - author: bskyProfile - ? { - name: `@${bskyProfile.handle}`, - icon_url: bskyProfile.avatar, - url: `https://frontpage.fyi/profile/${bskyProfile.handle}`, - } - : undefined, - fields: [ - { - name: "Link", - value: post.url, - }, - ], - }, - ], - }); } export async function deletePost(rkey: string) { diff --git a/packages/frontpage/lib/api/relayHandler.ts b/packages/frontpage/lib/api/relayHandler.ts index a1fa131d..adc54e31 100644 --- a/packages/frontpage/lib/api/relayHandler.ts +++ b/packages/frontpage/lib/api/relayHandler.ts @@ -31,13 +31,17 @@ export async function handlePost({ op, repo, rkey }: HandlerInput) { const post = await dbPost.uncached_doesPostExist(repo, rkey); if (!post && postRecord) { - await dbPost.createPost({ + const createdDbPost = await dbPost.createPost({ post: postRecord, rkey, authorDid: repo, cid: postRecord.cid, }); + if (!createdDbPost) { + throw new Error("Failed to insert post from relay in database"); + } + const bskyProfile = await getBlueskyProfile(repo); await sendDiscordMessage({ embeds: [ @@ -80,6 +84,7 @@ export async function handleComment({ op, repo, rkey }: HandlerInput) { const comment = await dbComment.uncached_doesCommentExist(rkey); + console.log("comment", comment); if (!comment && commentRecord) { const createdComment = await dbComment.createComment({ cid: commentRecord.cid, @@ -88,6 +93,10 @@ export async function handleComment({ op, repo, rkey }: HandlerInput) { rkey, }); + if (!createdComment) { + throw new Error("Failed to insert comment from relay in database"); + } + const didToNotify = createdComment.parent ? createdComment.parent.authorDid : createdComment.post.authordid; @@ -121,12 +130,18 @@ export async function handleVote({ op, repo, rkey }: HandlerInput) { ); if (!postVote) { - await dbVote.createPostVote({ + const createdDbPostVote = await dbVote.createPostVote({ repo, rkey, vote: hydratedRecord, cid: hydratedRecord.cid, }); + + if (!createdDbPostVote) { + throw new Error( + "Failed to insert post vote from relay in database", + ); + } } break; case atprotoComment.CommentCollection: @@ -137,12 +152,18 @@ export async function handleVote({ op, repo, rkey }: HandlerInput) { ); if (!commentVote) { - await dbVote.createCommentVote({ + const createdDbCommentVote = await dbVote.createCommentVote({ cid: hydratedRecord.cid, vote: hydratedRecord, repo, rkey, }); + + if (!createdDbCommentVote) { + throw new Error( + "Failed to insert comment vote from relay in database", + ); + } } break; default: diff --git a/packages/frontpage/lib/api/vote.ts b/packages/frontpage/lib/api/vote.ts index f17a9b24..65708bc0 100644 --- a/packages/frontpage/lib/api/vote.ts +++ b/packages/frontpage/lib/api/vote.ts @@ -8,49 +8,57 @@ import { DID } from "../data/atproto/did"; import { CommentCollection } from "../data/atproto/comment"; export type ApiCreateVoteInput = { - rkey: string; - cid: string; - repo: DID; - collection: typeof PostCollection | typeof CommentCollection; + subjectRkey: string; + subjectCid: string; + subjectAuthorDid: DID; + subjectCollection: typeof PostCollection | typeof CommentCollection; }; export async function createVote({ - rkey, - cid, - repo, - collection, + subjectRkey, + subjectCid, + subjectAuthorDid, + subjectCollection, }: ApiCreateVoteInput) { - await ensureUser(); + const user = await ensureUser(); try { - const createdVote = await atproto.createVote({ - subjectRkey: rkey, - subjectCid: cid, - subjectCollection: collection, - subjectAuthorDid: repo, + const { rkey, cid } = await atproto.createVote({ + subjectRkey, + subjectCid, + subjectCollection, + subjectAuthorDid, }); - if (!createdVote) { + if (!rkey || !cid) { throw new DataLayerError("Failed to create vote"); } - if (collection == PostCollection) { + const vote = await atproto.getVote({ rkey, repo: user.did }); + + if (!vote) { + throw new DataLayerError( + "Failed to retrieve atproto vote, database creation aborted", + ); + } + + if (subjectCollection == PostCollection) { const dbCreatedVote = await db.createPostVote({ - repo, - rkey, - vote: createdVote, + repo: user.did, cid, + rkey, + vote, }); if (!dbCreatedVote) { throw new DataLayerError("Failed to insert post vote in database"); } - } else if (collection == CommentCollection) { + } else if (subjectCollection == CommentCollection) { const dbCreatedVote = await db.createCommentVote({ - repo, - rkey, - vote: createdVote, + repo: user.did, cid, + rkey, + vote, }); if (!dbCreatedVote) { @@ -61,3 +69,15 @@ export async function createVote({ throw new DataLayerError(`Failed to create post vote: ${e}`); } } + +export async function deleteVote(rkey: string) { + const user = await ensureUser(); + + try { + await atproto.deleteVote(rkey); + + await db.deleteVote(rkey, user.did); + } catch (e) { + throw new DataLayerError(`Failed to delete vote: ${e}`); + } +} diff --git a/packages/frontpage/lib/data/atproto/post.ts b/packages/frontpage/lib/data/atproto/post.ts index 56f35d92..c68f277c 100644 --- a/packages/frontpage/lib/data/atproto/post.ts +++ b/packages/frontpage/lib/data/atproto/post.ts @@ -40,6 +40,7 @@ export async function createPost({ title, url }: PostInput) { return { rkey: result.uri.rkey, + cid: result.cid, }; } diff --git a/packages/frontpage/lib/data/atproto/vote.ts b/packages/frontpage/lib/data/atproto/vote.ts index 022873dd..e1e23b2d 100644 --- a/packages/frontpage/lib/data/atproto/vote.ts +++ b/packages/frontpage/lib/data/atproto/vote.ts @@ -1,5 +1,4 @@ import "server-only"; -import { ensureUser } from "../user"; import { atprotoCreateRecord, atprotoDeleteRecord, @@ -42,7 +41,6 @@ export async function createVote({ subjectCollection, subjectAuthorDid, }: VoteInput) { - await ensureUser(); const uri = `at://${subjectAuthorDid}/${subjectCollection}/${subjectRkey}`; const record = { @@ -53,21 +51,25 @@ export async function createVote({ }, }; - VoteRecord.parse(record); + const parseResult = VoteRecord.safeParse(record); + if (!parseResult.success) { + throw new DataLayerError("Invalid vote record", { + cause: parseResult.error, + }); + } const response = await atprotoCreateRecord({ collection: VoteCollection, record: record, }); - const createdRecord = { createdAt: record.createdAt, subject: response }; - const parsedRecord = VoteRecord.parse(createdRecord); - return parsedRecord; + return { + rkey: response.uri.rkey, + cid: response.cid, + }; } export async function deleteVote(rkey: string) { - await ensureUser(); - await atprotoDeleteRecord({ collection: VoteCollection, rkey, diff --git a/packages/frontpage/lib/data/db/vote.ts b/packages/frontpage/lib/data/db/vote.ts index 5489b9d8..9da86bf1 100644 --- a/packages/frontpage/lib/data/db/vote.ts +++ b/packages/frontpage/lib/data/db/vote.ts @@ -88,19 +88,19 @@ export const getVoteForComment = cache( }, ); -export type CreatePostVoteInput = { +export type CreateVoteInput = { repo: DID; + cid: string; rkey: string; vote: atprotoVote.Vote; - cid: string; }; export const createPostVote = async ({ repo, + cid, rkey, vote, - cid, -}: CreatePostVoteInput) => { +}: CreateVoteInput) => { return await db.transaction(async (tx) => { const subject = ( await tx @@ -139,20 +139,12 @@ export const createPostVote = async ({ }); }; -export type CreateCommentVoteInput = { - repo: DID; - rkey: string; - vote: atprotoVote.Vote; - - cid: string; -}; - export async function createCommentVote({ repo, rkey, vote, cid, -}: CreateCommentVoteInput) { +}: CreateVoteInput) { return await db.transaction(async (tx) => { const subject = ( await tx @@ -177,7 +169,7 @@ export async function createCommentVote({ commentId: subject.id, authorDid: repo, createdAt: new Date(vote.createdAt), - cid: cid, + cid, rkey, }) .returning({ id: schema.CommentVote.id }); From 5067a70074178bf052ee3badf146ed6f6d19fee8 Mon Sep 17 00:00:00 2001 From: WillCorrigan Date: Wed, 27 Nov 2024 05:11:00 +0000 Subject: [PATCH 3/9] it makes the linter happy or it gets error rows again --- packages/frontpage/app/(app)/post/new/_action.ts | 2 -- packages/frontpage/lib/api/comment.ts | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/frontpage/app/(app)/post/new/_action.ts b/packages/frontpage/app/(app)/post/new/_action.ts index 091931d6..751ceb3a 100644 --- a/packages/frontpage/app/(app)/post/new/_action.ts +++ b/packages/frontpage/app/(app)/post/new/_action.ts @@ -1,9 +1,7 @@ "use server"; import { createPost } from "@/lib/api/post"; -import { DID } from "@/lib/data/atproto/did"; import { getVerifiedHandle } from "@/lib/data/atproto/identity"; -import { uncached_doesPostExist } from "@/lib/data/db/post"; import { DataLayerError } from "@/lib/data/error"; import { ensureUser } from "@/lib/data/user"; import { redirect } from "next/navigation"; diff --git a/packages/frontpage/lib/api/comment.ts b/packages/frontpage/lib/api/comment.ts index 419529bf..347a26f2 100644 --- a/packages/frontpage/lib/api/comment.ts +++ b/packages/frontpage/lib/api/comment.ts @@ -3,7 +3,7 @@ import * as atproto from "../data/atproto/comment"; import { DataLayerError } from "../data/error"; import { ensureUser } from "../data/user"; import * as db from "../data/db/comment"; -import { DID, getPdsUrl } from "../data/atproto/did"; +import { DID } from "../data/atproto/did"; import { createNotification } from "../data/db/notification"; export type ApiCreateCommentInput = atproto.CommentInput & { From 4db6a147ea97a1f06c28fdad19471729fb0a164f Mon Sep 17 00:00:00 2001 From: WillCorrigan Date: Wed, 27 Nov 2024 12:44:15 +0000 Subject: [PATCH 4/9] Enhance error handling with invariant checks in comment, post, and vote APIs; add consumed offset check in receive hook --- .../frontpage/app/api/receive_hook/route.ts | 15 ++++++++-- packages/frontpage/lib/api/comment.ts | 30 ++++++++----------- packages/frontpage/lib/api/post.ts | 18 +++++------ packages/frontpage/lib/api/vote.ts | 22 +++++--------- 4 files changed, 41 insertions(+), 44 deletions(-) diff --git a/packages/frontpage/app/api/receive_hook/route.ts b/packages/frontpage/app/api/receive_hook/route.ts index 7d026ec6..a6c05f04 100644 --- a/packages/frontpage/app/api/receive_hook/route.ts +++ b/packages/frontpage/app/api/receive_hook/route.ts @@ -6,6 +6,7 @@ import * as atprotoComment from "@/lib/data/atproto/comment"; import * as atprotoVote from "@/lib/data/atproto/vote"; import { getPdsUrl } from "@/lib/data/atproto/did"; import { handleComment, handlePost, handleVote } from "@/lib/api/relayHandler"; +import { eq } from "drizzle-orm"; export async function POST(request: Request) { const auth = request.headers.get("Authorization"); @@ -20,11 +21,22 @@ export async function POST(request: Request) { } const { ops, repo, seq } = commit.data; + const row = await db + .select() + .from(schema.ConsumedOffset) + .where(eq(schema.ConsumedOffset.offset, seq)) + .limit(1); + + const operationConsumed = Boolean(row[0]); + if (operationConsumed) { + console.log("Already consumed sequence:", seq); + return new Response("OK"); + } + const service = await getPdsUrl(repo); if (!service) { throw new Error("No AtprotoPersonalDataServer service found"); } - console.log("ops", ops); const promises = ops.map(async (op) => { const { collection, rkey } = op.path; console.log("Processing", collection, rkey, op.action); @@ -45,7 +57,6 @@ export async function POST(request: Request) { }); await Promise.all(promises); - console.log("offset", seq); await db.insert(schema.ConsumedOffset).values({ offset: seq }); return new Response("OK"); } diff --git a/packages/frontpage/lib/api/comment.ts b/packages/frontpage/lib/api/comment.ts index 347a26f2..68e57084 100644 --- a/packages/frontpage/lib/api/comment.ts +++ b/packages/frontpage/lib/api/comment.ts @@ -5,6 +5,7 @@ import { ensureUser } from "../data/user"; import * as db from "../data/db/comment"; import { DID } from "../data/atproto/did"; import { createNotification } from "../data/db/notification"; +import { invariant } from "../utils"; export type ApiCreateCommentInput = atproto.CommentInput & { repo: DID; @@ -25,41 +26,36 @@ export async function createComment({ content, }); - if (!rkey || !cid) { - throw new DataLayerError("Failed to create comment"); - } + invariant(rkey && cid, "Failed to create comment, rkey/cid missing"); const comment = await atproto.getComment({ rkey, repo: user.did, }); - if (!comment) { - throw new DataLayerError( - "Failed to retrieve atproto comment, database creation aborted", - ); - } + invariant( + comment, + "Failed to retrieve atproto comment, database creation aborted", + ); - const createdComment = await db.createComment({ + const dbCreatedComment = await db.createComment({ cid, comment, repo, rkey: rkey, }); - if (!createdComment) { - throw new DataLayerError("Failed to insert comment in database"); - } + invariant(dbCreatedComment, "Failed to insert comment in database"); - const didToNotify = createdComment.parent - ? createdComment.parent.authorDid - : createdComment.post.authordid; + const didToNotify = dbCreatedComment.parent + ? dbCreatedComment.parent.authorDid + : dbCreatedComment.post.authordid; if (didToNotify !== repo) { await createNotification({ - commentId: createdComment.id, + commentId: dbCreatedComment.id, did: didToNotify, - reason: createdComment.parent ? "commentReply" : "postComment", + reason: dbCreatedComment.parent ? "commentReply" : "postComment", }); } } catch (e) { diff --git a/packages/frontpage/lib/api/post.ts b/packages/frontpage/lib/api/post.ts index 2d7a0f87..e8f8c214 100644 --- a/packages/frontpage/lib/api/post.ts +++ b/packages/frontpage/lib/api/post.ts @@ -4,6 +4,7 @@ import * as atproto from "../data/atproto/post"; import { ensureUser, getBlueskyProfile } from "../data/user"; import { DataLayerError } from "../data/error"; import { sendDiscordMessage } from "../discord"; +import { invariant } from "../utils"; export interface ApiCreatePostInput extends Omit {} @@ -16,20 +17,17 @@ export async function createPost({ title, url }: ApiCreatePostInput) { url: url, }); - if (!rkey || !cid) { - throw new DataLayerError("Failed to create post"); - } + invariant(rkey && cid, "Failed to create post, rkey/cid missing"); const post = await atproto.getPost({ rkey, repo: user.did, }); - if (!post) { - throw new DataLayerError( - "Failed to retrieve atproto post, database creation aborted", - ); - } + invariant( + post, + "Failed to retrieve atproto post, database creation aborted", + ); const dbCreatedPost = await db.createPost({ post, @@ -38,9 +36,7 @@ export async function createPost({ title, url }: ApiCreatePostInput) { cid, }); - if (!dbCreatedPost) { - throw new DataLayerError("Failed to insert post in database"); - } + invariant(dbCreatedPost, "Failed to insert post in database"); const bskyProfile = await getBlueskyProfile(user.did); diff --git a/packages/frontpage/lib/api/vote.ts b/packages/frontpage/lib/api/vote.ts index 65708bc0..587009c3 100644 --- a/packages/frontpage/lib/api/vote.ts +++ b/packages/frontpage/lib/api/vote.ts @@ -6,6 +6,7 @@ import { ensureUser } from "../data/user"; import { PostCollection } from "../data/atproto/post"; import { DID } from "../data/atproto/did"; import { CommentCollection } from "../data/atproto/comment"; +import { invariant } from "../utils"; export type ApiCreateVoteInput = { subjectRkey: string; @@ -30,17 +31,14 @@ export async function createVote({ subjectAuthorDid, }); - if (!rkey || !cid) { - throw new DataLayerError("Failed to create vote"); - } + invariant(rkey && cid, "Failed to create vote, rkey/cid missing"); const vote = await atproto.getVote({ rkey, repo: user.did }); - if (!vote) { - throw new DataLayerError( - "Failed to retrieve atproto vote, database creation aborted", - ); - } + invariant( + vote, + "Failed to retrieve atproto vote, database creation aborted", + ); if (subjectCollection == PostCollection) { const dbCreatedVote = await db.createPostVote({ @@ -50,9 +48,7 @@ export async function createVote({ vote, }); - if (!dbCreatedVote) { - throw new DataLayerError("Failed to insert post vote in database"); - } + invariant(dbCreatedVote, "Failed to insert post vote in database"); } else if (subjectCollection == CommentCollection) { const dbCreatedVote = await db.createCommentVote({ repo: user.did, @@ -61,9 +57,7 @@ export async function createVote({ vote, }); - if (!dbCreatedVote) { - throw new DataLayerError("Failed to insert comment vote in database"); - } + invariant(dbCreatedVote, "Failed to insert post vote in database"); } } catch (e) { throw new DataLayerError(`Failed to create post vote: ${e}`); From 54012f4741446ad93bb35123e0b113d5e5e11073 Mon Sep 17 00:00:00 2001 From: WillCorrigan Date: Mon, 2 Dec 2024 06:29:44 +0000 Subject: [PATCH 5/9] Refactor post, comment, and vote deletion functions to include authorDid; update createPost to accept createdAt parameter; add new dependency for common-web --- .../app/(app)/_components/post-card.tsx | 8 +- .../[postAuthor]/[postRkey]/_lib/actions.tsx | 4 +- .../frontpage/app/(app)/post/new/_action.ts | 2 +- .../frontpage/drizzle/0005_slimy_unus.sql | 38 + .../frontpage/drizzle/meta/0005_snapshot.json | 1119 +++++++++++++++++ packages/frontpage/drizzle/meta/_journal.json | 7 + packages/frontpage/lib/api/comment.ts | 48 +- packages/frontpage/lib/api/post.ts | 51 +- packages/frontpage/lib/api/relayHandler.ts | 101 +- packages/frontpage/lib/api/vote.ts | 44 +- .../frontpage/lib/data/atproto/comment.ts | 3 +- packages/frontpage/lib/data/atproto/post.ts | 5 +- packages/frontpage/lib/data/atproto/record.ts | 8 + packages/frontpage/lib/data/atproto/vote.ts | 3 +- packages/frontpage/lib/data/db/comment.ts | 133 +- packages/frontpage/lib/data/db/post.ts | 35 +- packages/frontpage/lib/data/db/vote.ts | 81 +- packages/frontpage/lib/schema.ts | 8 +- packages/frontpage/package.json | 1 + pnpm-lock.yaml | 27 +- 20 files changed, 1473 insertions(+), 253 deletions(-) create mode 100644 packages/frontpage/drizzle/0005_slimy_unus.sql create mode 100644 packages/frontpage/drizzle/meta/0005_snapshot.json diff --git a/packages/frontpage/app/(app)/_components/post-card.tsx b/packages/frontpage/app/(app)/_components/post-card.tsx index eff84b50..e73e10f1 100644 --- a/packages/frontpage/app/(app)/_components/post-card.tsx +++ b/packages/frontpage/app/(app)/_components/post-card.tsx @@ -65,14 +65,14 @@ export async function PostCard({ }} unvoteAction={async () => { "use server"; - await ensureUser(); + const user = await ensureUser(); const vote = await getVoteForPost(id); if (!vote) { // TODO: Show error notification console.error("Vote not found for post", id); return; } - await deleteVote(vote.rkey); + await deleteVote({ authorDid: user.did, rkey: vote.rkey }); }} initialState={ (await getUser())?.did === author @@ -148,8 +148,8 @@ export async function PostCard({ export async function deletePostAction(rkey: string) { "use server"; - await ensureUser(); - await deletePost(rkey); + const user = await ensureUser(); + await deletePost({ authorDid: user.did, rkey }); revalidatePath("/"); } diff --git a/packages/frontpage/app/(app)/post/[postAuthor]/[postRkey]/_lib/actions.tsx b/packages/frontpage/app/(app)/post/[postAuthor]/[postRkey]/_lib/actions.tsx index 8745bbe8..19ae948d 100644 --- a/packages/frontpage/app/(app)/post/[postAuthor]/[postRkey]/_lib/actions.tsx +++ b/packages/frontpage/app/(app)/post/[postAuthor]/[postRkey]/_lib/actions.tsx @@ -50,7 +50,7 @@ export async function createCommentAction( export async function deleteCommentAction(rkey: string) { const user = await ensureUser(); - await deleteComment({ rkey, repo: user.did }); + await deleteComment({ rkey, authorDid: user.did }); revalidatePath("/post"); } @@ -100,5 +100,5 @@ export async function commentUnvoteAction(commentId: number) { return; } - await deleteVote(vote.rkey); + await deleteVote({ authorDid: user.did, rkey: vote.rkey }); } diff --git a/packages/frontpage/app/(app)/post/new/_action.ts b/packages/frontpage/app/(app)/post/new/_action.ts index 751ceb3a..3bbe7c86 100644 --- a/packages/frontpage/app/(app)/post/new/_action.ts +++ b/packages/frontpage/app/(app)/post/new/_action.ts @@ -25,7 +25,7 @@ export async function newPostAction(_prevState: unknown, formData: FormData) { } try { - const { rkey } = await createPost({ title, url }); + const { rkey } = await createPost({ title, url, createdAt: new Date() }); const handle = await getVerifiedHandle(user.did); redirect(`/post/${handle}/${rkey}`); } catch (error) { diff --git a/packages/frontpage/drizzle/0005_slimy_unus.sql b/packages/frontpage/drizzle/0005_slimy_unus.sql new file mode 100644 index 00000000..a3058a14 --- /dev/null +++ b/packages/frontpage/drizzle/0005_slimy_unus.sql @@ -0,0 +1,38 @@ +DROP INDEX IF EXISTS `comments_cid_unique`;--> statement-breakpoint +DROP INDEX IF EXISTS "admin_users_did_unique";--> statement-breakpoint +DROP INDEX IF EXISTS "beta_users_did_unique";--> statement-breakpoint +DROP INDEX IF EXISTS "comments_author_did_rkey_unique";--> statement-breakpoint +DROP INDEX IF EXISTS "comment_aggregates_comment_id_unique";--> statement-breakpoint +DROP INDEX IF EXISTS "comment_id_idx";--> statement-breakpoint +DROP INDEX IF EXISTS "comment_votes_author_did_rkey_unique";--> statement-breakpoint +DROP INDEX IF EXISTS "comment_votes_author_did_comment_id_unique";--> statement-breakpoint +DROP INDEX IF EXISTS "labelled_profiles_did_unique";--> statement-breakpoint +DROP INDEX IF EXISTS "oauth_auth_requests_state_unique";--> statement-breakpoint +DROP INDEX IF EXISTS "posts_author_did_rkey_unique";--> statement-breakpoint +DROP INDEX IF EXISTS "post_id_idx";--> statement-breakpoint +DROP INDEX IF EXISTS "rank_idx";--> statement-breakpoint +DROP INDEX IF EXISTS "post_aggregates_post_id_unique";--> statement-breakpoint +DROP INDEX IF EXISTS "post_votes_author_did_rkey_unique";--> statement-breakpoint +DROP INDEX IF EXISTS "post_votes_author_did_post_id_unique";--> statement-breakpoint +ALTER TABLE `comments` ALTER COLUMN "cid" TO "cid" text NOT NULL DEFAULT '';--> statement-breakpoint +CREATE UNIQUE INDEX `admin_users_did_unique` ON `admin_users` (`did`);--> statement-breakpoint +CREATE UNIQUE INDEX `beta_users_did_unique` ON `beta_users` (`did`);--> statement-breakpoint +CREATE UNIQUE INDEX `comments_author_did_rkey_unique` ON `comments` (`author_did`,`rkey`);--> statement-breakpoint +CREATE UNIQUE INDEX `comment_aggregates_comment_id_unique` ON `comment_aggregates` (`comment_id`);--> statement-breakpoint +CREATE INDEX `comment_id_idx` ON `comment_aggregates` (`comment_id`);--> statement-breakpoint +CREATE UNIQUE INDEX `comment_votes_author_did_rkey_unique` ON `comment_votes` (`author_did`,`rkey`);--> statement-breakpoint +CREATE UNIQUE INDEX `comment_votes_author_did_comment_id_unique` ON `comment_votes` (`author_did`,`comment_id`);--> statement-breakpoint +CREATE UNIQUE INDEX `labelled_profiles_did_unique` ON `labelled_profiles` (`did`);--> statement-breakpoint +CREATE UNIQUE INDEX `oauth_auth_requests_state_unique` ON `oauth_auth_requests` (`state`);--> statement-breakpoint +CREATE UNIQUE INDEX `posts_author_did_rkey_unique` ON `posts` (`author_did`,`rkey`);--> statement-breakpoint +CREATE INDEX `post_id_idx` ON `post_aggregates` (`post_id`);--> statement-breakpoint +CREATE INDEX `rank_idx` ON `post_aggregates` (`rank`);--> statement-breakpoint +CREATE UNIQUE INDEX `post_aggregates_post_id_unique` ON `post_aggregates` (`post_id`);--> statement-breakpoint +CREATE UNIQUE INDEX `post_votes_author_did_rkey_unique` ON `post_votes` (`author_did`,`rkey`);--> statement-breakpoint +CREATE UNIQUE INDEX `post_votes_author_did_post_id_unique` ON `post_votes` (`author_did`,`post_id`);--> statement-breakpoint +DROP INDEX IF EXISTS `comment_votes_cid_unique`;--> statement-breakpoint +ALTER TABLE `comment_votes` ALTER COLUMN "cid" TO "cid" text NOT NULL DEFAULT '';--> statement-breakpoint +DROP INDEX IF EXISTS `posts_cid_unique`;--> statement-breakpoint +ALTER TABLE `posts` ALTER COLUMN "cid" TO "cid" text NOT NULL DEFAULT '';--> statement-breakpoint +DROP INDEX IF EXISTS `post_votes_cid_unique`;--> statement-breakpoint +ALTER TABLE `post_votes` ALTER COLUMN "cid" TO "cid" text NOT NULL DEFAULT ''; \ No newline at end of file diff --git a/packages/frontpage/drizzle/meta/0005_snapshot.json b/packages/frontpage/drizzle/meta/0005_snapshot.json new file mode 100644 index 00000000..80d27386 --- /dev/null +++ b/packages/frontpage/drizzle/meta/0005_snapshot.json @@ -0,0 +1,1119 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "cbbc3b16-be08-4e59-adb0-c89fd922137c", + "prevId": "849ad769-aa1c-4689-becb-f8f11170deec", + "tables": { + "admin_users": { + "name": "admin_users", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + }, + "did": { + "name": "did", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "admin_users_did_unique": { + "name": "admin_users_did_unique", + "columns": [ + "did" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "beta_users": { + "name": "beta_users", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "did": { + "name": "did", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "beta_users_did_unique": { + "name": "beta_users_did_unique", + "columns": [ + "did" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "comments": { + "name": "comments", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "rkey": { + "name": "rkey", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cid": { + "name": "cid", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "post_id": { + "name": "post_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "body": { + "name": "body", + "type": "text(10000)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "author_did": { + "name": "author_did", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'live'" + }, + "parent_comment_id": { + "name": "parent_comment_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "comments_author_did_rkey_unique": { + "name": "comments_author_did_rkey_unique", + "columns": [ + "author_did", + "rkey" + ], + "isUnique": true + } + }, + "foreignKeys": { + "comments_post_id_posts_id_fk": { + "name": "comments_post_id_posts_id_fk", + "tableFrom": "comments", + "tableTo": "posts", + "columnsFrom": [ + "post_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "parent_comment_id_fkey": { + "name": "parent_comment_id_fkey", + "tableFrom": "comments", + "tableTo": "comments", + "columnsFrom": [ + "parent_comment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "comment_aggregates": { + "name": "comment_aggregates", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "comment_id": { + "name": "comment_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "vote_count": { + "name": "vote_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "rank": { + "name": "rank", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CAST(1 AS REAL) / (pow(2,1.8)))" + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))" + } + }, + "indexes": { + "comment_aggregates_comment_id_unique": { + "name": "comment_aggregates_comment_id_unique", + "columns": [ + "comment_id" + ], + "isUnique": true + }, + "comment_id_idx": { + "name": "comment_id_idx", + "columns": [ + "comment_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "comment_aggregates_comment_id_comments_id_fk": { + "name": "comment_aggregates_comment_id_comments_id_fk", + "tableFrom": "comment_aggregates", + "tableTo": "comments", + "columnsFrom": [ + "comment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "comment_votes": { + "name": "comment_votes", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "comment_id": { + "name": "comment_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "author_did": { + "name": "author_did", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cid": { + "name": "cid", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "rkey": { + "name": "rkey", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "comment_votes_author_did_rkey_unique": { + "name": "comment_votes_author_did_rkey_unique", + "columns": [ + "author_did", + "rkey" + ], + "isUnique": true + }, + "comment_votes_author_did_comment_id_unique": { + "name": "comment_votes_author_did_comment_id_unique", + "columns": [ + "author_did", + "comment_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "comment_votes_comment_id_comments_id_fk": { + "name": "comment_votes_comment_id_comments_id_fk", + "tableFrom": "comment_votes", + "tableTo": "comments", + "columnsFrom": [ + "comment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "consumed_offsets": { + "name": "consumed_offsets", + "columns": { + "offset": { + "name": "offset", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "labelled_profiles": { + "name": "labelled_profiles", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "did": { + "name": "did", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_hidden": { + "name": "is_hidden", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "labels": { + "name": "labels", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + } + }, + "indexes": { + "labelled_profiles_did_unique": { + "name": "labelled_profiles_did_unique", + "columns": [ + "did" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "moderation_events": { + "name": "moderation_events", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "subject_uri": { + "name": "subject_uri", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "subject_did": { + "name": "subject_did", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "subject_collection": { + "name": "subject_collection", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "subject_rkey": { + "name": "subject_rkey", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "subject_cid": { + "name": "subject_cid", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + }, + "labels_added": { + "name": "labels_added", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "labels_removed": { + "name": "labels_removed", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "report_type": { + "name": "report_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "notifications": { + "name": "notifications", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "did": { + "name": "did", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))" + }, + "read_at": { + "name": "read_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "comment_id": { + "name": "comment_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "notifications_comment_id_comments_id_fk": { + "name": "notifications_comment_id_comments_id_fk", + "tableFrom": "notifications", + "tableTo": "comments", + "columnsFrom": [ + "comment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "oauth_auth_requests": { + "name": "oauth_auth_requests", + "columns": { + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "iss": { + "name": "iss", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "did": { + "name": "did", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "nonce": { + "name": "nonce", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "pkce_verifier": { + "name": "pkce_verifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "dpop_private_jwk": { + "name": "dpop_private_jwk", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "dpop_public_jwk": { + "name": "dpop_public_jwk", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "oauth_auth_requests_state_unique": { + "name": "oauth_auth_requests_state_unique", + "columns": [ + "state" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "oauth_sessions": { + "name": "oauth_sessions", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "did": { + "name": "did", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "iss": { + "name": "iss", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "dpop_nonce": { + "name": "dpop_nonce", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "dpop_private_jwk": { + "name": "dpop_private_jwk", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "dpop_public_jwk": { + "name": "dpop_public_jwk", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "posts": { + "name": "posts", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "rkey": { + "name": "rkey", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cid": { + "name": "cid", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "title": { + "name": "title", + "type": "text(300)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "author_did": { + "name": "author_did", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'live'" + } + }, + "indexes": { + "posts_author_did_rkey_unique": { + "name": "posts_author_did_rkey_unique", + "columns": [ + "author_did", + "rkey" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "post_aggregates": { + "name": "post_aggregates", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "post_id": { + "name": "post_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "comment_count": { + "name": "comment_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "vote_count": { + "name": "vote_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "rank": { + "name": "rank", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CAST(1 AS REAL) / (pow(2,1.8)))" + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))" + } + }, + "indexes": { + "post_id_idx": { + "name": "post_id_idx", + "columns": [ + "post_id" + ], + "isUnique": false + }, + "rank_idx": { + "name": "rank_idx", + "columns": [ + "rank" + ], + "isUnique": false + }, + "post_aggregates_post_id_unique": { + "name": "post_aggregates_post_id_unique", + "columns": [ + "post_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "post_aggregates_post_id_posts_id_fk": { + "name": "post_aggregates_post_id_posts_id_fk", + "tableFrom": "post_aggregates", + "tableTo": "posts", + "columnsFrom": [ + "post_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "post_votes": { + "name": "post_votes", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "post_id": { + "name": "post_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "author_did": { + "name": "author_did", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cid": { + "name": "cid", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "rkey": { + "name": "rkey", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "post_votes_author_did_rkey_unique": { + "name": "post_votes_author_did_rkey_unique", + "columns": [ + "author_did", + "rkey" + ], + "isUnique": true + }, + "post_votes_author_did_post_id_unique": { + "name": "post_votes_author_did_post_id_unique", + "columns": [ + "author_did", + "post_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "post_votes_post_id_posts_id_fk": { + "name": "post_votes_post_id_posts_id_fk", + "tableFrom": "post_votes", + "tableTo": "posts", + "columnsFrom": [ + "post_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "reports": { + "name": "reports", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "actioned_at": { + "name": "actioned_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "actioned_by": { + "name": "actioned_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "subject_uri": { + "name": "subject_uri", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "subject_did": { + "name": "subject_did", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "subject_collection": { + "name": "subject_collection", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "subject_rkey": { + "name": "subject_rkey", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "subject_cid": { + "name": "subject_cid", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + }, + "creator_comment": { + "name": "creator_comment", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "report_reason": { + "name": "report_reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'pending'" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + } + }, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/packages/frontpage/drizzle/meta/_journal.json b/packages/frontpage/drizzle/meta/_journal.json index e738bfd6..a26950d5 100644 --- a/packages/frontpage/drizzle/meta/_journal.json +++ b/packages/frontpage/drizzle/meta/_journal.json @@ -36,6 +36,13 @@ "when": 1730554481486, "tag": "0004_illegal_boomerang", "breakpoints": true + }, + { + "idx": 5, + "version": "6", + "when": 1733119575838, + "tag": "0005_slimy_unus", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/frontpage/lib/api/comment.ts b/packages/frontpage/lib/api/comment.ts index 68e57084..392e3b1f 100644 --- a/packages/frontpage/lib/api/comment.ts +++ b/packages/frontpage/lib/api/comment.ts @@ -6,6 +6,7 @@ import * as db from "../data/db/comment"; import { DID } from "../data/atproto/did"; import { createNotification } from "../data/db/notification"; import { invariant } from "../utils"; +import { TID } from "@atproto/common-web"; export type ApiCreateCommentInput = atproto.CommentInput & { repo: DID; @@ -19,56 +20,51 @@ export async function createComment({ }: ApiCreateCommentInput) { const user = await ensureUser(); + const rkey = TID.next().toString(); try { - const { rkey, cid } = await atproto.createComment({ + const dbCreatedComment = await db.createComment({ + cid: "", + authorDid: user.did, + rkey, + content, + createdAt: new Date(), parent, post, - content, }); - invariant(rkey && cid, "Failed to create comment, rkey/cid missing"); + invariant(dbCreatedComment, "Failed to insert comment in database"); - const comment = await atproto.getComment({ - rkey, - repo: user.did, + const { cid } = await atproto.createComment({ + parent, + post, + content, }); - invariant( - comment, - "Failed to retrieve atproto comment, database creation aborted", - ); + invariant(cid, "Failed to create comment, rkey/cid missing"); - const dbCreatedComment = await db.createComment({ - cid, - comment, - repo, - rkey: rkey, - }); + db.updateComment({ authorDid: user.did, rkey, cid }); - invariant(dbCreatedComment, "Failed to insert comment in database"); - - const didToNotify = dbCreatedComment.parent - ? dbCreatedComment.parent.authorDid - : dbCreatedComment.post.authordid; + const didToNotify = parent ? parent.authorDid : post.authorDid; if (didToNotify !== repo) { await createNotification({ commentId: dbCreatedComment.id, did: didToNotify, - reason: dbCreatedComment.parent ? "commentReply" : "postComment", + reason: parent ? "commentReply" : "postComment", }); } } catch (e) { + db.deleteComment({ authorDid: user.did, rkey }); throw new DataLayerError(`Failed to create comment: ${e}`); } } -export async function deleteComment({ rkey, repo }: db.DeleteCommentInput) { - await ensureUser(); +export async function deleteComment({ rkey }: db.DeleteCommentInput) { + const user = await ensureUser(); try { - await atproto.deleteComment(rkey); - await db.deleteComment({ rkey, repo }); + await atproto.deleteComment(user.did, rkey); + await db.deleteComment({ authorDid: user.did, rkey }); } catch (e) { throw new DataLayerError(`Failed to delete comment: ${e}`); } diff --git a/packages/frontpage/lib/api/post.ts b/packages/frontpage/lib/api/post.ts index e8f8c214..63b14828 100644 --- a/packages/frontpage/lib/api/post.ts +++ b/packages/frontpage/lib/api/post.ts @@ -5,39 +5,39 @@ import { ensureUser, getBlueskyProfile } from "../data/user"; import { DataLayerError } from "../data/error"; import { sendDiscordMessage } from "../discord"; import { invariant } from "../utils"; +import { TID } from "@atproto/common-web"; -export interface ApiCreatePostInput extends Omit {} +export type ApiCreatePostInput = { + title: string; + url: string; + createdAt: Date; +}; -export async function createPost({ title, url }: ApiCreatePostInput) { +export async function createPost({ + title, + url, + createdAt, +}: ApiCreatePostInput) { const user = await ensureUser(); + const rkey = TID.next().toString(); try { - const { rkey, cid } = await atproto.createPost({ - title: title, - url: url, - }); - - invariant(rkey && cid, "Failed to create post, rkey/cid missing"); - - const post = await atproto.getPost({ - rkey, - repo: user.did, - }); - - invariant( - post, - "Failed to retrieve atproto post, database creation aborted", - ); - const dbCreatedPost = await db.createPost({ - post, + post: { title, url, createdAt }, rkey, authorDid: user.did, - cid, }); - invariant(dbCreatedPost, "Failed to insert post in database"); + const { cid } = await atproto.createPost({ + title: title, + url: url, + }); + + invariant(cid, "Failed to create comment, rkey/cid missing"); + + db.updatePost({ authorDid: user.did, rkey, cid }); + const bskyProfile = await getBlueskyProfile(user.did); await sendDiscordMessage({ @@ -66,17 +66,18 @@ export async function createPost({ title, url }: ApiCreatePostInput) { return { rkey, cid }; } catch (e) { + db.deletePost({ authorDid: user.did, rkey }); throw new DataLayerError(`Failed to create post: ${e}`); } } -export async function deletePost(rkey: string) { +export async function deletePost({ rkey }: db.DeletePostInput) { const user = await ensureUser(); try { - await atproto.deletePost(rkey); + await atproto.deletePost(user.did, rkey); - await db.deletePost({ rkey, authorDid: user.did }); + await db.deletePost({ authorDid: user.did, rkey }); } catch (e) { throw new DataLayerError(`Failed to delete post: ${e}`); } diff --git a/packages/frontpage/lib/api/relayHandler.ts b/packages/frontpage/lib/api/relayHandler.ts index adc54e31..832907d6 100644 --- a/packages/frontpage/lib/api/relayHandler.ts +++ b/packages/frontpage/lib/api/relayHandler.ts @@ -9,6 +9,7 @@ import * as dbPost from "../data/db/post"; import * as dbVote from "../data/db/vote"; import { getBlueskyProfile } from "../data/user"; import { sendDiscordMessage } from "../discord"; +import { invariant } from "../utils"; type HandlerInput = { op: Zod.infer; @@ -28,19 +29,23 @@ export async function handlePost({ op, repo, rkey }: HandlerInput) { rkey, }); + invariant(postRecord, "atproto post record not found"); + const post = await dbPost.uncached_doesPostExist(repo, rkey); if (!post && postRecord) { const createdDbPost = await dbPost.createPost({ - post: postRecord, + post: { + title: postRecord.title, + url: postRecord.url, + createdAt: new Date(postRecord.createdAt), + }, rkey, - authorDid: repo, cid: postRecord.cid, + authorDid: repo, }); - if (!createdDbPost) { - throw new Error("Failed to insert post from relay in database"); - } + invariant(createdDbPost, "Failed to insert post from relay in database"); const bskyProfile = await getBlueskyProfile(repo); await sendDiscordMessage({ @@ -69,8 +74,8 @@ export async function handlePost({ op, repo, rkey }: HandlerInput) { } } else if (op.action === "delete") { await dbPost.deletePost({ - rkey, authorDid: repo, + rkey, }); } } @@ -82,35 +87,48 @@ export async function handleComment({ op, repo, rkey }: HandlerInput) { repo, }); - const comment = await dbComment.uncached_doesCommentExist(rkey); + invariant(commentRecord, "atproto comment record not found"); + + const comment = await dbComment.uncached_doesCommentExist(repo, rkey); - console.log("comment", comment); if (!comment && commentRecord) { const createdComment = await dbComment.createComment({ cid: commentRecord.cid, - comment: commentRecord, - repo, + authorDid: repo, rkey, + content: commentRecord.content, + createdAt: new Date(commentRecord.createdAt), + parent: commentRecord.parent + ? { + //TODO: is authority a DID? + authorDid: commentRecord.parent.uri.authority as DID, + rkey: commentRecord.parent.uri.rkey, + } + : undefined, + post: { + authorDid: commentRecord.post.uri.authority as DID, + rkey: commentRecord.post.uri.rkey, + }, }); if (!createdComment) { throw new Error("Failed to insert comment from relay in database"); } - const didToNotify = createdComment.parent - ? createdComment.parent.authorDid - : createdComment.post.authordid; + const didToNotify = commentRecord.parent + ? commentRecord.parent.uri.authority + : commentRecord.post.uri.authority; if (didToNotify !== repo) { await dbNotification.createNotification({ commentId: createdComment.id, - did: didToNotify, - reason: createdComment.parent ? "commentReply" : "postComment", + did: didToNotify as DID, + reason: commentRecord.parent ? "commentReply" : "postComment", }); } } } else if (op.action === "delete") { - await dbComment.deleteComment({ rkey, repo }); + await dbComment.deleteComment({ rkey, authorDid: repo }); } } @@ -123,47 +141,31 @@ export async function handleVote({ op, repo, rkey }: HandlerInput) { switch (hydratedRecord.subject.uri.collection) { case atprotoPost.PostCollection: - const postVote = await dbVote.uncached_doesPostVoteExist( + const createdDbPostVote = await dbVote.createPostVote({ repo, rkey, - hydratedRecord.cid, - ); + cid: hydratedRecord.cid, + subjectRkey: hydratedRecord.subject.uri.rkey, + subjectAuthorDid: hydratedRecord.subject.uri.authority as DID, + }); - if (!postVote) { - const createdDbPostVote = await dbVote.createPostVote({ - repo, - rkey, - vote: hydratedRecord, - cid: hydratedRecord.cid, - }); - - if (!createdDbPostVote) { - throw new Error( - "Failed to insert post vote from relay in database", - ); - } + if (!createdDbPostVote) { + throw new Error("Failed to insert post vote from relay in database"); } break; case atprotoComment.CommentCollection: - const commentVote = await dbVote.uncached_doesCommentVoteExist( + const createdDbCommentVote = await dbVote.createCommentVote({ repo, rkey, - hydratedRecord.cid, - ); + cid: hydratedRecord.cid, + subjectRkey: hydratedRecord.subject.uri.rkey, + subjectAuthorDid: hydratedRecord.subject.uri.authority as DID, + }); - if (!commentVote) { - const createdDbCommentVote = await dbVote.createCommentVote({ - cid: hydratedRecord.cid, - vote: hydratedRecord, - repo, - rkey, - }); - - if (!createdDbCommentVote) { - throw new Error( - "Failed to insert comment vote from relay in database", - ); - } + if (!createdDbCommentVote) { + throw new Error( + "Failed to insert comment vote from relay in database", + ); } break; default: @@ -172,7 +174,6 @@ export async function handleVote({ op, repo, rkey }: HandlerInput) { ); } } else if (op.action === "delete") { - // do we get collections with jetstream now? - await dbVote.deleteVote(rkey, repo); + await dbVote.deleteVote({ authorDid: repo, rkey }); } } diff --git a/packages/frontpage/lib/api/vote.ts b/packages/frontpage/lib/api/vote.ts index 587009c3..ba84886a 100644 --- a/packages/frontpage/lib/api/vote.ts +++ b/packages/frontpage/lib/api/vote.ts @@ -7,6 +7,7 @@ import { PostCollection } from "../data/atproto/post"; import { DID } from "../data/atproto/did"; import { CommentCollection } from "../data/atproto/comment"; import { invariant } from "../utils"; +import { TID } from "@atproto/common-web"; export type ApiCreateVoteInput = { subjectRkey: string; @@ -17,60 +18,55 @@ export type ApiCreateVoteInput = { export async function createVote({ subjectRkey, - subjectCid, subjectAuthorDid, subjectCollection, + subjectCid, }: ApiCreateVoteInput) { const user = await ensureUser(); + const rkey = TID.next().toString(); try { - const { rkey, cid } = await atproto.createVote({ - subjectRkey, - subjectCid, - subjectCollection, - subjectAuthorDid, - }); - - invariant(rkey && cid, "Failed to create vote, rkey/cid missing"); - - const vote = await atproto.getVote({ rkey, repo: user.did }); - - invariant( - vote, - "Failed to retrieve atproto vote, database creation aborted", - ); - if (subjectCollection == PostCollection) { const dbCreatedVote = await db.createPostVote({ repo: user.did, - cid, rkey, - vote, + subjectRkey, + subjectAuthorDid, }); invariant(dbCreatedVote, "Failed to insert post vote in database"); } else if (subjectCollection == CommentCollection) { const dbCreatedVote = await db.createCommentVote({ repo: user.did, - cid, rkey, - vote, + subjectRkey, + subjectAuthorDid, }); invariant(dbCreatedVote, "Failed to insert post vote in database"); } + + const { cid } = await atproto.createVote({ + subjectRkey, + subjectCid, + subjectCollection, + subjectAuthorDid, + }); + + invariant(cid, "Failed to create vote, cid missing"); } catch (e) { + db.deleteVote({ authorDid: user.did, rkey }); throw new DataLayerError(`Failed to create post vote: ${e}`); } } -export async function deleteVote(rkey: string) { +export async function deleteVote({ rkey }: db.DeleteVoteInput) { const user = await ensureUser(); try { - await atproto.deleteVote(rkey); + await atproto.deleteVote(user.did, rkey); - await db.deleteVote(rkey, user.did); + await db.deleteVote({ authorDid: user.did, rkey }); } catch (e) { throw new DataLayerError(`Failed to delete vote: ${e}`); } diff --git a/packages/frontpage/lib/data/atproto/comment.ts b/packages/frontpage/lib/data/atproto/comment.ts index 1ad7281f..09d80d43 100644 --- a/packages/frontpage/lib/data/atproto/comment.ts +++ b/packages/frontpage/lib/data/atproto/comment.ts @@ -72,8 +72,9 @@ export async function createComment({ parent, post, content }: CommentInput) { }; } -export async function deleteComment(rkey: string) { +export async function deleteComment(authorDid: DID, rkey: string) { await atprotoDeleteRecord({ + authorDid, rkey, collection: CommentCollection, }); diff --git a/packages/frontpage/lib/data/atproto/post.ts b/packages/frontpage/lib/data/atproto/post.ts index c68f277c..6b8c76cf 100644 --- a/packages/frontpage/lib/data/atproto/post.ts +++ b/packages/frontpage/lib/data/atproto/post.ts @@ -44,10 +44,11 @@ export async function createPost({ title, url }: PostInput) { }; } -export async function deletePost(rkey: string) { +export async function deletePost(authorDid: DID, rkey: string) { await atprotoDeleteRecord({ - rkey, + authorDid, collection: PostCollection, + rkey, }); } diff --git a/packages/frontpage/lib/data/atproto/record.ts b/packages/frontpage/lib/data/atproto/record.ts index 229531ca..df9569a2 100644 --- a/packages/frontpage/lib/data/atproto/record.ts +++ b/packages/frontpage/lib/data/atproto/record.ts @@ -4,6 +4,7 @@ import { ensureUser } from "../user"; import { DataLayerError } from "../error"; import { fetchAuthenticatedAtproto } from "@/lib/auth"; import { AtUri } from "./uri"; +import { DID } from "./did"; const CreateRecordResponse = z.object({ uri: AtUri, @@ -46,15 +47,22 @@ export async function atprotoCreateRecord({ } type DeleteRecordInput = { + authorDid: DID; collection: string; rkey: string; }; export async function atprotoDeleteRecord({ + authorDid, collection, rkey, }: DeleteRecordInput) { const user = await ensureUser(); + + if (user.did !== authorDid) { + throw new DataLayerError("User does not own record"); + } + const pdsUrl = new URL(user.pdsUrl); pdsUrl.pathname = "/xrpc/com.atproto.repo.deleteRecord"; diff --git a/packages/frontpage/lib/data/atproto/vote.ts b/packages/frontpage/lib/data/atproto/vote.ts index e1e23b2d..30e77e56 100644 --- a/packages/frontpage/lib/data/atproto/vote.ts +++ b/packages/frontpage/lib/data/atproto/vote.ts @@ -69,8 +69,9 @@ export async function createVote({ }; } -export async function deleteVote(rkey: string) { +export async function deleteVote(authorDid: DID, rkey: string) { await atprotoDeleteRecord({ + authorDid, collection: VoteCollection, rkey, }); diff --git a/packages/frontpage/lib/data/db/comment.ts b/packages/frontpage/lib/data/db/comment.ts index 53c30573..7a91258b 100644 --- a/packages/frontpage/lib/data/db/comment.ts +++ b/packages/frontpage/lib/data/db/comment.ts @@ -5,8 +5,7 @@ import { eq, sql, desc, and, InferSelectModel, isNotNull } from "drizzle-orm"; import * as schema from "@/lib/schema"; import { getUser, isAdmin } from "../user"; import { DID } from "../atproto/did"; -import * as atprotoComment from "../atproto/comment"; -import { Prettify } from "@/lib/utils"; +import { invariant, Prettify } from "@/lib/utils"; import { deleteCommentAggregateTrigger, newCommentAggregateTrigger, @@ -174,11 +173,31 @@ export const getComment = cache(async (rkey: string) => { return rows[0] ?? null; }); -export async function uncached_doesCommentExist(rkey: string) { +type UpdateCommentInput = Partial> & { + rkey: string; + authorDid: DID; +}; + +export const updateComment = async (input: UpdateCommentInput) => { + const { rkey, authorDid, ...updateFields } = input; + await db + .update(schema.Comment) + .set(updateFields) + .where( + and( + eq(schema.Comment.rkey, rkey), + eq(schema.Comment.authorDid, authorDid), + ), + ); +}; + +export async function uncached_doesCommentExist(repo: DID, rkey: string) { const row = await db .select({ id: schema.Comment.id }) .from(schema.Comment) - .where(eq(schema.Comment.rkey, rkey)) + .where( + and(eq(schema.Comment.rkey, rkey), eq(schema.Comment.authorDid, repo)), + ) .limit(1); return Boolean(row[0]); @@ -254,62 +273,84 @@ export async function moderateComment({ } export type CreateCommentInput = { - cid: string; - comment: atprotoComment.Comment; - repo: DID; + cid?: string; + authorDid: DID; rkey: string; + content: string; + createdAt: Date; + parent?: { + authorDid: DID; + rkey: string; + }; + post: { + authorDid: DID; + rkey: string; + }; }; export async function createComment({ cid, - comment, - repo, + authorDid, rkey, + content, + createdAt, + parent, + post, }: CreateCommentInput) { return await db.transaction(async (tx) => { - const parentComment = - comment.parent != null - ? ( - await tx - .select() - .from(schema.Comment) - .where(eq(schema.Comment.cid, comment.parent.cid)) - )[0] - : null; - - const post = ( + const existingPost = ( await tx - .select() + .select({ id: schema.Post.id, status: schema.Post.status }) .from(schema.Post) - .where(eq(schema.Post.cid, comment.post.cid)) + .where( + and( + eq(schema.Post.rkey, post.rkey), + eq(schema.Post.authorDid, post.authorDid), + ), + ) + .limit(1) )[0]; - if (!post) { - throw new Error("Post not found"); + let existingParent; + if (parent) { + existingParent = ( + await tx + .select({ id: schema.Comment.id }) + .from(schema.Comment) + .where( + and( + eq(schema.Comment.rkey, parent.rkey), + eq(schema.Comment.authorDid, parent.authorDid), + ), + ) + .limit(1) + )[0]; } - if (post.status !== "live") { - throw new Error(`[naughty] Cannot comment on deleted post. ${repo}`); + invariant(existingPost, "Post not found"); + + if (existingPost.status !== "live") { + throw new Error(`[naughty] Cannot comment on deleted post. ${authorDid}`); } + const [insertedComment] = await tx .insert(schema.Comment) .values({ - cid, + cid: cid ?? "", rkey, - body: comment.content, - postId: post.id, - authorDid: repo, - createdAt: new Date(comment.createdAt), - parentCommentId: parentComment?.id ?? null, + body: content, + postId: existingPost.id, + authorDid, + createdAt: createdAt, + parentCommentId: existingParent?.id ?? null, }) .returning({ id: schema.Comment.id, postId: schema.Comment.postId, + parentCommentId: schema.Comment.parentCommentId, }); - if (!insertedComment) { - throw new Error("Failed to insert comment"); - } + invariant(insertedComment, "Failed to insert comment"); await newCommentAggregateTrigger( insertedComment.postId, @@ -317,33 +358,25 @@ export async function createComment({ tx, ); - return { - id: insertedComment.id, - parent: parentComment - ? { - id: parentComment.id, - authorDid: parentComment.authorDid, - } - : null, - post: { - authordid: post.authorDid, - }, - }; + return insertedComment; }); } export type DeleteCommentInput = { rkey: string; - repo: DID; + authorDid: DID; }; -export async function deleteComment({ rkey, repo }: DeleteCommentInput) { +export async function deleteComment({ rkey, authorDid }: DeleteCommentInput) { await db.transaction(async (tx) => { const [deletedComment] = await tx .update(schema.Comment) .set({ status: "deleted" }) .where( - and(eq(schema.Comment.rkey, rkey), eq(schema.Comment.authorDid, repo)), + and( + eq(schema.Comment.rkey, rkey), + eq(schema.Comment.authorDid, authorDid), + ), ) .returning({ id: schema.Comment.id, diff --git a/packages/frontpage/lib/data/db/post.ts b/packages/frontpage/lib/data/db/post.ts index ebfd78f5..395aa363 100644 --- a/packages/frontpage/lib/data/db/post.ts +++ b/packages/frontpage/lib/data/db/post.ts @@ -2,7 +2,7 @@ import "server-only"; import { cache } from "react"; import { db } from "@/lib/db"; -import { eq, sql, desc, and, isNull, or } from "drizzle-orm"; +import { eq, sql, desc, and, isNull, or, InferSelectModel } from "drizzle-orm"; import * as schema from "@/lib/schema"; import { getUser, isAdmin } from "../user"; import * as atprotoPost from "../atproto/post"; @@ -70,7 +70,7 @@ export const getFrontpagePosts = cache(async (offset: number) => { const posts = rows.map((row) => ({ id: row.id, rkey: row.rkey, - cid: row.cid, + cid: row.cid!, title: row.title, url: row.url, createdAt: row.createdAt, @@ -164,16 +164,16 @@ export async function uncached_doesPostExist(authorDid: DID, rkey: string) { } export type CreatePostInput = { - post: atprotoPost.Post; + post: { title: string; url: string; createdAt: Date }; authorDid: DID; rkey: string; - cid: string; + cid?: string; }; export async function createPost({ post, - rkey, authorDid, + rkey, cid, }: CreatePostInput) { return await db.transaction(async (tx) => { @@ -181,11 +181,11 @@ export async function createPost({ .insert(schema.Post) .values({ rkey, - cid, + cid: cid ?? "", authorDid, title: post.title, url: post.url, - createdAt: new Date(post.createdAt), + createdAt: post.createdAt, }) .returning({ postId: schema.Post.id }); @@ -201,12 +201,29 @@ export async function createPost({ }); } -export type DeletePostInput = { +type UpdatePostInput = Partial< + Omit, "id"> +> & { + authorDid: DID; rkey: string; +}; + +export const updatePost = async (input: UpdatePostInput) => { + const { rkey, authorDid, ...updateFields } = input; + await db + .update(schema.Post) + .set(updateFields) + .where( + and(eq(schema.Post.rkey, rkey), eq(schema.Post.authorDid, authorDid)), + ); +}; + +export type DeletePostInput = { authorDid: DID; + rkey: string; }; -export async function deletePost({ rkey, authorDid }: DeletePostInput) { +export async function deletePost({ authorDid, rkey }: DeletePostInput) { console.log("Deleting post", rkey); await db.transaction(async (tx) => { console.log("Updating post status to deleted", rkey); diff --git a/packages/frontpage/lib/data/db/vote.ts b/packages/frontpage/lib/data/db/vote.ts index 9da86bf1..62fa9d76 100644 --- a/packages/frontpage/lib/data/db/vote.ts +++ b/packages/frontpage/lib/data/db/vote.ts @@ -12,7 +12,7 @@ import { newCommentVoteAggregateTrigger, newPostVoteAggregateTrigger, } from "./triggers"; -import { atUriToString } from "../atproto/uri"; +import { invariant } from "@/lib/utils"; export const getVoteForPost = cache(async (postId: number) => { const user = await getUser(); @@ -35,7 +35,6 @@ export const getVoteForPost = cache(async (postId: number) => { export const uncached_doesPostVoteExist = async ( authorDid: DID, rkey: string, - cid: string, ) => { const row = await db .select({ id: schema.PostVote.id }) @@ -44,17 +43,16 @@ export const uncached_doesPostVoteExist = async ( and( eq(schema.PostVote.authorDid, authorDid), eq(schema.PostVote.rkey, rkey), - eq(schema.PostVote.cid, cid), ), ) .limit(1); return Boolean(row[0]); }; + export const uncached_doesCommentVoteExist = async ( authorDid: DID, rkey: string, - cid: string, ) => { const row = await db .select({ id: schema.CommentVote.id }) @@ -63,7 +61,6 @@ export const uncached_doesCommentVoteExist = async ( and( eq(schema.CommentVote.authorDid, authorDid), eq(schema.CommentVote.rkey, rkey), - eq(schema.CommentVote.cid, cid), ), ) .limit(1); @@ -90,41 +87,44 @@ export const getVoteForComment = cache( export type CreateVoteInput = { repo: DID; - cid: string; rkey: string; - vote: atprotoVote.Vote; + cid?: string; + subjectRkey: string; + subjectAuthorDid: DID; }; export const createPostVote = async ({ repo, - cid, rkey, - vote, + cid, + subjectRkey, + subjectAuthorDid, }: CreateVoteInput) => { return await db.transaction(async (tx) => { - const subject = ( + const post = ( await tx .select() .from(schema.Post) - .where(eq(schema.Post.rkey, vote.subject.uri.rkey)) + .where( + and( + eq(schema.Post.rkey, subjectRkey), + eq(schema.Post.authorDid, subjectAuthorDid), + ), + ) )[0]; - if (!subject) { - throw new Error( - `Subject not found with uri: ${atUriToString(vote.subject.uri)}`, - ); - } + invariant(post, `Post not found with rkey: ${subjectRkey}`); - if (subject.authorDid === repo) { + if (post.authorDid === repo) { throw new Error(`[naughty] Cannot vote on own content ${repo}`); } const [insertedVote] = await tx .insert(schema.PostVote) .values({ - postId: subject.id, + postId: post.id, authorDid: repo, - createdAt: new Date(vote.createdAt), - cid, + createdAt: new Date(), + cid: cid ?? "", rkey, }) .returning({ id: schema.PostVote.id }); @@ -133,7 +133,7 @@ export const createPostVote = async ({ throw new Error("Failed to insert vote"); } - await newPostVoteAggregateTrigger(subject.id, tx); + await newPostVoteAggregateTrigger(post.id, tx); return { id: insertedVote?.id }; }); @@ -142,34 +142,36 @@ export const createPostVote = async ({ export async function createCommentVote({ repo, rkey, - vote, cid, + subjectRkey, + subjectAuthorDid, }: CreateVoteInput) { return await db.transaction(async (tx) => { - const subject = ( + const comment = ( await tx .select() .from(schema.Comment) - .where(eq(schema.Comment.rkey, vote.subject.uri.rkey)) + .where( + and( + eq(schema.Comment.rkey, subjectRkey), + eq(schema.Comment.authorDid, subjectAuthorDid), + ), + ) )[0]; - if (!subject) { - throw new Error( - `Subject not found with uri: ${atUriToString(vote.subject.uri)}`, - ); - } + invariant(comment, `Comment not found with rkey: ${subjectRkey}`); - if (subject.authorDid === repo) { + if (comment.authorDid === repo) { throw new Error(`[naughty] Cannot vote on own content ${repo}`); } const [insertedVote] = await tx .insert(schema.CommentVote) .values({ - commentId: subject.id, + commentId: comment.id, authorDid: repo, - createdAt: new Date(vote.createdAt), - cid, + createdAt: new Date(), + cid: cid ?? "", rkey, }) .returning({ id: schema.CommentVote.id }); @@ -178,7 +180,7 @@ export async function createCommentVote({ throw new Error("Failed to insert vote"); } - await newCommentVoteAggregateTrigger(subject.postId, subject.id, tx); + await newCommentVoteAggregateTrigger(comment.postId, comment.id, tx); return { id: insertedVote?.id }; }); @@ -186,14 +188,19 @@ export async function createCommentVote({ // 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 const deleteVote = async (rkey: string, repo: DID) => { +export type DeleteVoteInput = { + authorDid: DID; + rkey: string; +}; + +export const deleteVote = async ({ authorDid, rkey }: DeleteVoteInput) => { await db.transaction(async (tx) => { const [deletedCommentVoteRow] = await tx .delete(schema.CommentVote) .where( and( eq(schema.CommentVote.rkey, rkey), - eq(schema.CommentVote.authorDid, repo), + eq(schema.CommentVote.authorDid, authorDid), ), ) .returning({ @@ -205,7 +212,7 @@ export const deleteVote = async (rkey: string, repo: DID) => { .where( and( eq(schema.PostVote.rkey, rkey), - eq(schema.PostVote.authorDid, repo), + eq(schema.PostVote.authorDid, authorDid), ), ) .returning({ postId: schema.PostVote.postId }); diff --git a/packages/frontpage/lib/schema.ts b/packages/frontpage/lib/schema.ts index 8e93d8d7..231b184f 100644 --- a/packages/frontpage/lib/schema.ts +++ b/packages/frontpage/lib/schema.ts @@ -46,7 +46,7 @@ export const Post = sqliteTable( { id: integer("id").primaryKey(), rkey: text("rkey").notNull(), - cid: text("cid").notNull().unique(), + cid: text("cid").notNull().default(""), title: text("title", { length: MAX_POST_TITLE_LENGTH, }).notNull(), @@ -72,7 +72,7 @@ export const PostVote = sqliteTable( .references(() => Post.id), createdAt: dateIsoText("created_at").notNull(), authorDid: did("author_did").notNull(), - cid: text("cid").notNull().unique(), + cid: text("cid").notNull().default(""), rkey: text("rkey").notNull(), }, (t) => ({ @@ -108,7 +108,7 @@ export const Comment = sqliteTable( { id: integer("id").primaryKey(), rkey: text("rkey").notNull(), - cid: text("cid").notNull().unique(), + cid: text("cid").notNull().default(""), postId: integer("post_id") .notNull() .references(() => Post.id), @@ -159,7 +159,7 @@ export const CommentVote = sqliteTable( .references(() => Comment.id), createdAt: dateIsoText("created_at").notNull(), authorDid: did("author_did").notNull(), - cid: text("cid").notNull().unique(), + cid: text("cid").notNull().default(""), rkey: text("rkey").notNull(), }, (t) => ({ diff --git a/packages/frontpage/package.json b/packages/frontpage/package.json index 58749f6f..a3693b12 100644 --- a/packages/frontpage/package.json +++ b/packages/frontpage/package.json @@ -17,6 +17,7 @@ "type-check": "tsc --noEmit" }, "dependencies": { + "@atproto/common-web": "^0.3.1", "@atproto/oauth-types": "^0.1.2", "@atproto/syntax": "^0.3.0", "@libsql/client": "^0.14.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2ab396bc..e0ea0906 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -98,7 +98,7 @@ importers: version: 4.3.2(typescript@5.5.2)(vite@5.3.5(@types/node@20.13.0)(terser@5.34.1)) vitest: specifier: ^2.0.4 - version: 2.0.4(@types/node@20.13.0)(jsdom@24.1.1(bufferutil@4.0.8)(utf-8-validate@6.0.3))(terser@5.34.1) + version: 2.0.4(@types/node@20.13.0)(jsdom@24.1.1)(terser@5.34.1) packages/eslint-config: devDependencies: @@ -126,6 +126,9 @@ importers: packages/frontpage: dependencies: + '@atproto/common-web': + specifier: ^0.3.1 + version: 0.3.1 '@atproto/oauth-types': specifier: ^0.1.2 version: 0.1.2 @@ -300,7 +303,7 @@ importers: version: 4.3.2(typescript@5.4.5)(vite@5.3.5(@types/node@20.13.0)(terser@5.34.1)) vitest: specifier: ^2.0.4 - version: 2.0.4(@types/node@20.13.0)(jsdom@24.1.1(bufferutil@4.0.8)(utf-8-validate@6.0.3))(terser@5.34.1) + version: 2.0.4(@types/node@20.13.0)(jsdom@24.1.1)(terser@5.34.1) packages/frontpage-atproto-client: dependencies: @@ -409,9 +412,6 @@ packages: resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} engines: {node: '>=6.0.0'} - '@atproto/common-web@0.3.0': - resolution: {integrity: sha512-67VnV6JJyX+ZWyjV7xFQMypAgDmjVaR9ZCuU/QW+mqlqI7fex2uL4Fv+7/jHadgzhuJHVd6OHOvNn0wR5WZYtA==} - '@atproto/common-web@0.3.1': resolution: {integrity: sha512-N7wiTnus5vAr+lT//0y8m/FaHHLJ9LpGuEwkwDAeV3LCiPif4m/FS8x/QOYrx1PdZQwKso95RAPzCGWQBH5j6Q==} @@ -5950,13 +5950,6 @@ snapshots: '@jridgewell/gen-mapping': 0.3.5 '@jridgewell/trace-mapping': 0.3.25 - '@atproto/common-web@0.3.0': - dependencies: - graphemer: 1.4.0 - multiformats: 9.9.0 - uint8arrays: 3.0.0 - zod: 3.23.8 - '@atproto/common-web@0.3.1': dependencies: graphemer: 1.4.0 @@ -5966,7 +5959,7 @@ snapshots: '@atproto/common@0.4.1': dependencies: - '@atproto/common-web': 0.3.0 + '@atproto/common-web': 0.3.1 '@ipld/dag-cbor': 7.0.3 cbor-x: 1.5.9 iso-datestring-validator: 2.2.2 @@ -5985,7 +5978,7 @@ snapshots: '@atproto/identity@0.4.1': dependencies: - '@atproto/common-web': 0.3.0 + '@atproto/common-web': 0.3.1 '@atproto/crypto': 0.4.1 axios: 0.27.2 transitivePeerDependencies: @@ -6023,7 +6016,7 @@ snapshots: '@atproto/repo@0.4.3': dependencies: '@atproto/common': 0.4.1 - '@atproto/common-web': 0.3.0 + '@atproto/common-web': 0.3.1 '@atproto/crypto': 0.4.1 '@atproto/lexicon': 0.4.2 '@ipld/car': 3.2.4 @@ -9770,7 +9763,7 @@ snapshots: eslint: 8.57.0 optionalDependencies: '@typescript-eslint/eslint-plugin': 7.14.1(@typescript-eslint/parser@7.14.1(eslint@8.57.0)(typescript@5.5.2))(eslint@8.57.0)(typescript@5.5.2) - vitest: 2.0.4(@types/node@20.13.0)(jsdom@24.1.1(bufferutil@4.0.8)(utf-8-validate@6.0.3))(terser@5.34.1) + vitest: 2.0.4(@types/node@20.13.0)(jsdom@24.1.1)(terser@5.34.1) transitivePeerDependencies: - supports-color - typescript @@ -11954,7 +11947,7 @@ snapshots: fsevents: 2.3.3 terser: 5.34.1 - vitest@2.0.4(@types/node@20.13.0)(jsdom@24.1.1(bufferutil@4.0.8)(utf-8-validate@6.0.3))(terser@5.34.1): + vitest@2.0.4(@types/node@20.13.0)(jsdom@24.1.1)(terser@5.34.1): dependencies: '@ampproject/remapping': 2.3.0 '@vitest/expect': 2.0.4 From 8a58a304fd74935a241c53c7ef77dd2f240650e8 Mon Sep 17 00:00:00 2001 From: WillCorrigan Date: Mon, 2 Dec 2024 07:26:47 +0000 Subject: [PATCH 6/9] Implement update functions for post and comment votes and populate cid when atproto comment created --- packages/frontpage/lib/api/vote.ts | 6 ++++ packages/frontpage/lib/data/db/vote.ts | 45 ++++++++++++++++++++++++-- 2 files changed, 49 insertions(+), 2 deletions(-) diff --git a/packages/frontpage/lib/api/vote.ts b/packages/frontpage/lib/api/vote.ts index ba84886a..d53981e8 100644 --- a/packages/frontpage/lib/api/vote.ts +++ b/packages/frontpage/lib/api/vote.ts @@ -54,6 +54,12 @@ export async function createVote({ }); invariant(cid, "Failed to create vote, cid missing"); + + if (subjectCollection == PostCollection) { + db.updatePostVote({ authorDid: user.did, rkey, cid }); + } else if (subjectCollection == CommentCollection) { + db.updateCommentVote({ authorDid: user.did, rkey, cid }); + } } catch (e) { db.deleteVote({ authorDid: user.did, rkey }); throw new DataLayerError(`Failed to create post vote: ${e}`); diff --git a/packages/frontpage/lib/data/db/vote.ts b/packages/frontpage/lib/data/db/vote.ts index 62fa9d76..29da7434 100644 --- a/packages/frontpage/lib/data/db/vote.ts +++ b/packages/frontpage/lib/data/db/vote.ts @@ -2,8 +2,7 @@ import "server-only"; import { getUser } from "../user"; import { db } from "@/lib/db"; import * as schema from "@/lib/schema"; -import * as atprotoVote from "../atproto/vote"; -import { and, eq } from "drizzle-orm"; +import { and, eq, InferSelectModel } from "drizzle-orm"; import { cache } from "react"; import { DID } from "../atproto/did"; import { @@ -186,6 +185,48 @@ export async function createCommentVote({ }); } +type UpdatePostVoteInput = Partial< + Omit, "id"> +> & { + authorDid: DID; + rkey: string; +}; + +export const updatePostVote = async (input: UpdatePostVoteInput) => { + const { rkey, authorDid, ...updateFields } = input; + + return await db + .update(schema.PostVote) + .set(updateFields) + .where( + and( + eq(schema.PostVote.rkey, rkey), + eq(schema.PostVote.authorDid, authorDid), + ), + ); +}; + +type UpdateCommentVoteInput = Partial< + Omit, "id"> +> & { + authorDid: DID; + rkey: string; +}; + +export const updateCommentVote = async (input: UpdateCommentVoteInput) => { + const { rkey, authorDid, ...updateFields } = input; + + return await db + .update(schema.CommentVote) + .set(updateFields) + .where( + and( + eq(schema.CommentVote.rkey, rkey), + eq(schema.CommentVote.authorDid, authorDid), + ), + ); +}; + // 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 = { From ddf34234c1f7583b82de41434d99f696135b1483 Mon Sep 17 00:00:00 2001 From: WillCorrigan Date: Mon, 2 Dec 2024 09:18:44 +0000 Subject: [PATCH 7/9] Refactor database update calls in comment, post, and vote APIs to ensure proper async handling; add logging for delete operations --- packages/frontpage/lib/api/comment.ts | 5 +- packages/frontpage/lib/api/post.ts | 5 +- packages/frontpage/lib/api/relayHandler.ts | 54 ++++++++++++++-------- packages/frontpage/lib/api/vote.ts | 8 ++-- 4 files changed, 43 insertions(+), 29 deletions(-) diff --git a/packages/frontpage/lib/api/comment.ts b/packages/frontpage/lib/api/comment.ts index 392e3b1f..09530ae8 100644 --- a/packages/frontpage/lib/api/comment.ts +++ b/packages/frontpage/lib/api/comment.ts @@ -42,7 +42,7 @@ export async function createComment({ invariant(cid, "Failed to create comment, rkey/cid missing"); - db.updateComment({ authorDid: user.did, rkey, cid }); + await db.updateComment({ authorDid: user.did, rkey, cid }); const didToNotify = parent ? parent.authorDid : post.authorDid; @@ -63,8 +63,9 @@ export async function deleteComment({ rkey }: db.DeleteCommentInput) { const user = await ensureUser(); try { - await atproto.deleteComment(user.did, rkey); + console.log("deleteComment", rkey); await db.deleteComment({ authorDid: user.did, rkey }); + await atproto.deleteComment(user.did, rkey); } catch (e) { throw new DataLayerError(`Failed to delete comment: ${e}`); } diff --git a/packages/frontpage/lib/api/post.ts b/packages/frontpage/lib/api/post.ts index 63b14828..35d6e1ab 100644 --- a/packages/frontpage/lib/api/post.ts +++ b/packages/frontpage/lib/api/post.ts @@ -36,7 +36,7 @@ export async function createPost({ invariant(cid, "Failed to create comment, rkey/cid missing"); - db.updatePost({ authorDid: user.did, rkey, cid }); + await db.updatePost({ authorDid: user.did, rkey, cid }); const bskyProfile = await getBlueskyProfile(user.did); @@ -75,9 +75,8 @@ export async function deletePost({ rkey }: db.DeletePostInput) { const user = await ensureUser(); try { - await atproto.deletePost(user.did, rkey); - await db.deletePost({ authorDid: user.did, rkey }); + await atproto.deletePost(user.did, rkey); } catch (e) { throw new DataLayerError(`Failed to delete post: ${e}`); } diff --git a/packages/frontpage/lib/api/relayHandler.ts b/packages/frontpage/lib/api/relayHandler.ts index 832907d6..2b0da119 100644 --- a/packages/frontpage/lib/api/relayHandler.ts +++ b/packages/frontpage/lib/api/relayHandler.ts @@ -139,33 +139,46 @@ export async function handleVote({ op, repo, rkey }: HandlerInput) { rkey, }); + invariant(hydratedRecord, "atproto vote record not found"); + switch (hydratedRecord.subject.uri.collection) { case atprotoPost.PostCollection: - const createdDbPostVote = await dbVote.createPostVote({ - repo, - rkey, - cid: hydratedRecord.cid, - subjectRkey: hydratedRecord.subject.uri.rkey, - subjectAuthorDid: hydratedRecord.subject.uri.authority as DID, - }); - - if (!createdDbPostVote) { - throw new Error("Failed to insert post vote from relay in database"); + const postVote = await dbVote.uncached_doesCommentVoteExist(repo, rkey); + if (!postVote) { + const createdDbPostVote = await dbVote.createPostVote({ + repo, + rkey, + cid: hydratedRecord.cid, + subjectRkey: hydratedRecord.subject.uri.rkey, + subjectAuthorDid: hydratedRecord.subject.uri.authority as DID, + }); + + if (!createdDbPostVote) { + throw new Error( + "Failed to insert post vote from relay in database", + ); + } } break; case atprotoComment.CommentCollection: - const createdDbCommentVote = await dbVote.createCommentVote({ + const commentVote = await dbVote.uncached_doesCommentVoteExist( repo, rkey, - cid: hydratedRecord.cid, - subjectRkey: hydratedRecord.subject.uri.rkey, - subjectAuthorDid: hydratedRecord.subject.uri.authority as DID, - }); - - if (!createdDbCommentVote) { - throw new Error( - "Failed to insert comment vote from relay in database", - ); + ); + if (!commentVote) { + const createdDbCommentVote = await dbVote.createCommentVote({ + repo, + rkey, + cid: hydratedRecord.cid, + subjectRkey: hydratedRecord.subject.uri.rkey, + subjectAuthorDid: hydratedRecord.subject.uri.authority as DID, + }); + + if (!createdDbCommentVote) { + throw new Error( + "Failed to insert comment vote from relay in database", + ); + } } break; default: @@ -174,6 +187,7 @@ export async function handleVote({ op, repo, rkey }: HandlerInput) { ); } } else if (op.action === "delete") { + console.log("deleting vote", rkey); await dbVote.deleteVote({ authorDid: repo, rkey }); } } diff --git a/packages/frontpage/lib/api/vote.ts b/packages/frontpage/lib/api/vote.ts index d53981e8..daedb245 100644 --- a/packages/frontpage/lib/api/vote.ts +++ b/packages/frontpage/lib/api/vote.ts @@ -56,9 +56,9 @@ export async function createVote({ invariant(cid, "Failed to create vote, cid missing"); if (subjectCollection == PostCollection) { - db.updatePostVote({ authorDid: user.did, rkey, cid }); + await db.updatePostVote({ authorDid: user.did, rkey, cid }); } else if (subjectCollection == CommentCollection) { - db.updateCommentVote({ authorDid: user.did, rkey, cid }); + await db.updateCommentVote({ authorDid: user.did, rkey, cid }); } } catch (e) { db.deleteVote({ authorDid: user.did, rkey }); @@ -70,9 +70,9 @@ export async function deleteVote({ rkey }: db.DeleteVoteInput) { const user = await ensureUser(); try { - await atproto.deleteVote(user.did, rkey); + // await db.deleteVote({ authorDid: user.did, rkey }); - await db.deleteVote({ authorDid: user.did, rkey }); + await atproto.deleteVote(user.did, rkey); } catch (e) { throw new DataLayerError(`Failed to delete vote: ${e}`); } From 3948817f125758eee92a875c393f1db62f6aa486 Mon Sep 17 00:00:00 2001 From: WillCorrigan Date: Mon, 2 Dec 2024 10:54:54 +0000 Subject: [PATCH 8/9] Add rkey parameter to createComment, createPost, and createVote functions; update related types and API calls --- packages/frontpage/lib/api/comment.ts | 1 + packages/frontpage/lib/api/post.ts | 1 + packages/frontpage/lib/api/relayHandler.ts | 2 +- packages/frontpage/lib/api/vote.ts | 3 ++- packages/frontpage/lib/data/atproto/comment.ts | 9 ++++++++- packages/frontpage/lib/data/atproto/post.ts | 4 +++- packages/frontpage/lib/data/atproto/record.ts | 3 +++ packages/frontpage/lib/data/atproto/vote.ts | 3 +++ 8 files changed, 22 insertions(+), 4 deletions(-) diff --git a/packages/frontpage/lib/api/comment.ts b/packages/frontpage/lib/api/comment.ts index 09530ae8..adb259b4 100644 --- a/packages/frontpage/lib/api/comment.ts +++ b/packages/frontpage/lib/api/comment.ts @@ -38,6 +38,7 @@ export async function createComment({ parent, post, content, + rkey, }); invariant(cid, "Failed to create comment, rkey/cid missing"); diff --git a/packages/frontpage/lib/api/post.ts b/packages/frontpage/lib/api/post.ts index 35d6e1ab..8335359d 100644 --- a/packages/frontpage/lib/api/post.ts +++ b/packages/frontpage/lib/api/post.ts @@ -32,6 +32,7 @@ export async function createPost({ const { cid } = await atproto.createPost({ title: title, url: url, + rkey, }); invariant(cid, "Failed to create comment, rkey/cid missing"); diff --git a/packages/frontpage/lib/api/relayHandler.ts b/packages/frontpage/lib/api/relayHandler.ts index 2b0da119..bbcd4a33 100644 --- a/packages/frontpage/lib/api/relayHandler.ts +++ b/packages/frontpage/lib/api/relayHandler.ts @@ -143,7 +143,7 @@ export async function handleVote({ op, repo, rkey }: HandlerInput) { switch (hydratedRecord.subject.uri.collection) { case atprotoPost.PostCollection: - const postVote = await dbVote.uncached_doesCommentVoteExist(repo, rkey); + const postVote = await dbVote.uncached_doesPostVoteExist(repo, rkey); if (!postVote) { const createdDbPostVote = await dbVote.createPostVote({ repo, diff --git a/packages/frontpage/lib/api/vote.ts b/packages/frontpage/lib/api/vote.ts index daedb245..d3d402a8 100644 --- a/packages/frontpage/lib/api/vote.ts +++ b/packages/frontpage/lib/api/vote.ts @@ -47,6 +47,7 @@ export async function createVote({ } const { cid } = await atproto.createVote({ + rkey, subjectRkey, subjectCid, subjectCollection, @@ -70,7 +71,7 @@ export async function deleteVote({ rkey }: db.DeleteVoteInput) { const user = await ensureUser(); try { - // await db.deleteVote({ authorDid: user.did, rkey }); + await db.deleteVote({ authorDid: user.did, rkey }); await atproto.deleteVote(user.did, rkey); } catch (e) { diff --git a/packages/frontpage/lib/data/atproto/comment.ts b/packages/frontpage/lib/data/atproto/comment.ts index 09d80d43..1bfa8e1c 100644 --- a/packages/frontpage/lib/data/atproto/comment.ts +++ b/packages/frontpage/lib/data/atproto/comment.ts @@ -34,9 +34,15 @@ export type CommentInput = { parent?: { cid: string; rkey: string; authorDid: DID }; post: { cid: string; rkey: string; authorDid: DID }; content: string; + rkey: string; }; -export async function createComment({ parent, post, content }: CommentInput) { +export async function createComment({ + parent, + post, + content, + rkey, +}: CommentInput) { // Collapse newlines into a single \n\n and trim whitespace const sanitizedContent = content.replace(/\n\n+/g, "\n\n").trim(); const record = { @@ -64,6 +70,7 @@ export async function createComment({ parent, post, content }: CommentInput) { const result = await atprotoCreateRecord({ record, collection: CommentCollection, + rkey, }); return { diff --git a/packages/frontpage/lib/data/atproto/post.ts b/packages/frontpage/lib/data/atproto/post.ts index 6b8c76cf..3a79028c 100644 --- a/packages/frontpage/lib/data/atproto/post.ts +++ b/packages/frontpage/lib/data/atproto/post.ts @@ -22,9 +22,10 @@ export type Post = z.infer; export type PostInput = { title: string; url: string; + rkey: string; }; -export async function createPost({ title, url }: PostInput) { +export async function createPost({ title, url, rkey }: PostInput) { const record = { title, url, createdAt: new Date().toISOString() }; const parseResult = PostRecord.safeParse(record); if (!parseResult.success) { @@ -36,6 +37,7 @@ export async function createPost({ title, url }: PostInput) { const result = await atprotoCreateRecord({ record, collection: PostCollection, + rkey, }); return { diff --git a/packages/frontpage/lib/data/atproto/record.ts b/packages/frontpage/lib/data/atproto/record.ts index df9569a2..26667fb5 100644 --- a/packages/frontpage/lib/data/atproto/record.ts +++ b/packages/frontpage/lib/data/atproto/record.ts @@ -14,11 +14,13 @@ const CreateRecordResponse = z.object({ type CreateRecordInput = { record: unknown; collection: string; + rkey: string; }; export async function atprotoCreateRecord({ record, collection, + rkey, }: CreateRecordInput) { const user = await ensureUser(); const pdsUrl = new URL(user.pdsUrl); @@ -32,6 +34,7 @@ export async function atprotoCreateRecord({ body: JSON.stringify({ repo: user.did, collection, + rkey, validate: false, record: record, }), diff --git a/packages/frontpage/lib/data/atproto/vote.ts b/packages/frontpage/lib/data/atproto/vote.ts index 30e77e56..68027a6c 100644 --- a/packages/frontpage/lib/data/atproto/vote.ts +++ b/packages/frontpage/lib/data/atproto/vote.ts @@ -33,9 +33,11 @@ export type VoteInput = { subjectCid: string; subjectCollection: string; subjectAuthorDid: DID; + rkey: string; }; export async function createVote({ + rkey, subjectRkey, subjectCid, subjectCollection, @@ -61,6 +63,7 @@ export async function createVote({ const response = await atprotoCreateRecord({ collection: VoteCollection, record: record, + rkey, }); return { From 4c724962947e42fbf0eff986a60649f339eb6d8e Mon Sep 17 00:00:00 2001 From: WillCorrigan Date: Mon, 2 Dec 2024 15:12:22 +0000 Subject: [PATCH 9/9] Refactor vote-related API calls to use a unified subject object; update createVote and related functions for improved readability and maintainability --- .../app/(app)/_components/post-card.tsx | 10 +++-- .../[postAuthor]/[postRkey]/_lib/actions.tsx | 10 +++-- packages/frontpage/lib/api/comment.ts | 4 +- packages/frontpage/lib/api/post.ts | 2 +- packages/frontpage/lib/api/relayHandler.ts | 12 +++-- packages/frontpage/lib/api/vote.ts | 44 +++++++++---------- packages/frontpage/lib/data/atproto/vote.ts | 22 ++++------ packages/frontpage/lib/data/db/post.ts | 1 - packages/frontpage/lib/data/db/vote.ts | 24 +++++----- 9 files changed, 65 insertions(+), 64 deletions(-) diff --git a/packages/frontpage/app/(app)/_components/post-card.tsx b/packages/frontpage/app/(app)/_components/post-card.tsx index 0a5401b8..7a81d2f2 100644 --- a/packages/frontpage/app/(app)/_components/post-card.tsx +++ b/packages/frontpage/app/(app)/_components/post-card.tsx @@ -57,10 +57,12 @@ export async function PostCard({ "use server"; await ensureUser(); await createVote({ - subjectRkey: rkey, - subjectCid: cid, - subjectAuthorDid: author, - subjectCollection: PostCollection, + subject: { + rkey, + cid, + authorDid: author, + collection: PostCollection, + }, }); }} unvoteAction={async () => { diff --git a/packages/frontpage/app/(app)/post/[postAuthor]/[postRkey]/_lib/actions.tsx b/packages/frontpage/app/(app)/post/[postAuthor]/[postRkey]/_lib/actions.tsx index 19ae948d..259be689 100644 --- a/packages/frontpage/app/(app)/post/[postAuthor]/[postRkey]/_lib/actions.tsx +++ b/packages/frontpage/app/(app)/post/[postAuthor]/[postRkey]/_lib/actions.tsx @@ -85,10 +85,12 @@ export async function commentVoteAction(input: { }) { await ensureUser(); await createVote({ - subjectAuthorDid: input.authorDid, - subjectCid: input.cid, - subjectRkey: input.rkey, - subjectCollection: CommentCollection, + subject: { + rkey: input.rkey, + cid: input.cid, + authorDid: input.authorDid, + collection: CommentCollection, + }, }); } diff --git a/packages/frontpage/lib/api/comment.ts b/packages/frontpage/lib/api/comment.ts index adb259b4..a88cf3af 100644 --- a/packages/frontpage/lib/api/comment.ts +++ b/packages/frontpage/lib/api/comment.ts @@ -8,7 +8,7 @@ import { createNotification } from "../data/db/notification"; import { invariant } from "../utils"; import { TID } from "@atproto/common-web"; -export type ApiCreateCommentInput = atproto.CommentInput & { +export type ApiCreateCommentInput = Omit & { repo: DID; }; @@ -55,7 +55,7 @@ export async function createComment({ }); } } catch (e) { - db.deleteComment({ authorDid: user.did, rkey }); + await db.deleteComment({ authorDid: user.did, rkey }); throw new DataLayerError(`Failed to create comment: ${e}`); } } diff --git a/packages/frontpage/lib/api/post.ts b/packages/frontpage/lib/api/post.ts index 8335359d..e58f05f0 100644 --- a/packages/frontpage/lib/api/post.ts +++ b/packages/frontpage/lib/api/post.ts @@ -67,7 +67,7 @@ export async function createPost({ return { rkey, cid }; } catch (e) { - db.deletePost({ authorDid: user.did, rkey }); + await db.deletePost({ authorDid: user.did, rkey }); throw new DataLayerError(`Failed to create post: ${e}`); } } diff --git a/packages/frontpage/lib/api/relayHandler.ts b/packages/frontpage/lib/api/relayHandler.ts index bbcd4a33..c3f27338 100644 --- a/packages/frontpage/lib/api/relayHandler.ts +++ b/packages/frontpage/lib/api/relayHandler.ts @@ -149,8 +149,10 @@ export async function handleVote({ op, repo, rkey }: HandlerInput) { repo, rkey, cid: hydratedRecord.cid, - subjectRkey: hydratedRecord.subject.uri.rkey, - subjectAuthorDid: hydratedRecord.subject.uri.authority as DID, + subject: { + rkey: hydratedRecord.subject.uri.rkey, + authorDid: hydratedRecord.subject.uri.authority as DID, + }, }); if (!createdDbPostVote) { @@ -170,8 +172,10 @@ export async function handleVote({ op, repo, rkey }: HandlerInput) { repo, rkey, cid: hydratedRecord.cid, - subjectRkey: hydratedRecord.subject.uri.rkey, - subjectAuthorDid: hydratedRecord.subject.uri.authority as DID, + subject: { + rkey: hydratedRecord.subject.uri.rkey, + authorDid: hydratedRecord.subject.uri.authority as DID, + }, }); if (!createdDbCommentVote) { diff --git a/packages/frontpage/lib/api/vote.ts b/packages/frontpage/lib/api/vote.ts index d3d402a8..f5854ae3 100644 --- a/packages/frontpage/lib/api/vote.ts +++ b/packages/frontpage/lib/api/vote.ts @@ -10,37 +10,38 @@ import { invariant } from "../utils"; import { TID } from "@atproto/common-web"; export type ApiCreateVoteInput = { - subjectRkey: string; - subjectCid: string; - subjectAuthorDid: DID; - subjectCollection: typeof PostCollection | typeof CommentCollection; + subject: { + rkey: string; + cid: string; + authorDid: DID; + collection: typeof PostCollection | typeof CommentCollection; + }; }; -export async function createVote({ - subjectRkey, - subjectAuthorDid, - subjectCollection, - subjectCid, -}: ApiCreateVoteInput) { +export async function createVote({ subject }: ApiCreateVoteInput) { const user = await ensureUser(); const rkey = TID.next().toString(); try { - if (subjectCollection == PostCollection) { + if (subject.collection == PostCollection) { const dbCreatedVote = await db.createPostVote({ repo: user.did, rkey, - subjectRkey, - subjectAuthorDid, + subject: { + rkey: subject.rkey, + authorDid: subject.authorDid, + }, }); invariant(dbCreatedVote, "Failed to insert post vote in database"); - } else if (subjectCollection == CommentCollection) { + } else if (subject.collection == CommentCollection) { const dbCreatedVote = await db.createCommentVote({ repo: user.did, rkey, - subjectRkey, - subjectAuthorDid, + subject: { + rkey: subject.rkey, + authorDid: subject.authorDid, + }, }); invariant(dbCreatedVote, "Failed to insert post vote in database"); @@ -48,21 +49,18 @@ export async function createVote({ const { cid } = await atproto.createVote({ rkey, - subjectRkey, - subjectCid, - subjectCollection, - subjectAuthorDid, + subject, }); invariant(cid, "Failed to create vote, cid missing"); - if (subjectCollection == PostCollection) { + if (subject.collection == PostCollection) { await db.updatePostVote({ authorDid: user.did, rkey, cid }); - } else if (subjectCollection == CommentCollection) { + } else if (subject.collection == CommentCollection) { await db.updateCommentVote({ authorDid: user.did, rkey, cid }); } } catch (e) { - db.deleteVote({ authorDid: user.did, rkey }); + await db.deleteVote({ authorDid: user.did, rkey }); throw new DataLayerError(`Failed to create post vote: ${e}`); } } diff --git a/packages/frontpage/lib/data/atproto/vote.ts b/packages/frontpage/lib/data/atproto/vote.ts index 68027a6c..685e8e7d 100644 --- a/packages/frontpage/lib/data/atproto/vote.ts +++ b/packages/frontpage/lib/data/atproto/vote.ts @@ -29,26 +29,22 @@ export const VoteRecord = z.object({ export type Vote = z.infer; export type VoteInput = { - subjectRkey: string; - subjectCid: string; - subjectCollection: string; - subjectAuthorDid: DID; rkey: string; + subject: { + rkey: string; + cid: string; + authorDid: DID; + collection: typeof PostCollection | typeof CommentCollection; + }; }; -export async function createVote({ - rkey, - subjectRkey, - subjectCid, - subjectCollection, - subjectAuthorDid, -}: VoteInput) { - const uri = `at://${subjectAuthorDid}/${subjectCollection}/${subjectRkey}`; +export async function createVote({ rkey, subject }: VoteInput) { + const uri = `at://${subject.authorDid}/${subject.collection}/${subject.rkey}`; const record = { createdAt: new Date().toISOString(), subject: { - cid: subjectCid, + cid: subject.cid, uri, }, }; diff --git a/packages/frontpage/lib/data/db/post.ts b/packages/frontpage/lib/data/db/post.ts index 395aa363..4822933d 100644 --- a/packages/frontpage/lib/data/db/post.ts +++ b/packages/frontpage/lib/data/db/post.ts @@ -5,7 +5,6 @@ import { db } from "@/lib/db"; import { eq, sql, desc, and, isNull, or, InferSelectModel } from "drizzle-orm"; import * as schema from "@/lib/schema"; import { getUser, isAdmin } from "../user"; -import * as atprotoPost from "../atproto/post"; import { DID } from "../atproto/did"; import { newPostAggregateTrigger } from "./triggers"; diff --git a/packages/frontpage/lib/data/db/vote.ts b/packages/frontpage/lib/data/db/vote.ts index 29da7434..b3811f23 100644 --- a/packages/frontpage/lib/data/db/vote.ts +++ b/packages/frontpage/lib/data/db/vote.ts @@ -88,16 +88,17 @@ export type CreateVoteInput = { repo: DID; rkey: string; cid?: string; - subjectRkey: string; - subjectAuthorDid: DID; + subject: { + rkey: string; + authorDid: DID; + }; }; export const createPostVote = async ({ repo, rkey, cid, - subjectRkey, - subjectAuthorDid, + subject, }: CreateVoteInput) => { return await db.transaction(async (tx) => { const post = ( @@ -106,13 +107,13 @@ export const createPostVote = async ({ .from(schema.Post) .where( and( - eq(schema.Post.rkey, subjectRkey), - eq(schema.Post.authorDid, subjectAuthorDid), + eq(schema.Post.rkey, subject.rkey), + eq(schema.Post.authorDid, subject.authorDid), ), ) )[0]; - invariant(post, `Post not found with rkey: ${subjectRkey}`); + invariant(post, `Post not found with rkey: ${subject.rkey}`); if (post.authorDid === repo) { throw new Error(`[naughty] Cannot vote on own content ${repo}`); @@ -142,8 +143,7 @@ export async function createCommentVote({ repo, rkey, cid, - subjectRkey, - subjectAuthorDid, + subject, }: CreateVoteInput) { return await db.transaction(async (tx) => { const comment = ( @@ -152,13 +152,13 @@ export async function createCommentVote({ .from(schema.Comment) .where( and( - eq(schema.Comment.rkey, subjectRkey), - eq(schema.Comment.authorDid, subjectAuthorDid), + eq(schema.Comment.rkey, subject.rkey), + eq(schema.Comment.authorDid, subject.authorDid), ), ) )[0]; - invariant(comment, `Comment not found with rkey: ${subjectRkey}`); + invariant(comment, `Comment not found with rkey: ${subject.rkey}`); if (comment.authorDid === repo) { throw new Error(`[naughty] Cannot vote on own content ${repo}`);