AT-based link agregator. Mirror of https://github.com/likeandscribe/frontpage

Refactor vote to be lexicon first (#249)

* Refactor vote to be lexicon first

* Remove unused import

* Fix error message

authored by tom.sherman.is and committed by

GitHub deb7f503 5354f204

+53 -273
+5 -8
packages/frontpage/app/(app)/_components/post-card.tsx
··· 58 58 voteAction={async () => { 59 59 "use server"; 60 60 invariant(cid, "Vote action requires cid"); 61 - const user = await ensureUser(); 61 + await ensureUser(); 62 62 await createVote({ 63 - authorDid: user.did, 64 - subject: { 65 - rkey, 66 - cid, 67 - authorDid: author, 68 - collection: nsids.FyiUnravelFrontpagePost, 69 - }, 63 + rkey, 64 + cid, 65 + authorDid: author, 66 + collection: nsids.FyiUnravelFrontpagePost, 70 67 }); 71 68 }} 72 69 unvoteAction={async () => {
+5 -8
packages/frontpage/app/(app)/post/[postAuthor]/[postRkey]/_lib/actions.tsx
··· 89 89 rkey: string; 90 90 authorDid: DID; 91 91 }) { 92 - const user = await ensureUser(); 92 + await ensureUser(); 93 93 await createVote({ 94 - authorDid: user.did, 95 - subject: { 96 - rkey: input.rkey, 97 - cid: input.cid, 98 - authorDid: input.authorDid, 99 - collection: nsids.FyiUnravelFrontpageComment, 100 - }, 94 + rkey: input.rkey, 95 + cid: input.cid, 96 + authorDid: input.authorDid, 97 + collection: nsids.FyiUnravelFrontpageComment, 101 98 }); 102 99 } 103 100
+10 -9
packages/frontpage/app/api/receive_hook/handlers.ts
··· 2 2 import { type Operation } from "@/lib/data/atproto/event"; 3 3 import { getAtprotoClient, nsids } from "@/lib/data/atproto/repo"; 4 4 import { AtUri } from "@/lib/data/atproto/uri"; 5 - import * as atprotoVote from "@/lib/data/atproto/vote"; 6 5 import * as dbComment from "@/lib/data/db/comment"; 7 6 import * as dbNotification from "@/lib/data/db/notification"; 8 7 import * as dbPost from "@/lib/data/db/post"; 9 8 import * as dbVote from "@/lib/data/db/vote"; 10 9 import { getBlueskyProfile } from "@/lib/data/user"; 11 10 import { sendDiscordMessage } from "@/lib/discord"; 12 - import { exhaustiveCheck, invariant } from "@/lib/utils"; 11 + import { invariant } from "@/lib/utils"; 13 12 14 13 type HandlerInput = { 15 14 op: Zod.infer<typeof Operation>; ··· 158 157 } 159 158 160 159 export async function handleVote({ op, repo, rkey }: HandlerInput) { 160 + const atproto = await getAtprotoClientFromRepo(repo); 161 161 if (op.action === "create") { 162 - const hydratedRecord = await atprotoVote.getVote({ 162 + const hydratedRecord = await atproto.fyi.unravel.frontpage.vote.get({ 163 163 repo, 164 164 rkey, 165 165 }); ··· 167 167 invariant(hydratedRecord, "atproto vote record not found"); 168 168 169 169 const { subject } = hydratedRecord.value; 170 + const subjectUri = AtUri.parse(subject.uri); 170 171 171 - switch (subject.uri.collection) { 172 + switch (subjectUri.collection) { 172 173 case nsids.FyiUnravelFrontpagePost: { 173 174 const postVote = await dbVote.uncached_doesPostVoteExist(repo, rkey); 174 175 if (postVote) { ··· 184 185 rkey, 185 186 cid: hydratedRecord.cid, 186 187 subject: { 187 - rkey: subject.uri.rkey, 188 - authorDid: subject.uri.authority as DID, 188 + rkey: subjectUri.rkey, 189 + authorDid: subjectUri.authority as DID, 189 190 cid: subject.cid, 190 191 }, 191 192 status: "live", ··· 217 218 rkey, 218 219 cid: hydratedRecord.cid, 219 220 subject: { 220 - rkey: subject.uri.rkey, 221 - authorDid: subject.uri.authority as DID, 221 + rkey: subjectUri.rkey, 222 + authorDid: subjectUri.authority as DID, 222 223 cid: subject.cid, 223 224 }, 224 225 status: "live", ··· 233 234 break; 234 235 } 235 236 default: 236 - exhaustiveCheck(subject.uri.collection, "Unknown collection"); 237 + invariant(subjectUri.collection, "Unknown collection"); 237 238 } 238 239 } else if (op.action === "delete") { 239 240 console.log("deleting vote", rkey);
+1 -2
packages/frontpage/app/api/receive_hook/route.ts
··· 1 1 import { db } from "@/lib/db"; 2 2 import * as schema from "@/lib/schema"; 3 3 import { Commit } from "@/lib/data/atproto/event"; 4 - import * as atprotoVote from "@/lib/data/atproto/vote"; 5 4 import { getPdsUrl } from "@/lib/data/atproto/did"; 6 5 import { handleComment, handlePost, handleVote } from "./handlers"; 7 6 import { eq } from "drizzle-orm"; ··· 54 53 case nsids.FyiUnravelFrontpageComment: 55 54 await handleComment({ op, repo, rkey }); 56 55 break; 57 - case atprotoVote.VoteCollection: 56 + case nsids.FyiUnravelFrontpageVote: 58 57 await handleVote({ op, repo, rkey }); 59 58 break; 60 59 default:
+32 -23
packages/frontpage/lib/api/vote.ts
··· 1 1 import "server-only"; 2 2 import * as db from "../data/db/vote"; 3 - import * as atproto from "../data/atproto/vote"; 4 3 import { DataLayerError } from "../data/error"; 5 4 import { ensureUser } from "../data/user"; 6 5 import { type DID } from "../data/atproto/did"; 7 6 import { invariant } from "../utils"; 8 7 import { TID } from "@atproto/common-web"; 9 8 import { after } from "next/server"; 10 - import { nsids } from "../data/atproto/repo"; 9 + import { getAtprotoClient, nsids } from "../data/atproto/repo"; 11 10 11 + // TODO: Should use a strongRef 12 12 export type ApiCreateVoteInput = { 13 + rkey: string; 14 + cid: string; 13 15 authorDid: DID; 14 - subject: { 15 - rkey: string; 16 - cid: string; 17 - authorDid: DID; 18 - collection: 19 - | typeof nsids.FyiUnravelFrontpagePost 20 - | typeof nsids.FyiUnravelFrontpageComment; 21 - }; 16 + collection: 17 + | typeof nsids.FyiUnravelFrontpagePost 18 + | typeof nsids.FyiUnravelFrontpageComment; 22 19 }; 23 20 24 - export async function createVote({ authorDid, subject }: ApiCreateVoteInput) { 21 + export async function createVote(subject: ApiCreateVoteInput) { 25 22 const user = await ensureUser(); 26 23 27 - if (authorDid !== user.did) { 28 - throw new DataLayerError("You can only vote for yourself"); 29 - } 30 - 31 24 const rkey = TID.next().toString(); 32 25 try { 33 26 if (subject.collection == nsids.FyiUnravelFrontpagePost) { 34 27 const dbCreatedVote = await db.createPostVote({ 35 - repo: authorDid, 28 + repo: user.did, 36 29 rkey, 37 30 subject: { 38 31 rkey: subject.rkey, ··· 45 38 invariant(dbCreatedVote, "Failed to insert post vote in database"); 46 39 } else if (subject.collection == nsids.FyiUnravelFrontpageComment) { 47 40 const dbCreatedVote = await db.createCommentVote({ 48 - repo: authorDid, 41 + repo: user.did, 49 42 rkey, 50 43 subject: { 51 44 rkey: subject.rkey, ··· 55 48 status: "pending", 56 49 }); 57 50 58 - invariant(dbCreatedVote, "Failed to insert post vote in database"); 51 + invariant(dbCreatedVote, "Failed to insert comment vote in database"); 59 52 } 60 53 54 + const atproto = getAtprotoClient(); 61 55 after(() => 62 - atproto.createVote({ 63 - rkey, 64 - subject, 65 - }), 56 + atproto.fyi.unravel.frontpage.vote.create( 57 + { 58 + rkey, 59 + repo: user.did, 60 + }, 61 + { 62 + subject: { 63 + uri: `at://${subject.authorDid}/${subject.collection}/${subject.rkey}`, 64 + cid: subject.cid, 65 + }, 66 + createdAt: new Date().toISOString(), 67 + }, 68 + ), 66 69 ); 67 70 } catch (e) { 68 71 await db.deleteVote({ authorDid: user.did, rkey }); ··· 78 81 } 79 82 80 83 try { 81 - after(() => atproto.deleteVote(authorDid, rkey)); 84 + const atproto = getAtprotoClient(); 85 + after(() => 86 + atproto.fyi.unravel.frontpage.vote.delete({ 87 + repo: user.did, 88 + rkey, 89 + }), 90 + ); 82 91 await db.deleteVote({ authorDid: user.did, rkey }); 83 92 } catch (e) { 84 93 // eslint-disable-next-line @typescript-eslint/restrict-template-expressions
-128
packages/frontpage/lib/data/atproto/record.ts
··· 1 - import "server-only"; 2 - import { z } from "zod"; 3 - import { ensureUser } from "../user"; 4 - import { DataLayerError } from "../error"; 5 - import { fetchAuthenticatedAtproto } from "@/lib/auth"; 6 - import { AtUri } from "./uri"; 7 - import { type DID } from "./did"; 8 - 9 - const CreateRecordResponse = z.object({ 10 - uri: AtUri, 11 - cid: z.string(), 12 - }); 13 - 14 - type CreateRecordInput = { 15 - record: unknown; 16 - collection: string; 17 - rkey: string; 18 - }; 19 - 20 - export async function atprotoCreateRecord({ 21 - record, 22 - collection, 23 - rkey, 24 - }: CreateRecordInput) { 25 - const user = await ensureUser(); 26 - const pdsUrl = new URL(user.pdsUrl); 27 - pdsUrl.pathname = "/xrpc/com.atproto.repo.createRecord"; 28 - 29 - const response = await fetchAuthenticatedAtproto(pdsUrl.toString(), { 30 - method: "POST", 31 - headers: { 32 - "Content-Type": "application/json", 33 - }, 34 - body: JSON.stringify({ 35 - repo: user.did, 36 - collection, 37 - rkey, 38 - validate: false, 39 - record: record, 40 - }), 41 - signal: AbortSignal.timeout(2500), 42 - }); 43 - 44 - if (!response.ok) { 45 - throw new DataLayerError(`Failed to create record ${response.status}`, { 46 - cause: response, 47 - }); 48 - } 49 - 50 - return CreateRecordResponse.parse(await response.json()); 51 - } 52 - 53 - type DeleteRecordInput = { 54 - authorDid: DID; 55 - collection: string; 56 - rkey: string; 57 - }; 58 - 59 - export async function atprotoDeleteRecord({ 60 - authorDid, 61 - collection, 62 - rkey, 63 - }: DeleteRecordInput) { 64 - const user = await ensureUser(); 65 - 66 - if (user.did !== authorDid) { 67 - throw new DataLayerError("User does not own record"); 68 - } 69 - 70 - const pdsUrl = new URL(user.pdsUrl); 71 - pdsUrl.pathname = "/xrpc/com.atproto.repo.deleteRecord"; 72 - 73 - const response = await fetchAuthenticatedAtproto(pdsUrl.toString(), { 74 - method: "POST", 75 - headers: { 76 - "Content-Type": "application/json", 77 - }, 78 - body: JSON.stringify({ 79 - repo: user.did, 80 - collection, 81 - rkey, 82 - }), 83 - signal: AbortSignal.timeout(2500), 84 - }); 85 - 86 - if (!response.ok) { 87 - throw new DataLayerError("Failed to delete record", { cause: response }); 88 - } 89 - } 90 - 91 - const AtProtoRecord = z.object({ 92 - value: z.custom<unknown>( 93 - (value) => typeof value === "object" && value != null, 94 - ), 95 - cid: z.string(), 96 - }); 97 - 98 - type GetRecordInput = { 99 - serviceEndpoint: string; 100 - repo: string; 101 - collection: string; 102 - rkey: string; 103 - }; 104 - 105 - export async function atprotoGetRecord({ 106 - serviceEndpoint, 107 - repo, 108 - collection, 109 - rkey, 110 - }: GetRecordInput) { 111 - const url = new URL(`${serviceEndpoint}/xrpc/com.atproto.repo.getRecord`); 112 - url.searchParams.append("repo", repo); 113 - url.searchParams.append("collection", collection); 114 - url.searchParams.append("rkey", rkey); 115 - 116 - const response = await fetch(url.toString(), { 117 - headers: { 118 - "Content-Type": "application/json", 119 - }, 120 - }); 121 - 122 - if (!response.ok) 123 - throw new Error("Failed to fetch record", { cause: response }); 124 - 125 - const json = await response.json(); 126 - 127 - return AtProtoRecord.parse(json); 128 - }
-95
packages/frontpage/lib/data/atproto/vote.ts
··· 1 - import "server-only"; 2 - import { 3 - atprotoCreateRecord, 4 - atprotoDeleteRecord, 5 - atprotoGetRecord, 6 - } from "./record"; 7 - import { z } from "zod"; 8 - import { type DID, getPdsUrl } from "./did"; 9 - import { createAtUriParser } from "./uri"; 10 - import { DataLayerError } from "../error"; 11 - import { nsids } from "./repo"; 12 - 13 - export const VoteCollection = "fyi.unravel.frontpage.vote"; 14 - 15 - const VoteSubjectCollection = z.union([ 16 - z.literal(nsids.FyiUnravelFrontpagePost), 17 - z.literal(nsids.FyiUnravelFrontpageComment), 18 - ]); 19 - 20 - export const VoteRecord = z.object({ 21 - createdAt: z.string(), 22 - subject: z.object({ 23 - cid: z.string(), 24 - uri: createAtUriParser(VoteSubjectCollection), 25 - }), 26 - }); 27 - 28 - export type Vote = z.infer<typeof VoteRecord>; 29 - 30 - export type VoteInput = { 31 - rkey: string; 32 - subject: { 33 - rkey: string; 34 - cid: string; 35 - authorDid: DID; 36 - collection: 37 - | typeof nsids.FyiUnravelFrontpagePost 38 - | typeof nsids.FyiUnravelFrontpageComment; 39 - }; 40 - }; 41 - 42 - export async function createVote({ rkey, subject }: VoteInput) { 43 - const uri = `at://${subject.authorDid}/${subject.collection}/${subject.rkey}`; 44 - 45 - const record = { 46 - createdAt: new Date().toISOString(), 47 - subject: { 48 - cid: subject.cid, 49 - uri, 50 - }, 51 - }; 52 - 53 - const parseResult = VoteRecord.safeParse(record); 54 - if (!parseResult.success) { 55 - throw new DataLayerError("Invalid vote record", { 56 - cause: parseResult.error, 57 - }); 58 - } 59 - 60 - const response = await atprotoCreateRecord({ 61 - collection: VoteCollection, 62 - record: record, 63 - rkey, 64 - }); 65 - 66 - return { 67 - rkey: response.uri.rkey, 68 - cid: response.cid, 69 - }; 70 - } 71 - 72 - export async function deleteVote(authorDid: DID, rkey: string) { 73 - await atprotoDeleteRecord({ 74 - authorDid, 75 - collection: VoteCollection, 76 - rkey, 77 - }); 78 - } 79 - 80 - export async function getVote({ rkey, repo }: { rkey: string; repo: DID }) { 81 - const service = await getPdsUrl(repo); 82 - 83 - if (!service) { 84 - throw new DataLayerError("Failed to get service url"); 85 - } 86 - 87 - const { value, cid } = await atprotoGetRecord({ 88 - serviceEndpoint: service, 89 - repo, 90 - collection: VoteCollection, 91 - rkey, 92 - }); 93 - 94 - return { value: VoteRecord.parse(value), cid }; 95 - }