From 54012f4741446ad93bb35123e0b113d5e5e11073 Mon Sep 17 00:00:00 2001 From: WillCorrigan Date: Mon, 2 Dec 2024 06:29:44 +0000 Subject: [PATCH] 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