diff --git a/packages/frontpage/app/(app)/_components/post-card.tsx b/packages/frontpage/app/(app)/_components/post-card.tsx index 204fb1d6..7a81d2f2 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,22 +57,24 @@ export async function PostCard({ "use server"; await ensureUser(); await createVote({ - subjectAuthorDid: author, - subjectCid: cid, - subjectRkey: rkey, - subjectCollection: PostCollection, + subject: { + rkey, + cid, + authorDid: author, + collection: PostCollection, + }, }); }} 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 @@ -130,6 +133,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++; - } - 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, authorDid: user.did }); revalidatePath("/post"); } @@ -101,20 +85,22 @@ 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, + }, }); } 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; } - 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 0e1995f4..3bbe7c86 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 { DID } from "@/lib/data/atproto/did"; +import { createPost } from "@/lib/api/post"; import { getVerifiedHandle } from "@/lib/data/atproto/identity"; -import { createPost } from "@/lib/data/atproto/post"; -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"; @@ -27,25 +25,11 @@ export async function newPostAction(_prevState: unknown, formData: FormData) { } try { - const { rkey } = await createPost({ title, url }); - const [handle] = await Promise.all([ - getVerifiedHandle(user.did), - waitForPost(user.did, rkey), - ]); + const { rkey } = await createPost({ title, url, createdAt: new Date() }); + const handle = await getVerifiedHandle(user.did); redirect(`/post/${handle}/${rkey}`); } catch (error) { if (!(error instanceof DataLayerError)) throw error; return { error: "Failed to create post" }; } } - -const MAX_POLLS = 10; -async function waitForPost(authorDid: DID, rkey: string) { - let exists = false; - let polls = 0; - while (!exists && polls < MAX_POLLS) { - exists = await uncached_doesPostExist(authorDid, rkey); - await new Promise((resolve) => 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 04bda27c..a6c05f04 100644 --- a/packages/frontpage/app/api/receive_hook/route.ts +++ b/packages/frontpage/app/api/receive_hook/route.ts @@ -1,22 +1,12 @@ 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"; +import { eq } from "drizzle-orm"; export async function POST(request: Request) { const auth = request.headers.get("Authorization"); @@ -31,113 +21,42 @@ 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"); } - const promises = ops.map(async (op) => { 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, - }); - } - } - // 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 }); - }); + 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}`); } }); await Promise.all(promises); - + await db.insert(schema.ConsumedOffset).values({ offset: seq }); return new Response("OK"); } 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 new file mode 100644 index 00000000..a88cf3af --- /dev/null +++ b/packages/frontpage/lib/api/comment.ts @@ -0,0 +1,73 @@ +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 } from "../data/atproto/did"; +import { createNotification } from "../data/db/notification"; +import { invariant } from "../utils"; +import { TID } from "@atproto/common-web"; + +export type ApiCreateCommentInput = Omit & { + repo: DID; +}; + +export async function createComment({ + parent, + post, + content, + repo, +}: ApiCreateCommentInput) { + const user = await ensureUser(); + + const rkey = TID.next().toString(); + try { + const dbCreatedComment = await db.createComment({ + cid: "", + authorDid: user.did, + rkey, + content, + createdAt: new Date(), + parent, + post, + }); + + invariant(dbCreatedComment, "Failed to insert comment in database"); + + const { cid } = await atproto.createComment({ + parent, + post, + content, + rkey, + }); + + invariant(cid, "Failed to create comment, rkey/cid missing"); + + await db.updateComment({ authorDid: user.did, rkey, cid }); + + const didToNotify = parent ? parent.authorDid : post.authorDid; + + if (didToNotify !== repo) { + await createNotification({ + commentId: dbCreatedComment.id, + did: didToNotify, + reason: parent ? "commentReply" : "postComment", + }); + } + } catch (e) { + await db.deleteComment({ authorDid: user.did, rkey }); + throw new DataLayerError(`Failed to create comment: ${e}`); + } +} + +export async function deleteComment({ rkey }: db.DeleteCommentInput) { + const user = await ensureUser(); + + try { + 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 new file mode 100644 index 00000000..e58f05f0 --- /dev/null +++ b/packages/frontpage/lib/api/post.ts @@ -0,0 +1,84 @@ +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"; +import { invariant } from "../utils"; +import { TID } from "@atproto/common-web"; + +export type ApiCreatePostInput = { + title: string; + url: string; + createdAt: Date; +}; + +export async function createPost({ + title, + url, + createdAt, +}: ApiCreatePostInput) { + const user = await ensureUser(); + + const rkey = TID.next().toString(); + try { + const dbCreatedPost = await db.createPost({ + post: { title, url, createdAt }, + rkey, + authorDid: user.did, + }); + invariant(dbCreatedPost, "Failed to insert post in database"); + + const { cid } = await atproto.createPost({ + title: title, + url: url, + rkey, + }); + + invariant(cid, "Failed to create comment, rkey/cid missing"); + + await db.updatePost({ authorDid: user.did, rkey, cid }); + + 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) { + await db.deletePost({ authorDid: user.did, rkey }); + throw new DataLayerError(`Failed to create post: ${e}`); + } +} + +export async function deletePost({ rkey }: db.DeletePostInput) { + const user = await ensureUser(); + + try { + 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 new file mode 100644 index 00000000..c3f27338 --- /dev/null +++ b/packages/frontpage/lib/api/relayHandler.ts @@ -0,0 +1,197 @@ +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"; +import { invariant } from "../utils"; + +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, + }); + + invariant(postRecord, "atproto post record not found"); + + const post = await dbPost.uncached_doesPostExist(repo, rkey); + + if (!post && postRecord) { + const createdDbPost = await dbPost.createPost({ + post: { + title: postRecord.title, + url: postRecord.url, + createdAt: new Date(postRecord.createdAt), + }, + rkey, + cid: postRecord.cid, + authorDid: repo, + }); + + invariant(createdDbPost, "Failed to insert post from relay in database"); + + 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({ + authorDid: repo, + rkey, + }); + } +} + +export async function handleComment({ op, repo, rkey }: HandlerInput) { + if (op.action === "create") { + const commentRecord = await atprotoComment.getComment({ + rkey, + repo, + }); + + invariant(commentRecord, "atproto comment record not found"); + + const comment = await dbComment.uncached_doesCommentExist(repo, rkey); + + if (!comment && commentRecord) { + const createdComment = await dbComment.createComment({ + cid: commentRecord.cid, + 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 = commentRecord.parent + ? commentRecord.parent.uri.authority + : commentRecord.post.uri.authority; + + if (didToNotify !== repo) { + await dbNotification.createNotification({ + commentId: createdComment.id, + did: didToNotify as DID, + reason: commentRecord.parent ? "commentReply" : "postComment", + }); + } + } + } else if (op.action === "delete") { + await dbComment.deleteComment({ rkey, authorDid: repo }); + } +} + +export async function handleVote({ op, repo, rkey }: HandlerInput) { + if (op.action === "create") { + const hydratedRecord = await atprotoVote.getVote({ + repo, + rkey, + }); + + invariant(hydratedRecord, "atproto vote record not found"); + + switch (hydratedRecord.subject.uri.collection) { + case atprotoPost.PostCollection: + const postVote = await dbVote.uncached_doesPostVoteExist(repo, rkey); + if (!postVote) { + const createdDbPostVote = await dbVote.createPostVote({ + repo, + rkey, + cid: hydratedRecord.cid, + subject: { + rkey: hydratedRecord.subject.uri.rkey, + authorDid: 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 commentVote = await dbVote.uncached_doesCommentVoteExist( + repo, + rkey, + ); + if (!commentVote) { + const createdDbCommentVote = await dbVote.createCommentVote({ + repo, + rkey, + cid: hydratedRecord.cid, + subject: { + rkey: hydratedRecord.subject.uri.rkey, + authorDid: hydratedRecord.subject.uri.authority as DID, + }, + }); + + if (!createdDbCommentVote) { + throw new Error( + "Failed to insert comment vote from relay in database", + ); + } + } + break; + default: + throw new Error( + `Unknown collection: ${hydratedRecord.subject.uri.collection}`, + ); + } + } 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 new file mode 100644 index 00000000..f5854ae3 --- /dev/null +++ b/packages/frontpage/lib/api/vote.ts @@ -0,0 +1,78 @@ +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"; +import { invariant } from "../utils"; +import { TID } from "@atproto/common-web"; + +export type ApiCreateVoteInput = { + subject: { + rkey: string; + cid: string; + authorDid: DID; + collection: typeof PostCollection | typeof CommentCollection; + }; +}; + +export async function createVote({ subject }: ApiCreateVoteInput) { + const user = await ensureUser(); + + const rkey = TID.next().toString(); + try { + if (subject.collection == PostCollection) { + const dbCreatedVote = await db.createPostVote({ + repo: user.did, + rkey, + subject: { + rkey: subject.rkey, + authorDid: subject.authorDid, + }, + }); + + invariant(dbCreatedVote, "Failed to insert post vote in database"); + } else if (subject.collection == CommentCollection) { + const dbCreatedVote = await db.createCommentVote({ + repo: user.did, + rkey, + subject: { + rkey: subject.rkey, + authorDid: subject.authorDid, + }, + }); + + invariant(dbCreatedVote, "Failed to insert post vote in database"); + } + + const { cid } = await atproto.createVote({ + rkey, + subject, + }); + + invariant(cid, "Failed to create vote, cid missing"); + + if (subject.collection == PostCollection) { + await db.updatePostVote({ authorDid: user.did, rkey, cid }); + } else if (subject.collection == CommentCollection) { + await db.updateCommentVote({ authorDid: user.did, rkey, cid }); + } + } catch (e) { + await db.deleteVote({ authorDid: user.did, rkey }); + throw new DataLayerError(`Failed to create post vote: ${e}`); + } +} + +export async function deleteVote({ rkey }: db.DeleteVoteInput) { + const user = await ensureUser(); + + try { + await db.deleteVote({ authorDid: user.did, rkey }); + + await atproto.deleteVote(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 42dfb3ba..1bfa8e1c 100644 --- a/packages/frontpage/lib/data/atproto/comment.ts +++ b/packages/frontpage/lib/data/atproto/comment.ts @@ -30,13 +30,19 @@ 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; + 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,15 +70,18 @@ export async function createComment({ parent, post, content }: CommentInput) { const result = await atprotoCreateRecord({ record, collection: CommentCollection, + rkey, }); return { rkey: result.uri.rkey, + cid: result.cid, }; } -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/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..3a79028c 100644 --- a/packages/frontpage/lib/data/atproto/post.ts +++ b/packages/frontpage/lib/data/atproto/post.ts @@ -19,12 +19,13 @@ export const PostRecord = z.object({ export type Post = z.infer; -type PostInput = { +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,17 +37,20 @@ export async function createPost({ title, url }: PostInput) { const result = await atprotoCreateRecord({ record, collection: PostCollection, + rkey, }); return { rkey: result.uri.rkey, + cid: result.cid, }; } -export async function deletePost(rkey: string) { +export async function deletePost(authorDid: DID, rkey: string) { await atprotoDeleteRecord({ - rkey, + authorDid, collection: PostCollection, + rkey, }); } @@ -57,12 +61,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/record.ts b/packages/frontpage/lib/data/atproto/record.ts index 229531ca..26667fb5 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, @@ -13,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); @@ -31,6 +34,7 @@ export async function atprotoCreateRecord({ body: JSON.stringify({ repo: user.did, collection, + rkey, validate: false, record: record, }), @@ -46,15 +50,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 93bdb788..685e8e7d 100644 --- a/packages/frontpage/lib/data/atproto/vote.ts +++ b/packages/frontpage/lib/data/atproto/vote.ts @@ -1,11 +1,17 @@ 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,43 +28,67 @@ export const VoteRecord = z.object({ export type Vote = z.infer; -type VoteInput = { - subjectRkey: string; - subjectCid: string; - subjectCollection: string; - subjectAuthorDid: DID; +export type VoteInput = { + rkey: string; + subject: { + rkey: string; + cid: string; + authorDid: DID; + collection: typeof PostCollection | typeof CommentCollection; + }; }; -export async function createVote({ - subjectRkey, - subjectCid, - subjectCollection, - subjectAuthorDid, -}: VoteInput) { - await ensureUser(); - 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, }, }; - VoteRecord.parse(record); + const parseResult = VoteRecord.safeParse(record); + if (!parseResult.success) { + throw new DataLayerError("Invalid vote record", { + cause: parseResult.error, + }); + } - await atprotoCreateRecord({ - collection: "fyi.unravel.frontpage.vote", + const response = await atprotoCreateRecord({ + collection: VoteCollection, record: record, + rkey, }); -} -export async function deleteVote(rkey: string) { - await ensureUser(); + return { + rkey: response.uri.rkey, + cid: response.cid, + }; +} +export async function deleteVote(authorDid: DID, rkey: string) { await atprotoDeleteRecord({ - collection: "fyi.unravel.frontpage.vote", + authorDid, + 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..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]); @@ -253,63 +272,85 @@ export async function moderateComment({ ); } -export type UnauthedCreateCommentInput = { - cid: string; - comment: atprotoComment.Comment; - repo: DID; +export type CreateCommentInput = { + cid?: string; + authorDid: DID; rkey: string; + content: string; + createdAt: Date; + parent?: { + authorDid: DID; + rkey: string; + }; + post: { + authorDid: DID; + rkey: string; + }; }; -export async function unauthed_createComment({ +export async function createComment({ cid, - comment, - repo, + authorDid, rkey, -}: UnauthedCreateCommentInput) { + 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,36 +358,25 @@ export async function unauthed_createComment({ tx, ); - return { - id: insertedComment.id, - parent: parentComment - ? { - id: parentComment.id, - authorDid: parentComment.authorDid, - } - : null, - post: { - authordid: post.authorDid, - }, - }; + return insertedComment; }); } -export type UnauthedDeleteCommentInput = { +export type DeleteCommentInput = { rkey: string; - repo: DID; + authorDid: DID; }; -export async function unauthed_deleteComment({ - rkey, - repo, -}: UnauthedDeleteCommentInput) { +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/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..4822933d 100644 --- a/packages/frontpage/lib/data/db/post.ts +++ b/packages/frontpage/lib/data/db/post.ts @@ -2,12 +2,10 @@ 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 { getBlueskyProfile, getUser, isAdmin } from "../user"; -import * as atprotoPost from "../atproto/post"; +import { getUser, isAdmin } from "../user"; import { DID } from "../atproto/did"; -import { sendDiscordMessage } from "@/lib/discord"; import { newPostAggregateTrigger } from "./triggers"; const buildUserHasVotedQuery = cache(async () => { @@ -71,7 +69,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,31 +162,29 @@ export async function uncached_doesPostExist(authorDid: DID, rkey: string) { return Boolean(row[0]); } -type CreatePostInput = { - post: atprotoPost.Post; +export type CreatePostInput = { + post: { title: string; url: string; createdAt: Date }; authorDid: DID; rkey: string; - cid: string; - offset: number; + cid?: string; }; -export async function unauthed_createPost({ +export async function createPost({ post, - rkey, authorDid, + rkey, cid, - offset, }: CreatePostInput) { - await db.transaction(async (tx) => { + return await db.transaction(async (tx) => { const [insertedPostRow] = await tx .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 }); @@ -198,47 +194,36 @@ 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 = { +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; - offset: number; + rkey: string; }; -export async function unauthed_deletePost({ - rkey, - authorDid, - offset, -}: DeletePostInput) { - console.log("Deleting post", rkey, offset); +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); await tx @@ -247,10 +232,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..b3811f23 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 { @@ -12,7 +11,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(); @@ -32,120 +31,217 @@ 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, +) => { + const row = await db + .select({ id: schema.PostVote.id }) + .from(schema.PostVote) + .where( + and( + eq(schema.PostVote.authorDid, authorDid), + eq(schema.PostVote.rkey, rkey), + ), + ) + .limit(1); - const rows = await db - .select() + return Boolean(row[0]); +}; + +export const uncached_doesCommentVoteExist = async ( + authorDid: DID, + rkey: 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), ), ) .limit(1); - return rows[0] ?? null; -}); + return Boolean(row[0]); +}; -export type UnauthedCreatePostVoteInput = { +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 CreateVoteInput = { repo: DID; rkey: string; - vote: atprotoVote.Vote; - cid: string; + cid?: string; + subject: { + rkey: string; + authorDid: DID; + }; }; -export const unauthed_createPostVote = async ({ +export const createPostVote = async ({ repo, rkey, - vote, cid, -}: UnauthedCreatePostVoteInput) => { - await db.transaction(async (tx) => { - const subject = ( + subject, +}: CreateVoteInput) => { + return await db.transaction(async (tx) => { + const post = ( await tx .select() .from(schema.Post) - .where(eq(schema.Post.rkey, vote.subject.uri.rkey)) + .where( + and( + eq(schema.Post.rkey, subject.rkey), + eq(schema.Post.authorDid, subject.authorDid), + ), + ) )[0]; - if (!subject) { - throw new Error( - `Subject not found with uri: ${atUriToString(vote.subject.uri)}`, - ); - } + invariant(post, `Post not found with rkey: ${subject.rkey}`); - if (subject.authorDid === repo) { + if (post.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, - }); - - await newPostVoteAggregateTrigger(subject.id, tx); - }); -}; + const [insertedVote] = await tx + .insert(schema.PostVote) + .values({ + postId: post.id, + authorDid: repo, + createdAt: new Date(), + cid: cid ?? "", + rkey, + }) + .returning({ id: schema.PostVote.id }); -export type UnauthedCreateCommentVoteInput = { - repo: DID; - rkey: string; - vote: atprotoVote.Vote; + if (!insertedVote) { + throw new Error("Failed to insert vote"); + } - cid: string; + await newPostVoteAggregateTrigger(post.id, tx); + + return { id: insertedVote?.id }; + }); }; -export async function unauthed_createCommentVote({ +export async function createCommentVote({ repo, rkey, - vote, cid, -}: UnauthedCreateCommentVoteInput) { - await db.transaction(async (tx) => { - const subject = ( + subject, +}: CreateVoteInput) { + return await db.transaction(async (tx) => { + const comment = ( await tx .select() .from(schema.Comment) - .where(eq(schema.Comment.rkey, vote.subject.uri.rkey)) + .where( + and( + eq(schema.Comment.rkey, subject.rkey), + eq(schema.Comment.authorDid, subject.authorDid), + ), + ) )[0]; - if (!subject) { - throw new Error( - `Subject not found with uri: ${atUriToString(vote.subject.uri)}`, - ); - } + invariant(comment, `Comment not found with rkey: ${subject.rkey}`); - if (subject.authorDid === repo) { + if (comment.authorDid === repo) { 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: comment.id, + authorDid: repo, + createdAt: new Date(), + cid: cid ?? "", + rkey, + }) + .returning({ id: schema.CommentVote.id }); + + if (!insertedVote) { + throw new Error("Failed to insert vote"); + } + + await newCommentVoteAggregateTrigger(comment.postId, comment.id, tx); - await newCommentVoteAggregateTrigger(subject.postId, subject.id, tx); + return { id: insertedVote?.id }; }); } +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 const unauthed_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({ @@ -157,7 +253,7 @@ export const unauthed_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 183b1aed..ca426811 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==} @@ -6022,13 +6022,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 @@ -6038,7 +6031,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 @@ -6057,7 +6050,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: @@ -6095,7 +6088,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 @@ -9870,7 +9863,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 @@ -12048,7 +12041,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