Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions drizzle/0088_flag.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
CREATE TABLE "flag" (
"id" uuid PRIMARY KEY NOT NULL,
"iri" text NOT NULL,
"reporter_id" uuid NOT NULL,
"post_id" uuid,
"actor_id" uuid,
"reason" text NOT NULL,
"created" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL,
CONSTRAINT "flag_iri_unique" UNIQUE("iri"),
CONSTRAINT "flag_reporter_id_post_id_unique" UNIQUE("reporter_id","post_id"),
CONSTRAINT "flag_reporter_id_actor_id_unique" UNIQUE("reporter_id","actor_id"),
CONSTRAINT "flag_target_check" CHECK (("flag"."post_id" IS NOT NULL AND "flag"."actor_id" IS NULL) OR ("flag"."post_id" IS NULL AND "flag"."actor_id" IS NOT NULL))
);
--> statement-breakpoint
ALTER TABLE "flag" ADD CONSTRAINT "flag_reporter_id_actor_id_fk" FOREIGN KEY ("reporter_id") REFERENCES "public"."actor"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "flag" ADD CONSTRAINT "flag_post_id_post_id_fk" FOREIGN KEY ("post_id") REFERENCES "public"."post"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "flag" ADD CONSTRAINT "flag_actor_id_actor_id_fk" FOREIGN KEY ("actor_id") REFERENCES "public"."actor"("id") ON DELETE cascade ON UPDATE no action;
1 change: 1 addition & 0 deletions graphql/mod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import "./passkey.ts";
import "./poll.ts";
import "./post.ts";
import "./reactable.ts";
import "./report.ts";
import "./search.ts";
import "./signup.ts";
import "./timeline.ts";
Expand Down
91 changes: 91 additions & 0 deletions graphql/report.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { createFlag } from "@hackerspub/models/flag";
import { generateUuidV7 } from "@hackerspub/models/uuid";
import { builder } from "./builder.ts";
import { InvalidInputError } from "./error.ts";
import { Article, Note, Post, Question } from "./post.ts";
import { NotAuthenticatedError } from "./session.ts";

export class AlreadyReportedError extends Error {
public constructor() {
super("You have already reported this post");
}
}

builder.objectType(AlreadyReportedError, {
name: "AlreadyReportedError",
fields: (t) => ({
message: t.string({
resolve: () => "You have already reported this post",
}),
}),
});

builder.relayMutationField(
"reportPost",
{
inputFields: (t) => ({
postId: t.globalID({
for: [Note, Article, Question],
required: true,
}),
reason: t.string({ required: true }),
}),
},
{
errors: {
types: [NotAuthenticatedError, InvalidInputError, AlreadyReportedError],
},
async resolve(_root, args, ctx) {
if (ctx.account == null) {
throw new NotAuthenticatedError();
}

const post = await ctx.db.query.postTable.findFirst({
columns: { id: true, actorId: true },
where: { id: args.input.postId.id },
});

if (post == null) {
throw new InvalidInputError("postId");
}

if (post.actorId === ctx.account.actor.id) {
throw new InvalidInputError("postId");
}

const flagId = generateUuidV7();
const iri = new URL(
`#flags/${post.id}/${flagId}`,
ctx.fedCtx.getActorUri(ctx.account.id),
).href;

const result = await createFlag(
ctx.db,
flagId,
iri,
ctx.account.actor.id,
post.id,
args.input.reason,
);

if (!result.created) {
throw new AlreadyReportedError();
}

return { postId: post.id };
},
},
{
outputFields: (t) => ({
post: t.drizzleField({
type: Post,
async resolve(query, result, _args, ctx) {
const post = await ctx.db.query.postTable.findFirst(
query({ where: { id: result.postId } }),
);
return post!;
},
}),
}),
},
);
18 changes: 18 additions & 0 deletions graphql/schema.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,10 @@ type AddReactionToPostPayload {

union AddReactionToPostResult = AddReactionToPostPayload | InvalidInputError | NotAuthenticatedError

type AlreadyReportedError {
message: String!
}

type Article implements Node & Post & Reactable {
account: Account!
actor: Actor!
Expand Down Expand Up @@ -672,6 +676,7 @@ type Mutation {
registerApnsDeviceToken(input: RegisterApnsDeviceTokenInput!): RegisterApnsDeviceTokenResult!
removeFollower(input: RemoveFollowerInput!): RemoveFollowerResult!
removeReactionFromPost(input: RemoveReactionFromPostInput!): RemoveReactionFromPostResult!
reportPost(input: ReportPostInput!): ReportPostResult!
revokePasskey(passkeyId: ID!): ID

"""Revoke a session by its ID."""
Expand Down Expand Up @@ -1243,6 +1248,19 @@ type ReplyNotification implements Node & Notification {
uuid: UUID!
}

input ReportPostInput {
clientMutationId: ID
postId: ID!
reason: String!
}

type ReportPostPayload {
clientMutationId: ID
post: Post!
}

union ReportPostResult = AlreadyReportedError | InvalidInputError | NotAuthenticatedError | ReportPostPayload

input SaveArticleDraftInput {
clientMutationId: ID
content: Markdown!
Expand Down
1 change: 1 addition & 0 deletions models/deno.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
"./db": "./db.ts",
"./dblogger": "./dblogger.ts",
"./emoji": "./emoji.ts",
"./flag": "./flag.ts",
"./following": "./following.ts",
"./html": "./html.ts",
"./i18n": "./i18n.ts",
Expand Down
33 changes: 33 additions & 0 deletions models/flag.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import type { Database } from "./db.ts";
import { flagTable } from "./schema.ts";
import type { Uuid } from "./uuid.ts";

export interface CreateFlagResult {
created: boolean;
flagId: Uuid;
}

export async function createFlag(
db: Database,
id: Uuid,
iri: string,
reporterId: Uuid,
postId: Uuid,
reason: string,
): Promise<CreateFlagResult> {
const rows = await db.insert(flagTable)
.values({ id, iri, reporterId, postId, reason })
.onConflictDoNothing()
.returning({ id: flagTable.id });

if (rows.length > 0) {
return { created: true, flagId: rows[0].id };
}

const existing = await db.query.flagTable.findFirst({
columns: { id: true },
where: { reporterId, postId },
});

return { created: false, flagId: existing!.id };
}
20 changes: 20 additions & 0 deletions models/relations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,8 @@ export const relations = defineRelations(schema, (r) => ({
posts: r.many.postTable(),
pins: r.many.pinTable(),
votedPolls: r.many.pollTable(),
reportedFlags: r.many.flagTable({ alias: "reporter" }),
receivedFlags: r.many.flagTable({ alias: "flaggedActor" }),
},
followingTable: {
follower: r.one.actorTable({
Expand Down Expand Up @@ -213,6 +215,7 @@ export const relations = defineRelations(schema, (r) => ({
from: r.postTable.id,
to: r.pollTable.postId,
}),
flags: r.many.flagTable(),
},
pinTable: {
post: r.one.postTable({
Expand Down Expand Up @@ -372,4 +375,21 @@ export const relations = defineRelations(schema, (r) => ({
optional: false,
}),
},
flagTable: {
reporter: r.one.actorTable({
alias: "reporter",
from: r.flagTable.reporterId,
to: r.actorTable.id,
optional: false,
}),
post: r.one.postTable({
from: r.flagTable.postId,
to: r.postTable.id,
}),
flaggedActor: r.one.actorTable({
alias: "flaggedActor",
from: r.flagTable.actorId,
to: r.actorTable.id,
}),
},
}));
33 changes: 33 additions & 0 deletions models/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1215,3 +1215,36 @@ export const articleMediumTable = pgTable(

export type ArticleMedium = typeof articleMediumTable.$inferSelect;
export type NewArticleMedium = typeof articleMediumTable.$inferInsert;

export const flagTable = pgTable(
"flag",
{
id: uuid().$type<Uuid>().primaryKey(),
iri: text().notNull().unique(),
reporterId: uuid("reporter_id")
.$type<Uuid>()
.notNull()
.references(() => actorTable.id, { onDelete: "cascade" }),
postId: uuid("post_id")
.$type<Uuid>()
.references(() => postTable.id, { onDelete: "cascade" }),
actorId: uuid("actor_id")
.$type<Uuid>()
.references(() => actorTable.id, { onDelete: "cascade" }),
reason: text().notNull(),
created: timestamp({ withTimezone: true })
.notNull()
.default(currentTimestamp),
},
(table) => [
unique().on(table.reporterId, table.postId),
unique().on(table.reporterId, table.actorId),
check(
"flag_target_check",
sql`(${table.postId} IS NOT NULL AND ${table.actorId} IS NULL) OR (${table.postId} IS NULL AND ${table.actorId} IS NOT NULL)`,
),
],
);

export type Flag = typeof flagTable.$inferSelect;
export type NewFlag = typeof flagTable.$inferInsert;
Loading